fix(preview): serve external symlink assets (#2764)

## What

Allow Studio Preview to serve an asset reached through a project-local symlink whose target is in a shared directory outside the project, including browser-hostile video assets that need an authoring proxy.

## Why

Preview rejected these assets with a 404 while the renderer accepted the same path. The initial static-route fix still failed for HEVC, ProRes, AV1, and VP9 assets because the proxy transcoder rejected the external target.

## How

Use lexical project-root containment for the read-only static asset route and proxy source request. The transcoder canonicalizes the target for ffmpeg and includes that identity in its cache key, while keeping the proxy cache inside the project. Composition source paths retain canonical containment because preview can persist their data-hf-id values.

## Test plan

- [x] Unit tests added/updated
- [x] `bun run --cwd packages/studio-server test` (397 tests)
- [x] Studio Server typecheck, oxlint, and oxfmt
- [x] External-symlinked hostile-video proxy route regression
- [x] Static-route traversal regression
- [ ] Documentation updated (not applicable)
This commit is contained in:
James Russo
2026-07-24 13:35:10 -04:00
committed by GitHub
parent 9e7ddb0188
commit 7778c093b6
5 changed files with 146 additions and 23 deletions
+23 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { ChildProcess, execFile } from "node:child_process"; 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 { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import type { HyperframeLintFinding } from "./types.js"; import type { HyperframeLintFinding } from "./types.js";
@@ -47,6 +47,28 @@ afterEach(() => {
dirs = []; dirs = [];
}); });
describe("external symlink assets", () => {
it("does not report a shared asset addressed through an in-project symlink", async () => {
const project = makeProject(
validHtml().replace("</div>", '<img src="assets/shared/sample.svg" /></div>'),
);
const externalDir = tmpProject("shared-assets");
dirs.push(externalDir);
mkdirSync(join(project, "assets"));
writeFileSync(join(externalDir, "sample.svg"), "<svg>shared</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", () => { describe("missing_or_empty_sub_composition", () => {
function htmlWithSubComp(srcPath: string): string { function htmlWithSubComp(srcPath: string): string {
return `<html><body> return `<html><body>
@@ -3,6 +3,7 @@ import {
existsSync, existsSync,
mkdtempSync, mkdtempSync,
readdirSync, readdirSync,
realpathSync,
rmSync, rmSync,
symlinkSync, symlinkSync,
utimesSync, utimesSync,
@@ -497,9 +498,9 @@ describe("resolveProxy", () => {
expect(calls).toHaveLength(0); 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 { spawn, calls } = createSpawnSpy();
const { resolveProxy, ProxySourceOutsideProjectError } = await loadModule(spawn, FFMPEG_PATH); const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
const projectDir = tmpProject(); const projectDir = tmpProject();
const outsideDir = tmpProject(); const outsideDir = tmpProject();
const outsidePath = join(outsideDir, "outside.mov"); const outsidePath = join(outsideDir, "outside.mov");
@@ -507,10 +508,15 @@ describe("resolveProxy", () => {
writeFileSync(outsidePath, "source-bytes"); writeFileSync(outsidePath, "source-bytes");
symlinkSync(outsidePath, sourcePath); symlinkSync(outsidePath, sourcePath);
await expect(resolveProxy(projectDir, sourcePath)).rejects.toBeInstanceOf( const cachePath = getProxyCachePath(projectDir, sourcePath);
ProxySourceOutsideProjectError, const result = resolveProxy(projectDir, sourcePath);
); await flush();
expect(calls).toHaveLength(0);
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 () => { it("retries after the source file changes (mtime in the cache key invalidates the remembered failure)", async () => {
@@ -9,7 +9,7 @@ import {
unlinkSync, unlinkSync,
utimesSync, utimesSync,
} from "node:fs"; } 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 { findFfBinary } from "@hyperframes/parsers/ff-binaries";
import { probeMediaMetadata } from "./mediaMetadata.js"; import { probeMediaMetadata } from "./mediaMetadata.js";
import { cleanupProxyCache } from "./proxyCache.js"; import { cleanupProxyCache } from "./proxyCache.js";
@@ -96,7 +96,7 @@ export class ProxyCapacityError extends ProxyTranscodeError {
export class ProxySourceOutsideProjectError extends ProxyTranscodeError { export class ProxySourceOutsideProjectError extends ProxyTranscodeError {
constructor() { 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"; this.name = "ProxySourceOutsideProjectError";
} }
} }
@@ -129,33 +129,52 @@ export async function waitForProxy<T>(
} }
/** /**
* Cache key inputs per the plan: source path relative to the project (so the * Cache key inputs per the plan: project-relative source path (portable across
* cache is portable across checkouts at different absolute locations), mtime * checkouts), canonical source identity (so retargeted external symlinks do
* and file size (mtime alone can collide on same-second re-exports on * not reuse a proxy), mtime and file size (mtime alone can collide on
* coarse-timestamp filesystems; size catches nearly all such cases at zero * same-second re-exports on coarse-timestamp filesystems; size catches nearly
* cost), and a params version token so changing the ffmpeg recipe below * all such cases at zero cost), and a params version token so changing the
* invalidates every cached proxy cleanly. * ffmpeg recipe below invalidates every cached proxy cleanly.
*/ */
type CanonicalProxySource = { type CanonicalProxySource = {
projectDir: string; projectDir: string;
sourcePath: string; sourcePath: string;
relativePath: string; relativePath: string;
cacheIdentity: string;
}; };
function canonicalizeProxySource( function canonicalizeProxySource(
projectDir: string, projectDir: string,
absoluteSourcePath: string, absoluteSourcePath: string,
): CanonicalProxySource { ): CanonicalProxySource {
const canonicalProjectDir = realpathSync(projectDir); const requestedProjectDir = resolve(projectDir);
const canonicalSourcePath = realpathSync(absoluteSourcePath); const requestedSourcePath = resolve(absoluteSourcePath);
const relPath = relative(canonicalProjectDir, canonicalSourcePath); const requestedRelativePath = relative(requestedProjectDir, requestedSourcePath);
if (relPath === ".." || relPath.startsWith(`..${sep}`) || isAbsolute(relPath)) { if (
requestedRelativePath === ".." ||
requestedRelativePath.startsWith(`..${sep}`) ||
isAbsolute(requestedRelativePath)
) {
throw new ProxySourceOutsideProjectError(); 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 { return {
projectDir: canonicalProjectDir, projectDir: canonicalProjectDir,
sourcePath: canonicalSourcePath, 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); const stat = statSync(source.sourcePath);
return createHash("sha256") return createHash("sha256")
.update( .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"); .digest("hex");
} }
@@ -516,6 +516,31 @@ describe("hf-id surfacing in preview route", () => {
expect(readFileSync(svgPath, "utf-8")).toBe(svgBytes); 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"), "<svg>shared</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("<svg>shared</svg>");
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 <template> (runtime clone-source)", async () => { it("sub-comp route does NOT persist ids inside a plain <template> (runtime clone-source)", async () => {
const { readFileSync } = await import("node:fs"); const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir(); 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 () => { it("escapes script terminators and JavaScript line separators in codec-map keys", async () => {
const projectDir = createProjectDir(); const projectDir = createProjectDir();
const { registerPreviewRoutes: register } = await loadPreviewModule({ const { registerPreviewRoutes: register } = await loadPreviewModule({
+8 -2
View File
@@ -1,8 +1,9 @@
import type { Hono } from "hono"; import type { Hono } from "hono";
import { existsSync, readFileSync, statSync } from "node:fs"; import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path"; import { join, resolve } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler"; import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import type { StudioApiAdapter } from "../types.js"; import type { StudioApiAdapter } from "../types.js";
import { resolveWithinProject } from "../helpers/safePath.js"; import { resolveWithinProject } from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js"; import { getMimeType } from "../helpers/mime.js";
@@ -528,7 +529,12 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
const subPath = decodeURIComponent( const subPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "", 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) { if (!file) {
return c.text("not found", 404); return c.text("not found", 404);
} }