Skip to content

Commit 526709c

Browse files
committed
fix(cli): handle encoded lint asset paths
1 parent 7ad10a2 commit 526709c

6 files changed

Lines changed: 144 additions & 40 deletions

File tree

packages/cli/src/utils/lintProject.test.ts

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import { describe, it, expect, afterEach } from "vitest";
2-
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
2+
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
55
import { lintProject, shouldBlockRender } from "./lintProject.js";
66
import type { ProjectDir } from "./project.js";
77

88
function tmpProject(name: string): string {
9-
const dir = join(tmpdir(), `hf-test-${name}-${Date.now()}`);
10-
mkdirSync(dir, { recursive: true });
11-
return dir;
9+
return mkdtempSync(join(tmpdir(), `hf-test-${name}-`));
1210
}
1311

1412
function validHtml(compId = "main"): string {
@@ -182,6 +180,29 @@ function validHtmlWithAudio(compId = "main"): string {
182180
</body></html>`;
183181
}
184182

183+
function validHtmlWithAudioSrc(src: string): string {
184+
return `<html><body>
185+
<div data-composition-id="main" data-width="1920" data-height="1080">
186+
<audio id="music" src="${src}" data-start="0" data-track-index="0" data-volume="1"></audio>
187+
</div>
188+
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
189+
</body></html>`;
190+
}
191+
192+
function validHtmlWithMaskImageUrl(url: string): string {
193+
return `<html><body>
194+
<div data-composition-id="main" data-width="1920" data-height="1080">
195+
<div class="hf-texture-text hf-texture-lava">TEXT</div>
196+
</div>
197+
<style>
198+
.hf-texture-lava {
199+
mask-image: url("${url}");
200+
}
201+
</style>
202+
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
203+
</body></html>`;
204+
}
205+
185206
describe("audio_file_without_element", () => {
186207
it("warns when audio file exists but no <audio> element", () => {
187208
const project = makeProject(validHtml());
@@ -333,13 +354,7 @@ describe("audio_src_not_found", () => {
333354
it("does not error for percent-encoded non-Latin filenames that exist on disk", () => {
334355
const encodedFilename =
335356
"%D9%87%D9%86%D8%A7%20%D9%85%D8%B1%D9%88%D8%A7%20-%20%D9%85%D8%A8%D8%A7%D8%B1%D9%83.mp4";
336-
const html = `<html><body>
337-
<div data-composition-id="main" data-width="1920" data-height="1080">
338-
<audio id="music" src="assets/${encodedFilename}" data-start="0" data-track-index="0" data-volume="1"></audio>
339-
</div>
340-
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
341-
</body></html>`;
342-
const project = makeProject(html);
357+
const project = makeProject(validHtmlWithAudioSrc(`assets/${encodedFilename}`));
343358
mkdirSync(join(project.dir, "assets"), { recursive: true });
344359
writeFileSync(join(project.dir, "assets", decodeURIComponent(encodedFilename)), "fake");
345360

@@ -351,6 +366,31 @@ describe("audio_src_not_found", () => {
351366
expect(finding).toBeUndefined();
352367
});
353368

369+
it("does not error for malformed percent sequences that are literal filenames", () => {
370+
const filename = "100%-discount.mp4";
371+
const project = makeProject(validHtmlWithAudioSrc(`assets/${filename}`));
372+
mkdirSync(join(project.dir, "assets"), { recursive: true });
373+
writeFileSync(join(project.dir, "assets", filename), "fake");
374+
375+
const { results } = lintProject(project);
376+
377+
const first = results[0];
378+
expect(first).toBeDefined();
379+
const finding = first?.result.findings.find((f) => f.code === "audio_src_not_found");
380+
expect(finding).toBeUndefined();
381+
});
382+
383+
it("does not treat decoded traversal as an existing file outside the project", () => {
384+
const project = makeProject(
385+
validHtmlWithAudioSrc("assets/foo/%2E%2E/%2E%2E/%2E%2E/etc/passwd"),
386+
);
387+
388+
const { results } = lintProject(project);
389+
390+
const finding = results[0]?.result.findings.find((f) => f.code === "audio_src_not_found");
391+
expect(finding).toBeDefined();
392+
});
393+
354394
it("deduplicates missing files across compositions", () => {
355395
const project = makeProject(validHtmlWithAudio(), {
356396
"captions.html": validHtmlWithAudio("captions"),
@@ -514,6 +554,33 @@ describe("texture_mask_asset_not_found", () => {
514554

515555
expect(finding).toBeUndefined();
516556
});
557+
558+
it("does not error for percent-encoded non-Latin mask filenames that exist on disk", () => {
559+
const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.png";
560+
const project = makeProject(validHtmlWithMaskImageUrl(`assets/${encodedFilename}`));
561+
mkdirSync(join(project.dir, "assets"), { recursive: true });
562+
writeFileSync(join(project.dir, "assets", decodeURIComponent(encodedFilename)), "fake");
563+
564+
const { results } = lintProject(project);
565+
const finding = results[0]?.result.findings.find(
566+
(item) => item.code === "texture_mask_asset_not_found",
567+
);
568+
569+
expect(finding).toBeUndefined();
570+
});
571+
572+
it("does not treat decoded mask traversal as an existing file outside the project", () => {
573+
const project = makeProject(
574+
validHtmlWithMaskImageUrl("assets/foo/%2E%2E/%2E%2E/%2E%2E/etc/passwd"),
575+
);
576+
577+
const { results } = lintProject(project);
578+
const finding = results[0]?.result.findings.find(
579+
(item) => item.code === "texture_mask_asset_not_found",
580+
);
581+
582+
expect(finding).toBeDefined();
583+
});
517584
});
518585

519586
describe("multiple_root_compositions", () => {

packages/cli/src/utils/lintProject.ts

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { existsSync, readFileSync, readdirSync } from "node:fs";
2-
import { dirname, join, resolve, extname } from "node:path";
2+
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path";
33
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
44
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
5-
import { rewriteAssetPath } from "@hyperframes/core";
5+
import { decodeUrlPathVariants, rewriteAssetPath } from "@hyperframes/core";
66
import type { ProjectDir } from "./project.js";
77

88
/**
@@ -113,31 +113,53 @@ function cleanAssetUrl(url: string): string {
113113
return url.trim().split(/[?#]/, 1)[0] ?? "";
114114
}
115115

116+
function isWithinProjectRoot(projectDir: string, candidate: string): boolean {
117+
const projectRoot = resolve(projectDir);
118+
const relativePath = relative(projectRoot, candidate);
119+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
120+
}
121+
122+
function addCandidate(candidates: string[], candidate: string): void {
123+
if (!candidates.includes(candidate)) candidates.push(candidate);
124+
}
125+
116126
function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
117127
const cleanUrl = cleanAssetUrl(url);
118-
const variants = [cleanUrl];
119-
try {
120-
const decodedUrl = decodeURIComponent(cleanUrl);
121-
if (decodedUrl !== cleanUrl) variants.unshift(decodedUrl);
122-
} catch {
123-
// Malformed percent sequences can be literal filenames.
128+
const projectRoot = resolve(projectDir);
129+
const candidates: string[] = [];
130+
131+
for (const variant of decodeUrlPathVariants(cleanUrl)) {
132+
const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
133+
const resolved = resolve(projectRoot, projectRelative);
134+
if (isWithinProjectRoot(projectRoot, resolved)) {
135+
addCandidate(candidates, resolved);
136+
continue;
137+
}
138+
139+
const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
140+
const clamped = normalized.replace(/^(\.\.\/)+/, "");
141+
if (clamped && !clamped.startsWith("..")) {
142+
addCandidate(candidates, resolve(projectRoot, clamped));
143+
}
124144
}
125145

126-
return [...new Set(variants)].map((variant) =>
127-
variant.startsWith("/") ? resolve(projectDir, variant.slice(1)) : resolve(projectDir, variant),
128-
);
146+
return candidates;
129147
}
130148

131-
function resolveCssAssetPath(
149+
function resolveCssAssetCandidates(
132150
projectDir: string,
133151
url: string,
134152
htmlCompSrcPath?: string,
135153
cssRootRelativePath?: string,
136-
): string {
137-
if (url.startsWith("/")) return resolve(projectDir, url.slice(1));
138-
if (cssRootRelativePath) return resolve(projectDir, join(dirname(cssRootRelativePath), url));
139-
if (htmlCompSrcPath) return resolve(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
140-
return resolve(projectDir, url);
154+
): string[] {
155+
if (url.startsWith("/")) return resolveLocalAssetCandidates(projectDir, url);
156+
if (cssRootRelativePath) {
157+
return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url));
158+
}
159+
if (htmlCompSrcPath) {
160+
return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
161+
}
162+
return resolveLocalAssetCandidates(projectDir, url);
141163
}
142164

143165
/**
@@ -318,14 +340,14 @@ function lintTextureMaskAssetNotFound(
318340
if (!url || isRemoteOrInlineUrl(url)) continue;
319341
if (/^__[A-Z_]+__$/.test(url)) continue;
320342

321-
const resolved = resolveCssAssetPath(
343+
const candidates = resolveCssAssetCandidates(
322344
projectDir,
323345
url,
324346
compSrcPath,
325347
cssSource.rootRelativePath,
326348
);
327-
if (existsSync(resolved)) continue;
328-
missing.set(url, resolved);
349+
if (candidates.some(existsSync)) continue;
350+
missing.set(url, candidates[0] ?? resolve(projectDir, url));
329351
}
330352
}
331353
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export {
137137
rewriteAssetPath,
138138
rewriteCssAssetUrls,
139139
} from "./compiler/rewriteSubCompPaths";
140+
export { decodeUrlPathVariants } from "./utils/urlPath";
140141

141142
// Inline scripts
142143
export {

packages/core/src/utils/urlPath.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function decodeUrlPathVariants(path: string): string[] {
2+
const variants = [path];
3+
try {
4+
const decoded = decodeURIComponent(path);
5+
if (decoded !== path) variants.unshift(decoded);
6+
} catch {
7+
// Malformed percent sequences may be literal filesystem names.
8+
}
9+
10+
return variants;
11+
}

packages/engine/src/services/videoFrameExtractor.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,16 @@ describe("resolveProjectRelativeSrc — sub-composition path clamping", () => {
156156
);
157157
}
158158
});
159+
160+
it("falls back to literal filenames when percent sequences are malformed", () => {
161+
const projectDir = join(tmp, "project");
162+
const filename = "100%-discount.mp4";
163+
writeFileSync(join(projectDir, "assets", filename), "");
164+
165+
expect(resolveProjectRelativeSrc(`assets/${filename}`, projectDir)).toBe(
166+
join(projectDir, "assets", filename),
167+
);
168+
});
159169
});
160170

161171
describe("parseVideoElements", () => {

packages/engine/src/services/videoFrameExtractor.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { spawn } from "child_process";
99
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
1010
import { isAbsolute, join, posix, resolve, sep } from "path";
1111
import { parseHTML } from "linkedom";
12+
import { decodeUrlPathVariants } from "@hyperframes/core";
1213
import { trackChildProcess } from "../utils/processTracker.js";
1314
import { extractMediaMetadata, type VideoMetadata } from "../utils/ffprobe.js";
1415
import {
@@ -518,19 +519,11 @@ export function resolveProjectRelativeSrc(
518519
const cleanSrc = qIdx >= 0 ? src.slice(0, qIdx) : src;
519520
const candidates: string[] = [];
520521

521-
const srcVariants = [cleanSrc];
522-
try {
523-
const decodedSrc = decodeURIComponent(cleanSrc);
524-
if (decodedSrc !== cleanSrc) srcVariants.unshift(decodedSrc);
525-
} catch {
526-
// Keep malformed percent sequences as literal filenames.
527-
}
528-
529522
const addCandidate = (candidate: string): void => {
530523
if (!candidates.includes(candidate)) candidates.push(candidate);
531524
};
532525

533-
for (const variant of srcVariants) {
526+
for (const variant of decodeUrlPathVariants(cleanSrc)) {
534527
const fromCompiled = compiledDir ? join(compiledDir, variant) : null;
535528
const fromBase = join(baseDir, variant);
536529

0 commit comments

Comments
 (0)