@@ -0,0 +1,277 @@
|
||||
// @vitest-environment node
|
||||
import { File as NodeFile } from "node:buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { crc32 } from "../../src/archive/checksums";
|
||||
import { inspectArchive, reportJson } from "../../src/archive/service";
|
||||
|
||||
const asFile = (bytes: Uint8Array, name: string) =>
|
||||
new NodeFile([new Uint8Array(bytes).buffer], name) as unknown as File;
|
||||
|
||||
describe("bounded 7z structural inspection", () => {
|
||||
it("validates both start CRCs and inventories plain FilesInfo names", async () => {
|
||||
const names = utf16le("alpha.txt\0folder\0");
|
||||
const next = bytes(
|
||||
[
|
||||
0x01,
|
||||
0x05,
|
||||
0x02,
|
||||
0x0e,
|
||||
0x01,
|
||||
0x40,
|
||||
0x0f,
|
||||
0x01,
|
||||
0x00,
|
||||
0x11,
|
||||
names.length + 1,
|
||||
0x00,
|
||||
],
|
||||
names,
|
||||
[0x00, 0x00],
|
||||
);
|
||||
const document = await inspectArchive(asFile(sevenZip(next), "plain.7z"));
|
||||
expect(document.structural).toMatchObject({
|
||||
parser: "7z",
|
||||
headerCrcs: { verified: 2, failed: 0 },
|
||||
nextHeader: { kind: "plain", metadataParsed: true },
|
||||
});
|
||||
expect(
|
||||
document.entries.map(({ path, kind, sizeKnown, extractable }) => ({
|
||||
path,
|
||||
kind,
|
||||
sizeKnown,
|
||||
extractable,
|
||||
})),
|
||||
).toEqual([
|
||||
{ path: "alpha.txt", kind: "file", sizeKnown: false, extractable: false },
|
||||
{
|
||||
path: "folder/",
|
||||
kind: "directory",
|
||||
sizeKnown: true,
|
||||
extractable: false,
|
||||
},
|
||||
]);
|
||||
expect(reportJson(document)).toContain(
|
||||
"packed streams, codec chains, passwords and extraction are unsupported",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an encoded next header without decoding it", async () => {
|
||||
const document = await inspectArchive(
|
||||
asFile(sevenZip(new Uint8Array([0x17])), "encoded.7z"),
|
||||
);
|
||||
expect(document.entries).toEqual([]);
|
||||
expect(document.structural?.nextHeader).toMatchObject({
|
||||
kind: "encoded",
|
||||
metadataParsed: false,
|
||||
});
|
||||
expect(document.issues.map((issue) => issue.code)).toContain(
|
||||
"SEVEN_ZIP_ENCODED_HEADER",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on a corrupt next-header CRC", async () => {
|
||||
const input = sevenZip(new Uint8Array([0x17]));
|
||||
input[input.length - 1] = input[input.length - 1]! ^ 1;
|
||||
await expect(inspectArchive(asFile(input, "corrupt.7z"))).rejects.toThrow(
|
||||
/next-header CRC/iu,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded RAR structural inspection", () => {
|
||||
it("validates RAR5 blocks and inventories a stored file", async () => {
|
||||
const name = new TextEncoder().encode("hello.txt");
|
||||
const archive = bytes(
|
||||
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00],
|
||||
rar5Block([0x01, 0x00, 0x00]),
|
||||
rar5Block(
|
||||
[0x02, 0x1b, 0x02, 0x03, 0x00, 0x03, 0x00, 0x00, 0x00, name.length],
|
||||
bytes(name, [0x01, 0x01]),
|
||||
[1, 2, 3],
|
||||
),
|
||||
rar5Block([0x05, 0x00, 0x00]),
|
||||
);
|
||||
const document = await inspectArchive(asFile(archive, "sample.rar"));
|
||||
expect(document.structural).toMatchObject({
|
||||
parser: "rar5",
|
||||
blocks: 3,
|
||||
ended: true,
|
||||
});
|
||||
expect(document.entries[0]).toMatchObject({
|
||||
path: "hello.txt",
|
||||
size: 3,
|
||||
compressedSize: 3,
|
||||
compression: "RAR5 stored",
|
||||
encrypted: true,
|
||||
extractable: false,
|
||||
});
|
||||
expect(document.entries[0]?.issues.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining(["RAR_ENCRYPTED_ENTRY", "RAR_SPLIT_ENTRY"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates RAR4 blocks and inventories legacy file metadata", async () => {
|
||||
const name = new TextEncoder().encode("legacy.txt");
|
||||
const fileBody = new Uint8Array(25 + name.length);
|
||||
const view = new DataView(fileBody.buffer);
|
||||
view.setUint32(0, 3, true);
|
||||
view.setUint32(4, 3, true);
|
||||
view.setUint32(9, 0x352441c2, true);
|
||||
fileBody[17] = 20;
|
||||
fileBody[18] = 0x30;
|
||||
view.setUint16(19, name.length, true);
|
||||
name.forEach((byte, index) => (fileBody[25 + index] = byte));
|
||||
const archive = bytes(
|
||||
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00],
|
||||
rar4Block(0x73, 0, new Uint8Array(6)),
|
||||
rar4Block(0x74, 0x8000, fileBody, new Uint8Array([1, 2, 3])),
|
||||
rar4Block(0x7b, 0, new Uint8Array()),
|
||||
);
|
||||
const document = await inspectArchive(asFile(archive, "legacy.rar"));
|
||||
expect(document.structural).toMatchObject({
|
||||
parser: "rar4",
|
||||
blocks: 3,
|
||||
ended: true,
|
||||
});
|
||||
expect(document.entries[0]).toMatchObject({
|
||||
path: "legacy.txt",
|
||||
size: 3,
|
||||
compressedSize: 3,
|
||||
compression: "RAR4 stored",
|
||||
extractable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops safely at an encrypted RAR5 header and rejects bad header CRCs", async () => {
|
||||
const main = rar5Block([0x01, 0x00, 0x00]);
|
||||
const encryptedHeader = rar5Block([0x04, 0x00, 0x00]);
|
||||
const encrypted = bytes(
|
||||
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00],
|
||||
main,
|
||||
encryptedHeader,
|
||||
new Uint8Array([1, 2, 3]),
|
||||
);
|
||||
const document = await inspectArchive(
|
||||
asFile(encrypted, "headers-encrypted.rar"),
|
||||
);
|
||||
expect(document.structural).toMatchObject({
|
||||
encryptedHeaders: true,
|
||||
ended: false,
|
||||
});
|
||||
expect(document.issues.map((issue) => issue.code)).toContain(
|
||||
"RAR5_ENCRYPTED_HEADERS",
|
||||
);
|
||||
|
||||
const corrupt = bytes(
|
||||
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00],
|
||||
main,
|
||||
);
|
||||
corrupt[8] = corrupt[8]! ^ 1;
|
||||
await expect(
|
||||
inspectArchive(asFile(corrupt, "corrupt.rar")),
|
||||
).rejects.toThrow(/CRC-32/iu);
|
||||
await expect(
|
||||
inspectArchive(
|
||||
asFile(
|
||||
bytes([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00], [1, 2, 3]),
|
||||
"truncated.rar",
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/truncated/iu);
|
||||
});
|
||||
|
||||
it("stops safely before encrypted RAR4 file headers", async () => {
|
||||
const archive = bytes(
|
||||
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00],
|
||||
rar4Block(0x73, 0x0080, new Uint8Array(6)),
|
||||
[9, 9, 9],
|
||||
);
|
||||
const document = await inspectArchive(
|
||||
asFile(archive, "rar4-encrypted-headers.rar"),
|
||||
);
|
||||
expect(document.entries).toEqual([]);
|
||||
expect(document.structural).toMatchObject({
|
||||
parser: "rar4",
|
||||
encryptedHeaders: true,
|
||||
ended: false,
|
||||
});
|
||||
expect(document.issues.map((issue) => issue.code)).toContain(
|
||||
"RAR4_ENCRYPTED_HEADERS",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function sevenZip(next: Uint8Array): Uint8Array {
|
||||
const result = new Uint8Array(32 + next.length);
|
||||
result.set([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0x00, 0x04]);
|
||||
const view = new DataView(result.buffer);
|
||||
view.setBigUint64(12, 0n, true);
|
||||
view.setBigUint64(20, BigInt(next.length), true);
|
||||
view.setUint32(28, crc32(next), true);
|
||||
view.setUint32(8, crc32(result.subarray(12, 32)), true);
|
||||
result.set(next, 32);
|
||||
return result;
|
||||
}
|
||||
|
||||
function rar5Block(
|
||||
body: number[] | Uint8Array,
|
||||
suffix?: Uint8Array,
|
||||
data?: number[] | Uint8Array,
|
||||
): Uint8Array {
|
||||
const headerBody = bytes(body, suffix ?? new Uint8Array());
|
||||
const size = vint(headerBody.length);
|
||||
const crcInput = bytes(size, headerBody);
|
||||
const header = new Uint8Array(4 + crcInput.length);
|
||||
new DataView(header.buffer).setUint32(0, crc32(crcInput), true);
|
||||
header.set(crcInput, 4);
|
||||
return bytes(header, data ?? new Uint8Array());
|
||||
}
|
||||
|
||||
function rar4Block(
|
||||
type: number,
|
||||
flags: number,
|
||||
body: Uint8Array,
|
||||
data = new Uint8Array(),
|
||||
): Uint8Array {
|
||||
const header = new Uint8Array(7 + body.length);
|
||||
const view = new DataView(header.buffer);
|
||||
header[2] = type;
|
||||
view.setUint16(3, flags, true);
|
||||
view.setUint16(5, header.length, true);
|
||||
header.set(body, 7);
|
||||
view.setUint16(0, crc32(header.subarray(2)) & 0xffff, true);
|
||||
return bytes(header, data);
|
||||
}
|
||||
|
||||
function vint(value: number): Uint8Array {
|
||||
const result: number[] = [];
|
||||
do {
|
||||
const next = value & 0x7f;
|
||||
value >>>= 7;
|
||||
result.push(value ? next | 0x80 : next);
|
||||
} while (value);
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
|
||||
function utf16le(value: string): Uint8Array {
|
||||
const result = new Uint8Array(value.length * 2);
|
||||
const view = new DataView(result.buffer);
|
||||
for (let index = 0; index < value.length; index += 1)
|
||||
view.setUint16(index * 2, value.charCodeAt(index), true);
|
||||
return result;
|
||||
}
|
||||
|
||||
function bytes(...parts: (number[] | Uint8Array)[]): Uint8Array {
|
||||
const arrays = parts.map((part) =>
|
||||
part instanceof Uint8Array ? part : new Uint8Array(part),
|
||||
);
|
||||
const result = new Uint8Array(
|
||||
arrays.reduce((sum, part) => sum + part.length, 0),
|
||||
);
|
||||
let offset = 0;
|
||||
for (const part of arrays) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// @vitest-environment node
|
||||
import { File } from "node:buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gzipDeterministic, gunzipBounded } from "../../src/archive/gzip";
|
||||
import {
|
||||
gzipDeterministic,
|
||||
gunzipBounded,
|
||||
gunzipBoundedDetailed,
|
||||
} from "../../src/archive/gzip";
|
||||
import { createTar, parseTar } from "../../src/archive/tar";
|
||||
|
||||
function input(path: string, text: string) {
|
||||
@@ -73,16 +77,81 @@ describe("TAR, USTAR, PAX and gzip", () => {
|
||||
expect(() => gunzipBounded(compressed, 1024)).toThrow(/expanded-byte/iu);
|
||||
});
|
||||
|
||||
it("explicitly rejects concatenated gzip members", () => {
|
||||
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(() => gunzipBounded(joined, 1024)).toThrow(/multi-member/iu);
|
||||
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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createSafeSelectionZip,
|
||||
inspectArchive,
|
||||
readEntryBytes,
|
||||
detectArchiveFormat,
|
||||
} from "../../src/archive/service";
|
||||
|
||||
const asFile = (
|
||||
@@ -18,6 +19,26 @@ const asFile = (
|
||||
) => new NodeFile(parts, name, { type }) as unknown as globalThis.File;
|
||||
|
||||
describe("ZIP inspection and safe workflows", () => {
|
||||
it("identifies structural-only 7z, RAR4 and RAR5 inputs", () => {
|
||||
expect(
|
||||
detectArchiveFormat(
|
||||
"archive.bin",
|
||||
new Uint8Array([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]),
|
||||
),
|
||||
).toBe("7z");
|
||||
expect(
|
||||
detectArchiveFormat(
|
||||
"archive.bin",
|
||||
new Uint8Array([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00]),
|
||||
),
|
||||
).toBe("rar4");
|
||||
expect(
|
||||
detectArchiveFormat(
|
||||
"archive.bin",
|
||||
new Uint8Array([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]),
|
||||
),
|
||||
).toBe("rar5");
|
||||
});
|
||||
it("creates byte-deterministic ZIPs, reads CRC-checked content and previews text", async () => {
|
||||
const files = [
|
||||
asFile(["hello"], "hello.txt"),
|
||||
@@ -41,7 +62,7 @@ describe("ZIP inspection and safe workflows", () => {
|
||||
).resolves.toMatchObject({ kind: "text", text: "hello" });
|
||||
});
|
||||
|
||||
it("blocks traversal, case-colliding and encrypted entries at inspection", async () => {
|
||||
it("blocks unsafe paths while opening AES entries only with a password", async () => {
|
||||
const blobWriter = new BlobWriter("application/zip");
|
||||
const writer = new ZipWriter(blobWriter, { useWebWorkers: false });
|
||||
await writer.add("../escape.txt", new TextReader("bad"), {
|
||||
@@ -67,12 +88,62 @@ describe("ZIP inspection and safe workflows", () => {
|
||||
expect(document.entries[2]?.issues.map((issue) => issue.code)).toContain(
|
||||
"DUPLICATE_PATH",
|
||||
);
|
||||
expect(document.entries[3]?.issues.map((issue) => issue.code)).toContain(
|
||||
"ENCRYPTED_ENTRY",
|
||||
);
|
||||
expect(document.entries.filter((entry) => entry.extractable)).toEqual([]);
|
||||
expect(document.entries[3]).toMatchObject({
|
||||
encrypted: true,
|
||||
encryption: "AES-256",
|
||||
extractable: true,
|
||||
});
|
||||
await expect(
|
||||
readEntryBytes(document, document.entries[3]!, 1024),
|
||||
).rejects.toThrow(/password is required/iu);
|
||||
await expect(
|
||||
readEntryBytes(document, document.entries[3]!, 1024, undefined, "wrong"),
|
||||
).rejects.toThrow(/incorrect|damaged/iu);
|
||||
expect(
|
||||
new TextDecoder().decode(
|
||||
await readEntryBytes(
|
||||
document,
|
||||
document.entries[3]!,
|
||||
1024,
|
||||
undefined,
|
||||
"test-password",
|
||||
),
|
||||
),
|
||||
).toBe("secret");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["aes-256", "AES-256"],
|
||||
["zipcrypto", "ZipCrypto"],
|
||||
] as const)(
|
||||
"creates and reads %s encrypted ZIPs locally",
|
||||
async (method, label) => {
|
||||
const encrypted = await createArchive(
|
||||
[asFile(["classified"], "secret.txt")],
|
||||
"zip",
|
||||
undefined,
|
||||
undefined,
|
||||
{ password: "correct horse battery staple", method },
|
||||
);
|
||||
const document = await inspectArchive(
|
||||
asFile([await encrypted.arrayBuffer()], `${method}.zip`),
|
||||
);
|
||||
expect(document.entries[0]).toMatchObject({
|
||||
encrypted: true,
|
||||
encryption: label,
|
||||
extractable: true,
|
||||
});
|
||||
const bytes = await readEntryBytes(
|
||||
document,
|
||||
document.entries[0]!,
|
||||
1024,
|
||||
undefined,
|
||||
"correct horse battery staple",
|
||||
);
|
||||
expect(new TextDecoder().decode(bytes)).toBe("classified");
|
||||
},
|
||||
);
|
||||
|
||||
it("enforces compression-ratio policy before decompression", async () => {
|
||||
const blob = await createArchive(
|
||||
[asFile([new Uint8Array(1024 * 1024)], "zeros.bin")],
|
||||
|
||||
Reference in New Issue
Block a user