From 13823ff66c0783eb511d2bd32375763d0b28a605 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 29 Jul 2026 15:38:26 +0900 Subject: [PATCH] feat: bind cloud plans to provider account scope --- ...26-07-21-cloud-capacity-evidence-design.md | 22 ++- src-tauri/src/bin/disksage-cloud-plan.rs | 173 +++++++++++------- src-tauri/src/commands.rs | 54 +++--- src-tauri/src/provider_capacity.rs | 110 ++++++++++- 4 files changed, 271 insertions(+), 88 deletions(-) diff --git a/docs/superpowers/specs/2026-07-21-cloud-capacity-evidence-design.md b/docs/superpowers/specs/2026-07-21-cloud-capacity-evidence-design.md index aba67556d..fa33ae912 100644 --- a/docs/superpowers/specs/2026-07-21-cloud-capacity-evidence-design.md +++ b/docs/superpowers/specs/2026-07-21-cloud-capacity-evidence-design.md @@ -26,6 +26,23 @@ Google Workspace pooled-storage accounts can return organization-wide limit and therefore includes `google-capacity-may-reflect-pooled-organization-storage` as a notice rather than mislabeling the figures as necessarily personal. +## Account-scope binding + +Capacity schema v3 carries an optional provider-authoritative account scope. Apple's exact native +quota response binds iCloud to `personal`. Microsoft Graph maps `driveType=personal` to `personal`, +`business` to `organization`, and `documentLibrary` to `shared`. Google Drive quota does not classify +personal versus pooled organization storage precisely enough, so its scope remains unknown. + +DiskSage collects capacity before constructing the plan. When discovery reported an unknown scope, +the provider scope refines the selected cloud root before review and decision fingerprints are +calculated. A provider mismatch or conflict with an already-known root scope fails closed. An +unavailable response or a provider without authoritative scope preserves the discovery result +instead of guessing. + +The CLI and Tauri application use the same binding rule. A plan, its human review, and a subsequent +copy reuse the same bounded capacity snapshot, so a second provider call cannot silently change the +destination policy between fingerprint verification and receipt creation. + ## Decision gate The default reserve is 1 GiB. A candidate is copy-eligible only when a fresh provider snapshot proves @@ -41,8 +58,9 @@ the same exact byte-plus-reserve comparison still gates the copy. A zero remaini The plan-level assessment uses the total potentially reclaimable candidate bytes. A plan may report that the full batch does not fit even though a smaller individual candidate can fit; the copy command -therefore obtains a fresh snapshot and evaluates that exact candidate immediately before copying. -Adopting an already-present cloud object does not upload bytes and skips this capacity gate. +therefore re-evaluates that exact candidate against the plan's freshly collected, root-bound +snapshot immediately before copying. Adopting an already-present cloud object does not upload bytes +and skips this capacity gate. No capacity check authorizes deletion. DiskSage still retains the source until copy hash verification, provider sync attestation, immutable receipt validation, and an explicit local-eviction confirmation diff --git a/src-tauri/src/bin/disksage-cloud-plan.rs b/src-tauri/src/bin/disksage-cloud-plan.rs index b512e7a37..97b5a1c3b 100644 --- a/src-tauri/src/bin/disksage-cloud-plan.rs +++ b/src-tauri/src/bin/disksage-cloud-plan.rs @@ -13,7 +13,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; #[cfg(not(coverage))] -use disksage_lib::cloud::{self, ArchiveKind, CloudPlanOptions, CloudProvider, CloudRoot}; +use disksage_lib::cloud::{ + self, ArchiveKind, CloudAccountScope, CloudPlanOptions, CloudProvider, CloudRoot, +}; #[cfg(not(coverage))] use disksage_lib::cloud_eviction::{self, CloudEvictionResult, CloudSourceEvictionApproval}; #[cfg(not(coverage))] @@ -538,11 +540,12 @@ fn validate_action_args(args: &Args) -> Result<(), String> { || adoption_action || attestation_action || eviction_action - || review_action || exact_duplicate_review || args.export_naruon_lineage.is_some()) { - return Err("capacity verification은 plan 또는 copy action에서만 사용할 수 있음".into()); + return Err( + "capacity verification은 plan, review 또는 copy action에서만 사용할 수 있음".into(), + ); } if args .provider_object_id @@ -1547,38 +1550,19 @@ fn collect_root_capacity( } #[cfg(not(coverage))] -fn verified_capacity_for_bytes( - root: &CloudRoot, - oauth_connections: Option<&Path>, - requested_bytes: u64, - largest_candidate_bytes: u64, - reserve_mib: u64, -) -> Result { - let reserve_bytes = reserve_mib.saturating_mul(1024 * 1024); - let observed_at_ms = cloud::system_now_ms(); - let snapshot = match collect_root_capacity(root, oauth_connections, observed_at_ms) { - Ok(snapshot) => snapshot, - Err(error) => provider_capacity::unavailable_capacity_from_error( - root.provider, - observed_at_ms, - &error, - ), - }; - Ok(provider_capacity::assess_capacity( - snapshot, - requested_bytes, - largest_candidate_bytes, - reserve_bytes, - )) -} - -#[cfg(not(coverage))] -fn attach_verified_capacity( +fn attach_capacity_snapshot( report: &mut cloud::CloudPlanReport, - root: &CloudRoot, - oauth_connections: Option<&Path>, + snapshot: provider_capacity::CloudCapacitySnapshot, reserve_mib: u64, ) -> Result<(), String> { + if snapshot.provider != report.cloud_root.provider + || snapshot.account_scope.is_some_and(|scope| { + report.cloud_root.account_scope != CloudAccountScope::Unknown + && report.cloud_root.account_scope != scope + }) + { + return Err("cloud-capacity-root-binding-mismatch".into()); + } let largest_candidate_bytes = report .candidates .iter() @@ -1586,13 +1570,12 @@ fn attach_verified_capacity( .map(|candidate| candidate.bytes) .max() .unwrap_or_default(); - let assessment = verified_capacity_for_bytes( - root, - oauth_connections, + let assessment = provider_capacity::assess_capacity( + snapshot, report.potentially_reclaimable_bytes, largest_candidate_bytes, - reserve_mib, - )?; + reserve_mib.saturating_mul(1024 * 1024), + ); report .notices .retain(|notice| notice != "cloud-quota-unverified"); @@ -1614,6 +1597,36 @@ fn attach_verified_capacity( Ok(()) } +#[cfg(not(coverage))] +fn plan_with_optional_capacity( + source: &cloud::CloudSourceSnapshot, + root: &CloudRoot, + verify_capacity: bool, + oauth_connections: Option<&Path>, + reserve_mib: u64, +) -> Result<(CloudRoot, cloud::CloudPlanReport), String> { + if !verify_capacity { + return Ok(( + root.clone(), + cloud::plan_cloud_archive_from_snapshot(source, root), + )); + } + let observed_at_ms = cloud::system_now_ms(); + let capacity_snapshot = match collect_root_capacity(root, oauth_connections, observed_at_ms) { + Ok(snapshot) => snapshot, + Err(error) => provider_capacity::unavailable_capacity_from_error( + root.provider, + observed_at_ms, + &error, + ), + }; + let refined_root = + provider_capacity::root_with_verified_capacity_scope(root, &capacity_snapshot)?; + let mut report = cloud::plan_cloud_archive_from_snapshot(source, &refined_root); + attach_capacity_snapshot(&mut report, capacity_snapshot, reserve_mib)?; + Ok((refined_root, report)) +} + #[cfg(not(coverage))] fn collect_receipt_sync_evidence( receipt: &CloudCopyReceipt, @@ -1938,15 +1951,13 @@ fn run() -> Result<(), String> { if args.all_readable_roots { let mut summaries = Vec::with_capacity(selected_roots.len()); for selected in &selected_roots { - let mut report = cloud::plan_cloud_archive_from_snapshot(&snapshot, selected); - if args.verify_capacity { - attach_verified_capacity( - &mut report, - selected, - args.oauth_connections.as_deref(), - args.capacity_reserve_mib, - )?; - } + let (_, report) = plan_with_optional_capacity( + &snapshot, + selected, + args.verify_capacity, + args.oauth_connections.as_deref(), + args.capacity_reserve_mib, + )?; summaries.push(match args.review_reason_set.as_deref() { Some(reasons) => review_batch_summary(&report, reasons)?, None => decision_summary(&report), @@ -1986,15 +1997,14 @@ fn run() -> Result<(), String> { .into_iter() .next() .ok_or_else(|| "선택된 클라우드 루트가 없음".to_string())?; - let mut report = cloud::plan_cloud_archive_from_snapshot(&snapshot, &selected); - if args.verify_capacity { - attach_verified_capacity( - &mut report, - &selected, - args.oauth_connections.as_deref(), - args.capacity_reserve_mib, - )?; - } + let capacity_required_for_plan = args.verify_capacity || args.copy_fingerprint.is_some(); + let (selected, report) = plan_with_optional_capacity( + &snapshot, + &selected, + capacity_required_for_plan, + args.oauth_connections.as_deref(), + args.capacity_reserve_mib, + )?; if let (Some(redundant_prefix), Some(kind)) = ( args.exact_duplicate_review_prefix.as_deref(), args.exact_duplicate_kind, @@ -2091,13 +2101,17 @@ fn run() -> Result<(), String> { None }; if !adopt_existing { - let assessment = verified_capacity_for_bytes( - &selected, - args.oauth_connections.as_deref(), + let capacity_snapshot = report + .capacity + .as_ref() + .map(|assessment| assessment.snapshot.clone()) + .ok_or_else(|| "cloud-capacity-verification-required".to_string())?; + let assessment = provider_capacity::assess_capacity( + capacity_snapshot, candidate.bytes, candidate.bytes, - args.capacity_reserve_mib, - )?; + args.capacity_reserve_mib.saturating_mul(1024 * 1024), + ); if assessment.can_fit != Some(true) { return Err(if assessment.blockers.is_empty() { "cloud-capacity-verification-required".into() @@ -3168,6 +3182,27 @@ mod tests { copy.oauth_connections = Some(PathBuf::from("/connections.json")); assert!(validate_action_args(©).is_ok()); + let review = parse_args( + &[ + "--verify-capacity".into(), + "--review-candidate-fingerprint".into(), + "c".repeat(64), + "--review-fingerprint".into(), + "d".repeat(64), + "--review-disposition".into(), + "approved".into(), + "--reviewed-by".into(), + "human:test".into(), + "--review-rationale".into(), + "provider scope and embedded metadata reviewed".into(), + "--review-dir".into(), + "/reviews".into(), + ], + Path::new("/h"), + ) + .unwrap(); + assert!(validate_action_args(&review).is_ok()); + let mut adoption = copy.clone(); adoption.copy_fingerprint = None; adoption.adopt_existing_fingerprint = Some("b".repeat(64)); @@ -3189,7 +3224,16 @@ mod tests { access_issue: None, }; - let assessment = verified_capacity_for_bytes(&root, None, 10, 10, 1).unwrap(); + let observed_at_ms = cloud::system_now_ms(); + let snapshot = match collect_root_capacity(&root, None, observed_at_ms) { + Ok(snapshot) => snapshot, + Err(error) => provider_capacity::unavailable_capacity_from_error( + root.provider, + observed_at_ms, + &error, + ), + }; + let assessment = provider_capacity::assess_capacity(snapshot, 10, 10, 1024 * 1024); assert_eq!(assessment.can_fit, None); assert_eq!( @@ -3224,7 +3268,12 @@ mod tests { notices: vec!["dry-run-only".into(), "cloud-quota-unverified".into()], }; - attach_verified_capacity(&mut report, &root, None, 1024).unwrap(); + let snapshot = provider_capacity::unavailable_capacity_from_error( + CloudProvider::Onedrive, + 1, + "provider-capacity-oauth-connections-required", + ); + attach_capacity_snapshot(&mut report, snapshot, 1024).unwrap(); let assessment = report.capacity.unwrap(); assert_eq!(assessment.can_fit, None); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f57627cd0..b0b0e4d46 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -630,17 +630,29 @@ fn cloud_plan_for_inputs( return Err("이미 클라우드 안에 있는 경로는 오프로드 원본으로 사용할 수 없음".into()); } let files = cloud::collect_archive_files(&root_path, &excluded); - let report = cloud::plan_cloud_archive( + let observed_at_ms = cloud::system_now_ms(); + let capacity_snapshot = match authenticated_capacity_snapshot(&selected, app, observed_at_ms) { + Ok(snapshot) => snapshot, + Err(error) => provider_capacity::unavailable_capacity_from_error( + selected.provider, + observed_at_ms, + &error, + ), + }; + let selected = + provider_capacity::root_with_verified_capacity_scope(&selected, &capacity_snapshot)?; + let mut report = cloud::plan_cloud_archive( &files, &root_path, &selected, - cloud::system_now_ms(), + observed_at_ms, cloud::CloudPlanOptions { min_size_bytes: min_size_mib.saturating_mul(1024 * 1024), min_age_days, limit: limit.clamp(1, 1_000), }, ); + attach_capacity_assessment(&mut report, capacity_snapshot)?; Ok((selected, report)) } @@ -666,18 +678,16 @@ fn authenticated_capacity_snapshot( #[cfg(not(coverage))] fn attach_capacity_assessment( report: &mut cloud::CloudPlanReport, - selected: &cloud::CloudRoot, - app: &AppHandle, -) { - let observed_at_ms = cloud::system_now_ms(); - let snapshot = match authenticated_capacity_snapshot(selected, app, observed_at_ms) { - Ok(snapshot) => snapshot, - Err(error) => provider_capacity::unavailable_capacity_from_error( - selected.provider, - observed_at_ms, - &error, - ), - }; + snapshot: provider_capacity::CloudCapacitySnapshot, +) -> Result<(), String> { + if snapshot.provider != report.cloud_root.provider + || snapshot.account_scope.is_some_and(|scope| { + report.cloud_root.account_scope != cloud::CloudAccountScope::Unknown + && report.cloud_root.account_scope != scope + }) + { + return Err("cloud-capacity-root-binding-mismatch".into()); + } let largest_candidate_bytes = report .candidates .iter() @@ -707,17 +717,16 @@ fn attach_capacity_assessment( } .into()); report.capacity = Some(assessment); + Ok(()) } #[cfg(not(coverage))] fn require_capacity_for_copy( - selected: &cloud::CloudRoot, candidate: &cloud::CloudCandidate, - app: &AppHandle, + snapshot: &provider_capacity::CloudCapacitySnapshot, ) -> Result<(), String> { - let snapshot = authenticated_capacity_snapshot(selected, app, cloud::system_now_ms())?; let assessment = provider_capacity::assess_capacity( - snapshot, + snapshot.clone(), candidate.bytes, candidate.bytes, provider_capacity::DEFAULT_CAPACITY_RESERVE_BYTES, @@ -746,9 +755,8 @@ pub async fn plan_cloud_archive( app: AppHandle, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - let (selected, mut report) = + let (_, report) = cloud_plan_for_inputs(&root, &cloud_root, min_size_mib, min_age_days, limit, &app)?; - attach_capacity_assessment(&mut report, &selected, &app); Ok(report) }) .await @@ -888,7 +896,11 @@ fn create_cloud_candidate_receipt( None }; if !adopt_existing { - require_capacity_for_copy(&selected, candidate, app)?; + let snapshot = report + .capacity + .as_ref() + .ok_or_else(|| "cloud-capacity-verification-required".to_string())?; + require_capacity_for_copy(candidate, &snapshot.snapshot)?; } let (receipt, receipt_path) = if adopt_existing { cloud_transfer::adopt_existing_cloud_copy_with_review( diff --git a/src-tauri/src/provider_capacity.rs b/src-tauri/src/provider_capacity.rs index a2d17fec4..05967ce57 100644 --- a/src-tauri/src/provider_capacity.rs +++ b/src-tauri/src/provider_capacity.rs @@ -5,9 +5,9 @@ //! read-only `/usr/bin/brctl quota` client. That native evidence is kept distinct from provider API //! evidence and is never inferred from local APFS free space. -use crate::cloud::CloudProvider; +use crate::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; -pub const CAPACITY_SCHEMA_VERSION: u32 = 2; +pub const CAPACITY_SCHEMA_VERSION: u32 = 3; pub const DEFAULT_CAPACITY_RESERVE_BYTES: u64 = 1024 * 1024 * 1024; #[cfg(not(coverage))] @@ -44,6 +44,12 @@ pub enum CloudCapacityState { pub struct CloudCapacitySnapshot { pub schema_version: u32, pub provider: CloudProvider, + /// Provider-authoritative account scope when the bounded capacity response exposes it. + /// + /// `None` means the capacity evidence cannot classify the destination account and callers + /// must preserve the discovery-time scope instead of guessing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_scope: Option, pub evidence_kind: CapacityEvidenceKind, pub observed_at_ms: u64, pub total_bytes: Option, @@ -68,6 +74,32 @@ pub struct CloudCapacityAssessment { pub notices: Vec, } +/// Bind provider-authoritative account scope to a discovered cloud root before planning. +/// +/// A provider that does not expose account scope leaves the discovery-time root unchanged. A +/// provider mismatch or conflicting non-unknown scope fails closed so review fingerprints and +/// copy receipts cannot silently switch destination policy. +pub fn root_with_verified_capacity_scope( + root: &CloudRoot, + snapshot: &CloudCapacitySnapshot, +) -> Result { + if snapshot.provider != root.provider { + return Err("cloud-capacity-provider-mismatch".into()); + } + let Some(verified_scope) = snapshot.account_scope else { + return Ok(root.clone()); + }; + match root.account_scope { + CloudAccountScope::Unknown => { + let mut refined = root.clone(); + refined.account_scope = verified_scope; + Ok(refined) + } + existing if existing == verified_scope => Ok(root.clone()), + _ => Err("cloud-capacity-account-scope-conflict".into()), + } +} + pub fn provider_capacity_url(provider: CloudProvider) -> Result<&'static str, String> { match provider { CloudProvider::Onedrive => Ok(ONEDRIVE_CAPACITY_URL), @@ -83,9 +115,18 @@ fn update_optional_u64(hasher: &mut blake3::Hasher, value: Option) { fn evidence_fingerprint(snapshot: &CloudCapacitySnapshot, provider_binding: &str) -> String { let mut hasher = blake3::Hasher::new(); - hasher.update(b"disksage-cloud-capacity-v2\0"); + hasher.update(b"disksage-cloud-capacity-v3\0"); hasher.update(snapshot.provider.as_str().as_bytes()); hasher.update(&[0]); + hasher.update(&[snapshot.account_scope.is_some() as u8]); + hasher.update( + snapshot + .account_scope + .unwrap_or(CloudAccountScope::Unknown) + .as_str() + .as_bytes(), + ); + hasher.update(&[0]); hasher.update(&[match snapshot.evidence_kind { CapacityEvidenceKind::ProviderApi => 1, CapacityEvidenceKind::ProviderNativeStatus => 2, @@ -149,6 +190,7 @@ pub fn parse_icloud_brctl_quota( let mut snapshot = CloudCapacitySnapshot { schema_version: CAPACITY_SCHEMA_VERSION, provider: CloudProvider::Icloud, + account_scope: Some(CloudAccountScope::Personal), evidence_kind: CapacityEvidenceKind::ProviderNativeStatus, observed_at_ms, total_bytes: None, @@ -290,6 +332,12 @@ pub fn parse_onedrive_capacity( .drive_type .filter(|value| matches!(value.as_str(), "personal" | "business" | "documentLibrary")) .ok_or_else(|| "onedrive-capacity-drive-type-invalid".to_string())?; + let account_scope = match drive_type.as_str() { + "personal" => CloudAccountScope::Personal, + "business" => CloudAccountScope::Organization, + "documentLibrary" => CloudAccountScope::Shared, + _ => return Err("onedrive-capacity-drive-type-invalid".into()), + }; let quota = response .quota .ok_or_else(|| "onedrive-quota-missing".to_string())?; @@ -314,6 +362,7 @@ pub fn parse_onedrive_capacity( let mut snapshot = CloudCapacitySnapshot { schema_version: CAPACITY_SCHEMA_VERSION, provider: CloudProvider::Onedrive, + account_scope: Some(account_scope), evidence_kind: CapacityEvidenceKind::ProviderApi, observed_at_ms, total_bytes: Some(total), @@ -438,6 +487,7 @@ pub fn parse_google_drive_capacity( let mut snapshot = CloudCapacitySnapshot { schema_version: CAPACITY_SCHEMA_VERSION, provider: CloudProvider::GoogleDrive, + account_scope: None, evidence_kind: CapacityEvidenceKind::ProviderApi, observed_at_ms, total_bytes: total, @@ -464,6 +514,7 @@ pub fn unavailable_capacity( CloudCapacitySnapshot { schema_version: CAPACITY_SCHEMA_VERSION, provider, + account_scope: None, evidence_kind: CapacityEvidenceKind::Unavailable, observed_at_ms, total_bytes: None, @@ -685,6 +736,39 @@ mod tests { ); } + #[test] + fn provider_capacity_scope_refines_unknown_root_and_rejects_mismatches() { + let root = CloudRoot { + id: "icloud:test".into(), + provider: CloudProvider::Icloud, + account_scope: CloudAccountScope::Unknown, + label: "iCloud Drive".into(), + path: "/Cloud/iCloud".into(), + readable: true, + access_issue: None, + }; + let snapshot = + parse_icloud_brctl_quota("100 bytes of quota remaining in personal account\n", 1) + .unwrap(); + + let refined = root_with_verified_capacity_scope(&root, &snapshot).unwrap(); + assert_eq!(refined.account_scope, CloudAccountScope::Personal); + + let mut conflicting = root.clone(); + conflicting.account_scope = CloudAccountScope::Organization; + assert_eq!( + root_with_verified_capacity_scope(&conflicting, &snapshot).unwrap_err(), + "cloud-capacity-account-scope-conflict" + ); + + let mut wrong_provider = snapshot; + wrong_provider.provider = CloudProvider::Onedrive; + assert_eq!( + root_with_verified_capacity_scope(&root, &wrong_provider).unwrap_err(), + "cloud-capacity-provider-mismatch" + ); + } + #[test] fn parses_icloud_native_remaining_quota_without_inventing_total_usage() { let snapshot = parse_icloud_brctl_quota( @@ -694,6 +778,7 @@ mod tests { .unwrap(); assert_eq!(snapshot.schema_version, CAPACITY_SCHEMA_VERSION); assert_eq!(snapshot.provider, CloudProvider::Icloud); + assert_eq!(snapshot.account_scope, Some(CloudAccountScope::Personal)); assert_eq!( snapshot.evidence_kind, CapacityEvidenceKind::ProviderNativeStatus @@ -743,6 +828,7 @@ mod tests { 30, ) .unwrap(); + assert_eq!(snapshot.account_scope, Some(CloudAccountScope::Personal)); assert_eq!(snapshot.remaining_bytes, Some(4_000)); assert_eq!(snapshot.state, CloudCapacityState::Normal); assert_eq!(snapshot.evidence_fingerprint.as_ref().unwrap().len(), 64); @@ -757,6 +843,22 @@ mod tests { assert!(full .blockers .contains(&"cloud-capacity-insufficient-with-reserve".to_string())); + + let organization = parse_onedrive_capacity( + r#"{"id":"business-drive","driveType":"business","quota":{"remaining":4000,"state":"normal","total":10000,"used":6000}}"#, + 31, + ) + .unwrap(); + assert_eq!( + organization.account_scope, + Some(CloudAccountScope::Organization) + ); + let shared = parse_onedrive_capacity( + r#"{"id":"library-drive","driveType":"documentLibrary","quota":{"remaining":4000,"state":"normal","total":10000,"used":6000}}"#, + 32, + ) + .unwrap(); + assert_eq!(shared.account_scope, Some(CloudAccountScope::Shared)); } #[test] @@ -785,6 +887,7 @@ mod tests { 40, ) .unwrap(); + assert_eq!(limited.account_scope, None); assert_eq!(limited.used_bytes, Some(9_951)); assert_eq!(limited.remaining_bytes, Some(49)); assert_eq!(limited.state, CloudCapacityState::Critical); @@ -863,6 +966,7 @@ mod tests { 1, ); assert_eq!(unavailable.can_fit, None); + assert_eq!(unavailable.snapshot.account_scope, None); assert_eq!( unavailable.blockers, ["icloud-quota-api-unavailable".to_string()]