fix(audit): charge range-read progress before a read fails

readSpan added a member's bytes to memberBytesRead only after the whole
range read succeeded, so a read that consumed some bytes and then
failed (for example EIO) charged nothing against the packaged-probe
allowance. Charge the cumulative bytes from readFileHandleRange's
checkpoint as they arrive.

Also cover the transient stat failure that HostFileRead's test named
but never induced: EIO now has its own module-mocked test, and the
original case is renamed for what it checks.

Refs #10443
Refs #9703

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
gensokyo-devandgensokyo-dev authored and Ysopwnable committed 2026-11-14 12:13:50 +01:00
1 parent d68383f98d
commit b3c0ac4bc4
3 files changed
+45 -12

No files matched your search

+17 -7
View File
@@ -3110,19 +3110,29 @@ const readSpan = async (
`Artifact changed before read: ${filePath}`,
);
if (offset + length > observed.size) return null;
const bytes = await readFileHandleRange(handle, offset, length, () => {
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, {
cause: signal.reason,
});
});
// Charge bytes as they arrive, so a read that fails after partial
// progress still counts what it consumed against the allowance.
let charged = 0;
const bytes = await readFileHandleRange(
handle,
offset,
length,
(bytesRead) => {
state.memberBytesRead += bytesRead - charged;
charged = bytesRead;
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, {
cause: signal.reason,
});
},
);
const after = await handle.stat();
if (!sameRegularFileState(initial, after))
throw new ArtifactReaderFailure(
"integrity",
`Artifact changed during read: ${filePath}`,
);
state.memberBytesRead += bytes.length;
state.memberBytesRead += bytes.length - charged;
return Buffer.from(bytes);
},
);
+2 -5
View File
@@ -32,14 +32,11 @@ describe.skipIf(process.platform === "win32")("probeHostFile", () => {
});
});
it("keeps a transient failure unresolved", async () => {
// EIO is not a refusal and does not settle the candidate, so the walk still
// treats it as a check that may succeed on a retry.
it("reports a readable regular file as a file", async () => {
// The transient (EIO) branch is covered in HostFileRead.transient.test.ts.
const root = await createTestTempDirectory("rea-host-file-probe-");
const path = join(root, "libcore.so");
await writeFile(path, "carrier");
// A regular file is probeable; this asserts the settled side of the probe
// still reports the kind, so the transient branch is not reached by mistake.
await expect(probeHostFile(path)).resolves.toEqual({
kind: "file",
isFile: true,
@@ -0,0 +1,26 @@
import { expect, it, vi } from "vitest";
vi.mock("node:fs/promises", async (importOriginal) => {
const fs = await importOriginal<typeof import("node:fs/promises")>();
return {
...fs,
stat: async (...args: Parameters<typeof fs.stat>) => {
if (String(args[0]).endsWith("transient.so"))
throw Object.assign(new Error("injected I/O failure"), {
code: "EIO",
});
return fs.stat(...args);
},
};
});
import { hostFileKind, probeHostFile } from "./HostFileRead.js";
it("keeps a transient stat failure unresolved rather than absent", async () => {
// EIO neither proves absence nor lets ld.so search past the candidate.
await expect(probeHostFile("/fixture/lib/transient.so")).resolves.toEqual({
kind: "unknown",
unreachable: false,
});
await expect(hostFileKind("/fixture/lib/transient.so")).resolves.toBeNull();
});