Skip to content

Fix: workspace root marker must canonicalise before containment check (#298) - #299

Merged
fdaviddpt merged 3 commits into
mainfrom
fix/298
Oct 5, 2026
Merged

fdaviddpt merged 3 commits into
mainfrom
fix/298

Conversation

@fdaviddpt

Copy link
Copy Markdown
Contributor

Summary

#296/#297 fixed the plain-path containment bypass in WorkspaceRootResolver -- a marker directory outside every workspace folder hijacking root resolution. This issue is the direct sequel: the same bypass survives through a path spelling the earlier fix never canonicalised -- a document path containing .. that textually re-creates the workspace's own prefix, or one reached through a symlink pointing outside the workspace. Reproducible with no workspaceFolders declared at all.

isDescendant() canonicalises both sides (via realpath, falling back to a lexical ..-collapse or an existing-ancestor walk for a not-yet-existing path) before the containment prefix check.

Three commits: the primary fix, then two review follow-ups fixing real regressions the lane's own self-review rounds found (an asymmetric realpath fallback that could silently resolve to the wrong root for an ordinary unsaved document, a Windows drive-letter mishandling in the new dot-segment collapse, and a relative-path filesystem-probing gap the first follow-up itself introduced). Full review history, tree-snapshot receipts and test evidence are in the accompanying report.

Merge gate

This diff changes which project root gets chdir()'d into (WorkspaceRootResolver::resolve()'s return value is RootedDiagnosticsSourcePool::get()'s chokepoint), so per this repo's CLAUDE.md, "A pull request that can change what warm writes gets an independent end-to-end check before merge" applies -- the same gate #296/#297 needed. An independent E2E check (warm vs cold rector output, byte-for-byte) is required before merge; the lane's own unit tests are not enough.

Test plan

  • New regression tests for the .. bypass and the symlink bypass, confirmed red against pre-fix code, green with the fix
  • New regression tests for the two follow-up fixes (asymmetric realpath fallback, Windows drive-letter), confirmed red/green across commits
  • New regression test for the relative-path ancestor-walk floor, confirmed red/green
  • Full tests/Unit/Lsp suite green (134/134, 3 skipped by design)
  • Independent E2E check (warm vs cold, byte-for-byte) -- required before merge per the merge gate above, not yet performed

Closes #298

Florian DAVID and others added 3 commits October 5, 2026 11:04
…#298)

WorkspaceRootResolver::isDescendant() was a plain string-prefix test over
normalize()'d paths, which never resolves ".." segments or symlinks. A
document path containing ".." (e.g. "$workspace/../elsewhere/proj/x.php")
textually re-creates the workspace's own prefix and was treated as
"inside" it, and a symlink inside the workspace whose real target sits
outside it was treated the same way -- both the same containment bypass
#296 fixed, reached through a different path spelling, reproducing even
with no workspaceFolders declared at all.

Both sides of isDescendant()'s comparison are now canonicalised via
realpath() first, falling back to a purely lexical "."/".." collapse
when realpath() returns false for a path that does not yet exist on
disk (a directory the walk-up loop has not reached yet, a synthetic
path in a unit test, or a Windows drive-letter path exercised on a
non-Windows runner).

Adds a ".."-bearing regression test paired with the existing #296 test,
plus a symlinked-subdirectory case (skipped if symlink() is unavailable
in the test environment).

This changes which project root a warm worker boots against, so per
CLAUDE.md it needs an independent end-to-end check (warm vs cold) before
merge, same as #296/#297.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
… drive-letter dot-dot

Two further gaps found by the self-review spawns on the first #298 commit, both in
WorkspaceRootResolver, both fixed before merge:

1. canonicalize() fell straight to a purely lexical "."/".." collapse of the WHOLE
   path the moment realpath() failed on it. That mixed two canonicalisation
   strategies across the two sides of isDescendant()'s comparison whenever only one
   side existed on disk yet: a workspace root (which almost always exists, and is
   routinely itself behind a symlink -- macOS resolves sys_get_temp_dir() under
   /tmp, itself a symlink to /private/tmp) got realpath()'d through the symlink,
   while a brand-new/unsaved document's own directory (which often does not exist
   yet) did not -- so the two no longer shared a prefix even though one was
   genuinely inside the other, and resolve() silently fell back to the wrong, too
   shallow root for an entirely ordinary document. Fixed by resolving the longest
   EXISTING ancestor through realpath() first (new resolveExistingAncestor()), and
   only running the lexical collapse over the non-existent tail.

