Skip to content

Skip unsafe Rush subspace locks in the lock inventory - #2003

Open
Mikola Lysenko (mikolalysenko) wants to merge 3 commits into
mainfrom
fix/rush-subspace-listing
Open

Mikola Lysenko (mikolalysenko) wants to merge 3 commits into
mainfrom
fix/rush-subspace-listing

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

What was wrong

Lockfile discovery lists a Rush monorepo's subspace pnpm locks with rush_lock_rels (vendor/lock_inventory/pnpm.rs). That function keeps only real directories with UTF-8 names that pass is_safe_single_segment, and it reads them through the ProjectView.

The lock inventory (inventory_rush_pnpm_locks_in) built its own list of the same locks:

  • Disk and Snapshot views: it ran a separate read_dir with no name check and read each lock by absolute path. As a result it inventoried packages from subspace locks that discovery skips on purpose, such as a subspace named a:b. Under a Snapshot view it also bypassed the snapshot cache, overlay and read tracking, so discovery and the inventory could see different bytes in one run.
  • Memory view: it listed children without the name check.

Discovery and the inventory also decided "is this a Rush monorepo?" in three different ways:

Reader Check
Discovery exists_no_follow (lstat)
Inventory Rush lock reader raw tokio::fs::metadata (follows links, bypasses the view) on disk, contains in memory
Inventory member-lock reader is_file

Finally, rush_lock_rels followed symlinked common/… directories, so it could read another repository's locks.

