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 34d8dbeb7..bb40e02a1 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -551,6 +551,67 @@ impl VendoredBackend<'_> { continue; } } + // A stray file in the tree (a Finder `.DS_Store`) fails + // the Gradle build (#656); the index says what belongs + // there, so it goes offline too. + if vendor::jvm::apply::is_jvm_entry(&entry) { + let indexed = vendor::jvm::apply::recorded_tree_files(&entry); + let swept = + match vendor::jvm::apply::unindexed_tree_entries(&common.cwd, &indexed) + .and_then(|found| { + vendor::jvm::apply::refuse_linked(&found).map(|()| found) + }) { + Ok(stray) if stray.is_empty() || common.dry_run => Ok(stray), + Ok(stray) => vendor::jvm::apply::sweep_unindexed(&stray) + .await + .map(|()| stray), + Err(e) => Err(e), + }; + match swept { + Ok(stray) if stray.is_empty() => {} + Ok(stray) if common.dry_run => { + env.record( + PatchEvent::new(PatchAction::Verified, purl.clone()) + .with_details(serde_json::json!({ + "vendorArtifact": true, + "wouldRemoveUnindexed": stray + .iter() + .map(|s| s.rel.as_str()) + .collect::>(), + })), + ); + } + Ok(stray) => { + if !quiet { + for s in &stray { + println!("Removed unindexed vendored file {}", s.rel); + } + } + env.record( + PatchEvent::new(PatchAction::Rebuilt, purl.clone()) + .with_details(serde_json::json!({ + "path": entry.artifact.path, + "unindexedRemoved": stray + .iter() + .map(|s| s.rel.as_str()) + .collect::>(), + "artifactRebuilt": false, + })), + ); + repaired += 1; + } + Err(e) => { + fail( + env, + common.json, + purl, + "vendor_artifact_unrepairable", + format!("cannot remove unindexed vendored files: {e}"), + ); + continue; + } + } + } // Dir-shaped gem artifacts from pre-inventory vendors: // the health check could only verify the PATCHED members // — unpatched-file drift is invisible until a re-vendor diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gradle_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gradle_build.rs index 4c40b6240..d2ce46452 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gradle_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gradle_build.rs @@ -573,6 +573,44 @@ fn gradle_vendor_428_subproject_refuses() { assert_victim(&out, "pristine", None, "`cd app && gradle` still builds"); } +// ── #656 ──────────────────────────────────────────────────────────────── + +/// #656: a Finder `.DS_Store` in the vendored tree fails the build with +/// "unindexed files … re-run `socket-patch vendor`". That re-run, and +/// `repair`, now remove it, and the build consumes the patched jar again. +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_656_stray_tree_file() { + const SUITE: &str = "e2e_vendor_gradle_build::656"; + let Some(world) = World::new(SUITE) else { + return; + }; + let proj = world.root.join("proj"); + simple_project(&proj); + prepare(&world, &proj, "printRuntimeClasspath"); + world.vendor(&proj); + let out = world.build(&proj, &["printRuntimeClasspath"]); + assert_victim(&out, "patched", Some(&proj), "vendored build"); + let stray = proj + .join(tree_rel(VICTIM, VICTIM_VERSION)) + .join(".DS_Store"); + for remedy in [&["vendor"][..], &["repair"]] { + std::fs::write(&stray, b"\0\0\0\x01Bud1").unwrap(); + let out = world.build(&proj, &["printRuntimeClasspath"]); + assert!(!ok(&out), "{remedy:?}: the stray file fails the build"); + assert!( + dump(&out).contains("unindexed files"), + "{remedy:?}:\n{}", + dump(&out) + ); + world.socket_ok(&proj, remedy); + assert!(!stray.exists(), "{remedy:?} removes the stray file"); + world.socket_ok(&proj, &["vendor", "--check"]); + let out = world.build(&proj, &["printRuntimeClasspath"]); + assert_victim(&out, "patched", Some(&proj), &format!("after {remedy:?}")); + } +} + // ── #429 ──────────────────────────────────────────────────────────────── /// #429: a `core.autocrlf=true` clone of a vendored checkout (buildSrc diff --git a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs index 18b749a51..a8450199a 100644 --- a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs +++ b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs @@ -359,6 +359,86 @@ fn cold_cache_rerun_vex_repair_and_revert() { } } +/// #656: a file the index does not list in the vendored tree (a Finder +/// `.DS_Store`) fails the Gradle build and `vendor --check`. Re-running +/// `vendor`, the remedy the build error names, and `repair` both sweep +/// it instead of reporting the tree in sync. +#[test] +fn stray_tree_files_are_swept_by_vendor_and_repair() { + for shape in [Shape::Reactor, Shape::Gradle] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, shape, &[("foo", FOO_UUID)]); + ok(root, &["vendor"]); + let vendored = snapshot(root); + let tree = if shape == Shape::Reactor { + "proj/.socket/vendor/maven2/org/example/foo/1.0-socket.1d3c1fd2" + } else { + "proj/.socket/vendor/gradle/org/example/foo/1.0" + }; + let stray = root.join(tree).join(".DS_Store"); + + std::fs::write(&stray, b"\0\0\0\x01Bud1").unwrap(); + // A stray symlink goes, not its target: vendor deletes it + // directly, outside its group commit. + let target = root.join("proj/keep.txt"); + std::fs::write(&target, b"keep").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, root.join(tree).join("link")).unwrap(); + let (code, env) = socket(root, &["vendor", "--check"]); + assert_eq!(code, Some(1), "{env}"); + let env = ok(root, &["vendor", "--dry-run"]); + assert!( + !events(&env).iter().any(|(_, _, c)| c == "already_vendored"), + "the dry run previews the sweep: {env}" + ); + assert!(stray.exists(), "a dry run writes nothing"); + let env = ok(root, &["vendor"]); + assert!( + !events(&env).iter().any(|(_, _, c)| c == "already_vendored"), + "{env}" + ); + assert!(!stray.exists(), "vendor sweeps the stray file: {env}"); + assert!(std::fs::symlink_metadata(root.join(tree).join("link")).is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"keep"); + std::fs::remove_file(&target).unwrap(); + ok(root, &["vendor", "--check"]); + let env = ok(root, &["vendor"]); + assert_eq!( + events(&env), + [( + purl("foo"), + "skipped".to_string(), + "already_vendored".to_string() + )], + "in sync after the sweep: {env}" + ); + + std::fs::write(&stray, b"\0\0\0\x01Bud1").unwrap(); + std::fs::create_dir_all(root.join(tree).join("backup")).unwrap(); + std::fs::write(root.join(tree).join("backup/old.jar"), b"x").unwrap(); + let env = ok(root, &["repair", "--dry-run"]); + assert_eq!( + env["events"][0]["details"]["wouldRemoveUnindexed"], + serde_json::json!([ + format!("{}/.DS_Store", tree.strip_prefix("proj/").unwrap()), + format!("{}/backup", tree.strip_prefix("proj/").unwrap()), + ]), + "{env}" + ); + assert!(stray.exists(), "repair --dry-run writes nothing"); + let env = ok(root, &["repair"]); + assert_eq!(env["events"][0]["action"], "rebuilt", "{env}"); + assert!(!stray.exists() && !root.join(tree).join("backup").exists()); + ok(root, &["vendor", "--check"]); + let mut repaired = snapshot(root); + let mut want = vendored.clone(); + repaired.remove(".socket/vendor/state.json"); + want.remove(".socket/vendor/state.json"); + assert_eq!(repaired, want); + } +} + /// `rollback --preserve-state` keeps the tree and the ledger entry. #[test] fn preserve_state_keeps_the_tree() { diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index f81caa585..d9387f4cd 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -811,6 +811,150 @@ pub async fn sweep_replaced_tree<'e>( Ok(!removed.is_empty()) } +/// A file or directory in a vendored tree that its index does not list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnindexedEntry { + /// Project-relative, for messages. + pub rel: String, + /// The entry itself, inside its resolved in-checkout tree directory + /// (never resolved through the entry, so a stray symlink is the link). + pub path: PathBuf, + /// The tree directory is reached through a symlink, so its entries + /// may belong to another tree: [`sweep_unindexed`] refuses them. + pub linked: bool, +} + +/// What `dirs` (vendored tree directories) hold beyond the files `indexed` +/// lists: a Finder `.DS_Store`, an editor backup, a stray directory. The +/// Gradle settings script and `vendor --check` refuse any of them, so a +/// re-vendor and `repair` sweep them (#656). A missing directory holds +/// nothing. +pub fn unindexed_in( + root: &Path, + dirs: &[String], + indexed: &BTreeSet, +) -> Result, String> { + let reader = ProjectReader::new(root); + let mut stray = Vec::new(); + for dir in dirs { + let path = reader.resolve(dir)?; + let linked = reader + .canonical + .as_ref() + .is_none_or(|root| path != dir.split('/').fold(root.clone(), |p, s| p.join(s))); + let items = match std::fs::read_dir(&path) { + Ok(items) => items, + Err(e) if is_missing(&e) => continue, + Err(e) => return Err(format!("cannot list {dir}: {e}")), + }; + for item in items { + let item = item.map_err(|e| format!("cannot list {dir}: {e}"))?; + let name = item.file_name(); + let rel = format!("{dir}/{}", name.to_string_lossy()); + if !indexed.contains(&rel) { + stray.push(UnindexedEntry { + rel, + path: path.join(name), + linked, + }); + } + } + } + stray.sort_by(|a, b| a.rel.cmp(&b.rel)); + stray.dedup(); + Ok(stray) +} + +/// The vendored tree directories `indexed` lists files in. +pub fn indexed_tree_dirs(indexed: &BTreeSet) -> Vec { + let dirs: BTreeSet = indexed + .iter() + .filter(|rel| safe_rel(rel)) + .filter_map(|rel| rel.rsplit_once('/').map(|(dir, _)| dir)) + .filter(|dir| { + layout::VENDOR_TREES + .iter() + .any(|tree| dir.strip_prefix(tree).is_some_and(|r| r.starts_with('/'))) + }) + .map(str::to_string) + .collect(); + dirs.into_iter().collect() +} + +/// [`unindexed_in`] over the directories `indexed` lists files in. +pub fn unindexed_tree_entries( + root: &Path, + indexed: &BTreeSet, +) -> Result, String> { + unindexed_in(root, &indexed_tree_dirs(indexed), indexed) +} + +/// Through a linked tree directory a stray may be another artifact's +/// file, so [`sweep_unindexed`] refuses it and leaves the call to the +/// user. A dry run checks this too, so it never previews a sweep the real +/// run refuses. +pub fn refuse_linked(stray: &[UnindexedEntry]) -> Result<(), String> { + match stray.iter().find(|s| s.linked) { + Some(s) => Err(format!( + "{} is in a vendored tree directory reached through a symlink; delete it by \ + hand if it is not another artifact's file", + s.rel + )), + None => Ok(()), + } +} + +/// Delete `stray` now, outside any group commit: nothing names these +/// entries, so there is nothing to journal, and a captured removal would +/// be read back through a stray symlink at the commit. A symlink goes, +/// not its target; a directory goes with everything in it (the tree is +/// socket-patch's own). One already gone counts as removed. +pub async fn sweep_unindexed(stray: &[UnindexedEntry]) -> Result<(), String> { + refuse_linked(stray)?; + for entry in stray { + let gone = match tokio::fs::symlink_metadata(&entry.path).await { + Ok(meta) if meta.is_dir() => tokio::fs::remove_dir_all(&entry.path).await, + // A Windows directory symlink or junction is unlinked with + // `remove_dir` (which leaves its target alone), not + // `remove_file`. + #[cfg(windows)] + Ok(meta) if std::os::windows::fs::FileTypeExt::is_symlink_dir(&meta.file_type()) => { + tokio::fs::remove_dir(&entry.path).await + } + Ok(_) => tokio::fs::remove_file(&entry.path).await, + Err(e) => Err(e), + }; + match gone { + Ok(()) => {} + Err(e) if is_missing(&e) => {} + Err(e) => return Err(format!("failed to remove unindexed {}: {e}", entry.rel)), + } + } + Ok(()) +} + +/// The tree files `entry` records, the index [`unindexed_tree_entries`] +/// holds its directories to. The ledger is committed and tamper-able, so +/// only records inside the entry's own version directories count: a +/// forged marker elsewhere names no directory to sweep. +pub fn recorded_tree_files(entry: &VendorEntry) -> BTreeSet { + let Ok((g, a, v)) = entry_gav(entry) else { + return BTreeSet::new(); + }; + let c = Coords { + group_id: &g, + artifact_id: &a, + version: &v, + uuid: &entry.uuid, + }; + entry + .wiring + .iter() + .filter(|w| w.kind == TREE_KIND && record_allowed(w, &c)) + .map(|w| w.file.clone()) + .collect() +} + /// Whether the project still wires the JVM `entry` for `vex` (see /// [`entry_wired_checked`]). pub fn entry_wired(root: &Path, entry: &VendorEntry) -> bool { @@ -1030,19 +1174,14 @@ pub fn check_entry( if maven && gradle { tree_dirs.push(gradle::tree_dir(&c)); } - for tree_dir in tree_dirs { - let dir = reader.resolve(&tree_dir)?; - for item in std::fs::read_dir(&dir).map_err(|e| e.to_string())? { - let item = item.map_err(|e| e.to_string())?; - let rel = format!("{tree_dir}/{}", item.file_name().to_string_lossy()); - if !entry - .wiring - .iter() - .any(|w| w.kind == TREE_KIND && w.file == rel) - { - return Err(format!("unindexed vendored file: {rel}")); - } - } + let indexed: BTreeSet = entry + .wiring + .iter() + .filter(|w| w.kind == TREE_KIND) + .map(|w| w.file.clone()) + .collect(); + if let Some(stray) = unindexed_in(root, &tree_dirs, &indexed)?.first() { + return Err(format!("unindexed vendored file: {}", stray.rel)); } // The sbt tree is the reactor's suffixed layout, so a Maven local // repository may cache the same suffixed GAV. @@ -1685,4 +1824,84 @@ mod tests { assert!(root.join(tree_file("a-1-socket.1d3c1fd2.pom")).is_file()); assert!(!sweep_replaced_tree(root, &prev, [&live]).await.unwrap()); } + + /// #656: the sweep removes what the index does not list from the + /// entry's own tree (files, a stray directory, a symlink but not its + /// target) and nothing a forged ledger marker points elsewhere at. + #[tokio::test] + async fn sweep_unindexed_removes_only_strays_in_the_own_tree() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let marker = tree_file(layout::MARKER_FILE); + let jar = tree_file("a-1-socket.1d3c1fd2.jar"); + let peer = ".socket/vendor/maven2/g/b/1-socket.1d3c1fd2/b-1-socket.1d3c1fd2.jar"; + for (rel, bytes) in [(&marker[..], "{}"), (&jar, "JAR"), (peer, "PEER")] { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + let own = root.join(tree_file("")); + std::fs::write(own.join(".DS_Store"), "x").unwrap(); + std::fs::create_dir_all(own.join("backup")).unwrap(); + std::fs::write(own.join("backup/old.jar"), "x").unwrap(); + let outside = root.join("keep.txt"); + std::fs::write(&outside, "keep").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, own.join("link")).unwrap(); + + // A forged marker one level up would make `g/` a "tree" whose + // other artifacts are all unindexed. + let forged = format!(".socket/vendor/maven2/g/{}", layout::MARKER_FILE); + let indexed = recorded_tree_files(&entry(vec![ + record(TREE_KIND, &marker), + record(TREE_KIND, &jar), + record(TREE_KIND, &forged), + ])); + assert_eq!(indexed, BTreeSet::from([marker.clone(), jar.clone()])); + let mut want = vec![tree_file(".DS_Store"), tree_file("backup")]; + if cfg!(unix) { + want.push(tree_file("link")); + } + want.sort(); + let stray = unindexed_tree_entries(root, &indexed).unwrap(); + let rels: Vec = stray.iter().map(|s| s.rel.clone()).collect(); + assert_eq!(rels, want); + + sweep_unindexed(&stray).await.unwrap(); + assert!(unindexed_tree_entries(root, &indexed).unwrap().is_empty()); + assert!(root.join(&jar).is_file() && root.join(&marker).is_file()); + assert!(root.join(peer).is_file(), "another tree is never swept"); + assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep"); + sweep_unindexed(&stray) + .await + .expect("already gone is removed"); + } + + /// A tree directory reached through an in-checkout symlink may be + /// another artifact's: its entries are listed (so `--check` still + /// fails) but never swept. + #[cfg(unix)] + #[tokio::test] + async fn sweep_unindexed_refuses_a_linked_tree_dir() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let peer = root.join(".socket/vendor/maven2/g/b/1-socket.1d3c1fd2"); + std::fs::create_dir_all(&peer).unwrap(); + std::fs::write(peer.join("b-1-socket.1d3c1fd2.jar"), "PEER").unwrap(); + let own = root.join(tree_file("x")); + let own = own.parent().unwrap(); + std::fs::create_dir_all(own.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&peer, own).unwrap(); + let indexed = BTreeSet::from([tree_file(layout::MARKER_FILE)]); + let stray = unindexed_tree_entries(root, &indexed).unwrap(); + assert_eq!(stray.len(), 1); + assert!(stray[0].linked); + let err = refuse_linked(&stray).unwrap_err(); + assert!( + err.contains("through a symlink"), + "a dry run refuses too: {err}" + ); + assert_eq!(sweep_unindexed(&stray).await.unwrap_err(), err); + assert!(peer.join("b-1-socket.1d3c1fd2.jar").is_file()); + } } diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 1b7afe0f4..f2e5f11e1 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -1070,7 +1070,40 @@ async fn vendor_maven_jvm( ); let jar_path = project_root.join(&plan.jar_rel); result.package_path = jar_path.display().to_string(); - if plan.writes.is_empty() && (previous.is_none() || registry_verified == prior_verified) { + // Anything else in the tree fails the build and `vendor --check` + // (#656), so it is drift a re-vendor sweeps. + let indexed: std::collections::BTreeSet = plan + .tree_files + .iter() + .map(|(rel, _)| rel.clone()) + .chain(plan.writes.iter().filter(|w| w.tree).map(|w| w.rel.clone())) + .collect(); + // A file the committed ledger still records is not a stray: this sweep + // runs before the commit, so deleting it here would outlive a refused + // or rolled-back write. A patch update's dropped files go after the + // commit, through `sweep_replaced_tree`. + let recorded: std::collections::BTreeSet = state + .iter() + .flat_map(|s| s.entries.values()) + .filter(|e| super::jvm::apply::is_jvm_entry(e)) + .flat_map(super::jvm::apply::recorded_tree_files) + .collect(); + let unindexed = match super::jvm::apply::unindexed_tree_entries(project_root, &indexed) + .map(|found| { + found + .into_iter() + .filter(|s| !recorded.contains(&s.rel)) + .collect::>() + }) + .and_then(|found| super::jvm::apply::refuse_linked(&found).map(|()| found)) + { + Ok(found) => found, + Err(e) => return done(failed_result(purl, &jar_path, e), None, warnings), + }; + if plan.writes.is_empty() + && unindexed.is_empty() + && (previous.is_none() || registry_verified == prior_verified) + { // Notes describe what a vendoring did; an in-sync run did nothing. warnings.retain(|w| w.code != super::jvm::gradle::NOTE); return done( @@ -1086,6 +1119,11 @@ async fn vendor_maven_jvm( warnings, ); } + // Before any wiring is written, so a stray that cannot go leaves + // nothing half-done. + if let Err(e) = super::jvm::apply::sweep_unindexed(&unindexed).await { + return done(failed_result(purl, &jar_path, e), None, warnings); + } let mut wiring = match super::jvm::apply::write_plan(project_root, &plan).await { Ok(records) => records, Err(e) => return done(failed_result(purl, &jar_path, e), None, warnings), @@ -3488,6 +3526,159 @@ mod tests { assert!(detail.contains("patch service request failed"), "{detail}"); } + /// A file the index does not list in the vendored tree (a Finder + /// `.DS_Store`, a stray directory) fails the Gradle build and + /// `vendor --check` (#656), so a re-run is not in sync: a dry run + /// previews the re-vendor and a real one sweeps the strays. + #[tokio::test] + #[serial_test::serial] + async fn jvm_rerun_sweeps_unindexed_tree_files() { + for gradle in [false, true] { + let (dir, blobs, installed, record) = reactor_fixture(true).await; + let root = dir.path(); + let shape = if gradle { + tokio::fs::remove_file(root.join("pom.xml")).await.unwrap(); + tokio::fs::write( + root.join("settings.gradle"), + "rootProject.name = 'fixture'\n", + ) + .await + .unwrap(); + tokio::fs::write(root.join("build.gradle"), "plugins { id 'java' }\nrepositories { mavenCentral() }\ndependencies { implementation 'org.apache.commons:commons-text:1.10.0' }\n").await.unwrap(); + super::super::jvm::Shape::Gradle + } else { + super::super::jvm::Shape::MavenReactor + }; + let sources = PatchSources::blobs_only(&blobs); + let service = crate::vendor::test_support::service_fixture::Fixture::new( + PURL, + installed.as_path().into(), + &record, + &sources, + ) + .await; + let run = |dry_run| { + vendor_maven_jvm( + shape, + PURL, + &installed, + root, + &record, + &sources, + dry_run, + false, + Some(&service.cfg), + ) + }; + let (result, entry, _) = unwrap_done(run(false).await); + assert!(result.success, "gradle={gradle}: {:?}", result.error); + let entry = entry.unwrap(); + crate::vendor::test_support::persist(root, PURL, entry.clone()).await; + let (_, again, _) = unwrap_done(run(false).await); + assert!( + again.is_none(), + "gradle={gradle}: a clean re-run is in sync" + ); + + let tree = root.join(&entry.artifact.path); + let tree = tree.parent().unwrap(); + let ds_store = tree.join(".DS_Store"); + let stray_dir = tree.join("backup"); + tokio::fs::write(&ds_store, b"\0\0\0\x01Bud1") + .await + .unwrap(); + tokio::fs::create_dir_all(&stray_dir).await.unwrap(); + tokio::fs::write(stray_dir.join("old.jar"), b"x") + .await + .unwrap(); + let check = super::super::jvm::apply::check_entry(root, &entry, None).unwrap_err(); + assert!(check.contains("unindexed vendored file"), "{check}"); + + let (preview, none, _) = unwrap_done(run(true).await); + assert!(none.is_none() && preview.success, "gradle={gradle}"); + assert!( + preview + .files_verified + .iter() + .all(|f| f.status == crate::patch::apply::VerifyStatus::Ready), + "gradle={gradle}: the dry run previews a re-vendor: {preview:?}" + ); + assert!( + ds_store.exists() && stray_dir.exists(), + "dry run writes nothing" + ); + + let (result, swept, _) = unwrap_done(run(false).await); + assert!(result.success, "gradle={gradle}: {:?}", result.error); + let swept = swept.expect("the re-run is not in sync"); + assert!(!ds_store.exists(), "gradle={gradle}: .DS_Store swept"); + assert!( + !stray_dir.exists(), + "gradle={gradle}: stray directory swept" + ); + assert!(root.join(&entry.artifact.path).is_file(), "the jar stays"); + crate::vendor::test_support::persist(root, PURL, swept.clone()).await; + super::super::jvm::apply::check_entry(root, &swept, None) + .unwrap_or_else(|e| panic!("gradle={gradle}: {e}")); + let (_, again, _) = unwrap_done(run(false).await); + assert!(again.is_none(), "gradle={gradle}: in sync after the sweep"); + } + } + + /// A tree file the committed ledger records but the new plan drops is + /// not a stray: the pre-commit sweep would delete it even when the + /// write is then refused or rolled back, leaving the restored ledger + /// and index naming a missing file. It goes only after a commit, + /// through `sweep_replaced_tree`. + #[tokio::test] + #[serial_test::serial] + async fn jvm_rerun_keeps_tree_files_the_ledger_records() { + let (dir, blobs, installed, record) = reactor_fixture(true).await; + let root = dir.path(); + let (result, entry, _) = + unwrap_done(run_jvm(root, &blobs, &installed, &record, None).await); + assert!(result.success, "{result:?}"); + let mut entry = entry.unwrap(); + let tree = std::path::Path::new(&entry.artifact.path) + .parent() + .unwrap() + .to_string_lossy() + .into_owned(); + let name = entry + .artifact + .path + .rsplit('/') + .next() + .unwrap() + .replace(".jar", "-dropped.jar"); + let dropped = format!("{tree}/{name}"); + tokio::fs::write(root.join(&dropped), b"OLD CLASSIFIER") + .await + .unwrap(); + entry.wiring.push(WiringRecord { + file: dropped.clone(), + kind: super::super::jvm::TREE_KIND.into(), + action: WiringAction::Added, + key: None, + original: None, + new: Some(serde_json::Value::String(super::super::jvm::sha256_hex( + b"OLD CLASSIFIER", + ))), + }); + crate::vendor::test_support::persist(root, PURL, entry).await; + let (result, again, _) = + unwrap_done(run_jvm(root, &blobs, &installed, &record, None).await); + assert!(result.success && again.is_none(), "in sync: {result:?}"); + assert!(root.join(&dropped).is_file(), "a recorded file is kept"); + // A file nothing records is still swept. + tokio::fs::write(root.join(&tree).join(".DS_Store"), b"x") + .await + .unwrap(); + let (_, swept, _) = unwrap_done(run_jvm(root, &blobs, &installed, &record, None).await); + assert!(swept.is_some() && !root.join(&tree).join(".DS_Store").exists()); + assert!(root.join(&dropped).is_file(), "still kept"); + } + #[tokio::test] #[serial_test::serial] async fn jvm_revert_honours_keep_artifact_and_restores_the_poms() { diff --git a/docs/design/maven-vendoring.md b/docs/design/maven-vendoring.md index 432789057..748d32231 100644 --- a/docs/design/maven-vendoring.md +++ b/docs/design/maven-vendoring.md @@ -51,6 +51,11 @@ with `vendor_ledger_missing`. Offline upstream metadata is identified by the `vendor_jvm_upstream_unverified` warning; run vendor online to authenticate it against registry checksums. +A vendored tree directory holds only the files its index lists. `vendor` and +`repair` delete anything else found there (a Finder `.DS_Store`, an editor +backup, a stray directory), since the Gradle script and `vendor --check` +reject it (#656); `vendor --dry-run` previews that as a re-vendor. + ## Maven Supported reactors have an explicit root `pom.xml` and `` or