Skip to content

Never read or rewrite project files outside the git repository root - #2025

Draft
Mikola Lysenko (mikolalysenko) wants to merge 2 commits into
mainfrom
fix/reads-bounded-to-repo-root
Draft

Mikola Lysenko (mikolalysenko) wants to merge 2 commits into
mainfrom
fix/reads-bounded-to-repo-root

Conversation

@mikolalysenko

Copy link
Copy Markdown
Collaborator

Owner decision: socket-patch must not read project files outside the git repository root, or outside the project root when there is no repository. This PR applies that rule to VEX discovery, the lock inventory, hosted mode and vendored mode. It also fixes one concrete bug, N5: the vendored writers could rewrite or delete wiring that belongs to another project.

Two commits:

  1. N5: vendored writers, reverters and GC claim only this project's vendor paths.
  2. Bounds: one shared repository-root check, applied to the reads listed below.

1. N5: vendored writers claimed a sibling checkout's vendor paths

vendor::parse_vendor_path matched the .socket/vendor/ anchor anywhere in a string. Discovery's vendor_ref already required the path to start with that anchor. So a lock entry such as

"node_modules/left-pad": { "resolved": "file:../.socket/vendor/npm/<uuid>/left-pad-1.3.0.tgz" }

points at a sibling checkout's vendor store, but every vendored writer, reverter and GC treated it as ours. If the same patch uuid was vendored in both checkouts, vendor --revert wrote this project's recorded pre-vendor original over the other project's wiring.

Fix (vendor/path.rs):

  • parse_vendor_path and parse_vendor_reference (the ownership parse) now accept only a root-anchored path:

    • only . and .. segments may come before the anchor, and they must land exactly on the project root;
    • parse_vendor_path_from(base, s) handles a file below the root, for example ../../.socket/… from packages/a.
  • One function, vendor_rest_from, now does this anchoring for both the writers and discovery's vendor_ref, so the two cannot drift apart again.

  • parse_vendor_locator: yarn berry resolution: "left-pad@file:./.socket/…" has its name@ cut before the root-anchored parse.

  • parse_vendor_path_or_machine_absolute covers pnpm <= 8 only. pnpm itself writes file: override specifiers as machine-absolute paths, so a committed lock holds another checkout's absolute root. Such a path is still claimed when it names the entry's own uuid and leaf, but never when it contains ... DepRef gains a parse field so the shared pnpm revert uses this rule only for legacy locks.

  • recognize_vendor_path_anywhere keeps the old match-anywhere behavior, for recognition only. Its callers never rewrite anything:

    • the lock inventory's "is this a vendored spec" checks;
    • composer and pnpm registry listings;
    • discovery's hosted-wiring probe;
    • the binary-lock half of scan_vendor_references, which repair, vendor --check, rollback's ledger-missing guard and the orphan sweep use.

    A sibling reference there can only keep one of our own dirs from being swept, or flag it as still wired. It never leads to a rewrite.

Every parse_vendor_path caller was reviewed:

  • Writers, reverters and GC (npm, yarn classic/berry, pnpm v9/legacy, bun, composer, gem, every pypi flavor, verify, vendored_wiring_in_files): strict.
  • Recognition-only callers: recognize_vendor_path_anywhere.
  • The requirements planner: strict at base "". pip resolves vendor-line paths against the working directory, not the including file.

2. Bounds added

The shared check lives in utils/repo_root.rs:

  • ReadBoundary::of(project_root) returns the root of find_git_repo, else the project root.
  • contains(path) makes the path absolute, folds .., and canonicalizes its deepest existing level, so neither .. nor a symlink can escape.
  • bounded_project_path(root, rel) returns NotFound (a refused read looks like a missing file) and checks only when rel climbs or is absolute.
  • is_refused_read(&e) lets a writer tell "refused" apart from "missing" and refuse.
