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
48 changes: 12 additions & 36 deletions packages/cli/src/capture/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LottieDiscovery } from "./lottieDiscovery.js";
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
/**
* Website capture orchestrator.
Expand Down Expand Up @@ -84,6 +85,7 @@ export async function captureWebsite(
onPhase,
} = opts;

const downloadByteBudget = createCaptureDownloadBudget();
const warnings: string[] = [];
const progress = (stage: string, detail?: string) => {
onProgress?.(stage, detail);
Expand Down Expand Up @@ -204,51 +206,20 @@ export async function captureWebsite(

// Intercept network responses to detect Lottie JSON files
const discoveredLotties: DiscoveredLottie[] = [];
const lottieDiscovery = new LottieDiscovery();
// Layer 1 (passive video discovery): every direct-video URL the page fetches
// over the whole session (load / scroll / carousel rotation), independent of
// whether a <video> for it exists at snapshot time. captureVideoManifest
// downloads these (guarded) and merges them into the manifest.
const discoveredVideoUrls = new Set<string>();
// fallow-ignore-next-line complexity
page1.on("response", async (response) => {
page1.on("response", (response) => {
try {
const responseUrl = response.url();
if (/\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(responseUrl)) {
discoveredVideoUrls.add(responseUrl);
}
const contentType = response.headers()["content-type"] || "";
const isJsonUrl = responseUrl.endsWith(".json");
const isLottieUrl = responseUrl.endsWith(".lottie");
const isJson =
contentType.includes("application/json") || contentType.includes("text/plain");

if (isLottieUrl) {
discoveredLotties.push({ url: responseUrl });
return;
}

if (isJsonUrl || isJson) {
// Check Content-Length before downloading to avoid OOM on huge responses
const cl = parseInt(response.headers()["content-length"] || "0", 10);
if (cl > 5_000_000) return;
const buffer = await response.buffer();
if (buffer.length < 100 || buffer.length > 5_000_000) return; // Skip tiny or huge
const text = buffer.toString("utf-8");
const json = JSON.parse(text);
// Validate Lottie structure: must have version, in/out points, layers, dimensions, framerate
if (
json &&
typeof json === "object" &&
["v", "ip", "op", "layers", "w", "h", "fr"].every((k: string) => k in json)
) {
discoveredLotties.push({
url: responseUrl,
data: json,
dimensions: { w: json.w, h: json.h },
frameRate: json.fr,
});
}
}
lottieDiscovery.collect(response);
} catch {
/* not JSON or parse error — skip */
}
Expand Down Expand Up @@ -382,10 +353,16 @@ export async function captureWebsite(
/* DOM scan failed — non-critical */
}

for (const found of await lottieDiscovery.run(downloadByteBudget, remainingMs)) {
const existing = discoveredLotties.findIndex((item) => item.url === found.url);
if (existing < 0) discoveredLotties.push(found);
else discoveredLotties[existing] = found;
}

