Skip to content

Commit a7082f4

Browse files
committed
Scan every vendored-write file for references
The vendored-reference scan behind repair, the orphan sweeps (vendor --revert, the vendored gc), the vendor stranded-reference gate and rollback's ledger-less gate read only the registry's VENDORED rows, while the files NuGet, Maven, Hatch and pnpm vendoring also write sat in a second list, VENDORED_WRITES_UNMARKED, that only the dry run's symlink check used. NuGet's feed and Maven's repository also name the uuid directory itself, which parse_vendor_path rejects. So with a missing ledger entry, a NuGet or Maven unit, or a wheel a hatch.toml environment installs, was deleted by the orphan sweep and never reported by repair while the project still pointed at it. The eight rows now carry the VENDORED role and the second list is gone, so the scan and wiring_paths read one notion of "a file a vendored run writes". parse_vendor_reference is parse_vendor_path's grammar plus the bare uuid dir, and the scan reads through it, with `<` ending a reference inside XML text. Fixes #832 and #958. Assisted-by: Claude Code:claude-opus-5-5
1 parent 2978aa7 commit a7082f4

4 files changed

Lines changed: 296 additions & 41 deletions

File tree

‎crates/socket-patch-cli/src/commands/vendored_backend/repair.rs‎

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord};
1212
use socket_patch_core::utils::fs::read_regular_to_string;
1313
use socket_patch_core::utils::purl::normalize_purl;
1414
use socket_patch_core::vendor::{
15-
self, artifact_is_file_shaped, check_vendored_artifact, parse_vendor_path, ArtifactHealth,
16-
VendorEntry, VendorState, VendorWarning,
15+
self, artifact_is_file_shaped, check_vendored_artifact, parse_vendor_path,
16+
path::parse_vendor_reference, ArtifactHealth, VendorEntry, VendorState, VendorWarning,
1717
};
1818

1919
use super::VendoredBackend;
@@ -33,7 +33,8 @@ struct Candidate {
3333
}
3434

3535
/// Scan the wiring-bearing files for vendored-artifact references,
36-
/// returning deduped `(ecosystem, uuid, artifact relpath)` triples. Pure
36+
/// returning deduped `(ecosystem, uuid, artifact relpath)` triples (the
37+
/// relpath is the uuid dir itself for a directory-wired unit). Pure
3738
/// text scan plus native binary Bun resolution records and the canonical
3839
/// path parser. Used by repair (references the ledger does not cover), by
3940
/// 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,
6970
let slice = &rest[idx..];
7071
// `:` ends a reference too: pnpm snapshot keys are
7172
// `name@file:<path>:` and yaml mappings suffix the path with a
72-
// colon — npm names/versions never contain one.
73+
// colon — npm names/versions never contain one. `<` ends an XML
74+
// element's text (Maven's `<url>…/<uuid></url>`).
7375
let end = slice
7476
.find([
75-
'"', '\'', '`', ' ', '\t', '\n', '\r', ',', ')', ']', '}', ';', ':',
77+
'"', '\'', '`', ' ', '\t', '\n', '\r', ',', ')', ']', '}', ';', ':', '<',
7678
])
7779
.unwrap_or(slice.len());
7880
let candidate = slice[..end].replace('\\', "/");
79-
if let Some(parts) = parse_vendor_path(&candidate) {
81+
// NuGet's feed and Maven's repository name the uuid dir itself.
82+
if let Some(parts) = parse_vendor_reference(&candidate) {
8083
if seen.insert((parts.eco.to_string(), parts.uuid.clone())) {
8184
out.push((
8285
parts.eco.to_string(),
8386
parts.uuid.clone(),
84-
candidate.trim_start_matches("./").to_string(),
87+
candidate
88+
.trim_start_matches("./")
89+
.trim_end_matches('/')
90+
.to_string(),
8591
));
8692
}
8793
}
@@ -93,8 +99,9 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String,
9399
}
94100

95101
/// Every wiring-bearing file name the vendor backends may rewrite, relative
96-
/// to `project_root`: the registry's vendored wiring files
97-
/// ([`registry::VENDORED`]), vlt importer manifests, the Python
102+
/// to `project_root`: every file the registry says a vendored run writes
103+
/// ([`registry::VENDORED`]: `nuget.config`, `pom.xml` and `hatch.toml`
104+
/// included), vlt importer manifests, the Python
98105
/// locks the root lists (and their scripts) and the requirements `-r`
99106
/// include tree. Sorted and deduplicated; entries need not exist.
100107
async fn wiring_files(project_root: &Path) -> Vec<String> {
@@ -1015,6 +1022,80 @@ mod tests {
10151022
);
10161023
}
10171024