Read Before Now
go.work (go_workspace::resolve_with / load_with, the shared loader that #2011 makes discovery use too) nearest ancestor go.work up to /, or any absolute GOWORK Outside the root it is only stat'ed, never opened. The result is GoWork::Invalid with the reason, so vendored and hosted writers refuse through the existing workspace refusal and discovery withholds.
go.work use members outside the root go.mod / go.sum read read as missing, so the selection is Unknown
replace => ../dir go.mod in the module graph (read_dep_go_mod) and go_native_vendor::expected_text read missing
ProjectView::read_text / read_bytes (Disk + Snapshot), used by discovery, the lock inventory and the requirements walks: -r includes, yarn file:/portal:/link: copies, any ..//absolute rel read missing to readers. The vendored requirements planner (collect_requirements_files) and the lock flavors' pin_outside_root refuse with pypi_requirements_outside_root.
Hosted upstream restore staged reads (redirect/staged.rs::read_rel), including the absolute ancestor pnpm-workspace.yaml / .npmrc probes read the restore refuses with read <rel>: … is outside the repository root …
NuGet parent nuget.config (nuget_config::inherited_source_keys_traced) every ancestor to / Above the root it is only stat'ed. Inherited::outside names it; vendoring refuses, and hosted mode skips each NuGet dep with the new code redirect_nuget_inherited_config_outside_root, documented in CLI_CONTRACT.md

CLI_CONTRACT.md has a new section, "Reads bounded to the repository root", and a sentence in the Go paragraph.

Machine-level reads (not changed)

  • crates/socket-patch-core/src/utils/go_env.rs:26 (go_env), :148-177: the GOENV file ($GOENV, else the OS config dir's go/env), used for GOWORK / GOFLAGS / GOPROXY / GOPRIVATE.
  • crates/socket-patch-core/src/utils/go_env.rs:62 gomodcache: the Go module cache. go.mod files are read from it in vendor/go_workspace.rs (read_dep_go_mod), vendor/go_build_list.rs:313 and vendor/go_native_vendor.rs:55.
  • crates/socket-patch-core/src/vendor/nuget_config.rs:56 user_config_path (read at :145) and settings_chain: the user NuGet.Config (%APPDATA% / $DOTNET_CLI_HOME / ~/.nuget/NuGet/NuGet.Config).
  • crates/socket-patch-core/src/patch/redirect/npmrc.rs:564 outer_config_layers: npm builtin npmrc, user ~/.npmrc / npm_config_userconfig, global <prefix>/etc/npmrc.
  • crates/socket-patch-core/src/patch/redirect/yarnrc.rs:317 RcLayout::files: ~/.yarnrc, ~/.npmrc, <prefix>/etc/yarnrc|npmrc.
  • crates/socket-patch-core/src/crawlers/pkg_managers.rs:208,247: the home .yarnrc.yml.
  • crates/socket-patch-core/src/patch/redirect/upstream/npm.rs (~639-648 home .yarnrc.yml; ~2057-2075 / ~2466-2474 $XDG_CONFIG_HOME/.npmrc or ~/.npmrc, and .bunfig.toml).
  • crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:999 global_uv_settings: $XDG_CONFIG_HOME/uv/uv.toml, $XDG_CONFIG_DIRS, /etc/uv/uv.toml, %APPDATA%, %PROGRAMDATA%.
  • crates/socket-patch-core/src/vendor/cargo_config.rs:150-178 and crates/socket-patch-core/src/formats/cargo/source_replacement.rs:112-139: $CARGO_HOME/config*.
  • crates/socket-patch-core/src/crawlers/ruby_crawler.rs:51,173 (and ~2012-2035, ~2221-2240): Bundler app config ($BUNDLE_APP_CONFIG) and global config ($BUNDLE_CONFIG, $BUNDLE_USER_CONFIG, $BUNDLE_USER_HOME, ~/.bundle/config).
  • crates/socket-patch-core/src/crawlers/maven_crawler.rs:619 m2_repo_path_with: ~/.m2/settings.xml, $MAVEN_HOME/conf/settings.xml. Also jvm_cache.rs local repository and Gradle cache roots.
  • crates/socket-patch-core/src/gradle/home.rs, crawlers/gradle_cache.rs: the Gradle user home init.d and wrapper init scripts.
  • crates/socket-patch-core/src/utils/repo_root.rs:33 GitRepo::config_path: git's own config, which a gitdir: / commondir may place outside the worktree.

Left unbounded (owner to judge)

These walks search upward for a workspace root that the package manager really uses. If they are bounded and there is no .git (a CI tarball checkout, a Docker build context), the boundary becomes the project itself, so a workspace member would be treated as standalone. Today, hosted and vendored use these walks to refuse ("settings live above the project, nothing is written"); a silent bound would turn those refusals into wrong writes. Each one needs its own writer refusal, as go.work and NuGet now have. With a .git present they already stop at the checkout in practice: none of them reads above the repo root unless a workspace file actually sits there.

  • hosted/governing_root.rs: pnpm_lock_elsewhere (ancestor pnpm-workspace.yaml, workspace-root .npmrc, lockfileDir stat), package_json_workspace_root, vlt_workspace_root, vlt_project_root, member_stray_lock.
  • utils/pnpm_workspace.rs: governing_workspace_file, git_branch_locks.
  • utils/uv_workspace.rs governing_uv_workspace; utils/python_script.rs find_ancestor_uv_config / ancestor_uv_config_files; utils/hatch_workspace.rs (bounded by the checkout, but walks to $HOME without one).
  • vendor/cargo.rs workspace_root_refusal, vendor/cargo_config.rs read_config_chain_with, formats/cargo/source_replacement.rs config_layer_paths, utils/cargo_build_cache.rs (agent mode).
  • crawlers/pkg_managers.rs .yarnrc.yml ancestor walk; patch/redirect/yarnrc.rs RcLayout::files ancestor .yarnrc / .npmrc; patch/redirect/upstream/npm.rs BerryRegistrySettings::read. The restore's absolute-path probes now refuse through read_rel, but these reads use their own I/O.
  • vendor/maven_repo.rs not_build_root: bounded by ancestor_search_dirs, except with no repo.
  • crawlers/gradle_cache.rs build_roots, vex/verify.rs node_resolve: scan/verify only, stat-level.
  • vendor/cargo.rs path_crate_version: a user [patch] path = "../x"; a writer-side refusal needs its own code.
  • formats/yarn/rc.rs injectEnvironmentFiles (dotenv paths may be ../ or absolute, and may come from the home rc).
  • patch/redirect/yarnrc.rs yarn_classic_workspace_members: follows directory symlinks to members.
  • vendor/go_consumer_sync.rs modules_txt_location: when the ancestor go.work is out of bounds, resolve now says Invalid and this falls back to the project's own vendor/modules.txt. The audit there may then miss staleness in the out-of-root workspace, but the write path refuses first.
  • The scan_vendor_references text scan (repair / vendor --check / rollback guard / orphan sweep) still recognizes a ../.socket/vendor/… mention as a reference. It never rewrites it. The only effect is that one of our own dirs with the same uuid is kept rather than swept, or reported as still wired.
  • Discovery's out-of-repository -r includes (Vendored pip: a pin in a -r include outside the project root is ignored when a pylock.toml exists, so vendor --check passes and vex attests not_affected while pip install -r requirements.txt installs the unpatched release #1851) now read as missing, following "readers treat a refused read as missing". Their registry pins no longer contest a lock's wiring. Includes outside the project but inside the repo are still read.

Tests

Each new test fails with the bound disabled (ReadBoundary::contains forced to true) or with the old anywhere-anchored parse, and passes with this change:

  • vendor::npm_lock::tests::revert_never_claims_a_sibling_checkouts_vendor_path (N5: the entry is left byte-for-byte, vendor_lock_entry_drifted, artifact kept)
  • vendor::path::tests::ownership_parse_is_root_anchored
  • utils::repo_root::tests::read_boundary_is_the_checkout_else_the_project, read_boundary_sees_through_links
  • vendor::go_workspace::tests::go_work_outside_the_repository_is_never_read
  • vex::discover::golang::tests::gowork_naming_another_workspace_attests_nothing (expectation updated: an out-of-repo GOWORK now says "outside the repository root")
  • vendor::pypi_requirements::tests::includes_outside_the_repository_are_never_read
  • vendor::nuget_config::tests::a_parent_config_above_the_repository_is_never_read, vendor::nuget_feed::tests::vendor_refuses_under_a_parent_config_above_the_repository
  • patch::redirect::tests::nuget_inherited_config_outside_the_repository_skips_the_dep
  • patch::redirect::staged::tests::reads_outside_the_repository_refuse

Fixtures that model "beside the project, inside the checkout" now create a .git at their tempdir root: the go workspace / consumer-sync / go_mod_edit tests, the nuget parent-config tests, the pypi out-of-root include tests, discover's Project::nested, and one hosted engine test and one upstream npm test. Before this change these fixtures were not repositories at all.

Local runs (macOS):

Run Result
cargo test -p socket-patch-core --lib -- vendor:: vex::discover:: patch::redirect:: hosted:: utils::repo_root 4389 passed
vex::discover::testing::golden::tests::committed_fixture_corpus_matches_golden passes, no golden changes
cargo test -p socket-patch-core --lib 7144 passed
socket-patch-cli --lib 868 passed
--test vendor 159 passed
--test in_process_rollback_vendored 23 passed
--test in_process_remove_repair_lifecycle 41 passed
--test in_process_vendor_bun_takeover 45 passed
--test in_process_redirect 152 passed
--test in_process_rollback_hosted 41 passed
--test hosted_memory_engine 49 passed
--test in_process_scan 27 passed
cargo clippy --locked --workspace --all-features -- -D warnings clean
cargo fmt --all -- --check clean

🤖 Generated with Claude Code

`parse_vendor_path` found the `.socket/vendor/` anchor anywhere in a
string, so a lock entry naming a sibling checkout's store
(`file:../.socket/vendor/npm/<uuid>/x.tgz`) read as ours to every
vendored writer, reverter and GC, while discovery's `vendor_ref` already
required a root-anchored path. With the same patch vendored in both
checkouts, `vendor --revert` restored our recorded original over the
other project's wiring.

The ownership parse is now root-anchored and shared with discovery
(`vendor_rest_from`): only `.`/`..` may precede the anchor and they must
land exactly on the project root (`parse_vendor_path_from` for a file
below the root). Yarn berry locators cut their `name@` first
(`parse_vendor_locator`); pnpm <= 8 keeps its machine-absolute specifier
(`parse_vendor_path_or_machine_absolute`), which pnpm itself writes.
Readers that only ask "is this a vendored spec" (the lock inventory,
composer/pnpm registry listings, the hosted-wiring probe, the reference
scan behind repair and the orphan sweep) use
`recognize_vendor_path_anywhere`, which nothing rewrites by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One shared bound (`utils::repo_root::ReadBoundary`): the git checkout
enclosing the project, else the project itself; `..` and symlinked
levels are resolved before the check. `bounded_project_path` refuses a
climbing or absolute project-relative path as `NotFound` (a reader sees
a missing file), and `is_refused_read` lets a writer refuse instead.

- go.work: an ancestor's or an absolute `GOWORK` outside the root is
  only stat'ed; the workspace is Invalid, so writers refuse and
  discovery withholds. `use` members and `replace` directories outside
  the root read as missing (the selection stays Unknown).
- ProjectView reads (discovery, lock inventory, requirements walks,
  yarn file:/portal:/link: copies) and the hosted restore's staged
  reads are bounded; the requirements planner and the lock flavors'
  out-of-root pin check refuse `pypi_requirements_outside_root`, the
  restore refuses with the reason.
- NuGet parent configs above the root are not read; vendoring refuses
  and hosted mode skips the dep with
  `redirect_nuget_inherited_config_outside_root` (CLI_CONTRACT.md).

Fixtures that model files beside the project inside a checkout now
create the `.git` they assumed.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant