diff --git a/packages/cli/src/utils/renderArgs.test.ts b/packages/cli/src/utils/renderArgs.test.ts index ed6b2d65f8..5ed0bb54d2 100644 --- a/packages/cli/src/utils/renderArgs.test.ts +++ b/packages/cli/src/utils/renderArgs.test.ts @@ -1,6 +1,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_CONFIG } from "@hyperframes/engine"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync, type Stats } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, + type Stats, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { @@ -215,6 +224,28 @@ describe("parseCompositionEntryArg", () => { expect(err.kind).toBe("outside-project"); }); + it("accepts an absolute path inside the project and returns it project-relative", () => { + expect( + parseCompositionEntryArg(resolve(PROJECT, "compositions/intro.html"), PROJECT, stat), + ).toEqual({ ok: true, value: "compositions/intro.html" }); + }); + + it("keeps a dot-named entry relative to the project", () => { + const dotStat = makeStat({ [resolve(PROJECT, ".solo.html")]: "file", [PROJECT]: "directory" }); + expect(parseCompositionEntryArg(".solo.html", PROJECT, dotStat)).toEqual({ + ok: true, + value: ".solo.html", + }); + }); + + it("rejects an absolute path outside the project", () => { + const outsideStat = makeStat({ "/elsewhere/x.html": "file", [PROJECT]: "directory" }); + const err = expectCompositionErr( + parseCompositionEntryArg("/elsewhere/x.html", PROJECT, outsideStat), + ); + expect(err.kind).toBe("outside-project"); + }); + it("rejects a sibling-prefix path (trailing-separator guard)", () => { // Without the trailing-separator guard, `/proj-evil/x.html`.startsWith('/proj') // returns true and the sibling-directory escape passes validation. @@ -229,6 +260,28 @@ describe("parseCompositionEntryArg", () => { }); }); +describe.skipIf(process.platform === "win32")( + "parseCompositionEntryArg through a symlinked project path", + () => { + it("accepts an absolute entry spelled with the alias of the project directory", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "hyperframes-entry-alias-"))); + try { + const project = join(root, "project"); + mkdirSync(project); + writeFileSync(join(project, "entry.html"), ""); + const alias = join(root, "alias"); + symlinkSync(project, alias); + expect(parseCompositionEntryArg(join(alias, "entry.html"), project, statSync)).toEqual({ + ok: true, + value: "entry.html", + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }, +); + describe("resolveDefaultFpsArg", () => { function writeComposition(path: string, fps: string): void { writeFileSync( diff --git a/packages/cli/src/utils/renderArgs.ts b/packages/cli/src/utils/renderArgs.ts index f35134a06f..851666a5e0 100644 --- a/packages/cli/src/utils/renderArgs.ts +++ b/packages/cli/src/utils/renderArgs.ts @@ -11,8 +11,8 @@ import { failUsage } from "./commandResult.js"; * `timeout: 0` footguns at the rate of "one per missing branch". */ -import { readFileSync, type Stats } from "node:fs"; -import { resolve, sep } from "node:path"; +import { readFileSync, realpathSync, type Stats } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; import { parseFps } from "@hyperframes/core"; import { errorBox } from "../ui/format.js"; import { readCompositionFps } from "./compositionFps.js"; @@ -165,6 +165,7 @@ export function parseCompositionEntryArg( raw: string | undefined, projectDir: string, stat: (path: string) => Stats, + real: (path: string) => string = realpathOrSame, ): CompositionEntryParseResult { const trimmed = normalizeCompositionEntryArg(raw); // Normalize the project-root shorthands to "no entry override" so the @@ -174,12 +175,11 @@ export function parseCompositionEntryArg( const absProjectDir = resolve(projectDir); const entryPath = resolve(absProjectDir, trimmed); - // Trailing-separator guard: `startsWith` alone treats `/proj` as a - // prefix of `/proj-evil`, letting a sibling-directory escape through. - // Allow the resolved path to BE the project dir (already covered by - // the trimmed === "." branch above) or to live beneath it with a - // path separator. - if (entryPath !== absProjectDir && !entryPath.startsWith(absProjectDir + sep)) { + // An absolute entry may name the project through a symlink (macOS /tmp), so try both spellings of each. + const relativeEntry = [absProjectDir, real(absProjectDir)] + .flatMap((root) => [entryPath, real(entryPath)].map((path) => relative(root, path))) + .find((rel) => rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); + if (relativeEntry === undefined) { return { ok: false, error: { kind: "outside-project", entryFile: trimmed } }; } @@ -195,7 +195,15 @@ export function parseCompositionEntryArg( // producer's readFileSync. Reject here with an actionable message. return { ok: false, error: { kind: "not-a-file", entryFile: trimmed } }; } - return { ok: true, value: trimmed }; + return { ok: true, value: relativeEntry.split(sep).join("/") }; +} + +function realpathOrSame(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } } function compositionEntryErrorMessage(error: CompositionEntryParseError): {