import { constants } from "node:fs"; import { open } from "node:fs/promises"; const admissionFailure = (code: string, kind: "file-bytes", measured: number, limit: number) => Object.assign(Error(code), { metadataAdmission: { kind, measured, limit } }); /** Admission estimates, not a hard V8 heap limit. A process/cgroup ceiling is * still required. Charge input, parse trees and derived indexes before reading * or parsing. Each live candidate retains its reservation until explicit close. */ export class CandidateMetadataBudget { readonly limits: Readonly<{ bytes: number; ownerBytes: number; candidates: number }>; private bytes = 0; private scopes = 0; private readonly owners = new Map(); // A cutover holds both the serving and proposed installation plus transient // parsing space. The Reader with checked Query Trees retains ~176 MiB per // installation; its replacement also needs transient parse/index space. // Keep the aggregate ceiling independent and leave room for parsing overlap. constructor(limits = { bytes: 768 * 1024 * 1024, ownerBytes: 512 * 1024 * 1024, candidates: 128 }) { for (const value of Object.values(limits)) if (!Number.isSafeInteger(value) || value < 1) throw Error("INVALID_CANDIDATE_METADATA_BUDGET"); if (limits.ownerBytes > limits.bytes) throw Error("INVALID_CANDIDATE_METADATA_BUDGET"); this.limits = Object.freeze({ ...limits }); } snapshot() { return { bytes: this.bytes, candidates: this.scopes, owners: this.owners.size, limits: this.limits }; } ownedBytes(owner: string) { return this.owners.get(owner) ?? 0; } acquire(owner: string) { if (!owner || owner.length > 4096) throw Error("INVALID_CANDIDATE_METADATA_OWNER"); if (this.scopes >= this.limits.candidates) throw Error("CANDIDATE_METADATA_LIMIT"); this.scopes++; let bytes = 0, closed = false; const charge = (amount: number) => { if (closed) throw Error("CANDIDATE_METADATA_CLOSED"); if (!Number.isSafeInteger(amount) || amount < 0) throw Error("INVALID_CANDIDATE_METADATA_CHARGE"); const owned = this.owners.get(owner) ?? 0; if (this.bytes + amount > this.limits.bytes || owned + amount > this.limits.ownerBytes) throw Error("CANDIDATE_METADATA_LIMIT"); bytes += amount; this.bytes += amount; this.owners.set(owner, owned + amount); }; const subtract = (amount: number) => { bytes -= amount; this.bytes -= amount; const remaining = (this.owners.get(owner) ?? 0) - amount; if (remaining) this.owners.set(owner, remaining); else this.owners.delete(owner); }; return { get bytes() { return bytes; }, charge, /** Opaque, idempotent transient reservation; callers cannot subtract an * arbitrary amount or release another candidate's charges. */ reserve: (amount: number) => { charge(amount); let released = false; return () => { if (closed || released) return; released = true; subtract(amount); }; }, release: () => { if (closed) return; closed = true; subtract(bytes); this.scopes--; }, }; } } export type CandidateMetadataScope = ReturnType; export const candidateMetadataBudget = new CandidateMetadataBudget(); // Estimate JSON allocation without constructing its tree. Object/array headers, // value slots and strings get explicit weights, with a multiplier for checked // indexes/derived representations. Indentation must not multiply tree charges. function jsonAllocation(bytes: Uint8Array): number { let estimate = 0; for (let i = 0; i < bytes.length; i++) { const c = bytes[i]!; if (c === 123) estimate += 64; else if (c === 91) estimate += 32; else if (c === 34) { const start = ++i; while (i < bytes.length && bytes[i] !== 34) { if (bytes[i] === 92) i++; i++; } estimate += 32 + 2 * (i - start); } else if (c === 45 || (c >= 48 && c <= 57) || c === 116 || c === 102 || c === 110) { estimate += 16; while (i + 1 < bytes.length) { const next = bytes[i + 1]!; if (next === 9 || next === 10 || next === 13 || next === 32 || next === 44 || next === 93 || next === 125) break; i++; } } } return estimate * 2; } /** Immutable regular files only. Fixed allocation plus an extra byte catches * growth; readFile() would allocate before the admission check can intervene. */ export async function readCandidateMetadata( path: string, scope: CandidateMetadataScope, limit: number, ): Promise<{ bytes: Uint8Array; releaseInput: () => void; releaseTree: () => void }> { const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); let releaseInput: (() => void) | undefined; try { const stat = await handle.stat(); if (!stat.isFile() || !Number.isSafeInteger(stat.size)) throw Error("CANDIDATE_METADATA_FILE_LIMIT"); if (stat.size > limit) throw admissionFailure("CANDIDATE_METADATA_FILE_LIMIT", "file-bytes", stat.size, limit); // Reserve the input buffer and UTF-16 decoding before allocating either. releaseInput = scope.reserve((stat.size + 1) * 3); const bytes = Buffer.alloc(stat.size + 1); let offset = 0; while (offset < bytes.length) { const read = await handle.read(bytes, offset, bytes.length - offset, offset); if (!read.bytesRead) break; offset += read.bytesRead; } if (offset !== stat.size) throw Error("CANDIDATE_METADATA_CHANGED"); const content = bytes.subarray(0, offset); // No JSON.parse/decoded tree exists until its shape-based charge succeeds. const releaseTree = scope.reserve(jsonAllocation(content)); return { bytes: content, releaseInput, releaseTree }; } catch (error) { releaseInput?.(); throw error; } finally { await handle.close(); } }