From a66b3543e14cf54cd08f24a4506fee4148171c37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 12:51:40 +0900 Subject: [PATCH 1/7] feat: checkpoint provider sync rechecks --- CHANGELOG.md | 3 + ...usive-provider-and-reclaim-presentation.md | 9 + src-tauri/src/provider_global_sync.rs | 207 +++++++++++++++++- 3 files changed, 209 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 764b5a107..4404f6adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Return a bounded, path-free keep-local receipt when a native provider probe times out or returns no output; separate shared APFS movement from completed action-attributable receipt totals, and keep customer guidance action-first while moving evidence codes into audit details. +- Checkpoint third-party provider queue observations with a tamper-evident lineage counter and a + five-minute automatic recheck boundary while blocked; a clear global queue stops polling but + still requires a fresh item-specific sync and eviction check. - Keep coverage builds compile-safe by applying the same `not(coverage)` boundary to native-copy identity cleanup and dependent eviction helpers; the focused authority contract remains green. diff --git a/docs/architecture/adr/0012-inconclusive-provider-and-reclaim-presentation.md b/docs/architecture/adr/0012-inconclusive-provider-and-reclaim-presentation.md index d428bbdfe..dbe215865 100644 --- a/docs/architecture/adr/0012-inconclusive-provider-and-reclaim-presentation.md +++ b/docs/architecture/adr/0012-inconclusive-provider-and-reclaim-presentation.md @@ -23,6 +23,11 @@ say what to do next, while stable reason codes and evidence kinds remain availab allocation bytes in unique, completed action receipts. Incomplete receipts cannot claim bytes. - Customer-facing status leads with the next action. Evidence kinds, reason codes, and other implementation diagnostics appear only under an explicitly opened audit-detail disclosure. +- Automatic third-party provider rechecks persist a path-free, fingerprinted checkpoint. Blocked + or inconclusive observations become due after the already documented five-minute safety interval; + a monotonic observation counter and timestamp bind the next checkpoint. A clear global queue + stops automatic polling, but it is only permission to perform a fresh item-specific check and is + never local-eviction authority. ## Consequences @@ -38,6 +43,10 @@ and customers can act without interpreting internal evidence terminology. admits concurrent writers and copy-on-write or snapshot effects. - Showing reason codes in the primary status was rejected because they do not tell a customer what safe action to take. +- Polling a busy provider continuously was rejected because repeated full queue dumps add readers + during the condition being diagnosed. Treating a clear global queue as item readiness was rejected + because it does not prove upload, identity, content, conflict, active-use, or eviction capability + for any particular file. ## Evidence diff --git a/src-tauri/src/provider_global_sync.rs b/src-tauri/src/provider_global_sync.rs index d7b48e5b8..a20ce6038 100644 --- a/src-tauri/src/provider_global_sync.rs +++ b/src-tauri/src/provider_global_sync.rs @@ -7,6 +7,7 @@ use crate::cloud::CloudProvider; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; // macOS provider dumps include bounded item summaries even with --limit-dump-size. Keep enough // room for real OneDrive/Google Drive dumps while retaining a hard memory ceiling. @@ -16,6 +17,9 @@ const PROBE_TIMEOUT_MARKER: &str = "provider-global-sync-probe-timeout: yes"; const PROBE_TIMEOUT_NOTICE: &str = "provider-global-sync-probe-timeout"; const PROBE_RECEIPT_SCHEMA_KIND: &str = "disksage.provider-probe-receipt"; const PROBE_RECEIPT_SCHEMA_VERSION: u32 = 1; +const AUTOMATIC_PROBE_BACKOFF_MS: u64 = 5 * 60 * 1_000; +const PROBE_CHECKPOINT_SCHEMA_KIND: &str = "disksage.provider-global-sync-checkpoint"; +const PROBE_CHECKPOINT_SCHEMA_VERSION: u32 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -68,6 +72,25 @@ pub struct ProviderProbeReceipt { pub audit_reason_codes: Vec, } +/// Path-free checkpoint for bounded automatic provider re-observation. +/// +/// A clear global queue permits a later item-specific check; it never authorizes local eviction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderGlobalSyncCheckpoint { + pub schema_kind: String, + pub schema_version: u32, + pub provider: CloudProvider, + pub observation_count: u64, + pub observed_at_ms: u64, + pub evidence_complete: bool, + pub state: ProviderGlobalSyncState, + pub keep_local: bool, + pub next_automatic_probe_at_ms: Option, + pub audit_reason_codes: Vec, + pub checkpoint_fingerprint_sha256: String, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ProviderProbeOutcome { @@ -82,6 +105,111 @@ pub enum ProviderProbeNextAction { pub const PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION: u32 = 1; +fn checkpoint_fingerprint(checkpoint: &ProviderGlobalSyncCheckpoint) -> String { + let mut hash = Sha256::new(); + hash.update(b"disksage.provider-global-sync-checkpoint/v1\0"); + hash.update(checkpoint.provider.as_str().as_bytes()); + hash.update(checkpoint.observation_count.to_be_bytes()); + hash.update(checkpoint.observed_at_ms.to_be_bytes()); + hash.update([checkpoint.evidence_complete as u8]); + hash.update(checkpoint.state.as_str().as_bytes()); + hash.update([checkpoint.keep_local as u8]); + hash.update( + checkpoint + .next_automatic_probe_at_ms + .unwrap_or(u64::MAX) + .to_be_bytes(), + ); + for reason in &checkpoint.audit_reason_codes { + hash.update((reason.len() as u64).to_be_bytes()); + hash.update(reason.as_bytes()); + } + hash.finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub fn validate_probe_checkpoint(checkpoint: &ProviderGlobalSyncCheckpoint) -> Result<(), String> { + let blocked = + !(checkpoint.evidence_complete && checkpoint.state == ProviderGlobalSyncState::Clear); + if checkpoint.schema_kind != PROBE_CHECKPOINT_SCHEMA_KIND + || checkpoint.schema_version != PROBE_CHECKPOINT_SCHEMA_VERSION + || checkpoint.observation_count == 0 + || checkpoint.observed_at_ms == 0 + || checkpoint.keep_local != blocked + || blocked != checkpoint.next_automatic_probe_at_ms.is_some() + || checkpoint.next_automatic_probe_at_ms.is_some_and(|next| { + next != checkpoint + .observed_at_ms + .saturating_add(AUTOMATIC_PROBE_BACKOFF_MS) + }) + || checkpoint.audit_reason_codes.len() > 32 + || checkpoint + .audit_reason_codes + .iter() + .any(|reason| !probe_reason_code_is_valid(reason)) + || checkpoint_fingerprint(checkpoint) != checkpoint.checkpoint_fingerprint_sha256 + { + return Err("provider-global-sync-checkpoint-invalid".into()); + } + Ok(()) +} + +pub fn checkpoint_report( + report: &ProviderGlobalSyncReport, + observed_at_ms: u64, + previous: Option<&ProviderGlobalSyncCheckpoint>, +) -> Result { + validate_report_evidence(report)?; + if observed_at_ms == 0 { + return Err("provider-global-sync-checkpoint-time-invalid".into()); + } + let observation_count = if let Some(previous) = previous { + validate_probe_checkpoint(previous)?; + if previous.provider != report.provider || observed_at_ms <= previous.observed_at_ms { + return Err("provider-global-sync-checkpoint-lineage-invalid".into()); + } + previous + .observation_count + .checked_add(1) + .ok_or_else(|| "provider-global-sync-checkpoint-count-overflow".to_string())? + } else { + 1 + }; + let keep_local = !(report.evidence_complete && report.state == ProviderGlobalSyncState::Clear); + let mut audit_reason_codes = report.blockers.clone(); + audit_reason_codes.sort(); + audit_reason_codes.dedup(); + let mut checkpoint = ProviderGlobalSyncCheckpoint { + schema_kind: PROBE_CHECKPOINT_SCHEMA_KIND.into(), + schema_version: PROBE_CHECKPOINT_SCHEMA_VERSION, + provider: report.provider, + observation_count, + observed_at_ms, + evidence_complete: report.evidence_complete, + state: report.state, + keep_local, + next_automatic_probe_at_ms: keep_local + .then(|| observed_at_ms.saturating_add(AUTOMATIC_PROBE_BACKOFF_MS)), + audit_reason_codes, + checkpoint_fingerprint_sha256: String::new(), + }; + checkpoint.checkpoint_fingerprint_sha256 = checkpoint_fingerprint(&checkpoint); + validate_probe_checkpoint(&checkpoint)?; + Ok(checkpoint) +} + +pub fn automatic_probe_due( + checkpoint: &ProviderGlobalSyncCheckpoint, + now_ms: u64, +) -> Result { + validate_probe_checkpoint(checkpoint)?; + Ok(checkpoint + .next_automatic_probe_at_ms + .is_some_and(|next| now_ms >= next)) +} + fn provider_identifier(provider: CloudProvider) -> Option<&'static str> { match provider { CloudProvider::Onedrive => Some("com.microsoft.OneDrive.FileProvider"), @@ -176,7 +304,8 @@ fn probe_receipt_is_consistent(report: &ProviderGlobalSyncReport) -> bool { && !receipt.audit_reason_codes.is_empty() && receipt.audit_reason_codes.len() <= 16 && receipt.audit_reason_codes.iter().all(|reason| { - probe_reason_code_is_valid(reason) && report.blockers.iter().any(|blocker| blocker == reason) + probe_reason_code_is_valid(reason) + && report.blockers.iter().any(|blocker| blocker == reason) }) } @@ -252,14 +381,15 @@ pub fn parse_dump( has_item_not_found |= marker_lower.contains("code=-1005") || marker_lower.contains("itemnotfound") || marker.contains("파일이 존재하지 않습니다"); - has_local_disk_full |= contains_bounded_numeric_marker(&marker_lower, "odresult_errno ", "28") - || contains_bounded_numeric_marker(&marker_lower, "errno ", "28") - || marker_lower.contains("enospc") - || contains_bounded_numeric_marker(&marker_lower, "code=", "28") - || contains_bounded_numeric_marker(&marker_lower, "code ", "28") - || contains_bounded_numeric_marker(&marker_lower, "osstatus ", "-34") - || marker_lower.contains("no space left on device") - || marker_lower.contains("disk full"); + has_local_disk_full |= + contains_bounded_numeric_marker(&marker_lower, "odresult_errno ", "28") + || contains_bounded_numeric_marker(&marker_lower, "errno ", "28") + || marker_lower.contains("enospc") + || contains_bounded_numeric_marker(&marker_lower, "code=", "28") + || contains_bounded_numeric_marker(&marker_lower, "code ", "28") + || contains_bounded_numeric_marker(&marker_lower, "osstatus ", "-34") + || marker_lower.contains("no space left on device") + || marker_lower.contains("disk full"); if has_filename_too_long || has_temporarily_disconnected || has_server_unreachable @@ -865,7 +995,9 @@ sync engine state: "provider-global-sync-evidence-incomplete" ); assert!(report.notices.contains(&PROBE_TIMEOUT_NOTICE.into())); - let receipt = report.probe_receipt.expect("partial timeout must carry its receipt"); + let receipt = report + .probe_receipt + .expect("partial timeout must carry its receipt"); assert_eq!(receipt.outcome, ProviderProbeOutcome::Inconclusive); assert!(receipt.keep_local); assert_eq!(receipt.audit_reason_codes, vec![PROBE_TIMEOUT_NOTICE]); @@ -906,6 +1038,61 @@ sync engine state: ); } + #[test] + fn blocked_probe_checkpoint_backs_off_and_preserves_lineage() { + let report = inconclusive_probe_report( + CloudProvider::Onedrive, + 1_000, + "provider-global-sync-probe-timeout", + ); + let first = checkpoint_report(&report, 1_000, None).unwrap(); + assert!(first.keep_local); + assert_eq!(first.observation_count, 1); + assert_eq!(first.next_automatic_probe_at_ms, Some(301_000)); + assert!(!automatic_probe_due(&first, 300_999).unwrap()); + assert!(automatic_probe_due(&first, 301_000).unwrap()); + + let second = checkpoint_report(&report, 301_000, Some(&first)).unwrap(); + assert_eq!(second.observation_count, 2); + assert_ne!( + first.checkpoint_fingerprint_sha256, + second.checkpoint_fingerprint_sha256 + ); + } + + #[test] + fn clear_global_checkpoint_stops_polling_without_granting_eviction() { + let report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + let checkpoint = checkpoint_report(&report, 2_000, None).unwrap(); + + assert!(!checkpoint.keep_local); + assert_eq!(checkpoint.next_automatic_probe_at_ms, None); + assert!(!automatic_probe_due(&checkpoint, u64::MAX).unwrap()); + assert!(checkpoint.audit_reason_codes.is_empty()); + } + + #[test] + fn checkpoint_rejects_provider_or_time_drift() { + let report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + let first = checkpoint_report(&report, 2_000, None).unwrap(); + assert_eq!( + checkpoint_report(&report, 2_000, Some(&first)).unwrap_err(), + "provider-global-sync-checkpoint-lineage-invalid" + ); + let google = parse_dump( + CloudProvider::GoogleDrive, + &QUIET_DUMP.replace( + "com.microsoft.OneDrive.FileProvider", + "com.google.drivefs.fpext", + ), + ) + .unwrap(); + assert_eq!( + checkpoint_report(&google, 3_000, Some(&first)).unwrap_err(), + "provider-global-sync-checkpoint-lineage-invalid" + ); + } + #[test] fn malformed_or_icloud_dump_is_rejected() { assert!(parse_dump(CloudProvider::Onedrive, "sync engine state:").is_err()); From bd47bf570f005e94da10c3fb54ca251c8a5bb055 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 13:06:01 +0900 Subject: [PATCH 2/7] fix: persist provider sync recheck checkpoints --- src-tauri/src/commands.rs | 7 +- src-tauri/src/provider_global_sync.rs | 212 +++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4265d7751..afb3ac42f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1283,7 +1283,12 @@ pub fn inspect_cloud_provider_global_sync( if selected.provider == cloud::CloudProvider::Icloud { return Err("provider-global-sync-icloud-specialized".into()); } - provider_global_sync::inspect_new_copy_admission(selected.provider) + let app_data_dir = app.path().app_data_dir().map_err(|error| error.to_string())?; + provider_global_sync::inspect_new_copy_admission_checkpointed( + selected.provider, + &app_data_dir, + cloud::system_now_ms(), + ) } #[cfg(not(coverage))] diff --git a/src-tauri/src/provider_global_sync.rs b/src-tauri/src/provider_global_sync.rs index a20ce6038..acfda900e 100644 --- a/src-tauri/src/provider_global_sync.rs +++ b/src-tauri/src/provider_global_sync.rs @@ -8,6 +8,8 @@ use crate::cloud::CloudProvider; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::io::Write; +use std::path::{Path, PathBuf}; // macOS provider dumps include bounded item summaries even with --limit-dump-size. Keep enough // room for real OneDrive/Google Drive dumps while retaining a hard memory ceiling. @@ -165,6 +167,13 @@ pub fn checkpoint_report( if observed_at_ms == 0 { return Err("provider-global-sync-checkpoint-time-invalid".into()); } + if report + .probe_receipt + .as_ref() + .is_some_and(|receipt| receipt.observed_at_ms != observed_at_ms) + { + return Err("provider-global-sync-checkpoint-time-mismatch".into()); + } let observation_count = if let Some(previous) = previous { validate_probe_checkpoint(previous)?; if previous.provider != report.provider || observed_at_ms <= previous.observed_at_ms { @@ -200,6 +209,131 @@ pub fn checkpoint_report( Ok(checkpoint) } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProviderCheckpointDocument { + checkpoint: ProviderGlobalSyncCheckpoint, + report: ProviderGlobalSyncReport, +} + +fn checkpoint_path(app_data_dir: &Path, provider: CloudProvider) -> Result { + if !app_data_dir.is_absolute() || provider_identifier(provider).is_none() { + return Err("provider-global-sync-checkpoint-path-invalid".into()); + } + Ok(app_data_dir.join(format!("provider-global-sync-{}.json", provider.as_str()))) +} + +fn load_checkpoint_document( + app_data_dir: &Path, + provider: CloudProvider, +) -> Result, String> { + let path = checkpoint_path(app_data_dir, provider)?; + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("provider-global-sync-checkpoint-read-failed".into()), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 { + return Err("provider-global-sync-checkpoint-file-invalid".into()); + } + let document: ProviderCheckpointDocument = serde_json::from_slice( + &std::fs::read(path).map_err(|_| "provider-global-sync-checkpoint-read-failed")?, + ) + .map_err(|_| "provider-global-sync-checkpoint-decode-failed")?; + validate_probe_checkpoint(&document.checkpoint)?; + validate_report_evidence(&document.report)?; + if document.checkpoint.provider != provider || document.report.provider != provider { + return Err("provider-global-sync-checkpoint-provider-invalid".into()); + } + Ok(Some(document)) +} + +fn persist_checkpoint_document( + app_data_dir: &Path, + document: &ProviderCheckpointDocument, +) -> Result<(), String> { + std::fs::create_dir_all(app_data_dir) + .map_err(|_| "provider-global-sync-checkpoint-directory-failed")?; + let path = checkpoint_path(app_data_dir, document.checkpoint.provider)?; + let mut nonce = [0_u8; 16]; + getrandom::fill(&mut nonce).map_err(|_| "provider-global-sync-checkpoint-random-failed")?; + let nonce = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let temporary = path.with_extension(format!("{nonce}.tmp")); + let encoded = serde_json::to_vec_pretty(document) + .map_err(|_| "provider-global-sync-checkpoint-encode-failed")?; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|_| "provider-global-sync-checkpoint-create-failed")?; + if file.write_all(&encoded).is_err() || file.sync_all().is_err() { + let _ = std::fs::remove_file(&temporary); + return Err("provider-global-sync-checkpoint-write-failed".into()); + } + #[cfg(windows)] + if path.exists() { + std::fs::remove_file(&path) + .map_err(|_| "provider-global-sync-checkpoint-replace-failed")?; + } + std::fs::rename(&temporary, &path).map_err(|_| { + let _ = std::fs::remove_file(&temporary); + "provider-global-sync-checkpoint-replace-failed".to_string() + }) +} + +pub(crate) fn inspect_new_copy_admission_checkpointed_with( + provider: CloudProvider, + app_data_dir: &Path, + now_ms: u64, + probe: F, +) -> Result +where + F: FnOnce() -> Result, +{ + let previous = load_checkpoint_document(app_data_dir, provider)?; + if let Some(previous) = previous.as_ref() { + if !previous.checkpoint.keep_local || !automatic_probe_due(&previous.checkpoint, now_ms)? { + return Ok(previous.report.clone()); + } + } + let report = probe()?; + let observed_at_ms = report + .probe_receipt + .as_ref() + .map_or(now_ms, |receipt| receipt.observed_at_ms); + let checkpoint = checkpoint_report( + &report, + observed_at_ms, + previous.as_ref().map(|document| &document.checkpoint), + )?; + persist_checkpoint_document( + app_data_dir, + &ProviderCheckpointDocument { + checkpoint, + report: report.clone(), + }, + )?; + Ok(report) +} + +pub fn inspect_new_copy_admission_checkpointed( + provider: CloudProvider, + app_data_dir: &Path, + now_ms: u64, +) -> Result { + inspect_new_copy_admission_checkpointed_with(provider, app_data_dir, now_ms, || { + inspect_new_copy_admission(provider) + }) +} + pub fn automatic_probe_due( checkpoint: &ProviderGlobalSyncCheckpoint, now_ms: u64, @@ -1052,7 +1186,12 @@ sync engine state: assert!(!automatic_probe_due(&first, 300_999).unwrap()); assert!(automatic_probe_due(&first, 301_000).unwrap()); - let second = checkpoint_report(&report, 301_000, Some(&first)).unwrap(); + let fresh_report = inconclusive_probe_report( + CloudProvider::Onedrive, + 301_000, + "provider-global-sync-probe-timeout", + ); + let second = checkpoint_report(&fresh_report, 301_000, Some(&first)).unwrap(); assert_eq!(second.observation_count, 2); assert_ne!( first.checkpoint_fingerprint_sha256, @@ -1060,6 +1199,77 @@ sync engine state: ); } + #[test] + fn checkpoint_rejects_a_timestamp_newer_than_inconclusive_evidence() { + let report = inconclusive_probe_report( + CloudProvider::Onedrive, + 1_000, + "provider-global-sync-probe-timeout", + ); + assert_eq!( + checkpoint_report(&report, 301_000, None).unwrap_err(), + "provider-global-sync-checkpoint-time-mismatch" + ); + } + + #[test] + fn checkpointed_inspection_persists_and_delays_blocked_reobservation() { + let directory = tempfile::tempdir().unwrap(); + let probes = std::cell::Cell::new(0_u32); + let first = inspect_new_copy_admission_checkpointed_with( + CloudProvider::Onedrive, + directory.path(), + 1_000, + || { + probes.set(probes.get() + 1); + Ok(inconclusive_probe_report( + CloudProvider::Onedrive, + 1_000, + "provider-global-sync-probe-timeout", + )) + }, + ) + .unwrap(); + assert!(!first.evidence_complete); + + let cached = inspect_new_copy_admission_checkpointed_with( + CloudProvider::Onedrive, + directory.path(), + 300_999, + || -> Result { + panic!("a blocked provider must respect the persisted backoff") + }, + ) + .unwrap(); + assert_eq!(cached, first); + assert_eq!(probes.get(), 1); + + let fresh = inspect_new_copy_admission_checkpointed_with( + CloudProvider::Onedrive, + directory.path(), + 301_000, + || { + probes.set(probes.get() + 1); + Ok(parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap()) + }, + ) + .unwrap(); + assert!(fresh.evidence_complete); + assert_eq!(fresh.state, ProviderGlobalSyncState::Clear); + assert_eq!(probes.get(), 2); + + let stopped = inspect_new_copy_admission_checkpointed_with( + CloudProvider::Onedrive, + directory.path(), + u64::MAX, + || -> Result { + panic!("a complete clear checkpoint stops automatic polling") + }, + ) + .unwrap(); + assert_eq!(stopped, fresh); + } + #[test] fn clear_global_checkpoint_stops_polling_without_granting_eviction() { let report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); From bc51845b85b0f7dd2fa0316f6320fa48a25735ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 13:26:26 +0900 Subject: [PATCH 3/7] fix: serialize and refresh provider checkpoints --- src-tauri/src/commands.rs | 2 + src-tauri/src/naruon_cloud_copy_readiness.rs | 1 + src-tauri/src/provider_global_sync.rs | 144 +++++++++++++++--- src/lib/CloudArchive.svelte | 4 +- src/lib/api.test.ts | 2 +- src/lib/api.ts | 6 +- src/lib/cloudArchiveAdmissionContract.test.ts | 2 + 7 files changed, 136 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index afb3ac42f..e0bf4ab82 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1275,6 +1275,7 @@ pub fn inspect_icloud_new_copy_admission( #[tauri::command] pub fn inspect_cloud_provider_global_sync( cloud_root: String, + force: bool, app: AppHandle, ) -> Result { let selected = selected_cloud_root(&app, &cloud_root)?; @@ -1288,6 +1289,7 @@ pub fn inspect_cloud_provider_global_sync( selected.provider, &app_data_dir, cloud::system_now_ms(), + force, ) } diff --git a/src-tauri/src/naruon_cloud_copy_readiness.rs b/src-tauri/src/naruon_cloud_copy_readiness.rs index c1eab9e45..2ec985c44 100644 --- a/src-tauri/src/naruon_cloud_copy_readiness.rs +++ b/src-tauri/src/naruon_cloud_copy_readiness.rs @@ -1590,6 +1590,7 @@ mod tests { let blocked_sync = ProviderGlobalSyncReport { schema_version: provider_global_sync::PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION, provider: CloudProvider::Onedrive, + observed_at_ms: 1, evidence_kind: "fileproviderctl-global-dump".into(), evidence_complete: true, state: ProviderGlobalSyncState::Pending, diff --git a/src-tauri/src/provider_global_sync.rs b/src-tauri/src/provider_global_sync.rs index acfda900e..14f5a1437 100644 --- a/src-tauri/src/provider_global_sync.rs +++ b/src-tauri/src/provider_global_sync.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; // macOS provider dumps include bounded item summaries even with --limit-dump-size. Keep enough // room for real OneDrive/Google Drive dumps while retaining a hard memory ceiling. @@ -48,6 +49,7 @@ impl ProviderGlobalSyncState { pub struct ProviderGlobalSyncReport { pub schema_version: u32, pub provider: CloudProvider, + pub observed_at_ms: u64, pub evidence_kind: String, pub evidence_complete: bool, pub state: ProviderGlobalSyncState, @@ -167,10 +169,11 @@ pub fn checkpoint_report( if observed_at_ms == 0 { return Err("provider-global-sync-checkpoint-time-invalid".into()); } - if report - .probe_receipt - .as_ref() - .is_some_and(|receipt| receipt.observed_at_ms != observed_at_ms) + if report.observed_at_ms != observed_at_ms + || report + .probe_receipt + .as_ref() + .is_some_and(|receipt| receipt.observed_at_ms != report.observed_at_ms) { return Err("provider-global-sync-checkpoint-time-mismatch".into()); } @@ -293,22 +296,34 @@ pub(crate) fn inspect_new_copy_admission_checkpointed_with( provider: CloudProvider, app_data_dir: &Path, now_ms: u64, + force: bool, probe: F, ) -> Result where F: FnOnce() -> Result, { + static ONEDRIVE_CHECKPOINT_LOCK: OnceLock> = OnceLock::new(); + static GOOGLE_DRIVE_CHECKPOINT_LOCK: OnceLock> = OnceLock::new(); + let lock = match provider { + CloudProvider::Onedrive => ONEDRIVE_CHECKPOINT_LOCK.get_or_init(|| Mutex::new(())), + CloudProvider::GoogleDrive => GOOGLE_DRIVE_CHECKPOINT_LOCK.get_or_init(|| Mutex::new(())), + CloudProvider::Icloud => return Err("provider-global-sync-checkpoint-path-invalid".into()), + }; + let _transaction = lock + .lock() + .map_err(|_| "provider-global-sync-checkpoint-lock-failed")?; let previous = load_checkpoint_document(app_data_dir, provider)?; - if let Some(previous) = previous.as_ref() { - if !previous.checkpoint.keep_local || !automatic_probe_due(&previous.checkpoint, now_ms)? { - return Ok(previous.report.clone()); + if !force { + if let Some(previous) = previous.as_ref() { + if !previous.checkpoint.keep_local + || !automatic_probe_due(&previous.checkpoint, now_ms)? + { + return Ok(previous.report.clone()); + } } } let report = probe()?; - let observed_at_ms = report - .probe_receipt - .as_ref() - .map_or(now_ms, |receipt| receipt.observed_at_ms); + let observed_at_ms = report.observed_at_ms; let checkpoint = checkpoint_report( &report, observed_at_ms, @@ -328,8 +343,9 @@ pub fn inspect_new_copy_admission_checkpointed( provider: CloudProvider, app_data_dir: &Path, now_ms: u64, + force: bool, ) -> Result { - inspect_new_copy_admission_checkpointed_with(provider, app_data_dir, now_ms, || { + inspect_new_copy_admission_checkpointed_with(provider, app_data_dir, now_ms, force, || { inspect_new_copy_admission(provider) }) } @@ -432,6 +448,7 @@ fn probe_receipt_is_consistent(report: &ProviderGlobalSyncReport) -> bool { && receipt.schema_kind == PROBE_RECEIPT_SCHEMA_KIND && receipt.schema_version == PROBE_RECEIPT_SCHEMA_VERSION && receipt.observed_at_ms > 0 + && receipt.observed_at_ms == report.observed_at_ms && receipt.outcome == ProviderProbeOutcome::Inconclusive && receipt.keep_local && receipt.next_action == ProviderProbeNextAction::KeepLocalAndRescan @@ -449,6 +466,7 @@ fn probe_receipt_is_consistent(report: &ProviderGlobalSyncReport) -> bool { /// blocked/inconclusive report can validate its shape without accidentally authorizing a copy. pub fn validate_report_evidence(report: &ProviderGlobalSyncReport) -> Result<(), String> { if report.schema_version != PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION + || report.observed_at_ms == 0 || report.evidence_kind != "fileproviderctl-global-dump" || provider_identifier(report.provider).is_none() || !probe_receipt_is_consistent(report) @@ -597,9 +615,11 @@ pub fn parse_dump( if probe_timed_out { notices.push(PROBE_TIMEOUT_NOTICE.into()); } + let observed_at_ms = crate::cloud::system_now_ms(); Ok(ProviderGlobalSyncReport { schema_version: PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION, provider, + observed_at_ms, evidence_kind: "fileproviderctl-global-dump".into(), evidence_complete: !probe_timed_out, state, @@ -608,9 +628,8 @@ pub fn parse_dump( pending_indexable_count, blockers, notices, - probe_receipt: probe_timed_out.then(|| { - inconclusive_probe_receipt(crate::cloud::system_now_ms(), PROBE_TIMEOUT_NOTICE) - }), + probe_receipt: probe_timed_out + .then(|| inconclusive_probe_receipt(observed_at_ms, PROBE_TIMEOUT_NOTICE)), }) } @@ -622,6 +641,7 @@ fn inconclusive_probe_report( ProviderGlobalSyncReport { schema_version: PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION, provider, + observed_at_ms, evidence_kind: "fileproviderctl-global-dump".into(), evidence_complete: false, state: ProviderGlobalSyncState::Unavailable, @@ -938,7 +958,8 @@ sync engine state: #[test] fn quiet_dump_is_clear_without_retaining_paths() { - let report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + let mut report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + report.observed_at_ms = 2_000; assert_eq!(report.state, ProviderGlobalSyncState::Clear); assert!(report.evidence_complete); assert!(report.blockers.is_empty()); @@ -1220,6 +1241,7 @@ sync engine state: CloudProvider::Onedrive, directory.path(), 1_000, + false, || { probes.set(probes.get() + 1); Ok(inconclusive_probe_report( @@ -1236,6 +1258,7 @@ sync engine state: CloudProvider::Onedrive, directory.path(), 300_999, + false, || -> Result { panic!("a blocked provider must respect the persisted backoff") }, @@ -1248,6 +1271,7 @@ sync engine state: CloudProvider::Onedrive, directory.path(), 301_000, + false, || { probes.set(probes.get() + 1); Ok(parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap()) @@ -1262,6 +1286,7 @@ sync engine state: CloudProvider::Onedrive, directory.path(), u64::MAX, + false, || -> Result { panic!("a complete clear checkpoint stops automatic polling") }, @@ -1270,9 +1295,88 @@ sync engine state: assert_eq!(stopped, fresh); } + #[test] + fn forced_refresh_bypasses_a_clear_checkpoint() { + let directory = tempfile::tempdir().unwrap(); + let clear = inspect_new_copy_admission_checkpointed_with( + CloudProvider::Onedrive, + directory.path(), + 1_000, + false, + || { + let mut report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + report.observed_at_ms = 1_000; + Ok(report) + }, + ) + .unwrap(); + assert_eq!(clear.state, ProviderGlobalSyncState::Clear); + let forced = inspect_new_copy_admission_checkpointed_with( + CloudProvider::Onedrive, + directory.path(), + 2_000, + true, + || { + let mut report = parse_dump( + CloudProvider::Onedrive, + "com.microsoft.OneDrive.FileProvider\nsync engine state:\n+ upload progress: 1/2\n", + ) + .unwrap(); + report.observed_at_ms = 2_000; + Ok(report) + }, + ) + .unwrap(); + assert_eq!(forced.state, ProviderGlobalSyncState::Pending); + assert_eq!(forced.observed_at_ms, 2_000); + } + + #[test] + fn concurrent_forced_checks_preserve_checkpoint_lineage() { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + let directory = tempfile::tempdir().unwrap(); + let directory_path = directory.path(); + let sequence = Arc::new(AtomicU64::new(1_000)); + std::thread::scope(|scope| { + let handles = (0..2) + .map(|_| { + let sequence = Arc::clone(&sequence); + scope.spawn(move || { + inspect_new_copy_admission_checkpointed_with( + CloudProvider::Onedrive, + directory_path, + 10_000, + true, + || { + let observed_at_ms = sequence.fetch_add(1_000, Ordering::SeqCst); + Ok(inconclusive_probe_report( + CloudProvider::Onedrive, + observed_at_ms, + "provider-global-sync-probe-timeout", + )) + }, + ) + .unwrap() + }) + }) + .collect::>(); + for handle in handles { + assert!(!handle.join().unwrap().evidence_complete); + } + }); + let document = load_checkpoint_document(directory.path(), CloudProvider::Onedrive) + .unwrap() + .unwrap(); + assert_eq!(document.checkpoint.observation_count, 2); + assert_eq!(document.checkpoint.observed_at_ms, 2_000); + } + #[test] fn clear_global_checkpoint_stops_polling_without_granting_eviction() { - let report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + let mut report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + report.observed_at_ms = 2_000; let checkpoint = checkpoint_report(&report, 2_000, None).unwrap(); assert!(!checkpoint.keep_local); @@ -1283,13 +1387,14 @@ sync engine state: #[test] fn checkpoint_rejects_provider_or_time_drift() { - let report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + let mut report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + report.observed_at_ms = 2_000; let first = checkpoint_report(&report, 2_000, None).unwrap(); assert_eq!( checkpoint_report(&report, 2_000, Some(&first)).unwrap_err(), "provider-global-sync-checkpoint-lineage-invalid" ); - let google = parse_dump( + let mut google = parse_dump( CloudProvider::GoogleDrive, &QUIET_DUMP.replace( "com.microsoft.OneDrive.FileProvider", @@ -1297,6 +1402,7 @@ sync engine state: ), ) .unwrap(); + google.observed_at_ms = 3_000; assert_eq!( checkpoint_report(&google, 3_000, Some(&first)).unwrap_err(), "provider-global-sync-checkpoint-lineage-invalid" diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index 18b793c81..8a98ad7ec 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -600,8 +600,8 @@ checkingProviderGlobalSync = true; providerGlobalSyncError = ""; try { - const observedAtMs = Date.now(); - const next = await api.inspectCloudProviderGlobalSync(root.path); + const next = await api.inspectCloudProviderGlobalSync(root.path, force); + const observedAtMs = next.observed_at_ms; const fingerprint = [ next.provider, next.state, diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 1a3cafef1..2850f6195 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -73,7 +73,7 @@ describe("api wrappers", () => { [() => api.verifyCloudProviderCapacity("/cloud"), "verify_cloud_provider_capacity", { cloudRoot: "/cloud" }], [() => api.inspectIcloudNewCopyAdmission(), "inspect_icloud_new_copy_admission"], [() => api.cancelFinderCopy(), "cancel_finder_copy"], - [() => api.inspectCloudProviderGlobalSync("/cloud"), "inspect_cloud_provider_global_sync", { cloudRoot: "/cloud" }], + [() => api.inspectCloudProviderGlobalSync("/cloud"), "inspect_cloud_provider_global_sync", { cloudRoot: "/cloud", force: false }], [() => api.recoverCloudProviderClient("/cloud"), "recover_cloud_provider_client", { cloudRoot: "/cloud" }], [() => api.listCloudReviewDecisions(), "list_cloud_review_decisions"], [() => api.connectCloudProvider("/cloud", "desktop-client-id"), "connect_cloud_provider", { cloudRoot: "/cloud", clientId: "desktop-client-id", writeAccess: false }], diff --git a/src/lib/api.ts b/src/lib/api.ts index 425645948..c4edf1f50 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -350,7 +350,6 @@ export interface BrewCleanupPlan { brew_version: string; dry_run_output: string; dry_run_output_truncated: boolean; - observed_at_ms: number; plan_fingerprint: string; exact_approval_phrase: string; } @@ -878,6 +877,7 @@ export type ProviderGlobalSyncState = "clear" | "pending" | "error" | "unavailab export interface ProviderGlobalSyncReport { schema_version: number; provider: Exclude; + observed_at_ms: number; evidence_kind: string; evidence_complete: boolean; state: ProviderGlobalSyncState; @@ -1262,8 +1262,8 @@ export const verifyCloudProviderCapacity = (cloudRoot: string) => export const inspectIcloudNewCopyAdmission = () => invoke("inspect_icloud_new_copy_admission"); export const cancelFinderCopy = () => invoke("cancel_finder_copy"); -export const inspectCloudProviderGlobalSync = (cloudRoot: string) => - invoke("inspect_cloud_provider_global_sync", { cloudRoot }); +export const inspectCloudProviderGlobalSync = (cloudRoot: string, force = false) => + invoke("inspect_cloud_provider_global_sync", { cloudRoot, force }); export interface ProviderRecoveryOutput { schema_version: number; provider: Exclude; diff --git a/src/lib/cloudArchiveAdmissionContract.test.ts b/src/lib/cloudArchiveAdmissionContract.test.ts index 100214f47..8b2478fd1 100644 --- a/src/lib/cloudArchiveAdmissionContract.test.ts +++ b/src/lib/cloudArchiveAdmissionContract.test.ts @@ -35,6 +35,8 @@ describe("CloudArchive iCloud admission contract", () => { expect(source).toContain("동일한 iCloud 차단 상태가 15분 이상 지속되었습니다."); expect(source).toContain("refreshIcloudHealth(true)"); expect(source).toContain("refreshProviderGlobalSync(true)"); + expect(source).toContain("api.inspectCloudProviderGlobalSync(root.path, force)"); + expect(source).toContain("const observedAtMs = next.observed_at_ms"); expect(source).toContain("const observedAtMs = Date.now();"); expect(source).toContain("providerGlobalSyncBlockedSinceMs"); expect(source).toContain("PROVIDER_GLOBAL_SYNC_BLOCKED_RETRY_INTERVAL_MS"); From 351a7d5a4819e251033f5c3c014604e9845e4e0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 14:21:42 +0900 Subject: [PATCH 4/7] fix: harden provider checkpoint transactions --- src-tauri/src/commands.rs | 14 +- src-tauri/src/provider_global_sync.rs | 132 +++++++++++++++++- .../naruon_readiness_global_sync_identity.rs | 1 + ...vider_global_sync_success_pipe_contract.rs | 21 +++ 4 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e0bf4ab82..cc281ef40 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1479,7 +1479,19 @@ fn cloud_plan_for_inputs( icloud_sync_health::attach_new_copy_admission_notice(&mut report.notices, health.as_ref()); (health, None) } else { - let global_sync = provider_global_sync::inspect_new_copy_admission(selected.provider).ok(); + let global_sync = app + .path() + .app_data_dir() + .ok() + .and_then(|app_data_dir| { + provider_global_sync::inspect_new_copy_admission_checkpointed( + selected.provider, + &app_data_dir, + cloud::system_now_ms(), + false, + ) + .ok() + }); provider_global_sync::attach_new_copy_admission_notice( &mut report.notices, global_sync.as_ref(), diff --git a/src-tauri/src/provider_global_sync.rs b/src-tauri/src/provider_global_sync.rs index 14f5a1437..a839952e3 100644 --- a/src-tauri/src/provider_global_sync.rs +++ b/src-tauri/src/provider_global_sync.rs @@ -6,6 +6,7 @@ //! in the report. use crate::cloud::CloudProvider; +use fs4::FileExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::io::Write; @@ -107,7 +108,8 @@ pub enum ProviderProbeNextAction { KeepLocalAndRescan, } -pub const PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION: u32 = 1; +// Version two makes `observed_at_ms` a required part of the serialized report contract. +pub const PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION: u32 = 2; fn checkpoint_fingerprint(checkpoint: &ProviderGlobalSyncCheckpoint) -> String { let mut hash = Sha256::new(); @@ -226,6 +228,59 @@ fn checkpoint_path(app_data_dir: &Path, provider: CloudProvider) -> Result Result { + checkpoint_path(app_data_dir, provider).map(|path| path.with_extension("lock")) +} + +fn lock_checkpoint_transaction( + app_data_dir: &Path, + provider: CloudProvider, +) -> Result { + std::fs::create_dir_all(app_data_dir) + .map_err(|_| "provider-global-sync-checkpoint-directory-failed")?; + let path = checkpoint_lock_path(app_data_dir, provider)?; + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options + .open(path) + .map_err(|_| "provider-global-sync-checkpoint-lock-open-failed")?; + FileExt::lock(&file) + .map_err(|_| "provider-global-sync-checkpoint-lock-failed")?; + Ok(file) +} + +fn recoverable_checkpoint_error(error: &str) -> bool { + matches!( + error, + "provider-global-sync-checkpoint-file-invalid" + | "provider-global-sync-checkpoint-decode-failed" + | "provider-global-sync-checkpoint-invalid" + | "provider-global-sync-evidence-invalid" + | "provider-global-sync-checkpoint-provider-invalid" + ) +} + +fn quarantine_invalid_checkpoint( + app_data_dir: &Path, + provider: CloudProvider, +) -> Result<(), String> { + let path = checkpoint_path(app_data_dir, provider)?; + let mut nonce = [0_u8; 16]; + getrandom::fill(&mut nonce).map_err(|_| "provider-global-sync-checkpoint-random-failed")?; + let suffix = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let quarantine = path.with_extension(format!("invalid-{suffix}")); + std::fs::rename(path, quarantine) + .map_err(|_| "provider-global-sync-checkpoint-quarantine-failed".to_string()) +} + fn load_checkpoint_document( app_data_dir: &Path, provider: CloudProvider, @@ -312,7 +367,18 @@ where let _transaction = lock .lock() .map_err(|_| "provider-global-sync-checkpoint-lock-failed")?; - let previous = load_checkpoint_document(app_data_dir, provider)?; + // The advisory file lock covers the complete read-probe-write transaction across app + // instances. The process-local lock above preserves deterministic same-process ordering. + let _cross_process_transaction = lock_checkpoint_transaction(app_data_dir, provider)?; + let (previous, recovered_invalid_checkpoint) = + match load_checkpoint_document(app_data_dir, provider) { + Ok(previous) => (previous, false), + Err(error) if recoverable_checkpoint_error(&error) => { + quarantine_invalid_checkpoint(app_data_dir, provider)?; + (None, true) + } + Err(error) => return Err(error), + }; if !force { if let Some(previous) = previous.as_ref() { if !previous.checkpoint.keep_local @@ -322,7 +388,12 @@ where } } } - let report = probe()?; + let mut report = probe()?; + if recovered_invalid_checkpoint { + report + .notices + .push("provider-global-sync-checkpoint-recovered".into()); + } let observed_at_ms = report.observed_at_ms; let checkpoint = checkpoint_report( &report, @@ -1331,6 +1402,61 @@ sync engine state: assert_eq!(forced.observed_at_ms, 2_000); } + #[test] + fn corrupt_checkpoint_is_quarantined_and_replaced_by_fresh_evidence() { + let directory = tempfile::tempdir().unwrap(); + let path = checkpoint_path(directory.path(), CloudProvider::Onedrive).unwrap(); + std::fs::write(&path, b"{truncated").unwrap(); + + let report = inspect_new_copy_admission_checkpointed_with( + CloudProvider::Onedrive, + directory.path(), + 2_000, + false, + || { + let mut report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + report.observed_at_ms = 2_000; + Ok(report) + }, + ) + .unwrap(); + + assert_eq!(report.state, ProviderGlobalSyncState::Clear); + assert!(report + .notices + .contains(&"provider-global-sync-checkpoint-recovered".to_string())); + assert!( + load_checkpoint_document(directory.path(), CloudProvider::Onedrive) + .unwrap() + .is_some() + ); + assert!(std::fs::read_dir(directory.path()) + .unwrap() + .any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".invalid-"))); + } + + #[test] + fn checkpoint_transaction_lock_is_shared_across_file_handles() { + let directory = tempfile::tempdir().unwrap(); + let first = lock_checkpoint_transaction(directory.path(), CloudProvider::Onedrive).unwrap(); + let lock_path = checkpoint_lock_path(directory.path(), CloudProvider::Onedrive).unwrap(); + let second = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(lock_path) + .unwrap(); + assert!(matches!( + FileExt::try_lock(&second), + Err(fs4::TryLockError::WouldBlock) + )); + drop(first); + FileExt::try_lock(&second).unwrap(); + } + #[test] fn concurrent_forced_checks_preserve_checkpoint_lineage() { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/src-tauri/tests/naruon_readiness_global_sync_identity.rs b/src-tauri/tests/naruon_readiness_global_sync_identity.rs index 09516e9ba..e361ccb18 100644 --- a/src-tauri/tests/naruon_readiness_global_sync_identity.rs +++ b/src-tauri/tests/naruon_readiness_global_sync_identity.rs @@ -55,6 +55,7 @@ fn canonical_clear_report() -> ProviderGlobalSyncReport { ProviderGlobalSyncReport { schema_version: PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION, provider: CloudProvider::Onedrive, + observed_at_ms: 42, evidence_kind: "fileproviderctl-global-dump".into(), evidence_complete: true, state: ProviderGlobalSyncState::Clear, diff --git a/src-tauri/tests/provider_global_sync_success_pipe_contract.rs b/src-tauri/tests/provider_global_sync_success_pipe_contract.rs index b29bd5da5..0ea9ed841 100644 --- a/src-tauri/tests/provider_global_sync_success_pipe_contract.rs +++ b/src-tauri/tests/provider_global_sync_success_pipe_contract.rs @@ -39,6 +39,27 @@ fn successful_provider_dump_terminates_private_group_before_reader_join() { ); } +#[test] +fn cloud_planning_uses_the_persisted_provider_checkpoint_backoff() { + let source = include_str!("../src/commands.rs"); + let planning = source + .split_once("fn cloud_plan_for_inputs(") + .expect("cloud planning boundary must exist") + .1 + .split_once("fn authenticated_capacity_snapshot(") + .expect("cloud planning boundary must end before capacity helper") + .0; + + assert!( + planning.contains("inspect_new_copy_admission_checkpointed("), + "planning must reuse the persisted provider checkpoint and its automatic backoff" + ); + assert!( + !planning.contains("inspect_new_copy_admission(selected.provider)"), + "planning must not bypass checkpoint backoff with a direct provider dump" + ); +} + #[cfg(unix)] #[test] fn descendant_inheriting_stdout_keeps_pipe_open_until_private_group_is_terminated() { From 0911f5ec5f7455717c23fa8adcd2d4fe6fc08b8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 14:54:35 +0900 Subject: [PATCH 5/7] fix: require fresh provider admission evidence --- src-tauri/src/provider_global_sync.rs | 56 +++++++++++++++---- ...vider_global_sync_clear_state_integrity.rs | 1 + 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/provider_global_sync.rs b/src-tauri/src/provider_global_sync.rs index a839952e3..b4fa80912 100644 --- a/src-tauri/src/provider_global_sync.rs +++ b/src-tauri/src/provider_global_sync.rs @@ -249,8 +249,7 @@ fn lock_checkpoint_transaction( let file = options .open(path) .map_err(|_| "provider-global-sync-checkpoint-lock-open-failed")?; - FileExt::lock(&file) - .map_err(|_| "provider-global-sync-checkpoint-lock-failed")?; + FileExt::lock(&file).map_err(|_| "provider-global-sync-checkpoint-lock-failed")?; Ok(file) } @@ -300,7 +299,19 @@ fn load_checkpoint_document( .map_err(|_| "provider-global-sync-checkpoint-decode-failed")?; validate_probe_checkpoint(&document.checkpoint)?; validate_report_evidence(&document.report)?; - if document.checkpoint.provider != provider || document.report.provider != provider { + let mut report_reason_codes = document.report.blockers.clone(); + report_reason_codes.sort(); + report_reason_codes.dedup(); + let report_keep_local = !(document.report.evidence_complete + && document.report.state == ProviderGlobalSyncState::Clear); + if document.checkpoint.provider != provider + || document.report.provider != provider + || document.checkpoint.observed_at_ms != document.report.observed_at_ms + || document.checkpoint.evidence_complete != document.report.evidence_complete + || document.checkpoint.state != document.report.state + || document.checkpoint.keep_local != report_keep_local + || document.checkpoint.audit_reason_codes != report_reason_codes + { return Err("provider-global-sync-checkpoint-provider-invalid".into()); } Ok(Some(document)) @@ -381,8 +392,9 @@ where }; if !force { if let Some(previous) = previous.as_ref() { - if !previous.checkpoint.keep_local - || !automatic_probe_due(&previous.checkpoint, now_ms)? + // A blocked result is safe to cache until its retry deadline. A clear result is only + // an observation, never durable authorization: every later admission must re-probe. + if previous.checkpoint.keep_local && !automatic_probe_due(&previous.checkpoint, now_ms)? { return Ok(previous.report.clone()); } @@ -1345,7 +1357,9 @@ sync engine state: false, || { probes.set(probes.get() + 1); - Ok(parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap()) + let mut report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + report.observed_at_ms = 301_000; + Ok(report) }, ) .unwrap(); @@ -1353,17 +1367,21 @@ sync engine state: assert_eq!(fresh.state, ProviderGlobalSyncState::Clear); assert_eq!(probes.get(), 2); - let stopped = inspect_new_copy_admission_checkpointed_with( + let rechecked = inspect_new_copy_admission_checkpointed_with( CloudProvider::Onedrive, directory.path(), - u64::MAX, + 302_000, false, - || -> Result { - panic!("a complete clear checkpoint stops automatic polling") + || { + probes.set(probes.get() + 1); + let mut report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + report.observed_at_ms = 302_000; + Ok(report) }, ) .unwrap(); - assert_eq!(stopped, fresh); + assert_eq!(rechecked.state, ProviderGlobalSyncState::Clear); + assert_eq!(probes.get(), 3); } #[test] @@ -1439,6 +1457,22 @@ sync engine state: .contains(".invalid-"))); } + #[test] + fn checkpoint_rejects_unbound_report_and_checkpoint_halves() { + let directory = tempfile::tempdir().unwrap(); + let mut report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); + report.observed_at_ms = 1_000; + let checkpoint = checkpoint_report(&report, 1_000, None).unwrap(); + let mut document = ProviderCheckpointDocument { checkpoint, report }; + document.report.observed_at_ms = 2_000; + persist_checkpoint_document(directory.path(), &document).unwrap(); + + assert_eq!( + load_checkpoint_document(directory.path(), CloudProvider::Onedrive).unwrap_err(), + "provider-global-sync-checkpoint-provider-invalid" + ); + } + #[test] fn checkpoint_transaction_lock_is_shared_across_file_handles() { let directory = tempfile::tempdir().unwrap(); diff --git a/src-tauri/tests/provider_global_sync_clear_state_integrity.rs b/src-tauri/tests/provider_global_sync_clear_state_integrity.rs index 9ebbfc946..d511e110a 100644 --- a/src-tauri/tests/provider_global_sync_clear_state_integrity.rs +++ b/src-tauri/tests/provider_global_sync_clear_state_integrity.rs @@ -14,6 +14,7 @@ fn clear_report() -> ProviderGlobalSyncReport { ProviderGlobalSyncReport { schema_version: PROVIDER_GLOBAL_SYNC_SCHEMA_VERSION, provider: CloudProvider::Onedrive, + observed_at_ms: 42, evidence_kind: "fileproviderctl-global-dump".into(), evidence_complete: true, state: ProviderGlobalSyncState::Clear, From a99b21354a0fe613ec5497f63321d5ffc639d485 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 14:59:06 +0900 Subject: [PATCH 6/7] fix: separate passive sync status from admission --- src-tauri/src/commands.rs | 2 +- src-tauri/src/provider_global_sync.rs | 20 +++++++++---------- ...vider_global_sync_success_pipe_contract.rs | 4 ++++ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cc281ef40..de1f75f53 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1488,7 +1488,7 @@ fn cloud_plan_for_inputs( selected.provider, &app_data_dir, cloud::system_now_ms(), - false, + true, ) .ok() }); diff --git a/src-tauri/src/provider_global_sync.rs b/src-tauri/src/provider_global_sync.rs index b4fa80912..ee8c5d62c 100644 --- a/src-tauri/src/provider_global_sync.rs +++ b/src-tauri/src/provider_global_sync.rs @@ -392,9 +392,10 @@ where }; if !force { if let Some(previous) = previous.as_ref() { - // A blocked result is safe to cache until its retry deadline. A clear result is only - // an observation, never durable authorization: every later admission must re-probe. - if previous.checkpoint.keep_local && !automatic_probe_due(&previous.checkpoint, now_ms)? + // Passive status polling reuses clear evidence and blocked evidence before its retry + // deadline. Admission callers pass `force=true` and always obtain fresh evidence. + if !previous.checkpoint.keep_local + || !automatic_probe_due(&previous.checkpoint, now_ms)? { return Ok(previous.report.clone()); } @@ -1367,21 +1368,18 @@ sync engine state: assert_eq!(fresh.state, ProviderGlobalSyncState::Clear); assert_eq!(probes.get(), 2); - let rechecked = inspect_new_copy_admission_checkpointed_with( + let stopped = inspect_new_copy_admission_checkpointed_with( CloudProvider::Onedrive, directory.path(), 302_000, false, - || { - probes.set(probes.get() + 1); - let mut report = parse_dump(CloudProvider::Onedrive, QUIET_DUMP).unwrap(); - report.observed_at_ms = 302_000; - Ok(report) + || -> Result { + panic!("passive polling must reuse a clear checkpoint") }, ) .unwrap(); - assert_eq!(rechecked.state, ProviderGlobalSyncState::Clear); - assert_eq!(probes.get(), 3); + assert_eq!(stopped, fresh); + assert_eq!(probes.get(), 2); } #[test] diff --git a/src-tauri/tests/provider_global_sync_success_pipe_contract.rs b/src-tauri/tests/provider_global_sync_success_pipe_contract.rs index 0ea9ed841..dba2a91cf 100644 --- a/src-tauri/tests/provider_global_sync_success_pipe_contract.rs +++ b/src-tauri/tests/provider_global_sync_success_pipe_contract.rs @@ -54,6 +54,10 @@ fn cloud_planning_uses_the_persisted_provider_checkpoint_backoff() { planning.contains("inspect_new_copy_admission_checkpointed("), "planning must reuse the persisted provider checkpoint and its automatic backoff" ); + assert!( + planning.contains("cloud::system_now_ms(),\n true,"), + "planning is an admission boundary and must force fresh provider evidence" + ); assert!( !planning.contains("inspect_new_copy_admission(selected.provider)"), "planning must not bypass checkpoint backoff with a direct provider dump" From 1dc8f28993ed70be347525d99700c44528c07c58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 15:02:52 +0900 Subject: [PATCH 7/7] fix: describe provider recheck timing accurately --- src/lib/CloudArchive.svelte | 5 ++++- src/lib/cloudArchiveAdmissionContract.test.ts | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index 8a98ad7ec..3d9149cde 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -1083,7 +1083,10 @@

