// @vitest-environment node import { File } from "node:buffer"; import { describe, expect, it } from "vitest"; import { gzipDeterministic, gunzipBounded, gunzipBoundedDetailed, } from "../../src/archive/gzip"; import { createTar, parseTar } from "../../src/archive/tar"; function input(path: string, text: string) { return { path, file: new File( [text], path.split("/").at(-1)!, ) as unknown as globalThis.File, }; } describe("TAR, USTAR, PAX and gzip", () => { it("creates deterministic TAR and parses regular entries", async () => { const inputs = [input("b.txt", "second"), input("folder/a.txt", "first")]; const first = await createTar(inputs); const second = await createTar(inputs); expect(first).toEqual(second); const parsed = parseTar(first); expect(parsed.entries.map((entry) => entry.path)).toEqual([ "b.txt", "folder/a.txt", ]); expect(parsed.entries.every((entry) => entry.extractable)).toBe(true); expect(parsed.entries[1]?.crc32).toBe("9271ee57"); }); it("round-trips a long path through a bounded PAX path header", async () => { const longPath = `${"long-".repeat(24)}name.txt`; const tar = await createTar([input(longPath, "PAX")]); expect(parseTar(tar).entries[0]?.path).toBe(longPath); }); it("lists but blocks symbolic links", async () => { const tar = await createTar([input("link", "target")]); tar[156] = 0x32; rewriteChecksum(tar.subarray(0, 512)); const entry = parseTar(tar).entries[0]!; expect(entry.kind).toBe("symlink"); expect(entry.extractable).toBe(false); expect(entry.issues.map((issue) => issue.code)).toContain("SPECIAL_ENTRY"); }); it("rejects a damaged TAR header checksum", async () => { const tar = await createTar([input("file.txt", "data")]); tar[0] = tar[0]! ^ 1; expect(() => parseTar(tar)).toThrow(/checksum/iu); }); it("rejects ambiguous non-zero data after the TAR end marker", async () => { const tar = await createTar([input("file.txt", "data")]); const appended = new Uint8Array(tar.length + 1); appended.set(tar); appended[tar.length] = 1; expect(() => parseTar(appended)).toThrow(/follows the TAR end/iu); }); it("creates deterministic gzip and validates CRC and ISIZE", () => { const source = new TextEncoder().encode("local archive payload"); const first = gzipDeterministic(source); expect(first).toEqual(gzipDeterministic(source)); expect(gunzipBounded(first, 1024)).toEqual(source); first[first.length - 8] = first[first.length - 8]! ^ 1; expect(() => gunzipBounded(first, 1024)).toThrow(/CRC-32|footer/iu); }); it("stops gzip expansion at the configured operation limit", () => { const compressed = gzipDeterministic(new Uint8Array(32_768)); expect(() => gunzipBounded(compressed, 1024)).toThrow(/expanded-byte/iu); }); it("decodes concatenated gzip members and validates each footer", () => { const left = gzipDeterministic(new TextEncoder().encode("left")); const right = gzipDeterministic(new TextEncoder().encode("right")); const joined = new Uint8Array(left.length + right.length); joined.set(left); joined.set(right, left.length); expect(new TextDecoder().decode(gunzipBounded(joined, 1024))).toBe( "leftright", ); expect(gunzipBoundedDetailed(joined, 1024).members).toMatchObject([ { index: 0, expandedBytes: 4 }, { index: 1, expandedBytes: 5 }, ]); joined[left.length - 8] = joined[left.length - 8]! ^ 1; expect(() => gunzipBounded(joined, 1024)).toThrow(/member 1/iu); }); it("reads bounded GNU long-name and long-link metadata", async () => { const regular = await createTar([input("placeholder", "data")]); const longPath = `${"gnu/".repeat(35)}entry.txt`; const named = prependGnuMetadata(regular, 0x4c, longPath); expect(parseTar(named).entries[0]?.path).toBe(longPath); const linkTarget = `${"target/".repeat(24)}file.txt`; const linked = prependGnuMetadata(regular.slice(), 0x4b, linkTarget); linked[512 + paddedPayloadSize(linkTarget) + 156] = 0x32; rewriteChecksum( linked.subarray( 512 + paddedPayloadSize(linkTarget), 1024 + paddedPayloadSize(linkTarget), ), ); expect(parseTar(linked).entries[0]).toMatchObject({ kind: "symlink", linkTarget, extractable: false, }); }); }); function paddedPayloadSize(value: string): number { return ( Math.ceil(new TextEncoder().encode(`${value}\0`).byteLength / 512) * 512 ); } function prependGnuMetadata( tar: Uint8Array, type: number, value: string, ): Uint8Array { const payload = new TextEncoder().encode(`${value}\0`); const padded = Math.ceil(payload.byteLength / 512) * 512; const output = new Uint8Array(512 + padded + tar.byteLength); const header = output.subarray(0, 512); header.set(new TextEncoder().encode("././@LongLink"), 0); header.set(new TextEncoder().encode("0000644\0"), 100); header.set(new TextEncoder().encode("0000000\0"), 108); header.set(new TextEncoder().encode("0000000\0"), 116); header.set( new TextEncoder().encode( `${payload.byteLength.toString(8).padStart(11, "0")}\0`, ), 124, ); header.set(new TextEncoder().encode("00000000000\0"), 136); header[156] = type; header.set(new TextEncoder().encode("ustar\0"), 257); header.set(new TextEncoder().encode("00"), 263); rewriteChecksum(header); output.set(payload, 512); output.set(tar, 512 + padded); return output; } function rewriteChecksum(header: Uint8Array) { header.fill(0x20, 148, 156); const checksum = header.reduce((sum, byte) => sum + byte, 0); const text = checksum.toString(8).padStart(6, "0"); header.set(new TextEncoder().encode(text), 148); header[154] = 0; header[155] = 0x20; }