From 3d674041327490c36cdc2bba5e0f30de06bb4d93 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 23:47:20 +0800 Subject: [PATCH] refactor(domain): charge resource allowances through one Budget owner (#11293) --- .../verify/architecture-guard-baselines.json | 19 +++++++ scripts/verify/architecture-guards.mjs | 13 +++++ .../native/NativeMatchingAdmission.ts | 46 ++++++++-------- src/artifacts/UnityFsStructures.ts | 26 +++------ src/artifacts/WxapkgStructures.ts | 27 ++++----- src/browser/CdpScriptSourceBudget.ts | 9 +-- src/browser/history/protobuf/ProtobufWire.ts | 20 +++---- src/domain/budget.test.ts | 43 +++++++++++++++ src/domain/budget.ts | 55 +++++++++++++++++++ src/domain/wasm/wasmInterfaceBudget.ts | 42 +++++++------- src/model/ModelFormat.ts | 11 ++-- 11 files changed, 215 insertions(+), 96 deletions(-) create mode 100644 src/domain/budget.test.ts create mode 100644 src/domain/budget.ts diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 8828ea7364..614a7ea931 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -500,5 +500,24 @@ "src/signals/SigrokRecordingProvider.ts": 1, "src/signals/parseDecoderDiscovery.ts": 1, "src/v8/V8CodeCache.errors.ts": 1 + }, + "budget-arithmetic": { + "src/application/investigation/EvidenceLedger.ts": 1, + "src/artifacts/UnityFsArtifactReader.ts": 1, + "src/artifacts/apple/InterfaceBuilderDecodeBudget.ts": 2, + "src/artifacts/bun/BunModuleGraphLayout.ts": 2, + "src/browser/PlaywrightScenarioEvents.ts": 3, + "src/browser/WebBundleAnalysisWorker.ts": 2, + "src/browser/execution/CdpRuntimeSources.ts": 2, + "src/desktop/DesktopContentStore.ts": 1, + "src/domain/analysisView/WebNetworkProjectionAccumulator.ts": 1, + "src/domain/apiContract/parseApiContract.ts": 1, + "src/domain/firmware/hexRecords.ts": 1, + "src/domain/firmware/uf2.ts": 1, + "src/domain/javascript/javascriptSemanticWorkAllowance.ts": 1, + "src/domain/javascript/javascriptSymbolicBindings.ts": 4, + "src/dotnet/ManagedDecodeBudget.ts": 1, + "src/javascript/analysis/JavaScriptAnalysisWorker.ts": 4, + "src/python/PythonPycParser.ts": 2 } } diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index 2fe82b20a2..ceaa7a7e7b 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -340,6 +340,19 @@ checkCounted( [], ); +// A resource allowance is charged and refunded through `Budget` in +// src/domain/budget, which refuses malformed or oversized charges without +// changing the remainder. Hand-written `remaining -=` / `retained +=` +// arithmetic repeats that admission and drifts (charge-then-check, no +// safe-integer check). +checkCounted( + "hand-written budget arithmetic (use Budget from src/domain/budget)", + "budget-arithmetic", + files, + /this\.#?\w*(?:[Rr]emaining|[Rr]etained|[Uu]sed)\w*\s*[-+]=/gu, + ["src/domain/budget.ts"], +); + // A deadline is a monotonic budget owned by `Deadline` in src/domain/deadline; // a wall-clock sum moves with clock changes and has no finalize phase. The // remaining copy converts a monotonic budget for a browser page, which can diff --git a/src/application/native/NativeMatchingAdmission.ts b/src/application/native/NativeMatchingAdmission.ts index ea1b5cc893..df119eba11 100644 --- a/src/application/native/NativeMatchingAdmission.ts +++ b/src/application/native/NativeMatchingAdmission.ts @@ -1,3 +1,4 @@ +import { Budget } from "../../domain/budget.js"; import { setImmediate } from "node:timers/promises"; import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; import type { Evidence } from "../../domain/evidence.js"; @@ -13,7 +14,7 @@ import { export class NativeMatchingAdmission { readonly #maximum: number; readonly #source: NativeMatchingAllowance["source"]; - #remaining: number; + readonly #bytes: Budget; #work = 0; readonly #seen = new WeakSet(); constructor( @@ -25,29 +26,10 @@ export class NativeMatchingAdmission { // budget this request actually ran under. this.#maximum = allowance.maximumBytes; this.#source = allowance.source; - this.#remaining = this.#maximum; + this.#bytes = new Budget(this.#maximum); } readonly checkpoint = async (bytes = 0): Promise => { - this.#remaining -= bytes; - if (this.#remaining < 0) - throw new AnalysisResourceConstraintError( - this.operation, - "capacity", - `Native function matching exceeds its ${nativeMatchingByteAllowance(this.#maximum)} representation allowance (native.matching_representation_bytes).`, - { maximum_bytes: this.#maximum }, - { - retryable: false, - remediationAction: nativeMatchingAllowanceRecovery({ - maximumBytes: this.#maximum, - source: this.#source, - }), - limit: analysisSettingLimit( - "native.matching_representation_bytes", - this.#maximum, - this.#source, - ), - }, - ); + this.#bytes.charge(bytes, () => this.#exhausted()); this.signal?.throwIfAborted(); if (++this.#work % 256 === 0) await setImmediate( @@ -55,6 +37,26 @@ export class NativeMatchingAdmission { this.signal === undefined ? {} : { signal: this.signal }, ); }; + #exhausted(): AnalysisResourceConstraintError { + return new AnalysisResourceConstraintError( + this.operation, + "capacity", + `Native function matching exceeds its ${nativeMatchingByteAllowance(this.#maximum)} representation allowance (native.matching_representation_bytes).`, + { maximum_bytes: this.#maximum }, + { + retryable: false, + remediationAction: nativeMatchingAllowanceRecovery({ + maximumBytes: this.#maximum, + source: this.#source, + }), + limit: analysisSettingLimit( + "native.matching_representation_bytes", + this.#maximum, + this.#source, + ), + }, + ); + } /** * Charge a borrowed record's envelope, which matching copies into its * limitations, digests and links. Its result payloads stay resident: the diff --git a/src/artifacts/UnityFsStructures.ts b/src/artifacts/UnityFsStructures.ts index 6a8591ba69..8f92c8a2e6 100644 --- a/src/artifacts/UnityFsStructures.ts +++ b/src/artifacts/UnityFsStructures.ts @@ -1,3 +1,4 @@ +import { Budget } from "../domain/budget.js"; import type { FileHandle } from "node:fs/promises"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { readArtifactFileBytes } from "./ArtifactFileRange.js"; @@ -39,15 +40,6 @@ const directoryLimit = (): ArtifactReaderFailure => { boundary: "memory" }, ); -/** Reserve buffers, JS rows and escaped path projections before constructing them. */ -class UnityFsDirectoryBudget { - #remaining = DIRECTORY_BUDGET_BYTES; - reserve(bytes: number): void { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) - throw directoryLimit(); - this.#remaining -= bytes; - } -} /** Signature, version, two bounded strings and the fixed tail; reads are bounded. */ const HEADER_PREFIX_MAX = 8 + 4 + UNITYFS_HEADER_STRING_MAX * 2 + 20; const UTF8 = strictUtf8Decoder("preserve"); @@ -258,7 +250,7 @@ export const decodeUnityFsLz4 = async ( const parseBlocks = async ( table: Buffer, - budget: UnityFsDirectoryBudget, + budget: Budget, signal?: AbortSignal, ): Promise => { if (table.length < 24) @@ -267,7 +259,7 @@ const parseBlocks = async ( if (20 + blockCount * 10 + 4 > table.length) throw unityFsFormatFailure("declared block count exceeds the table"); // Includes array slots, numeric fields and the retained lookup representation. - budget.reserve(blockCount * 256); + budget.charge(blockCount * 256, directoryLimit); const blocks: UnityFsBlock[] = []; let cursor = 20; let compressedStart = 0; @@ -327,7 +319,7 @@ const parseBlocks = async ( const parseNodes = async ( table: Buffer, blocks: readonly UnityFsBlock[], - budget: UnityFsDirectoryBudget, + budget: Budget, signal?: AbortSignal, ): Promise => { let cursor = 20 + blocks.length * 10; @@ -338,7 +330,7 @@ const parseNodes = async ( if (cursor + nodeCount * 21 > table.length) throw unityFsFormatFailure("declared node count exceeds the table"); // Covers parsed rows plus the reader's owned entries/map; paths are charged separately. - budget.reserve(nodeCount * 512); + budget.charge(nodeCount * 512, directoryLimit); const streamSize = blocks.length === 0 ? 0 @@ -357,7 +349,7 @@ const parseNodes = async ( const nameEnd = table.indexOf(0, cursor); if (nameEnd === -1) throw unityFsFormatFailure("node path is unterminated"); // UTF-16 plus repeated JSON-escaped source/canonical path projections. - budget.reserve((nameEnd - cursor) * 24); + budget.charge((nameEnd - cursor) * 24, directoryLimit); let path: string; try { path = UTF8.decode(table.subarray(cursor, nameEnd)); @@ -428,10 +420,10 @@ export const readUnityFsBlocksInfo = async ( header.uncompressedBlocksInfoSize > MAX_TABLE_BYTES ) throw directoryLimit(); - const budget = new UnityFsDirectoryBudget(); - budget.reserve(header.compressedBlocksInfoSize); + const budget = new Budget(DIRECTORY_BUDGET_BYTES); + budget.charge(header.compressedBlocksInfoSize, directoryLimit); if (header.compression !== UNITYFS_COMPRESSION_NONE) - budget.reserve(header.uncompressedBlocksInfoSize); + budget.charge(header.uncompressedBlocksInfoSize, directoryLimit); const compressed = await readArtifactFileBytes( handle, layout.blocksInfoOffset, diff --git a/src/artifacts/WxapkgStructures.ts b/src/artifacts/WxapkgStructures.ts index 17787cdbb0..f65ae29fc3 100644 --- a/src/artifacts/WxapkgStructures.ts +++ b/src/artifacts/WxapkgStructures.ts @@ -1,3 +1,4 @@ +import { Budget } from "../domain/budget.js"; import type { FileHandle } from "node:fs/promises"; import { ArtifactReaderFailure, @@ -31,19 +32,13 @@ const UTF8 = strictUtf8Decoder("preserve"); export const wxapkgFormatFailure = (detail: string): ArtifactReaderFailure => new ArtifactReaderFailure("format", `wxapkg is malformed: ${detail}`); -/** Reserve buffers, JS rows and escaped name projections before allocating. */ -class WxapkgDirectoryBudget { - #remaining = DIRECTORY_BUDGET_BYTES; - reserve(bytes: number): void { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) - throw artifactFixedLimitFailure( - "wxapkg directory representation", - DIRECTORY_BUDGET_BYTES, - "bytes", - ); - this.#remaining -= bytes; - } -} +/** Buffers, JS rows and escaped name projections, reserved before allocating. */ +const directoryLimit = (): Error => + artifactFixedLimitFailure( + "wxapkg directory representation", + DIRECTORY_BUDGET_BYTES, + "bytes", + ); export interface WxapkgHeader { readonly info: number; @@ -138,8 +133,8 @@ export const readWxapkgFileTable = async ( MAX_TABLE_BYTES, "bytes", ); - const budget = new WxapkgDirectoryBudget(); - budget.reserve(readBytes); + const budget = new Budget(DIRECTORY_BUDGET_BYTES); + budget.charge(readBytes, directoryLimit); const table = await readArtifactFileBytes( handle, tableStart, @@ -166,7 +161,7 @@ export const readWxapkgFileTable = async ( ) throw wxapkgFormatFailure("member row exceeds the table"); // Row storage plus the reader's owned entries/map; charge before decode. - budget.reserve(512 + nameLength * 24); + budget.charge(512 + nameLength * 24, directoryLimit); let declaredPath: string; try { declaredPath = UTF8.decode(table.subarray(cursor, cursor + nameLength)); diff --git a/src/browser/CdpScriptSourceBudget.ts b/src/browser/CdpScriptSourceBudget.ts index 09bd007344..a8faa6243b 100644 --- a/src/browser/CdpScriptSourceBudget.ts +++ b/src/browser/CdpScriptSourceBudget.ts @@ -1,3 +1,4 @@ +import { Budget } from "../domain/budget.js"; import { noRuntimeOverrideSentence, selectedCdpCommandTimeoutMs, @@ -19,7 +20,7 @@ export const cdpScriptSourceDeadlineOmissionReason = ( export class CdpScriptSourceBudget { readonly #deadline: ProviderStartupDeadline; readonly commandTimeoutMs: number; - #retainedBytes = 0; + readonly #retained = new Budget(WEB_RUNTIME_LIMITS.sourceBytes); constructor( signal?: AbortSignal, @@ -39,11 +40,7 @@ export class CdpScriptSourceBudget { /** Admit exact UTF-8 source bytes before building or retaining its artifact. */ admit(text: string): boolean { - const bytes = Buffer.byteLength(text, "utf8"); - if (bytes > WEB_RUNTIME_LIMITS.sourceBytes - this.#retainedBytes) - return false; - this.#retainedBytes += bytes; - return true; + return this.#retained.tryCharge(Buffer.byteLength(text, "utf8")); } dispose(): void { diff --git a/src/browser/history/protobuf/ProtobufWire.ts b/src/browser/history/protobuf/ProtobufWire.ts index 142f4ed3a7..23d1159069 100644 --- a/src/browser/history/protobuf/ProtobufWire.ts +++ b/src/browser/history/protobuf/ProtobufWire.ts @@ -1,3 +1,4 @@ +import { Budget } from "../../../domain/budget.js"; import { strictUtf8Decoder } from "../../../domain/strictUtf8.js"; import { captureBudgets } from "../CaptureBudgets.js"; import { readLeb128, ULEB64 } from "../../../domain/leb128.js"; @@ -8,19 +9,18 @@ import { CaptureFormatError } from "../CaptureFormatError.js"; /** One charge covers all selected records, including temporary interpretation strings. */ export class ProtobufBudget { - #remaining = captureBudgets().outputBytes; + readonly #bytes = new Budget(captureBudgets().outputBytes); #work = 0; constructor(readonly signal?: AbortSignal) {} charge(bytes: number): void { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) - throw new CaptureFormatError( - { - name: "protobuf-decoding", - maximum: captureBudgets().outputBytes, - }, - `Protobuf decoding exceeds max_reply_bytes (${String(captureBudgets().outputBytes)} bytes); raise that per-call allowance and retry.`, - ); - this.#remaining -= bytes; + this.#bytes.charge( + bytes, + () => + new CaptureFormatError( + { name: "protobuf-decoding", maximum: this.#bytes.maximum }, + `Protobuf decoding exceeds max_reply_bytes (${String(this.#bytes.maximum)} bytes); raise that per-call allowance and retry.`, + ), + ); } async checkpoint(): Promise { this.signal?.throwIfAborted(); diff --git a/src/domain/budget.test.ts b/src/domain/budget.test.ts new file mode 100644 index 0000000000..0ea67be1aa --- /dev/null +++ b/src/domain/budget.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { Budget } from "./budget.js"; + +describe("Budget", () => { + const exhausted = () => new Error("exhausted"); + + it("charges until the maximum and refuses without charging", () => { + const budget = new Budget(10); + budget.charge(6, exhausted); + expect(budget.tryCharge(5)).toBe(false); + expect(() => { + budget.charge(5, exhausted); + }).toThrow("exhausted"); + expect(budget.used).toBe(6); + budget.charge(4, exhausted); + expect(budget.remaining).toBe(0); + }); + + it.each([-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])( + "refuses a malformed charge %s without changing the remainder", + (amount) => { + const budget = new Budget(Number.MAX_SAFE_INTEGER); + expect(budget.tryCharge(amount)).toBe(false); + expect(budget.used).toBe(0); + }, + ); + + it("refunds without rising above the maximum", () => { + const budget = new Budget(10); + budget.charge(4, exhausted); + budget.refund(3); + expect(budget.used).toBe(1); + budget.refund(5); + expect(budget.remaining).toBe(10); + expect(() => { + budget.refund(-1); + }).toThrow(/refund/u); + }); + + it("rejects a malformed maximum", () => { + expect(() => new Budget(-1)).toThrow(/maximum/u); + }); +}); diff --git a/src/domain/budget.ts b/src/domain/budget.ts new file mode 100644 index 0000000000..3f88e0bf7a --- /dev/null +++ b/src/domain/budget.ts @@ -0,0 +1,55 @@ +import { invariantViolation } from "./internalInvariant.js"; + +const isAmount = (value: number): boolean => + Number.isSafeInteger(value) && value >= 0; + +/** + * One allowance of a counted resource (bytes, records, visits) that a producer + * charges before it allocates and refunds when it discards a reservation. + * + * The budget owns the arithmetic: a charge must be a safe non-negative integer + * that fits what remains, a refused charge changes nothing, and a refund never + * raises the remainder above the maximum. Each owner keeps its own limit + * failure, passed to `charge` as `exhausted`. + */ +export class Budget { + #used = 0; + + constructor(readonly maximum: number) { + if (!isAmount(maximum)) + throw invariantViolation( + `Budget maximum must be a non-negative safe integer, got ${String(maximum)}`, + ); + } + + /** Amount charged and not refunded. */ + get used(): number { + return this.#used; + } + + /** Amount still available. */ + get remaining(): number { + return this.maximum - this.#used; + } + + /** Charge `amount` when it fits; otherwise charge nothing and return false. */ + tryCharge(amount: number): boolean { + if (!isAmount(amount) || amount > this.remaining) return false; + this.#used += amount; + return true; + } + + /** Charge `amount`, or throw the owner's limit failure without charging. */ + charge(amount: number, exhausted: () => Error): void { + if (!this.tryCharge(amount)) throw exhausted(); + } + + /** Return a reservation that was not retained. */ + refund(amount: number): void { + if (!isAmount(amount)) + throw invariantViolation( + `Budget refund must be a non-negative safe integer, got ${String(amount)}`, + ); + this.#used = Math.max(0, this.#used - amount); + } +} diff --git a/src/domain/wasm/wasmInterfaceBudget.ts b/src/domain/wasm/wasmInterfaceBudget.ts index 9f89e34db0..6801bc52fe 100644 --- a/src/domain/wasm/wasmInterfaceBudget.ts +++ b/src/domain/wasm/wasmInterfaceBudget.ts @@ -1,3 +1,4 @@ +import { Budget } from "../budget.js"; import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; import { DEFAULT_WASM_INTERFACE_REPRESENTATION_BYTES, @@ -6,7 +7,7 @@ import { /** One selected interface allowance, shared by nested producer reads and comparison. */ export class WasmInterfaceBudget { - #remaining: number; + readonly #bytes: Budget; readonly maximumBytes: number; readonly source: "default" | "request"; constructor( @@ -16,26 +17,27 @@ export class WasmInterfaceBudget { this.maximumBytes = maximumBytes ?? DEFAULT_WASM_INTERFACE_REPRESENTATION_BYTES; this.source = maximumBytes === undefined ? "default" : "request"; - this.#remaining = this.maximumBytes; + this.#bytes = new Budget(this.maximumBytes); } reserve(bytes: number): void { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) - throw new AnalysisResourceConstraintError( - this.operation, - "capacity", - `Typed WASM interface representation exceeds max_interface_bytes (${String(this.maximumBytes)} bytes)`, - { - maximum_bytes: this.maximumBytes, - default_bytes: DEFAULT_WASM_INTERFACE_REPRESENTATION_BYTES, - maximum_supported_bytes: MAX_WASM_INTERFACE_REPRESENTATION_BYTES, - source: this.source, - unit: "bytes", - }, - { - retryable: false, - remediationAction: `${this.maximumBytes < MAX_WASM_INTERFACE_REPRESENTATION_BYTES ? `Raise max_interface_bytes above ${String(this.maximumBytes)} bytes (maximum ${String(MAX_WASM_INTERFACE_REPRESENTATION_BYTES)}) on the next ${this.operation} call.` : `max_interface_bytes is already at the runtime representation maximum of ${String(MAX_WASM_INTERFACE_REPRESENTATION_BYTES)} bytes; no higher interface allowance is supported. A comparison may select only imports or exports; otherwise report the unsupported complete representation.`} No restart is required. Other output, heap and deadline controls do not raise this allowance. A comparison cannot restore facts omitted from partial producer Evidence; inspect the original module with a larger producer budget first when that budget is below its maximum.`, - }, - ); - this.#remaining -= bytes; + this.#bytes.charge(bytes, () => this.#exhausted()); + } + #exhausted(): AnalysisResourceConstraintError { + return new AnalysisResourceConstraintError( + this.operation, + "capacity", + `Typed WASM interface representation exceeds max_interface_bytes (${String(this.maximumBytes)} bytes)`, + { + maximum_bytes: this.maximumBytes, + default_bytes: DEFAULT_WASM_INTERFACE_REPRESENTATION_BYTES, + maximum_supported_bytes: MAX_WASM_INTERFACE_REPRESENTATION_BYTES, + source: this.source, + unit: "bytes", + }, + { + retryable: false, + remediationAction: `${this.maximumBytes < MAX_WASM_INTERFACE_REPRESENTATION_BYTES ? `Raise max_interface_bytes above ${String(this.maximumBytes)} bytes (maximum ${String(MAX_WASM_INTERFACE_REPRESENTATION_BYTES)}) on the next ${this.operation} call.` : `max_interface_bytes is already at the runtime representation maximum of ${String(MAX_WASM_INTERFACE_REPRESENTATION_BYTES)} bytes; no higher interface allowance is supported. A comparison may select only imports or exports; otherwise report the unsupported complete representation.`} No restart is required. Other output, heap and deadline controls do not raise this allowance. A comparison cannot restore facts omitted from partial producer Evidence; inspect the original module with a larger producer budget first when that budget is below its maximum.`, + }, + ); } } diff --git a/src/model/ModelFormat.ts b/src/model/ModelFormat.ts index 7076355bb6..ea4225916b 100644 --- a/src/model/ModelFormat.ts +++ b/src/model/ModelFormat.ts @@ -1,3 +1,4 @@ +import { Budget } from "../domain/budget.js"; import { malformedInput, ParseFailureError, @@ -117,13 +118,13 @@ export const modelCapacityReportedLimits = ( /** Charge representation growth before retaining rows or decoding long strings. */ export class ModelReportBudget { - #remaining = MODEL_REPORT_BYTES; + readonly #bytes = new Budget(MODEL_REPORT_BYTES); charge(bytes: number): void { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) - throw modelCapacityFailure( + this.#bytes.charge(bytes, () => + modelCapacityFailure( "representation-bytes", "Model facts exceed the 32 MiB retained representation budget. This release has no runtime override.", - ); - this.#remaining -= bytes; + ), + ); } }