266 lines
8.8 KiB
TypeScript
266 lines
8.8 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { Buffer } from "node:buffer";
|
|
import { strToU8, zipSync } from "fflate";
|
|
|
|
const ORIGIN = "http://127.0.0.1:4173";
|
|
async function localOnly(page: Page) {
|
|
const external: string[] = [];
|
|
await page.route("**/*", async (route) => {
|
|
const url = new URL(route.request().url());
|
|
if (url.origin !== ORIGIN) {
|
|
external.push(url.href);
|
|
await route.abort();
|
|
} else await route.continue();
|
|
});
|
|
return external;
|
|
}
|
|
|
|
test("runs from a nested path without external requests", async ({ page }) => {
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") errors.push(message.text());
|
|
});
|
|
const external = await localOnly(page);
|
|
await page.goto("/deep/nested/archive/");
|
|
await expect(
|
|
page.getByRole("heading", { name: "Archive Tools" }),
|
|
).toBeVisible();
|
|
expect(external).toEqual([]);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("serves the release identity and hardened headers", async ({
|
|
request,
|
|
}) => {
|
|
const index = await request.get("/deep/nested/archive/");
|
|
expect(index.ok()).toBe(true);
|
|
expect(index.headers()["content-security-policy"]).toContain(
|
|
"default-src 'self'",
|
|
);
|
|
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
|
const manifest = await request.get("/deep/nested/archive/toolbox-app.json");
|
|
await expect(manifest.json()).resolves.toMatchObject({
|
|
id: "de.add-ideas.archive-tools",
|
|
version: "0.2.0",
|
|
entry: "./",
|
|
requirements: { workers: false },
|
|
});
|
|
});
|
|
|
|
test("inspects, previews and safely repackages a ZIP locally", async ({
|
|
page,
|
|
}) => {
|
|
const external = await localOnly(page);
|
|
await page.goto("/deep/nested/archive/");
|
|
const archive = zipSync({
|
|
"hello.txt": strToU8("Hello from a local ZIP"),
|
|
"folder/data.bin": new Uint8Array([0, 1, 2, 3]),
|
|
});
|
|
await page.getByLabel("Choose archive").setInputFiles({
|
|
name: "sample.zip",
|
|
mimeType: "application/zip",
|
|
buffer: Buffer.from(archive),
|
|
});
|
|
await expect(page.getByText("Inspected 2 entries locally.")).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "hello.txt" })).toBeVisible();
|
|
await page.getByRole("button", { name: "hello.txt" }).click();
|
|
await expect(page.getByText("Hello from a local ZIP")).toBeVisible();
|
|
await page.getByRole("checkbox", { name: "Select hello.txt" }).check();
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await page
|
|
.getByRole("button", { name: "Verify & download safe ZIP" })
|
|
.click();
|
|
expect((await downloadPromise).suggestedFilename()).toBe(
|
|
"sample-safe-selection.zip",
|
|
);
|
|
expect(external).toEqual([]);
|
|
});
|
|
|
|
test("shows validated RAR5 structural evidence without offering extraction", async ({
|
|
page,
|
|
}) => {
|
|
const external = await localOnly(page);
|
|
await page.goto("/deep/nested/archive/");
|
|
const name = new TextEncoder().encode("evidence.txt");
|
|
const archive = concatBytes(
|
|
new Uint8Array([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]),
|
|
rar5FixtureBlock(new Uint8Array([0x01, 0x00, 0x00])),
|
|
rar5FixtureBlock(
|
|
concatBytes(
|
|
new Uint8Array([
|
|
0x02,
|
|
0x02,
|
|
0x03,
|
|
0x00,
|
|
0x03,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
name.length,
|
|
]),
|
|
name,
|
|
),
|
|
new Uint8Array([1, 2, 3]),
|
|
),
|
|
rar5FixtureBlock(new Uint8Array([0x05, 0x00, 0x00])),
|
|
);
|
|
await page.getByLabel("Choose archive").setInputFiles({
|
|
name: "evidence.rar",
|
|
mimeType: "application/vnd.rar",
|
|
buffer: Buffer.from(archive),
|
|
});
|
|
await expect(page.getByText("Inspected 1 entries locally.")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("row", { name: /evidence\.txt/iu }),
|
|
).toContainText("RAR entries are inventory-only");
|
|
await page.getByText("Structural inspection evidence").click();
|
|
await expect(page.getByText("3 verified")).toBeVisible();
|
|
await expect(
|
|
page.getByRole("checkbox", { name: "Select evidence.txt" }),
|
|
).toBeDisabled();
|
|
expect(external).toEqual([]);
|
|
});
|
|
|
|
test("blocks traversal and case-colliding ZIP paths", async ({ page }) => {
|
|
await page.goto("/deep/nested/archive/");
|
|
const archive = zipSync({
|
|
"../escape.txt": strToU8("bad"),
|
|
"Report.txt": strToU8("one"),
|
|
"report.TXT": strToU8("two"),
|
|
});
|
|
await page.getByLabel("Choose archive").setInputFiles({
|
|
name: "unsafe.zip",
|
|
mimeType: "application/zip",
|
|
buffer: Buffer.from(archive),
|
|
});
|
|
await expect(
|
|
page.getByText("Parent-directory path segments are blocked."),
|
|
).toBeVisible();
|
|
await expect(page.getByText(/Path collides with entry/u)).toHaveCount(2);
|
|
await expect(
|
|
page.getByRole("checkbox", { name: "Select escape.txt" }),
|
|
).toBeDisabled();
|
|
await expect(
|
|
page.getByRole("checkbox", { name: "Select Report.txt", exact: true }),
|
|
).toBeDisabled();
|
|
});
|
|
|
|
test("creates a TAR and compares archive inventories", async ({ page }) => {
|
|
await page.goto("/deep/nested/archive/");
|
|
await page.getByRole("button", { name: "Create" }).click();
|
|
await page.getByLabel("Files").setInputFiles([
|
|
{
|
|
name: "alpha.txt",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("alpha"),
|
|
},
|
|
{
|
|
name: "beta.txt",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("beta"),
|
|
},
|
|
]);
|
|
await page.getByLabel("Output name").fill("browser-created");
|
|
await page.getByLabel("Format").selectOption("tar");
|
|
const createdPromise = page.waitForEvent("download");
|
|
await page.getByRole("button", { name: "Create & download" }).click();
|
|
const created = await createdPromise;
|
|
expect(created.suggestedFilename()).toBe("browser-created.tar");
|
|
|
|
await page.getByRole("button", { name: "Inspect & extract" }).click();
|
|
await page.getByLabel("Choose archive").setInputFiles(await created.path());
|
|
await expect(page.getByText("Inspected 2 entries locally.")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Compare" }).click();
|
|
const left = zipSync({
|
|
"same.txt": strToU8("same"),
|
|
"changed.txt": strToU8("old"),
|
|
});
|
|
const right = zipSync({
|
|
"same.txt": strToU8("same"),
|
|
"changed.txt": strToU8("new"),
|
|
});
|
|
await page.getByLabel("Left archive").setInputFiles({
|
|
name: "left.zip",
|
|
mimeType: "application/zip",
|
|
buffer: Buffer.from(left),
|
|
});
|
|
await page.getByLabel("Right archive").setInputFiles({
|
|
name: "right.zip",
|
|
mimeType: "application/zip",
|
|
buffer: Buffer.from(right),
|
|
});
|
|
await page.getByRole("button", { name: "Compare locally" }).click();
|
|
await expect(
|
|
page.getByRole("row", { name: /changed\.txt changed/u }),
|
|
).toBeVisible();
|
|
await expect(page.getByRole("row", { name: /same\.txt/u })).toHaveCount(0);
|
|
await page.getByRole("checkbox", { name: "Show changes only" }).uncheck();
|
|
await expect(
|
|
page.getByRole("row", { name: /same\.txt same/u }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("creates and opens an AES-256 ZIP with a memory-only password", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/deep/nested/archive/");
|
|
await page.getByRole("button", { name: "Create" }).click();
|
|
await page.getByLabel("Files").setInputFiles({
|
|
name: "secret.txt",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("local secret"),
|
|
});
|
|
await page.getByLabel("Encryption").selectOption("aes-256");
|
|
await page.getByLabel("Password (memory only)").fill("correct horse");
|
|
const encryptedPromise = page.waitForEvent("download");
|
|
await page.getByRole("button", { name: "Create & download" }).click();
|
|
const encrypted = await encryptedPromise;
|
|
|
|
await page.getByRole("button", { name: "Inspect & extract" }).click();
|
|
await page.getByLabel("Choose archive").setInputFiles(await encrypted.path());
|
|
await expect(
|
|
page.getByRole("row", {
|
|
name: /secret\.txt.*AES-256.*password required/iu,
|
|
}),
|
|
).toBeVisible();
|
|
await page.getByLabel("ZIP password (memory only)").fill("correct horse");
|
|
await page.getByRole("button", { name: "secret.txt" }).click();
|
|
await expect(page.getByText("local secret")).toBeVisible();
|
|
});
|
|
|
|
function rar5FixtureBlock(
|
|
body: Uint8Array,
|
|
data = new Uint8Array(),
|
|
): Uint8Array {
|
|
const size = new Uint8Array([body.length]);
|
|
const crcInput = concatBytes(size, body);
|
|
const header = new Uint8Array(4 + crcInput.length);
|
|
new DataView(header.buffer).setUint32(0, fixtureCrc32(crcInput), true);
|
|
header.set(crcInput, 4);
|
|
return concatBytes(header, data);
|
|
}
|
|
|
|
function fixtureCrc32(input: Uint8Array): number {
|
|
let value = 0xffffffff;
|
|
for (const byte of input) {
|
|
value ^= byte;
|
|
for (let bit = 0; bit < 8; bit += 1)
|
|
value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0);
|
|
}
|
|
return (value ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
|
const output = new Uint8Array(
|
|
parts.reduce((total, part) => total + part.length, 0),
|
|
);
|
|
let offset = 0;
|
|
for (const part of parts) {
|
|
output.set(part, offset);
|
|
offset += part.length;
|
|
}
|
|
return output;
|
|
}
|