1025+
/// #832, #958: every file a vendored run writes is scanned, and a
1026+
/// reference to the uuid dir itself counts. NuGet's feed and Maven's
1027+
/// repository name the dir (Windows backslashes and a trailing slash
1028+
/// included); a Hatch environment in `hatch.toml` names a wheel. Every
1029+
/// caller (repair, the orphan sweeps, the `vendor` stranded-reference
1030+
/// gate, rollback's ledger-less gate) reads this one scan.
1031+
#[tokio::test]
1032+
async fn scan_recovers_unit_dir_and_hatch_toml_references() {
1033+
let tmp = tempfile::tempdir().unwrap();
1034+
let nuget = "22222222-2222-4222-8222-222222222222";
1035+
let nuget_win = "55555555-5555-4555-8555-555555555555";
1036+
let maven = "33333333-3333-4333-8333-333333333333";
1037+
let pypi = "44444444-4444-4444-8444-444444444444";
1038+
let wheel = "six-1.16.0-py2.py3-none-any.whl";
1039+
for (file, text) in [
1040+
(
1041+
"nuget.config",
1042+
format!("<add key=\"socket-patch-vendor\" value=\".socket/vendor/nuget/{nuget}/\" />"),
1043+
),
1044+
(
1045+
"NuGet.Config",
1046+
format!("<add key=\"socket-patch-vendor\" value=\".socket\\vendor\\nuget\\{nuget_win}\" />"),
1047+
),
1048+
(
1049+
"pom.xml",
1050+
format!("<url>file://${{project.basedir}}/.socket/vendor/maven/{maven}</url>"),
1051+
),
1052+
(
1053+
"hatch.toml",
1054+
format!("[envs.default]\ndependencies = [\"six @ {{root:uri}}/.socket/vendor/pypi/{pypi}/{wheel}\"]\n"),
1055+
),
1056+
] {
1057+
tokio::fs::write(tmp.path().join(file), text).await.unwrap();
1058+
}
1059+
let refs = scan_vendor_references(tmp.path()).await;
1060+
assert_eq!(
1061+
refs,
1062+
vec![
1063+
(
1064+
"maven".to_string(),
1065+
maven.to_string(),
1066+
format!(".socket/vendor/maven/{maven}")
1067+
),
1068+
(
1069+
"nuget".to_string(),
1070+
nuget.to_string(),
1071+
format!(".socket/vendor/nuget/{nuget}")
1072+
),
1073+
(
1074+
"nuget".to_string(),
1075+
nuget_win.to_string(),
1076+
format!(".socket/vendor/nuget/{nuget_win}")
1077+
),
1078+
(
1079+
"pypi".to_string(),
1080+
pypi.to_string(),
1081+
format!(".socket/vendor/pypi/{pypi}/{wheel}")
1082+
),
1083+
]
1084+
);
1085+
1086+
// A bare eco dir or a non-uuid dir is still no reference.
1087+
tokio::fs::write(
1088+
tmp.path().join("pom.xml"),
1089+
"<url>file://${project.basedir}/.socket/vendor/maven</url>\n\
1090+
<url>file://${maven.multiModuleProjectDirectory}/.socket/vendor/maven2</url>\n\
1091+
<url>file://${project.basedir}/.socket/vendor/maven/not-a-uuid</url>",
1092+
)
1093+
.await
1094+
.unwrap();
1095+
let refs = scan_vendor_references(tmp.path()).await;
1096+
assert!(refs.iter().all(|(eco, _, _)| eco != "maven"), "{refs:?}");
1097+
}
1098+
10181099
/// pnpm writes vendored paths in THREE spellings — override values,
10191100
/// `tarball:` fields, and snapshot KEYS with a trailing colon. The
10201101
/// scanner must yield the clean relpath whichever form it meets first.

‎crates/socket-patch-cli/tests/repair/repair_vendor_e2e.rs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,106 @@ async fn repair_rebuilds_detached_entry_without_manifest() {
535535
assert_socket_dir_lean(tmp.path());
536536
}
537537

