refactor(core): route project paths through a single resolveWithinProject chokepoint#1398
refactor(core): route project paths through a single resolveWithinProject chokepoint#1398jrusso1020 wants to merge 1 commit into
Conversation
miguel-heygen
left a comment
There was a problem hiding this comment.
Building on my review of #1397.
The structural argument is sound. The historical miss — render.ts got the +sep check but not the symlink-aware one, play.ts had neither — happened because both were resolve() + manual guard, with no enforcement that the guard had to be there. resolveWithinProject makes the forget-able step physically unavailable: you get the path or null, there's nothing to omit.
Implementation review:
safePath.ts — resolveWithinProject is a clean wrapper. Correct that it re-uses isSafePath rather than re-implementing; no edge-case divergence possible.
files.ts — All four call sites (resolveProjectPath, rename, duplicate, upload ?dir) cleanly converted. The upload-dir case deserves a call-out: subDir ? resolveWithinProject(project.dir, subDir) : project.dir correctly handles the empty-subDir path (falls back to project.dir, no resolve needed).
preview.ts — Both call sites converted. Minor improvement: const file = resolveWithinProject(…) + early if (!file) return c.text("not found", 404) is cleaner than the old resolve() + isSafePath() split — the 404 fires before the existsSync call on a rejected path.
render.ts — resolveWithinProject(project.dir, body.composition) with null-check; composition assignment still uses the original relative body.composition (not the resolved absolute), which is correct — the route stores and passes the relative path downstream.
htmlBundler.ts — safePath() local wrapper eliminated, replaced by resolveWithinProject. All six call sites updated consistently. The @import CSS containment check stays on isSafePath directly (correct: it resolves against cssFileDir, not projectDir, so a single-base chokepoint can't model it — per the PR description).
Intentionally left on isSafePath (two-base cases) — documented and correct per the PR body.
New tests — 4 resolveWithinProject cases covering in-project path, not-yet-existing target, .. → null, symlink escape → null. Cover the contract. The symlink test doesn't use tryCreateSymlink/early-return — on Windows non-privileged runners this will throw rather than skip. Low severity (this is test-only, and the symlinkSync call is unconditional), but worth aligning with the repo convention for consistency.
CI — All green including all 8 regression shards and Perf jobs. mergeStateStatus: CLEAN.
Verdict: APPROVE
Reasoning: Pure refactor — behavior-identical for all existing test paths, structurally removes the "forget isSafePath after resolve()" failure mode. Tests pass. One minor nit (missing tryCreateSymlink guard in the new resolveWithinProject symlink test), doesn't block.
— Via
…ject chokepoint Structural follow-up to the symlink-escape fix. The recurring miss (#465 fixed isSafePath but left render.ts; the sweep then turned up play.ts, htmlBundler, ...) is because containment was enforced by convention — "remember to call isSafePath after every resolve()" — which a new call site can silently skip. Add resolveWithinProject(base, relativePath) -> string | null (resolve + containment in one call) and route the studio-api + bundler sites through it, so a caller cannot resolve a project-relative path without the guard: - studio-api routes/files.ts (read, rename, duplicate, upload-dir), preview.ts (sub-comp + static asset), render.ts (composition) — all the resolve()+isSafePath() pairs collapse to a single call. - compiler/htmlBundler.ts: its local safePath helper was exactly this; drop it for the shared one. Left intentionally on isSafePath: files.ts upload (resolves a name against a validated sub-dir but contains against the project root) and htmlBundler's CSS @import (resolves against the CSS file's dir, contains against the root) — these resolve and contain against *different* bases, which the single-base chokepoint doesn't model. Exported from @hyperframes/core and re-exported from studio-api/helpers for back-compat. Adds resolveWithinProject unit tests; all existing studio-api route tests pass unchanged (behavior is identical — same resolve, same containment, same reject paths). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cd90352 to
809a978
Compare
What
Stacked on #1397. Introduce
resolveWithinProject(base, relativePath): string | null(resolve + containment in one call) and route the studio-api routes and the bundler through it, replacing the scatteredresolve()+ separateisSafePath()pairs.Why
#1397 fixed the symlink-escape, but the reason it had to be a multi-round sweep is structural: containment was enforced by convention — "remember to call
isSafePathafter everyresolve()." That's exactly how #465 fixed the helper yet leftrender.tsunguarded, and howplay.ts/htmlBundlerwere missed initially. A new route can silently reintroduce the hole.Collapsing the two steps into one call removes the failure mode: you cannot resolve a project-relative path without the containment guard, because the resolve only happens inside the guarded helper and a rejected path comes back as
null.How
Routed through it:
studio-api/routes/files.ts— read, rename, duplicate, upload-dirstudio-api/routes/preview.ts— sub-composition + static-asset servingstudio-api/routes/render.ts— composition pathcompiler/htmlBundler.ts— its localsafePathhelper was this; dropped for the shared oneExported from
@hyperframes/coreand re-exported fromstudio-api/helpers/safePath.tsfor back-compat.Intentionally left on
isSafePath(two sites that resolve and contain against different bases, which a single-base chokepoint can't model):files.tsupload — resolves a filename against a validated sub-dir, contains against the project root.htmlBundlerCSS@import— resolves against the CSS file's dir, contains against the project root.Test plan
resolveWithinProjectunit tests (in-project path, not-yet-existing target,..escape → null, in-project-symlink escape → null).@hyperframes/core(1484 tests) pass;tsc --noEmit(core + cli + engine),oxlint,oxfmt --checkclean.🤖 Generated with Claude Code