Pool host-approved restricted code with scoped and fair graph execution
This commit is contained in:
@@ -24,28 +24,44 @@ export class CandidateMetadataBudget {
|
||||
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: (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);
|
||||
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;
|
||||
this.bytes -= bytes;
|
||||
subtract(bytes);
|
||||
this.scopes--;
|
||||
const remaining = (this.owners.get(owner) ?? 0) - bytes;
|
||||
if (remaining) this.owners.set(owner, remaining);
|
||||
else this.owners.delete(owner);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -53,21 +69,50 @@ export class CandidateMetadataBudget {
|
||||
export type CandidateMetadataScope = ReturnType<CandidateMetadataBudget["acquire"]>;
|
||||
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<Uint8Array> {
|
||||
): Promise<{ bytes: Uint8Array; releaseInput: () => 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) || 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);
|
||||
// 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) {
|
||||
@@ -76,7 +121,13 @@ export async function readCandidateMetadata(
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
if (offset !== stat.size) throw Error("CANDIDATE_METADATA_CHANGED");
|
||||
return bytes.subarray(0, offset);
|
||||
const content = bytes.subarray(0, offset);
|
||||
// No JSON.parse/decoded tree exists until its shape-based charge succeeds.
|
||||
scope.charge(jsonAllocation(content));
|
||||
return { bytes: content, releaseInput };
|
||||
} catch (error) {
|
||||
releaseInput?.();
|
||||
throw error;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user