From 7778c093b6288756cf5e27311828e6049e7c85c3 Mon Sep 17 00:00:00 2001 From: James Russo Date: Fri, 24 Jul 2026 13:35:10 -0400 Subject: [PATCH] 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) --- packages/lint/src/project.test.ts | 24 ++++++- .../src/helpers/proxyTranscoder.test.ts | 18 +++-- .../src/helpers/proxyTranscoder.ts | 47 +++++++++---- .../studio-server/src/routes/preview.test.ts | 70 +++++++++++++++++++ packages/studio-server/src/routes/preview.ts | 10 ++- 5 files changed, 146 insertions(+), 23 deletions(-) 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"), "shared"); + 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"), "shared"); + 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("shared"); + + 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