From ef2a01bc03e42ec3112b47b7718dd2b35ede2ea7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 20:22:23 +0900 Subject: [PATCH 1/5] test(security): require cloud eviction fail closed without object-bound recycle --- src-tauri/tests/cloud_eviction_fail_closed.rs | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src-tauri/tests/cloud_eviction_fail_closed.rs diff --git a/src-tauri/tests/cloud_eviction_fail_closed.rs b/src-tauri/tests/cloud_eviction_fail_closed.rs new file mode 100644 index 000000000..3828cde38 --- /dev/null +++ b/src-tauri/tests/cloud_eviction_fail_closed.rs @@ -0,0 +1,145 @@ +use disksage::cloud::{ + candidate_review_fingerprint, ArchiveKind, CloudAccountScope, CloudCandidate, CloudProvider, + CloudRoot, MetadataEvidence, +}; +use disksage::cloud_eviction::{ + create_source_eviction_approval, evict_source_with_human_approval, +}; +use disksage::cloud_local_eviction::ActiveUseEvidence; +use disksage::cloud_transfer::{ + approve_local_eviction, prepare_cloud_copy, ProviderSyncEvidence, SyncEvidenceKind, +}; +use disksage::provider_evidence::create_sync_evidence_record; +use std::path::Path; + +fn valid_receipt( + temp: &tempfile::TempDir, +) -> ( + disksage::cloud_transfer::CloudCopyReceipt, + disksage::cloud_transfer::LocalEvictionPermit, +) { + let source_dir = temp.path().join("source"); + let cloud_dir = temp.path().join("cloud"); + let receipt_dir = temp.path().join("receipts"); + std::fs::create_dir_all(&source_dir).unwrap(); + std::fs::create_dir_all(&cloud_dir).unwrap(); + + let source = source_dir.join("report.bin"); + let destination = cloud_dir.join("report.bin"); + std::fs::write(&source, b"verified source bytes").unwrap(); + let metadata = std::fs::metadata(&source).unwrap(); + let modified_ms = metadata + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + let mut candidate = CloudCandidate { + metadata_fingerprint: "a".repeat(64), + review_fingerprint: String::new(), + src: source.to_string_lossy().into_owned(), + dst: destination.to_string_lossy().into_owned(), + provider: CloudProvider::Onedrive, + destination_account_scope: CloudAccountScope::Personal, + kind: ArchiveKind::Document, + bytes: metadata.len(), + age_days: 1, + created_ms: modified_ms, + modified_ms, + production_time_ms: modified_ms, + production_time_source: "embedded:test:CreateDate".into(), + production_time_confidence: "high".into(), + source_root: source_dir.to_string_lossy().into_owned(), + relative_path: "report.bin".into(), + source_context: ".".into(), + requires_review: false, + review_reasons: Vec::new(), + content_title: Some("Report".into()), + content_authors: Vec::new(), + content_context: Vec::new(), + duration_ms: None, + dataset_profile: None, + metadata_evidence: vec![MetadataEvidence { + field: "production-date".into(), + value: "2026-07-17".into(), + source: "embedded:test:CreateDate".into(), + confidence: "high".into(), + }], + blocked_reason: None, + }; + candidate.review_fingerprint = candidate_review_fingerprint(&candidate); + + let root = CloudRoot { + id: cloud_dir.to_string_lossy().into_owned(), + provider: CloudProvider::Onedrive, + account_scope: CloudAccountScope::Personal, + label: "test".into(), + path: cloud_dir.to_string_lossy().into_owned(), + readable: true, + access_issue: None, + }; + let (receipt, _) = prepare_cloud_copy(&candidate, &root, &receipt_dir, 100).unwrap(); + let evidence = ProviderSyncEvidence { + receipt_id: receipt.receipt_id.clone(), + provider: receipt.provider, + destination: receipt.destination.clone(), + observed_bytes: receipt.bytes, + destination_blake3: receipt.blake3.clone(), + confirmed_at_ms: 101, + kind: SyncEvidenceKind::ProviderNativeStatus, + evidence_id: "native-test-evidence".into(), + sync_complete: true, + remote_content: None, + }; + let evidence_record = create_sync_evidence_record(&evidence).unwrap(); + let permit = approve_local_eviction(&receipt, &evidence_record).unwrap(); + (receipt, permit) +} + +fn idle_active_use() -> ActiveUseEvidence { + ActiveUseEvidence { + method: "lsof-fp+ps-command".into(), + evidence_complete: true, + active: false, + observed_pids: Vec::new(), + results_truncated: false, + error: None, + } +} + +#[test] +fn production_cloud_eviction_fails_closed_without_identity_bound_recycle() { + let temp = tempfile::tempdir().unwrap(); + let (receipt, permit) = valid_receipt(&temp); + let source = Path::new(&receipt.source); + let original = std::fs::read(source).unwrap(); + let approval = create_source_eviction_approval( + &receipt, + &permit, + &receipt.receipt_id, + 160, + "human:local:test", + "verified cloud copy; move only this source to Trash", + 150, + idle_active_use(), + ) + .unwrap(); + + let error = evict_source_with_human_approval( + &receipt, + &permit, + &approval, + &receipt.receipt_id, + &temp.path().join("evictions"), + &temp.path().join("journal/operations.jsonl"), + 200, + ) + .unwrap_err(); + + assert_eq!(error, "source-eviction-identity-bound-recycle-unavailable"); + assert!(source.exists()); + assert_eq!(std::fs::read(source).unwrap(), original); + assert!(!temp.path().join("evictions").exists()); + assert!(!temp.path().join("journal").exists()); +} From ad137819d64cd2160916500f202f60b496a333c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 21:16:20 +0900 Subject: [PATCH 2/5] security: fail closed cloud source recycle without identity binding --- src-tauri/src/cloud_eviction.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src-tauri/src/cloud_eviction.rs b/src-tauri/src/cloud_eviction.rs index b2fbabe06..5dd0d02c0 100644 --- a/src-tauri/src/cloud_eviction.rs +++ b/src-tauri/src/cloud_eviction.rs @@ -20,6 +20,7 @@ use std::path::Path; const EVICTION_RECORD_VERSION: u32 = 2; const SOURCE_EVICTION_APPROVAL_VERSION: u32 = 1; const MAX_RECORD_BYTES: u64 = 64 * 1024; +const IDENTITY_BOUND_RECYCLE_AVAILABLE: bool = false; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -836,10 +837,16 @@ pub fn evict_source_with_human_approval( now_ms: u64, ) -> Result { validate_source_eviction_approval(receipt, permit, approval)?; + if confirmation_receipt_id != receipt.receipt_id { + return Err("eviction-confirmation-receipt-id-mismatch".into()); + } let live_active_use = observe_path_active_use(Path::new(&receipt.source)); if !approval_active_use_is_safe(&live_active_use) { return Err("source-eviction-live-active-use-blocked".into()); } + if !IDENTITY_BOUND_RECYCLE_AVAILABLE { + return Err("source-eviction-identity-bound-recycle-unavailable".into()); + } evict_source_with_context( receipt, permit, From 89cdad4c56967228caa1373c1b3014c322db7e24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 21:17:30 +0900 Subject: [PATCH 3/5] test: preserve cloud eviction validation ordering --- src-tauri/tests/cloud_eviction_fail_closed.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src-tauri/tests/cloud_eviction_fail_closed.rs b/src-tauri/tests/cloud_eviction_fail_closed.rs index 3828cde38..9f58fe649 100644 --- a/src-tauri/tests/cloud_eviction_fail_closed.rs +++ b/src-tauri/tests/cloud_eviction_fail_closed.rs @@ -143,3 +143,39 @@ fn production_cloud_eviction_fails_closed_without_identity_bound_recycle() { assert!(!temp.path().join("evictions").exists()); assert!(!temp.path().join("journal").exists()); } + +#[test] +fn invalid_confirmation_is_rejected_before_capability_gate_without_mutation() { + let temp = tempfile::tempdir().unwrap(); + let (receipt, permit) = valid_receipt(&temp); + let source = Path::new(&receipt.source); + let original = std::fs::read(source).unwrap(); + let approval = create_source_eviction_approval( + &receipt, + &permit, + &receipt.receipt_id, + 160, + "human:local:test", + "verified cloud copy; move only this source to Trash", + 150, + idle_active_use(), + ) + .unwrap(); + + let error = evict_source_with_human_approval( + &receipt, + &permit, + &approval, + &"0".repeat(64), + &temp.path().join("evictions"), + &temp.path().join("journal/operations.jsonl"), + 200, + ) + .unwrap_err(); + + assert_eq!(error, "eviction-confirmation-receipt-id-mismatch"); + assert!(source.exists()); + assert_eq!(std::fs::read(source).unwrap(), original); + assert!(!temp.path().join("evictions").exists()); + assert!(!temp.path().join("journal").exists()); +} From 9b40ea926fcefedaad9454db9e7f42f0c382adfd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 21:21:53 +0900 Subject: [PATCH 4/5] test: import DiskSage library by declared crate name --- src-tauri/tests/cloud_eviction_fail_closed.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src-tauri/tests/cloud_eviction_fail_closed.rs b/src-tauri/tests/cloud_eviction_fail_closed.rs index 9f58fe649..f3b0717ba 100644 --- a/src-tauri/tests/cloud_eviction_fail_closed.rs +++ b/src-tauri/tests/cloud_eviction_fail_closed.rs @@ -1,22 +1,22 @@ -use disksage::cloud::{ +use disksage_lib::cloud::{ candidate_review_fingerprint, ArchiveKind, CloudAccountScope, CloudCandidate, CloudProvider, CloudRoot, MetadataEvidence, }; -use disksage::cloud_eviction::{ +use disksage_lib::cloud_eviction::{ create_source_eviction_approval, evict_source_with_human_approval, }; -use disksage::cloud_local_eviction::ActiveUseEvidence; -use disksage::cloud_transfer::{ +use disksage_lib::cloud_local_eviction::ActiveUseEvidence; +use disksage_lib::cloud_transfer::{ approve_local_eviction, prepare_cloud_copy, ProviderSyncEvidence, SyncEvidenceKind, }; -use disksage::provider_evidence::create_sync_evidence_record; +use disksage_lib::provider_evidence::create_sync_evidence_record; use std::path::Path; fn valid_receipt( temp: &tempfile::TempDir, ) -> ( - disksage::cloud_transfer::CloudCopyReceipt, - disksage::cloud_transfer::LocalEvictionPermit, + disksage_lib::cloud_transfer::CloudCopyReceipt, + disksage_lib::cloud_transfer::LocalEvictionPermit, ) { let source_dir = temp.path().join("source"); let cloud_dir = temp.path().join("cloud"); From 50bbf9fbdbe12f6902a11b71988bbd413c89376c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 22:16:23 +0900 Subject: [PATCH 5/5] test: exercise cloud eviction through public copy API --- src-tauri/tests/cloud_eviction_fail_closed.rs | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/src-tauri/tests/cloud_eviction_fail_closed.rs b/src-tauri/tests/cloud_eviction_fail_closed.rs index f3b0717ba..e1f9ef31b 100644 --- a/src-tauri/tests/cloud_eviction_fail_closed.rs +++ b/src-tauri/tests/cloud_eviction_fail_closed.rs @@ -7,11 +7,20 @@ use disksage_lib::cloud_eviction::{ }; use disksage_lib::cloud_local_eviction::ActiveUseEvidence; use disksage_lib::cloud_transfer::{ - approve_local_eviction, prepare_cloud_copy, ProviderSyncEvidence, SyncEvidenceKind, + approve_local_eviction, cloud_copy_approval_phrase, create_cloud_copy_approval, + prepare_cloud_copy_with_approval, CloudCopyApprovalAction, ProviderSyncEvidence, + SyncEvidenceKind, }; use disksage_lib::provider_evidence::create_sync_evidence_record; use std::path::Path; +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} + fn valid_receipt( temp: &tempfile::TempDir, ) -> ( @@ -79,14 +88,28 @@ fn valid_receipt( readable: true, access_issue: None, }; - let (receipt, _) = prepare_cloud_copy(&candidate, &root, &receipt_dir, 100).unwrap(); + let approval_time = now_ms(); + let action = CloudCopyApprovalAction::CopyOnly; + let copy_approval = create_cloud_copy_approval( + &candidate, + &root, + action, + approval_time, + "human:local:test", + "authorize exact test cloud copy", + &cloud_copy_approval_phrase(&candidate, action), + ) + .unwrap(); + let (receipt, _) = + prepare_cloud_copy_with_approval(&candidate, &root, &receipt_dir, None, ©_approval) + .unwrap(); let evidence = ProviderSyncEvidence { receipt_id: receipt.receipt_id.clone(), provider: receipt.provider, destination: receipt.destination.clone(), observed_bytes: receipt.bytes, destination_blake3: receipt.blake3.clone(), - confirmed_at_ms: 101, + confirmed_at_ms: receipt.copied_at_ms + 1, kind: SyncEvidenceKind::ProviderNativeStatus, evidence_id: "native-test-evidence".into(), sync_complete: true, @@ -108,20 +131,29 @@ fn idle_active_use() -> ActiveUseEvidence { } } +fn staging_dir(receipt: &disksage_lib::cloud_transfer::CloudCopyReceipt) -> std::path::PathBuf { + Path::new(&receipt.source) + .parent() + .unwrap() + .join(format!(".disksage-evict-{}", receipt.receipt_id)) +} + #[test] fn production_cloud_eviction_fails_closed_without_identity_bound_recycle() { let temp = tempfile::tempdir().unwrap(); let (receipt, permit) = valid_receipt(&temp); let source = Path::new(&receipt.source); let original = std::fs::read(source).unwrap(); + let observed_at_ms = permit.approved_at_ms + 1; + let approved_at_ms = observed_at_ms + 1; let approval = create_source_eviction_approval( &receipt, &permit, &receipt.receipt_id, - 160, + approved_at_ms, "human:local:test", "verified cloud copy; move only this source to Trash", - 150, + observed_at_ms, idle_active_use(), ) .unwrap(); @@ -133,13 +165,14 @@ fn production_cloud_eviction_fails_closed_without_identity_bound_recycle() { &receipt.receipt_id, &temp.path().join("evictions"), &temp.path().join("journal/operations.jsonl"), - 200, + approved_at_ms + 1, ) .unwrap_err(); assert_eq!(error, "source-eviction-identity-bound-recycle-unavailable"); assert!(source.exists()); assert_eq!(std::fs::read(source).unwrap(), original); + assert!(!staging_dir(&receipt).exists()); assert!(!temp.path().join("evictions").exists()); assert!(!temp.path().join("journal").exists()); } @@ -150,14 +183,16 @@ fn invalid_confirmation_is_rejected_before_capability_gate_without_mutation() { let (receipt, permit) = valid_receipt(&temp); let source = Path::new(&receipt.source); let original = std::fs::read(source).unwrap(); + let observed_at_ms = permit.approved_at_ms + 1; + let approved_at_ms = observed_at_ms + 1; let approval = create_source_eviction_approval( &receipt, &permit, &receipt.receipt_id, - 160, + approved_at_ms, "human:local:test", "verified cloud copy; move only this source to Trash", - 150, + observed_at_ms, idle_active_use(), ) .unwrap(); @@ -169,13 +204,14 @@ fn invalid_confirmation_is_rejected_before_capability_gate_without_mutation() { &"0".repeat(64), &temp.path().join("evictions"), &temp.path().join("journal/operations.jsonl"), - 200, + approved_at_ms + 1, ) .unwrap_err(); assert_eq!(error, "eviction-confirmation-receipt-id-mismatch"); assert!(source.exists()); assert_eq!(std::fs::read(source).unwrap(), original); + assert!(!staging_dir(&receipt).exists()); assert!(!temp.path().join("evictions").exists()); assert!(!temp.path().join("journal").exists()); }