From 3910bedae8924a08dc4ebfa8a4d3a8b50cc064ec Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:38:53 +0800 Subject: [PATCH] fix(artifacts): always verify archived checksums; route link CRCs via policy --- src/artifacts/CpioArtifactReader.ts | 31 +++++++++++++--- src/artifacts/XarArtifactReader.ts | 7 +++- src/artifacts/inventory/scanReader.ts | 5 ++- .../installerPackageInventory.test.ts | 37 +++++++++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/artifacts/CpioArtifactReader.ts b/src/artifacts/CpioArtifactReader.ts index ec3cc13be..ec05e9532 100644 --- a/src/artifacts/CpioArtifactReader.ts +++ b/src/artifacts/CpioArtifactReader.ts @@ -255,6 +255,13 @@ export class CpioArtifactReader implements ArtifactReader { private readonly openCompressed: ( signal?: AbortSignal, ) => Promise, + /** + * Integrity mode for iteration-time CRC failures. Streamed data CRCs are + * always checked; under record-and-continue a zero-size hard-link header + * whose CRC disagrees is yielded as an unavailable occurrence so later + * siblings are still inventoried, instead of aborting the archive. + */ + private readonly integrity: "fail" | "record-and-continue" = "fail", ) {} async *entries(signal?: AbortSignal): AsyncIterable { @@ -384,11 +391,25 @@ export class CpioArtifactReader implements ArtifactReader { header.fileSize === 0 ) { // Zero-size headers carry no bytes, so a CRC archive must declare zero. - if (header.format === "crc" && header.check !== 0) - throw new ArtifactReaderFailure( - "integrity", - `cpio CRC disagrees with content: ${raw}`, - ); + // Under record-and-continue this is the occurrence's own forgotten + // bytes: yield it unavailable so later siblings still inventory. + if (header.format === "crc" && header.check !== 0) { + if (this.integrity !== "record-and-continue") + throw new ArtifactReaderFailure( + "integrity", + `cpio CRC disagrees with content: ${raw}`, + ); + yield entryOf({ + path, + kind: "file", + key, + header, + size: null, + limitations: [`cpio CRC disagrees with content: ${raw}`], + contentUnavailable: true, + }); + return; + } const stored = this.#links.get(header.identity); if (stored !== undefined) { yield this.#alias(path, key, header, stored); diff --git a/src/artifacts/XarArtifactReader.ts b/src/artifacts/XarArtifactReader.ts index 0a209ee3b..1c4bb20b2 100644 --- a/src/artifacts/XarArtifactReader.ts +++ b/src/artifacts/XarArtifactReader.ts @@ -336,6 +336,8 @@ export class XarArtifactReader implements ArtifactReader { * @param options.verifyChecksums Fail `open()` streams whose bytes disagree * with the member's extracted checksum (default). Inventory passes false and * verifies the declared checksum itself under the caller's integrity policy. + * Archived checksums are always verified: the scanner only observes decoded + * bytes, so it cannot apply its policy to the stored stream. */ constructor( private readonly path: string, @@ -618,9 +620,12 @@ export class XarArtifactReader implements ArtifactReader { "format", `xar member ${entry.path} uses unsupported encoding ${data.encoding}`, ); + // Archived checksums cover the stored stream, which inventory's scanner + // never observes: it hashes decoded bytes. Always verify them here; the + // scanner's per-entry recovery still honors record-and-continue. const archived = data.archivedChecksum; const archivedVerifier = - archived === undefined || !this.#verifyChecksums + archived === undefined ? undefined : new ChecksumVerifier(`${entry.path} (archived)`, archived); const raw = Readable.from(this.#chunks(data.offset, data.length, signal)); diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts index 60fe7d42d..c695e9a4f 100644 --- a/src/artifacts/inventory/scanReader.ts +++ b/src/artifacts/inventory/scanReader.ts @@ -179,7 +179,10 @@ const visitArtifactEntries = async ( const parent = frame.reader; const nested: ArtifactReader = entry.nestedArchive === "gzip-cpio" - ? new CpioArtifactReader((signal) => parent.open(entry, signal)) + ? new CpioArtifactReader( + (signal) => parent.open(entry, signal), + context.integrity.mode, + ) : new AsarArtifactReader(entry.adapterKey); // Only traversed containers can own members, not opaque archive-named files. context.expandedContainerIds.add(occurrence.occurrence_id); diff --git a/tests/boundary/filesystem/installerPackageInventory.test.ts b/tests/boundary/filesystem/installerPackageInventory.test.ts index a7d1bbbbe..ea00d308d 100644 --- a/tests/boundary/filesystem/installerPackageInventory.test.ts +++ b/tests/boundary/filesystem/installerPackageInventory.test.ts @@ -224,4 +224,41 @@ describe("installer package CRC recovery", () => { ), ).toMatchObject({ hash_status: "verified" }); }); + + it("keeps later siblings after a zero-size hard-link CRC mismatch", async () => { + const directory = await createTestTempDirectory("rea-pkg-link-crc-"); + const path = join(directory, "Installer.pkg"); + await writeFile( + path, + xarArchive([ + { + name: "Payload", + data: gzipCpio( + [ + { name: "./bad", mode: MODE.file, ino: 4, links: 2, check: 1 }, + { name: "./good", mode: MODE.file, data: "ok" }, + ], + "crc", + ), + }, + ]), + ); + const inventory = artifactInventoryResultSchema.parse( + parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", { + integrity_policy: "record-and-continue", + }), + ).normalized_result, + ); + expect( + inventory.occurrences.find( + ({ logical_path: logical }) => logical === "Payload/bad", + ), + ).toMatchObject({ hash_status: "unavailable" }); + expect( + inventory.occurrences.find( + ({ logical_path: logical }) => logical === "Payload/good", + ), + ).toMatchObject({ hash_status: "verified" }); + }); });