if (discoveredLotties.length > 0 && remainingMs() > 0) {
const lottieDir = join(outputDir, "assets", "lottie");
mkdirSync(lottieDir, { recursive: true });
const lottieBudget = { remainingMs };
const lottieBudget = { remainingMs, byteBudget: downloadByteBudget };
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, lottieBudget);
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
if (savedCount > 0 && remainingMs() > 0) {
Expand Down Expand Up @@ -609,7 +586,6 @@ export async function captureWebsite(
// `budget-exhausted` for every one of them replaces a warning string that could only ever
// say "some". A zero budget means it breaks on the first url, so this costs no network.
phase("fonts", "started");
const downloadByteBudget = createCaptureDownloadBudget();
const fontPass = await downloadAndRewriteFonts(extracted.headHtml, outputDir, {
remainingMs,
byteBudget: downloadByteBudget,
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/capture/lottieArchiveEntryLimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { readLottieArchive } from "./lottieValidation.js";

const getEntries = vi.hoisted(() => vi.fn(() => []));
vi.mock("adm-zip", () => ({
default: class {
getEntryCount() {
return 1000;
}
getEntries = getEntries;
},
}));

describe("Lottie archive entry allocation", () => {
it("rejects the EOCD count without materializing entries", () => {
expect(readLottieArchive(Buffer.from("archive metadata supplied by test"))).toBeNull();
expect(getEntries).not.toHaveBeenCalled();
});
});
59 changes: 59 additions & 0 deletions packages/cli/src/capture/lottieArchivePreflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** Bound raw central-directory names before adm-zip synthesizes their parent folders. */
export function preflightLottieArchive(bytes: Buffer): boolean {
const end = findDirectoryEnd(bytes);
if (end < 0) return false;
if (!unambiguousFooter(bytes, end)) return false;
const count = bytes.readUInt16LE(end + 10);
if (count > 256 || bytes.readUInt16LE(end + 8) !== count) return false;
if (bytes.readUInt32LE(end + 4) !== 0) return false; // no multi-disk archives
let offset = bytes.readUInt32LE(end + 16);
const directoryEnd = offset + bytes.readUInt32LE(end + 12);
if (directoryEnd > end) return false;
for (let i = 0; i < count; i++) {
const next = nextBoundedEntry(bytes, offset, directoryEnd);
if (next === null) return false;
offset = next;
}
return offset === directoryEnd;
}

function unambiguousFooter(bytes: Buffer, end: number): boolean {
const signature = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
if (bytes.lastIndexOf(signature) !== end) return false;
// adm-zip scans just before EOCD for alternate/ZIP64 records. This bounded
// archive format does not need those; do not let it select another directory.
const preceding = bytes.subarray(Math.max(0, end - 76), end);
return ![
signature,
Buffer.from([0x50, 0x4b, 0x06, 0x06]),
Buffer.from([0x50, 0x4b, 0x06, 0x07]),
].some((marker) => preceding.includes(marker));
}

function findDirectoryEnd(bytes: Buffer): number {
for (let at = bytes.length - 22; at >= Math.max(0, bytes.length - 65557); at--) {
if (
bytes.readUInt32LE(at) === 0x06054b50 &&
at + 22 + bytes.readUInt16LE(at + 20) === bytes.length
)
return at;
}
return -1;
}

function nextBoundedEntry(bytes: Buffer, offset: number, end: number): number | null {
if (offset + 46 > end || bytes.readUInt32LE(offset) !== 0x02014b50) return null;
const length = bytes.readUInt16LE(offset + 28);
const next =
offset + 46 + length + bytes.readUInt16LE(offset + 30) + bytes.readUInt16LE(offset + 32);
if (length > 512 || next > end) return null;
const name = bytes.toString("utf8", offset + 46, offset + 46 + length);
return safeLottieArchivePath(name) ? next : null;
}

export function safeLottieArchivePath(path: string): boolean {
if (path.length > 512 || path.split("/").length > 16) return false;
if (path.startsWith("/") || /[\\:]/.test(path)) return false;
if (Array.from(path).some((char) => char.charCodeAt(0) < 32)) return false;
return path.split("/").every((segment) => segment !== ".." && segment !== ".");
}
122 changes: 122 additions & 0 deletions packages/cli/src/capture/lottieDiscovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverLottieResponse, LottieDiscovery } from "./lottieDiscovery.js";

describe("bounded Lottie discovery", () => {
it("awaits delayed candidates and ignores response events after its save boundary", async () => {
const discovery = new LottieDiscovery();
const response = { url: () => "https://public.example/a.json", headers: () => ({}) };
discovery.collect(response);
discovery.collect(response);
let finish!: (response: Response) => void;
const fetchMock = vi.fn(
() =>
new Promise<Response>((resolve) => {
finish = resolve;
}),
);
vi.stubGlobal("fetch", fetchMock);
const budget = { remainingBytes: 1000 };
let settled = false;
const pending = discovery
.run(budget, () => 10000)
.then((result) => {
settled = true;
return result;
});
await Promise.resolve();
expect(settled).toBe(false);
finish(
new Response(JSON.stringify({ v: "5", w: 100, h: 100, fr: 30, ip: 0, op: 30, layers: [] })),
);
expect(await pending).toHaveLength(1);
const remaining = budget.remainingBytes;
discovery.collect({ url: () => "https://public.example/late.json", headers: () => ({}) });
expect(await discovery.run(budget, () => 10000)).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(budget.remainingBytes).toBe(remaining);
});

it("caps ordinary JSON candidates and their aggregate byte consumption", async () => {
const discovery = new LottieDiscovery();
for (let n = 0; n < 100; n++)
discovery.collect({
url: () => `https://public.example/api/${n}`,
headers: () => ({ "content-type": "application/json" }),
});
const fetchMock = vi.fn(async (_url: string | URL | Request) => new Response("{}"));
vi.stubGlobal("fetch", fetchMock);
const budget = { remainingBytes: 1000 };
expect(await discovery.run(budget, () => 10000)).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(32);
expect(budget.remainingBytes).toBe(936);
});
it("retains explicit JSON when a later archive displaces a generic candidate", async () => {
const discovery = new LottieDiscovery();
discovery.collect({ url: () => "https://public.example/real.json", headers: () => ({}) });
for (let n = 0; n < 31; n++)
discovery.collect({
url: () => `https://public.example/api/${n}`,
headers: () => ({ "content-type": "application/json" }),
});
discovery.collect({ url: () => "https://public.example/anim.lottie", headers: () => ({}) });
const fetchMock = vi.fn(async (_url: string | URL | Request) => new Response("{}"));
vi.stubGlobal("fetch", fetchMock);
await discovery.run({ remainingBytes: 1000 }, () => 10000);
const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toContain("https://public.example/real.json");
expect(urls).not.toContain("https://public.example/api/0");
expect(urls).toHaveLength(31);
});
afterEach(() => vi.unstubAllGlobals());
it.each([undefined, "1"])(
"does not trust intercepted Content-Length %s or materialize Puppeteer bodies",
async (length) => {
const buffer = vi.fn(() => {
throw new Error("unbounded body read");
});
const response = {
url: () => "https://public.example/animation.json",
headers: () => ({
"content-type": "application/json",
...(length ? { "content-length": length } : {}),
}),
buffer,
};
const cancel = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(4));
},
cancel,
}),
{ headers: length ? { "content-length": length } : {} },
),
),
);
expect(await discoverLottieResponse(response, { remainingBytes: 5 })).toBeNull();
expect(buffer).not.toHaveBeenCalled();
expect(cancel).toHaveBeenCalledOnce();
},
);
it("retains valid discovered JSON with its existing byte accounting", async () => {
const data = { v: "5.12.2", w: 100, h: 100, layers: [], fr: 30, ip: 0, op: 30 };
const body = JSON.stringify(data);
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(body)),
);
const budget = { remainingBytes: body.length };
const found = await discoverLottieResponse(
{ url: () => "https://public.example/animation.json?version=1", headers: () => ({}) },
budget,
);
expect(found?.data).toEqual(data);
expect(found?.dataBudget).toBe(budget);
expect(budget.remainingBytes).toBe(0);
});
});
Loading
Loading