diff --git a/packages/parsers/src/assetPaths.test.ts b/packages/parsers/src/assetPaths.test.ts new file mode 100644 index 0000000000..cb72762396 --- /dev/null +++ b/packages/parsers/src/assetPaths.test.ts @@ -0,0 +1,38 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { isPathInside } from "./assetPaths.js"; +import { isWithinProjectRoot, resolveLocalAssetCandidates } from "./assetResolution.js"; + +describe("asset path containment", () => { + const root = resolve("project"); + + it.each(["..intro.mp4", "..assets/clip.mp4", ".../clip.mp4"])( + "accepts an in-project path named %s", + (name) => { + const candidate = resolve(root, name); + expect(isPathInside(candidate, root)).toBe(true); + expect(isWithinProjectRoot(root, candidate)).toBe(true); + expect(resolveLocalAssetCandidates(root, name)).toEqual([candidate]); + }, + ); + + it.each(["..", "../outside.mp4", "../project-sibling/clip.mp4"])( + "rejects a path outside the project: %s", + (name) => { + const candidate = resolve(root, name); + expect(isPathInside(candidate, root)).toBe(false); + expect(isWithinProjectRoot(root, candidate)).toBe(false); + }, + ); + + it("keeps the existing project-root clamping for dot-prefixed asset names", () => { + expect(resolveLocalAssetCandidates(root, "../..assets/clip.mp4")).toEqual([ + resolve(root, "..assets/clip.mp4"), + ]); + }); + + it("accepts the project root itself", () => { + expect(isPathInside(root, root)).toBe(true); + expect(isWithinProjectRoot(root, root)).toBe(true); + }); +}); diff --git a/packages/parsers/src/assetPaths.ts b/packages/parsers/src/assetPaths.ts index f02c374464..1eb8d521f1 100644 --- a/packages/parsers/src/assetPaths.ts +++ b/packages/parsers/src/assetPaths.ts @@ -5,7 +5,7 @@ * localizeExternalAssets (CLI publish). */ -import { isAbsolute, relative, resolve } from "node:path"; +import { isAbsolute, relative, resolve, sep } from "node:path"; /** * Regex matching CSS `url(...)` references — captures the quote style and the @@ -41,5 +41,5 @@ export function isPathInside(childPath: string, parentPath: string): boolean { const absParent = resolve(parentPath); if (absChild === absParent) return true; const rel = relative(absParent, absChild); - return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); + return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); } diff --git a/packages/parsers/src/assetResolution.ts b/packages/parsers/src/assetResolution.ts index aca5fbeb1e..edf4629ee3 100644 --- a/packages/parsers/src/assetResolution.ts +++ b/packages/parsers/src/assetResolution.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { isAbsolute, posix, relative, resolve } from "node:path"; +import { isAbsolute, posix, relative, resolve, sep } from "node:path"; import { decodeUrlPathVariants } from "./composition.js"; /** @@ -118,7 +118,7 @@ export function cleanAssetUrl(url: string): string { export function isWithinProjectRoot(projectDir: string, candidate: string): boolean { const projectRoot = resolve(projectDir); const relativePath = relative(projectRoot, candidate); - return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); + return relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath); } function addCandidate(candidates: string[], candidate: string): void { @@ -140,7 +140,7 @@ export function resolveLocalAssetCandidates(projectDir: string, url: string): st const normalized = posix.normalize(projectRelative.replace(/\\/g, "/")); const clamped = normalized.replace(/^(\.\.\/)+/, ""); - if (clamped && !clamped.startsWith("..")) { + if (clamped && clamped !== ".." && !clamped.startsWith("../")) { addCandidate(candidates, resolve(projectRoot, clamped)); } }