Skip to content

Commit e8a48a6

Browse files
fix(producer): external assets work on Windows (GH heygen-com#321) (heygen-com#324)
* fix(producer): external assets work on Windows (GH heygen-com#321) Two Unix-only assumptions in the external-asset pipeline caused every absolute path on Windows to be rejected as "unsafe" at render time: 1. Containment checks used `child.startsWith(parent + "/")`. On Windows the separator is `\`, so the predicate is always false unless the paths are equal — every external asset tripped the safety guard in `renderOrchestrator.ts`. The reporter saw: [Render] Skipping external asset with unsafe path: hf-ext/D:\coder\reactGin\hyperframes\reading\assets\segment_001.wav Fix: use `path.relative()` through a shared helper `isPathInside(child, parent)` that normalises separators per-platform and correctly rejects siblings whose names start with the parent (e.g. `/foo/bar-sibling` is NOT inside `/foo/bar`). 2. The external-asset key was built as `"hf-ext/" + absPath.replace(/^\//, "")`. A Windows absolute path (`D:\coder\...`) became `"hf-ext/D:\\coder\\..."` — and because Node's `path.join` treats a drive-letter prefix as absolute, `join(compileDir, key)` silently escaped `compileDir`. Fix: `toExternalAssetKey()` strips the drive colon and normalises to forward slashes, producing `hf-ext/D/coder/...` — a pure relative path that `path.join` cannot promote to absolute on any OS. Both helpers live in `packages/producer/src/utils/paths.ts` and are exercised by 14 unit tests covering Unix paths, Windows drive-letter paths, mixed separators, sibling-prefix confusion, and `..` traversal. Docs: new "External assets" section in `docs/packages/producer.mdx` describes detection, sanitised keys, and the cross-platform containment invariant. Closes heygen-com#321. * fix(producer): address review on heygen-com#324 — UNC + integration test Addresses the non-blocking observations from the PR heygen-com#324 staff review (heygen-com#324 (comment)): 1. UNC and extended-length Windows paths. `toExternalAssetKey` now handles: - `\\?\D:\very\long\path\clip.mp4` (extended-length) → `hf-ext/D/very/long/path/clip.mp4` - `\\server\share\file.wav` (plain UNC) → `hf-ext/unc/server/share/file.wav` - `\\?\UNC\server\share\file.wav` (extended-length UNC) → `hf-ext/unc/server/share/file.wav` The UNC-collapsed form keeps the server boundary so two different servers exposing the same share/file name cannot collide under one relative key. Previously both edge cases silently produced keys with stray `?` or `:` characters that downstream `isPathInside` rejected — not a security hole, but a silent drop of user assets. 2. Short-circuit on already-sanitised input. `toExternalAssetKey("hf-ext/…")` now returns its input unchanged instead of prepending `hf-ext/` a second time. Makes the helper genuinely idempotent, which is what the unit test claimed all along. Renamed the test accordingly. 3. JSDoc caller contract. `toExternalAssetKey` now documents that it expects canonicalised input (`path.resolve`'d upstream) and does not strip `..` components. `isPathInside` at copy time is still the defensive backstop — called out explicitly in the doc so future callers read the contract before the code. 4. End-to-end integration test. `renderOrchestrator.test.ts` gains two seam tests that run the full external-asset pipeline — build the sanitised key, populate an `externalAssets` map, invoke `writeCompiledArtifacts`, and assert both the success path (the file lands under `<compileDir>/hf-ext/…`) and the escape-rejection path (a malicious `hf-ext/../../etc/passwd` key does NOT materialise above `compileDir`). `writeCompiledArtifacts` is exported for the test seam with a clear JSDoc disclaimer that it's not part of the public API. 22 tests pass across `paths.test.ts` (17) and `renderOrchestrator.test.ts` (5). Out of scope for this follow-up (tracked as follow-ups): - Centralising every `startsWith("/")` absolute-path check into a shared helper across htmlCompiler / audioExtractor / audioMixer / videoFrameExtractor. Mentioned in the review; touches 5 files and deserves its own PR. - Windows CI runner.
1 parent 64e3735 commit e8a48a6

6 files changed

Lines changed: 342 additions & 9 deletions

File tree

docs/packages/producer.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,30 @@ bun run benchmark
252252

253253
The benchmark runs several compositions with different quality and FPS settings and reports timing for each combination.
254254

255+
## External assets (files outside `projectDir`)
256+
257+
A composition can reference absolute paths to assets outside the project
258+
directory — a local voiceover in `~/Downloads`, a shared-drive image, a
259+
generated fixture at an absolute path. The producer handles these by:
260+
261+
1. **Detection.** During compilation, the HTML compiler walks every
262+
`[src]` / `[href]` and every `url(...)` in `<style>`. A path that
263+
resolves to a file outside `projectDir` is collected into an
264+
`externalAssets` map.
265+
2. **Sanitised keys.** Each absolute path is converted into a safe,
266+
cross-platform relative key prefixed with `hf-ext/`. Windows
267+
drive-letter colons are stripped (`D:\foo\x.wav``hf-ext/D/foo/x.wav`)
268+
so that `path.join(compileDir, key)` stays inside the compile
269+
directory on every OS.
270+
3. **Copy + rewrite.** The orchestrator copies the file under
271+
`<compileDir>/hf-ext/...` and the HTML is rewritten to point at the
272+
sanitised key. The file server then serves both project-internal and
273+
external assets from the same root.
274+
275+
The containment check uses `path.relative()` rather than a hardcoded
276+
separator, so external assets work identically on macOS, Linux, and
277+
Windows. See `packages/producer/src/utils/paths.ts` for the helpers.
278+
255279
## Related Packages
256280

257281
<CardGroup cols={2}>

packages/producer/src/services/htmlCompiler.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
rewriteCssAssetUrls,
2525
} from "@hyperframes/core";
2626
import { extractVideoMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
27+
import { isPathInside, toExternalAssetKey } from "../utils/paths.js";
2728
import {
2829
parseVideoElements,
2930
type VideoElement,
@@ -804,12 +805,14 @@ export function collectExternalAssets(
804805
return null;
805806
}
806807
const absPath = resolve(absProjectDir, trimmed);
807-
if (absPath.startsWith(absProjectDir + "/") || absPath === absProjectDir) {
808+
if (isPathInside(absPath, absProjectDir)) {
808809
return null; // inside projectDir, file server handles this
809810
}
810811
if (!existsSync(absPath)) return null;
811-
// resolve() already canonicalizes the path (no .. components remain)
812-
const safeKey = "hf-ext/" + absPath.replace(/^\//, "");
812+
// resolve() already canonicalises the path (no .. components remain);
813+
// toExternalAssetKey() produces a cross-platform relative key that
814+
// `path.join(compileDir, key)` cannot escape on any OS.
815+
const safeKey = toExternalAssetKey(absPath);
813816
externalAssets.set(safeKey, absPath);
814817
return safeKey;
815818
}

packages/producer/src/services/renderOrchestrator.test.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { describe, expect, it } from "vitest";
2-
import { extractStandaloneEntryFromIndex } from "./renderOrchestrator.js";
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { tmpdir } from "node:os";
5+
6+
import { extractStandaloneEntryFromIndex, writeCompiledArtifacts } from "./renderOrchestrator.js";
7+
import { toExternalAssetKey } from "../utils/paths.js";
38

49
describe("extractStandaloneEntryFromIndex", () => {
510
it("reuses the index wrapper and keeps only the requested composition host", () => {
@@ -59,3 +64,100 @@ describe("extractStandaloneEntryFromIndex", () => {
5964
expect(extracted).toBeNull();
6065
});
6166
});
67+
68+
describe("writeCompiledArtifacts — external assets on Windows drive-letter paths (GH #321)", () => {
69+
// End-to-end seam test: covers both `toExternalAssetKey` and
70+
// `renderOrchestrator`'s copy step by simulating a Windows absolute
71+
// path flowing through the full external-asset pipeline. The helpers
72+
// are logically cross-platform, but this is the integration that
73+
// guarantees they compose — catches any regression at the boundary.
74+
75+
const tempDirs: string[] = [];
76+
afterEach(() => {
77+
while (tempDirs.length > 0) {
78+
const d = tempDirs.pop();
79+
if (d) {
80+
try {
81+
rmSync(d, { recursive: true, force: true });
82+
} catch {
83+
/* ignore */
84+
}
85+
}
86+
}
87+
});
88+
89+
function makeWorkDir(): string {
90+
const d = mkdtempSync(join(tmpdir(), "hf-orch-"));
91+
tempDirs.push(d);
92+
return d;
93+
}
94+
95+
it("copies an external asset with a Windows-style drive-letter key into compileDir", () => {
96+
const workDir = makeWorkDir();
97+
// Simulate a real external asset: write a dummy file to an absolute
98+
// path, then build the sanitised key the way `collectExternalAssets`
99+
// would on Windows.
100+
const sourceDir = mkdtempSync(join(tmpdir(), "hf-src-"));
101+
tempDirs.push(sourceDir);
102+
const srcFile = join(sourceDir, "segment.wav");
103+
writeFileSync(srcFile, "fake wav bytes");
104+
105+
// The simulated Windows input is a path with backslashes and a drive
106+
// letter — even though the test runs on Unix, the helper is expressed
107+
// with regex on the string so we can exercise the Windows code path
108+
// deterministically.
109+
const windowsStyleInput = "D:\\coder\\assets\\segment.wav";
110+
const key = toExternalAssetKey(windowsStyleInput);
111+
expect(key).toBe("hf-ext/D/coder/assets/segment.wav");
112+
113+
const externalAssets = new Map<string, string>([[key, srcFile]]);
114+
const compiled = {
115+
html: "<!doctype html><html><body></body></html>",
116+
subCompositions: new Map<string, string>(),
117+
videos: [],
118+
audios: [],
119+
unresolvedCompositions: [],
120+
externalAssets,
121+
width: 1920,
122+
height: 1080,
123+
staticDuration: 10,
124+
};
125+
126+
writeCompiledArtifacts(compiled, workDir, /* includeSummary */ false);
127+
128+
const landed = join(workDir, "compiled", key);
129+
expect(existsSync(landed)).toBe(true);
130+
expect(readFileSync(landed, "utf-8")).toBe("fake wav bytes");
131+
});
132+
133+
it("rejects a maliciously crafted key that tries to escape compileDir", () => {
134+
// Defense-in-depth: if a buggy upstream produced a key with `..`
135+
// components, `isPathInside` at copy time must catch it and skip.
136+
const workDir = makeWorkDir();
137+
const sourceDir = mkdtempSync(join(tmpdir(), "hf-src-"));
138+
tempDirs.push(sourceDir);
139+
const srcFile = join(sourceDir, "evil.wav");
140+
writeFileSync(srcFile, "should never be copied");
141+
142+
const externalAssets = new Map<string, string>([["hf-ext/../../etc/passwd", srcFile]]);
143+
const compiled = {
144+
html: "<!doctype html>",
145+
subCompositions: new Map<string, string>(),
146+
videos: [],
147+
audios: [],
148+
unresolvedCompositions: [],
149+
externalAssets,
150+
width: 1920,
151+
height: 1080,
152+
staticDuration: 10,
153+
};
154+
155+
writeCompiledArtifacts(compiled, workDir, false);
156+
157+
// Assert that the file was NOT written outside compileDir (the
158+
// attacker's target). We check the escape destination didn't
159+
// materialise next to workDir.
160+
const escapeTarget = join(workDir, "..", "..", "etc", "passwd");
161+
expect(existsSync(escapeTarget)).toBe(false);
162+
});
163+
});

packages/producer/src/services/renderOrchestrator.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
type CompiledComposition,
6969
} from "./htmlCompiler.js";
7070
import { defaultLogger, type ProducerLogger } from "../logger.js";
71+
import { isPathInside } from "../utils/paths.js";
7172

7273
/**
7374
* Wrap a cleanup operation so it never throws, but logs any failure.
@@ -246,7 +247,9 @@ function installDebugLogger(logPath: string, log: ProducerLogger = defaultLogger
246247
/**
247248
* Write compiled HTML and sub-compositions to the work directory.
248249
*/
249-
function writeCompiledArtifacts(
250+
// Exported for integration tests. Not part of the stable public API —
251+
// callers outside this package should use `executeRenderJob` instead.
252+
export function writeCompiledArtifacts(
250253
compiled: CompiledComposition,
251254
workDir: string,
252255
includeSummary: boolean,
@@ -263,10 +266,13 @@ function writeCompiledArtifacts(
263266
}
264267

265268
// Copy external assets (files outside projectDir) into the compiled directory
266-
// so the file server can serve them.
269+
// so the file server can serve them. The safe-path check uses
270+
// `isPathInside()` rather than a hardcoded separator — on Windows,
271+
// `compileDir + "/"` never matches because paths use `\\`, which caused
272+
// every external asset to be wrongly rejected as "unsafe" (see GH #321).
267273
for (const [relativePath, absolutePath] of compiled.externalAssets) {
268274
const outPath = resolve(join(compileDir, relativePath));
269-
if (!outPath.startsWith(compileDir + "/")) {
275+
if (!isPathInside(outPath, compileDir)) {
270276
console.warn(`[Render] Skipping external asset with unsafe path: ${relativePath}`);
271277
continue;
272278
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Cross-platform containment + external-asset-key tests.
3+
*
4+
* Regression coverage for GH #321 — on Windows, every external asset was
5+
* wrongly rejected as "unsafe path" because the containment check used
6+
* `startsWith(parent + "/")` and the safe key carried a drive-letter
7+
* colon that made the downstream `path.join` absolute.
8+
*
9+
* We exercise both OS layouts by posing the hypothetical paths the
10+
* respective platforms would generate — the logic itself is expressed
11+
* using `path.relative()` so it works regardless of the runtime OS.
12+
*/
13+
14+
import { describe, expect, it } from "vitest";
15+
import { resolve } from "node:path";
16+
17+
import { isPathInside, toExternalAssetKey } from "./paths.js";
18+
19+
describe("isPathInside", () => {
20+
it("returns true when child is directly inside parent", () => {
21+
expect(isPathInside(resolve("/foo/bar/baz.wav"), resolve("/foo/bar"))).toBe(true);
22+
});
23+
24+
it("returns true when child is deeply nested inside parent", () => {
25+
expect(isPathInside(resolve("/foo/bar/a/b/c/d.wav"), resolve("/foo/bar"))).toBe(true);
26+
});
27+
28+
it("returns true when child equals parent (a dir contains itself)", () => {
29+
expect(isPathInside(resolve("/foo/bar"), resolve("/foo/bar"))).toBe(true);
30+
});
31+
32+
it("returns false when child is a sibling whose name starts with parent", () => {
33+
// Regression: the old `startsWith(parent + "/")` accidentally worked for
34+
// this case, but a naive rewrite without the trailing separator would
35+
// admit `/foo/bar-sibling` as a child of `/foo/bar`. Verify we don't.
36+
expect(isPathInside(resolve("/foo/bar-sibling/x"), resolve("/foo/bar"))).toBe(false);
37+
});
38+
39+
it("returns false when child is outside parent", () => {
40+
expect(isPathInside(resolve("/tmp/evil/file.wav"), resolve("/foo/bar"))).toBe(false);
41+
});
42+
43+
it("returns false when child resolves above parent via ..", () => {
44+
expect(isPathInside(resolve("/foo/bar/../../etc/passwd"), resolve("/foo/bar"))).toBe(false);
45+
});
46+
47+
it("normalises trailing slashes on parent", () => {
48+
expect(isPathInside(resolve("/foo/bar/baz"), resolve("/foo/bar/"))).toBe(true);
49+
});
50+
});
51+
52+
describe("toExternalAssetKey", () => {
53+
it("prefixes with hf-ext/ and keeps a Unix absolute path", () => {
54+
expect(toExternalAssetKey("/Users/miguel/assets/segment.wav")).toBe(
55+
"hf-ext/Users/miguel/assets/segment.wav",
56+
);
57+
});
58+
59+
it("converts Windows drive-letter paths to a colonless, slash-delimited key", () => {
60+
// GH #321: `D:\coder\reactGin\hyperframes\reading\assets\segment_001.wav`
61+
// used to become `hf-ext/D:\coder\...`, which makes the downstream
62+
// `path.join(compileDir, key)` absolute on Windows (drive letter wins).
63+
expect(
64+
toExternalAssetKey("D:\\coder\\reactGin\\hyperframes\\reading\\assets\\segment_001.wav"),
65+
).toBe("hf-ext/D/coder/reactGin/hyperframes/reading/assets/segment_001.wav");
66+
});
67+
68+
it("handles Windows paths with forward slashes (mixed separators)", () => {
69+
expect(toExternalAssetKey("C:/Users/Alice/Downloads/clip.mp4")).toBe(
70+
"hf-ext/C/Users/Alice/Downloads/clip.mp4",
71+
);
72+
});
73+
74+
it("lowercases / uppercases drive letters faithfully (we don't munge)", () => {
75+
expect(toExternalAssetKey("e:\\data\\a.wav")).toBe("hf-ext/e/data/a.wav");
76+
expect(toExternalAssetKey("Z:\\data\\a.wav")).toBe("hf-ext/Z/data/a.wav");
77+
});
78+
79+
it("is truly idempotent — double-wrap short-circuits on the hf-ext/ prefix", () => {
80+
// Earlier revision of this test claimed "idempotent" but actually
81+
// produced `hf-ext/hf-ext/...` — a silent doubling. The short-circuit
82+
// on the hf-ext/ prefix makes the helper exactly idempotent now, so
83+
// the invariant test matches the label.
84+
const once = toExternalAssetKey("/foo/bar.mp3");
85+
const twice = toExternalAssetKey(once);
86+
expect(twice).toBe(once);
87+
});
88+
89+
it("strips the Windows extended-length prefix (\\\\?\\)", () => {
90+
expect(toExternalAssetKey("\\\\?\\D:\\very\\long\\path\\clip.mp4")).toBe(
91+
"hf-ext/D/very/long/path/clip.mp4",
92+
);
93+
});
94+
95+
it("collapses UNC paths to unc/<server>/<share>/... so cross-server names can't collide", () => {
96+
expect(toExternalAssetKey("\\\\server\\share\\file.wav")).toBe(
97+
"hf-ext/unc/server/share/file.wav",
98+
);
99+
});
100+
101+
it("handles UNC extended-length form (\\\\?\\UNC\\server\\...)", () => {
102+
expect(toExternalAssetKey("\\\\?\\UNC\\server\\share\\file.wav")).toBe(
103+
"hf-ext/unc/server/share/file.wav",
104+
);
105+
});
106+
107+
it("treats leading double-slash as UNC (the Windows-correct reading)", () => {
108+
// A leading `//host/share/...` is the Windows UNC form — NOT a Unix
109+
// absolute path with an extra slash. The sanitiser now preserves the
110+
// host/share boundary instead of collapsing it, matching the actual
111+
// meaning of the input on the platform that produces these paths.
112+
expect(toExternalAssetKey("//foo/bar.mp3")).toBe("hf-ext/unc/foo/bar.mp3");
113+
});
114+
115+
it("produces a key that path.join(compileDir, key) keeps inside compileDir", () => {
116+
// The real failure mode from #321: on Windows, join(compileDir, key) with
117+
// a key containing a drive letter silently escaped compileDir. Our key
118+
// must be a pure relative path — no `:`, no leading separator — so
119+
// `isPathInside(join(compileDir, key), compileDir)` is always true.
120+
const key = toExternalAssetKey("D:\\evil\\x.wav");
121+
// Key cannot start with a separator or drive letter.
122+
expect(key.startsWith("/")).toBe(false);
123+
expect(/^[A-Za-z]:/.test(key)).toBe(false);
124+
});
125+
});

0 commit comments

Comments
 (0)