Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"adm-zip": "^0.6.0",
"citty": "^0.2.1",
"compare-versions": "^6.1.1",
"css-tree": "^3.2.1",
"debug": "^4.4.0",
"esbuild": "^0.25.12",
"fontkit": "^2.0.4",
Expand All @@ -59,6 +60,7 @@
"@hyperframes/producer": "workspace:*",
"@hyperframes/studio": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
"@types/css-tree": "^3.2.0",
"@types/fontkit": "^2.0.9",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.0.10",
Expand Down
184 changes: 177 additions & 7 deletions packages/cli/src/capture/assetDownloader.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
Expand Down Expand Up @@ -289,7 +289,20 @@ describe("drop counts — why a referenced asset is not in the capture", () => {
await withTempDir(async (dir) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(new Uint8Array(2048), { status: 200 })),
vi.fn(
async () =>
new Response(
new Uint8Array(
readFileSync(
new URL(
"../../../../docs/public/catalog/assets/a634cb9e7783af7e.woff2",
import.meta.url,
),
),
),
{ status: 200 },
),
),
);
const { css, drops } = await downloadAndRewriteFonts(fontCss(1), dir);
expect(css).toContain("assets/fonts/font-0.woff2");
Expand All @@ -303,11 +316,21 @@ describe("drop counts — why a referenced asset is not in the capture", () => {
});

it("counts an image dropped for being under the raster floor", async () => {
// 9 KB is under the 10 KB floor. Nothing lands, and the reason is now on the record.
// A valid small PNG is under the 10 KB floor; preserve the recorded drop reason.
await withTempDir(async (dir) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(new Uint8Array(9000), { status: 200 })),
vi.fn(
async () =>
new Response(
new Uint8Array(
await sharp({ create: { width: 2, height: 2, channels: 4, background: "red" } })
.png()
.toBuffer(),
),
{ status: 200 },
),
),
);
const { assets, drops } = await downloadAssets(tokensWithNoSvgs(), dir, [
{ type: "Image", url: "https://cdn.example/hero.png", contexts: ["img[src]"] },
Expand Down Expand Up @@ -374,7 +397,8 @@ describe("asset fetches present the same identity as the page navigation", () =>
headers: { "content-type": "text/html" },
});
}
const body = new Uint8Array(4096);
const body =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24"/></svg>';
return new Response(body, { status: 200, headers: { "content-type": "image/svg+xml" } });
});
}
Expand Down Expand Up @@ -404,6 +428,22 @@ describe("asset fetches present the same identity as the page navigation", () =>
});
});

async function testIco(): Promise<Buffer> {
const png = await sharp({ create: { width: 16, height: 16, channels: 4, background: "red" } })
.png()
.toBuffer();
const header = Buffer.alloc(22);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(1, 4);
header[6] = 16;
header[7] = 16;
header.writeUInt16LE(1, 10);
header.writeUInt16LE(32, 12);
header.writeUInt32LE(png.length, 14);
header.writeUInt32LE(22, 18);
return Buffer.concat([header, png]);
}

describe("declared icons — keep them all, headline the bare mark", () => {
afterEach(() => vi.unstubAllGlobals());

Expand Down Expand Up @@ -451,7 +491,7 @@ describe("declared icons — keep them all, headline the bare mark", () => {
"fetch",
serve({
"favicon.svg": BADGE_SVG,
"favicon.ico": Buffer.from("not a decodable ico"),
"favicon.ico": await testIco(),
"apple-icon.png": await solidPng(1),
}),
);
Expand All @@ -474,7 +514,7 @@ describe("declared icons — keep them all, headline the bare mark", () => {
"fetch",
serve({
"favicon.svg": BADGE_SVG,
"favicon.ico": Buffer.from("not a decodable ico"),
"favicon.ico": await testIco(),
"apple-icon.png": await solidPng(1),
}),
);
Expand Down Expand Up @@ -537,3 +577,133 @@ describe("declared icons — keep them all, headline the bare mark", () => {
});
});
});

