diff --git a/packages/cli/src/commands/keyframes.test.ts b/packages/cli/src/commands/keyframes.test.ts index 9167e01ed18..71791d4e442 100644 --- a/packages/cli/src/commands/keyframes.test.ts +++ b/packages/cli/src/commands/keyframes.test.ts @@ -481,4 +481,23 @@ describe("keyframes project compositions", () => { expect(collectCompositions(join(dir, "index.html"))).toHaveLength(1); }); + + it.each(["../outside.html", "OUTSIDE"])( + "does not read a data-composition-src outside the project (%s)", + (written) => { + const dir = mkdtempSync(join(tmpdir(), "hf-keyframes-outside-src-")); + onTestFinished(() => rmSync(dir, { recursive: true, force: true })); + const project = join(dir, "project"); + mkdirSync(project); + const outside = join(dir, "outside.html"); + writeFileSync(outside, `
`); + const src = written === "OUTSIDE" ? outside : written; + writeFileSync( + join(project, "index.html"), + `
`, + ); + + expect(collectCompositions(join(project, "index.html"))).toHaveLength(1); + }, + ); }); diff --git a/packages/cli/src/commands/keyframes.ts b/packages/cli/src/commands/keyframes.ts index fbe3478523a..16b8e2dd227 100644 --- a/packages/cli/src/commands/keyframes.ts +++ b/packages/cli/src/commands/keyframes.ts @@ -1,6 +1,6 @@ import { defineCommand } from "../utils/defineCommand.js"; import { existsSync, readFileSync } from "node:fs"; -import { readProjectFile } from "@hyperframes/parsers/asset-resolution"; +import { isWithinProjectRoot, readProjectFile } from "@hyperframes/parsers/asset-resolution"; import { resolve, dirname, basename, join, relative, sep } from "node:path"; import { parseGsapScript, @@ -735,6 +735,7 @@ export function collectCompositions(indexPath: string): SurfacedComposition[] { const src = div.getAttribute("data-composition-src"); if (!src) continue; const subPath = resolve(baseDir, src); + if (!isWithinProjectRoot(baseDir, subPath)) continue; const sub = readProjectFile(subPath); if (sub.kind !== "file") continue; const id = div.getAttribute("data-composition-id") ?? src; diff --git a/packages/cli/src/utils/catalogUsage.test.ts b/packages/cli/src/utils/catalogUsage.test.ts index 52808f83d94..8edc9b62e3c 100644 --- a/packages/cli/src/utils/catalogUsage.test.ts +++ b/packages/cli/src/utils/catalogUsage.test.ts @@ -119,6 +119,25 @@ describe("summarizeCatalogUsage", () => { ).toEqual(["inner", "outer"]); }); + // The render refuses a mount outside the project, so a walk must not read one either. + it("does not follow a mount that leaves the project", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-catalog-outside-")); + try { + const project = join(dir, "project"); + mkdirSync(join(project, "compositions"), { recursive: true }); + writeFileSync(join(dir, "outside.html"), subCompDoc("outside", "compositions/kept.html")); + writeFileSync(join(project, "compositions", "kept.html"), subCompDoc("kept")); + writeFileSync(join(project, "index.html"), entryDoc("../outside.html")); + writeFileSync( + join(project, "hyperframes.json"), + JSON.stringify({ registry: "https://example.test", registryItems: [BLOCK("kept")] }), + ); + expect(summarizeCatalogUsage(project, join(project, "index.html")).usedBlocks).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + // A cyclic project must not wedge a render that already produced a video. it("terminates on a mount cycle", () => { expect( diff --git a/packages/cli/src/utils/catalogUsage.ts b/packages/cli/src/utils/catalogUsage.ts index 715b9cd6ec9..a386c3aa8c0 100644 --- a/packages/cli/src/utils/catalogUsage.ts +++ b/packages/cli/src/utils/catalogUsage.ts @@ -15,7 +15,10 @@ import { readFileSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; -import { collectSubCompositionSrcs } from "@hyperframes/parsers/asset-resolution"; +import { + collectSubCompositionSrcs, + resolveCompositionSrc, +} from "@hyperframes/parsers/asset-resolution"; import { type RegistryItemRecord, readProjectConfigWithStatus } from "./projectConfig.js"; /** Installed catalog items, and the subset the rendered composition reaches. */ @@ -105,7 +108,8 @@ function reachableCompositions(projectDir: string, entryPath: string): Set MAX_HTML_BYTES) continue; for (const src of collectSubCompositionSrcs(html)) { - queue.push(resolve(projectDir, src)); + const file = resolveCompositionSrc(projectDir, src); + if (file) queue.push(file); } } return seen; diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts index 2e3d9a8ddd7..fb8fb638ad4 100644 --- a/packages/lint/src/project.test.ts +++ b/packages/lint/src/project.test.ts @@ -1577,3 +1577,93 @@ it("names the unmounted count in one line", () => { ); expect(unmountedNotice(3)).toContain("3 files in compositions/ are not mounted"); }); + +describe("references that leave the project", () => { + const findingsOf = async (project: string) => + (await lintProject(project)).results.flatMap((entry) => entry.result.findings); + + function hostOf(srcPath: string): string { + return ` +
+
+
+ + +`; + } + + function outsideScene(project: string): string { + const outside = join(dirname(project), `${project.split(/[\\/]/).pop()}-scene.html`); + writeFileSync( + outside, + `
+ +
`, + ); + dirs.push(outside); + return outside; + } + + it.each([ + (outside: string) => `../${outside.split(/[\\/]/).pop()}`, + (outside: string) => outside, + ])( + "reports a sub-composition outside the project folder without reading it (%#)", + async (srcOf) => { + const project = makeProject(""); + const outside = outsideScene(project); + writeFileSync(join(project, "index.html"), hostOf(srcOf(outside))); + const findings = await findingsOf(project); + const sub = findings.find((f) => f.code === "missing_or_empty_sub_composition"); + expect(sub?.message ?? "").toMatch(/outside the project folder|does not exist/); + expect(JSON.stringify(findings)).not.toContain("outside-audio.mp3"); + }, + ); + + it("reads a sub-composition written with a leading slash from the project root, as the render does", async () => { + const project = makeProject(hostOf("/compositions/scene.html"), { + "scene.html": `
`, + }); + const findings = await findingsOf(project); + expect(findings.find((f) => f.code === "missing_or_empty_sub_composition")).toBeUndefined(); + }); + + it("does not read an import map through an encoded absolute path", async () => { + const project = makeProject(""); + const secret = join(dirname(project), `${project.split(/[\\/]/).pop()}-secret.txt`); + writeFileSync(secret, "TOP-SECRET-VALUE not json"); + dirs.push(secret); + writeFileSync( + join(project, "index.html"), + validHtml().replace( + "", + `\n\n`, + ), + ); + expect(JSON.stringify(await findingsOf(project))).not.toContain("TOP-SECRET-VALUE"); + }); +}); + +describe("missing_timeline_registry with a script outside the project", () => { + const rootWithScript = (src: string) => ` +
+ +`; + const registryErrors = async (project: string) => + (await lintProject(project)).results + .flatMap((result) => result.result.findings) + .filter((finding) => finding.code === "missing_timeline_registry"); + + it("is not reported when the timeline may be registered in a script lint did not read", async () => { + const project = makeProject(rootWithScript("../shared/timeline.js")); + + expect(await registryErrors(project)).toEqual([]); + }); + + it("is still reported when every script is in the project and none registers", async () => { + const project = makeProject(rootWithScript("scene.js")); + writeFileSync(join(project, "scene.js"), "console.log('no timeline');"); + + expect(await registryErrors(project)).toHaveLength(1); + }); +}); diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index 4bcbafd9eea..71e386c43e1 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -2,8 +2,8 @@ export { shouldBlockRender } from "./shouldBlockRender.js"; import { createHash } from "node:crypto"; import { existsSync, readdirSync } from "node:fs"; import { projectIgnore } from "./projectIgnore.js"; -import { dirname, extname, join, relative, resolve } from "node:path"; -import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; +import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { isNonRelativeUrl, rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; import { spansOverlap } from "@hyperframes/parsers/time-spans"; import { compositionOriginSeconds, @@ -28,8 +28,10 @@ import { isRemoteOrInlineUrl, isUnresolvedAssetPlaceholder, isWithinProjectRoot, + localAbsoluteAssetFile, maskNonScannableRanges, readProjectFile, + resolveCompositionSrc, resolveExistingLocalAsset, resolveLocalAssetCandidates, resolveProjectRelativeSrc, @@ -175,19 +177,27 @@ export function linkedSources( projectDir: string, html: string, compSrcPath?: string, -): Pick { +): Pick< + HyperframeLinterOptions, + "externalStyles" | "externalScripts" | "scriptsOutsideProjectUnread" +> { return { externalStyles: collectExternalStyles(projectDir, html, compSrcPath), - externalScripts: collectExternalScripts(projectDir, html, compSrcPath), + ...collectExternalScripts(projectDir, html, compSrcPath), }; } +function leavesProject(projectDir: string, path: string): boolean { + return !isNonRelativeUrl(path) && !isWithinProjectRoot(projectDir, resolve(projectDir, path)); +} + function collectExternalScripts( projectDir: string, html: string, compSrcPath?: string, -): NonNullable { +): Pick { const scripts: NonNullable = []; + const scriptsOutsideProjectUnread: string[] = []; const { document } = parseHTML(html); for (const element of querySelectorAllIncludingTemplates(document, "script[src]")) { const src = element.getAttribute("src") ?? ""; @@ -199,7 +209,12 @@ function collectExternalScripts( (compSrcPath && staysInsideProjectRoot(path) ? resolveExistingLocalAsset(projectDir, path) : null); - if (!script) continue; + if (!script) { + if (leavesProject(projectDir, isAbsolute(path) ? path : besideFile)) { + scriptsOutsideProjectUnread.push(src); + } + continue; + } const read = readProjectFile(script.resolved); if (read.kind !== "file" || read.text.length > MAX_EXTERNAL_SCRIPT_BYTES) continue; const attrs = Array.from(element.attributes) @@ -213,7 +228,7 @@ function collectExternalScripts( rootRelativePath: script.rootRelativePath, }); } - return scripts; + return { externalScripts: scripts, scriptsOutsideProjectUnread }; } function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] { @@ -599,7 +614,7 @@ function lintMissingLocalAsset( ? rewriteAssetPath(compSrcPath, src, (path) => existsSync(join(projectDir, path))) : src; const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative); - if (resolvedAsset) continue; + if (resolvedAsset || localAbsoluteAssetFile(projectDir, rootRelative)) continue; const rawRoot = rawSrc.trim(); const safe = urlSafeSrcOf( @@ -682,6 +697,8 @@ function lintTextureMaskAssetNotFound( cssSource.rootRelativePath, ); if (candidates.some(existsSync)) continue; + const inPageCss = !cssSource.rootRelativePath; + if (inPageCss && localAbsoluteAssetFile(projectDir, url)) continue; missingByFile.set(file, (missingByFile.get(file) ?? new Set()).add(url)); } } @@ -841,7 +858,7 @@ function collectMainTimelineAudio(projectDir: string, rootHtml: string): AudioSp const inner = { ...at, ...scope }; const src = el.getAttribute("data-composition-src")?.trim(); const file = src && !isUnresolvedAssetPlaceholder(src) && !isRemoteOrInlineUrl(src); - const filePath = file ? resolve(projectDir, src) : null; + const filePath = file ? resolveCompositionSrc(projectDir, src) : null; if (filePath && !at.mounting.includes(filePath)) { const read = readProjectFile(filePath); if (read.kind === "file") @@ -959,7 +976,13 @@ function lintMissingOrEmptySubComposition( // nested sub-composition) — matches the resolution the renderer uses // in packages/producer/src/services/htmlCompiler.ts (parseSubCompositions // / assertSubCompositionsUsable). - const filePath = resolve(projectDir, srcPath); + const filePath = resolveCompositionSrc(projectDir, srcPath); + if (filePath === null) { + if (!checked.has(srcPath)) { + checked.set(srcPath, { srcPath, problem: "it is outside the project folder" }); + } + continue; + } // Circular reference guard — same as assertSubCompositionsUsable. // Already-visited files were already checked (or are mid-walk); skip diff --git a/packages/lint/src/project.windowsAbsolute.test.ts b/packages/lint/src/project.windowsAbsolute.test.ts new file mode 100644 index 00000000000..a283e3de748 --- /dev/null +++ b/packages/lint/src/project.windowsAbsolute.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +vi.mock("node:child_process", () => { + const mocked = { ChildProcess: class {}, execFile: vi.fn(), execSync: vi.fn() }; + return { ...mocked, default: mocked }; +}); + +// A Windows disk on any host: these drive-letter files exist, as render would find them. +const ON_DISK = new Set(["C:\\media\\clip.png", "C:\\media\\grain.png"]); +vi.mock("@hyperframes/parsers/asset-resolution", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + localAbsoluteAssetFile: (_projectDir: string, src: string) => (ON_DISK.has(src) ? src : null), + }; +}); + +const { lintProject } = await import("./project.js"); + +let project: string | undefined; +afterEach(() => { + if (project) rmSync(project, { recursive: true, force: true }); + project = undefined; +}); + +async function lintCodes(body: string, files: Record = {}): Promise { + project = mkdtempSync(join(tmpdir(), "hf-lint-windows-absolute-")); + writeFileSync( + join(project, "index.html"), + ` +
${body}
+ +`, + ); + for (const [name, text] of Object.entries(files)) writeFileSync(join(project, name), text); + const { results } = await lintProject(project); + return results.flatMap((result) => result.result.findings.map((finding) => finding.code)); +} + +describe("local absolute paths that render copies in", () => { + it("are not reported as missing media", async () => { + expect(await lintCodes('')).not.toContain( + "missing_local_asset", + ); + }); + + it("are still reported when the file is not there", async () => { + expect(await lintCodes('')).toContain("missing_local_asset"); + }); + + it("are not reported as a missing texture mask in the page's own CSS", async () => { + const codes = await lintCodes( + '
', + ); + expect(codes).not.toContain("texture_mask_asset_not_found"); + }); + + it("are still reported from a linked stylesheet, where url() stays a browser URL", async () => { + const codes = await lintCodes( + '
', + { "styles.css": '.grain { mask-image: url("C:\\media\\grain.png"); }' }, + ); + expect(codes).toContain("texture_mask_asset_not_found"); + }); +}); diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index e90d00a62d4..0cf05550c72 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -625,6 +625,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ return []; } if (/(?:^|\s)data-no-timeline(?=[\s=/]|$)/i.test(rootTag?.attrs || "")) return []; + if (options.scriptsOutsideProjectUnread?.length) return []; const findings: HyperframeLintFinding[] = []; const registered = [source, ...(options.externalScripts ?? []).map((script) => script.content)]; if ( diff --git a/packages/lint/src/types.ts b/packages/lint/src/types.ts index d51f008884e..bf8d3ea8df6 100644 --- a/packages/lint/src/types.ts +++ b/packages/lint/src/types.ts @@ -54,6 +54,7 @@ export type HyperframeLinterOptions = { file: string; rootRelativePath: string; }>; + scriptsOutsideProjectUnread?: string[]; /** * Set to `true` when linting compositions destined for distributed / Lambda * rendering, where system-font capture (`allowSystemFontCapture`) is diff --git a/packages/parsers/src/assetResolution.test.ts b/packages/parsers/src/assetResolution.test.ts index 1d39398a649..d28376de054 100644 --- a/packages/parsers/src/assetResolution.test.ts +++ b/packages/parsers/src/assetResolution.test.ts @@ -11,6 +11,8 @@ import { maskNonScannableRanges, readProjectFile, readTextFile, + resolveCompositionSrc, + resolveExistingLocalAsset, resolveProjectRelativeSrc, } from "./assetResolution.js"; @@ -270,6 +272,48 @@ describe("resolveProjectRelativeSrc — the one src resolver for lint and render }); }); +describe("resolveExistingLocalAsset stays inside the project", () => { + let dir: string; + let project: string; + let outside: string; + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "hf-asset-confine-")); + project = join(dir, "project"); + mkdirSync(join(project, "assets"), { recursive: true }); + writeFileSync(join(project, "assets", "a.css"), "a{}"); + outside = join(dir, "secret.env"); + writeFileSync(outside, "TOKEN=1"); + }); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + it.each([ + (path: string) => `/%2F${path.slice(1)}`, + (path: string) => `%2F${path}`, + (path: string) => `/${encodeURIComponent(path)}`, + ])("finds no file outside the project for an encoded absolute path (%#)", (encode) => { + expect(resolveExistingLocalAsset(project, encode(outside))).toBeNull(); + }); + + it("still finds a project file by its root-absolute URL or a clamped ../", () => { + expect(resolveExistingLocalAsset(project, "/assets/a.css")?.rootRelativePath).toBe( + join("assets", "a.css"), + ); + expect(resolveExistingLocalAsset(project, "../assets/a.css")?.rootRelativePath).toBe( + join("assets", "a.css"), + ); + }); + + it("resolves a sub-composition src like the render, and refuses one that leaves", () => { + expect(resolveCompositionSrc(project, "/assets/a.css")).toBe(join(project, "assets", "a.css")); + expect(resolveCompositionSrc(project, "../secret.env")).toBeNull(); + expect(resolveCompositionSrc(project, "%2E%2E/secret.env")).toBeNull(); + const driveLetterPathLeavesProject = process.platform === "win32"; + expect(resolveCompositionSrc(project, outside)).toBe( + driveLetterPathLeavesProject ? null : join(project, outside), + ); + }); +}); + // A CI runner is one OS. Injecting path.win32/path.posix exercises both // platforms' separator and drive-letter rules from here, so a regression in // either fails this suite regardless of which OS actually runs it. diff --git a/packages/parsers/src/assetResolution.ts b/packages/parsers/src/assetResolution.ts index 39590905f76..5b1ce169d06 100644 --- a/packages/parsers/src/assetResolution.ts +++ b/packages/parsers/src/assetResolution.ts @@ -1,8 +1,10 @@ import { scanHtmlOpeningTags, decodeAuthoredAttribute } from "./htmlAttributeSpans.js"; import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync } from "node:fs"; -import { isAbsolute, join, posix, relative, resolve, sep } from "node:path"; +import { isAbsolute, join, parse, posix, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { isNonRelativeUrl } from "./assetPaths.js"; import { decodeUrlPathVariants } from "./composition.js"; +import { decodedUrlPath } from "./utils/urlPath.js"; import { rewriteAssetPath } from "./rewriteSubCompPaths.js"; /** The subset of `node:path` that `isWithinProjectRoot` needs to run under @@ -122,14 +124,46 @@ export function resolveLocalAssetCandidates(projectDir: string, url: string): st const normalized = posix.normalize(projectRelative.replace(/\\/g, "/")); const clamped = normalized.replace(/^(\.\.\/)+/, ""); - if (clamped && !clamped.startsWith("..")) { - addCandidate(candidates, resolve(projectRoot, clamped)); - } + if (!clamped || clamped.startsWith("..")) continue; + const reanchored = resolve(projectRoot, clamped); + if (isWithinProjectRoot(projectRoot, reanchored)) addCandidate(candidates, reanchored); } return candidates; } +// On Windows even a stat of a UNC or device path (\\host\share, \\?\, \\.\) connects to that host. +export function namesAnotherNetworkRoot(base: string, target: string): boolean { + const root = parse(resolve(target)).root; + return /^[\\/]{2}/.test(root) && root.toLowerCase() !== parse(resolve(base)).root.toLowerCase(); +} + +const LEADING_PARENT_SEGMENT = /^\.\.(?:[\\/]|$)/; + +function staysInsideProject(projectDir: string, path: string): boolean { + const relativePath = relative(resolve(projectDir), path); + return !LEADING_PARENT_SEGMENT.test(relativePath) && !isAbsolute(relativePath); +} + +/** The project file a data-composition-src names, as the preview reads it; null when it leaves the project. */ +export function resolveCompositionSrc(projectDir: string, src: string): string | null { + const rooted = (value: string) => resolve(projectDir, value.replace(/^(?:\.?[\\/])+/, "")); + const decoded = rooted(decodedUrlPath(src)); + if (!staysInsideProject(projectDir, decoded)) return null; + const raw = rooted(src); + return !existsSync(decoded) && staysInsideProject(projectDir, raw) && existsSync(raw) + ? raw + : decoded; +} + +// A Windows absolute src (C:\clip.mp4) that render copies in. A POSIX /x is a project URL; another share is skipped. +export function localAbsoluteAssetFile(projectDir: string, src: string): string | null { + const path = cleanAssetUrl(src); + if (isNonRelativeUrl(path) || !isAbsolute(path)) return null; + const file = resolve(projectDir, path); + return !namesAnotherNetworkRoot(projectDir, file) && existsSync(file) ? file : null; +} + export function resolveExistingLocalAsset( projectDir: string, url: string, @@ -151,7 +185,10 @@ export function resolveAuthoredAssetFile( const rootRelative = cleanSrc.startsWith("/") ? cleanSrc : rewriteAssetPath(compositionFile, cleanSrc, (path) => existsSync(join(projectDir, path))); - return resolveExistingLocalAsset(projectDir, rootRelative)?.resolved ?? null; + return ( + resolveExistingLocalAsset(projectDir, rootRelative)?.resolved ?? + localAbsoluteAssetFile(projectDir, cleanSrc) + ); } // Candidates for a variant whose join escaped the project root, re-anchored at the root. diff --git a/packages/parsers/src/assetResolution.win32.test.ts b/packages/parsers/src/assetResolution.win32.test.ts new file mode 100644 index 00000000000..80c34f8a083 --- /dev/null +++ b/packages/parsers/src/assetResolution.win32.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; + +// Windows path rules on any host, and a disk that records every look at it. +const touched = vi.hoisted(() => [] as string[]); +const present = vi.hoisted(() => new Set()); +vi.mock("node:path", async () => { + const path = await vi.importActual("node:path"); + return { ...path.win32, default: path.win32 }; +}); +vi.mock("node:fs", async () => { + const fs = await vi.importActual("node:fs"); + const existsSync = (path: string) => { + touched.push(path); + return present.has(path); + }; + return { ...fs, existsSync, default: { ...fs, existsSync } }; +}); + +const { + localAbsoluteAssetFile, + namesAnotherNetworkRoot, + resolveAuthoredAssetFile, + resolveCompositionSrc, + resolveLocalAssetCandidates, +} = await import("./assetResolution.js"); +const { resolve } = await import("node:path"); + +const PROJECT = "C:\\project"; +const insideProject = (path: string) => path.toLowerCase().startsWith("c:\\project\\"); + +describe("namesAnotherNetworkRoot under Windows rules", () => { + it.each([ + "\\\\attacker.example\\share\\x.png", + "//attacker.example/share/x.png", + "/\\attacker.example\\share\\x.png", + "\\\\?\\UNC\\attacker.example\\share\\x.png", + "\\\\.\\pipe\\x", + ])("is true for %s", (path) => { + expect(namesAnotherNetworkRoot(PROJECT, path)).toBe(true); + }); + + it.each(["C:\\project\\a.png", "D:\\media\\a.png", "assets\\a.png"])( + "is false for %s", + (path) => { + expect(namesAnotherNetworkRoot(PROJECT, path)).toBe(false); + }, + ); + + it("is false for a path on the share the project itself lives on", () => { + expect(namesAnotherNetworkRoot("\\\\nas\\films\\project", "//NAS/films/clip.mp4")).toBe(false); + }); + + it.each([ + "node_modules/gsap/dist/gsap.min.js", + "../shared/gsap/dist/gsap.min.js", + "/vendor/gsap/dist/gsap.min.js", + "\\\\nas\\films\\shared\\clip.mp4", + ])("is false for %s resolved in a project on a share", (src) => { + const project = "\\\\nas\\films\\project"; + expect(namesAnotherNetworkRoot(project, resolve(project, src))).toBe(false); + }); +}); + +describe("resolveLocalAssetCandidates under Windows rules", () => { + it.each([ + "C:\\Users\\me\\.env", + "C:/Users/me/.env", + "\\Users\\me\\.env", + "/%2FUsers/me/.env", + "%2F%2Fattacker.example/share/x.css", + "/C:%5CUsers%5Cme%5C.env", + ])("offers only files inside the project for %s", (url) => { + expect(resolveLocalAssetCandidates(PROJECT, url).filter((c) => !insideProject(c))).toEqual([]); + }); + + it("still clamps a leading ../ into the project, as a browser does", () => { + expect(resolveLocalAssetCandidates(PROJECT, "../assets/a.png")).toEqual([ + "C:\\project\\assets\\a.png", + ]); + }); +}); + +describe("resolveCompositionSrc under Windows rules", () => { + it.each(["C:\\elsewhere\\scene.html", "..\\scene.html", "%2E%2E/scene.html"])( + "refuses %s without touching the disk", + (src) => { + touched.length = 0; + expect(resolveCompositionSrc(PROJECT, src)).toBeNull(); + expect(touched).toEqual([]); + }, + ); + + it("reads a UNC-looking src as a project path, never as a share", () => { + touched.length = 0; + expect(resolveCompositionSrc(PROJECT, "\\\\attacker.example\\share\\x.html")).toBe( + "C:\\project\\attacker.example\\share\\x.html", + ); + expect(touched.every(insideProject)).toBe(true); + }); +}); + +describe("localAbsoluteAssetFile under Windows rules", () => { + it.each([ + ["C:\\media\\clip.mp4", "C:\\media\\clip.mp4"], + ["D:/media/clip.mp4", "D:\\media\\clip.mp4"], + ["C:\\media\\clip.mp4?v=2", "C:\\media\\clip.mp4"], + ["\\media\\clip.mp4", "C:\\media\\clip.mp4"], + ])("finds the local file %s names, as render does", (src, file) => { + present.clear(); + present.add(file); + expect(localAbsoluteAssetFile(PROJECT, src)).toBe(file); + expect(resolveAuthoredAssetFile(PROJECT, "index.html", src)).toBe(file); + }); + + it("finds a file on the share the project itself lives on", () => { + present.clear(); + present.add("\\\\NAS\\films\\shared\\clip.mp4"); + expect( + localAbsoluteAssetFile("\\\\nas\\films\\project", "\\\\NAS\\films\\shared\\clip.mp4"), + ).toBe("\\\\NAS\\films\\shared\\clip.mp4"); + }); + + it.each([ + "/media/clip.mp4", + "assets/clip.mp4", + "file:///C:/media/clip.mp4", + "https://x.example/a.mp4", + ])("leaves %s to the project resolver", (src) => { + touched.length = 0; + expect(localAbsoluteAssetFile(PROJECT, src)).toBeNull(); + expect(touched).toEqual([]); + }); + + it("never looks at another computer's share", () => { + touched.length = 0; + expect(localAbsoluteAssetFile(PROJECT, "\\\\attacker.example\\share\\x.mp4")).toBeNull(); + expect(touched).toEqual([]); + }); + + it("returns null when the file is not there", () => { + present.clear(); + expect(localAbsoluteAssetFile(PROJECT, "C:\\media\\gone.mp4")).toBeNull(); + }); +}); diff --git a/packages/producer/src/services/animatedGifPrep.ts b/packages/producer/src/services/animatedGifPrep.ts index d3519c66d8c..5f9a0bdf23d 100644 --- a/packages/producer/src/services/animatedGifPrep.ts +++ b/packages/producer/src/services/animatedGifPrep.ts @@ -5,6 +5,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path"; import { parseHTML } from "linkedom"; import { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "@hyperframes/core"; import { DEFAULT_VP9_CPU_USED, runFfmpeg, renameWithRetry } from "@hyperframes/engine"; +import { namesAnotherNetworkRoot } from "@hyperframes/parsers/asset-resolution"; import { isHttpUrl } from "../utils/urlDownloader.js"; import { encoderFailureError } from "./render/encoderInterruption.js"; @@ -92,9 +93,13 @@ export function resolveLocalImageSourcePath( if (mapped && existsSync(mapped)) return mapped; if (isHttpUrl(trimmed)) return null; + const protocolRelativeUrlOnShare = + basePath.startsWith("/") && namesAnotherNetworkRoot(options.projectDir, basePath); const projectRelative = basePath.startsWith("/") ? basePath.slice(1) : basePath; const candidates = [ - isAbsolute(basePath) ? basePath : resolve(options.projectDir, projectRelative), + ...(protocolRelativeUrlOnShare + ? [] + : [isAbsolute(basePath) ? basePath : resolve(options.projectDir, projectRelative)]), resolve(options.downloadDir, normalizedBase), ]; diff --git a/packages/producer/src/services/htmlCompiler.networkRoot.test.ts b/packages/producer/src/services/htmlCompiler.networkRoot.test.ts new file mode 100644 index 00000000000..a327ad12061 --- /dev/null +++ b/packages/producer/src/services/htmlCompiler.networkRoot.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +// Any host stands in for Windows here: "/share-host/..." plays the part of another computer's share. +const SHARE = "/share-host/"; +const touched = vi.hoisted(() => ({ paths: [] as string[] })); +const onShare = (path: string) => resolve(String(path)).startsWith("/share-host/"); + +vi.mock("@hyperframes/parsers/asset-resolution", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, namesAnotherNetworkRoot: (_base: string, target: string) => onShare(target) }; +}); + +for (const fsModule of ["fs", "node:fs"]) { + vi.doMock(fsModule, async () => { + const actual = await vi.importActual("node:fs"); + const existsSync = (path: string) => { + touched.paths.push(String(path)); + return actual.existsSync(path); + }; + return { ...actual, existsSync, default: { ...actual, existsSync } }; + }); +} + +const recordProbe = async (path: string) => { + touched.paths.push(path); + return { durationSeconds: 1, isVFR: false }; +}; +vi.mock("@hyperframes/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + extractMediaMetadata: recordProbe, + probeMediaProfile: async (path: string) => { + touched.paths.push(path); + return { hasVideoStream: true, hasAudioStream: true, visualKind: "moving" }; + }, + analyzeKeyframeIntervals: async (path: string) => { + touched.paths.push(path); + return { isProblematic: false, maxIntervalSeconds: 0 }; + }, + }; +}); +vi.mock("../utils/ffprobe.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, extractMediaMetadata: recordProbe, extractAudioMetadata: recordProbe }; +}); + +const { compileForRender } = await import("./htmlCompiler.js"); +const { resolveLocalImageSourcePath } = await import("./animatedGifPrep.js"); + +describe("composition paths on another network root", () => { + let projectDir: string | undefined; + + afterEach(() => { + if (projectDir) rmSync(projectDir, { recursive: true, force: true }); + projectDir = undefined; + touched.paths.length = 0; + }); + + it("are never looked at by the render compile probes", async () => { + projectDir = mkdtempSync(join(tmpdir(), "hf-network-root-")); + writeFileSync(join(projectDir, "local.mp4"), "fixture"); + const htmlPath = join(projectDir, "index.html"); + writeFileSync( + htmlPath, + ` +
+ + + + + +
+ + `, + ); + + await compileForRender(projectDir, htmlPath, join(projectDir, "downloads")); + await new Promise((done) => setTimeout(done, 0)); + + expect(touched.paths.filter(onShare)).toEqual([]); + expect(touched.paths.some((path) => path.endsWith("local.mp4"))).toBe(true); + }); + + it("are never looked at when an image src is resolved", () => { + projectDir = mkdtempSync(join(tmpdir(), "hf-network-root-img-")); + const downloadDir = join(projectDir, "downloads"); + expect( + resolveLocalImageSourcePath(`/${SHARE}anim.gif`, { projectDir, downloadDir }), + ).toBeNull(); + expect(touched.paths.filter(onShare)).toEqual([]); + }); +}); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index ff2fde18bf7..0b98ed1299e 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -71,11 +71,13 @@ import { } from "@hyperframes/parsers/sub-composition-validity"; import { isUnresolvedAssetPlaceholder, + namesAnotherNetworkRoot, readProjectFile, readTextFile, + resolveCompositionSrc, } from "@hyperframes/parsers/asset-resolution"; import { extractMediaMetadata, extractAudioMetadata } from "../utils/ffprobe.js"; -import { isPathInside, resolveCompositionSrc, toExternalAssetKey } from "../utils/paths.js"; +import { isPathInside, toExternalAssetKey } from "../utils/paths.js"; import { collectHiddenVideoIds, collectRenderMedia } from "./renderMediaCollector.js"; import { type VideoElement, @@ -537,6 +539,9 @@ async function resolveMediaDuration( if (!filePath.startsWith("/")) filePath = join(baseDir, filePath); // ffprobe reads only the header of a remote source, so it is probed in place. filePath = urlByAbsentLocalPath?.get(filePath) ?? filePath; + if (!isHttpUrl(filePath) && namesAnotherNetworkRoot(baseDir, filePath)) { + return { duration: null, resolvedPath: filePath }; + } } if (!isHttpUrl(filePath) && !existsSync(filePath)) { @@ -2033,7 +2038,10 @@ function rewriteUnresolvableGsapToCdn( compSrcPath?: string, ): string { let gsapDist: string | undefined; - const exists = (path: string) => existsSync(resolve(projectDir, path)); + const exists = (path: string) => { + const file = resolve(projectDir, path); + return !namesAnotherNetworkRoot(projectDir, file) && existsSync(file); + }; return html.replace( /(]*\bsrc=["'])([^"']*gsap[^"']*\/dist\/([^"']+))(["'][^>]*>)/gi, (full, prefix, src, file, suffix) => { @@ -2301,7 +2309,7 @@ export async function compileForRender( for (const video of videos) { if (isHttpUrl(video.src)) continue; const videoPath = resolve(projectDir, video.src); - if (advised.has(videoPath)) continue; + if (advised.has(videoPath) || namesAnotherNetworkRoot(projectDir, videoPath)) continue; advised.add(videoPath); const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`; (async () => { diff --git a/packages/producer/src/utils/paths.ts b/packages/producer/src/utils/paths.ts index b0495d2db2f..14327a081ab 100644 --- a/packages/producer/src/utils/paths.ts +++ b/packages/producer/src/utils/paths.ts @@ -9,9 +9,7 @@ import { relative as nodeRelative, isAbsolute as nodeIsAbsolute, } from "node:path"; -import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { decodedUrlPath } from "@hyperframes/parsers/asset-paths"; export interface RenderPaths { absoluteProjectDir: string; @@ -136,12 +134,3 @@ export function resolveRenderPaths( return { absoluteProjectDir, absoluteOutputPath }; } - -/** The project file a data-composition-src names, as the preview reads it; null when it leaves the project. */ -export function resolveCompositionSrc(projectDir: string, src: string): string | null { - const rooted = (value: string) => nodeResolve(projectDir, value.replace(/^(?:\.?[\\/])+/, "")); - const decoded = rooted(decodedUrlPath(src)); - if (!isPathInside(decoded, projectDir)) return null; - const raw = rooted(src); - return !existsSync(decoded) && isPathInside(raw, projectDir) && existsSync(raw) ? raw : decoded; -}