fast-browser-with-jev
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { constants } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import type { CredentialBindings, CredentialSource } from "./contracts.ts";
|
||||
|
||||
const MAX_VALUE = 8_000;
|
||||
const MAX_FILE = 64 * 1024;
|
||||
const NAME = /^[A-Za-z0-9_.-]{1,100}$/;
|
||||
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
/** Messages from this class contain no credential values or file contents. */
|
||||
export class CredentialError extends Error {}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateSource(source: unknown): asserts source is CredentialSource {
|
||||
if (typeof source === "string") {
|
||||
if (source.length > MAX_VALUE) throw new CredentialError("Credential literal is too long");
|
||||
return;
|
||||
}
|
||||
if (!isRecord(source)) throw new CredentialError("Invalid credential source");
|
||||
const keys = Object.keys(source).sort().join(",");
|
||||
if (keys === "env" && typeof source.env === "string" && source.env.length <= 200 && ENV_NAME.test(source.env)) return;
|
||||
if ((keys === "file" || keys === "file,key") && typeof source.file === "string"
|
||||
&& source.file.length > 0 && source.file.length <= 4_000 && !source.file.includes("\0")
|
||||
&& (source.key === undefined || (typeof source.key === "string" && source.key.length > 0 && source.key.length <= 200))) return;
|
||||
throw new CredentialError("Credential source must be a literal, environment reference, or local file reference");
|
||||
}
|
||||
|
||||
function validateValue(value: unknown): string {
|
||||
if (typeof value !== "string" || value.length > MAX_VALUE) {
|
||||
throw new CredentialError("Resolved credential must be a bounded string");
|
||||
}
|
||||
// Do not trim: spaces/newlines can be part of a test credential.
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readCredentialFile(source: { file: string; key?: string }, cwd: string, signal: AbortSignal): Promise<string> {
|
||||
signal.throwIfAborted();
|
||||
// Nonblocking open permits rejecting FIFOs/devices before a read could hang.
|
||||
const handle = await open(resolve(cwd, source.file), constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.size > MAX_FILE) throw new CredentialError("Credential source must be a bounded regular file");
|
||||
const bytes = Buffer.alloc(MAX_FILE + 1);
|
||||
let used = 0;
|
||||
while (used <= MAX_FILE) {
|
||||
signal.throwIfAborted();
|
||||
const { bytesRead } = await handle.read(bytes, used, bytes.length - used, null);
|
||||
if (!bytesRead) break;
|
||||
used += bytesRead;
|
||||
if (used > MAX_FILE) throw new CredentialError("Credential file exceeds the size limit");
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used));
|
||||
if (source.key === undefined) return validateValue(contents);
|
||||
let json: unknown;
|
||||
try { json = JSON.parse(contents); } catch { throw new CredentialError("Credential file is not valid JSON"); }
|
||||
if (!isRecord(json) || !Object.hasOwn(json, source.key)) throw new CredentialError("Credential JSON entry is missing");
|
||||
// key is an exact top-level key, never an expression or executable path.
|
||||
return validateValue(json[source.key]);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate declarations now; access values only when the selected fill needs them. */
|
||||
export function createCredentialResolver(
|
||||
bindings: CredentialBindings,
|
||||
options: { cwd: string; env?: NodeJS.ProcessEnv },
|
||||
): (name: string, signal: AbortSignal) => Promise<string> {
|
||||
if (!isRecord(bindings) || Object.keys(bindings).length > 40) throw new CredentialError("Invalid or oversized credential bindings");
|
||||
const sources = new Map<string, CredentialSource>();
|
||||
for (const [name, source] of Object.entries(bindings)) {
|
||||
if (!NAME.test(name)) throw new CredentialError("Invalid credential reference name");
|
||||
validateSource(source);
|
||||
sources.set(name, typeof source === "string" ? source : { ...source });
|
||||
}
|
||||
const env = options.env ?? process.env;
|
||||
return async (name, signal) => {
|
||||
signal.throwIfAborted();
|
||||
if (!sources.has(name)) throw new CredentialError("Requested credential reference was not supplied");
|
||||
const source = sources.get(name)!;
|
||||
try {
|
||||
if (typeof source === "string") return source;
|
||||
if ("env" in source) return validateValue(env[source.env]);
|
||||
return await readCredentialFile(source, options.cwd, signal);
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
if (error instanceof CredentialError) throw error;
|
||||
throw new CredentialError("Credential source could not be read");
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user