538+
/// 7b. #832, #958: NuGet's vendored feed (`nuget.config`), Maven's vendored
539+
/// repository (`pom.xml`) and a Hatch environment (`hatch.toml`) wire a
540+
/// unit too. With the ledger gone, repair reports each one as
541+
/// `vendor_ledger_missing` instead of seeing no vendored traces at all.
542+
/// NuGet and Maven name the uuid dir itself, not a file inside it.
543+
#[tokio::test]
544+
async fn repair_reports_missing_ledger_for_nuget_maven_and_hatch_wiring() {
545+
let mock = MockServer::start().await;
546+
mount_patch_api(&mock).await;
547+
let tmp = tempfile::tempdir().unwrap();
548+
let nuget = "22222222-2222-4222-8222-222222222222";
549+
let maven = "33333333-3333-4333-8333-333333333333";
550+
let pypi = "44444444-4444-4444-8444-444444444444";
551+
let wheel = "six-1.16.0-py2.py3-none-any.whl";
552+
let files = [
553+
(
554+
"nuget.config".to_string(),
555+
format!(
556+
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<configuration>\n <packageSources>\n \
557+
<add key=\"socket-patch-vendor\" value=\".socket/vendor/nuget/{nuget}\" />\n \
558+
</packageSources>\n</configuration>\n"
559+
),
560+
),
561+
(
562+
"pom.xml".to_string(),
563+
format!(
564+
"<project>\n <repositories>\n <repository>\n \
565+
<id>socket-patch-vendor-{maven}</id>\n \
566+
<url>file://${{project.basedir}}/.socket/vendor/maven/{maven}</url>\n \
567+
</repository>\n </repositories>\n</project>\n"
568+
),
569+
),
570+
(
571+
"hatch.toml".to_string(),
572+
format!(
573+
"[envs.default]\ndependencies = [\n \"six @ {{root:uri}}/.socket/vendor/pypi/{pypi}/{wheel}#sha256={}\",\n]\n",
574+
"0".repeat(64)
575+
),
576+
),
577+
];
578+
for (name, text) in &files {
579+
std::fs::write(tmp.path().join(name), text).unwrap();
580+
}
581+
for (eco, uuid, leaf) in [
582+
("nuget", nuget, "x.nupkg"),
583+
("maven", maven, "x.pom"),
584+
("pypi", pypi, wheel),
585+
] {
586+
let dir = tmp.path().join(format!(".socket/vendor/{eco}/{uuid}"));
587+
std::fs::create_dir_all(&dir).unwrap();
588+
std::fs::write(dir.join(leaf), b"artifact").unwrap();
589+
}
590+
591+
let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]);
592+
assert_eq!(code, 1, "stdout={stdout} stderr={stderr}");
593+
let v = parse_env(&stdout);
594+
let mut missing: Vec<(String, String, String)> = events_of(&v)
595+
.into_iter()
596+
.filter(|e| e["errorCode"] == "vendor_ledger_missing")
597+
.map(|e| {
598+
(
599+
e["details"]["ecosystem"].as_str().unwrap_or("").to_string(),
600+
e["uuid"].as_str().unwrap_or("").to_string(),
601+
e["details"]["path"].as_str().unwrap_or("").to_string(),
602+
)
603+
})
604+
.collect();
605+
missing.sort();
606+
assert_eq!(
607+
missing,
608+
vec![
609+
(
610+
"maven".to_string(),
611+
maven.to_string(),
612+
format!(".socket/vendor/maven/{maven}")
613+
),
614+
(
615+
"nuget".to_string(),
616+
nuget.to_string(),
617+
format!(".socket/vendor/nuget/{nuget}")
618+
),
619+
(
620+
"pypi".to_string(),
621+
pypi.to_string(),
622+
format!(
623+
".socket/vendor/pypi/{pypi}/{wheel}#sha256={}",
624+
"0".repeat(64)
625+
)
626+
),
627+
],
628+
"envelope={v}"
629+
);
630+
for (name, text) in &files {
631+
assert_eq!(
632+
&std::fs::read_to_string(tmp.path().join(name)).unwrap(),
633+
text
634+
);
635+
}
636+
}
637+
538638
/// G6 for a manifest-free vendored project: after the run, `.socket/` holds
539639
/// exactly `vendor/` — no `apply.lock` outlives it, no blobs/diffs/packages
540640
/// are conjured by a repair that rebuilds from the ledger's embedded record.

0 commit comments

Comments
 (0)