diff --git a/src/backend/plugins/artifact-manifest.ts b/src/backend/plugins/artifact-manifest.ts index b1065db4f..7992dd8f8 100644 --- a/src/backend/plugins/artifact-manifest.ts +++ b/src/backend/plugins/artifact-manifest.ts @@ -1,50 +1,59 @@ +import path from "node:path"; import zlib from "node:zlib"; +import * as tar from "tar"; const MAX_UNPACKED_BYTES = 512 * 1024 * 1024; /** - * Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to - * disk. Returns null when the archive has no such file. + * Reads every copy of one file out of a .tmxplug (a gzipped tar) without + * unpacking it to disk. Uses the same tar parser as the loader's unpack, so + * pax and long-name headers resolve the same way, and only plain files count + * because they are the only kind the unpack writes. Names compare + * case-insensitively since a case-insensitive disk folds them together. */ -export function readArtifactFile( - buffer: Buffer, - wanted: string, -): Buffer | null { - const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES }); - let offset = 0; - while (offset + 512 <= tar.length) { - const header = tar.subarray(offset, offset + 512); - if (header.every((byte) => byte === 0)) break; - const field = (start: number, length: number) => - header - .subarray(start, start + length) - .toString("utf8") - .replace(/\0.*$/s, ""); - const prefix = field(345, 155); - const name = ( - prefix ? `${prefix}/${field(0, 100)}` : field(0, 100) - ).replace(/^\.\//, ""); - const size = Number.parseInt(field(124, 12).trim() || "0", 8); - if (!Number.isFinite(size) || size < 0) break; - const start = offset + 512; - if (name === wanted) return tar.subarray(start, start + size); - offset = start + Math.ceil(size / 512) * 512; - } - return null; +export function readArtifactFiles(buffer: Buffer, wanted: string): Buffer[] { + const archive = zlib.gunzipSync(buffer, { + maxOutputLength: MAX_UNPACKED_BYTES, + }); + const key = wanted.toLowerCase(); + const found: Buffer[] = []; + const parser = new tar.Parser({ + strict: true, + onReadEntry: (entry) => { + const name = path.posix.normalize(entry.path.replace(/\\/g, "/")); + if (entry.type !== "File" || name.toLowerCase() !== key) { + entry.resume(); + return; + } + const chunks: Buffer[] = []; + entry.on("data", (chunk: Buffer) => chunks.push(chunk)); + entry.on("end", () => found.push(Buffer.concat(chunks))); + }, + }); + // The parser runs synchronously on an in-memory buffer. + parser.end(archive); + return found; } -/** The manifest.json packed in a .tmxplug, parsed but not validated. */ +/** + * The manifest.json packed in a .tmxplug, parsed but not validated. An + * archive with more than one is refused: the unpack keeps the last copy, so + * whichever one this read the other could carry different capabilities. + */ export function readArtifactManifest(buffer: Buffer): Record { - let raw: Buffer | null; + let copies: Buffer[]; try { - raw = readArtifactFile(buffer, "manifest.json"); + copies = readArtifactFiles(buffer, "manifest.json"); } catch { throw new Error("the file is not a valid .tmxplug archive"); } - if (!raw) throw new Error("the archive has no manifest.json"); + if (copies.length === 0) throw new Error("the archive has no manifest.json"); + if (copies.length > 1) { + throw new Error("the archive has more than one manifest.json"); + } let parsed: unknown; try { - parsed = JSON.parse(raw.toString("utf8")); + parsed = JSON.parse(copies[0].toString("utf8")); } catch { throw new Error("manifest.json is not valid JSON"); } diff --git a/src/backend/tests/plugins/artifact-manifest.test.ts b/src/backend/tests/plugins/artifact-manifest.test.ts index 0d11677a3..d4693cfe6 100644 --- a/src/backend/tests/plugins/artifact-manifest.test.ts +++ b/src/backend/tests/plugins/artifact-manifest.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import zlib from "node:zlib"; import * as tar from "tar"; import { - readArtifactFile, + readArtifactFiles, readArtifactManifest, sameCapabilities, } from "../../plugins/artifact-manifest.js"; @@ -27,20 +28,42 @@ function pack(files: Record, prefix = ""): Buffer { } } -describe("readArtifactFile", () => { +/** Two archives' entries back to back, so a name can appear twice. */ +function concat(first: Buffer, second: Buffer): Buffer { + const entries = (buffer: Buffer) => { + const raw = zlib.gunzipSync(buffer); + // Drop the two zero blocks that end every tar. + return raw.subarray(0, raw.length - 1024); + }; + return zlib.gzipSync( + Buffer.concat([entries(first), entries(second), Buffer.alloc(1024)]), + ); +} + +describe("readArtifactFiles", () => { it("finds a file by name, with or without a ./ prefix", () => { const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" }); - expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x"); + expect( + readArtifactFiles(plain, "dist/a.js").map((b) => b.toString()), + ).toEqual(["x"]); const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./"); - expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe( - '{"id":"a"}', + expect( + readArtifactFiles(dotted, "manifest.json").map((b) => b.toString()), + ).toEqual(['{"id":"a"}']); + }); + + it("returns nothing for a missing file", () => { + expect(readArtifactFiles(pack({ "a.txt": "a" }), "manifest.json")).toEqual( + [], ); }); - it("returns null for a missing file", () => { - expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe( - null, - ); + it("resolves names that need a pax header", () => { + const long = `dist/${"x".repeat(150)}.js`; + const archive = pack({ "manifest.json": "{}", [long]: "x" }); + expect(readArtifactFiles(archive, long).map((b) => b.toString())).toEqual([ + "x", + ]); }); }); @@ -68,6 +91,18 @@ describe("readArtifactManifest", () => { readArtifactManifest(pack({ "manifest.json": "[1]" })), ).toThrow(/not an object/); }); + + it("refuses an archive with two manifests", () => { + const first = pack({ "manifest.json": '{"capabilities":[]}' }); + const second = pack({ "manifest.json": '{"capabilities":["ssh"]}' }); + expect(() => readArtifactManifest(concat(first, second))).toThrow( + /more than one manifest.json/, + ); + const upper = pack({ "MANIFEST.JSON": '{"capabilities":["ssh"]}' }); + expect(() => readArtifactManifest(concat(first, upper))).toThrow( + /more than one manifest.json/, + ); + }); }); describe("sameCapabilities", () => {