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 268540822..464feec93 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,8 @@ 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, ArtifactHealth, - VendorEntry, VendorState, VendorWarning, + self, artifact_is_file_shaped, check_vendored_artifact, parse_vendor_path, + path::parse_vendor_reference, ArtifactHealth, VendorEntry, VendorState, VendorWarning, }; use super::VendoredBackend; @@ -33,7 +33,8 @@ struct Candidate { } /// Scan the wiring-bearing files for vendored-artifact references, -/// returning deduped `(ecosystem, uuid, artifact relpath)` triples. Pure +/// returning deduped `(ecosystem, uuid, artifact relpath)` triples (the +/// relpath is the uuid dir itself for a directory-wired unit). Pure /// text scan plus native binary Bun resolution records and the canonical /// path parser. Used by repair (references the ledger does not cover), by /// the orphan sweeps (`vendor --revert`, `scan --prune`: a dir a lockfile @@ -69,19 +70,24 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, let slice = &rest[idx..]; // `:` ends a reference too: pnpm snapshot keys are // `name@file::` and yaml mappings suffix the path with a - // colon — npm names/versions never contain one. + // colon — npm names/versions never contain one. `<` ends an XML + // element's text (Maven's `…/`). let end = slice .find([ - '"', '\'', '`', ' ', '\t', '\n', '\r', ',', ')', ']', '}', ';', ':', + '"', '\'', '`', ' ', '\t', '\n', '\r', ',', ')', ']', '}', ';', ':', '<', ]) .unwrap_or(slice.len()); let candidate = slice[..end].replace('\\', "/"); - if let Some(parts) = parse_vendor_path(&candidate) { + // NuGet's feed and Maven's repository name the uuid dir itself. + if let Some(parts) = parse_vendor_reference(&candidate) { if seen.insert((parts.eco.to_string(), parts.uuid.clone())) { out.push(( parts.eco.to_string(), parts.uuid.clone(), - candidate.trim_start_matches("./").to_string(), + candidate + .trim_start_matches("./") + .trim_end_matches('/') + .to_string(), )); } } @@ -93,8 +99,9 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, } /// Every wiring-bearing file name the vendor backends may rewrite, relative -/// to `project_root`: the registry's vendored wiring files -/// ([`registry::VENDORED`]), vlt importer manifests, the Python +/// to `project_root`: every file the registry says a vendored run writes +/// ([`registry::VENDORED`]: `nuget.config`, `pom.xml` and `hatch.toml` +/// included), vlt importer manifests, the Python /// locks the root lists (and their scripts) and the requirements `-r` /// include tree. Sorted and deduplicated; entries need not exist. async fn wiring_files(project_root: &Path) -> Vec { @@ -1015,6 +1022,92 @@ mod tests { ); } + /// #832, #958: every file a vendored run writes is scanned, and a + /// reference to the uuid dir itself counts. NuGet's feed and Maven's + /// repository name the dir (Windows backslashes and a trailing slash + /// included); a Hatch environment in `hatch.toml` names a wheel. Every + /// caller (repair, the orphan sweeps, the `vendor` stranded-reference + /// gate, rollback's ledger-less gate) reads this one scan. + #[tokio::test] + async fn scan_recovers_unit_dir_and_hatch_toml_references() { + let tmp = tempfile::tempdir().unwrap(); + let nuget = "22222222-2222-4222-8222-222222222222"; + let nuget_win = "55555555-5555-4555-8555-555555555555"; + let maven = "33333333-3333-4333-8333-333333333333"; + let pypi = "44444444-4444-4444-8444-444444444444"; + let wheel = "six-1.16.0-py2.py3-none-any.whl"; + for (file, text) in [ + ( + "nuget.config", + format!(""), + ), + ( + "pom.xml", + format!("file://${{project.basedir}}/.socket/vendor/maven/{maven}"), + ), + ( + "hatch.toml", + format!("[envs.default]\ndependencies = [\"six @ {{root:uri}}/.socket/vendor/pypi/{pypi}/{wheel}\"]\n"), + ), + ] { + tokio::fs::write(tmp.path().join(file), text).await.unwrap(); + } + let refs = scan_vendor_references(tmp.path()).await; + assert_eq!( + refs, + vec![ + ( + "maven".to_string(), + maven.to_string(), + format!(".socket/vendor/maven/{maven}") + ), + ( + "nuget".to_string(), + nuget.to_string(), + format!(".socket/vendor/nuget/{nuget}") + ), + ( + "pypi".to_string(), + pypi.to_string(), + format!(".socket/vendor/pypi/{pypi}/{wheel}") + ), + ] + ); + + // The backslashed Windows spelling, under another config spelling. + // Its own project: on a case-insensitive file system `NuGet.Config` + // and `nuget.config` are one file. + let win = tempfile::tempdir().unwrap(); + tokio::fs::write( + win.path().join("NuGet.Config"), + format!( + "" + ), + ) + .await + .unwrap(); + assert_eq!( + scan_vendor_references(win.path()).await, + vec![( + "nuget".to_string(), + nuget_win.to_string(), + format!(".socket/vendor/nuget/{nuget_win}") + )] + ); + + // A bare eco dir or a non-uuid dir is still no reference. + tokio::fs::write( + tmp.path().join("pom.xml"), + "file://${project.basedir}/.socket/vendor/maven\n\ + file://${maven.multiModuleProjectDirectory}/.socket/vendor/maven2\n\ + file://${project.basedir}/.socket/vendor/maven/not-a-uuid", + ) + .await + .unwrap(); + let refs = scan_vendor_references(tmp.path()).await; + assert!(refs.iter().all(|(eco, _, _)| eco != "maven"), "{refs:?}"); + } + /// pnpm writes vendored paths in THREE spellings — override values, /// `tarball:` fields, and snapshot KEYS with a trailing colon. The /// scanner must yield the clean relpath whichever form it meets first. diff --git a/crates/socket-patch-cli/tests/repair/repair_vendor_e2e.rs b/crates/socket-patch-cli/tests/repair/repair_vendor_e2e.rs index 88fd159f6..21bb9c422 100644 --- a/crates/socket-patch-cli/tests/repair/repair_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/repair/repair_vendor_e2e.rs @@ -535,6 +535,106 @@ async fn repair_rebuilds_detached_entry_without_manifest() { assert_socket_dir_lean(tmp.path()); } +/// 7b. #832, #958: NuGet's vendored feed (`nuget.config`), Maven's vendored +/// repository (`pom.xml`) and a Hatch environment (`hatch.toml`) wire a +/// unit too. With the ledger gone, repair reports each one as +/// `vendor_ledger_missing` instead of seeing no vendored traces at all. +/// NuGet and Maven name the uuid dir itself, not a file inside it. +#[tokio::test] +async fn repair_reports_missing_ledger_for_nuget_maven_and_hatch_wiring() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + let nuget = "22222222-2222-4222-8222-222222222222"; + let maven = "33333333-3333-4333-8333-333333333333"; + let pypi = "44444444-4444-4444-8444-444444444444"; + let wheel = "six-1.16.0-py2.py3-none-any.whl"; + let files = [ + ( + "nuget.config".to_string(), + format!( + "\n\n \n \ + \n \ + \n\n" + ), + ), + ( + "pom.xml".to_string(), + format!( + "\n \n \n \ + socket-patch-vendor-{maven}\n \ + file://${{project.basedir}}/.socket/vendor/maven/{maven}\n \ + \n \n\n" + ), + ), + ( + "hatch.toml".to_string(), + format!( + "[envs.default]\ndependencies = [\n \"six @ {{root:uri}}/.socket/vendor/pypi/{pypi}/{wheel}#sha256={}\",\n]\n", + "0".repeat(64) + ), + ), + ]; + for (name, text) in &files { + std::fs::write(tmp.path().join(name), text).unwrap(); + } + for (eco, uuid, leaf) in [ + ("nuget", nuget, "x.nupkg"), + ("maven", maven, "x.pom"), + ("pypi", pypi, wheel), + ] { + let dir = tmp.path().join(format!(".socket/vendor/{eco}/{uuid}")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(leaf), b"artifact").unwrap(); + } + + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + let mut missing: Vec<(String, String, String)> = events_of(&v) + .into_iter() + .filter(|e| e["errorCode"] == "vendor_ledger_missing") + .map(|e| { + ( + e["details"]["ecosystem"].as_str().unwrap_or("").to_string(), + e["uuid"].as_str().unwrap_or("").to_string(), + e["details"]["path"].as_str().unwrap_or("").to_string(), + ) + }) + .collect(); + missing.sort(); + assert_eq!( + missing, + vec![ + ( + "maven".to_string(), + maven.to_string(), + format!(".socket/vendor/maven/{maven}") + ), + ( + "nuget".to_string(), + nuget.to_string(), + format!(".socket/vendor/nuget/{nuget}") + ), + ( + "pypi".to_string(), + pypi.to_string(), + format!( + ".socket/vendor/pypi/{pypi}/{wheel}#sha256={}", + "0".repeat(64) + ) + ), + ], + "envelope={v}" + ); + for (name, text) in &files { + assert_eq!( + &std::fs::read_to_string(tmp.path().join(name)).unwrap(), + text + ); + } +} + /// G6 for a manifest-free vendored project: after the run, `.socket/` holds /// exactly `vendor/` — no `apply.lock` outlives it, no blobs/diffs/packages /// are conjured by a repair that rebuilds from the ledger's embedded record. diff --git a/crates/socket-patch-core/src/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs index 03bff38b4..26551b4da 100644 --- a/crates/socket-patch-core/src/formats/registry.rs +++ b/crates/socket-patch-core/src/formats/registry.rs @@ -16,9 +16,10 @@ /// Read by the hosted planners (`scan --mode hosted`, the in-memory /// engine's candidate reads). pub const HOSTED: u8 = 1 << 0; -/// Rewired by a vendored planner: the search space for -/// `.socket/vendor///` references when the vendor ledger -/// is gone (`repair`). +/// Written by a vendored run: the search space for +/// `.socket/vendor//[/]` references (`repair`, the orphan +/// sweeps, rollback's ledger-less gate) and the files a vendored dry run +/// checks for symbolic links ([`wiring_paths`]). pub const VENDORED: u8 = 1 << 1; /// A lock (or wiring config) a vendored artifact is consumed through — the /// liveness probe of a ledger entry whose recorded wiring files are gone @@ -77,7 +78,7 @@ const REGISTRY: &[FormatFile] = &[ // Package-manager detection only: the vendor probe and the hosted // planners have never accepted these spellings. row("pnpm-lock.yml", "npm", PNPM_MARKER), - row("pnpm-workspace.yaml", "npm", PNPM_MARKER), + row("pnpm-workspace.yaml", "npm", VENDORED | PNPM_MARKER), // pnpm <= 2 uses the same package identities under the old filename. row("shrinkwrap.yaml", "npm", HOSTED), row("node_modules/.modules.yaml", "npm", HOSTED), @@ -107,7 +108,7 @@ const REGISTRY: &[FormatFile] = &[ // than abandoned. row("Pipfile", "pypi", HOSTED | PRESENCE_ONLY), row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE), - row("hatch.toml", "pypi", HOSTED | PROBE), + row("hatch.toml", "pypi", HOSTED | VENDORED | PROBE), // ── cargo ── row("Cargo.toml", "cargo", HOSTED | VENDORED | PROBE), row("Cargo.lock", "cargo", HOSTED | VENDORED | ROOT), @@ -121,10 +122,10 @@ const REGISTRY: &[FormatFile] = &[ row("composer.json", "composer", VENDORED), row("composer.lock", "composer", HOSTED | VENDORED | PROBE | ROOT), // ── nuget ── - row("nuget.config", "nuget", HOSTED | PROBE), - row("NuGet.config", "nuget", HOSTED | PROBE), - row("NuGet.Config", "nuget", HOSTED | PROBE), - row("packages.lock.json", "nuget", HOSTED), + row("nuget.config", "nuget", HOSTED | VENDORED | PROBE), + row("NuGet.config", "nuget", HOSTED | VENDORED | PROBE), + row("NuGet.Config", "nuget", HOSTED | VENDORED | PROBE), + row("packages.lock.json", "nuget", HOSTED | VENDORED), // ── gem ── row("Gemfile", "gem", HOSTED | VENDORED), row("Gemfile.lock", "gem", HOSTED | VENDORED | PROBE | ROOT), @@ -140,10 +141,10 @@ const REGISTRY: &[FormatFile] = &[ row("go.mod", "golang", HOSTED | VENDORED | PROBE | ROOT), row("go.sum", "golang", HOSTED | ROOT), // ── maven ── - row("pom.xml", "maven", HOSTED | PROBE), + row("pom.xml", "maven", HOSTED | VENDORED | PROBE), // Maven Trusted Checksums files the fail-closed maven planner merges // into (read so an existing user config / checksum set is preserved). - row(".mvn/maven.config", "maven", HOSTED), + row(".mvn/maven.config", "maven", HOSTED | VENDORED), row(".mvn/checksums/checksums.sha256", "maven", HOSTED), // Never edited: its `distributionUrl` names the project's Maven, which // the maven planner checks against the Trusted Checksums floor (3.9.4) @@ -241,21 +242,6 @@ pub fn hosted_file_ecosystem(rel: &str) -> Option<&'static str> { .map(|f| f.ecosystem) } -/// Files a vendored run writes that carry no [`VENDORED`] role (that role -/// also scopes `repair`'s fingerprint): pnpm's workspace file, NuGet's -/// config and lock (the vendored feed), the root `pom.xml` and -/// `.mvn/maven.config` (vendored Maven), and `hatch.toml` (vendored Hatch). -const VENDORED_WRITES_UNMARKED: &[&str] = &[ - "pnpm-workspace.yaml", - "nuget.config", - "NuGet.config", - "NuGet.Config", - "packages.lock.json", - "pom.xml", - ".mvn/maven.config", - "hatch.toml", -]; - /// The project-relative paths of `ecosystem` that a vendored run may /// rewrite: the files a vendored dry run checks for symbolic links, since /// the wet run's commit refuses to rename over one. Files a vendored run @@ -264,10 +250,7 @@ const VENDORED_WRITES_UNMARKED: &[&str] = &[ pub fn wiring_paths(ecosystem: &str) -> Vec<&'static str> { REGISTRY .iter() - .filter(|f| { - f.ecosystem == ecosystem - && (f.has(VENDORED) || VENDORED_WRITES_UNMARKED.contains(&f.path)) - }) + .filter(|f| f.ecosystem == ecosystem && f.has(VENDORED)) .map(|f| f.path) .collect() } @@ -325,6 +308,61 @@ mod tests { } } + /// One notion of "a file a vendored run writes" (#832, #958): the + /// [`VENDORED`] role. The vendored dry run's symlink check + /// ([`wiring_paths`]) and the vendored-reference scan + /// (`paths_with(VENDORED)`) read the same rows, so every file a vendored + /// backend rewires (NuGet's config and lock, Maven's pom and + /// `.mvn/maven.config`, Hatch's `hatch.toml`, pnpm's workspace file) is + /// searched for references. + #[test] + fn the_reference_scan_and_the_symlink_check_read_the_same_files() { + let vendored = paths_with(VENDORED); + let mut union: Vec<&str> = [ + "npm", "pypi", "cargo", "composer", "nuget", "gem", "golang", "maven", + ] + .iter() + .flat_map(|eco| wiring_paths(eco)) + .collect(); + union.sort_unstable(); + let mut sorted = vendored.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, union); + for p in [ + "pnpm-workspace.yaml", + "nuget.config", + "NuGet.config", + "NuGet.Config", + "packages.lock.json", + "pom.xml", + ".mvn/maven.config", + "hatch.toml", + ] { + assert!(vendored.contains(&p), "{p}"); + } + assert_eq!( + wiring_paths("nuget"), + [ + "nuget.config", + "NuGet.config", + "NuGet.Config", + "packages.lock.json" + ] + ); + assert_eq!( + wiring_paths("pypi"), + [ + "requirements.txt", + "uv.lock", + "poetry.lock", + "pdm.lock", + "Pipfile.lock", + "pyproject.toml", + "hatch.toml" + ] + ); + } + #[test] fn hosted_file_ecosystem_matches_basenames_of_edited_files_only() { assert_eq!(hosted_file_ecosystem("package-lock.json"), Some("npm")); diff --git a/crates/socket-patch-core/src/vendor/path.rs b/crates/socket-patch-core/src/vendor/path.rs index e0c3426cb..eead69e1e 100644 --- a/crates/socket-patch-core/src/vendor/path.rs +++ b/crates/socket-patch-core/src/vendor/path.rs @@ -137,6 +137,16 @@ pub struct VendorPathParts { /// This is the documented external-tool recovery rule; `None` means the /// string is not a Socket-vendored path. pub fn parse_vendor_path(s: &str) -> Option { + parse_vendor_reference(s).filter(|parts| !parts.leaf.is_empty()) +} + +/// [`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. +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); @@ -152,8 +162,8 @@ pub fn parse_vendor_path(s: &str) -> Option { let mut it = rest.splitn(3, '/'); let eco = it.next()?; let uuid = it.next()?; - let leaf = it.next()?.trim_end_matches('/'); - if !ECOSYSTEM_DIRS.contains(&eco) || !is_canonical_uuid(uuid) || leaf.is_empty() { + let leaf = it.next().unwrap_or("").trim_end_matches('/'); + if !ECOSYSTEM_DIRS.contains(&eco) || !is_canonical_uuid(uuid) { return None; } Some(VendorPathParts { @@ -636,6 +646,32 @@ mod tests { assert!(parse_vendor_path(&format!("x.socket/vendor/npm/{UUID}/y.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. + #[test] + fn parse_vendor_reference_accepts_the_uuid_dir_itself() { + for s in [ + 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!( + (p.eco.as_str(), p.uuid.as_str(), p.leaf.as_str()), + ("nuget", UUID, "") + ); + assert!(parse_vendor_path(&s).is_none(), "{s}"); + } + let p = parse_vendor_reference(&format!(".socket/vendor/npm/{UUID}/a/b.tgz")).unwrap(); + assert_eq!(p.leaf, "a/b.tgz"); + assert!(parse_vendor_reference(".socket/vendor/nuget").is_none()); + assert!(parse_vendor_reference(".socket/vendor/nuget/not-a-uuid").is_none()); + assert!(parse_vendor_reference(&format!(".socket/vendor/maven2/{UUID}")).is_none()); + assert!(parse_vendor_reference(&format!("x.socket/vendor/nuget/{UUID}")).is_none()); + } + /// The re-vendor carry-forward matches wiring keys ACROSS a patch-uuid /// change when the key embeds the vendored path (berry's `file:` locator /// key), and only byte-equal otherwise.