diff --git a/packages/lint/src/project.test.ts b/packages/lint/src/project.test.ts
index 64440e8e6..aca134326 100644
--- a/packages/lint/src/project.test.ts
+++ b/packages/lint/src/project.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { ChildProcess, execFile } from "node:child_process";
-import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { HyperframeLintFinding } from "./types.js";
@@ -47,6 +47,28 @@ afterEach(() => {
dirs = [];
});
+describe("external symlink assets", () => {
+ it("does not report a shared asset addressed through an in-project symlink", async () => {
+ const project = makeProject(
+ validHtml().replace("", '
'),
+ );
+ const externalDir = tmpProject("shared-assets");
+ dirs.push(externalDir);
+ mkdirSync(join(project, "assets"));
+ writeFileSync(join(externalDir, "sample.svg"), "");
+ try {
+ symlinkSync(externalDir, join(project, "assets", "shared"), "dir");
+ } catch {
+ return;
+ }
+
+ const { results } = await lintProject(project);
+ const findings = results.flatMap((result) => result.result.findings);
+
+ expect(findings.some((finding) => finding.code === "missing_local_asset")).toBe(false);
+ });
+});
+
describe("missing_or_empty_sub_composition", () => {
function htmlWithSubComp(srcPath: string): string {
return `
diff --git a/packages/studio-server/src/helpers/proxyTranscoder.test.ts b/packages/studio-server/src/helpers/proxyTranscoder.test.ts
index e335b2e4f..2c52d5b6f 100644
--- a/packages/studio-server/src/helpers/proxyTranscoder.test.ts
+++ b/packages/studio-server/src/helpers/proxyTranscoder.test.ts
@@ -3,6 +3,7 @@ import {
existsSync,
mkdtempSync,
readdirSync,
+ realpathSync,
rmSync,
symlinkSync,
utimesSync,
@@ -497,9 +498,9 @@ describe("resolveProxy", () => {
expect(calls).toHaveLength(0);
});
- it("rejects an in-project symlink whose target escapes the project", async () => {
+ it("proxies an external target reached through an in-project symlink", async () => {
const { spawn, calls } = createSpawnSpy();
- const { resolveProxy, ProxySourceOutsideProjectError } = await loadModule(spawn, FFMPEG_PATH);
+ const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
const projectDir = tmpProject();
const outsideDir = tmpProject();
const outsidePath = join(outsideDir, "outside.mov");
@@ -507,10 +508,15 @@ describe("resolveProxy", () => {
writeFileSync(outsidePath, "source-bytes");
symlinkSync(outsidePath, sourcePath);
- await expect(resolveProxy(projectDir, sourcePath)).rejects.toBeInstanceOf(
- ProxySourceOutsideProjectError,
- );
- expect(calls).toHaveLength(0);
+ const cachePath = getProxyCachePath(projectDir, sourcePath);
+ const result = resolveProxy(projectDir, sourcePath);
+ await flush();
+
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.args).toContain(realpathSync(outsidePath));
+ succeed(calls[0]!);
+ await expect(result).resolves.toBe(cachePath);
+ expect(cachePath.startsWith(join(realpathSync(projectDir), ".transcode-cache"))).toBe(true);
});
it("retries after the source file changes (mtime in the cache key invalidates the remembered failure)", async () => {
diff --git a/packages/studio-server/src/helpers/proxyTranscoder.ts b/packages/studio-server/src/helpers/proxyTranscoder.ts
index 097453df6..c8ad030e1 100644
--- a/packages/studio-server/src/helpers/proxyTranscoder.ts
+++ b/packages/studio-server/src/helpers/proxyTranscoder.ts
@@ -9,7 +9,7 @@ import {
unlinkSync,
utimesSync,
} from "node:fs";
-import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
+import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
import { probeMediaMetadata } from "./mediaMetadata.js";
import { cleanupProxyCache } from "./proxyCache.js";
@@ -96,7 +96,7 @@ export class ProxyCapacityError extends ProxyTranscodeError {
export class ProxySourceOutsideProjectError extends ProxyTranscodeError {
constructor() {
- super("media proxy source must be inside the project", null, "");
+ super("media proxy source must be addressed through the project", null, "");
this.name = "ProxySourceOutsideProjectError";
}
}
@@ -129,33 +129,52 @@ export async function waitForProxy(
}
/**
- * Cache key inputs per the plan: source path relative to the project (so the
- * cache is portable across checkouts at different absolute locations), mtime
- * and file size (mtime alone can collide on same-second re-exports on
- * coarse-timestamp filesystems; size catches nearly all such cases at zero
- * cost), and a params version token so changing the ffmpeg recipe below
- * invalidates every cached proxy cleanly.
+ * Cache key inputs per the plan: project-relative source path (portable across
+ * checkouts), canonical source identity (so retargeted external symlinks do
+ * not reuse a proxy), mtime and file size (mtime alone can collide on
+ * same-second re-exports on coarse-timestamp filesystems; size catches nearly
+ * all such cases at zero cost), and a params version token so changing the
+ * ffmpeg recipe below invalidates every cached proxy cleanly.
*/
type CanonicalProxySource = {
projectDir: string;
sourcePath: string;
relativePath: string;
+ cacheIdentity: string;
};
function canonicalizeProxySource(
projectDir: string,
absoluteSourcePath: string,
): CanonicalProxySource {
- const canonicalProjectDir = realpathSync(projectDir);
- const canonicalSourcePath = realpathSync(absoluteSourcePath);
- const relPath = relative(canonicalProjectDir, canonicalSourcePath);
- if (relPath === ".." || relPath.startsWith(`..${sep}`) || isAbsolute(relPath)) {
+ const requestedProjectDir = resolve(projectDir);
+ const requestedSourcePath = resolve(absoluteSourcePath);
+ const requestedRelativePath = relative(requestedProjectDir, requestedSourcePath);
+ if (
+ requestedRelativePath === ".." ||
+ requestedRelativePath.startsWith(`..${sep}`) ||
+ isAbsolute(requestedRelativePath)
+ ) {
throw new ProxySourceOutsideProjectError();
}
+
+ const canonicalProjectDir = realpathSync(projectDir);
+ const canonicalSourcePath = realpathSync(absoluteSourcePath);
+ const canonicalRelativePath = relative(canonicalProjectDir, canonicalSourcePath);
+ const sourceIsInsideCanonicalProject =
+ canonicalRelativePath !== ".." &&
+ !canonicalRelativePath.startsWith(`..${sep}`) &&
+ !isAbsolute(canonicalRelativePath);
return {
projectDir: canonicalProjectDir,
sourcePath: canonicalSourcePath,
- relativePath: relPath.normalize("NFC"),
+ relativePath: requestedRelativePath.normalize("NFC"),
+ // An external target needs a stable identity in addition to its project-local
+ // symlink path, otherwise retargeting the link can reuse an unrelated proxy.
+ cacheIdentity: (sourceIsInsideCanonicalProject
+ ? canonicalRelativePath
+ : canonicalSourcePath
+ ).normalize("NFC"),
};
}
@@ -163,7 +182,7 @@ function buildProxyCacheKey(source: CanonicalProxySource, variant: ProxyVariant)
const stat = statSync(source.sourcePath);
return createHash("sha256")
.update(
- `${source.relativePath}\0${stat.mtimeMs}\0${stat.size}\0${PROXY_PARAMS_VERSION}\0${variant}`,
+ `${source.relativePath}\0${source.cacheIdentity}\0${stat.mtimeMs}\0${stat.size}\0${PROXY_PARAMS_VERSION}\0${variant}`,
)
.digest("hex");
}
diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts
index 257e747db..cde15caca 100644
--- a/packages/studio-server/src/routes/preview.test.ts
+++ b/packages/studio-server/src/routes/preview.test.ts
@@ -516,6 +516,31 @@ describe("hf-id surfacing in preview route", () => {
expect(readFileSync(svgPath, "utf-8")).toBe(svgBytes);
});
+ it("serves an asset reached through an in-project symlink to a shared external directory", async () => {
+ const projectDir = createProjectDir();
+ const externalDir = mkdtempSync(join(tmpdir(), "hf-preview-shared-assets-"));
+ tempDirs.push(externalDir);
+ mkdirSync(join(projectDir, "assets"));
+ writeFileSync(join(externalDir, "sample.svg"), "");
+ if (!tryCreateSymlink(externalDir, join(projectDir, "assets", "shared"), "dir")) return;
+
+ const app = new Hono();
+ registerPreviewRoutes(app, createAdapter(projectDir));
+
+ const response = await app.request(
+ "http://localhost/projects/demo/preview/assets/shared/sample.svg",
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toContain("image/svg+xml");
+ expect(await response.text()).toBe("");
+
+ const traversal = await app.request(
+ "http://localhost/projects/demo/preview/..%2f..%2f..%2fetc%2fpasswd",
+ );
+ expect(traversal.status).toBe(404);
+ });
+
it("sub-comp route does NOT persist ids inside a plain (runtime clone-source)", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
@@ -1077,6 +1102,51 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
);
});
+ it("injects and serves a proxy for a hostile video through an external asset symlink", async () => {
+ const projectDir = createProjectDir();
+ const externalDir = mkdtempSync(join(tmpdir(), "hf-preview-shared-video-"));
+ tempDirs.push(externalDir);
+ mkdirSync(join(projectDir, "assets"));
+ writeFileSync(join(externalDir, "clip.mov"), "shared-hevc-bytes");
+ if (!tryCreateSymlink(externalDir, join(projectDir, "assets", "shared"), "dir")) return;
+
+ const proxyPath = join(projectDir, ".transcode-cache", "shared.mp4");
+ const resolveProxyMock = vi.fn(async () => {
+ mkdirSync(join(projectDir, ".transcode-cache"), { recursive: true });
+ writeFileSync(proxyPath, "proxy-bytes");
+ return proxyPath;
+ });
+ const scanMapMock = vi.fn(async () => ({
+ "/assets/shared/clip.mov": {
+ codecName: "hevc",
+ browserHostile: true,
+ representativeMime: 'video/mp4; codecs="hvc1.1.6.L120.B0"',
+ },
+ }));
+ const { registerPreviewRoutes: register } = await loadPreviewModule({
+ resolveProxyImpl: resolveProxyMock,
+ scanMapImpl: scanMapMock,
+ });
+ const app = new Hono();
+ register(app, createAdapter(projectDir));
+
+ const preview = await app.request("http://localhost/projects/demo/preview");
+ expect(await preview.text()).toContain("/assets/shared/clip.mov");
+ expect(scanMapMock).toHaveBeenCalled();
+
+ const proxied = await app.request(
+ "http://localhost/projects/demo/preview/assets/shared/clip.mov?hf-proxy=h264",
+ );
+ expect(proxied.status).toBe(200);
+ expect(proxied.headers.get("Content-Type")).toBe("video/mp4");
+ expect(await proxied.text()).toBe("proxy-bytes");
+ expect(resolveProxyMock).toHaveBeenCalledWith(
+ projectDir,
+ join(projectDir, "assets", "shared", "clip.mov"),
+ "h264",
+ );
+ });
+
it("escapes script terminators and JavaScript line separators in codec-map keys", async () => {
const projectDir = createProjectDir();
const { registerPreviewRoutes: register } = await loadPreviewModule({
diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts
index 78d421033..864af351b 100644
--- a/packages/studio-server/src/routes/preview.ts
+++ b/packages/studio-server/src/routes/preview.ts
@@ -1,8 +1,9 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, statSync } from "node:fs";
-import { join } from "node:path";
+import { join, resolve } from "node:path";
import { createHash } from "node:crypto";
import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
+import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import type { StudioApiAdapter } from "../types.js";
import { resolveWithinProject } from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
@@ -528,7 +529,12 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
const subPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
);
- const file = resolveWithinProject(project.dir, subPath);
+ // Assets are read-only and should mirror the renderer: permit a path that
+ // is lexically inside the project even if an explicit project symlink
+ // targets a shared directory outside it. Composition source files still
+ // use resolveWithinProject because preview mutates their data-hf-id values.
+ const candidate = resolve(project.dir, subPath);
+ const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
if (!file) {
return c.text("not found", 404);
}