fix(artifacts): always verify archived checksums; route link CRCs via policy
This commit is contained in:
parent
949347a148
commit
3910bedae8
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue