fix(cli): render -c takes an absolute entry inside the project, relative to the project (#6141)

* fix(cli): render -c takes an absolute entry inside the project, relative to the project

An absolute entry was returned as typed, so the producer joined it onto the project path and failed with the entry not found. A project reached through a symlinked path (macOS /tmp) refused its own files as outside the project. The entry is now checked against both spellings of the project path and handed on project-relative.

* test(cli): import the filesystem helpers the entry tests use

* chore(cli): format the entry path tests
This commit is contained in:
Miguel Ángel
2026-10-23 19:31:30 +00:00
committed by GitHub
parent f8ae1483c5
commit 9fe0511c2e
2 changed files with 71 additions and 10 deletions
+54 -1
View File
@@ -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"), "<html></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(
+17 -9
View File
@@ -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): {