fix(artifacts): always verify archived checksums; route link CRCs via policy

This commit is contained in:
morluto 2026-10-08 16:38:53 +08:00
parent 949347a148
commit 3910bedae8
4 changed files with 73 additions and 7 deletions

View File

@ -255,6 +255,13 @@ export class CpioArtifactReader implements ArtifactReader {
private readonly openCompressed: (
signal?: AbortSignal,
) => Promise<Readable>,
/**
* 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<ArtifactEntry> {
@ -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);

View File

@ -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));

View File

@ -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);

View File

@ -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" });
});
});