From b85b2bb806e9dc045fb8e7c8c9965c87d9f1b473 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 01:10:11 +0900 Subject: [PATCH 1/2] test(security): require Maven prune apply to fail closed --- .../tests/maven_cache_mutation_authority.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src-tauri/tests/maven_cache_mutation_authority.rs diff --git a/src-tauri/tests/maven_cache_mutation_authority.rs b/src-tauri/tests/maven_cache_mutation_authority.rs new file mode 100644 index 000000000..eb69dce09 --- /dev/null +++ b/src-tauri/tests/maven_cache_mutation_authority.rs @@ -0,0 +1,47 @@ +use disksage_lib::maven_cache::{ + audit_maven_repository, prune_maven_repository, MavenCacheAuditOptions, +}; +use std::fs; + +#[test] +fn apply_fails_closed_without_identity_bound_reversible_recycle() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("repository"); + let version = root.join("org/example/demo/1.0.0"); + fs::create_dir_all(&version).unwrap(); + let jar = version.join("demo-1.0.0.jar"); + let pom = version.join("demo-1.0.0.pom"); + let marker = version.join("_remote.repositories"); + fs::write(&jar, b"remote jar bytes").unwrap(); + fs::write(&pom, b"").unwrap(); + fs::write( + &marker, + "demo-1.0.0.jar>central=\ndemo-1.0.0.pom>central=\n", + ) + .unwrap(); + + let original_jar = fs::read(&jar).unwrap(); + let original_pom = fs::read(&pom).unwrap(); + let original_marker = fs::read(&marker).unwrap(); + let audit = + audit_maven_repository(&root, MavenCacheAuditOptions::default(), 123).unwrap(); + assert_eq!(audit.remote_recoverable_directories, 1); + + let error = prune_maven_repository( + &root, + &audit.candidate_set_fingerprint, + true, + 10_000, + 456, + ) + .unwrap_err(); + + assert_eq!( + error, + "maven-cache-prune-identity-bound-recycle-unavailable" + ); + assert!(version.is_dir()); + assert_eq!(fs::read(&jar).unwrap(), original_jar); + assert_eq!(fs::read(&pom).unwrap(), original_pom); + assert_eq!(fs::read(&marker).unwrap(), original_marker); +} From 91262b2cebb7d351d75da71e8eabfce8639069b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 01:19:59 +0900 Subject: [PATCH 2/2] fix(security): fail closed Maven prune apply --- src-tauri/src/maven_cache.rs | 93 +++++++----------------------------- 1 file changed, 18 insertions(+), 75 deletions(-) diff --git a/src-tauri/src/maven_cache.rs b/src-tauri/src/maven_cache.rs index f31242abf..e8a9b1b61 100644 --- a/src-tauri/src/maven_cache.rs +++ b/src-tauri/src/maven_cache.rs @@ -542,18 +542,6 @@ fn valid_candidate_set_fingerprint(value: &str) -> bool { .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) } -fn candidate_path(root: &Path, relative: &str) -> Result { - let relative = Path::new(relative); - if relative.components().next().is_none() - || relative - .components() - .any(|component| !matches!(component, Component::Normal(_))) - { - return Err("maven-cache-prune-relative-path-invalid".into()); - } - Ok(root.join(relative)) -} - pub fn prune_maven_repository( root: &Path, expected_candidate_set_fingerprint: &str, @@ -583,70 +571,26 @@ pub fn prune_maven_repository( if audit.candidate_set_fingerprint != expected_candidate_set_fingerprint { return Err("maven-cache-prune-candidate-set-mismatch".into()); } + if apply { + return Err("maven-cache-prune-identity-bound-recycle-unavailable".into()); + } - let candidate_directories = audit.remote_recoverable_directories; - let candidate_bytes = audit.remote_recoverable_bytes; - let mut report = MavenCachePruneReport { + Ok(MavenCachePruneReport { schema_kind: "disksage.maven-cache-prune/v1".into(), - repository_root: audit.repository_root.clone(), + repository_root: audit.repository_root, generated_at_ms, expected_candidate_set_fingerprint: expected_candidate_set_fingerprint.into(), observed_candidate_set_fingerprint: audit.candidate_set_fingerprint, - candidate_directories, - candidate_bytes, + candidate_directories: audit.remote_recoverable_directories, + candidate_bytes: audit.remote_recoverable_bytes, removed_directories: 0, removed_bytes: 0, skipped_directories: 0, skip_reason_counts: BTreeMap::new(), - apply_requested: apply, + apply_requested: false, filesystem_mutation_executed: false, - complete: !apply, - }; - if !apply { - return Ok(report); - } - - let canonical_root = - fs::canonicalize(root).map_err(|_| "maven-cache-prune-root-unavailable".to_string())?; - if canonical_root.to_string_lossy() != audit.repository_root { - return Err("maven-cache-prune-root-changed".into()); - } - - for candidate in audit.candidates { - let path = candidate_path(&canonical_root, &candidate.relative_path)?; - let current = audit_marker_directory(&canonical_root, &path).candidate; - let unchanged = current.as_ref().is_some_and(|value| { - value.relative_path == candidate.relative_path - && value.bytes == candidate.bytes - && value.candidate_fingerprint == candidate.candidate_fingerprint - }); - if !unchanged { - report.skipped_directories = report.skipped_directories.saturating_add(1); - *report - .skip_reason_counts - .entry("candidate-changed-before-remove".into()) - .or_insert(0) += 1; - continue; - } - match fs::remove_dir_all(&path) { - Ok(()) => { - report.removed_directories = report.removed_directories.saturating_add(1); - report.removed_bytes = report.removed_bytes.saturating_add(candidate.bytes); - report.filesystem_mutation_executed = true; - } - Err(_) => { - report.skipped_directories = report.skipped_directories.saturating_add(1); - *report - .skip_reason_counts - .entry("candidate-remove-failed".into()) - .or_insert(0) += 1; - } - } - } - report.complete = report.removed_directories == candidate_directories - && report.removed_bytes == candidate_bytes - && report.skipped_directories == 0; - Ok(report) + complete: true, + }) } #[cfg(test)] @@ -823,7 +767,7 @@ mod tests { } #[test] - fn prune_apply_removes_only_revalidated_remote_candidates() { + fn prune_apply_fails_closed_without_identity_bound_recycle() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("repository"); let remote = version_dir(&root, "org/example/remote/1.0.0"); @@ -833,16 +777,15 @@ mod tests { fs::write(local.join("_remote.repositories"), "local-1.0.0.jar>=\n").unwrap(); let audit = audit_maven_repository(&root, MavenCacheAuditOptions::default(), 123).unwrap(); - let report = + let error = prune_maven_repository(&root, &audit.candidate_set_fingerprint, true, 10_000, 456) - .unwrap(); + .unwrap_err(); - assert_eq!(report.candidate_directories, 1); - assert_eq!(report.removed_directories, 1); - assert_eq!(report.skipped_directories, 0); - assert!(report.filesystem_mutation_executed); - assert!(report.complete); - assert!(!remote.exists()); + assert_eq!( + error, + "maven-cache-prune-identity-bound-recycle-unavailable" + ); + assert!(remote.exists()); assert!(local.exists()); } }