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