11import { describe , it , expect , afterEach } from "vitest" ;
2- import { mkdirSync , writeFileSync , rmSync } from "node:fs" ;
2+ import { mkdirSync , mkdtempSync , writeFileSync , rmSync } from "node:fs" ;
33import { join } from "node:path" ;
44import { tmpdir } from "node:os" ;
55import { lintProject , shouldBlockRender } from "./lintProject.js" ;
66import type { ProjectDir } from "./project.js" ;
77
88function 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
1412function 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+
185206describe ( "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
519586describe ( "multiple_root_compositions" , ( ) => {
0 commit comments