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 { 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 { 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 { if (!isRecord(bindings) || Object.keys(bindings).length > 40) throw new CredentialError("Invalid or oversized credential bindings"); const sources = new Map(); 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"); } }; }