diff --git a/src/application/ArtifactGraphConstruction.ts b/src/application/ArtifactGraphConstruction.ts index 187d5d6776..893c9065c9 100644 --- a/src/application/ArtifactGraphConstruction.ts +++ b/src/application/ArtifactGraphConstruction.ts @@ -50,7 +50,10 @@ export const createOccurrence = ( compressed_size: entry.compressedSize, executable: entry.executable, encrypted: entry.encrypted, - hash_status: entry.encrypted ? "unavailable" : "not-hashed", + hash_status: + entry.encrypted || entry.contentUnavailable === true + ? "unavailable" + : "not-hashed", source_location: entry.byteOffset === null || entry.declaredSize === null ? null diff --git a/src/application/ArtifactInventory/scanReader.ts b/src/application/ArtifactInventory/scanReader.ts index 43af54fa15..768e67e4d9 100644 --- a/src/application/ArtifactInventory/scanReader.ts +++ b/src/application/ArtifactInventory/scanReader.ts @@ -193,7 +193,11 @@ const digestArtifactEntry = async ( ): Promise< { readonly node: ArtifactNode; readonly mismatched: boolean } | undefined > => { - if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted) + if ( + (entry.kind !== "file" && entry.kind !== "slice") || + entry.encrypted || + entry.contentUnavailable === true + ) return undefined; const digest = await hashReadable( await currentReader.open(entry, context.signal), diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index 419d340ed0..c4becbdc80 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -15,6 +15,8 @@ export interface ArtifactEntry { readonly unpacked: boolean; readonly limitations: readonly string[]; readonly adapterKey: string; + /** The container records this member but does not hold its bytes, such as an unresolved hard link. */ + readonly contentUnavailable?: boolean; /** Archive format of this member's own bytes, when the scan should expand it. */ readonly nestedArchive?: "gzip-cpio"; /** Filesystem identity captured during traversal, when supplied by an adapter. */ diff --git a/src/artifacts/CpioArtifactReader.ts b/src/artifacts/CpioArtifactReader.ts index a54a0881a3..565c1fb5a7 100644 --- a/src/artifacts/CpioArtifactReader.ts +++ b/src/artifacts/CpioArtifactReader.ts @@ -10,15 +10,21 @@ import { /** Longest member name accepted, including its terminating NUL. */ const MAX_NAME_BYTES = 64 * 1024; +/** Hard-link bytes are buffered to serve every linked path, within these bounds. */ +const MAX_LINK_BUFFER_BYTES = 16 * 1024 * 1024; +const MAX_LINK_BUFFER_TOTAL = 64 * 1024 * 1024; const TRAILER = "TRAILER!!!"; const S_IFMT = 0o170000; const S_IFDIR = 0o040000; const S_IFREG = 0o100000; const S_IFLNK = 0o120000; +const UNRESOLVED_LINK = + "Hard-link bytes are stored with another member of this archive and could not be associated with this path."; + /** Pull exactly the requested bytes from a stream, holding at most one chunk. */ class ByteSource { - readonly #iterator: AsyncIterator; + readonly #iterator: AsyncIterator; #buffer: Buffer = Buffer.alloc(0); constructor(stream: Readable) { @@ -26,16 +32,27 @@ class ByteSource { } async #fill(): Promise { - const next = await this.#iterator.next(); + let next: IteratorResult; + try { + next = await this.#iterator.next(); + } catch (cause: unknown) { + if (cause instanceof ArtifactReaderFailure) throw cause; + throw new ArtifactReaderFailure( + "format", + "cpio stream is not valid gzip data", + { cause }, + ); + } if (next.done === true) return false; - const chunk: unknown = next.value; - if (!Buffer.isBuffer(chunk)) + if (!Buffer.isBuffer(next.value)) throw new ArtifactReaderFailure( "format", "cpio stream yielded non-binary data", ); this.#buffer = - this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]); + this.#buffer.length === 0 + ? next.value + : Buffer.concat([this.#buffer, next.value]); return true; } @@ -62,20 +79,21 @@ class ByteSource { } } - async skip(length: number, label: string): Promise { - for await (const chunk of this.take(length, label)) void chunk; - } - async close(): Promise { await this.#iterator.return?.(); } } interface CpioHeader { - readonly format: "odc" | "newc"; + readonly format: "odc" | "newc" | "crc"; readonly mode: number; readonly fileSize: number; readonly nameSize: number; + readonly links: number; + /** Device and inode identifying hard links. */ + readonly identity: string; + /** `c_check` of `070702` archives: the unsigned 32-bit sum of the data bytes. */ + readonly check: number; } const field = ( @@ -102,18 +120,24 @@ const readHeader = async (source: ByteSource): Promise => { const rest = await source.read(70, "header"); return { format: "odc", + identity: `${field(rest, 0, 6, 8)}:${field(rest, 6, 6, 8)}`, mode: field(rest, 12, 6, 8), + links: field(rest, 30, 6, 8), nameSize: field(rest, 53, 6, 8), fileSize: field(rest, 59, 11, 8), + check: 0, }; } if (magic === "070701" || magic === "070702") { const rest = await source.read(104, "header"); return { - format: "newc", + format: magic === "070702" ? "crc" : "newc", + identity: `${field(rest, 56, 8, 16)}:${field(rest, 64, 8, 16)}:${field(rest, 0, 8, 16)}`, mode: field(rest, 8, 8, 16), + links: field(rest, 32, 8, 16), fileSize: field(rest, 48, 8, 16), nameSize: field(rest, 88, 8, 16), + check: field(rest, 96, 8, 16), }; } throw new ArtifactReaderFailure( @@ -123,7 +147,7 @@ const readHeader = async (source: ByteSource): Promise => { }; const padding = (format: CpioHeader["format"], length: number): number => - format === "newc" ? (4 - (length % 4)) % 4 : 0; + format === "odc" ? 0 : (4 - (length % 4)) % 4; /** Normalize a member name; reject absolute and traversing names. */ const memberPath = (raw: string): string | undefined => { @@ -138,17 +162,24 @@ const memberPath = (raw: string): string | undefined => { return segments.length === 0 ? undefined : segments.join("/"); }; -/** Project one cpio header as an archive-neutral entry. */ +/** Project one cpio member as an archive-neutral entry. */ const entryOf = (member: { readonly path: string; readonly kind: ArtifactEntry["kind"]; readonly key: string; readonly header: CpioHeader; readonly limitations: readonly string[]; + readonly size?: number | null; + readonly contentUnavailable?: boolean; }): ArtifactEntry => ({ path: member.path, kind: member.kind, - declaredSize: member.kind === "file" ? member.header.fileSize : null, + declaredSize: + member.size !== undefined + ? member.size + : member.kind === "file" + ? member.header.fileSize + : null, compressedSize: null, executable: (member.header.mode & 0o111) !== 0, encrypted: false, @@ -157,13 +188,25 @@ const entryOf = (member: { unpacked: false, limitations: member.limitations, adapterKey: member.key, + ...(member.contentUnavailable === true ? { contentUnavailable: true } : {}), }); +interface PendingLink { + readonly path: string; + readonly key: string; + readonly header: CpioHeader; +} + +const drain = async (chunks: AsyncIterable): Promise => { + for await (const chunk of chunks) void chunk; +}; + /** * Sequential reader for a gzip-compressed cpio archive, such as an installer * package's Scripts or Payload. Members are decompressed once, in order: open - * an entry before advancing to the next one. Device nodes, FIFOs, and sockets - * are skipped; hard links appear as separate entries. + * an entry before advancing to the next one. `070702` data is checked against + * its CRC. Hard links whose bytes are stored on another member are served + * from that member's bytes; device nodes, FIFOs, and sockets are skipped. */ export class CpioArtifactReader implements ArtifactReader { readonly format = "file" as const; @@ -171,11 +214,15 @@ export class CpioArtifactReader implements ArtifactReader { #current: | { readonly key: string; - readonly size: number; + readonly path: string; readonly header: CpioHeader; } | undefined; #consumed = false; + readonly #links = new Map(); + readonly #pending = new Map(); + readonly #aliases = new Map(); + #buffered = 0; constructor( private readonly openCompressed: ( @@ -189,8 +236,12 @@ export class CpioArtifactReader implements ArtifactReader { ); this.#source = source; for (let index = 0; ; index++) { - signal?.throwIfAborted(); - await this.#finishCurrent(source); + if (signal?.aborted === true) + throw new ArtifactReaderFailure( + "cancelled", + "cpio expansion was cancelled", + ); + yield* this.#finishCurrent(source); const header = await readHeader(source); if (header.nameSize < 1 || header.nameSize > MAX_NAME_BYTES) throw new ArtifactReaderFailure( @@ -198,64 +249,182 @@ export class CpioArtifactReader implements ArtifactReader { "cpio member name size is invalid", ); const nameBytes = await source.read(header.nameSize, "member name"); - await source.skip( - padding(header.format, 110 + header.nameSize), - "name padding", + await drain( + source.take( + padding(header.format, 110 + header.nameSize), + "name padding", + ), ); + const end = nameBytes.indexOf(0); const raw = nameBytes.toString( "utf8", 0, - nameBytes.indexOf(0) >= 0 ? nameBytes.indexOf(0) : nameBytes.length, + end >= 0 ? end : nameBytes.length, ); - if (raw === TRAILER) return; - const type = header.mode & S_IFMT; - const path = memberPath(raw); - const key = `${index}:${raw}`; - if (type === S_IFLNK) { - const target = ( - await source.read(header.fileSize, "symlink target") - ).toString("utf8"); - await source.skip( - padding(header.format, header.fileSize), - "data padding", - ); - if (path !== undefined) - yield entryOf({ - path, - kind: "symlink", - key, - header, - limitations: [`Symlink target: ${target}`], - }); - continue; + if (raw === TRAILER) { + yield* this.#unresolvedLinks(); + return; } - this.#current = { key, size: header.fileSize, header }; - this.#consumed = false; - if (path === undefined || (type !== S_IFREG && type !== S_IFDIR)) - continue; - yield entryOf({ - path, - kind: type === S_IFDIR ? "directory" : "file", - key, - header, - limitations: [], - }); + yield* this.#member(source, header, raw, `${index}:${raw}`); } } - /** Skip whatever the caller did not read of the current member. */ - async #finishCurrent(source: ByteSource): Promise { + async *#member( + source: ByteSource, + header: CpioHeader, + raw: string, + key: string, + ): AsyncGenerator { + const type = header.mode & S_IFMT; + const path = memberPath(raw); + if (type === S_IFLNK) { + const target = await this.#collect(source, header, raw); + if (path !== undefined) + yield entryOf({ + path, + kind: "symlink", + key, + header, + limitations: [`Symlink target: ${target.toString("utf8")}`], + }); + return; + } + if ( + path !== undefined && + type === S_IFREG && + header.links > 1 && + header.fileSize === 0 + ) { + const stored = this.#links.get(header.identity); + if (stored !== undefined) { + yield this.#alias(path, key, header, stored); + return; + } + // newc stores a hard link's bytes on its last member; wait for them. + const waiting = this.#pending.get(header.identity) ?? []; + waiting.push({ path, key, header }); + this.#pending.set(header.identity, waiting); + return; + } + this.#current = { key, path: raw, header }; + this.#consumed = false; + if (path === undefined || (type !== S_IFREG && type !== S_IFDIR)) return; + yield entryOf({ + path, + kind: type === S_IFDIR ? "directory" : "file", + key, + header, + limitations: [], + }); + } + + #alias( + path: string, + key: string, + header: CpioHeader, + bytes: Buffer, + ): ArtifactEntry { + this.#aliases.set(key, bytes); + return entryOf({ + path, + kind: "file", + key, + header, + size: bytes.length, + limitations: [ + "Hard link: bytes are stored with another member of this archive.", + ], + }); + } + + *#unresolvedLinks(): Generator { + for (const links of this.#pending.values()) + for (const { path, key, header } of links) + yield entryOf({ + path, + kind: "file", + key, + header, + size: null, + limitations: [UNRESOLVED_LINK], + contentUnavailable: true, + }); + this.#pending.clear(); + } + + /** Finish the current member, then serve hard links that were waiting for it. */ + async *#finishCurrent(source: ByteSource): AsyncGenerator { const current = this.#current; if (current === undefined) return; this.#current = undefined; - if (!this.#consumed) await source.skip(current.size, "member data"); - await source.skip( - padding(current.header.format, current.size), - "data padding", + if (!this.#consumed) { + if (this.#shouldKeep(current.header)) + this.#remember( + current.header, + await this.#collect(source, current.header, current.path), + ); + else await drain(this.#verified(source, current.header, current.path)); + } + const waiting = this.#pending.get(current.header.identity); + const stored = this.#links.get(current.header.identity); + if (waiting === undefined || stored === undefined) return; + this.#pending.delete(current.header.identity); + for (const { path, key, header } of waiting) + yield this.#alias(path, key, header, stored); + } + + #shouldKeep(header: CpioHeader): boolean { + return ( + (header.mode & S_IFMT) === S_IFREG && + header.links > 1 && + header.fileSize > 0 && + header.fileSize <= MAX_LINK_BUFFER_BYTES && + this.#buffered + header.fileSize <= MAX_LINK_BUFFER_TOTAL ); } + #remember(header: CpioHeader, bytes: Buffer): void { + if (this.#links.has(header.identity)) return; + this.#links.set(header.identity, bytes); + this.#buffered += bytes.length; + } + + /** Member data and its padding, checked against a `070702` CRC. */ + async *#verified( + source: ByteSource, + header: CpioHeader, + path: string, + ): AsyncGenerator { + let sum = 0; + for await (const chunk of source.take(header.fileSize, "member data")) { + if (header.format === "crc") + for (const byte of chunk) sum = (sum + byte) >>> 0; + yield chunk; + } + if (header.format === "crc" && sum !== header.check) + throw new ArtifactReaderFailure( + "integrity", + `cpio CRC disagrees with content: ${path}`, + ); + await drain( + source.take(padding(header.format, header.fileSize), "data padding"), + ); + } + + async #collect( + source: ByteSource, + header: CpioHeader, + path: string, + ): Promise { + const chunks: Buffer[] = []; + for await (const chunk of this.#verified(source, header, path)) + chunks.push(chunk); + return Buffer.concat(chunks); + } + open(entry: ArtifactEntry): Promise { + const alias = this.#aliases.get(entry.adapterKey); + if (alias !== undefined) return Promise.resolve(Readable.from([alias])); const source = this.#source; const current = this.#current; if ( @@ -267,12 +436,30 @@ export class CpioArtifactReader implements ArtifactReader { return Promise.reject( new ArtifactReaderFailure( "unavailable", - "cpio members can be read only once, in archive order", + entry.contentUnavailable === true + ? UNRESOLVED_LINK + : "cpio members can be read only once, in archive order", ), ); this.#consumed = true; + const chunks = this.#verified(source, current.header, current.path); + if (!this.#shouldKeep(current.header)) + return Promise.resolve(Readable.from(chunks)); + // Keep a linked member's bytes for paths that share them. + const kept: Buffer[] = []; + const remember = (): void => { + this.#remember(current.header, Buffer.concat(kept)); + }; return Promise.resolve( - Readable.from(source.take(current.size, "member data")), + Readable.from( + (async function* () { + for await (const chunk of chunks) { + kept.push(chunk); + yield chunk; + } + remember(); + })(), + ), ); } diff --git a/src/artifacts/InstallerPackage.fixture.ts b/src/artifacts/InstallerPackage.fixture.ts index bdbcce116c..bc8fa95627 100644 --- a/src/artifacts/InstallerPackage.fixture.ts +++ b/src/artifacts/InstallerPackage.fixture.ts @@ -11,6 +11,8 @@ export interface XarFixtureMember { readonly mode?: string; /** Replace the declared extracted checksum, to model tampering. */ readonly extractedSha1?: string; + /** Store these bytes instead of encoding `data`, to model corrupt members. */ + readonly archived?: Uint8Array; readonly children?: readonly XarFixtureMember[]; } @@ -23,7 +25,11 @@ const escape = (text: string): string => /** Build a xar archive with a SHA-1 TOC checksum and per-member checksums. */ export const xarArchive = ( members: readonly XarFixtureMember[], - options: { readonly corruptTocChecksum?: boolean } = {}, + options: { + readonly corruptTocChecksum?: boolean; + /** Declared TOC checksum size; the real digest is 20 bytes. */ + readonly tocChecksumSize?: number; + } = {}, ): Uint8Array => { const heap: Uint8Array[] = [new Uint8Array(20)]; let heapLength = 20; @@ -34,7 +40,8 @@ export const xarArchive = ( let data = ""; if (type === "file" && member.data !== undefined) { const archived = - member.encoding === "zlib" ? deflateSync(member.data) : member.data; + member.archived ?? + (member.encoding === "zlib" ? deflateSync(member.data) : member.data); const offset = heapLength; heap.push(archived); heapLength += archived.length; @@ -55,7 +62,7 @@ export const xarArchive = ( }; const files = members.map(fileXml).join(""); const toc = Buffer.from( - `200${files}`, + `${options.tocChecksumSize ?? 20}0${files}`, ); const compressed = deflateSync(toc); const checksum = createHash("sha1").update(compressed).digest(); @@ -76,6 +83,10 @@ export interface CpioFixtureMember { readonly name: string; readonly mode: number; readonly data?: Uint8Array | string; + readonly ino?: number; + readonly links?: number; + /** Override a crc archive's c_check, to model corruption. */ + readonly check?: number; } const octal = (value: number, width: number): string => @@ -85,11 +96,11 @@ const hex = (value: number): string => value.toString(16).padStart(8, "0"); /** Build a gzip-compressed odc or newc cpio archive ending in its trailer. */ export const gzipCpio = ( members: readonly CpioFixtureMember[], - format: "odc" | "newc" = "odc", + format: "odc" | "newc" | "crc" = "odc", ): Uint8Array => { const parts: Buffer[] = []; const pad = (length: number): void => { - if (format === "newc" && length % 4 !== 0) + if (format !== "odc" && length % 4 !== 0) parts.push(Buffer.alloc(4 - (length % 4))); }; for (const member of [...members, { name: "TRAILER!!!", mode: 0 }]) { @@ -98,10 +109,14 @@ export const gzipCpio = ( ? Buffer.from(member.data) : Buffer.from(member.data ?? new Uint8Array()); const name = Buffer.from(`${member.name}\0`); + const ino = "ino" in member ? (member.ino ?? 1) : 1; + const links = "links" in member ? (member.links ?? 1) : 1; + const sum = [...data].reduce((total, byte) => (total + byte) >>> 0, 0); + const check = "check" in member ? (member.check ?? sum) : sum; if (format === "odc") parts.push( Buffer.from( - `070707${octal(0, 6)}${octal(1, 6)}${octal(member.mode, 6)}${octal(0, 6)}${octal(0, 6)}${octal(1, 6)}${octal(0, 6)}${octal(0, 11)}${octal(name.length, 6)}${octal(data.length, 11)}`, + `070707${octal(0, 6)}${octal(ino, 6)}${octal(member.mode, 6)}${octal(0, 6)}${octal(0, 6)}${octal(links, 6)}${octal(0, 6)}${octal(0, 11)}${octal(name.length, 6)}${octal(data.length, 11)}`, ), name, data, @@ -109,7 +124,7 @@ export const gzipCpio = ( else { parts.push( Buffer.from( - `070701${hex(1)}${hex(member.mode)}${hex(0)}${hex(0)}${hex(1)}${hex(0)}${hex(data.length)}${hex(0)}${hex(0)}${hex(0)}${hex(0)}${hex(name.length)}${hex(0)}`, + `${format === "crc" ? "070702" : "070701"}${hex(ino)}${hex(member.mode)}${hex(0)}${hex(0)}${hex(links)}${hex(0)}${hex(data.length)}${hex(0)}${hex(0)}${hex(0)}${hex(0)}${hex(name.length)}${hex(format === "crc" ? check : 0)}`, ), name, ); diff --git a/src/artifacts/InstallerPackageReaders.test.ts b/src/artifacts/InstallerPackageReaders.test.ts index 2344b44afd..4b6c818fbc 100644 --- a/src/artifacts/InstallerPackageReaders.test.ts +++ b/src/artifacts/InstallerPackageReaders.test.ts @@ -223,3 +223,161 @@ describe("gzip cpio reader", () => { await reader.close(); }); }); + +describe("installer package reader hardening", () => { + it("refuses a TOC checksum whose declared size is not one digest", async () => { + const reader = new XarArtifactReader( + await writePackage( + xarArchive([{ name: "Bom", data: Buffer.from("bom") }], { + tocChecksumSize: 0x7fffffff, + }), + ), + ); + await expect(collect(reader)).rejects.toMatchObject({ + reason: "format", + message: "xar TOC declares a 2147483647-byte sha1 checksum", + }); + await reader.close(); + }); + + it("reports corrupt zlib members as malformed and keeps cancellation tagged", async () => { + const reader = new XarArtifactReader( + await writePackage( + xarArchive([ + { + name: "PackageInfo", + data: Buffer.from(""), + encoding: "zlib", + archived: Buffer.from("not zlib at all"), + }, + ]), + ), + ); + try { + const [entry] = await collect(reader); + if (entry === undefined) throw new Error("missing entry"); + await expect(buffer(await reader.open(entry))).rejects.toMatchObject({ + reason: "format", + }); + const controller = new AbortController(); + controller.abort(); + await expect(reader.open(entry, controller.signal)).rejects.toMatchObject( + { + reason: "cancelled", + }, + ); + } finally { + await reader.close(); + } + }); +}); + +describe("cpio hard links and CRC archives", () => { + const readAll = async (archive: Uint8Array) => { + const reader = new CpioArtifactReader(() => + Promise.resolve(Readable.from([archive])), + ); + const files: Record = {}; + try { + for await (const entry of reader.entries()) + if (entry.kind === "file") + files[entry.path] = + entry.contentUnavailable === true + ? undefined + : (await buffer(await reader.open(entry))).toString(); + } finally { + await reader.close(); + } + return files; + }; + + it("serves newc hard links stored on the last member to every path", async () => { + expect( + await readAll( + gzipCpio( + [ + { name: "./first", mode: MODE.file, ino: 7, links: 2 }, + { + name: "./second", + mode: MODE.file, + ino: 7, + links: 2, + data: "shared", + }, + { + name: "./third", + mode: MODE.file, + ino: 9, + links: 2, + data: "kept", + }, + { name: "./fourth", mode: MODE.file, ino: 9, links: 2 }, + ], + "newc", + ), + ), + ).toEqual({ + first: "shared", + second: "shared", + third: "kept", + fourth: "kept", + }); + }); + + it("reports a hard link whose bytes never appear as unavailable", async () => { + const reader = new CpioArtifactReader(() => + Promise.resolve( + Readable.from([ + gzipCpio( + [{ name: "./orphan", mode: MODE.file, ino: 4, links: 2 }], + "newc", + ), + ]), + ), + ); + const entries = await collect(reader); + expect(entries).toEqual([ + expect.objectContaining({ + path: "orphan", + contentUnavailable: true, + declaredSize: null, + }), + ]); + const orphan = entries[0]; + if (orphan === undefined) throw new Error("missing entry"); + await expect(reader.open(orphan)).rejects.toMatchObject({ + reason: "unavailable", + }); + await reader.close(); + }); + + it("verifies 070702 member checksums whether or not members are opened", async () => { + expect( + await readAll( + gzipCpio([{ name: "./ok", mode: MODE.file, data: "abc" }], "crc"), + ), + ).toEqual({ ok: "abc" }); + await expect( + readAll( + gzipCpio( + [{ name: "./bad", mode: MODE.file, data: "abc", check: 1 }], + "crc", + ), + ), + ).rejects.toMatchObject({ reason: "integrity" }); + const reader = new CpioArtifactReader(() => + Promise.resolve( + Readable.from([ + gzipCpio( + [{ name: "./bad", mode: MODE.file, data: "abc", check: 1 }], + "crc", + ), + ]), + ), + ); + await expect(collect(reader)).rejects.toMatchObject({ + reason: "integrity", + }); + await reader.close(); + }); +}); diff --git a/src/artifacts/XarArtifactReader.ts b/src/artifacts/XarArtifactReader.ts index 6fb5d11fcf..f11798dd66 100644 --- a/src/artifacts/XarArtifactReader.ts +++ b/src/artifacts/XarArtifactReader.ts @@ -1,6 +1,11 @@ import { createHash, type Hash } from "node:crypto"; import { open, type FileHandle } from "node:fs/promises"; -import { Readable, Transform, type TransformCallback } from "node:stream"; +import { + PassThrough, + Readable, + Transform, + type TransformCallback, +} from "node:stream"; import { createInflate, inflateSync } from "node:zlib"; import { DOMParser, type Element, type Node } from "@xmldom/xmldom"; @@ -23,6 +28,17 @@ const CHECKSUM_ALGORITHMS: Readonly> = { sha256: "sha256", sha512: "sha512", }; +const DIGEST_BYTES: Readonly> = { + sha1: 20, + md5: 16, + sha256: 32, + sha512: 64, +}; + +const cancelled = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new ArtifactReaderFailure("cancelled", "PKG traversal was cancelled"); +}; /** One TOC `` element: where a member's archived bytes live in the heap. */ interface XarData { @@ -191,7 +207,7 @@ export class XarArtifactReader implements ArtifactReader { async #load(signal?: AbortSignal): Promise { if (this.#handle !== undefined) return; - signal?.throwIfAborted(); + cancelled(signal); const handle = await open(this.path, "r"); this.#handle = handle; const header = Buffer.alloc(28); @@ -251,12 +267,19 @@ export class XarArtifactReader implements ArtifactReader { /** The TOC checksum in the heap covers the compressed TOC bytes. */ async #verifyToc(toc: Element, compressed: Buffer): Promise { const checksum = childElement(toc, "checksum"); - const algorithm = - CHECKSUM_ALGORITHMS[checksum?.getAttribute("style")?.toLowerCase() ?? ""]; + const style = checksum?.getAttribute("style")?.toLowerCase() ?? ""; + const algorithm = CHECKSUM_ALGORITHMS[style]; if (checksum === undefined || algorithm === undefined) return; + const size = integer(textOf(checksum, "size"), "checksum size"); + // The stored checksum is exactly one digest; never allocate a declared size. + if (size !== DIGEST_BYTES[style]) + throw new ArtifactReaderFailure( + "format", + `xar TOC declares a ${size}-byte ${style} checksum`, + ); const stored = await this.#heapBytes( integer(textOf(checksum, "offset"), "checksum offset"), - integer(textOf(checksum, "size"), "checksum size"), + size, ); if (!createHash(algorithm).update(compressed).digest().equals(stored)) throw new ArtifactReaderFailure( @@ -325,7 +348,7 @@ export class XarArtifactReader implements ArtifactReader { async *entries(signal?: AbortSignal): AsyncIterable { await this.#load(signal); for (const member of this.#members) { - signal?.throwIfAborted(); + cancelled(signal); const classified = member.kind === "file" ? await this.#nestedArchive(member) @@ -359,6 +382,7 @@ export class XarArtifactReader implements ArtifactReader { } async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + cancelled(signal); await this.#load(signal); const member = this.#members.find(({ path }) => path === entry.adapterKey); if (member === undefined || member.kind !== "file") @@ -371,19 +395,26 @@ export class XarArtifactReader implements ArtifactReader { const raw = Readable.from(this.#chunks(data.offset, data.length, signal)); let decoded: Readable; if (data.encoding === "application/octet-stream") decoded = raw; - else if (data.encoding === "application/x-gzip") + else if (data.encoding === "application/x-gzip") { // xar's "x-gzip" members are zlib streams. - decoded = raw.pipe(createInflate()); - else + const inflate = createInflate(); + raw.on("error", (cause: unknown) => + inflate.destroy(memberFailure(entry.path, cause)), + ); + decoded = raw.pipe(inflate); + } else throw new ArtifactReaderFailure( "format", `xar member ${entry.path} uses unsupported encoding ${data.encoding}`, ); const checksum = data.extractedChecksum; - if (checksum === undefined) return decoded; - const verified = new ChecksumVerifier(entry.path, checksum); + const verified = + checksum === undefined + ? new PassThrough() + : new ChecksumVerifier(entry.path, checksum); + // Decoder errors become tagged failures; cancellation stays tagged. decoded.on("error", (cause: unknown) => - verified.destroy(cause instanceof Error ? cause : undefined), + verified.destroy(memberFailure(entry.path, cause)), ); return decoded.pipe(verified); } @@ -394,7 +425,7 @@ export class XarArtifactReader implements ArtifactReader { signal?: AbortSignal, ): AsyncGenerator { for (let done = 0; done < length;) { - signal?.throwIfAborted(); + cancelled(signal); const size = Math.min(READ_CHUNK_BYTES, length - done); yield await this.#heapBytes(offset + done, size); done += size; @@ -411,3 +442,13 @@ export class XarArtifactReader implements ArtifactReader { await handle?.close(); } } + +/** Keep tagged failures; any other decoder error means malformed member bytes. */ +const memberFailure = (path: string, cause: unknown): ArtifactReaderFailure => + cause instanceof ArtifactReaderFailure + ? cause + : new ArtifactReaderFailure( + "format", + `xar member ${path} could not be decoded`, + { cause }, + ); diff --git a/tests/boundary/filesystem/installerPackageInventory.test.ts b/tests/boundary/filesystem/installerPackageInventory.test.ts index 692c2954f0..8940d87e77 100644 --- a/tests/boundary/filesystem/installerPackageInventory.test.ts +++ b/tests/boundary/filesystem/installerPackageInventory.test.ts @@ -117,4 +117,34 @@ describe("installer package inventory", () => { details: { reason: "integrity" }, }); }); + + it("records an unresolved hard link as unavailable instead of empty", async () => { + const directory = await createTestTempDirectory("rea-pkg-hardlink-"); + const path = join(directory, "Installer.pkg"); + await writeFile( + path, + xarArchive([ + { + name: "Payload", + data: gzipCpio( + [{ name: "./orphan", mode: MODE.file, ino: 3, links: 2 }], + "newc", + ), + }, + ]), + ); + const inventory = artifactInventoryResultSchema.parse( + parseEvidence(await runProviderAnalysis(path, "inventory_artifact", {})) + .normalized_result, + ); + expect( + inventory.occurrences.find( + ({ logical_path: logical }) => logical === "Payload/orphan", + ), + ).toMatchObject({ + artifact_id: null, + hash_status: "unavailable", + limitations: [expect.stringContaining("Hard-link bytes")], + }); + }); });