98 lines
4.6 KiB
TypeScript
98 lines
4.6 KiB
TypeScript
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");
|
|
}
|
|
};
|
|
}
|