describe("capture download security boundaries", () => {
it("preserves two different fonts whose URL extensions canonicalize to the same name", async () => {
await withTempDir(async (dir) => {
const first = readFileSync(
new URL("../../../../docs/public/catalog/assets/a634cb9e7783af7e.woff2", import.meta.url),
);
const second = readFileSync(
new URL("../../../../docs/public/catalog/assets/8963f64fa28dc4ae.woff2", import.meta.url),
);
expect(first.equals(second)).toBe(false);
vi.stubGlobal(
"fetch",
vi.fn(
async (url: string) =>
new Response(new Uint8Array(url.endsWith(".ttf") ? first : second)),
),
);
const css =
"@font-face{font-family:A;src:url(https://fonts.example/site.ttf)} @font-face{font-family:B;src:url(https://fonts.example/site.woff2)}";
const result = await downloadAndRewriteFonts(css, dir);
expect(result.css).toContain("assets/fonts/site.woff2");
expect(result.css).toContain("assets/fonts/site-2.woff2");
expect(readFileSync(join(dir, "assets/fonts/site.woff2"))).toEqual(first);
expect(readFileSync(join(dir, "assets/fonts/site-2.woff2"))).toEqual(second);
});
});

it("shares the capture byte budget between fonts and icons", async () => {
await withTempDir(async (dir) => {
const bytes = readFileSync(
new URL("../../../../docs/public/catalog/assets/a634cb9e7783af7e.woff2", import.meta.url),
);
const fetchMock = vi.fn(async () => new Response(new Uint8Array(bytes)));
vi.stubGlobal("fetch", fetchMock);
const byteBudget = { remainingBytes: bytes.length };
await downloadAndRewriteFonts(
"@font-face{font-family:A;src:url(https://fonts.example/site.woff2)}",
dir,
{ byteBudget },
);
const result = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS, {
byteBudget,
});
expect(result.icons.icons).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(byteBudget.remainingBytes).toBe(0);
});
});

afterEach(() => vi.unstubAllGlobals());

it("canonicalizes an icon ADS suffix and its promoted copy while preserving SVG bytes", async () => {
await withTempDir(async (dir) => {
const body =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24"/></svg>';
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(body)),
);
const { icons } = await downloadAssets(
tokensWithNoSvgs(),
dir,
[],
[
{
rel: "icon",
href: "https://public.example/favicon.svg:payload",
sizes: null,
type: null,
},
],
);
expect(icons.icons[0]?.file).toBe("assets/icon-icon-unsized.svg");
expect(icons.headline?.file).toBe("assets/favicon.svg");
expect(readFileSync(join(dir, "assets/favicon.svg"), "utf8")).toBe(body);
});
});

it("does not publish arbitrary bytes as an OG image", async () => {
await withTempDir(async (dir) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(new Uint8Array(6000).fill(65))),
);
const tokens = tokensWithNoSvgs();
tokens.ogImage = "https://public.example/og.jpg::$DATA";
const { assets, drops } = await downloadAssets(tokens, dir);
expect(assets).toEqual([]);
expect(drops.unavailable).toBe(1);
expect(readdirSync(join(dir, "assets"))).not.toContain("og-image.jpg::$DATA");
});
});

it.each(["font.woff2:ads", "CON.woff2"])(
"writes valid font %s under a safe name and rewrites CSS",
async (name) => {
await withTempDir(async (dir) => {
const bytes = readFileSync(
new URL("../../../../docs/public/catalog/assets/a634cb9e7783af7e.woff2", import.meta.url),
);
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(new Uint8Array(bytes))),
);
const source = `@font-face{font-family:Demo;src:url(https://public.example/${name})}`;
const result = await downloadAndRewriteFonts(source, dir);
const names = readdirSync(join(dir, "assets/fonts"));
expect(names).toHaveLength(1);
expect(names[0]).not.toMatch(/:|^CON\./i);
expect(result.css).toContain(`assets/fonts/${names[0]}`);
expect(readFileSync(join(dir, "assets/fonts", names[0]!))).toEqual(bytes);
});
},
);

it("leaves invalid font URLs unchanged and records their rejection", async () => {
await withTempDir(async (dir) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(new Uint8Array(2048))),
);
const source = "@font-face{font-family:Demo;src:url(https://public.example/font.woff2)}";
const result = await downloadAndRewriteFonts(source, dir);
expect(result.css).toBe(source);
expect(result.drops.unavailable).toBe(1);
expect(readdirSync(join(dir, "assets/fonts"))).toEqual([]);
});
});
});
Loading
Loading