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
19 changes: 19 additions & 0 deletions packages/cli/src/commands/keyframes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<div data-composition-id="outside"></div>`);
const src = written === "OUTSIDE" ? outside : written;
writeFileSync(
join(project, "index.html"),
`<!doctype html><html><body><div data-composition-id="main" data-duration="4"><div data-composition-id="intro" data-composition-src="${src}"></div></div></body></html>`,
);

expect(collectCompositions(join(project, "index.html"))).toHaveLength(1);
},
);
});
3 changes: 2 additions & 1 deletion packages/cli/src/commands/keyframes.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/utils/catalogUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/utils/catalogUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -105,7 +108,8 @@ function reachableCompositions(projectDir: string, entryPath: string): Set<strin
}
if (html.length > 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;
Expand Down
90 changes: 90 additions & 0 deletions packages/lint/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
<div data-composition-src="${srcPath}" data-composition-id="scene" data-start="0" data-duration="5"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}

function outsideScene(project: string): string {
const outside = join(dirname(project), `${project.split(/[\\/]/).pop()}-scene.html`);
writeFileSync(
outside,
`<html><body><div data-composition-id="scene" data-width="1920" data-height="1080">
<audio id="leak" src="outside-audio.mp3" data-start="0" data-duration="1"></audio>
</div></body></html>`,
);
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": `<html><body><div data-composition-id="scene" data-width="1920" data-height="1080"></div></body></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(
"</body>",
`<script type="importmap" src="/%2F${secret.slice(1)}"></script>\n<link rel="stylesheet" href="/%2F${secret.slice(1)}">\n</body>`,
),
);
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) => `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
<script src="${src}"></script>
</body></html>`;
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);
});
});
43 changes: 33 additions & 10 deletions packages/lint/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,8 +28,10 @@ import {
isRemoteOrInlineUrl,
isUnresolvedAssetPlaceholder,
isWithinProjectRoot,
localAbsoluteAssetFile,
maskNonScannableRanges,
readProjectFile,
resolveCompositionSrc,
resolveExistingLocalAsset,
resolveLocalAssetCandidates,
resolveProjectRelativeSrc,
Expand Down Expand Up @@ -175,19 +177,27 @@ export function linkedSources(
projectDir: string,
html: string,
compSrcPath?: string,
): Pick<HyperframeLinterOptions, "externalStyles" | "externalScripts"> {
): 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<HyperframeLinterOptions["externalScripts"]> {
): Pick<HyperframeLinterOptions, "externalScripts" | "scriptsOutsideProjectUnread"> {
const scripts: NonNullable<HyperframeLinterOptions["externalScripts"]> = [];
const scriptsOutsideProjectUnread: string[] = [];
const { document } = parseHTML(html);
for (const element of querySelectorAllIncludingTemplates(document, "script[src]")) {
const src = element.getAttribute("src") ?? "";
Expand All @@ -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)
Expand All @@ -213,7 +228,7 @@ function collectExternalScripts(
rootRelativePath: script.rootRelativePath,
});
}
return scripts;
return { externalScripts: scripts, scriptsOutsideProjectUnread };
}

function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions packages/lint/src/project.windowsAbsolute.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@hyperframes/parsers/asset-resolution")>();
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<string, string> = {}): Promise<string[]> {
project = mkdtempSync(join(tmpdir(), "hf-lint-windows-absolute-"));
writeFileSync(
join(project, "index.html"),
`<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">${body}</div>
<script>window.__timelines = {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`,
);
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('<img src="C:\\media\\clip.png" />')).not.toContain(
"missing_local_asset",
);
});

it("are still reported when the file is not there", async () => {
expect(await lintCodes('<img src="C:\\media\\gone.png" />')).toContain("missing_local_asset");
});

it("are not reported as a missing texture mask in the page's own CSS", async () => {
const codes = await lintCodes(
'<style>.grain { mask-image: url("C:\\media\\grain.png"); }</style><div class="grain"></div>',
);
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(
'<link rel="stylesheet" href="styles.css" /><div class="grain"></div>',
{ "styles.css": '.grain { mask-image: url("C:\\media\\grain.png"); }' },
);
expect(codes).toContain("texture_mask_asset_not_found");
});
});
1 change: 1 addition & 0 deletions packages/lint/src/rules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading