fix(cli): refuse capture folders that resolve outside the output root (#4789)

* fix(cli): refuse capture directories that resolve outside the output root

writeCaptureFileSync guards the final path entry, but a directory in the
path that is itself a planted symlink (assets -> ~/somewhere) still carried
the staging directory and the rename into wherever it pointed, with a
filename the captured page chose.

Every capture directory is now created through ensureCaptureDirSync(root,
dir), which walks from the output root one level at a time and refuses any
directory whose real path is not the one it has under the root, before
creating anything inside it. The root itself stays trusted as given, so a
symlinked root like /tmp on macOS keeps working.

Fixes #4304

* fix(cli): vet assets/svgs before the SVG contact sheet writes into it

Under --skip-assets, downloadAssets never runs, so nothing checked
assets/svgs before createSvgContactSheet wrote its thumbnails and
contact-sheet.jpg into it; a planted assets/svgs link received them.
The sheet now runs ensureCaptureDirSync on the directory first, and a
refusal just skips the sheet.

A dangling or looping directory link is now refused with the same
message instead of a raw ENOENT/ELOOP, and a comment records why the
check uses the JS realpathSync rather than realpathSync.native.

Refs #4304

* fix(cli): vet capture --video's download folder

Video mode created assets/videos (or capture/assets/videos in a W2H
project) with a plain mkdirSync, so a planted assets or capture link
carried the downloaded clip outside the project. It now goes through
ensureCaptureDirSync with the project folder as the trusted root, and
a refused folder is reported before anything is downloaded.

* fix(cli): warn when a best-effort capture phase refuses a folder

Folder refusals now throw CaptureDirRefusedError. The contact sheets
and the video manifest still skip on any error, but a refusal adds a
warning so a planted link doesn't go unnoticed.

saveLottieAnimations now vets assets/lottie itself, taking the output
root like renderLottiePreviews, and every folder check in the capture
pipeline is pinned by a test.

* style(cli): trim the ensureCaptureDirSync doc comment to two lines

---------

Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
This commit is contained in:
Leonel Rivas
2026-10-02 05:53:31 -07:00
committed by GitHub
co-authored by Miguel Angel Simon Sierra
parent f1b79d8e7b
commit d2cdad418a
18 changed files with 581 additions and 42 deletions
@@ -1,5 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
@@ -9,6 +17,7 @@ import {
safeFetch,
toStandaloneSvg,
} from "./assetDownloader.js";
import { CaptureDirRefusedError } from "./captureErrors.js";
import type { DesignTokens } from "./types.js";
import type { IconCandidate } from "./faviconRanker.js";
import { CAPTURE_USER_AGENT } from "./userAgent.js";
@@ -741,3 +750,65 @@ describe("capture download security boundaries", () => {
});
});
});
describe("a planted directory link inside the capture directory (#4304)", () => {
// Creating symlinks needs elevated rights on Windows.
const posixOnly = it.skipIf(process.platform === "win32");
function plantedLink(root: string, link: string): { outputDir: string; outside: string } {
const outputDir = join(root, "capture");
const outside = join(root, "outside");
mkdirSync(join(outputDir, link, ".."), { recursive: true });
mkdirSync(outside);
symlinkSync(outside, join(outputDir, link));
return { outputDir, outside };
}
posixOnly.each(["assets", "assets/svgs"])(
"refuses to write page-derived assets through a planted %s/ symlink",
async (link) => {
await withTempDir(async (root) => {
const { outputDir, outside } = plantedLink(root, link);
const svgs = [
{
outerHTML: `<svg viewBox="0 0 10 10"><rect width="10" height="10" fill="#abc"/></svg>`,
isLogo: false,
},
];
await expect(
downloadAssets({ svgs, sections: [], ogImage: "" } as unknown as DesignTokens, outputDir),
).rejects.toThrow(/outside the capture directory/);
expect(readdirSync(outside)).toEqual([]);
});
},
);
posixOnly("refuses a dangling assets/ symlink before its svgs folder", async () => {
await withTempDir(async (root) => {
const outputDir = join(root, "capture");
const target = join(root, "missing");
mkdirSync(outputDir);
symlinkSync(target, join(outputDir, "assets"));
await expect(
downloadAssets(
{ svgs: [], sections: [], ogImage: "" } as unknown as DesignTokens,
outputDir,
),
).rejects.toThrow(CaptureDirRefusedError);
expect(existsSync(target)).toBe(false);
});
});
posixOnly("refuses to create the fonts directory through a planted assets/ symlink", async () => {
await withTempDir(async (root) => {
const { outputDir, outside } = plantedLink(root, "assets");
await expect(downloadAndRewriteFonts("", outputDir)).rejects.toThrow(
/outside the capture directory/,
);
expect(readdirSync(outside)).toEqual([]);
});
});
});
+4 -5
View File
@@ -6,8 +6,7 @@
*/
import { isBlockedNetworkHost } from "@hyperframes/engine";
import { mkdirSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
import { join, extname } from "node:path";
import { createHash } from "node:crypto";
import type { DesignTokens, DownloadedAsset } from "./types.js";
@@ -317,14 +316,14 @@ export async function downloadAssets(
): Promise<{ assets: DownloadedAsset[]; drops: AssetDropCounts; icons: IconManifest }> {
options = { ...options, byteBudget: options.byteBudget ?? createCaptureDownloadBudget() };
const assetsDir = join(outputDir, "assets");
mkdirSync(assetsDir, { recursive: true });
ensureCaptureDirSync(outputDir, assetsDir);
const assets: DownloadedAsset[] = [];
const drops = noDrops();
const downloadedUrls = new Set<string>();
let icons: IconManifest = emptyIconManifest();
mkdirSync(join(outputDir, "assets", "svgs"), { recursive: true });
ensureCaptureDirSync(outputDir, join(outputDir, "assets", "svgs"));
const usedSvgNames = new Set<string>();
const MAX_INLINE_SVGS = 30;
drops["cap-reached"] += Math.max(0, tokens.svgs.length - MAX_INLINE_SVGS);
@@ -542,7 +541,7 @@ export async function downloadAndRewriteFonts(
): Promise<{ css: string; drops: AssetDropCounts }> {
options = { ...options, byteBudget: options.byteBudget ?? createCaptureDownloadBudget() };
const assetsDir = join(outputDir, "assets", "fonts");
mkdirSync(assetsDir, { recursive: true });
ensureCaptureDirSync(outputDir, assetsDir);
const drops = noDrops();
const fontUrlRegex = /url\(['"]?(https?:\/\/[^'")\s]+\.(?:woff2?|ttf|otf)[^'")\s]*?)['"]?\)/g;
@@ -0,0 +1,47 @@
import { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { captureWebsiteAttempt } from "./captureAttempt.js";
import { CaptureDirRefusedError } from "./captureErrors.js";
import type { CaptureWatchdog } from "./captureWatchdog.js";
import { createPartialCaptureState } from "./partialCapture.js";
const { ensureBrowserMock } = vi.hoisted(() => ({
ensureBrowserMock: vi.fn(async () => {
throw new Error("the browser must not start");
}),
}));
vi.mock("../browser/manager.js", () => ({ ensureBrowser: ensureBrowserMock }));
const watchdog: CaptureWatchdog = {
expired: () => false,
promise: new Promise(() => {}),
registerBrowser: () => {},
unregisterBrowser: () => {},
dispose: () => {},
};
describe("captureWebsiteAttempt output folders", () => {
// Creating symlinks needs elevated rights on Windows.
it.skipIf(process.platform === "win32").each(["extracted", "screenshots", "assets"])(
"refuses a planted %s/ symlink before the browser starts (#4304)",
async (link) => {
const root = mkdtempSync(join(tmpdir(), "hf-capture-attempt-"));
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
const outputDir = join(root, "capture");
const outside = join(root, "outside");
mkdirSync(outputDir);
mkdirSync(outside);
symlinkSync(outside, join(outputDir, link));
const opts = { url: "https://example.com", outputDir };
await expect(
captureWebsiteAttempt(opts, undefined, false, watchdog, createPartialCaptureState(opts)),
).rejects.toThrow(CaptureDirRefusedError);
expect(readdirSync(outside)).toEqual([]);
expect(ensureBrowserMock).not.toHaveBeenCalled();
},
);
});
+5 -4
View File
@@ -5,9 +5,10 @@ import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
* - Rich animation metadata for Claude Code to recreate
*/
import { mkdirSync, existsSync } from "node:fs";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { noDrops } from "./assetDownloader.js";
import { ensureCaptureDirSync } from "./captureFile.js";
import type { IconCandidate } from "./faviconRanker.js";
import { CAPTURE_USER_AGENT } from "./userAgent.js";
import { setupAnimationCapture, startCdpAnimationCapture } from "./animationCataloger.js";
@@ -101,9 +102,9 @@ export async function captureWebsiteAttempt(
loadEnvFile(outputDir);
// Create output directories
mkdirSync(join(outputDir, "extracted"), { recursive: true });
mkdirSync(join(outputDir, "screenshots"), { recursive: true });
mkdirSync(join(outputDir, "assets"), { recursive: true });
ensureCaptureDirSync(outputDir, join(outputDir, "extracted"));
ensureCaptureDirSync(outputDir, join(outputDir, "screenshots"));
ensureCaptureDirSync(outputDir, join(outputDir, "assets"));
// Launch browser
progress("browser", "Launching headless Chrome...");
@@ -7,3 +7,10 @@ export class NavigationDeadlineError extends Error {
this.name = "NavigationDeadlineError";
}
}
export class CaptureDirRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = "CaptureDirRefusedError";
}
}
+74 -1
View File
@@ -12,7 +12,7 @@ import {
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { writeCaptureFileSync } from "./captureFile.js";
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
const temporaryDirectories: string[] = [];
@@ -122,3 +122,76 @@ describe("writeCaptureFileSync", () => {
expect(readdirSync(outputDir).sort()).toEqual(["a.txt", "occupied"]);
});
});
describe("ensureCaptureDirSync", () => {
it("creates nested directories under the root, and accepts them again on a re-run", () => {
const { outputDir } = scratch();
const fonts = join(outputDir, "assets", "fonts");
expect(ensureCaptureDirSync(outputDir, fonts)).toBe(fonts);
expect(ensureCaptureDirSync(outputDir, fonts)).toBe(fonts);
expect(lstatSync(fonts).isDirectory()).toBe(true);
});
posixOnly("refuses a planted directory symlink and creates nothing in its target", () => {
const { outputDir } = scratch();
const outside = join(outputDir, "..", "outside");
mkdirSync(outside);
symlinkSync(outside, join(outputDir, "assets"));
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, "assets", "fonts"))).toThrow(
/outside the capture directory/,
);
expect(readdirSync(outside)).toEqual([]);
});
posixOnly("refuses a directory symlink even when it points elsewhere inside the root", () => {
const { outputDir } = scratch();
mkdirSync(join(outputDir, "extracted"));
symlinkSync(join(outputDir, "extracted"), join(outputDir, "assets"));
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, "assets"))).toThrow(
/outside the capture directory/,
);
});
posixOnly("refuses a dangling or looping directory symlink with the same message", () => {
const { outputDir } = scratch();
symlinkSync(join(outputDir, "..", "missing"), join(outputDir, "assets"));
symlinkSync(join(outputDir, "screenshots"), join(outputDir, "screenshots"));
for (const name of ["assets", "screenshots"]) {
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, name))).toThrow(
/outside the capture directory/,
);
}
});
posixOnly("trusts a root that is itself a symlink, as /tmp is on macOS", () => {
const { outputDir } = scratch();
const linkedRoot = join(outputDir, "..", "linked-capture");
symlinkSync(outputDir, linkedRoot);
ensureCaptureDirSync(linkedRoot, join(linkedRoot, "assets", "svgs"));
expect(lstatSync(join(outputDir, "assets", "svgs")).isDirectory()).toBe(true);
});
it("refuses a file sitting where a directory belongs", () => {
const { outputDir } = scratch();
writeFileSync(join(outputDir, "assets"), "not a directory");
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, "assets"))).toThrow(
/not a directory/,
);
});
it("refuses a directory that is not under the root", () => {
const { outputDir } = scratch();
expect(() => ensureCaptureDirSync(outputDir, join(outputDir, "..", "elsewhere"))).toThrow(
/outside the capture directory/,
);
});
});
+67 -2
View File
@@ -1,5 +1,14 @@
import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import {
mkdirSync,
mkdtempSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { CaptureDirRefusedError } from "./captureErrors.js";
type WriteOptions = Parameters<typeof writeFileSync>[2];
@@ -40,3 +49,59 @@ export function writeCaptureFileSync(
}
writeFileSync(path, data, { ...normalized, mode: 0o600 });
}
function hasCode(error: unknown, ...codes: string[]): boolean {
return (
error instanceof Error &&
"code" in error &&
typeof error.code === "string" &&
codes.includes(error.code)
);
}
// The JS `realpathSync`, deliberately not `realpathSync.native`: native returns the on-disk case
// on macOS/Windows, so an existing `Assets/` asked for as `assets` would be falsely refused. The
// JS walk only rewrites the components that are links, which is exactly what is being checked.
function resolvedPath(path: string): string | undefined {
try {
return realpathSync(path);
} catch (error) {
// A dangling or looping link resolves nowhere: refuse it like any other escaping link.
if (hasCode(error, "ENOENT", "ELOOP")) return undefined;
throw error;
}
}
// Creates `dir` under the trusted capture root, refusing any directory whose real path leaves it (#4304).
// Checked on creation, not on every write: a link swapped in later by a racing process is out of scope.
export function ensureCaptureDirSync(root: string, dir: string): string {
const rootPath = resolve(root);
const target = resolve(dir);
const rel = relative(rootPath, target);
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new CaptureDirRefusedError(
`Refusing to create ${target}: it is outside the capture directory ${rootPath}`,
);
}
mkdirSync(rootPath, { recursive: true });
let current = rootPath;
let expected = realpathSync(rootPath);
for (const segment of rel.split(sep).filter(Boolean)) {
current = join(current, segment);
expected = join(expected, segment);
try {
mkdirSync(current);
} catch (error) {
if (!hasCode(error, "EEXIST")) throw error;
}
if (resolvedPath(current) !== expected) {
throw new CaptureDirRefusedError(
`Refusing to write into ${current}: it resolves outside the capture directory ${rootPath}`,
);
}
if (!statSync(current).isDirectory()) {
throw new CaptureDirRefusedError(`Refusing to write into ${current}: it is not a directory`);
}
}
return target;
}
@@ -9,8 +9,8 @@ import type { IconCandidate } from "./faviconRanker.js";
import { startCdpAnimationCapture } from "./animationCataloger.js";
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
import { detectLibraries } from "./contentExtractor.js";
import { mkdirSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { CaptureDirRefusedError } from "./captureErrors.js";
import { join } from "node:path";
import { extractHtml } from "./htmlExtractor.js";
import { extractTokens } from "./tokenExtractor.js";
@@ -176,9 +176,13 @@ export async function runCoreExtraction(input: CoreExtractionInput): Promise<Cor
if (discoveredLotties.length > 0 && remainingMs() > 0) {
const lottieDir = join(outputDir, "assets", "lottie");
if (!canWrite()) return;
mkdirSync(lottieDir, { recursive: true });
const lottieBudget = { remainingMs, byteBudget: downloadByteBudget };
const savedCount = await saveLottieAnimations(discoveredLotties, lottieDir, lottieBudget);
const savedCount = await saveLottieAnimations(
discoveredLotties,
lottieDir,
outputDir,
lottieBudget,
);
// Generate manifest + preview thumbnails so the agent can SEE what each animation is
if (savedCount > 0 && remainingMs() > 0 && canWrite()) {
await renderLottiePreviews(chromeBrowser, lottieDir, outputDir, lottieBudget);
@@ -348,8 +352,10 @@ export async function runCoreExtraction(input: CoreExtractionInput): Promise<Cor
remainingMs,
});
}
} catch {
/* non-blocking — video manifest is best-effort */
} catch (err) {
if (err instanceof CaptureDirRefusedError)
warnings.push(`Video manifest skipped: ${err.message}`);
/* otherwise non-blocking — video manifest is best-effort */
}
// Detect JS libraries via globals, DOM fingerprints, script URLs, and shaders
+95 -2
View File
@@ -19,6 +19,7 @@ import {
renderLottiePreviews,
saveLottieAnimations,
} from "./mediaCapture.js";
import { CaptureDirRefusedError } from "./captureErrors.js";
const tempDirs: string[] = [];
@@ -48,6 +49,7 @@ describe("Lottie capture budget", () => {
const saved = await saveLottieAnimations(
[{ url: "https://one.example/a.json" }, { url: "https://two.example/b.json" }],
dir,
dir,
{ remainingMs: () => remainingMs },
);
@@ -207,7 +209,9 @@ describe("Lottie capture rejects unsafe persistence", () => {
"fetch",
vi.fn(async () => new Response("not a zip")),
);
expect(await saveLottieAnimations([{ url: "https://public.example/bad.lottie" }], dir)).toBe(0);
expect(
await saveLottieAnimations([{ url: "https://public.example/bad.lottie" }], dir, dir),
).toBe(0);
expect(readdirSync(dir)).toEqual([]);
});
it("does not publish truthy non-array layers", async () => {
@@ -216,7 +220,9 @@ describe("Lottie capture rejects unsafe persistence", () => {
"fetch",
vi.fn(async () => new Response('{"w":100,"h":100,"layers":true}')),
);
expect(await saveLottieAnimations([{ url: "https://public.example/bad.json" }], dir)).toBe(0);
expect(await saveLottieAnimations([{ url: "https://public.example/bad.json" }], dir, dir)).toBe(
0,
);
expect(readdirSync(dir)).toEqual([]);
});
});
@@ -299,3 +305,90 @@ describe("media previews replace pre-planted links", () => {
},
);
});
describe("media folders refuse a planted directory link (#4304)", () => {
// captureVideoManifest keeps any http(s) video it finds; only the URL matters here.
const descriptor = { src: "https://video.example/planted.mp4", filename: "planted.mp4" };
function plantedCapture(link: string): { dir: string; outside: string } {
const dir = tempDir();
const outside = join(dir, "outside");
mkdirSync(outside);
mkdirSync(join(dir, "extracted"));
mkdirSync(join(dir, link, ".."), { recursive: true });
symlinkSync(outside, join(dir, link));
return { dir, outside };
}
// Creating symlinks needs elevated rights on Windows.
it.skipIf(process.platform === "win32").each(["assets/videos", "assets/videos/previews"])(
"does not write the video manifest through a planted %s link",
async (link) => {
const { dir, outside } = plantedCapture(link);
const screenshot = vi.fn();
const page = { evaluate: vi.fn(async () => [descriptor]), screenshot } as unknown as Page;
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 404 })),
);
await expect(captureVideoManifest(page, dir, () => {})).rejects.toThrow(
CaptureDirRefusedError,
);
expect(readdirSync(outside)).toEqual([]);
expect(screenshot).not.toHaveBeenCalled();
},
);
it.skipIf(process.platform === "win32")(
"refuses a dangling assets/videos link before its previews folder",
async () => {
const dir = tempDir();
const target = join(dir, "missing");
mkdirSync(join(dir, "assets"), { recursive: true });
symlinkSync(target, join(dir, "assets", "videos"));
const page = {
evaluate: vi.fn(async () => [descriptor]),
screenshot: vi.fn(),
} as unknown as Page;
await expect(captureVideoManifest(page, dir, () => {})).rejects.toThrow(
CaptureDirRefusedError,
);
expect(existsSync(target)).toBe(false);
},
);
it.skipIf(process.platform === "win32")(
"does not save Lottie files through a planted assets/lottie link",
async () => {
const { dir, outside } = plantedCapture("assets/lottie");
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
saveLottieAnimations(
[{ url: "https://public.example/a.json" }],
join(dir, "assets", "lottie"),
dir,
),
).rejects.toThrow(CaptureDirRefusedError);
expect(readdirSync(outside)).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
},
);
it.skipIf(process.platform === "win32")(
"does not render Lottie previews through a planted previews link",
async () => {
const { dir, outside } = plantedCapture("assets/lottie/previews");
const browser = { newPage: vi.fn() } as unknown as Browser;
await expect(
renderLottiePreviews(browser, join(dir, "assets", "lottie"), dir),
).rejects.toThrow(CaptureDirRefusedError);
expect(readdirSync(outside)).toEqual([]);
expect(browser.newPage).not.toHaveBeenCalled();
},
);
});
+7 -5
View File
@@ -8,8 +8,8 @@
*/
import type { Browser, Page } from "puppeteer-core";
import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
import { join, extname } from "node:path";
import { isPrivateUrl, safeFetch } from "./assetDownloader.js";
import { CAPTURE_USER_AGENT } from "./userAgent.js";
@@ -50,8 +50,10 @@ function liveRemainingMs(budget: RemainingBudget, fallbackMs: number): number {
export async function saveLottieAnimations(
discoveredLotties: DiscoveredLottie[],
lottieDir: string,
outputDir: string,
budget: RemainingBudget = {},
): Promise<number> {
ensureCaptureDirSync(outputDir, lottieDir);
const byteBudget = budget.byteBudget ?? createCaptureDownloadBudget();
let savedCount = 0;
const savedHashes = new Set<string>(); // Deduplicate by content
@@ -130,7 +132,7 @@ export async function renderLottiePreviews(
layers: number;
}> = [];
const previewDir = join(lottieDir, "previews");
mkdirSync(previewDir, { recursive: true });
ensureCaptureDirSync(outputDir, previewDir);
for (const file of readdirSync(lottieDir)) {
if (!file.endsWith(".json")) continue;
@@ -470,9 +472,9 @@ export async function captureVideoManifest(
if (merged.length === 0) return;
const videoManifestDir = join(outputDir, "assets", "videos");
mkdirSync(videoManifestDir, { recursive: true });
ensureCaptureDirSync(outputDir, videoManifestDir);
const previewDir = join(videoManifestDir, "previews");
mkdirSync(previewDir, { recursive: true });
ensureCaptureDirSync(outputDir, previewDir);
const videoManifest: Array<{
index: number;
@@ -1,4 +1,12 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -45,4 +53,30 @@ describe("writePartialCaptureBundle", () => {
partial: true,
});
});
// Creating symlinks needs elevated rights on Windows.
it.skipIf(process.platform === "win32")(
"refuses to write through a planted extracted/ symlink (#4304)",
() => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-partial-capture-"));
temporaryDirectories.push(root);
const outputDir = join(root, "capture");
const outside = join(root, "outside");
mkdirSync(outputDir);
mkdirSync(outside);
symlinkSync(outside, join(outputDir, "extracted"));
const opts = { url: "https://example.com", outputDir };
expect(() =>
writePartialCaptureBundle(opts, createPartialCaptureState(opts), {
schema: CAPTURE_PHASE_SCHEMA,
phase: "complete",
status: "degraded",
remainingMs: null,
reason: "deadline",
}),
).toThrow(/outside the capture directory/);
expect(readdirSync(outside)).toEqual([]);
},
);
});
+2 -5
View File
@@ -1,5 +1,4 @@
import { mkdirSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
import { join } from "node:path";
import { noDrops } from "./assetDownloader.js";
import type {
@@ -74,9 +73,7 @@ export function writePartialCaptureBundle(
lastPhase: CaptureResult["lastPhase"],
): CaptureResult {
const hostname = new URL(opts.url).hostname.replace(/^www\./, "");
mkdirSync(opts.outputDir, { recursive: true });
const extractedDir = join(opts.outputDir, "extracted");
mkdirSync(extractedDir, { recursive: true });
const extractedDir = ensureCaptureDirSync(opts.outputDir, join(opts.outputDir, "extracted"));
writeCaptureFileSync(join(extractedDir, "tokens.json"), serializeTokensForCapture(state.tokens));
writeCaptureFileSync(
join(extractedDir, "design-styles.json"),
@@ -0,0 +1,58 @@
import { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, onTestFinished } from "vitest";
import { noDrops } from "./assetDownloader.js";
import { createPartialCaptureState } from "./partialCapture.js";
import { runPostExtraction } from "./postExtractionPhase.js";
import { createCaptureDownloadBudget } from "./readBoundedResponse.js";
const PLANTED_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="#abc"/></svg>`;
describe("runPostExtraction", () => {
// Creating symlinks needs elevated rights on Windows.
it.skipIf(process.platform === "win32")(
"does not write the SVG contact sheet through a planted assets/svgs symlink under --skip-assets (#4304)",
async () => {
const root = mkdtempSync(join(tmpdir(), "hyperframes-post-extraction-"));
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
const outputDir = join(root, "capture");
const outside = join(root, "outside");
for (const dir of ["extracted", "screenshots", "assets"]) {
mkdirSync(join(outputDir, dir), { recursive: true });
}
mkdirSync(outside);
writeFileSync(join(outside, "planted.svg"), PLANTED_SVG);
symlinkSync(outside, join(outputDir, "assets", "svgs"));
const state = createPartialCaptureState({ url: "https://example.com", outputDir });
const warnings: string[] = [];
await runPostExtraction({
state,
outputDir,
warnings,
progress: () => {},
remainingMs: () => 60_000,
phase: () => {},
animationCatalog: undefined,
catalogedAssets: [],
visibleTextContent: "",
faviconLinks: [],
tokens: state.tokens,
extracted: state.extracted,
skipAssets: true,
skipVision: true,
downloadByteBudget: createCaptureDownloadBudget(),
assets: [],
dropped: noDrops(),
fontDrops: noDrops(),
canWrite: () => true,
});
expect(readdirSync(outside)).toEqual(["planted.svg"]);
expect(warnings).toEqual([
expect.stringMatching(/^SVG contact sheet skipped: Refusing to write/),
]);
},
);
});
@@ -1,5 +1,6 @@
import { existsSync } from "node:fs";
import { writeCaptureFileSync } from "./captureFile.js";
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
import { CaptureDirRefusedError } from "./captureErrors.js";
import { join } from "node:path";
import {
downloadAssets,
@@ -404,7 +405,9 @@ export async function runPostExtraction(input: PostExtractionInput): Promise<Pos
// so sites like huly.io that only use external SVGs still get a grid
const svgsDir = join(outputDir, "assets", "svgs");
const assetsRootDir = join(outputDir, "assets");
const svgOutputPath = existsSync(svgsDir)
const hasSvgsDir = existsSync(svgsDir);
if (hasSvgsDir) ensureCaptureDirSync(outputDir, svgsDir);
const svgOutputPath = hasSvgsDir
? join(outputDir, "assets", "svgs", "contact-sheet.jpg")
: join(outputDir, "assets", "contact-sheet-svgs.jpg");
const svgSheets = await createSvgContactSheet(
@@ -420,8 +423,10 @@ export async function runPostExtraction(input: PostExtractionInput): Promise<Pos
);
};
await runSvgContactSheet();
} catch {
/* contact sheets are non-critical — agent can still read images individually */
} catch (err) {
if (err instanceof CaptureDirRefusedError)
warnings.push(`SVG contact sheet skipped: ${err.message}`);
/* otherwise non-critical — agent can still read images individually */
}
phase(
"contact-sheets",
@@ -1,5 +1,13 @@
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Page } from "puppeteer-core";
@@ -9,6 +17,7 @@ import {
MAX_PLATE_HEIGHT_PX,
pngHeight,
} from "./screenshotCapture.js";
import { CaptureDirRefusedError } from "./captureErrors.js";
function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
@@ -263,3 +272,23 @@ describe("captureFullPagePlate — the guard sees the post-neutralisation page (
expect(String(evaluate.mock.calls.at(-1)?.[0])).toContain("removeAttribute");
});
});
describe("captureScrollScreenshots — planted screenshots link (#4304)", () => {
// Creating symlinks needs elevated rights on Windows.
it.skipIf(process.platform === "win32")(
"refuses a planted screenshots/ symlink before touching the page",
async () => {
const dir = tempDir("hf-scroll-planted-");
const outside = join(dir, "outside");
mkdirSync(outside);
symlinkSync(outside, join(dir, "screenshots"));
const { page, evaluate, screenshot } = fakePage();
await expect(captureScrollScreenshots(page, dir)).rejects.toThrow(CaptureDirRefusedError);
expect(readdirSync(outside)).toEqual([]);
expect(evaluate).not.toHaveBeenCalled();
expect(screenshot).not.toHaveBeenCalled();
},
);
});
@@ -7,9 +7,8 @@
import type { Page } from "puppeteer-core";
import { isDegradableEvaluateTimeoutError } from "./captureTimeout.js";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { writeCaptureFileSync } from "./captureFile.js";
import { ensureCaptureDirSync, writeCaptureFileSync } from "./captureFile.js";
/**
* Capture viewport screenshots covering the entire page height.
@@ -118,7 +117,7 @@ export async function captureScrollScreenshots(
budget: { remainingMs?: () => number } = {},
): Promise<string[]> {
const screenshotsDir = join(outputDir, "screenshots");
mkdirSync(screenshotsDir, { recursive: true });
ensureCaptureDirSync(outputDir, screenshotsDir);
const MAX_SCREENSHOTS = 20;
const filePaths: string[] = [];
@@ -1,9 +1,14 @@
import { describe, expect, it } from "vitest";
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { consumeCommandResult } from "../../utils/commandResult.js";
import {
MAX_VIDEO_BYTES,
VIDEO_CONTENT_TYPE_RE,
findFilenameCollision,
pickManifestEntry,
runVideoMode,
safeFilename,
type ManifestEntry,
} from "./video.js";
@@ -160,3 +165,44 @@ describe("findFilenameCollision", () => {
expect(collisions.every((c) => c.index !== 0)).toBe(true);
});
});
describe("runVideoMode", () => {
// Creating symlinks needs elevated rights on Windows.
it.skipIf(process.platform === "win32").each([
{ layout: "capture output", manifestDir: "extracted", planted: "assets" },
{ layout: "W2H project", manifestDir: "capture/extracted", planted: "capture" },
])(
"refuses to download through a planted $planted symlink in a $layout (#4304)",
async ({ manifestDir, planted }) => {
const root = mkdtempSync(join(tmpdir(), "hf-video-mode-"));
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
const projectDir = join(root, "project");
const outside = join(root, "outside");
mkdirSync(projectDir);
mkdirSync(outside);
symlinkSync(outside, join(projectDir, planted));
mkdirSync(join(projectDir, manifestDir), { recursive: true });
writeFileSync(
join(projectDir, manifestDir, "video-manifest.json"),
JSON.stringify([ENTRY(0)]),
);
const fetchMock = vi.fn(async () => {
throw new Error("no network in tests");
});
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
onTestFinished(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
await runVideoMode({ project: projectDir, index: "0" });
expect(consumeCommandResult().exitCode).toBe(1);
expect(String(error.mock.calls[0]?.[0])).toContain("resolves outside the capture directory");
expect(fetchMock).not.toHaveBeenCalled();
expect(existsSync(join(outside, planted === "assets" ? "videos" : "assets"))).toBe(false);
},
);
});
+9 -2
View File
@@ -1,8 +1,9 @@
import { setCommandExitCode } from "../../utils/commandResult.js";
import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
import { createWriteStream, existsSync, readFileSync, unlinkSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { c } from "../../ui/colors.js";
import { safeFetch } from "../../capture/assetDownloader.js";
import { ensureCaptureDirSync } from "../../capture/captureFile.js";
import { CAPTURE_USER_AGENT } from "../../capture/userAgent.js";
const MAX_VIDEO_BYTES = 250 * 1024 * 1024;
@@ -254,7 +255,13 @@ export async function runVideoMode(args: VideoModeArgs): Promise<void> {
const outDir = isW2hLayout
? join(projectDir, "capture", "assets", "videos")
: join(projectDir, "assets", "videos");
mkdirSync(outDir, { recursive: true });
try {
ensureCaptureDirSync(projectDir, outDir);
} catch (e) {
console.error(`${c.error("✗")} ${(e as Error).message}`);
setCommandExitCode(1);
return;
}
const fname = safeFilename(entry.filename || basename(entry.url));
const outPath = join(outDir, fname);
const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`;