0}> {providerGlobalSyncNextAction(providerGlobalSync)}

- 마지막 확인 {evidenceObservedAt(providerGlobalSyncObservedAtMs)} · {providerGlobalSync.blockers.length === 0 ? "1분" : "5분"} 후 자동 재확인 + + 마지막 확인 {evidenceObservedAt(providerGlobalSyncObservedAtMs)} · + {providerGlobalSync.blockers.length === 0 ? "새 복사 계획을 시작할 때 다시 확인" : "5분 후 자동 재확인"} + {#if providerGlobalSync.blockers.length > 0}

차단 사유: {providerGlobalSync.blockers.map(providerGlobalSyncBlockerLabel).join(", ")} diff --git a/src/lib/cloudArchiveAdmissionContract.test.ts b/src/lib/cloudArchiveAdmissionContract.test.ts index 8b2478fd1..e6c627129 100644 --- a/src/lib/cloudArchiveAdmissionContract.test.ts +++ b/src/lib/cloudArchiveAdmissionContract.test.ts @@ -57,8 +57,7 @@ describe("CloudArchive iCloud admission contract", () => { expect(source).toContain("

감사 세부 정보"); expect(source).not.toContain("전역 동기화 admission"); expect(source).toContain("마지막 확인 {evidenceObservedAt(providerGlobalSyncObservedAtMs)}"); - expect(source).toContain('providerGlobalSync.blockers.length === 0 ? "1분" : "5분"'); - expect(source).toContain("후 자동 재확인"); + expect(source).toContain('providerGlobalSync.blockers.length === 0 ? "새 복사 계획을 시작할 때 다시 확인" : "5분 후 자동 재확인"'); expect(source).toContain("접근 불가·진단만 가능"); expect(source).toContain("이 클라우드 위치를 현재 읽을 수 없습니다."); expect(source).toContain("!selectedRootDetails()?.readable");