Files
archive-tools/tests/archive/tar-gzip.test.ts
T
zemion fe578f46bd
Verify / verify (push) Canceled after 0s
Release Archive Tools 0.2.0
2026-09-02 09:34:59 +02:00

163 lines
5.7 KiB
TypeScript

// @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;
}