Skip to content

refactor(core): route project paths through a single resolveWithinProject chokepoint#1398

Open
jrusso1020 wants to merge 1 commit into
mainfrom
refactor/studio-api-path-chokepoint
Open

refactor(core): route project paths through a single resolveWithinProject chokepoint#1398
jrusso1020 wants to merge 1 commit into
mainfrom
refactor/studio-api-path-chokepoint

Conversation

@jrusso1020

Copy link
Copy Markdown
Collaborator

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 scattered resolve() + separate isSafePath() 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 isSafePath after every resolve()." That's exactly how #465 fixed the helper yet left render.ts unguarded, and how play.ts / htmlBundler were 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

export function resolveWithinProject(base: string, relativePath: string): string | null {
  const resolved = resolve(base, relativePath);
  return isSafePath(base, resolved) ? resolved : null;
}

Routed through it:

  • studio-api/routes/files.ts — read, rename, duplicate, upload-dir
  • studio-api/routes/preview.ts — sub-composition + static-asset serving
  • studio-api/routes/render.ts — composition path
  • compiler/htmlBundler.ts — its local safePath helper was this; dropped for the shared one

Exported from @hyperframes/core and re-exported from studio-api/helpers/safePath.ts for back-compat.

Intentionally left on isSafePath (two sites that resolve and contain against different bases, which a single-base chokepoint can't model):

  • files.ts upload — resolves a filename against a validated sub-dir, contains against the project root.
  • htmlBundler CSS @import — resolves against the CSS file's dir, contains against the project root.

Test plan

  • New resolveWithinProject unit tests (in-project path, not-yet-existing target, .. escape → null, in-project-symlink escape → null).
  • All existing studio-api route tests pass unchanged — behavior is identical (same resolve, same containment, same reject paths); this is a pure refactor with no behavior change.
  • @hyperframes/core (1484 tests) pass; tsc --noEmit (core + cli + engine), oxlint, oxfmt --check clean.
  • Docs — n/a (internal helpers/routes)

Note: GitHub shows this against main until #1397 merges; review the compare against the base branch for the true delta. Base auto-retargets to main on #1397 merge.

🤖 Generated with Claude Code

miguel-heygen
miguel-heygen previously approved these changes Jun 13, 2026

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsresolveWithinProject 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.tsresolveWithinProject(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.tssafePath() 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

Base automatically changed from fix/studio-api-symlink-path-escape to main June 13, 2026 03:08
@jrusso1020 jrusso1020 dismissed miguel-heygen’s stale review June 13, 2026 03:08

The base branch was changed.

…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>
@jrusso1020 jrusso1020 force-pushed the refactor/studio-api-path-chokepoint branch from cd90352 to 809a978 Compare June 13, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants