Files
archive-tools/tests/archive/tar-gzip.test.ts
T
2026-09-01 02:42:42 +02:00

94 lines
3.5 KiB
TypeScript

// @vitest-environment node
import { File } from "node:buffer";
import { describe, expect, it } from "vitest";
import { gzipDeterministic, gunzipBounded } 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("explicitly rejects concatenated gzip members", () => {
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(() => gunzipBounded(joined, 1024)).toThrow(/multi-member/iu);
});
});
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;
}