Bound checked candidate metadata and restricted invocation resources
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { constants } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
|
||||
/** 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<string, number>();
|
||||
constructor(limits = { bytes: 512 * 1024 * 1024, ownerBytes: 256 * 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 };
|
||||
}
|
||||
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;
|
||||
return {
|
||||
get bytes() {
|
||||
return bytes;
|
||||
},
|
||||
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);
|
||||
},
|
||||
release: () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
this.bytes -= bytes;
|
||||
this.scopes--;
|
||||
const remaining = (this.owners.get(owner) ?? 0) - bytes;
|
||||
if (remaining) this.owners.set(owner, remaining);
|
||||
else this.owners.delete(owner);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
export type CandidateMetadataScope = ReturnType<CandidateMetadataBudget["acquire"]>;
|
||||
export const candidateMetadataBudget = new CandidateMetadataBudget();
|
||||
|
||||
/** 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<Uint8Array> {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size > limit)
|
||||
throw Error("CANDIDATE_METADATA_FILE_LIMIT");
|
||||
// Conservative attribution includes strings, small JSON nodes, duplicate
|
||||
// checked representations, digest serialization and installed indexes.
|
||||
scope.charge((stat.size + 1) * 32);
|
||||
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");
|
||||
return bytes.subarray(0, offset);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user