From dc8f008c5fe9478d01951a314e8d4567ed2a6303 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:02:11 +0000 Subject: [PATCH 1/2] Start refactor for #989 Assisted-by: Claude Code:claude-opus-5-5 From ecd977b7d952effc19628c1c95053c64d515d8bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:21:43 +0000 Subject: [PATCH 2/2] Finish vendored reverts through one shared step Every vendored backend copied the same revert finish sequence: return on a dry run, keep the artifact when a record drifted, return under --preserve-state, then delete the uuid dir and prune empty vendor levels. vendor::revert::finish now owns that sequence, and each backend names its keep rule as a KeepPolicy (OnDrift, OnDriftWhileReferenced, NpmFamily) instead of re-deciding it. gem, composer, Maven legacy, NuGet and pnpm move onto the helper and their copies are deleted. Revert output, warnings, error messages and exit codes are unchanged. Refs #989 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/vendor/composer_lock.rs | 38 +- crates/socket-patch-core/src/vendor/gem.rs | 41 +- .../src/vendor/maven_repo.rs | 41 +- crates/socket-patch-core/src/vendor/mod.rs | 1 + .../src/vendor/nuget_feed.rs | 41 +- .../socket-patch-core/src/vendor/pnpm_lock.rs | 38 +- crates/socket-patch-core/src/vendor/revert.rs | 361 ++++++++++++++++++ 7 files changed, 404 insertions(+), 157 deletions(-) create mode 100644 crates/socket-patch-core/src/vendor/revert.rs diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index c491291e1..29a845cd9 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -44,7 +44,6 @@ use std::sync::Arc; use serde_json::{json, Map, Value}; -use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; @@ -53,16 +52,16 @@ use crate::utils::composer_version::composer_versions_equivalent; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::line_endings::LineEndings; use crate::utils::purl::{build_composer_purl, parse_composer_purl}; -use crate::utils::socket_dir::remove_tree_and_prune; use super::common::{ - already_patched_result, any_live_file_references, copy_matches_after_hashes, done, - inventory_or_warn, prune_empty_vendor_levels, refused, serialize_json, - service_offline_conflict, stage_dir_for, swap_stage_into_place, synthesized_result, + already_patched_result, copy_matches_after_hashes, done, inventory_or_warn, + prune_empty_vendor_levels, refused, serialize_json, service_offline_conflict, stage_dir_for, + swap_stage_into_place, synthesized_result, }; use super::parse_memo::ParseMemo; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::{extract_on_blocking_pool, extract_zip}; +use super::revert::{self, KeepPolicy}; use super::service_fetch::{ claim_prestaged, fetch_verified_archive, ServiceAttempt, ServicePolicy, ServiceTerminal, }; @@ -528,7 +527,6 @@ pub async fn revert_composer_opts( entry.uuid )); }; - let uuid_dir = project_root.join(&uuid_dir_rel); let lock_path = project_root.join(COMPOSER_LOCK); let mut warnings = Vec::new(); @@ -584,32 +582,18 @@ pub async fn revert_composer_opts( } } - let mut outcome = RevertOutcome { + let outcome = RevertOutcome { kept_artifact: false, success: true, warnings, error: None, }; - if !dry_run { - if outcome.drift_skipped() - && any_live_file_references(project_root, &[COMPOSER_LOCK], &uuid_dir_rel).await - { - // Drift-keep (see the fn doc): never delete a uuid dir the live - // lock still routes composer at. - outcome.keep_artifact(&uuid_dir_rel); - } else if !keep_artifact { - // `--preserve-state` (`keep_artifact`) skips only this deletion: - // the artifact dir stays behind and the caller keeps the ledger - // entry. The last composer entry leaves `.socket/vendor/composer/` - // (and `.socket/vendor/`) empty: the shared helper prunes them so - // a reverted project carries no vendor residue (non-recursive: - // siblings keep them). - if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { - outcome.success = false; - outcome.error = Some(format!("failed to remove {}: {e}", uuid_dir.display())); - return outcome; - } - } + // Drift-keep (see the fn doc): never delete a uuid dir the live lock + // still routes composer at. + let policy = KeepPolicy::OnDriftWhileReferenced(&[COMPOSER_LOCK]); + let mut outcome = revert::finish(outcome, project_root, &uuid_dir_rel, opts, policy).await; + if outcome.error.is_some() { + return outcome; } outcome.warnings.push(VendorWarning::new( diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index d4cd6becc..829a1c8de 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -55,7 +55,6 @@ use std::path::{Path, PathBuf}; use serde_json::Value; -use crate::constants::SOCKET_DIR; use crate::formats::gem::gemfile; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; @@ -64,7 +63,6 @@ use crate::patch::path_safety::is_safe_single_segment; use crate::patch::redirect::gem_line_tail_blocks_edit; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::purl::{build_gem_purl, parse_gem_purl, purl_qualifier}; -use crate::utils::socket_dir::remove_tree_and_prune; use super::common::{ already_patched_result, copy_matches_after_hashes, done, failed_result, inventory_or_warn, @@ -73,6 +71,7 @@ use super::common::{ }; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::{extract_gem_data, extract_on_blocking_pool}; +use super::revert::{self, KeepPolicy}; use super::service_fetch::{ claim_prestaged, fetch_verified_archive, fetch_verified_secondary, SecondaryArtifactResult, ServiceAttempt, ServicePolicy, ServiceTerminal, @@ -1207,10 +1206,7 @@ pub async fn revert_gem_opts( project_root: &Path, opts: RevertOpts, ) -> RevertOutcome { - let RevertOpts { - dry_run, - keep_artifact, - } = opts; + let dry_run = opts.dry_run; // SECURITY: state.json is committed and tamper-able; the uuid keys the // directory we are about to delete. Anything but the canonical uuid // grammar is rejected fail-closed before any disk access. @@ -1220,7 +1216,6 @@ pub async fn revert_gem_opts( entry.uuid )); }; - let uuid_dir = project_root.join(&uuid_dir_rel); let mut warnings = Vec::new(); // Fail-closed guard: an entry with NO wiring records (one an older @@ -1297,36 +1292,22 @@ pub async fn revert_gem_opts( } } - let mut outcome = RevertOutcome { + let outcome = RevertOutcome { kept_artifact: false, success: true, warnings, error: None, }; - if dry_run { - return outcome; - } // Drift-keep (see the fn doc): never delete a copy dir a left-alone // record may still reference. - if outcome.drift_skipped() { - outcome.keep_artifact(&uuid_dir_rel); - return outcome; - } - // `--preserve-state` (`keep_artifact`): the artifact dir stays behind - // (and the caller keeps the ledger entry), so only the deletion is - // skipped. - if keep_artifact { - return outcome; - } - // The last gem entry leaves `.socket/vendor/gem/` (and `.socket/vendor/`) - // empty: the shared helper prunes them so a reverted project carries no - // vendor residue (non-recursive: siblings keep them). - if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { - outcome.success = false; - outcome.error = Some(format!("failed to remove {}: {e}", uuid_dir.display())); - return outcome; - } - outcome + revert::finish( + outcome, + project_root, + &uuid_dir_rel, + opts, + KeepPolicy::OnDrift, + ) + .await } // ── Gemfile editing ────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 001d57c9e..31df8839e 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -16,7 +16,6 @@ use serde_json::Value; use sha1::Sha1; use sha2::{Digest as _, Sha256}; -use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::utils::fs::{ @@ -24,14 +23,14 @@ use crate::utils::fs::{ read_regular_to_string, }; use crate::utils::purl::{build_maven_purl, parse_maven_purl}; -use crate::utils::socket_dir::remove_tree_and_prune; use crate::vendor::jvm::layout; use super::common::{ - already_patched_result, any_live_file_references, done, failed_result, refused, - synthesized_result, zip_bytes_match_after_hashes, + already_patched_result, done, failed_result, refused, synthesized_result, + zip_bytes_match_after_hashes, }; use super::path::vendor_uuid_dir_rel; +use super::revert::{self, KeepPolicy}; use super::service_fetch::{service_archive_copy, ServiceCopy}; use super::state::{VendorArtifact, VendorEntry, WiringAction, WiringRecord}; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -222,10 +221,7 @@ pub async fn revert_maven_opts( project_root: &Path, opts: RevertOpts, ) -> RevertOutcome { - let RevertOpts { - dry_run, - keep_artifact, - } = opts; + let dry_run = opts.dry_run; // Routed only when EVERY record is a JVM kind; the JVM revert validates // the uuid, the coordinates and each recorded path before any disk // access (state.json is tamper-able). @@ -241,7 +237,6 @@ pub async fn revert_maven_opts( entry.uuid )); }; - let uuid_dir = project_root.join(&uuid_dir_rel); let mut warnings = Vec::new(); // One wiring record today; reverse-order iteration keeps parity with the @@ -280,38 +275,16 @@ pub async fn revert_maven_opts( } } - let mut outcome = RevertOutcome { + let outcome = RevertOutcome { kept_artifact: false, success: true, warnings, error: None, }; - if dry_run { - return outcome; - } // Drift-keep (see the fn doc): never delete a uuid dir the live pom // still routes Maven at. - if outcome.drift_skipped() - && any_live_file_references(project_root, &[PROJECT_POM], &uuid_dir_rel).await - { - outcome.keep_artifact(&uuid_dir_rel); - return outcome; - } - // `--preserve-state` (`keep_artifact`): the artifact dir stays behind - // (and the caller keeps the ledger entry), so only the deletion is - // skipped. - if keep_artifact { - return outcome; - } - // The last maven entry leaves `.socket/vendor/maven/` (and - // `.socket/vendor/`) empty: the shared helper prunes them so a - // reverted project carries no vendor residue (non-recursive: - // siblings keep them). - if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { - outcome.success = false; - outcome.error = Some(format!("failed to remove {}: {e}", uuid_dir.display())); - } - outcome + let policy = KeepPolicy::OnDriftWhileReferenced(&[PROJECT_POM]); + revert::finish(outcome, project_root, &uuid_dir_rel, opts, policy).await } // ── v5 JVM backend ───────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index fed4ed7c9..8619aa3b1 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -95,6 +95,7 @@ mod pypi_wheel; pub mod redownload; pub mod registry_fetch; pub(crate) mod reuse; +pub(crate) mod revert; pub(crate) mod service_fetch; pub mod source; #[cfg(any(test, feature = "test-fixtures"))] diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index e4dc49eb6..70d6be479 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use crate::utils::digest::sha512_base64_of; use serde_json::Value; -use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::remove_tree; @@ -13,14 +12,14 @@ use crate::utils::fs::{ atomic_write_artifact, atomic_write_bytes_preserving_mode, read_regular_to_string, }; use crate::utils::purl::{build_nuget_purl, parse_nuget_purl}; -use crate::utils::socket_dir::remove_tree_and_prune; use super::common::{ - already_patched_result, any_live_file_references, done, prune_empty_vendor_levels, - read_zip_artifact, refused, synthesized_result, zip_bytes_match_after_hashes, + already_patched_result, done, prune_empty_vendor_levels, read_zip_artifact, refused, + synthesized_result, zip_bytes_match_after_hashes, }; use super::parse_memo::ParseMemo; use super::path::vendor_uuid_dir_rel; +use super::revert::{self, KeepPolicy}; use super::service_fetch::{service_archive_copy, ServiceCopy}; use super::state::{ write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, @@ -708,10 +707,7 @@ pub async fn revert_nuget_opts( project_root: &Path, opts: RevertOpts, ) -> RevertOutcome { - let RevertOpts { - dry_run, - keep_artifact, - } = opts; + let dry_run = opts.dry_run; // SECURITY: state.json is committed and tamper-able; the uuid keys the // directory we are about to delete. Anything but the canonical uuid // grammar is rejected fail-closed before any disk access. @@ -721,7 +717,6 @@ pub async fn revert_nuget_opts( entry.uuid )); }; - let uuid_dir = project_root.join(&uuid_dir_rel); let mut warnings = Vec::new(); // Reverse application order: lock pin, then the (no-op) mapping audit @@ -766,15 +761,12 @@ pub async fn revert_nuget_opts( } } - let mut outcome = RevertOutcome { + let outcome = RevertOutcome { kept_artifact: false, success: true, warnings, error: None, }; - if dry_run { - return outcome; - } // Drift-keep (see the fn doc): never delete a uuid dir a live wiring // file still routes NuGet at. Only the root-level basenames vendor // records are probed (a tampered `../` path is never read). @@ -786,27 +778,8 @@ pub async fn revert_nuget_opts( .collect(); wired_files.sort_unstable(); wired_files.dedup(); - if outcome.drift_skipped() - && any_live_file_references(project_root, &wired_files, &uuid_dir_rel).await - { - outcome.keep_artifact(&uuid_dir_rel); - return outcome; - } - // `--preserve-state` (`keep_artifact`): the artifact dir stays behind - // (and the caller keeps the ledger entry), so only the deletion is - // skipped. - if keep_artifact { - return outcome; - } - // The last nuget entry leaves `.socket/vendor/nuget/` (and - // `.socket/vendor/`) empty: the shared helper prunes them so a - // reverted project carries no vendor residue (non-recursive: - // siblings keep them). - if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { - outcome.success = false; - outcome.error = Some(format!("failed to remove {}: {e}", uuid_dir.display())); - } - outcome + let policy = KeepPolicy::OnDriftWhileReferenced(&wired_files); + revert::finish(outcome, project_root, &uuid_dir_rel, opts, policy).await } // ── materialisation (service download) ───────────────────────── diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 72138f2f8..c843ae50e 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -55,13 +55,11 @@ use std::sync::{Arc, OnceLock}; use serde_json::Value; -use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::utils::fs::{ atomic_write_bytes_preserving_mode, read_regular_to_bytes, read_regular_to_string, }; -use crate::utils::socket_dir::remove_tree_and_prune; use super::common::{parse_json_manifest, refused, JsonLayout}; use super::npm_common::{ @@ -70,6 +68,7 @@ use super::npm_common::{ }; use super::parse_memo::ParseMemo; use super::path::parse_vendor_path; +use super::revert::{self, KeepPolicy}; use super::source::PackageSource; use super::state::{PnpmMeta, VendorEntry, WiringAction, WiringRecord}; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; @@ -1128,36 +1127,11 @@ pub(super) async fn revert_pnpm_dialect( // or the redirect ledger's recorded originals — still points at. Keep // it (and let the CLI keep the ledger entry) instead of deleting evidence // out from under a lock we just refused to touch. - if outcome.drift_skipped() { - outcome.keep_artifact(&uuid_dir_rel); - return outcome; - } - - // `--preserve-state` (`keep_artifact`): the wiring restore above already - // ran; the artifact dir stays behind (and the caller keeps the ledger - // entry), so only the deletion is skipped. - if !keep_artifact { - if super::npm_flavor::keep_artifact_while_lock_references_it( - &mut outcome, - project_root, - &[PNPM_LOCK, PACKAGE_JSON, PNPM_WORKSPACE], - &entry.uuid, - &uuid_dir_rel, - ) - .await - { - return outcome; - } - // The last npm-family entry leaves `.socket/vendor/npm/` (and - // `.socket/vendor/`) empty: the shared helper prunes them so a - // reverted project carries no vendor residue (non-recursive: - // siblings keep them). - let uuid_dir = project_root.join(&uuid_dir_rel); - if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { - return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); - } - } - outcome + let policy = KeepPolicy::NpmFamily { + locks: &[PNPM_LOCK, PACKAGE_JSON, PNPM_WORKSPACE], + uuid: &entry.uuid, + }; + revert::finish(outcome, project_root, &uuid_dir_rel, opts, policy).await } /// Undo the pnpm-workspace.yaml override: delete a file we created (when it diff --git a/crates/socket-patch-core/src/vendor/revert.rs b/crates/socket-patch-core/src/vendor/revert.rs new file mode 100644 index 000000000..dd6c3de8a --- /dev/null +++ b/crates/socket-patch-core/src/vendor/revert.rs @@ -0,0 +1,361 @@ +//! The finish step every vendored `revert_*` runs once its wiring is +//! restored: the dry-run return, the drift-keep, the `--preserve-state` +//! return and the artifact deletion, in that order. Each backend names its +//! keep rule as a [`KeepPolicy`] instead of re-writing the sequence (#989). + +use std::path::Path; + +use crate::constants::SOCKET_DIR; +use crate::utils::socket_dir::remove_tree_and_prune; + +use super::common::any_live_file_references; +use super::npm_flavor::keep_artifact_while_lock_references_it; +use super::{RevertOpts, RevertOutcome}; + +/// When a revert keeps the artifact instead of deleting it. `CLI_CONTRACT.md` +/// documents the rule each backend uses. +#[derive(Debug, Clone, Copy)] +pub(crate) enum KeepPolicy<'a> { + /// Any left-alone record (`vendor_lock_entry_drifted`) keeps the + /// artifact (gem). + OnDrift, + /// A left-alone record keeps the artifact only while one of these + /// root-relative files still names the uuid dir (composer, Maven, + /// NuGet): a converged file never does, so the keep cannot outlive the + /// drift. See [`any_live_file_references`]. + OnDriftWhileReferenced(&'a [&'a str]), + /// The npm family (pnpm, bun): any left-alone record keeps the + /// artifact, and a vanished lock entry keeps it while one of `locks` + /// may still resolve through `uuid` (#665). A failed deletion fails + /// the whole revert with the relative dir in the message. + NpmFamily { locks: &'a [&'a str], uuid: &'a str }, +} + +/// Finish a revert whose wiring restore produced `outcome`: return it +/// untouched on a dry run, keep the artifact per `policy` when a record +/// was left alone, skip only the deletion under `--preserve-state`, and +/// otherwise remove `uuid_dir_rel` and prune the vendor levels it empties +/// (non-recursive: siblings keep them). +pub(crate) async fn finish( + mut outcome: RevertOutcome, + project_root: &Path, + uuid_dir_rel: &str, + opts: RevertOpts, + policy: KeepPolicy<'_>, +) -> RevertOutcome { + if opts.dry_run { + return outcome; + } + let drift_keeps = match policy { + KeepPolicy::OnDrift | KeepPolicy::NpmFamily { .. } => outcome.drift_skipped(), + KeepPolicy::OnDriftWhileReferenced(files) => { + outcome.drift_skipped() + && any_live_file_references(project_root, files, uuid_dir_rel).await + } + }; + if drift_keeps { + outcome.keep_artifact(uuid_dir_rel); + return outcome; + } + // `--preserve-state`: the artifact dir stays behind and the caller + // keeps the ledger entry, so only the deletion is skipped. + if opts.keep_artifact { + return outcome; + } + if let KeepPolicy::NpmFamily { locks, uuid } = policy { + if keep_artifact_while_lock_references_it( + &mut outcome, + project_root, + locks, + uuid, + uuid_dir_rel, + ) + .await + { + return outcome; + } + } + let uuid_dir = project_root.join(uuid_dir_rel); + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { + if let KeepPolicy::NpmFamily { .. } = policy { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } + outcome.success = false; + outcome.error = Some(format!("failed to remove {}: {e}", uuid_dir.display())); + } + outcome +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::{VendorWarning, LOCK_ENTRY_REMOVED_CODE}; + + const UUID: &str = "11111111-2222-4333-8444-555555555555"; + + fn rel(eco: &str) -> String { + format!(".socket/vendor/{eco}/{UUID}") + } + + /// A project with a vendored artifact under `.socket/vendor//`. + fn project(eco: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let uuid_dir = dir.path().join(rel(eco)); + std::fs::create_dir_all(&uuid_dir).unwrap(); + std::fs::write(uuid_dir.join("artifact"), b"patched").unwrap(); + dir + } + + fn with(code: &'static str) -> RevertOutcome { + let mut outcome = RevertOutcome::ok(); + outcome + .warnings + .push(VendorWarning::new(code, "left alone".to_string())); + outcome + } + + fn drifted() -> RevertOutcome { + with("vendor_lock_entry_drifted") + } + + fn wet() -> RevertOpts { + RevertOpts::new(false) + } + + fn preserve() -> RevertOpts { + RevertOpts { + dry_run: false, + keep_artifact: true, + } + } + + const LOCKS: &[&str] = &["pnpm-lock.yaml"]; + + /// Every policy a former copy used: gem, composer/Maven/Nuget and the + /// npm family (pnpm). + fn policies() -> [KeepPolicy<'static>; 3] { + [ + KeepPolicy::OnDrift, + KeepPolicy::OnDriftWhileReferenced(&["composer.lock"]), + KeepPolicy::NpmFamily { + locks: LOCKS, + uuid: UUID, + }, + ] + } + + #[tokio::test] + async fn a_dry_run_returns_the_outcome_untouched_under_every_policy() { + for policy in policies() { + let dir = project("gem"); + let outcome = finish( + drifted(), + dir.path(), + &rel("gem"), + RevertOpts::new(true), + policy, + ) + .await; + assert!(outcome.success && !outcome.kept_artifact, "{policy:?}"); + assert_eq!(outcome.warnings.len(), 1, "{policy:?}"); + assert!(dir.path().join(rel("gem")).is_dir(), "{policy:?}"); + } + } + + #[tokio::test] + async fn a_clean_revert_removes_the_artifact_and_prunes_empty_levels() { + for policy in policies() { + let dir = project("gem"); + let outcome = finish(RevertOutcome::ok(), dir.path(), &rel("gem"), wet(), policy).await; + assert!(outcome.success && !outcome.kept_artifact, "{policy:?}"); + assert!(outcome.warnings.is_empty(), "{policy:?}"); + assert!(!dir.path().join(".socket/vendor").exists(), "{policy:?}"); + } + } + + #[tokio::test] + async fn preserve_state_skips_only_the_deletion() { + for policy in policies() { + let dir = project("gem"); + let outcome = finish( + RevertOutcome::ok(), + dir.path(), + &rel("gem"), + preserve(), + policy, + ) + .await; + assert!(outcome.success && !outcome.kept_artifact, "{policy:?}"); + assert!(outcome.warnings.is_empty(), "{policy:?}"); + assert!(dir.path().join(rel("gem")).is_dir(), "{policy:?}"); + } + } + + #[tokio::test] + async fn drift_keeps_unconditionally_for_gem_and_the_npm_family() { + for policy in [policies()[0], policies()[2]] { + for opts in [wet(), preserve()] { + let dir = project("gem"); + let outcome = finish(drifted(), dir.path(), &rel("gem"), opts, policy).await; + assert!(outcome.success && outcome.kept_artifact, "{policy:?}"); + assert!( + outcome + .warnings + .iter() + .any(|w| w.code == "vendor_artifact_kept"), + "{policy:?}" + ); + assert!(dir.path().join(rel("gem")).is_dir(), "{policy:?}"); + } + } + } + + #[tokio::test] + async fn drift_keeps_only_while_a_live_file_names_the_uuid_dir() { + let policy = KeepPolicy::OnDriftWhileReferenced(&["composer.lock", "pom.xml"]); + + let dir = project("composer"); + std::fs::write(dir.path().join("composer.lock"), "{}").unwrap(); + std::fs::write( + dir.path().join("pom.xml"), + format!("{}", rel("composer")), + ) + .unwrap(); + let outcome = finish(drifted(), dir.path(), &rel("composer"), wet(), policy).await; + assert!(outcome.success && outcome.kept_artifact); + assert!(dir.path().join(rel("composer")).is_dir()); + + // A converged file (no reference left) lets the drift-skipped + // revert delete the artifact. + let dir = project("composer"); + std::fs::write(dir.path().join("composer.lock"), "{}").unwrap(); + let outcome = finish(drifted(), dir.path(), &rel("composer"), wet(), policy).await; + assert!(outcome.success && !outcome.kept_artifact); + assert!(!dir.path().join(rel("composer")).exists()); + assert_eq!(outcome.warnings.len(), 1, "the drift warning is kept"); + } + + #[tokio::test] + async fn npm_family_keeps_a_removed_entry_while_a_lock_names_the_uuid() { + let policy = policies()[2]; + let dir = project("npm"); + std::fs::write( + dir.path().join("pnpm-lock.yaml"), + format!("x: file:{}/pkg.tgz\n", rel("npm")), + ) + .unwrap(); + let outcome = finish( + with(LOCK_ENTRY_REMOVED_CODE), + dir.path(), + &rel("npm"), + wet(), + policy, + ) + .await; + assert!(outcome.success && outcome.kept_artifact); + assert!(dir.path().join(rel("npm")).is_dir()); + + // Proven unreferenced: the artifact goes. + std::fs::write(dir.path().join("pnpm-lock.yaml"), "x: 1.0.0\n").unwrap(); + let outcome = finish( + with(LOCK_ENTRY_REMOVED_CODE), + dir.path(), + &rel("npm"), + wet(), + policy, + ) + .await; + assert!(outcome.success && !outcome.kept_artifact); + assert!(!dir.path().join(rel("npm")).exists()); + + // The other policies never consult the locks for a removed entry. + for policy in [policies()[0], policies()[1]] { + let dir = project("npm"); + std::fs::write( + dir.path().join("pnpm-lock.yaml"), + format!("x: file:{}/pkg.tgz\n", rel("npm")), + ) + .unwrap(); + let outcome = finish( + with(LOCK_ENTRY_REMOVED_CODE), + dir.path(), + &rel("npm"), + wet(), + policy, + ) + .await; + assert!(outcome.success && !outcome.kept_artifact, "{policy:?}"); + assert!(!dir.path().join(rel("npm")).exists(), "{policy:?}"); + } + } + + /// A symlinked vendor level makes the containment guard refuse the + /// delete (works as root, unlike a read-only parent). + #[cfg(unix)] + fn undeletable(eco: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let elsewhere = dir.path().join("elsewhere"); + std::fs::create_dir_all(elsewhere.join(UUID)).unwrap(); + std::fs::create_dir_all(dir.path().join(".socket/vendor")).unwrap(); + std::os::unix::fs::symlink(&elsewhere, dir.path().join(format!(".socket/vendor/{eco}"))) + .unwrap(); + dir + } + + #[cfg(unix)] + #[tokio::test] + async fn a_failed_deletion_keeps_the_warnings_outside_the_npm_family() { + for policy in [policies()[0], policies()[1]] { + let dir = undeletable("gem"); + let outcome = finish( + with(LOCK_ENTRY_REMOVED_CODE), + dir.path(), + &rel("gem"), + wet(), + policy, + ) + .await; + assert!(!outcome.success && !outcome.kept_artifact, "{policy:?}"); + let expected = format!( + "failed to remove {}: ", + dir.path().join(rel("gem")).display() + ); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.starts_with(&expected)), + "{policy:?}: {:?}", + outcome.error + ); + assert_eq!(outcome.warnings.len(), 1, "{policy:?}"); + assert!(dir.path().join("elsewhere").join(UUID).is_dir()); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn a_failed_deletion_fails_bare_in_the_npm_family() { + let dir = undeletable("npm"); + let outcome = finish( + RevertOutcome::ok(), + dir.path(), + &rel("npm"), + wet(), + policies()[2], + ) + .await; + assert!(!outcome.success && !outcome.kept_artifact); + let expected = format!("cannot remove {}: ", rel("npm")); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.starts_with(&expected)), + "{:?}", + outcome.error + ); + assert!(outcome.warnings.is_empty()); + assert!(dir.path().join("elsewhere").join(UUID).is_dir()); + } +}