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
@@ -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 () => {
@@ -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<T>(
}
/**
* 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");
}