fix(artifacts): harden xar and cpio readers for hostile packages

Address review findings in PKG inventory:
- Read the xar TOC checksum only when its declared size equals the
  algorithm's digest length. A crafted size can no longer trigger a huge
  allocation.
- Associate cpio hard links whose bytes are stored on another member
  (newc stores them on the last link). Linked bytes are kept, within 16
  MiB per member and 64 MiB in total, and served to every path. A link
  whose bytes never appear is recorded with content unavailable and a
  limitation, not hashed as an empty file. ArtifactEntry gains
  `contentUnavailable` for this.
- Verify `070702` member CRCs whether or not a member is opened.
- Report malformed zlib members as `format`, and keep cancellation as
  `cancelled` while loading, opening, or streaming members.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-07 12:03:28 +00:00
1 parent 54dd1a7604
commit daf504b62f
8 files changed
+525 -85

No files matched your search

+4 -1
View File
@@ -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
@@ -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),
+2
View File
@@ -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. */
+250 -63
View File
@@ -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<Buffer>;
readonly #iterator: AsyncIterator<unknown>;
#buffer: Buffer = Buffer.alloc(0);
constructor(stream: Readable) {
@@ -26,16 +32,27 @@ class ByteSource {
}
async #fill(): Promise<boolean> {
const next = await this.#iterator.next();
let next: IteratorResult<unknown>;
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<void> {
for await (const chunk of this.take(length, label)) void chunk;
}
async close(): Promise<void> {
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<CpioHeader> => {
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<CpioHeader> => {
};
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<Buffer>): Promise<void> => {
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<string, Buffer>();
readonly #pending = new Map<string, PendingLink[]>();
readonly #aliases = new Map<string, Buffer>();
#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<void> {
async *#member(
source: ByteSource,
header: CpioHeader,
raw: string,
key: string,
): AsyncGenerator<ArtifactEntry> {
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<ArtifactEntry> {
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<ArtifactEntry> {
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<Buffer> {
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<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of this.#verified(source, header, path))
chunks.push(chunk);
return Buffer.concat(chunks);
}
open(entry: ArtifactEntry): Promise<Readable> {
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();
})(),
),
);
}
+22 -7
View File
@@ -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(
`<?xml version="1.0" encoding="UTF-8"?><xar><toc><checksum style="sha1"><size>20</size><offset>0</offset></checksum>${files}</toc></xar>`,
`<?xml version="1.0" encoding="UTF-8"?><xar><toc><checksum style="sha1"><size>${options.tocChecksumSize ?? 20}</size><offset>0</offset></checksum>${files}</toc></xar>`,
);
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,
);
@@ -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("<pkg-info/>"),
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<string, string | undefined> = {};
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();
});
});
+54 -13
View File
@@ -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<Record<string, string>> = {
sha256: "sha256",
sha512: "sha512",
};
const DIGEST_BYTES: Readonly<Record<string, number>> = {
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 `<data>` 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<void> {
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<void> {
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<ArtifactEntry> {
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<Readable> {
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<Buffer> {
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 },
);
@@ -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")],
});
});
});