diff --git a/src/artifacts/CpioArtifactReader.ts b/src/artifacts/CpioArtifactReader.ts index d4e58f531e..3123628c1d 100644 --- a/src/artifacts/CpioArtifactReader.ts +++ b/src/artifacts/CpioArtifactReader.ts @@ -73,6 +73,15 @@ class ByteSource { return bytes; } + /** Consume the NUL alignment bytes required by newc and crc records. */ + async padding(length: number, label: string): Promise { + if ((await this.read(length, label)).some((byte) => byte !== 0)) + throw new ArtifactReaderFailure( + "format", + `cpio ${label} contains nonzero bytes`, + ); + } + /** Yield `length` bytes in chunks without buffering them together. */ async *take(length: number, label: string): AsyncGenerator { let remaining = length; @@ -325,11 +334,9 @@ export class CpioArtifactReader implements ArtifactReader { "cpio member name size is invalid", ); const nameBytes = await source.read(header.nameSize, "member name"); - await drain( - source.take( - padding(header.format, 110 + header.nameSize), - "name padding", - ), + await source.padding( + padding(header.format, 110 + header.nameSize), + "name padding", ); // cpio names require exactly one terminal NUL: a missing terminator or // an embedded NUL with trailing bytes is malformed. Decode without @@ -637,8 +644,9 @@ export class CpioArtifactReader implements ArtifactReader { for (const byte of chunk) sum.value = (sum.value + byte) >>> 0; yield chunk; } - await drain( - source.take(padding(header.format, header.fileSize), "data padding"), + await source.padding( + padding(header.format, header.fileSize), + "data padding", ); this.#cancelled(); } diff --git a/src/artifacts/InstallerPackageReaders.test.ts b/src/artifacts/InstallerPackageReaders.test.ts index 8a689aec14..c2e3fd12cb 100644 --- a/src/artifacts/InstallerPackageReaders.test.ts +++ b/src/artifacts/InstallerPackageReaders.test.ts @@ -2,6 +2,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Readable } from "node:stream"; import { buffer } from "node:stream/consumers"; +import { gunzipSync, gzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; @@ -411,3 +412,33 @@ describe("cpio hard links and CRC archives", () => { await reader.close(); }); }); + +it.each(["newc", "crc"] as const)( + "rejects nonzero %s alignment under either integrity policy", + async (format) => { + for (const policy of ["fail", "record-and-continue"] as const) { + for (const [offset, label] of [ + [113, "name padding"], + [117, "data padding"], + ] as const) { + // 110-byte header, a three-byte NUL-terminated name, then one data byte. + const decoded = gunzipSync( + gzipCpio([{ name: "ab", mode: MODE.file, data: "x" }], format), + ); + decoded[offset] = 1; + const reader = new CpioArtifactReader( + () => Promise.resolve(Readable.from([gzipSync(decoded)])), + policy, + ); + try { + await expect(collect(reader)).rejects.toMatchObject({ + reason: "format", + message: `cpio ${label} contains nonzero bytes`, + }); + } finally { + await reader.close(); + } + } + } + }, +);