163 lines
5.7 KiB
TypeScript
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;
|
|
}
|