Fix

  • One subspace list. inventory_rush_pnpm_locks_in gets its paths from rush_lock_rels(view) and reads each one through the view, for every view kind. The disk-only inventory_rush_pnpm_locks and the duplicated Memory listing are gone.
  • One Rush gate. The new is_rush(view) (view.is_file("rush.json"), hosted mode's gate; round 1 used exists_no_follow, see round 2) is used by hosted mode, the vendor flavor probe, discovery (extract_pnpm), the inventory's Rush lock reader and its member-lock reader. Hosted also pins exactly the rush_lock_rels list.
  • No symlinked lock directories. rush_lock_rels drops the common lock if common, common/config or common/config/rush is a symlink. It drops all subspace locks if common, common/config or common/config/subspaces is a symlink. The subspace directory itself must still be a real directory with a safe UTF-8 name.
  • Test helper removed. The #[cfg(test)] inventory_pnpm_lock_at helper and its re-export are deleted. The FIFO test now calls the production inventory_pnpm_lock_rel_in(&ProjectView::Disk(&root), "shrinkwrap.yaml").
  • Docs. The Rush fallback's doc and the comment at its call site now describe when it actually runs: on any flavor-probe refusal except the PnP ones (which propagate as errors), once no earlier fallback has returned entries. Outside a Rush monorepo it reads nothing.

Tests

New tests. Each behavior test failed on the code before the review fixes:

  • rush_skips_unsafe_subspace_names: the inventory twin of discovery's pnpm_rush_without_subspaces_and_unsafe_subspace_names. It checks the Disk, Snapshot and Memory views. The a:b directory is created on disk on Unix only. The Memory case covers both a:b and a\b and runs on every platform.
  • rush_gate_sees_the_snapshot_overlay
  • rush_gate_keeps_the_recording_reusable
  • rush_gate_counts_a_dangling_symlink (Unix only)
  • rush_gate_counts_a_memory_implied_dir
  • rush_member_locks_gate_matches_discovery
  • rush_refuses_symlinked_lock_dirs (Unix only): covers a symlinked subspaces, rush and common, checking both rush_lock_rels and the inventory.

Runs:

  • cargo test -p socket-patch-core --lib -- vendor::lock_inventory:: vex::discover:: crawlers::npm_crawler::: 712 passed, 0 failed. This includes the Rush inventory pins, discovery's Rush tests, the FIFO test and the vex-discover golden corpus test committed_fixture_corpus_matches_golden, which covers nested-rush-lock.
  • cargo clippy --locked --workspace --all-features -- -D warnings and cargo fmt --all -- --check both pass.

Review findings

All seven /code-review high findings were confirmed and fixed. For each behavior finding I first wrote a test and saw it fail.

  1. The Rush gate went around the view. Confirmed: the tracked-snapshot recording came back None and the overlay was ignored. Fixed by is_rush.
  2. The gate disagreed with discovery. Confirmed: a dangling-symlink rush.json and an implied in-memory rush.json/ directory gave different answers. Fixed by is_rush.
  3. The member-lock reader used a third check (is_file). Confirmed. All three readers now share is_rush.
  4. Symlinked common/… directories were followed. Confirmed. Symlinked components are now refused, and the doc comment says so.
  5. The new test only covered Disk. Confirmed as a coverage gap: the code from the first commit already passed under Snapshot and Memory. Coverage added.
  6. Test-only helper. Deleted; the FIFO test now uses the production path.
  7. Inaccurate doc. Confirmed and corrected.

Not changed, because they are outside this PR's readers:

  • The vendor flavor probe (vendor/npm_flavor.rs) checks rush.json with view.exists, which follows links. That check only chooses the refusal code and does not change which locks the inventory reads.
  • The hosted engine (hosted/engine.rs) has its own rush.json check. (Fixed in round 2 below.)

A lock file that is itself a symlink is still followed, the same way every other lock is read.

Review findings, round 2 (/code-review high on the review-fix commit, cloud routine)

Nine findings. Fixed in c0fe5b5; every behavior test below failed on f6f32f2.

  1. Hosted mode listed Rush locks on its own: confirmed and fixed. hosted/engine.rs had no name check and followed symlinked common/... dirs, so it pinned and wrote back locks that discovery and the inventory refuse (a subspace named a:b; every lock under a symlinked common/config/subspaces). Hosted now pins exactly the locks rush_lock_rels lists. Test: hosted::engine::tests::rush_locks_are_the_ones_discovery_reads.
  2. Hosted's Rush gate (rush.json is a regular file) differed from the readers' exists_no_follow: confirmed and fixed the other way round. Modes are ground truth (owner decision 3), so the shared is_rush is now hosted's gate, view.is_file("rush.json"), still probed through the view. Hosted, the vendor flavor probe, discovery and both inventory readers use it. This reverses round 1's choice of exists_no_follow.
    • Behavior change (discovery and inventory): a dangling rush.json link, a rush.json directory, or an implied in-memory rush.json/ directory is no longer a Rush monorepo. Rush can't load any of them, and hosted never pinned Rush locks there. Tests: rush_gate_ignores_a_dangling_symlink, rush_gate_ignores_a_memory_implied_dir, rush_gate_counts_a_link_to_a_file, vex::discover::npm::tests::pnpm_rush_gate_ignores_a_dangling_rush_json. The round-1 tests rush_gate_counts_a_dangling_symlink and rush_gate_counts_a_memory_implied_dir are replaced by these.
  3. A stale dangling rush.json suppressed a plain pnpm workspace's member locks: confirmed and fixed by the same gate. Test: rush_member_locks_gate_matches_discovery (extended).
  4. The vendor flavor probe used view.exists: confirmed and fixed. It uses is_rush now, so a rush.json directory no longer refuses with vendor_rush_unsupported. Test: vendor::npm_flavor::tests::a_rush_json_directory_is_not_rush.
  5. Discovery-side coverage: added. See the discovery test in 2. The symlinked-directory refusal lives in the shared rush_lock_rels, which is pinned by rush_refuses_symlinked_lock_dirs.

Not changed:

  • A lock file that is itself a symlink is still followed by the readers. Hosted refuses it (symlinked_reads). The read boundary for every project-file read is Never read or rewrite project files outside the git repository root #2025's job (ReadBoundary, real-path checked), so this PR doesn't add a Rush-only version of it.
  • No diagnostic when a symlinked Rush dir drops a lock. Discovery and hosted both skip that lock, so nothing is attested and nothing is written. A diagnostic would only be a UX improvement. Left for a follow-up.
  • TOCTOU between the lstat and the read, and three duplicate lstats. Every reader in the codebase opens files by path. A swap mid-run inside the user's own checkout isn't in the threat model, and the extra lstats are negligible.

Tests (cloud run): cargo test -p socket-patch-core --lib -- vendor::lock_inventory:: vex::discover:: crawlers::npm_crawler:: hosted::engine:: vendor::npm_flavor gives 828 passed, including the golden corpus. CLI --test in_process_redirect|hosted_memory_parity|in_process_alternate_installers rush pass. cargo clippy --locked --workspace --all-features -- -D warnings and cargo fmt --all -- --check pass.


Generated by Claude Code

Lockfile discovery lists a Rush monorepo's subspace pnpm locks with
`rush_lock_rels`, which keeps only real directories whose names pass
`is_safe_single_segment` (no `\`, `:`, traversal) and are UTF-8, and
reads them through the `ProjectView`. The lock inventory listed the same
locks separately: on disk and Snapshot views it ran its own `read_dir`
with no name check and read each lock by absolute path, so it inventoried
packages from subspace locks discovery deliberately skips (e.g. a
subspace named `a:b`), and under a Snapshot view it bypassed the snapshot
cache, overlay and read tracking, so the two passes could see different
bytes in one run. The Memory branch listed children without the name
check too.

`inventory_rush_pnpm_locks_in` now takes its paths from
`rush_lock_rels(view)` and reads each through the view with
`inventory_pnpm_lock_rel_in`, for every view kind. The `rush.json` gate
is unchanged (disk/Snapshot: `metadata` on disk; Memory: `contains`).
The disk-only `inventory_rush_pnpm_locks` and the duplicated Memory
listing are gone, and `inventory_pnpm_lock_at`, now used only by the
FIFO test, is `#[cfg(test)]`.

Tested: new `rush_skips_unsafe_subspace_names`, the inventory twin of
discovery's `pnpm_rush_without_subspaces_and_unsafe_subspace_names`
(Unix only, as `:` can't be created on Windows); it fails on the old
code and passes now. The existing Rush pins
(`rush_monorepo_inventories_common_and_subspace_locks`,
`rush_json_without_any_lock_yields_none`,
`root_pnpm_lock_wins_over_rush_fallback`), discovery's Rush tests and
the vex-discover golden corpus (incl. `nested-rush-lock`) still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Nov 4, 2026
Two npm-family inventory rules now follow lockfile discovery (and so
what hosted mode pins):

- Rush's common and subspace pnpm locks are separate install trees,
  read whenever rush.json is present, beside whatever lock the flavor
  router chose. Before, the inventory read them only as a fallback when
  the root had no lock (lock-model divergence N12). The listing itself
  is unchanged (#2003 aligns it separately). root_pnpm_lock_wins_over_
  rush_fallback pinned the old shadowing; it becomes
  rush_locks_are_read_beside_the_root_lock.
- pnpm's `lockfile` setting off (lockfile_disabled, not under Rush, as
  in discovery): pnpm neither reads nor writes the root or member locks.
  Discovery keeps a hosted ref from such a lock but withholds its lock
  pin; the inventory now keeps the entry (its coordinates and hosted url,
  which ledger liveness reads) and drops its integrity, so vendor's
  fetch and repair never verify against a pin the install does not use.
  Registry entries keep their integrity: a registry release's hash
  verifies its content whether or not pnpm reads the lock (N14). Pinned
  by pnpm_lockfile_off_withholds_hosted_pins.

"Hosted" here is the default Socket host (origin::hosted_patch_uuid with
no configured origins): the inventory has no per-run origin list yet;
the install-tree PR moves it onto the single classifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review November 5, 2026 16:24
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Nov 5, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Labeled Ready for review (burn-down agent).

  • Head: 870728ce2bbfcefad4cf086c076873bb54fd23b9
  • CI: 80/82 checks green; the only red is the e2e_vex_build pdm leg (+ its ci-ok rollup), which is main-wide: hishel 2.0.0 (released 2026-11-05) breaks pdm lock on every PR. Fix pdm e2e red on every PR: pin hishel<2 #2028 fixes it; merge main in after it lands.
  • Bugbot: reviewed this head, no issues found.
  • Mergeable, no CHANGELOG.md change.
  • Slack announcement not sent this run (no Slack send tool available); the next run will retry.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief

What it does. The Rush fallback in the lock inventory used to build its own list of subspace locks: a raw read_dir with no name check, reading each lock by absolute path. It now uses the same rush_lock_rels(view) list that lockfile discovery uses (real directories only, UTF-8 names that pass is_safe_single_segment, sorted) and reads each lock through the project view. So the inventory and discovery see the same locks with the same bytes, and Snapshot views now get the overlay, the cache and read tracking.

Risk: low. Two files change (pnpm.rs, about +25/−59, plus one test). The read is no looser: the Disk view uses the same FIFO-safe reader as before, and symlinked subspace directories are still skipped. The only change in behaviour is the narrowing the PR intends: names with :, \, NUL, ./.. or non-UTF-8 bytes are now skipped, as discovery already skips them.

Look here

  • pnpm.rs:115: the rewritten inventory_rush_pnpm_locks_in
  • pnpm.rs:21: rush_lock_rels, the shared list (unchanged)
  • pnpm.rs:63: inventory_pnpm_lock_at, now #[cfg(test)]
  • tests.rs:788: the new test rush_skips_unsafe_subspace_names, which would fail on the old Disk path

Verified

  • Read the full diff and the helpers it relies on (list_disk_dir, read_text, is_safe_single_segment), plus the discovery caller at vex/discover/npm.rs:711.
  • cargo test -p socket-patch-core --lib -- rush: 15 passed, including the new test.
  • CHANGELOG.md is untouched, and there's no debug code left in or unrelated edits.
  • CI: ci-ok is red only on the known pdm e2e (e2e_vex_build pdm, hishel 2.x), which Fix pdm e2e red on every PR: pin hishel<2 #2028 fixes. clippy is green.

Nits (non-blocking)

  • The new test covers the Disk view only. The Memory and Snapshot paths share the code but have no test of their own.
  • Pre-existing and out of scope: the Snapshot rush.json gate still checks the disk directly instead of going through the overlay.

Auto-merge is not armed: ci-ok has to go green first. Once it's green it gets armed, and approving then sends it straight to the merge queue.


Generated by Claude Code

Review follow-ups on the Rush subspace listing fix.

The inventory's rush.json gate went around the view on Disk and Snapshot
views (`disk_root()` + `tokio::fs::metadata`). That marked a tracked
snapshot's recording unusable, kept rush.json out of the ReadSet and
ignored overlays. It also followed links, where discovery uses lstat
(`exists_no_follow`), so a dangling rush.json symlink or an implied
in-memory `rush.json/` directory got different answers from the two.
The member-lock reader used a third check (`is_file`). Now one
`is_rush(view)` (`exists_no_follow("rush.json")`) gates discovery, the
Rush lock reader and the member-lock reader.

`rush_lock_rels` only checked that the subspace leaf was a real
directory. A symlinked `common`, `common/config`, `common/config/rush` or
`common/config/subspaces` was followed, so another repository's locks
could be read. Any symlinked component now leaves its locks out.

The `#[cfg(test)]` `inventory_pnpm_lock_at` helper is gone. The FIFO test
reads `shrinkwrap.yaml` through the production
`inventory_pnpm_lock_rel_in(&ProjectView::Disk(..), ..)`. The Rush
fallback's docs now give the condition it actually runs under: any
non-PnP flavor-probe refusal that no earlier fallback answered.

Tests, each failing before this change:
- rush_gate_sees_the_snapshot_overlay
- rush_gate_keeps_the_recording_reusable
- rush_gate_counts_a_dangling_symlink (Unix)
- rush_gate_counts_a_memory_implied_dir
- rush_member_locks_gate_matches_discovery
- rush_refuses_symlinked_lock_dirs (Unix)

rush_skips_unsafe_subspace_names now also runs through Snapshot and
Memory views. The Memory case runs on every platform and covers both `a:b`
and `a\b`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f6f32f2. Configure here.

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Ready for review re-confirmed at the current head (burn-down agent).

  • Head: f6f32f26
  • CI: 47/49 green or skipped. The 2 red checks are main-wide: e2e (ubuntu-latest, e2e_vex_build, … pdm:: …) fails with hishel 2.x (module 'hishel' has no attribute 'SyncSqliteStorage'), the same failure as pdm e2e on main; ci-ok is red only because of that job. The fix is Fix pdm e2e red on every PR: pin hishel<2 #2028.
  • Bugbot: reviewed f6f32f26 with no findings.
  • Mergeable: yes, no CHANGELOG change.
  • Slack: not announced; this run had no Slack send tool.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief (updated for f6f32f2)

What it does. Lockfile discovery and the lock inventory now read the same Rush pnpm locks: the common lock plus subspace locks whose names are safe single path segments, all read through the project view. The new commit makes one is_rush check (an lstat of rush.json through the view) gate all three readers, and skips any Rush lock reached through a symlinked common, common/config, common/config/rush or common/config/subspaces directory.

Risk: low. It narrowly tightens a fallback path, which now refuses more than it did before. Its only other effect is that the inventory no longer reads rush.json outside the view.

Look here

  • pnpm.rs:19: is_rush, the shared gate
  • pnpm.rs:33: rush_lock_rels and has_symlinked_dir (L58)
  • pnpm.rs:133: inventory_rush_pnpm_locks_in, which now goes through the view
  • npm.rs:691: discovery uses the same gate
  • tests.rs:935: rush_refuses_symlinked_lock_dirs

Verified

  • I read the new commit and the full PR diff. CHANGELOG.md is untouched, and there is no debug code or unrelated edit.
  • Missing directories are handled: is_symlink returns false and the listing comes back empty. On Windows, is_symlink also treats junctions as symlinks. The symlink tests are #[cfg(unix)], and the unsafe-name test (a:b, a\\b) runs on every platform.
  • I couldn't run the tests locally this hour because the runner's disk was full, so this relies on CI. clippy is green. ci-ok is red only on the shared pdm e2e (e2e_vex_build pdm, hishel 2.x), which Fix pdm e2e red on every PR: pin hishel<2 #2028 fixes.

Nits (non-blocking)

  • No test covers a symlinked common/config specifically. The prefix walk covers it.
  • There is a small TOCTOU window between the symlink check and the read. Exploiting it needs control of the local tree.

Auto-merge is not armed yet: ci-ok must go green first, after #2028 lands and this branch picks it up.


Generated by Claude Code

Second review round. Hosted mode had its own Rush gate (`rush.json` is a
regular file) and its own subspace listing (no name check, symlinked
`common/...` dirs followed), so it pinned, and wrote back, locks that
discovery and the inventory refuse, and the readers' gate
(`exists_no_follow`) disagreed with it on a dangling `rush.json` link or
a directory of that name.

Modes are ground truth, so the shared `is_rush` gate is now hosted's:
`view.is_file("rush.json")` (on disk a link to a regular file counts; a
dangling link, a directory, or an implied in-memory directory does not,
since Rush cannot load any of them). Hosted mode, the vendor flavor
probe, discovery and both inventory readers use it, and hosted pins
exactly the locks `rush_lock_rels` lists.

Behavior changes:
- discovery / inventory: a dangling `rush.json` link or a `rush.json`
  directory is no longer a Rush monorepo (no Rush lock read; a plain
  pnpm workspace's member locks are read again);
- hosted: a subspace whose name is not a safe path segment, and every
  lock under a symlinked `common`, `common/config`,
  `common/config/rush` or `common/config/subspaces`, is no longer pinned;
- vendor probe: a `rush.json` directory no longer refuses with
  `vendor_rush_unsupported`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants