fix(javascript): report oversized recovery inputs as file-size limits (#4757)

scanRecoveryFile raised a plain AnalysisOutputError when a selected file
exceeded the remaining operation-wide input allowance, and
snapshotRecoveryInput folded that, read failures and UTF-8 decode
failures into one invalid_value request issue. A 64 MiB + 1 input was
therefore reported as a retryable invalid argument whose only record of
the 67108864-byte budget was message text that also called it an
invalid analysis output, and malformed UTF-8 received the same reason.

The scanner now marks its byte-bound refusals with the bound it
enforced. snapshotRecoveryInput, which owns the meaning of that bound
as the remaining total input allowance, reports it as a non-retryable
file-size AnalysisResourceConstraintError with maximum_input_bytes and
remaining_input_bytes, and decodes UTF-8 separately as an invalid_format
issue. Other scanner consumers still receive an AnalysisOutputError, and
the limit stays enforced.

Fixes #4629

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-17 04:26:39 -07:00
1 parent 6a407619fe
commit 31c87b40ee
4 files changed
+175 -8

No files matched your search

+45 -5
View File
@@ -8,6 +8,7 @@ import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../../domain/analysisErrorCore.js";
import {
@@ -23,6 +24,16 @@ const OPERATION = "recover_javascript_sources";
export const recoveryDigest = (bytes: Uint8Array): string =>
createHash("sha256").update(bytes).digest("hex");
/** A file exceeded the byte bound its caller selected; the caller owns what that bound means. */
class RecoveryFileSizeError extends AnalysisOutputError {
constructor(
reason: string,
readonly maximum: number,
) {
super(OPERATION, reason);
}
}
/** Read a stable regular file without retaining bytes during engine fingerprinting. */
const scanRecoveryFile = async (
path: string,
@@ -54,9 +65,9 @@ const scanRecoveryFile = async (
try {
const before = await handle.stat();
if (before.size > maximum)
throw new AnalysisOutputError(
OPERATION,
throw new RecoveryFileSizeError(
`Expected a regular file no larger than ${String(maximum)} bytes: ${path}`,
maximum,
);
let bytes = 0;
for await (const raw of handle.createReadStream({ autoClose: false })) {
@@ -64,9 +75,9 @@ const scanRecoveryFile = async (
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
bytes += chunk.length;
if (bytes > maximum)
throw new AnalysisOutputError(
OPERATION,
throw new RecoveryFileSizeError(
`File exceeds ${String(maximum)} bytes: ${path}`,
maximum,
);
hash.update(chunk);
consume(chunk);
@@ -138,15 +149,44 @@ const snapshotRecoveryInput = async (
let bytes: Buffer;
try {
bytes = await readRecoveryFile(path, maximum, signal);
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) throw cause;
// The operation-wide input allowance is a deterministic byte budget, not
// a malformed argument; the same selection fails again unchanged.
if (cause instanceof RecoveryFileSizeError)
throw new AnalysisResourceConstraintError(
OPERATION,
"file-size",
`Selected JavaScript input ${path} exceeds the ${String(cause.maximum)} bytes remaining of the ${String(RECOVERY_LIMITS.inputBytes)}-byte total input allowance`,
{
maximum_input_bytes: RECOVERY_LIMITS.inputBytes,
remaining_input_bytes: cause.maximum,
},
{
cause,
retryable: false,
remediationAction:
"Select JavaScript inputs whose combined size is within maximum_input_bytes. Do not truncate required source; retrying the unchanged selection will fail again.",
},
);
throw recoveryInputFailure(
"path",
`Cannot snapshot UTF-8 JavaScript input ${path}: ${recoveryFailureMessage(cause)}`,
cause,
);
}
try {
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (cause: unknown) {
throw new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_format",
expected: "UTF-8",
message: `JavaScript input is not valid UTF-8: ${path}`,
},
]);
}
await mkdir(dirname(snapshotPath), { recursive: true, mode: 0o700 });
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
return {