2. collapseDotSegments() decided "is this path absolute" solely by a leading "/",
   so a Windows-style drive-letter path ("C:/...") was treated as relative -- a
   leading ".." could pop the drive letter itself off the stack like any ordinary
   segment. Fixed by protecting the drive letter the same way a POSIX leading "/"
   is already protected.

Both pinned with a new regression test, confirmed red against the first commit and
green with the fix. Full tests/Unit/Lsp suite: 133/133, 3 skipped by design.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
… rooted paths only

Second, lightweight review round over the previous follow-up commit (fix_commit_scope.py
flagged needs-second-pass: 3 files touched) found one more real, newly-introduced gap:
resolveExistingAncestor() probes shorter prefixes of a path through realpath() looking
for the longest existing one -- correct and bounded for an absolute path (a POSIX "/" or
a Windows drive letter floors the walk at the root segment), but a genuinely relative
path has no such floor, so an ordinary segment name that happens to coincide with a real
directory relative to the SERVER PROCESS's own cwd (not the workspace being resolved)
would get silently canonicalised against that unrelated directory.

Production document paths are always absolute (derived from a file:// URI), so this is
not reachable from real LSP traffic -- but it is a genuine, previously-absent surface
this follow-up itself introduced (the pre-follow-up lexical-only fallback never touched
the filesystem once realpath() had already failed), and it is cheap to close at the same
chokepoint: canonicalize() now only runs the ancestor-walk for a path it recognises as
rooted, and falls straight to the lexical collapse for anything else, which is exactly
the old, already-reviewed-safe behaviour for that case.

Pinned with a new regression test (confirmed red against the prior commit, green with
the fix). Full test suite: 556 tests green (ran by the review spawn), tests/Unit/Lsp:
134/134, 3 skipped by design.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
@fdaviddpt

Copy link
Copy Markdown
Contributor Author

Independent E2E check (2 of 2) against head e79c1eb

Own harness, fresh detached worktrees (e79c1eb, plus 56300c9 = main before this PR, as the red control). It drives the real bin/rector-warm-lsp over stdio with hand-rolled Content-Length framing: initialize with workspaceFolders, no --working-dir, then initialized, didOpen, codeAction, a didChange buffer edit and shutdown/exit. Every scenario gets a fresh fixture tree and a fresh server process. Observed on macOS (Darwin 24.3, PHP 8.2.0) only.

Check 1 -- sentinel, .. and symlink vectors: PASS

Fixture: base/W (rector.php + vendor/autoload.php that appends to base/SENTINEL_W, src/Inside.php, an empty sub/), and base/elsewhere/D (its own rector.php + vendor/autoload.php that appends to base/SENTINEL_D, doc.php). Also a symlink W/sub/linked -> D. Server cwd = W, workspaceFolders=[W]. Each run does a didOpen, a codeAction, then a didChange (unsaved buffer, which is the path that creates .rector-warm-<pid> next to the document) and another codeAction. A 2 ms poller watches D for any .rector-warm-* entry for the whole server lifetime.

scenario e79c1eb (this PR) 56300c9 (red control)
.. vector: W/sub/../../elsewhere/D/doc.php refused: "path is outside the configured working directory", 0 code actions, SENTINEL_D absent, no .rector-warm-* ever seen in D, doc.php unchanged D's config used: SimplifyIfReturnBool diagnostic + 2 code actions, SENTINEL_D created, .rector-warm-<pid> seen in D
symlink vector: W/sub/linked/doc.php refused (same message), 0 code actions, SENTINEL_D absent, no .rector-warm-* in D, doc.php unchanged D's config used, SENTINEL_D created, temp dir seen in D
positive control: W/src/Inside.php (no tricks) diagnostic + 2 actions, SENTINEL_W fires (SENTINEL_D absent) same
positive control: D's own sentinel and temp-dir watcher are live (workspaceFolders=[D], open D/doc.php) SENTINEL_D fires, .rector-warm-<pid> seen in D same

Every server exited 0. No .rector-warm-* was left anywhere in any fixture tree.

Check 2 -- in-workspace stability when the workspace folder is behind a symlink: PASS (warm == cold, byte-identical)

I read canonicalize() / resolveExistingAncestor() at e79c1eb, and the 6a78b2a diff, before running this. Both $owner and $dir go through the same canonicalize(). For existing paths that means a full realpath() on both sides, so a symlinked workspace folder and a document under it land on the same real prefix. Observed:

Fixture: realW (rector.php withPaths([__DIR__.'/src']) + codeQuality set, src/Target.php with two fixable smells, and a vendor/autoload.php sentinel that records the cwd it was loaded from). W -> realW is a symlink. Flow: didOpen W/src/Target.php, then codeAction "Apply all Rector fixes", then the edits are applied client-side. The cold oracle is vendor/bin/rector process --config=rector.php -- src/Target.php run in realW on the same original file.

variant diagnostics warm root (from sentinel cwd) warm vs cold
workspaceFolders=[W] (symlink), server cwd = an unrelated dir with no markers SimplifyIfReturnBool, RemoveAlwaysElse realW byte-identical (sha256 a127a836...e1e91, both changed the file)
workspaceFolders=[W] (symlink), server cwd = W same realW byte-identical (same sha)
macOS /tmp case: folder and document spelled /tmp/... (/tmp -> /private/tmp), server cwd unrelated same realW byte-identical (same sha)

The unrelated server cwd makes the check discriminating. If resolution had fallen back to the boot cwd, the containment check would have refused the document and produced no diagnostics. The same three variants on 56300c9 give the same diagnostics, the same root and the same sha, so ordinary symlinked-workspace use resolves to the same root as before #298.

Reasoned, not observed: the 6a78b2a case (an unsaved document whose directory does not exist yet) is not exercised end to end. The buffer path refuses a non-existent directory (realpath false) whatever root is chosen, so it cannot produce a write. Windows and Linux were not run.

Verdict: Check 1 PASS, Check 2 PASS. No merge-blocking finding from this check.

@fdaviddpt
fdaviddpt merged commit 883b6bc into main Oct 5, 2026
24 checks passed
@fdaviddpt

Copy link
Copy Markdown
Contributor Author

Independent E2E checks at e79c1eb (requested before merge, per #296/#297's merge-gate oversight)

Built fresh fixtures in throwaway worktrees (not reusing any other agent's worktree); both cleaned up afterward.

Check 1 -- sentinel test extended to canonicalisation (.. and symlink) -- PASS

Workspace W (real rector.php, server cwd=W, workspaceFolders=[W], no --working-dir) and a directory D entirely outside W with its own rector.php and a vendor/autoload.php that writes a sentinel file the instant PHP requires it (same technique project-composer-autoload.yaml already relies on, and exactly what RectorRunner::ensureProjectAutoloaded() requires off getcwd()).

Check 2 -- in-workspace stability when the workspace folder itself is reached through a symlink -- PASS (a)

Built realW (real directory, rector.php, fixable src/Fixable.php) and W as a symlink to it. Server started with cwd outside either (workspaceFolders is what matters here), workspaceFolders=[W] using the literal symlinked path, not resolved by the test script. didOpen on W/src/Fixable.php, one diagnostic came back (SimplifyIfReturnBoolRector), requested textDocument/codeAction, applied the returned edit to the original content, and diffed byte-for-byte against cold vendor/bin/rector process --config=rector.php -- src/Fixable.php run directly against realW.

Warm's applied edit is byte-identical to cold's, run for run, with no caveats. This is exactly the asymmetric-realpath() class of risk 6a78b2a's follow-up fixed (an existing workspace root behind a symlink vs. a document path) -- read that commit's diff and e79c1eb's own canonicalize()/resolveExistingAncestor() before building this: since the document's own directory exists on disk here, both sides of isDescendant()'s comparison go through the same full realpath() resolution, landing on the same real root either way. No regression found for this ordinary in-workspace case.

Summary

  • Check 1: PASS (both vectors refused, positive control fires, red control confirms the harness is real)
  • Check 2: PASS -- warm == cold, byte-for-byte, for an ordinary document under a symlinked workspace folder

No blockers found in either check.

@fdaviddpt
fdaviddpt deleted the fix/298 branch October 5, 2026 09:56
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.

Workspace containment bypass survives #296: ".." or a symlink still escapes the owner workspace

1 participant