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(
+ "
+
+
+