diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..1327dac1b 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -661,7 +661,9 @@ are patched by `socket-patch apply` like every other ecosystem: --check` is a read-only audit of the committed redirect. The `go.work` in effect is resolved as go resolves it: `GOWORK` naming an absolute file, none under `GOWORK=off`, else (unset, empty or `auto`) the nearest `go.work` at or above the project; a relative - `GOWORK` is invalid to go, so nothing is written or attested. A `-modfile` in `GOFLAGS` (#1799) + `GOWORK` is invalid to go, so nothing is written or attested. A `go.work` outside the repository + root is never read and refuses the same way (see "Reads bounded to the repository root"). + A `-modfile` in `GOFLAGS` (#1799) that names any file other than the project's `go.mod` (a relative one is taken from the project directory) makes go build from that file and ignore `go.mod`'s replaces, and in workspace mode go refuses `-modfile` outright: `apply`, `vendor` and `scan --mode hosted` write nothing @@ -902,6 +904,16 @@ socket-patch creates `.socket/` and everything under it itself and never writes Not covered: the agent-mode blob cache and manifest are checked from `.socket` down, so a `.socket` that is itself a link still redirects those writes (deciding that at lock time is deferred). +### Reads bounded to the repository root + +VEX discovery, the lock inventory, hosted mode and vendored mode never read a PROJECT file outside the repository root: the git checkout enclosing the project (the nearest `.git`, by the same rules as the repository lookup), or the project directory itself when there is none. One check (core `utils::repo_root::ReadBoundary`) decides it; a level that resolves outside through `..` or a symlink counts as outside. A refused read behaves like a missing file to a reader; a writer refuses with the reason (`… is outside the repository root …; socket-patch does not read project files outside it`). Machine-level tool configuration (the user `~/.npmrc` / `.yarnrc.yml` / `NuGet.Config`, the `GOENV` file, `$CARGO_HOME`, module and package caches) is not a project file and is read as before. + +- **Go**: a `go.work` outside the root (an ancestor's, or one an absolute `GOWORK` names) is only noticed, never read: the workspace is invalid, so vendored and hosted writes refuse with the Go workspace refusal and discovery withholds every Go ref. Inside the root, a `use` member or a `replace` directory outside it reads as missing, so the selection it would decide is unknown. +- **Requirements includes**: a `-r` include outside the root is never read. Discovery and the lock inventory see it as missing; vendored mode refuses `pypi_requirements_outside_root`, since the pins pip installs from it are unknowable. +- **NuGet parent configs**: a `nuget.config` in a directory above the root is never read. Vendoring refuses (`… also merges , which is outside the repository root …`) and hosted mode skips each NuGet dep with `redirect_nuget_inherited_config_outside_root`, writing nothing for it. +- **Relative and absolute references read through the project view** (yarn `file:` / `portal:` / `link:` copies, include chains, any `../` path a lockfile names) and the hosted upstream restore's staged reads: outside the root they read as missing, and the restore refuses. +- **Vendored ownership**: writers, reverters and the GC claim a lockfile reference only when it is root-anchored into THIS project's `.socket/vendor/` (`./.socket/vendor/…`, or `../` from a file below the root back to exactly the root). A sibling checkout's `../.socket/vendor///…`, even for the same patch uuid, is never rewritten, reverted or deleted; the revert leaves it with `vendor_lock_entry_drifted` and keeps the artifact. The one exception is pnpm <= 8, which records override specifiers as machine-absolute paths itself: an absolute `…/.socket/vendor/npm//` naming the entry's own uuid and leaf is still the entry's. + ## Vendor command contract `vendor` converts the hosted patches pinned in the project's lockfiles into committable diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 31075b1fa..01cc126b5 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -12,8 +12,9 @@ use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::utils::fs::read_regular_to_string; use socket_patch_core::utils::purl::normalize_purl; use socket_patch_core::vendor::{ - self, artifact_is_file_shaped, check_vendored_artifact, parse_vendor_path, - path::parse_vendor_reference, ArtifactHealth, VendorEntry, VendorState, VendorWarning, + self, artifact_is_file_shaped, check_vendored_artifact, + path::{parse_vendor_reference, recognize_vendor_path_anywhere}, + ArtifactHealth, VendorEntry, VendorState, VendorWarning, }; use super::VendoredBackend; @@ -37,6 +38,13 @@ struct Candidate { /// path parser. Used by repair (references the ledger does not cover), by /// the orphan sweeps (`vendor --revert`, `scan --prune`: a dir a lockfile /// still points at is never deleted) and by rollback. +/// +/// RECOGNITION only: a reference counts wherever its `.socket/vendor/` +/// anchor sits (a sibling's `../.socket/vendor/…` too), which only ever +/// keeps one of THIS project's dirs from being swept or flags it as +/// wired. Nothing here rewrites the referencing file; the writers and +/// reverters claim wiring through the root-anchored +/// [`socket_patch_core::vendor::parse_vendor_path`]. pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> { let mut seen: HashSet<(String, String)> = HashSet::new(); let mut out = Vec::new(); @@ -54,7 +62,9 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root).await { for path in paths { - if let Some(parts) = parse_vendor_path(&path) { + if let Some(parts) = + recognize_vendor_path_anywhere(&path).filter(|p| !p.leaf.is_empty()) + { if seen.insert((parts.eco.to_string(), parts.uuid.clone())) { let rel = format!(".socket/vendor/{}/{}/{}", parts.eco, parts.uuid, parts.leaf); diff --git a/crates/socket-patch-core/src/formats/composer/mod.rs b/crates/socket-patch-core/src/formats/composer/mod.rs index 6e3f8f3e9..8aa1a6aad 100644 --- a/crates/socket-patch-core/src/formats/composer/mod.rs +++ b/crates/socket-patch-core/src/formats/composer/mod.rs @@ -88,9 +88,9 @@ impl<'a> ComposerLockPackage<'a> { .map(LockIntegrity::Sha1Hex) } - /// The Socket-vendored path `dist.url` names, anchored anywhere - /// ([`parse_vendor_path`]: the writers' ownership rule, not discovery's - /// root-anchored attestation grammar). + /// The path into THIS project's `.socket/vendor/` that `dist.url` + /// names ([`parse_vendor_path`]: the writers' root-anchored ownership + /// rule; a sibling's `../.socket/vendor/…` is not ours). pub(crate) fn dist_vendor_path(&self) -> Option { self.dist_str("url").and_then(parse_vendor_path) } @@ -170,7 +170,12 @@ impl<'a> ComposerLock<'a> { continue; } // Our own vendored entries use a path dist — skip. - if pkg.dist_str("type") == Some("path") || pkg.dist_vendor_path().is_some() { + if pkg.dist_str("type") == Some("path") + || pkg + .dist_str("url") + .and_then(crate::vendor::path::recognize_vendor_path_anywhere) + .is_some() + { continue; } let dist_url = pkg.dist_str("url").unwrap_or(""); diff --git a/crates/socket-patch-core/src/formats/pnpm/mod.rs b/crates/socket-patch-core/src/formats/pnpm/mod.rs index 1b06aba9e..4abd400ba 100644 --- a/crates/socket-patch-core/src/formats/pnpm/mod.rs +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -450,7 +450,9 @@ impl<'t> PnpmLock<'t> { .map(|i| LockIntegrity::Sri(i.to_string())) .unwrap_or(LockIntegrity::None); let tarball = resolution.and_then(|r| r.tarball()); - if tarball.is_some_and(|t| parse_vendor_path(t).is_some()) { + if tarball + .is_some_and(|t| crate::vendor::path::recognize_vendor_path_anywhere(t).is_some()) + { continue; } out.push(LockfileEntry::npm( diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 09901663b..cb01e8259 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -807,6 +807,12 @@ pub async fn read_candidate_files( String::new(), ); } + if let Some(why) = inherited.outside_refusal() { + out.files.insert( + crate::patch::redirect::NUGET_INHERITED_OUTSIDE_KEY.to_string(), + why, + ); + } } } @@ -4403,6 +4409,8 @@ mod tests { #[tokio::test] async fn issue_1951_yarn_classic_copy_manifests_read_as_vendored_reads_them() { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); let manifest = r#"{"name":"left-pad","version":"1.3.0"}"#; for dir in ["app/lpdir", "ext/lpdir", "abs/lpdir"] { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..66033cead 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -7069,6 +7069,11 @@ pub const NUGET_INHERITED_SOURCES_KEY: &str = "\n\n \n \n\n"; @@ -7296,6 +7301,16 @@ fn rewrite_nuget( }); continue; } + // A parent config above the repository root is merged by NuGet but + // never read here, so the sources and mappings this edit must + // account for are unknown: skip rather than guess. + if let Some(why) = files.get(NUGET_INHERITED_OUTSIDE_KEY) { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_inherited_config_outside_root".into(), + detail: format!("{why}; {} not redirected", dep.name), + }); + continue; + } let unwritable = || RewriteWarning { code: "redirect_nuget_config_unwritable".into(), @@ -27581,6 +27596,31 @@ packages: assert_eq!(nuget_catch_all(&r.files["nuget.config"]), ["nuget.org"]); } + /// A parent config above the repository root, which the engine never + /// reads, skips the dep with a warning and writes nothing. + #[test] + fn nuget_inherited_config_outside_the_repository_skips_the_dep() { + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert( + NUGET_INHERITED_SOURCES_KEY.to_string(), + "nuget.org".to_string(), + ); + files.insert( + NUGET_INHERITED_OUTSIDE_KEY.to_string(), + "NuGet also merges /x/nuget.config, which is outside the repository root".to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.files.is_empty(), "{:?}", r.files.keys()); + assert_eq!( + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), + ["redirect_nuget_inherited_config_outside_root"] + ); + } + /// A PRESENT but unparseable packages.lock.json refuses the whole nuget /// redirect up front: landing the source + mapping while the lock kept /// the upstream contentHash would NU1403 every restore, with the ledger diff --git a/crates/socket-patch-core/src/patch/redirect/staged.rs b/crates/socket-patch-core/src/patch/redirect/staged.rs index c632f9391..8ffc33e10 100644 --- a/crates/socket-patch-core/src/patch/redirect/staged.rs +++ b/crates/socket-patch-core/src/patch/redirect/staged.rs @@ -25,7 +25,11 @@ pub(super) type StagedBytes = BTreeMap>; /// blocking open — the same posture as every other raw read in the patch /// engine. Errors read `read : `. pub(super) async fn read_rel(project_root: &Path, rel: &str) -> Result, String> { - match crate::utils::fs::read_regular_to_string(&project_root.join(rel)).await { + // A writer's read outside the repository root refuses rather than + // reading as missing: what it would write next depends on it. + let path = crate::utils::repo_root::bounded_project_path(project_root, rel) + .map_err(|refused| format!("read {rel}: {refused}"))?; + match crate::utils::fs::read_regular_to_string(&path).await { Ok(content) => Ok(Some(content)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(format!("read {rel}: {e}")), @@ -103,3 +107,31 @@ async fn refuse_non_regular(path: &Path, rel: &str) -> Result<(), String> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::read_rel; + + /// The restore's reads stay inside the repository root: a file outside + /// it refuses (the restore stops) instead of reading as missing. + #[tokio::test] + async fn reads_outside_the_repository_refuse() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + let root = repo.join("app"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(repo.join(".npmrc"), "x=1\n").unwrap(); + std::fs::write(tmp.path().join(".npmrc"), "x=2\n").unwrap(); + assert_eq!( + read_rel(&root, "../.npmrc").await, + Ok(Some("x=1\n".to_string())) + ); + assert_eq!(read_rel(&root, "../missing").await, Ok(None)); + let above = tmp.path().join(".npmrc"); + for rel in ["../../.npmrc".to_string(), above.display().to_string()] { + let err = read_rel(&root, &rel).await.unwrap_err(); + assert!(err.contains("outside the repository root"), "{rel}: {err}"); + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 1bb703280..eff9b2884 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -4410,6 +4410,8 @@ mod tests { use super::{pnpm_settings_prefix, read_sibling}; let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = std::fs::canonicalize(dir.path()).unwrap(); write( &ws, diff --git a/crates/socket-patch-core/src/utils/repo_root.rs b/crates/socket-patch-core/src/utils/repo_root.rs index 504268698..7c76598f0 100644 --- a/crates/socket-patch-core/src/utils/repo_root.rs +++ b/crates/socket-patch-core/src/utils/repo_root.rs @@ -124,6 +124,119 @@ pub fn find_git_repo(start: &Path) -> Option { repo_at_end_of(search_dirs(start)) } +/// The directory no read of a PROJECT file may leave: the git checkout +/// enclosing `project_root` ([`find_git_repo`]), else `project_root` +/// itself. Canonical when `project_root` exists. +/// +/// Every reader and writer that follows a project file's reference +/// (an ancestor `go.work`, a `-r ../x.txt` include, a parent +/// `nuget.config`, a `file:../x` dependency) asks [`ReadBoundary::contains`] +/// before opening it: a refused read behaves like a missing file for a +/// reader, and a writer refuses with a diagnostic instead of guessing. +/// Machine-level tool configuration (`~/.npmrc`, the user NuGet.Config, +/// the `GOENV` file, `$CARGO_HOME`) is not a project file and is not +/// bounded by this. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadBoundary { + root: PathBuf, +} + +impl ReadBoundary { + /// The boundary for a project at `project_root`. + pub fn of(project_root: &Path) -> Self { + let project = canonical_or_lexical(project_root); + let root = find_git_repo(&project).map_or(project, |repo| repo.root); + ReadBoundary { root } + } + + /// The boundary directory itself. + pub fn root(&self) -> &Path { + &self.root + } + + /// Whether `path` lies at or below the boundary. `path` is made + /// absolute against the working directory, `..` folded, and its + /// deepest existing level canonicalized, so neither a `..` nor a + /// symlinked level inside the tree reaches outside it. + pub fn contains(&self, path: &Path) -> bool { + canonical_or_lexical(path).starts_with(&self.root) + } + + /// The user-facing reason a read of `path` was refused. + pub fn refusal(&self, path: &Path) -> String { + format!( + "{} is outside the repository root {}; socket-patch does not read project \ + files outside it", + path.display(), + self.root.display() + ) + } +} + +/// `project_root.join(rel)` for a project file named by a root-relative +/// `rel` that may climb (`../shared/requirements.txt`) or be absolute; +/// `Err` of kind `NotFound` (a refused read reads as a missing file) +/// carrying [`ReadBoundary::refusal`] when it lies outside the repository +/// root. A plain in-tree `rel` (no `..`, not absolute) is never refused, +/// and costs no filesystem lookups. +pub fn bounded_project_path(project_root: &Path, rel: &str) -> std::io::Result { + let path = project_root.join(rel); + let climbs = + crate::utils::relpath::is_anchored(rel) || rel.split(['/', '\\']).any(|seg| seg == ".."); + if climbs { + let bound = ReadBoundary::of(project_root); + if !bound.contains(&path) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + OutsideRepoRoot(bound.refusal(&path)), + )); + } + } + Ok(path) +} + +/// The payload of a read [`bounded_project_path`] refused. +#[derive(Debug)] +struct OutsideRepoRoot(String); + +impl std::fmt::Display for OutsideRepoRoot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for OutsideRepoRoot {} + +/// Whether `e` is a read [`bounded_project_path`] refused. It reads as +/// `NotFound` (missing) to a reader; a writer, which must not proceed as +/// if the file were absent, refuses on it instead. +pub fn is_refused_read(e: &std::io::Error) -> bool { + e.get_ref() + .is_some_and(|inner| inner.is::()) +} + +/// `path` absolute, `..` folded lexically, with its deepest existing level +/// canonicalized (the rest re-appended as spelled). +fn canonical_or_lexical(path: &Path) -> PathBuf { + let abs = crate::utils::relpath::absolute_lexically(path); + let mut tail = Vec::new(); + let mut probe: &Path = &abs; + loop { + if let Ok(canon) = std::fs::canonicalize(probe) { + let mut out = canon; + out.extend(tail.iter().rev()); + return out; + } + match (probe.parent(), probe.file_name()) { + (Some(parent), Some(name)) => { + tail.push(name.to_os_string()); + probe = parent; + } + _ => return abs, + } + } +} + /// The checkout AT the home directory that a lookup from `start` stopped /// short of (the home rule), if there is one: for callers that should say /// why a repository the user may expect was not used. @@ -270,6 +383,54 @@ mod tests { assert!(dirs.is_empty(), "{dirs:?}"); } + #[test] + fn read_boundary_is_the_checkout_else_the_project() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + let project = repo.join("packages/app"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::create_dir_all(&project).unwrap(); + let bound = ReadBoundary::of(&project); + assert_eq!(bound.root(), repo.as_path()); + assert!(bound.contains(&project.join("../../go.work"))); + assert!(bound.contains(&repo.join("not/yet/created.txt"))); + assert!(!bound.contains(&project.join("../../../go.work"))); + assert!(!bound.contains(&base.join("sibling/.socket/vendor"))); + // No checkout: the project itself is the boundary. + let plain = base.join("plain/app"); + fs::create_dir_all(&plain).unwrap(); + let bound = ReadBoundary::of(&plain); + assert_eq!(bound.root(), plain.as_path()); + assert!(!bound.contains(&plain.join("../go.work"))); + // A project file named by a climbing path: refused as missing. + let err = bounded_project_path(&plain, "../go.work").unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + assert!(is_refused_read(&err)); + assert_eq!( + bounded_project_path(&plain, "sub/go.work").unwrap(), + plain.join("sub/go.work") + ); + let missing = std::io::Error::from(std::io::ErrorKind::NotFound); + assert!(!is_refused_read(&missing)); + } + + /// A symlink inside the tree that points out of it does not carry a + /// read across the boundary. + #[cfg(unix)] + #[test] + fn read_boundary_sees_through_links() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::create_dir_all(base.join("outside")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), repo.join("link")).unwrap(); + let bound = ReadBoundary::of(&repo); + assert!(!bound.contains(&repo.join("link/requirements.txt"))); + assert!(bound.contains(&repo.join("requirements.txt"))); + } + #[test] fn walk_stops_at_ceiling_dirs() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs index e4db0b3e8..3d5f6eb78 100644 --- a/crates/socket-patch-core/src/vendor/go_consumer_sync.rs +++ b/crates/socket-patch-core/src/vendor/go_consumer_sync.rs @@ -975,6 +975,8 @@ mod tests { #[tokio::test] async fn workspace_modules_txt_is_checked_against_every_member() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let root = dir.path(); consumer(root, REPLACE); write( @@ -1040,6 +1042,8 @@ mod tests { ("../a", COPY), ] { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path().join("ws"); let member = ws.join(use_dir); // How go spells a path relative to the go.work directory. @@ -1084,6 +1088,7 @@ mod tests { // has the same spelling as `a`'s recorded one but another target, // so the line is stale and `b`'s is not recorded. let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path().join("ws"); consumer(&ws.join("a"), ""); consumer(&ws.join("b"), REPLACE); @@ -1186,6 +1191,8 @@ mod tests { #[tokio::test] async fn go_work_replace_is_judged_by_where_it_points() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let (app, b) = (ws.join("app"), ws.join("b")); consumer(&app, REPLACE); @@ -1215,6 +1222,8 @@ mod tests { #[tokio::test] async fn go_work_that_does_not_use_the_project_makes_the_replace_inert() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let root = ws.join("app"); consumer(&root, REPLACE); @@ -1245,6 +1254,8 @@ mod tests { #[tokio::test] async fn go_work_member_matches_through_a_symlinked_workspace() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let real = dir.path().join("real"); std::fs::create_dir(&real).unwrap(); let ws = dir.path().join("link"); @@ -1265,6 +1276,8 @@ mod tests { #[tokio::test] async fn member_replaces_of_one_module_version_must_agree() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let (a, b) = (ws.join("a"), ws.join("b")); consumer(&a, REPLACE); diff --git a/crates/socket-patch-core/src/vendor/go_mod_edit.rs b/crates/socket-patch-core/src/vendor/go_mod_edit.rs index 6bffc966f..54cb9029c 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -1920,6 +1920,8 @@ replace ( #[tokio::test] async fn test_ensure_refuses_a_replace_go_work_defeats() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let ws = dir.path(); let root = ws.join("app"); let go_mod = "module example.com/app\n\ngo 1.21\n\nrequire example.com/m v1.0.0\n"; @@ -1972,6 +1974,8 @@ replace ( #[tokio::test] async fn test_symlinked_replace_host_is_refused() { let dir = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); let root = &dir.path().join("ws"); for m in ["a", "b"] { std::fs::create_dir_all(root.join(m)).unwrap(); diff --git a/crates/socket-patch-core/src/vendor/go_native_vendor.rs b/crates/socket-patch-core/src/vendor/go_native_vendor.rs index ef11df038..5791c611c 100644 --- a/crates/socket-patch-core/src/vendor/go_native_vendor.rs +++ b/crates/socket-patch-core/src/vendor/go_native_vendor.rs @@ -50,7 +50,11 @@ async fn expected_text(t: &Target<'_>, name: &str, hash: &str) -> Option e.module == t.module && (e.version.is_none() || e.version.as_deref() == Some(t.version)) }) { if let Some(path) = &entry.path { - paths.push(t.root.join(path).join(name)); + // A replacement directory outside the repository is never read. + let file = t.root.join(path).join(name); + if crate::utils::repo_root::ReadBoundary::of(t.root).contains(&file) { + paths.push(file); + } } else if let (Some(cache), Some(target), Some(ver)) = ( GoCrawler::get_gomodcache(), &entry.rhs_module, diff --git a/crates/socket-patch-core/src/vendor/go_workspace.rs b/crates/socket-patch-core/src/vendor/go_workspace.rs index 2c2218035..c477751b1 100644 --- a/crates/socket-patch-core/src/vendor/go_workspace.rs +++ b/crates/socket-patch-core/src/vendor/go_workspace.rs @@ -5,6 +5,12 @@ //! unset, empty or `auto` searches the project directory and its //! ancestors; any other value must be an absolute path to the file, and a //! relative one makes go fail (`invalid GOWORK: not an absolute path`). +//! A `go.work` outside the repository root +//! ([`crate::utils::repo_root::ReadBoundary`]) is never read: go would +//! use it, so the workspace is [`GoWork::Invalid`] (writers refuse, +//! discovery withholds) rather than read or silently ignored. Members, +//! `go.work.sum` and replacement directories outside the root read as +//! missing. //! * **What the build selects** ([`GoWorkspace::selection`]): MVS over the //! module graph go loads ([`GoWorkspace::read_graph`]): every `use` //! member's `require`s, the `require`s in each of those modules' go.mod, @@ -67,18 +73,32 @@ pub fn resolve(project_root: &Path) -> GoWork { /// [`resolve`] for an explicit `GOWORK` value. pub fn resolve_with(project_root: &Path, gowork: Option<&str>) -> GoWork { + let bounded = |file: PathBuf| { + let bound = crate::utils::repo_root::ReadBoundary::of(project_root); + if bound.contains(&file) { + GoWork::File(file) + } else { + GoWork::Invalid(format!( + "go builds in workspace mode from {}, which socket-patch cannot model: {} \ + (set GOWORK=off, or keep the go.work inside the repository)", + file.display(), + bound.refusal(&file) + )) + } + }; match gowork { Some("off") => GoWork::None, None | Some("") | Some("auto") => { let base = std::env::current_dir() .unwrap_or_default() .join(project_root); + // Only a stat above the root: the file is never opened there. base.ancestors() .map(|dir| dir.join("go.work")) .find(|file| file.is_file()) - .map_or(GoWork::None, GoWork::File) + .map_or(GoWork::None, bounded) } - Some(path) if Path::new(path).is_absolute() => GoWork::File(PathBuf::from(path)), + Some(path) if Path::new(path).is_absolute() => bounded(PathBuf::from(path)), Some(path) => GoWork::Invalid(format!( "GOWORK={path} is not an absolute path, so go refuses to build (\"invalid \ GOWORK: not an absolute path\")" @@ -228,11 +248,19 @@ pub async fn load_with(project_root: &Path, gowork: Option<&str>) -> Loaded { match resolve_with(project_root, gowork) { GoWork::None => Loaded::None, GoWork::Invalid(why) => Loaded::Invalid(why), - GoWork::File(work_file) => load_file(work_file).await, + GoWork::File(work_file) => { + load_file( + work_file, + &crate::utils::repo_root::ReadBoundary::of(project_root), + ) + .await + } } } -async fn load_file(work_file: PathBuf) -> Loaded { +/// Read `work_file` and what it names. A `use` member outside `bound` +/// reads as one without a go.mod or go.sum (missing). +async fn load_file(work_file: PathBuf, bound: &crate::utils::repo_root::ReadBoundary) -> Loaded { let text = match read_regular_to_string(&work_file).await { Ok(text) => go_mod_edit::normalize_for_read(&text), Err(e) => { @@ -252,11 +280,17 @@ async fn load_file(work_file: PathBuf) -> Loaded { let mut members = Vec::new(); for dir in go_mod_edit::parse_use_dirs(&text) { let dir = work_dir.join(dir); - let go_mod = read_regular_to_string(&dir.join("go.mod")) - .await - .ok() - .map(|t| go_mod_edit::normalize_for_read(&t)); - let go_sum = read_regular_to_string(&dir.join("go.sum")).await.ok(); + let (go_mod, go_sum) = if bound.contains(&dir) { + ( + read_regular_to_string(&dir.join("go.mod")) + .await + .ok() + .map(|t| go_mod_edit::normalize_for_read(&t)), + read_regular_to_string(&dir.join("go.sum")).await.ok(), + ) + } else { + (None, None) + }; members.push(Member { dir, go_mod, @@ -639,6 +673,7 @@ impl GoWorkspace { /// [`GoWorkspace::read_graph`] against the module cache `cache`. pub async fn read_graph_from(&mut self, cache: Option<&Path>) { let replaces = self.replace_sources(); + let bound = crate::utils::repo_root::ReadBoundary::of(self.work_dir()); let mut graph: BTreeMap> = BTreeMap::new(); // go loads each member at its `workspace` level, which queues the // member's requirements at the member's own pruning. @@ -666,7 +701,7 @@ impl GoWorkspace { continue; } if !graph.contains_key(&key) { - let dep = read_dep_go_mod(&replaces, cache, &path, &version).await; + let dep = read_dep_go_mod(&replaces, cache, &bound, &path, &version).await; graph.insert(key.clone(), dep); } let Some(Some(dep)) = graph.get(&key) else { @@ -1025,10 +1060,13 @@ fn replacement_of<'a>( } /// The go.mod go reads for the dependency `path@version` (see -/// [`GoWorkspace::read_graph`]). +/// [`GoWorkspace::read_graph`]). A replacement directory outside `bound` +/// is never read: its go.mod is missing, so the selection stays +/// [`Selection::Unknown`] unless a higher version already shows. async fn read_dep_go_mod( sources: &[Vec<(ReplaceEntry, PathBuf)>; 2], cache: Option<&Path>, + bound: &crate::utils::repo_root::ReadBoundary, path: &str, version: &str, ) -> Option { @@ -1049,7 +1087,10 @@ async fn read_dep_go_mod( Some((entry, dir)) => { match (&entry.path, &entry.rhs_module, &entry.rhs_version) { (Some(rel), _, _) => { - candidates.push(dir.join(rel.replace('\\', "/")).join("go.mod")) + let go_mod = dir.join(rel.replace('\\', "/")).join("go.mod"); + if bound.contains(&go_mod) { + candidates.push(go_mod); + } } (None, Some(m), Some(v)) => candidates.extend(cached(m, v)), _ => {} @@ -1116,7 +1157,12 @@ pub async fn module_roots(root: &Path) -> Vec { if resolve(root) == GoWork::None { return Vec::new(); } - match load_file(root.join("go.work")).await { + match load_file( + root.join("go.work"), + &crate::utils::repo_root::ReadBoundary::of(root), + ) + .await + { Loaded::Workspace(ws) => ws .members_within(root) .into_iter() @@ -1237,6 +1283,8 @@ mod tests { #[test] fn gowork_resolves_as_go_does() { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let project = tmp.path().join("ws").join("app"); std::fs::create_dir_all(&project).unwrap(); for unset in [None, Some(""), Some("auto")] { @@ -1271,11 +1319,77 @@ mod tests { assert_eq!(resolve_with(&project, None), GoWork::None); } + /// A go.work outside the repository root (an ancestor's, or one an + /// absolute GOWORK names) is never read: go would build from it, so + /// the workspace is Invalid and every writer refuses. Inside the root, + /// a `use` member or replacement directory outside it reads as missing. + #[tokio::test] + async fn go_work_outside_the_repository_is_never_read() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + let project = repo.join("app"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + member(&repo, "app", "1.21", &require("v1.0.0")); + write(tmp.path(), "go.work", "go 1.21\n\nuse ./repo/app\n"); + for gowork in [None, Some("auto")] { + assert!( + matches!(resolve_with(&project, gowork), + GoWork::Invalid(why) if why.contains("outside the repository root")), + "{gowork:?}" + ); + assert!(matches!( + load_with(&project, gowork).await, + Loaded::Invalid(_) + )); + } + let elsewhere = tmp.path().join("go.work").to_string_lossy().into_owned(); + assert!(matches!( + resolve_with(&project, Some(&elsewhere)), + GoWork::Invalid(why) if why.contains("outside the repository root") + )); + // Without a repository the project itself is the root. + std::fs::remove_dir(repo.join(".git")).unwrap(); + assert!(matches!(resolve_with(&project, None), GoWork::Invalid(_))); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + + // The repository's own go.work: a member outside it, and a replace + // pointing outside it, are missing rather than read. + member(tmp.path(), "outside", "1.21", &require("v1.9.0")); + write( + &repo, + "go.work", + &format!( + "go 1.21\n\nuse (\n\t./app\n\t../outside\n)\n\nreplace {DEP} v1.0.0 => ../fork\n" + ), + ); + write( + tmp.path(), + "fork/go.mod", + &format!("module {DEP}\n\ngo 1.21\n"), + ); + let Loaded::Workspace(mut ws) = load_with(&project, None).await else { + panic!("the repository's go.work loads"); + }; + let outside = ws + .members + .iter() + .find(|m| m.dir.ends_with("outside")) + .unwrap(); + assert_eq!((&outside.go_mod, &outside.go_sum), (&None, &None)); + let cache = tmp.path().join("cache"); + ws.read_graph_from(Some(&cache)).await; + let graph = ws.graph.as_ref().unwrap(); + assert_eq!(graph.get(&format!("{DEP}@v1.0.0")), Some(&None)); + assert_eq!(ws.selection(DEP, "v1.0.0"), Selection::Unknown); + } + /// #1799: `-modfile` keeps a go.mod replace from the build unless it /// names the project's own go.mod; in workspace mode go refuses it. #[test] fn goflags_modfile_conflicts_unless_it_names_the_own_go_mod() { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let project = tmp.path().join("app"); write(tmp.path(), "app/go.mod", "module example.com/app\n"); let conflict = |goflags: &str, gowork: Option<&str>| { @@ -1364,6 +1478,8 @@ mod tests { #[tokio::test] async fn selection_spans_every_member() { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = &tmp.path().join("ws"); let cache = &tmp.path().join("cache"); for v in ["v1.0.0", "v1.1.0"] { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs index 259332343..a3260abfe 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs @@ -13,7 +13,6 @@ use crate::constants::npm_family::NPM_LOCKS; use crate::utils::digest::is_sri_pin; use crate::vendor::npm_common::declares_bundled_deps; use crate::vendor::npm_origin::legacy_packages_key; -use crate::vendor::path::parse_vendor_path; use super::view::ProjectView; use super::{http_url, LockIntegrity, LockfileEntry}; @@ -440,7 +439,7 @@ pub(super) async fn inventory_package_lock_in( // Our own vendored spec: not a registry dependency. if node .resolved - .is_some_and(|r| parse_vendor_path(r).is_some()) + .is_some_and(|r| crate::vendor::path::recognize_vendor_path_anywhere(r).is_some()) { continue; } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index c55ec4549..fcfbd9676 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -13,6 +13,7 @@ use std::path::Path; use std::sync::Arc; use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; +use crate::utils::repo_root::bounded_project_path; /// One in-memory file. #[derive(Debug, Clone, PartialEq, Eq)] @@ -615,10 +616,12 @@ impl<'a> DiskSnapshot<'a> { async fn read_bytes(&self, rel: &str) -> io::Result> { self.touch(rel); + // Before the cache, which would not keep the refusal's payload. + let path = bounded_project_path(self.root, rel)?; if let Some(hit) = self.cached(rel) { return hit.map(|b| b.to_vec()); } - let read = read_regular_to_bytes(&self.root.join(rel)).await; + let read = read_regular_to_bytes(&path).await; self.remember(rel, &read); read } @@ -736,35 +739,46 @@ impl<'a> ProjectView<'a> { } } - /// FIFO-safe regular-file text read. + /// FIFO-safe regular-file text read. A `rel` that climbs out of the + /// repository root (`../…`, absolute) is refused as missing + /// ([`bounded_project_path`]). pub async fn read_text(&self, rel: &str) -> io::Result { if let ProjectView::Snapshot(snap) = self { snap.touch(rel); } match self { - ProjectView::Disk(root) => read_regular_to_string(&root.join(rel)).await, + ProjectView::Disk(root) => { + read_regular_to_string(&bounded_project_path(root, rel)?).await + } ProjectView::Memory(project) => project.read_text(rel), - ProjectView::Snapshot(snap) => match snap.cached(rel) { - Some(hit) => hit.and_then(|b| utf8(b.to_vec())), - None => { - let read = read_regular_to_string(&snap.root.join(rel)).await; - snap.remember( - rel, - &read - .as_ref() - .map(|t| t.as_bytes().to_vec()) - .map_err(|e| io::Error::new(e.kind(), e.to_string())), - ); - read + ProjectView::Snapshot(snap) => { + // Before the cache, which would not keep the refusal's + // payload ([`crate::utils::repo_root::is_refused_read`]). + let path = bounded_project_path(snap.root, rel)?; + match snap.cached(rel) { + Some(hit) => hit.and_then(|b| utf8(b.to_vec())), + None => { + let read = read_regular_to_string(&path).await; + snap.remember( + rel, + &read + .as_ref() + .map(|t| t.as_bytes().to_vec()) + .map_err(|e| io::Error::new(e.kind(), e.to_string())), + ); + read + } } - }, + } } } - /// FIFO-safe regular-file byte read. + /// FIFO-safe regular-file byte read, bounded as [`Self::read_text`]. pub async fn read_bytes(&self, rel: &str) -> io::Result> { match self { - ProjectView::Disk(root) => read_regular_to_bytes(&root.join(rel)).await, + ProjectView::Disk(root) => { + read_regular_to_bytes(&bounded_project_path(root, rel)?).await + } ProjectView::Memory(project) => project.read_bytes(rel), ProjectView::Snapshot(snap) => snap.read_bytes(rel).await, } diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index c20288900..cb0846761 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -4493,6 +4493,47 @@ mod tests { ); } + /// N5: an entry repointed at a SIBLING checkout's vendored copy of the + /// same patch (`file:../.socket/vendor/npm//…`) is that + /// project's wiring, not ours: the revert leaves it byte-for-byte and + /// keeps our artifact, instead of claiming it by its uuid and restoring + /// our recorded original over it. + #[tokio::test] + async fn revert_never_claims_a_sibling_checkouts_vendor_path() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + let sibling = format!("file:../.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + let mut live = fx.read_lock().await; + live["packages"]["node_modules/left-pad"]["resolved"] = json!(sibling); + tokio::fs::write(fx.lock_path(), serialize_json(&live, " ").unwrap()) + .await + .unwrap(); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + let after = fx.read_lock().await; + assert_eq!( + after["packages"]["node_modules/left-pad"], live["packages"]["node_modules/left-pad"], + "the sibling's wiring is never rewritten" + ); + assert!( + fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the artifact is kept while an entry is left alone" + ); + } + /// #665: the user dropped the patched dependency (`npm uninstall /// left-pad`), so npm deleted every lock entry the wiring recorded and /// nothing in the lock resolves through the artifact any more. That is diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 0aa773fd2..073670db1 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -90,7 +90,8 @@ pub(crate) mod tests_support { /// default — the `nuget.org` source it writes on first run), then each /// parent directory's config from the filesystem root down, each `` /// dropping the farther ones. A file that cannot be read or parsed is -/// skipped: it contributes nothing NuGet could use either. +/// skipped: it contributes nothing NuGet could use either. A parent config +/// above the repository root is not read ([`Inherited::outside`]). #[cfg(test)] pub(crate) async fn inherited_source_keys(project_root: &std::path::Path) -> Vec { inherited_source_keys_traced(project_root).await.0.keys @@ -106,6 +107,26 @@ pub(crate) struct Inherited { /// already route every other package, and a `*` catch-all written here /// would WIDEN a source they restrict. pub(crate) mapped: bool, + /// The nearest parent config above the repository root + /// ([`crate::utils::repo_root::ReadBoundary`]): NuGet merges it, but it + /// is never read, so `keys` and `mapped` cannot account for it and a + /// writer refuses ([`Inherited::outside_refusal`]). + pub(crate) outside: Option, +} + +impl Inherited { + /// Why a writer cannot wire `nuget.config` under a parent config it may + /// not read, if there is one. + pub(crate) fn outside_refusal(&self) -> Option { + self.outside.as_ref().map(|path| { + format!( + "NuGet also merges {}, which is outside the repository root, so socket-patch \ + does not read it and cannot tell which sources and mappings it adds; move \ + the settings into the repository's nuget.config", + path.display() + ) + }) + } } /// [`Inherited`] for `project_root`. @@ -137,6 +158,8 @@ pub(crate) async fn inherited_source_keys_traced( )], ..NugetConfig::default() })); + let bound = crate::utils::repo_root::ReadBoundary::of(project_root); + let mut outside = None; let mut ancestors: Vec<&std::path::Path> = project_root.ancestors().skip(1).collect(); ancestors.reverse(); for dir in ancestors { @@ -147,6 +170,12 @@ pub(crate) async fn inherited_source_keys_traced( if !exists { continue; } + // Above the repository root: never read, only noted (nearest + // wins), so a writer can refuse instead of guessing. + if !bound.contains(&path) { + outside = Some(path); + break; + } if let Some(cfg) = crate::utils::fs::read_regular_to_string(&path) .await .ok() @@ -162,6 +191,7 @@ pub(crate) async fn inherited_source_keys_traced( Inherited { keys: effective_source_keys(&chain), mapped, + outside, }, touched, ) @@ -534,6 +564,7 @@ mod tests { .unwrap(); let project = tmp.path().join("repo/app"); std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); USER_CONFIG.with(|c| *c.borrow_mut() = Some(user.clone())); assert_eq!(inherited_source_keys(&project).await, ["nuget.org", "corp"]); // A repo-root config adds its own feed. @@ -565,6 +596,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path().join("repo/app"); std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); assert!(!super::inherited_sources(&project).await.mapped); std::fs::write( tmp.path().join("repo/nuget.config"), @@ -574,6 +606,30 @@ mod tests { let got = super::inherited_sources(&project).await; assert!(got.mapped); assert_eq!(got.keys, ["nuget.org", "corp"]); + assert_eq!(got.outside, None); + } + + /// A parent config above the repository root is never read: its + /// sources and mappings do not show, and it is named so writers refuse. + #[tokio::test] + async fn a_parent_config_above_the_repository_is_never_read() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("repo/app"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap(); + let above = tmp.path().join("nuget.config"); + std::fs::write( + &above, + "", + ) + .unwrap(); + let got = super::inherited_sources(&project).await; + assert_eq!(got.keys, ["nuget.org"], "never read"); + assert!(!got.mapped); + assert_eq!(got.outside.as_deref(), Some(above.as_path())); + assert!(got + .outside_refusal() + .is_some_and(|why| why.contains("outside the repository root"))); } /// The walk reads a symlinked project file, never enters build output diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index b6252a5d1..98ef840c1 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -566,14 +566,18 @@ pub async fn vendor_nuget( // ── nuget.config wiring (runs after the artifact) ───────────────────── let inherited = super::nuget_config::inherited_sources(project_root).await; - let config_edit = match build_config_edit_with( - config_text.as_deref(), - &inherited.keys, - inherited.mapped, - &source_key, - &uuid_dir_rel, - name, - ) { + let built = match inherited.outside_refusal() { + Some(refusal) => Err(refusal), + None => build_config_edit_with( + config_text.as_deref(), + &inherited.keys, + inherited.mapped, + &source_key, + &uuid_dir_rel, + name, + ), + }; + let config_edit = match built { Ok(edit) => edit, Err(detail) => { let _ = remove_tree(&uuid_dir).await; @@ -5246,6 +5250,8 @@ mod tests { async fn vendor_keeps_a_parent_directorys_feed_routable() { let (dir, blobs, installed, record) = fixture(true, None).await; let outer = dir.path(); + // The parent is inside the repository. + tokio::fs::create_dir_all(outer.join(".git")).await.unwrap(); tokio::fs::write( outer.join("nuget.config"), "\n \n \n \n\n", @@ -5267,6 +5273,39 @@ mod tests { assert_eq!(catch_all_of(&t), ["nuget.org", "corp"], "{t}"); } + /// The same parent config, but above the repository root: it is never + /// read, so vendoring cannot tell which sources the catch-all must keep + /// and refuses, writing no config and leaving no artifact. + #[tokio::test] + async fn vendor_refuses_under_a_parent_config_above_the_repository() { + let (dir, blobs, installed, record) = fixture(true, None).await; + let outer = dir.path(); + tokio::fs::write( + outer.join("nuget.config"), + "\n \n \n \n\n", + ) + .await + .unwrap(); + let root = outer.join("app"); + tokio::fs::create_dir_all(root.join(".git")).await.unwrap(); + tokio::fs::rename(outer.join(PACKAGES_LOCK), root.join(PACKAGES_LOCK)) + .await + .unwrap(); + let (result, _entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(!result.success); + assert!( + result + .error + .as_deref() + .is_some_and(|e| e.contains("outside the repository root")), + "{:?}", + result.error + ); + assert!(!root.join("nuget.config").exists()); + assert!(!root.join(".socket/vendor/nuget").exists()); + } + /// A key listed twice gets one catch-all, and an `` without a key /// gets none. #[test] diff --git a/crates/socket-patch-core/src/vendor/path.rs b/crates/socket-patch-core/src/vendor/path.rs index 993d8b5d3..96163fbcf 100644 --- a/crates/socket-patch-core/src/vendor/path.rs +++ b/crates/socket-patch-core/src/vendor/path.rs @@ -134,34 +134,159 @@ pub struct VendorPathParts { pub leaf: String, } -/// Recover `(eco, uuid, leaf)` from any lockfile-recorded vendored path -/// string — `file:` npm specs, `./`-prefixed go.mod replace targets, -/// composer dist urls, requirement lines, backslashed Windows spellings. -/// This is the documented external-tool recovery rule; `None` means the -/// string is not a Socket-vendored path. +/// Recover `(eco, uuid, leaf)` from a vendored path recorded in a file at +/// the project root — `file:` npm specs, `./`-prefixed go.mod replace +/// targets, composer dist urls, requirement lines, backslashed Windows +/// spellings. `None` means the string is not a path into THIS project's +/// `.socket/vendor/`. +/// +/// This is the writers' ownership rule: every vendored writer, reverter +/// and GC claims a lock entry or wiring line only through it (or +/// [`parse_vendor_path_from`] for a file below the root). The anchor must +/// START the path, after `file:` and `./`: a `../.socket/vendor/…` or +/// `/abs/.socket/vendor/…` spelling names ANOTHER project's vendor store +/// (a sibling checkout's), and claiming it would let `vendor --revert` or +/// GC rewrite or delete wiring that project owns. To merely RECOGNIZE a +/// vendored path wherever it points (to report it, or to keep a directory +/// a lockfile may still name), use [`recognize_vendor_path_anywhere`]. pub fn parse_vendor_path(s: &str) -> Option { - parse_vendor_reference(s).filter(|parts| !parts.leaf.is_empty()) + parse_vendor_path_from("", s) +} + +/// [`parse_vendor_path`] for a string recorded in a file whose directory +/// is `base` (project-root-relative, `/`-separated, `""` for the root): a +/// pnpm importer's `specifier: file:../../.socket/vendor/…`, a +/// requirements file in `deploy/` naming `../.socket/vendor/…`. Only `.` +/// and `..` segments may precede the anchor, and they must resolve from +/// `base` to exactly the project root — never above it, never into a +/// sub-project's own `.socket/vendor/`. +pub fn parse_vendor_path_from(base: &str, s: &str) -> Option { + parse_vendor_reference_from(base, s).filter(|parts| !parts.leaf.is_empty()) +} + +/// [`parse_vendor_path`], also accepting a machine-absolute spelling +/// (`/home/ci/app/.socket/vendor/npm//…`), for pnpm <= 8 locks ONLY: +/// pnpm itself records a `file:` override specifier as an absolute path, +/// so a committed lock carries the vendoring checkout's absolute root, +/// which is this project at another path. The writer still binds the +/// result to its own entry's uuid and leaf. A relative escape +/// (`../.socket/vendor/…`) is still never ours, nor is an absolute path +/// with `.`/`..` segments. +pub fn parse_vendor_path_or_machine_absolute(s: &str) -> Option { + parse_vendor_path(s).or_else(|| { + let norm = normalize_vendor_spelling(s); + let idx = vendor_anchor_index(&norm)?; + let prefix = &norm[..idx]; + let absolute = crate::utils::relpath::is_anchored(prefix) + && !prefix.split('/').any(|seg| matches!(seg, "." | "..")); + if !absolute { + return None; + } + split_vendor_rest(&norm[idx + VENDOR_DIR.len() + 1..]).filter(|p| !p.leaf.is_empty()) + }) +} + +/// [`parse_vendor_path`] for a package locator or descriptor that prefixes +/// the path with its package name: yarn berry's +/// `resolution: "left-pad@file:./.socket/vendor/…"`, a pnpm v9 +/// `left-pad@file:.socket/vendor/…` key. The `[@scope/]name@` is cut, and +/// the rest must be root-anchored as usual; a string without that prefix +/// is parsed as it stands. +pub fn parse_vendor_locator(s: &str) -> Option { + let s = s.trim(); + let name_end = if let Some(scoped) = s.strip_prefix('@') { + scoped + .find('/') + .map(|slash| slash + 1) + .and_then(|slash| s[slash + 1..].find(['@', '/']).map(|at| slash + 1 + at)) + } else { + s.find(['@', '/']) + }; + match name_end { + Some(at) if at > 0 && s.as_bytes()[at] == b'@' => parse_vendor_path(&s[at + 1..]), + _ => parse_vendor_path(s), + } } /// [`parse_vendor_path`]'s grammar, also accepting a reference to the uuid /// directory itself (`leaf` empty): NuGet's vendored feed /// (`value=".socket/vendor/nuget/"`) and Maven's vendored repository -/// (`file://${project.basedir}/.socket/vendor/maven/`) -/// name the unit, not a file inside it. The vendored-reference scan reads -/// wiring through this, so a directory-wired unit counts as still wired. +/// (`file://${project.basedir}/.socket/vendor/maven/`, +/// whose caller strips the basedir prefix) name the unit, not a file +/// inside it. pub fn parse_vendor_reference(s: &str) -> Option { - let norm = s.replace('\\', "/"); - let norm = norm.strip_prefix("file:").unwrap_or(&norm); - let norm = norm.strip_prefix("./").unwrap_or(norm); - // Find the `.socket/vendor/` anchor anywhere in the string (a workspace - // sub-project may record `../.socket/vendor/...`). + parse_vendor_reference_from("", s) +} + +/// [`parse_vendor_reference`] for a file whose directory is `base` (see +/// [`parse_vendor_path_from`]). +pub fn parse_vendor_reference_from(base: &str, s: &str) -> Option { + split_vendor_rest(&vendor_rest_from(base, s)?) +} + +/// Recover `(eco, uuid, leaf)` from a vendored path wherever its +/// `.socket/vendor/` anchor sits — `../.socket/vendor/…`, an absolute +/// path, another project's store. For RECOGNITION only (reporting a +/// reference, keeping a directory a lockfile may still name from being +/// swept): never the basis for rewriting, reverting or deleting wiring, +/// which [`parse_vendor_path`] gates. `leaf` may be empty (a uuid-dir +/// reference). +pub fn recognize_vendor_path_anywhere(s: &str) -> Option { + let norm = normalize_vendor_spelling(s); + let idx = vendor_anchor_index(&norm)?; + split_vendor_rest(&norm[idx + VENDOR_DIR.len() + 1..]) +} + +/// `s` with surrounding whitespace, `\/` JSON escapes, backslashes and a +/// leading `file:` normalized away. +fn normalize_vendor_spelling(s: &str) -> String { + let norm = s.trim().replace("\\/", "/").replace('\\', "/"); + match norm.strip_prefix("file:") { + Some(rest) => rest.to_string(), + None => norm, + } +} + +/// The byte index of the first `.socket/vendor/` anchor in `norm` at a +/// path-component boundary. +fn vendor_anchor_index(norm: &str) -> Option { let anchor = format!("{VENDOR_DIR}/"); - let idx = norm.find(&anchor)?; - // Anchor must sit at a path-component boundary. - if idx > 0 && norm.as_bytes()[idx - 1] != b'/' { + let mut from = 0; + while let Some(found) = norm[from..].find(&anchor) { + let idx = from + found; + if idx == 0 || norm.as_bytes()[idx - 1] == b'/' { + return Some(idx); + } + from = idx + 1; + } + None +} + +/// Everything after the `.socket/vendor/` anchor of a path recorded in a +/// file at `base` (see [`parse_vendor_path_from`]) when that anchor is THIS +/// project's vendor directory, else `None`. The one root-anchoring rule the +/// writers ([`parse_vendor_path`]) and VEX discovery +/// ([`crate::vex::discover::vendor_ref`]) share. +pub(crate) fn vendor_rest_from(base: &str, s: &str) -> Option { + let norm = normalize_vendor_spelling(s); + let idx = vendor_anchor_index(&norm)?; + let prefix = &norm[..idx]; + // Only `.`/`..` may lead to the anchor: a named segment can be a link + // whose `..` the OS resolves elsewhere, and an anchored (absolute) + // spelling names whatever store it names. + if crate::utils::relpath::is_anchored(prefix) + || prefix.split('/').any(|seg| !matches!(seg, "" | "." | "..")) + { return None; } - let rest = &norm[idx + anchor.len()..]; + let landed = crate::utils::relpath::resolve_rel(base, prefix, 0)?; + landed + .is_empty() + .then(|| norm[idx + VENDOR_DIR.len() + 1..].to_string()) +} + +/// `/[/]`, split and validated. +fn split_vendor_rest(rest: &str) -> Option { let mut it = rest.splitn(3, '/'); let eco = it.next()?; let uuid = it.next()?; @@ -637,18 +762,70 @@ mod tests { ("cargo", "serde-1.0.190") ); - // anchored mid-string (workspace-relative) - assert!(parse_vendor_path(&format!( - "../.socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl" - )) - .is_some()); - // Rejections: bad uuid, unknown eco, non-boundary anchor. assert!(parse_vendor_path(".socket/vendor/npm/not-a-uuid/x.tgz").is_none()); assert!(parse_vendor_path(&format!(".socket/vendor/jsr/{UUID}/x")).is_none()); assert!(parse_vendor_path(&format!("x.socket/vendor/npm/{UUID}/y.tgz")).is_none()); } + /// N5: the writers' ownership parse claims only THIS project's + /// `.socket/vendor/`. A sibling checkout's store (`../.socket/vendor/…`), + /// an absolute path, or a sub-project's own store is recognized only + /// by [`recognize_vendor_path_anywhere`], which nothing rewrites by. + #[test] + fn ownership_parse_is_root_anchored() { + let leaf = "six-1.16.0-py2.py3-none-any.whl"; + for foreign in [ + format!("../.socket/vendor/pypi/{UUID}/{leaf}"), + format!("file:../.socket/vendor/pypi/{UUID}/{leaf}"), + format!("./../.socket/vendor/pypi/{UUID}/{leaf}"), + format!("..\\.socket\\vendor\\pypi\\{UUID}\\{leaf}"), + format!("../sibling/.socket/vendor/pypi/{UUID}/{leaf}"), + format!("/home/ci/other/.socket/vendor/pypi/{UUID}/{leaf}"), + format!("sub/.socket/vendor/pypi/{UUID}/{leaf}"), + format!("sub/../.socket/vendor/pypi/{UUID}/{leaf}"), + ] { + assert!(parse_vendor_path(&foreign).is_none(), "{foreign}"); + assert!(parse_vendor_reference(&foreign).is_none(), "{foreign}"); + assert!( + parse_vendor_locator(&format!("six@{foreign}")).is_none(), + "{foreign}" + ); + assert!( + recognize_vendor_path_anywhere(&foreign).is_some_and(|p| p.uuid == UUID), + "{foreign}" + ); + } + // From a file below the root, `..` back to exactly the root is ours; + // past the root, or short of it, is not. + let up_two = format!("file:../../.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + assert!(parse_vendor_path_from("packages/a", &up_two).is_some()); + assert!(parse_vendor_path_from("packages", &up_two).is_none()); + assert!(parse_vendor_path_from("a/b/c", &up_two).is_none()); + assert!(parse_vendor_path(&up_two).is_none()); + // Locators cut the package name, scoped or not. + for locator in [ + format!("left-pad@file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz::locator=a%40workspace%3A."), + format!("@s/left-pad@file:.socket/vendor/npm/{UUID}/@s/left-pad-1.3.0.tgz"), + format!("./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), + ] { + assert!(parse_vendor_locator(&locator).is_some(), "{locator}"); + } + // pnpm <= 8's machine-absolute specifier is the one absolute + // spelling a writer may claim; never with `..` in it. + let abs = format!("/home/ci/app/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); + assert!(parse_vendor_path_or_machine_absolute(&abs).is_some()); + assert!(parse_vendor_path_or_machine_absolute(&format!("file:{abs}")).is_some()); + assert!(parse_vendor_path_or_machine_absolute(&format!( + "/home/ci/app/../other/.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" + )) + .is_none()); + assert!(parse_vendor_path_or_machine_absolute(&format!( + "../.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz" + )) + .is_none()); + } + /// `parse_vendor_reference` is `parse_vendor_path`'s grammar plus the /// bare uuid dir (NuGet's feed, Maven's repository); `parse_vendor_path` /// itself still needs a leaf. @@ -658,7 +835,6 @@ mod tests { format!(".socket/vendor/nuget/{UUID}"), format!(".socket/vendor/nuget/{UUID}/"), format!(".socket\\vendor\\nuget\\{UUID}"), - format!("file://${{project.basedir}}/.socket/vendor/nuget/{UUID}"), ] { let p = parse_vendor_reference(&s).unwrap_or_else(|| panic!("{s}")); assert_eq!( diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 7547e780d..a02a9bbca 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -3600,6 +3600,7 @@ fn revert_snapshot_ref( entry_uuid, label: "snapshot ref", dep_maps_only: false, + parse: parse_vendor_path, }; for (header, block_end) in ref_record_blocks(lines, start, end, snapshot_key) { if target.restore_in(lines, header, block_end, dirty, warnings) { @@ -3677,6 +3678,9 @@ pub(super) struct DepRef<'a> { /// Only look inside `dependencies:` / `optionalDependencies:` maps (the /// legacy edit's scope; the v9 edit rewrites any 6-space key). pub(super) dep_maps_only: bool, + /// The ownership parse: [`parse_vendor_path`], or for pnpm <= 8 locks + /// [`super::path::parse_vendor_path_or_machine_absolute`]. + pub(super) parse: fn(&str) -> Option, } impl DepRef<'_> { @@ -3713,7 +3717,7 @@ impl DepRef<'_> { return true; } let ours = Some(rest) == rec.new.as_ref().and_then(Value::as_str) - || parse_vendor_path(rest).is_some_and(|p| p.eco == "npm" && p.uuid == self.entry_uuid); + || (self.parse)(rest).is_some_and(|p| p.eco == "npm" && p.uuid == self.entry_uuid); if !ours { warnings.push(drifted(format!( "{} `{}` was re-resolved since vendoring ({rest}); left alone", diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index 549524546..eac0fb8c4 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -62,11 +62,11 @@ use crate::patch::apply::PatchSources; use crate::utils::fs::read_regular_to_string; use super::common::refused; -use super::path::parse_vendor_path; +use super::path::parse_vendor_path_or_machine_absolute; use super::pnpm_lock::{ dep_field_lines, drifted, lines_value, merge_live_dep_refs, ref_record_blocks, removed, - revert_overrides_line, same_modulo_dep_refs, value_lines, vendor_value_is_for, DepRef, EditCtx, - PnpmDialect, KIND_LOCK_OVERRIDES, KIND_LOCK_PACKAGE, + revert_overrides_line, same_modulo_dep_refs, value_lines, DepRef, EditCtx, PnpmDialect, + KIND_LOCK_OVERRIDES, KIND_LOCK_PACKAGE, }; use super::source::PackageSource; use super::state::{VendorEntry, WiringAction, WiringRecord}; @@ -175,11 +175,12 @@ impl Ctx<'_> { } /// Does `value` point at OUR vendored tarball for THIS name@version - /// (any uuid, relative or the machine-absolute specifier spelling — - /// `parse_vendor_path` anchors on `.socket/vendor/` anywhere in the - /// string)? + /// (any uuid; root-relative, or the machine-absolute specifier spelling + /// pnpm <= 8 records — never a relative `../` escape)? fn is_ours(&self, value: &str) -> bool { - vendor_value_is_for(value, self.name, self.version) + parse_vendor_path_or_machine_absolute(value).is_some_and(|p| { + p.eco == "npm" && p.leaf == super::npm_common::tgz_rel_leaf(self.name, self.version) + }) } /// The peer-suffix marker this grammar appends to a version/key @@ -985,7 +986,7 @@ fn revert_value_line( .as_ref() .and_then(Value::as_str) .is_some_and(|new| rest == new || rest == yaml_value(new)) - || parse_vendor_path(unquote_value(rest)) + || parse_vendor_path_or_machine_absolute(unquote_value(rest)) .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { warnings.push(drifted(format!( @@ -1061,7 +1062,8 @@ fn revert_root_dep_pair( .and_then(|n| n.get("version")) .and_then(Value::as_str); let ours = Some(live_ver.as_str()) == new_ver - || parse_vendor_path(&live_ver).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + || parse_vendor_path_or_machine_absolute(&live_ver) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { warnings.push(drifted(format!( "root dep `{key}` was re-resolved since vendoring ({live_ver}); left alone" @@ -1131,8 +1133,8 @@ fn revert_package_block( continue; } let live: Vec = lines[block.header..block.end].to_vec(); - let key_is_ours = - parse_vendor_path(new_key).is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); + let key_is_ours = parse_vendor_path_or_machine_absolute(new_key) + .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if live != new_lines && !key_is_ours { warnings.push(drifted(format!( "packages entry `{new_key}` was changed since vendoring; left alone" @@ -1214,6 +1216,7 @@ fn revert_pkg_dep_ref( entry_uuid, label: "dep ref", dep_maps_only: true, + parse: parse_vendor_path_or_machine_absolute, }; for (header, block_end) in ref_record_blocks(lines, start, end, pkg_key) { if target.restore_in(lines, header, block_end, dirty, warnings) { diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 597720df8..761f07b88 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -4156,6 +4156,8 @@ mod tests { ("poetry.lock", ""), ] { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); tokio::fs::create_dir_all(&root).await.unwrap(); touch(tmp.path(), "base.txt", "six==1.16.0\n").await; @@ -4213,6 +4215,8 @@ mod tests { shapes.push(("utf-16 six==1.16.0", utf16)); for (shape, bytes) in shapes { let tmp = tempfile::tempdir().unwrap(); + // The repository every path of the fixture lives in. + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); tokio::fs::create_dir_all(&root).await.unwrap(); tokio::fs::write(tmp.path().join("base.txt"), &bytes) @@ -4232,6 +4236,7 @@ mod tests { // unknowable, so it refuses too. { let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); tokio::fs::create_dir_all(&root).await.unwrap(); tokio::fs::write(tmp.path().join("base.txt"), b"\x80\x81six==1.16.0\n") @@ -4253,6 +4258,7 @@ mod tests { "../wheels/idna-3.7-py3-none-any.whl\n", ] { let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".git")).unwrap(); let root = tmp.path().join("app"); tokio::fs::create_dir_all(&root).await.unwrap(); touch(tmp.path(), "base.txt", other).await; diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 490ef46ec..a27b57d6a 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -537,7 +537,13 @@ pub(super) async fn pin_outside_root( // pip reads an include this walk can't (or fails the install): // its pins are unknowable, so fail closed rather than wire the // lock beside it. Discovery diagnoses the same file. - Err(e) if outside && e.kind() != std::io::ErrorKind::NotFound => { + // One outside the repository root is never read at all: its + // pins are just as unknowable. + Err(e) + if outside + && (e.kind() != std::io::ErrorKind::NotFound + || crate::utils::repo_root::is_refused_read(&e)) => + { found = Some(( "pypi_requirements_outside_root", format!( @@ -1389,6 +1395,16 @@ async fn collect_requirements_files(root: &Path) -> Result, (&'stat "pypi_no_requirements", format!("cannot read {}", root.join(rel).display()), )), + // An include outside the repository root is never read, so the + // pins pip installs from it are unknowable: refuse, as for a pin + // found in an out-of-root include. + Err(e) if crate::utils::repo_root::is_refused_read(&e) => Err(( + "pypi_requirements_outside_root", + format!( + "{rel}: {e}, so vendor cannot tell what pip installs from it; inline it or \ + use agent mode (`scan --mode agent` + `socket-patch apply`) instead" + ), + )), // A broken include is pip's error to report; vendor just can't see // inside it. Skip. Err(_) => Ok(false), @@ -3208,6 +3224,55 @@ mod tests { ); } + /// An include inside the repository but outside the project is read + /// (its pin refuses as one outside the project); one outside the + /// repository is never read, and refuses because its pins are + /// unknowable. Discovery's and the lock flavors' walks see the same. + #[tokio::test] + async fn includes_outside_the_repository_are_never_read() { + let outer = tempfile::tempdir().unwrap(); + let repo = outer.path().join("repo"); + let root = repo.join("project"); + tokio::fs::create_dir_all(repo.join(".git")).await.unwrap(); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(repo.join("shared.txt"), "six==1.16.0\n") + .await + .unwrap(); + tokio::fs::write(outer.path().join("above.txt"), "six==1.16.0\n") + .await + .unwrap(); + let view = || crate::vendor::lock_inventory::ProjectView::Disk(&root); + + tokio::fs::write(root.join("requirements.txt"), "-r ../shared.txt\n") + .await + .unwrap(); + let err = wire_requirements(&root, "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + assert!(err.1.contains("is pinned in"), "{err:?}"); + assert_eq!( + requirements_outside_root_names_in(view()).await, + ["../shared.txt"] + ); + + tokio::fs::write(root.join("requirements.txt"), "-r ../../above.txt\n") + .await + .unwrap(); + let err = wire_requirements(&root, "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_requirements_outside_root"); + assert!(err.1.contains("outside the repository root"), "{err:?}"); + let err = pin_outside_root(&root, "six", "1.16.0").await.unwrap(); + assert_eq!(err.0, "pypi_requirements_outside_root"); + assert!(err.1.contains("outside the repository root"), "{err:?}"); + // To a reader it is missing. + let read = view().read_text("../../above.txt").await.unwrap_err(); + assert_eq!(read.kind(), std::io::ErrorKind::NotFound); + assert!(crate::utils::repo_root::is_refused_read(&read)); + assert!(requirements_outside_root_names_in(view()).await.is_empty()); + } + // ── revert edge cases ──────────────────────────────────────────────── /// SECURITY: a poisoned state.json wiring record naming a diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 2bfbe4d12..4a68c89fb 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -850,7 +850,7 @@ pub(super) fn revert_recorded_block( let orig_lines = rec.original.as_ref().and_then(json_to_lines); let ours = |lines: &[String]| { vendor_field(lines) - .and_then(parse_vendor_path) + .and_then(super::path::parse_vendor_locator) .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid) }; let edit = { diff --git a/crates/socket-patch-core/src/vex/discover/golang.rs b/crates/socket-patch-core/src/vex/discover/golang.rs index a44a3dbc6..938088ea0 100644 --- a/crates/socket-patch-core/src/vex/discover/golang.rs +++ b/crates/socket-patch-core/src/vex/discover/golang.rs @@ -1964,7 +1964,11 @@ mod tests { let missing = outside.path().join("nonexistent").join("go.work"); for (gowork, why) in [ ("go.work".to_string(), "not an absolute path"), - (missing.to_string_lossy().into_owned(), "other than"), + // Outside the repository root: never read, never modeled. + ( + missing.to_string_lossy().into_owned(), + "outside the repository root", + ), (elsewhere.to_string_lossy().into_owned(), "other than"), ] { let out = extract(Some(gowork.clone())).await; diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 815d4644d..81e12df36 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2351,14 +2351,12 @@ pub(crate) fn is_decorated_vendor_path(s: &str) -> bool { vendor_ref(s).is_none() && vendor_ref_decorated(s).is_some() } -/// Everything after a LEADING `.socket/vendor/` anchor (after `file:`, one -/// `./`, backslash and `\/` normalization), or `None`. +/// Everything after a LEADING `.socket/vendor/` anchor (after `file:`, +/// `./`, backslash and `\/` normalization), or `None`: the writers' own +/// root-anchoring rule ([`crate::vendor::path::vendor_rest_from`]), so +/// discovery attests exactly the references the writers claim. fn root_anchored_vendor_rest(s: &str) -> Option { - let norm = s.trim().replace("\\/", "/").replace('\\', "/"); - let norm = norm.strip_prefix("file:").unwrap_or(&norm); - let norm = norm.strip_prefix("./").unwrap_or(norm); - norm.strip_prefix(&format!("{VENDOR_DIR}/")) - .map(str::to_string) + crate::vendor::path::vendor_rest_from("", s) } /// Cut the lock-format decorations off a vendored leaf: everything from the @@ -2966,7 +2964,9 @@ pub async fn vendored_wiring_in_files(root: &Path, files: &[&str], eco: &str, uu .await .iter() .any(|resolution| { - crate::vendor::parse_vendor_path(resolution) + // A nested `sub/bun.lockb` resolves from `sub/`. + let base = file.rsplit_once('/').map_or("", |(dir, _)| dir); + crate::vendor::path::parse_vendor_path_from(base, resolution) .is_some_and(|p| p.eco == eco && p.uuid == uuid) }) { @@ -3069,8 +3069,10 @@ pub async fn hosted_wiring_in_files(root: &Path, files: &[&str], uuid: &str) -> .await .iter() .any(|resolution| { + // Any vendored spelling, ours or a sibling's, is not + // hosted wiring. resolution.contains(uuid) - && crate::vendor::parse_vendor_path(resolution).is_none() + && crate::vendor::path::recognize_vendor_path_anywhere(resolution).is_none() }) { return true; @@ -3330,9 +3332,14 @@ pub(crate) mod testing { /// A project rooted at `sub/` inside the throwaway directory, so a /// fixture can write files beside it (`../ext/…`) without leaving - /// the tempdir (#1516). + /// the tempdir (#1516). The throwaway directory is the git + /// checkout, so what lies beside the project is inside the + /// repository root that bounds every read. pub(crate) fn nested(sub: &str) -> Self { let dir = tempfile::tempdir().expect("create tempdir"); + if !sub.is_empty() { + std::fs::create_dir_all(dir.path().join(".git")).expect("create .git"); + } let root = dir.path().join(sub); std::fs::create_dir_all(&root).expect("create project root"); Project {