fix(producer): serve symlinked render assets (#486)

This commit is contained in:
Miguel Ángel
2026-04-25 06:36:27 +02:00
committed by GitHub
parent 5e1db03fe7
commit 2f60dd3727
9 changed files with 123 additions and 6 deletions
@@ -235,6 +235,14 @@ function discoverTestSuites(
return suites; return suites;
} }
function copyFixtureSupportFiles(suite: TestSuite, tempRoot: string): void {
const excluded = new Set(["src", "output", "meta.json", "failures"]);
for (const entry of readdirSync(suite.dir)) {
if (excluded.has(entry)) continue;
cpSync(join(suite.dir, entry), join(tempRoot, entry), { recursive: true });
}
}
// ── FFmpeg Utilities ───────────────────────────────────────────────────────── // ── FFmpeg Utilities ─────────────────────────────────────────────────────────
function runFfmpeg(args: string[], label: string): { stdout: Buffer; stderr: string } { function runFfmpeg(args: string[], label: string): { stdout: Buffer; stderr: string } {
@@ -582,6 +590,7 @@ async function runTestSuite(
logPretty("Rendering video...", "🎬"); logPretty("Rendering video...", "🎬");
const tempSrcDir = join(tempRoot, "src"); const tempSrcDir = join(tempRoot, "src");
copyFixtureSupportFiles(suite, tempRoot);
cpSync(suite.srcDir, tempSrcDir, { recursive: true }); cpSync(suite.srcDir, tempSrcDir, { recursive: true });
const job = createRenderJob({ const job = createRenderJob({
@@ -1,8 +1,9 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import path, { join } from "node:path"; import path, { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { import {
createFileServer,
HF_BRIDGE_SCRIPT, HF_BRIDGE_SCRIPT,
HF_EARLY_STUB, HF_EARLY_STUB,
injectScriptsAtHeadStart, injectScriptsAtHeadStart,
@@ -151,6 +152,45 @@ describe("isPathInside", () => {
}); });
}); });
describe("createFileServer", () => {
it("serves asset files through project-root symlinked directories", async () => {
const workspaceDir = mkdtempSync(join(tmpdir(), "hf-file-server-symlink-assets-"));
const adsDir = join(workspaceDir, "Ads");
const projectDir = join(adsDir, "annual-upsell-2");
const sharedDir = join(adsDir, "shared");
try {
mkdirSync(projectDir, { recursive: true });
mkdirSync(sharedDir, { recursive: true });
writeFileSync(join(projectDir, "index.html"), "<!doctype html><html></html>");
writeFileSync(
join(sharedDir, "brand.css"),
".aisplus-glass { backdrop-filter: blur(28px); }",
);
symlinkSync("../shared", join(projectDir, "shared"));
const server = await createFileServer({
projectDir,
preHeadScripts: [],
headScripts: [],
bodyScripts: [],
});
try {
const response = await fetch(`${server.url}/shared/brand.css`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/css");
expect(await response.text()).toContain(".aisplus-glass");
} finally {
server.close();
}
} finally {
rmSync(workspaceDir, { recursive: true, force: true });
}
});
});
describe("HF_EARLY_STUB + HF_BRIDGE_SCRIPT integration", () => { describe("HF_EARLY_STUB + HF_BRIDGE_SCRIPT integration", () => {
/** /**
* Simulates the real injection order in a Puppeteer page: * Simulates the real injection order in a Puppeteer page:
+6 -5
View File
@@ -31,8 +31,8 @@ type IsPathInsideOptions = {
/** /**
* Returns true iff `child` is the same as, or nested inside, `parent` after * Returns true iff `child` is the same as, or nested inside, `parent` after
* symlink-free path normalization. Used to reject path-traversal attempts * path normalization. Used to reject path-traversal attempts (e.g.
* (e.g. GET `/../etc/passwd`) before opening any file. * GET `/../etc/passwd`) before opening any file.
* *
* `path.join(root, "..")` normalizes traversal segments and can escape `root` * `path.join(root, "..")` normalizes traversal segments and can escape `root`
* entirely, so the join return value alone is not a safe guard. Callers must * entirely, so the join return value alone is not a safe guard. Callers must
@@ -537,13 +537,14 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
// Each candidate is rejected if `..` segments push it outside the // Each candidate is rejected if `..` segments push it outside the
// intended root: `path.join` normalizes traversal but does not enforce // intended root: `path.join` normalizes traversal but does not enforce
// containment, so a request like `GET /../etc/passwd` would otherwise // containment, so a request like `GET /../etc/passwd` would otherwise
// be served straight off the filesystem. // be served straight off the filesystem. Keep this lexical so project
// symlinks to sibling asset directories behave like preview mode.
let filePath: string | null = null; let filePath: string | null = null;
if (compiledDir) { if (compiledDir) {
const candidate = join(compiledDir, relativePath); const candidate = join(compiledDir, relativePath);
if ( if (
existsSync(candidate) && existsSync(candidate) &&
isPathInside(candidate, compiledDir, { resolveSymlinks: true }) && isPathInside(candidate, compiledDir) &&
statSync(candidate).isFile() statSync(candidate).isFile()
) { ) {
filePath = candidate; filePath = candidate;
@@ -553,7 +554,7 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
const candidate = join(projectDir, relativePath); const candidate = join(projectDir, relativePath);
if ( if (
existsSync(candidate) && existsSync(candidate) &&
isPathInside(candidate, projectDir, { resolveSymlinks: true }) && isPathInside(candidate, projectDir) &&
statSync(candidate).isFile() statSync(candidate).isFile()
) { ) {
filePath = candidate; filePath = candidate;
@@ -0,0 +1,13 @@
{
"name": "Render Symlinked Assets",
"description": "Regression test for render-mode FileServer parity with preview when a project asset directory is a symlink to a sibling shared folder.",
"tags": ["regression"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 24,
"workers": 1
}
}
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="shared/brand.css">
</head>
<body style="margin: 0">
<div data-composition-id="render-symlinked-assets" data-width="320" data-height="180" data-duration="5" style="position: relative; width: 320px; height: 180px; overflow: hidden">
<div class="stage clip" data-start="0" data-duration="5">
<div class="card">SYMLINK CSS</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d73f03b86c6c60f8900627df3a71771b47386add8bcbb50ed7b162b81abea976
size 10433
@@ -0,0 +1,18 @@
.stage {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #0b1220;
}
.card {
width: 260px;
height: 120px;
display: grid;
place-items: center;
border: 4px solid #86efac;
background: #22c55e;
color: #04130a;
font: 700 34px system-ui, sans-serif;
}
@@ -0,0 +1,19 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="shared/brand.css" />
</head>
<body style="margin: 0">
<div
data-composition-id="render-symlinked-assets"
data-width="320"
data-height="180"
data-duration="5"
style="position: relative; width: 320px; height: 180px; overflow: hidden"
>
<div class="stage clip" data-start="0" data-duration="5">
<div class="card">SYMLINK CSS</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1 @@
../shared