From 9bcb14ff9719029411591036b249537b22d2f3fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:40:34 +0900 Subject: [PATCH] feat: require exact approval for cloud copy actions (#125) * feat: bind exact human approval to cloud copies * test: stage live-clock cloud approval freshness repair * ci: validate and publish PR 125 live-clock repair * ci: make PR 125 freshness repair exact-head observable * chore: remove completed PR 125 repair workflow * chore: remove completed PR 125 repair script * chore: add one-shot PR 125 live-clock repair * chore: replace malformed one-shot PR 125 workflow * chore: add audited PR 125 live-clock repair script * chore: run audited PR 125 live-clock repair * ci: isolate PR 125 live-clock validation from publish * ci: retrigger audited PR125 live-clock repair * ci: limit PR125 repair formatting to audited files * ci: scope PR125 formatting verification to repaired files * fix: bind cloud copy approval to live mutation time * ci: repair PR125 coverage cfg mismatch * ci: repair PR 125 coverage helper * ci: trigger PR 125 coverage repair * ci: scope PR125 repair formatting to audited source * ci: supersede stale PR125 repair runs * ci: retrigger exact-head PR125 coverage repair * ci: consolidate PR125 coverage repair workflow * ci: scope PR125 repair token permissions * ci: retrigger exact-head PR 125 coverage repair * ci: publish bounded PR125 coverage repair * fix: compile cloud transfer tests under coverage * test: keep cloud approval fixture available under coverage * ci: add bounded PR 125 approval phrase repair * chore: remove stale PR 125 repair workflow * ci: retry bounded PR 125 phrase ownership repair * test: stage backend-owned approval phrase repair * ci: execute exact PR 125 backend phrase repair * ci: repair PR 125 backend approval phrase contract * ci: securely finalize PR 125 backend phrase repair * chore: remove completed PR 125 repair workflow * chore: remove completed approval phrase repair workflow * chore: remove completed PR 125 repair helper * ci: publish PR 125 backend phrase repair with final-head checks * chore: remove reintroduced completed repair workflow * docs: record cloud copy approval contract * ci: trigger exact-head backend phrase repair * test: add bounded PR 125 backend phrase finalizer * ci: finalize PR 125 backend phrase ownership * ci: allow exact-head PR 125 finalizer run * ci: trigger PR 125 finalizer on ready review * ci: harden PR 125 finalizer changed-file validation * ci: run PR 125 finalizer on exact workflow push * ci: expose exact PR 125 finalizer run * chore: remove completed PR 125 finalize workflow * chore: remove completed PR 125 finalize script * chore: remove completed PR 125 repair workflow * chore: remove completed PR 125 repair script * docs: clarify fail-closed approval phrase behavior * test: require docs for cloud copy approval APIs * chore: stage bounded cloud copy doc repair * ci: apply bounded cloud copy documentation repair * ci: authorize exact-branch one-shot repair * chore: remove superseded one-shot doc repair * ci: stage non-weakened PR 125 documentation repair * ci: run exact-head non-weakened PR 125 documentation repair * docs: restore cloud copy approval API contract * docs: correct approval phrase provenance and preserve regression evidence * ci: stage test-first backend approval phrase repair * ci: execute test-first backend approval phrase repair * ci: fix exact-file validation for PR 125 repair * feat: source cloud approval phrase from Rust plan * docs: describe backend-authored cloud approval phrase --------- Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 + src-tauri/src/bin/disksage-cloud-plan.rs | 195 +++-- src-tauri/src/cloud_plan_view.rs | 221 ++++++ src-tauri/src/cloud_transfer.rs | 695 ++++++++++++++++-- src-tauri/src/commands.rs | 406 +++++++--- src-tauri/src/lib.rs | 2 + src-tauri/src/naruon_lineage.rs | 79 +- .../tests/cloud_transfer_coverage_contract.rs | 95 +++ src/lib/CloudArchive.svelte | 143 +++- src/lib/api.test.ts | 29 +- src/lib/api.ts | 41 ++ 11 files changed, 1642 insertions(+), 268 deletions(-) create mode 100644 src-tauri/src/cloud_plan_view.rs create mode 100644 src-tauri/tests/cloud_transfer_coverage_contract.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e165659..166510adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Changed +- Require a fresh, exact, human-attributed approval and rationale for cloud copy-only and existing-copy adoption actions, with a 15-minute authorization lifetime bound to the candidate, destination, provider, account scope, and review fingerprint. +- Return the candidate-specific cloud copy approval action, exact confirmation phrase, and maximum approval age from the Rust plan contract; the frontend only displays and submits that backend-authored phrase and fails closed when it is missing or does not match the candidate action. - Align the frontend toolchain on Vite 8.2 and `@sveltejs/vite-plugin-svelte` 7.2 so the declared peer dependency graph is installable and reproducible. - Declare the supported Node.js runtime floor as Node.js 20.19 or Node.js 22.12 and later, matching Vite 8 requirements. - Pin the primary test workflow to Node.js 20.19.0 so the minimum supported runtime is continuously verified. @@ -16,8 +18,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Fixed - Hardened iCloud local-copy batch eviction with fresh per-item timestamps, deterministic planner/executor/recorder/clock seams, fail-closed immutable checkpoint handling, bounded manifest admission, symlink-safe control-path validation, and distinct operator diagnostics. +- Restored the cloud-copy public documentation regression contract after a temporary repair path removed it, so CI continues to fail when the new Rust or TypeScript approval surfaces lose beginner-readable documentation. ### Security +- Persist copy-approval provenance in immutable receipt lineage, reject stale, generic, mismatched, or tampered approvals, and retain explicit backward readability for pre-approval receipt formats. - Generate the npm lockfile in an exact-head validation job with repository contents read-only and dependency lifecycle scripts disabled, bind the artifact to SHA-256 evidence, and grant `contents: write` only to a separate publication job that verifies the same-run artifact and unchanged branch head before committing the lockfile. - Removed obsolete one-shot repair workflows and patch scripts so repository automation no longer retains dormant write-capable recovery paths. diff --git a/src-tauri/src/bin/disksage-cloud-plan.rs b/src-tauri/src/bin/disksage-cloud-plan.rs index 3bffaf083..65e56555d 100644 --- a/src-tauri/src/bin/disksage-cloud-plan.rs +++ b/src-tauri/src/bin/disksage-cloud-plan.rs @@ -71,6 +71,7 @@ struct Args { copy_fingerprint: Option, adopt_existing_fingerprint: Option, receipt_dir: Option, + confirm_copy_phrase: Option, attest_receipt: Option, evidence_dir: Option, provider_object_id: Option, @@ -193,6 +194,7 @@ fn parse_args(args: &[String], home: &Path) -> Result { copy_fingerprint: None, adopt_existing_fingerprint: None, receipt_dir: None, + confirm_copy_phrase: None, attest_receipt: None, evidence_dir: None, provider_object_id: None, @@ -320,6 +322,10 @@ fn parse_args(args: &[String], home: &Path) -> Result { "--receipt-dir" => { parsed.receipt_dir = Some(PathBuf::from(value(args, &mut index, "--receipt-dir")?)) } + "--confirm-copy-phrase" => { + parsed.confirm_copy_phrase = + Some(value(args, &mut index, "--confirm-copy-phrase")?) + } "--attest-receipt" => { parsed.attest_receipt = Some(PathBuf::from(value( args, @@ -439,7 +445,7 @@ fn parse_args(args: &[String], home: &Path) -> Result { "--export-semantic-catalog" => parsed.export_semantic_catalog = true, "--help" | "-h" => { return Err( - "usage: disksage-cloud-plan [--list-roots | --inspect-roots] [--root PATH] [--cloud-root PATH | --provider icloud|onedrive|google-drive | --all-readable-roots --decision-summary] [--min-size-mib N] [--min-age-days N] [--limit N] [--decision-summary [--private-candidate-inspection-output ABSOLUTE_NEW_FILE.json | --review-reason-set REASON|REASON [--private-review-output ABSOLUTE_NEW_FILE.json]] | --exact-duplicate-review-prefix DIR_PREFIX --exact-duplicate-kind document|media|archive|dataset|backup|creative|incomplete-download | --export-naruon-copy-readiness --verify-capacity [--naruon-copy-readiness-output ABSOLUTE_NEW_FILE.json] | --export-semantic-catalog] [--verify-capacity [--oauth-connections ABSOLUTE_PATH] [--export-naruon-capacity]] [--capacity-reserve-mib N] [--copy-fingerprint HEX64 --receipt-dir PATH [--review-dir PATH] [--oauth-connections ABSOLUTE_PATH] | --adopt-existing-fingerprint HEX64 --receipt-dir PATH [--review-dir PATH] | --attest-receipt RECEIPT.json --evidence-dir ABSOLUTE_PATH [--oauth-connections ABSOLUTE_PATH [--provider-object-id GOOGLE_FILE_ID]] | --evict-receipt RECEIPT.json --confirm-receipt-id HEX64 --eviction-dir ABSOLUTE_PATH --eviction-approval-dir ABSOLUTE_PATH --journal-path ABSOLUTE_PATH --evidence-dir ABSOLUTE_PATH --reviewed-by human:ID --review-rationale TEXT [--oauth-connections ABSOLUTE_PATH [--provider-object-id GOOGLE_FILE_ID]] | --review-candidate-fingerprint HEX64 --review-fingerprint HEX64 --review-disposition approved|held --reviewed-by human:ID --review-rationale TEXT --review-dir PATH | --export-naruon-lineage RECEIPT.json [--naruon-sync-evidence EVIDENCE.json]]".into(), + "usage: disksage-cloud-plan [--list-roots | --inspect-roots] [--root PATH] [--cloud-root PATH | --provider icloud|onedrive|google-drive | --all-readable-roots --decision-summary] [--min-size-mib N] [--min-age-days N] [--limit N] [--decision-summary [--private-candidate-inspection-output ABSOLUTE_NEW_FILE.json | --review-reason-set REASON|REASON [--private-review-output ABSOLUTE_NEW_FILE.json]] | --exact-duplicate-review-prefix DIR_PREFIX --exact-duplicate-kind document|media|archive|dataset|backup|creative|incomplete-download | --export-naruon-copy-readiness --verify-capacity [--naruon-copy-readiness-output ABSOLUTE_NEW_FILE.json] | --export-semantic-catalog] [--verify-capacity [--oauth-connections ABSOLUTE_PATH] [--export-naruon-capacity]] [--capacity-reserve-mib N] [--copy-fingerprint HEX64 --receipt-dir PATH --confirm-copy-phrase EXACT --reviewed-by human:ID --review-rationale TEXT [--review-dir PATH] [--oauth-connections ABSOLUTE_PATH] | --adopt-existing-fingerprint HEX64 --receipt-dir PATH --confirm-copy-phrase EXACT --reviewed-by human:ID --review-rationale TEXT [--review-dir PATH] | --attest-receipt RECEIPT.json --evidence-dir ABSOLUTE_PATH [--oauth-connections ABSOLUTE_PATH [--provider-object-id GOOGLE_FILE_ID]] | --evict-receipt RECEIPT.json --confirm-receipt-id HEX64 --eviction-dir ABSOLUTE_PATH --eviction-approval-dir ABSOLUTE_PATH --journal-path ABSOLUTE_PATH --evidence-dir ABSOLUTE_PATH --reviewed-by human:ID --review-rationale TEXT [--oauth-connections ABSOLUTE_PATH [--provider-object-id GOOGLE_FILE_ID]] | --review-candidate-fingerprint HEX64 --review-fingerprint HEX64 --review-disposition approved|held --reviewed-by human:ID --review-rationale TEXT --review-dir PATH | --export-naruon-lineage RECEIPT.json [--naruon-sync-evidence EVIDENCE.json]]".into(), ) } flag => return Err(format!("알 수 없는 인자: {flag}")), @@ -517,6 +523,9 @@ fn validate_action_args(args: &Args) -> Result<(), String> { if (copy_action || adoption_action) != args.receipt_dir.is_some() { return Err("copy/adoption fingerprint와 --receipt-dir은 함께 지정해야 함".into()); } + if (copy_action || adoption_action) != args.confirm_copy_phrase.is_some() { + return Err("copy/adoption action에는 --confirm-copy-phrase가 반드시 필요함".into()); + } let review_evidence_fields = [ args.review_candidate_fingerprint.is_some(), args.review_fingerprint.is_some(), @@ -538,6 +547,9 @@ fn validate_action_args(args: &Args) -> Result<(), String> { if review_action && !attributed { return Err("review action에는 reviewer와 rationale가 필요함".into()); } + if (copy_action || adoption_action) && !attributed { + return Err("copy/adoption action에는 reviewer와 rationale가 필요함".into()); + } if attributed { cloud_review::validate_review_attribution( args.reviewed_by @@ -563,8 +575,11 @@ fn validate_action_args(args: &Args) -> Result<(), String> { ); } let eviction_action = eviction_fields.iter().all(|value| *value) && attributed; - if attributed && !review_action && !eviction_action { - return Err("reviewer와 rationale는 review 또는 eviction action에만 지정할 수 있음".into()); + if attributed && !review_action && !copy_action && !adoption_action && !eviction_action { + return Err( + "reviewer와 rationale는 review, copy, adoption 또는 eviction action에만 지정할 수 있음" + .into(), + ); } let attestation_action = args.attest_receipt.is_some(); if (attestation_action || eviction_action) != args.evidence_dir.is_some() { @@ -618,17 +633,11 @@ fn validate_action_args(args: &Args) -> Result<(), String> { return Err("--export-naruon-capacity에는 --verify-capacity가 필요함".into()); } if args.export_naruon_copy_readiness && !args.verify_capacity { - return Err( - "--export-naruon-copy-readiness에는 --verify-capacity가 필요함" - .into(), - ); + return Err("--export-naruon-copy-readiness에는 --verify-capacity가 필요함".into()); } - if args.naruon_copy_readiness_output.is_some() - && !args.export_naruon_copy_readiness - { + if args.naruon_copy_readiness_output.is_some() && !args.export_naruon_copy_readiness { return Err( - "--naruon-copy-readiness-output에는 --export-naruon-copy-readiness가 필요함" - .into(), + "--naruon-copy-readiness-output에는 --export-naruon-copy-readiness가 필요함".into(), ); } if args @@ -636,9 +645,7 @@ fn validate_action_args(args: &Args) -> Result<(), String> { .as_ref() .is_some_and(|path| !path.is_absolute()) { - return Err( - "--naruon-copy-readiness-output은 절대 경로여야 함".into(), - ); + return Err("--naruon-copy-readiness-output은 절대 경로여야 함".into()); } let actions = usize::from(args.list_roots) + usize::from(args.inspect_roots) @@ -932,6 +939,13 @@ fn decision_aggregates(report: &cloud::CloudPlanReport) -> serde_json::Value { #[cfg(not(coverage))] fn redacted_decision(candidate: &cloud::CloudCandidate) -> serde_json::Value { + let approval_action = match candidate.blocked_reason.as_deref() { + None => Some(cloud_transfer::CloudCopyApprovalAction::CopyOnly), + Some("destination-exists") => { + Some(cloud_transfer::CloudCopyApprovalAction::AdoptExistingCopy) + } + Some(_) => None, + }; serde_json::json!({ "metadata_fingerprint": &candidate.metadata_fingerprint, "review_fingerprint": &candidate.review_fingerprint, @@ -947,6 +961,10 @@ fn redacted_decision(candidate: &cloud::CloudCandidate) -> serde_json::Value { "requires_review": candidate.requires_review, "review_reasons": &candidate.review_reasons, "blocked_reason": &candidate.blocked_reason, + "copy_approval_action": approval_action, + "exact_copy_approval_phrase": approval_action + .map(|action| cloud_transfer::cloud_copy_approval_phrase(candidate, action)), + "copy_approval_max_age_ms": cloud_transfer::MAX_CLOUD_COPY_APPROVAL_AGE_MS, }) } @@ -1028,7 +1046,7 @@ fn review_batch_summary( .collect::>(); Ok(serde_json::json!({ - "schema_version": 2, + "schema_version": 3, "output_mode": "review-batch-summary", "generated_at_ms": report.generated_at_ms, "source_selection_policy": report.source_selection_policy, @@ -1054,6 +1072,8 @@ fn review_batch_summary( "summary_is_dry_run_only": true, "batch_fingerprint_is_not_approval": true, "candidate_review_decisions_remain_individual": true, + "exact_human_attributed_copy_approval_required": true, + "copy_approval_max_age_ms": cloud_transfer::MAX_CLOUD_COPY_APPROVAL_AGE_MS, }, "redacted_from_summary": [ "absolute-source-path", @@ -1656,7 +1676,7 @@ fn decision_summary(report: &cloud::CloudPlanReport) -> serde_json::Value { .collect::>(); serde_json::json!({ - "schema_version": 2, + "schema_version": 3, "output_mode": "decision-summary", "generated_at_ms": report.generated_at_ms, "source_selection_policy": report.source_selection_policy, @@ -1672,6 +1692,8 @@ fn decision_summary(report: &cloud::CloudPlanReport) -> serde_json::Value { "filename_dates_are_auxiliary": true, "summary_is_dry_run_only": true, "review_fingerprints_bind_operator_decisions": true, + "exact_human_attributed_copy_approval_required": true, + "copy_approval_is_bound_to_review_fingerprint_and_action": true, "verified_provider_sync_required_before_local_eviction": true, }, "cloud": { @@ -1801,7 +1823,7 @@ fn compact_decision_summary(report: &cloud::CloudPlanReport) -> serde_json::Valu let organization_manifest = organization_manifest_summary(report); serde_json::json!({ - "schema_version": 2, + "schema_version": 3, "output_mode": "compact-decision-summary", "generated_at_ms": report.generated_at_ms, "source_selection_policy": report.source_selection_policy, @@ -1818,6 +1840,8 @@ fn compact_decision_summary(report: &cloud::CloudPlanReport) -> serde_json::Valu "summary_is_dry_run_only": true, "batch_fingerprint_is_not_approval": true, "private_candidate_review_required_before_copy": true, + "exact_human_attributed_copy_approval_required": true, + "copy_approval_max_age_ms": cloud_transfer::MAX_CLOUD_COPY_APPROVAL_AGE_MS, "verified_provider_sync_required_before_local_eviction": true, }, "cloud": { @@ -1997,10 +2021,7 @@ fn attach_local_copy_prerequisites(report: &mut cloud::CloudPlanReport, home: &P if report.cloud_root.provider == CloudProvider::Icloud { let health = icloud_sync_health::inspect_new_copy_admission(home, cloud::system_now_ms()).ok(); - icloud_sync_health::attach_new_copy_admission_notice( - &mut report.notices, - health.as_ref(), - ); + icloud_sync_health::attach_new_copy_admission_notice(&mut report.notices, health.as_ref()); } } @@ -2347,7 +2368,7 @@ fn run() -> Result<(), String> { "cloud-capacity-unverified" }; let output = serde_json::json!({ - "schema_version": 2, + "schema_version": 3, "output_mode": "multicloud-decision-summary", "source_snapshot": { "candidate_count": snapshot.candidate_count(), @@ -2399,30 +2420,23 @@ fn run() -> Result<(), String> { observed_at_ms, ); let icloud_health = if selected.provider == CloudProvider::Icloud { - icloud_sync_health::inspect_new_copy_admission( - &home, - observed_at_ms, - ) - .ok() + icloud_sync_health::inspect_new_copy_admission(&home, observed_at_ms).ok() } else { None }; - let envelope = - naruon_cloud_copy_readiness::export_naruon_cloud_copy_readiness( - &report, - &runtime, - icloud_health.as_ref(), - )?; + let envelope = naruon_cloud_copy_readiness::export_naruon_cloud_copy_readiness( + &report, + &runtime, + icloud_health.as_ref(), + )?; if let Some(output_path) = &args.naruon_copy_readiness_output { - let value = serde_json::to_value(&envelope).map_err(|_| { - "naruon-copy-readiness-output-json-invalid".to_string() - })?; + let value = serde_json::to_value(&envelope) + .map_err(|_| "naruon-copy-readiness-output-json-invalid".to_string())?; write_private_review_dossier(output_path, &value)?; } println!( "{}", - serde_json::to_string_pretty(&envelope) - .map_err(|error| error.to_string())? + serde_json::to_string_pretty(&envelope).map_err(|error| error.to_string())? ); return Ok(()); } @@ -2529,17 +2543,36 @@ fn run() -> Result<(), String> { } else { None }; + let action = if adopt_existing { + cloud_transfer::CloudCopyApprovalAction::AdoptExistingCopy + } else { + cloud_transfer::CloudCopyApprovalAction::CopyOnly + }; + let action_at_ms = cloud::system_now_ms(); + let copy_approval = cloud_transfer::create_cloud_copy_approval( + candidate, + &selected, + action, + action_at_ms, + args.reviewed_by + .as_deref() + .ok_or_else(|| "--reviewed-by가 필요함".to_string())?, + args.review_rationale + .as_deref() + .ok_or_else(|| "--review-rationale가 필요함".to_string())?, + args.confirm_copy_phrase + .as_deref() + .ok_or_else(|| "--confirm-copy-phrase가 필요함".to_string())?, + )?; if !adopt_existing { provider_client_runtime::require_provider_client_runtime( selected.provider, cloud::system_now_ms(), )?; if selected.provider == CloudProvider::Icloud { - let health = icloud_sync_health::inspect_new_copy_admission( - &home, - cloud::system_now_ms(), - ) - .map_err(|_| "icloud-new-copy-admission-evidence-unavailable".to_string())?; + let health = + icloud_sync_health::inspect_new_copy_admission(&home, cloud::system_now_ms()) + .map_err(|_| "icloud-new-copy-admission-evidence-unavailable".to_string())?; icloud_sync_health::require_new_copy_admission(&health)?; } let capacity_snapshot = report @@ -2562,20 +2595,20 @@ fn run() -> Result<(), String> { } } let (receipt, receipt_path) = if adopt_existing { - cloud_transfer::adopt_existing_cloud_copy_with_review( + cloud_transfer::adopt_existing_cloud_copy_with_approval( candidate, &selected, receipt_dir, - cloud::system_now_ms(), review_decision.as_ref(), + ©_approval, )? } else { - cloud_transfer::prepare_cloud_copy_with_review( + cloud_transfer::prepare_cloud_copy_with_approval( candidate, &selected, receipt_dir, - cloud::system_now_ms(), review_decision.as_ref(), + ©_approval, )? }; println!( @@ -3099,7 +3132,7 @@ mod tests { let summary = decision_summary(&report); let item = &summary["decisions"][0]; assert_eq!(summary["output_mode"], "decision-summary"); - assert_eq!(summary["schema_version"], 2); + assert_eq!(summary["schema_version"], 3); assert!(summary["redacted_from_summary"] .as_array() .unwrap() @@ -3124,6 +3157,31 @@ mod tests { ); assert!(item.get("relative_path").is_none()); assert_eq!(item["decision_state"], "review-required"); + assert_eq!(item["copy_approval_action"], "copy-only"); + assert_eq!( + item["exact_copy_approval_phrase"], + format!( + "DiskSage cloud copy-only {} 승인", + item["review_fingerprint"].as_str().unwrap() + ) + ); + assert_eq!(item["copy_approval_max_age_ms"], 15 * 60 * 1000); + let mut destination_exists = report.candidates[0].clone(); + destination_exists.blocked_reason = Some("destination-exists".into()); + let adoption = redacted_decision(&destination_exists); + assert_eq!(adoption["copy_approval_action"], "adopt-existing-copy"); + assert_eq!( + adoption["exact_copy_approval_phrase"], + format!( + "DiskSage cloud adopt-existing-copy {} 승인", + adoption["review_fingerprint"].as_str().unwrap() + ) + ); + + destination_exists.blocked_reason = Some("incomplete-download".into()); + let ineligible = redacted_decision(&destination_exists); + assert!(ineligible["copy_approval_action"].is_null()); + assert!(ineligible["exact_copy_approval_phrase"].is_null()); assert_eq!( summary["aggregates"]["decision_state"]["counts"]["review-required"], 1 @@ -3208,10 +3266,14 @@ mod tests { }; let compact = compact_decision_summary(&compact_report); assert_eq!(compact["output_mode"], "compact-decision-summary"); - assert_eq!(compact["schema_version"], 2); + assert_eq!(compact["schema_version"], 3); assert_eq!(compact["candidate_details_included"], false); assert_eq!(compact["cloud_write_executed"], false); assert_eq!(compact["source_eviction_authorized"], false); + assert_eq!( + compact["metadata_policy"]["exact_human_attributed_copy_approval_required"], + true + ); assert_eq!(compact["exact_duplicates"]["cluster_count"], 1); assert_eq!(compact["exact_duplicates"]["redundant_bytes"], 42); assert_eq!(compact["exact_duplicates"]["cluster_members_omitted"], true); @@ -3274,7 +3336,7 @@ mod tests { let reason_set = report.candidates[0].review_reasons.clone(); let review_batch = review_batch_summary(&report, &reason_set).unwrap(); assert_eq!(review_batch["output_mode"], "review-batch-summary"); - assert_eq!(review_batch["schema_version"], 2); + assert_eq!(review_batch["schema_version"], 3); assert!(review_batch["redacted_from_summary"] .as_array() .unwrap() @@ -3744,6 +3806,9 @@ mod tests { args.copy_fingerprint = Some("a".repeat(64)); assert!(validate_action_args(&args).is_err()); args.receipt_dir = Some(PathBuf::from("/receipts")); + args.confirm_copy_phrase = Some("exact copy phrase".into()); + args.reviewed_by = Some("human:local:test".into()); + args.review_rationale = Some("exact copy reviewed".into()); assert!(validate_action_args(&args).is_ok()); args.receipt_dir = Some(PathBuf::from("relative-receipts")); assert!(validate_action_args(&args).is_err()); @@ -3766,6 +3831,12 @@ mod tests { "b".repeat(64), "--receipt-dir".into(), "/receipts".into(), + "--confirm-copy-phrase".into(), + "exact copy phrase".into(), + "--reviewed-by".into(), + "human:local:test".into(), + "--review-rationale".into(), + "exact copy reviewed".into(), ], Path::new("/h"), ) @@ -3779,6 +3850,12 @@ mod tests { "e".repeat(64), "--receipt-dir".into(), "/receipts".into(), + "--confirm-copy-phrase".into(), + "exact adoption phrase".into(), + "--reviewed-by".into(), + "human:local:test".into(), + "--review-rationale".into(), + "exact adoption reviewed".into(), ], Path::new("/h"), ) @@ -3831,6 +3908,7 @@ mod tests { let help = parse_args(&["--help".into()], Path::new("/h")).unwrap_err(); assert!(help.contains("--reviewed-by human:ID")); + assert!(help.contains("--confirm-copy-phrase EXACT")); assert!(help.contains("--export-naruon-copy-readiness --verify-capacity")); assert!(help.contains("--naruon-copy-readiness-output ABSOLUTE_NEW_FILE.json")); assert!(help.contains("--private-candidate-inspection-output ABSOLUTE_NEW_FILE.json")); @@ -3851,6 +3929,9 @@ mod tests { let mut copy = parse_args(&[], Path::new("/h")).unwrap(); copy.copy_fingerprint = Some("a".repeat(64)); copy.receipt_dir = Some(PathBuf::from("/receipts")); + copy.confirm_copy_phrase = Some("exact copy phrase".into()); + copy.reviewed_by = Some("human:test".into()); + copy.review_rationale = Some("exact copy reviewed".into()); copy.oauth_connections = Some(PathBuf::from("/connections.json")); assert!(validate_action_args(©).is_ok()); @@ -3986,8 +4067,9 @@ mod tests { args.review_candidate_fingerprint = None; args.review_fingerprint = None; args.review_disposition = None; - args.reviewed_by = None; - args.review_rationale = None; + args.confirm_copy_phrase = Some("exact copy phrase".into()); + args.reviewed_by = Some("human:local:test".into()); + args.review_rationale = Some("exact copy reviewed".into()); assert!(validate_action_args(&args).is_ok()); let mut reason_set = parse_args( @@ -4091,11 +4173,8 @@ mod tests { ); assert!(validate_action_args(&export).is_ok()); - let missing_capacity = parse_args( - &["--export-naruon-copy-readiness".into()], - Path::new("/h"), - ) - .unwrap(); + let missing_capacity = + parse_args(&["--export-naruon-copy-readiness".into()], Path::new("/h")).unwrap(); assert!(validate_action_args(&missing_capacity).is_err()); let output_only = parse_args( diff --git a/src-tauri/src/cloud_plan_view.rs b/src-tauri/src/cloud_plan_view.rs new file mode 100644 index 000000000..e86ce84aa --- /dev/null +++ b/src-tauri/src/cloud_plan_view.rs @@ -0,0 +1,221 @@ +//! Backend-authored cloud-plan presentation contract. +//! +//! The core planner remains independent of approval presentation. This adapter enriches each +//! serialized candidate with the only action currently available and the exact phrase generated by +//! Rust for that action. Frontends may display the value but must not reconstruct authorization +//! text independently. + +use crate::cloud::{ + CloudCandidate, CloudPlanOptions, CloudPlanReport, CloudRoot, ExactDuplicateSummary, +}; +use crate::cloud_transfer::{ + cloud_copy_approval_phrase, CloudCopyApprovalAction, MAX_CLOUD_COPY_APPROVAL_AGE_MS, +}; +use crate::provider_capacity::CloudCapacityAssessment; + +/// One cloud candidate plus the backend-authored approval presentation for its current state. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CloudPlanCandidateView { + /// Original candidate evidence and destination decision fields. + #[serde(flatten)] + pub candidate: CloudCandidate, + /// Exact action available for this candidate, or `None` when another blocker applies. + pub copy_approval_action: Option, + /// Candidate-specific confirmation phrase generated by Rust for the available action. + pub exact_copy_approval_phrase: Option, + /// Maximum age, in milliseconds, accepted for an approval created from this plan. + pub copy_approval_max_age_ms: u64, +} + +impl From for CloudPlanCandidateView { + fn from(candidate: CloudCandidate) -> Self { + let action = match candidate.blocked_reason.as_deref() { + None => Some(CloudCopyApprovalAction::CopyOnly), + Some("destination-exists") => Some(CloudCopyApprovalAction::AdoptExistingCopy), + Some(_) => None, + }; + let exact_copy_approval_phrase = + action.map(|action| cloud_copy_approval_phrase(&candidate, action)); + Self { + candidate, + copy_approval_action: action, + exact_copy_approval_phrase, + copy_approval_max_age_ms: MAX_CLOUD_COPY_APPROVAL_AGE_MS, + } + } +} + +/// Serialized cloud plan consumed by the desktop UI and compatible CWL modules. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CloudPlanReportView { + /// Destination root selected and revalidated by the planner. + pub cloud_root: CloudRoot, + /// Millisecond Unix timestamp at which the plan was generated. + pub generated_at_ms: u64, + /// Source-selection policy used to collect this bounded candidate set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_selection_policy: Option, + /// Candidate evidence enriched with backend-authored approval presentation. + pub candidates: Vec, + /// Total logical bytes represented by all candidates. + pub candidate_bytes: u64, + /// Candidate bytes that may become locally reclaimable after all safety gates pass. + pub potentially_reclaimable_bytes: u64, + /// Read-only exact-duplicate analysis attached to the plan. + pub exact_duplicates: ExactDuplicateSummary, + /// Authenticated provider capacity evidence when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capacity: Option, + /// Stable operator notices produced by the planner and provider gates. + pub notices: Vec, +} + +impl From for CloudPlanReportView { + fn from(report: CloudPlanReport) -> Self { + let CloudPlanReport { + cloud_root, + generated_at_ms, + source_selection_policy, + candidates, + candidate_bytes, + potentially_reclaimable_bytes, + exact_duplicates, + capacity, + notices, + } = report; + Self { + cloud_root, + generated_at_ms, + source_selection_policy, + candidates: candidates + .into_iter() + .map(CloudPlanCandidateView::from) + .collect(), + candidate_bytes, + potentially_reclaimable_bytes, + exact_duplicates, + capacity, + notices, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cloud::{ArchiveKind, CloudAccountScope, CloudProvider, MetadataEvidence}; + + fn candidate(blocked_reason: Option<&str>) -> CloudCandidate { + CloudCandidate { + metadata_fingerprint: "a".repeat(64), + review_fingerprint: "b".repeat(64), + src: "/source/report.pdf".into(), + dst: "/cloud/DiskSage Archive/documents/report.pdf".into(), + provider: CloudProvider::Icloud, + destination_account_scope: CloudAccountScope::Personal, + kind: ArchiveKind::Document, + bytes: 4096, + age_days: 120, + created_ms: 1, + modified_ms: 2, + production_time_ms: 1, + production_time_source: "embedded:pdf".into(), + production_time_confidence: "high".into(), + source_root: "/source".into(), + relative_path: "report.pdf".into(), + source_context: "source".into(), + requires_review: false, + review_reasons: Vec::new(), + content_title: Some("Report".into()), + content_authors: vec!["Analyst".into()], + content_context: Vec::new(), + duration_ms: None, + dataset_profile: None, + metadata_evidence: vec![MetadataEvidence { + field: "title".into(), + value: "Report".into(), + source: "pdf-info".into(), + confidence: "high".into(), + }], + blocked_reason: blocked_reason.map(str::to_owned), + } + } + + #[test] + fn new_copy_candidate_exports_rust_phrase_and_lifetime() { + let view = CloudPlanCandidateView::from(candidate(None)); + assert_eq!( + view.copy_approval_action, + Some(CloudCopyApprovalAction::CopyOnly) + ); + assert_eq!( + view.exact_copy_approval_phrase.as_deref(), + Some(format!("DiskSage cloud copy-only {} 승인", "b".repeat(64)).as_str()) + ); + assert_eq!( + view.copy_approval_max_age_ms, + MAX_CLOUD_COPY_APPROVAL_AGE_MS + ); + let serialized = serde_json::to_value(&view).unwrap(); + assert_eq!(serialized["copy_approval_action"], "copy-only"); + assert_eq!(serialized["copy_approval_max_age_ms"], 900_000); + } + + #[test] + fn destination_collision_exports_existing_copy_adoption_phrase() { + let view = CloudPlanCandidateView::from(candidate(Some("destination-exists"))); + assert_eq!( + view.copy_approval_action, + Some(CloudCopyApprovalAction::AdoptExistingCopy) + ); + assert_eq!( + view.exact_copy_approval_phrase.as_deref(), + Some(format!("DiskSage cloud adopt-existing-copy {} 승인", "b".repeat(64)).as_str()) + ); + } + + #[test] + fn unrelated_blocker_exports_no_authorization_text() { + let view = CloudPlanCandidateView::from(candidate(Some("source-changed"))); + assert_eq!(view.copy_approval_action, None); + assert_eq!(view.exact_copy_approval_phrase, None); + let serialized = serde_json::to_value(&view).unwrap(); + assert!(serialized["copy_approval_action"].is_null()); + assert!(serialized["exact_copy_approval_phrase"].is_null()); + } + + #[test] + fn report_conversion_preserves_plan_evidence_and_enriches_candidates() { + let report = CloudPlanReport { + cloud_root: CloudRoot { + id: "icloud-personal".into(), + provider: CloudProvider::Icloud, + account_scope: CloudAccountScope::Personal, + label: "iCloud Drive".into(), + path: "/cloud".into(), + readable: true, + access_issue: None, + }, + generated_at_ms: 42, + source_selection_policy: Some(CloudPlanOptions::default()), + candidates: vec![candidate(None)], + candidate_bytes: 4096, + potentially_reclaimable_bytes: 4096, + exact_duplicates: ExactDuplicateSummary::default(), + capacity: None, + notices: vec!["cloud-quota-provider-native-verified".into()], + }; + let view = CloudPlanReportView::from(report); + assert_eq!(view.generated_at_ms, 42); + assert_eq!(view.candidate_bytes, 4096); + assert_eq!(view.candidates.len(), 1); + assert_eq!( + view.candidates[0].copy_approval_action, + Some(CloudCopyApprovalAction::CopyOnly) + ); + assert_eq!( + view.notices, + vec!["cloud-quota-provider-native-verified".to_string()] + ); + } +} diff --git a/src-tauri/src/cloud_transfer.rs b/src-tauri/src/cloud_transfer.rs index b7fe524e6..d8a54ef1c 100644 --- a/src-tauri/src/cloud_transfer.rs +++ b/src-tauri/src/cloud_transfer.rs @@ -9,8 +9,8 @@ use crate::cloud::{ CloudRoot, MetadataEvidence, ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON, }; use crate::cloud_review::{ - organization_tenant_authority_attested, validate_decision, CloudReviewDecision, - CloudReviewDisposition, DECISION_VERSION, + organization_tenant_authority_attested, validate_decision, validate_review_attribution, + CloudReviewDecision, CloudReviewDisposition, DECISION_VERSION, }; use crate::dataset_metadata::DatasetProfile; use crate::provider_evidence::{validate_sync_evidence_record, ProviderSyncEvidenceRecord}; @@ -23,8 +23,16 @@ use std::io::{Read, Write}; #[cfg(not(coverage))] use std::path::PathBuf; +/// Legacy receipt schema version retained for backward-compatible reads. pub const LEGACY_RECEIPT_VERSION: u32 = 2; -pub const RECEIPT_VERSION: u32 = 3; +/// Receipt schema version used before exact action approvals were embedded. +pub const PRE_APPROVAL_RECEIPT_VERSION: u32 = 3; +/// Current immutable cloud-copy receipt schema version. +pub const RECEIPT_VERSION: u32 = 4; +/// Schema version for one exact human cloud-copy approval. +pub const CLOUD_COPY_APPROVAL_VERSION: u32 = 1; +/// Maximum age accepted for an exact cloud-copy approval. +pub const MAX_CLOUD_COPY_APPROVAL_AGE_MS: u64 = 15 * 60 * 1000; #[cfg(not(coverage))] const MAX_RECEIPT_BYTES: u64 = 64 * 1024; @@ -56,6 +64,67 @@ impl CloudCopyVerificationMethod { } } +/// Identifies the exact cloud-copy action authorized by a human reviewer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CloudCopyApprovalAction { + /// Authorize creating a new provider copy while retaining the local source. + CopyOnly, + /// Authorize adopting an already-existing destination after digest verification. + AdoptExistingCopy, +} + +impl CloudCopyApprovalAction { + /// Return the stable kebab-case value stored in receipts and confirmation phrases. + pub fn as_str(self) -> &'static str { + match self { + Self::CopyOnly => "copy-only", + Self::AdoptExistingCopy => "adopt-existing-copy", + } + } + + fn verification_method(self) -> CloudCopyVerificationMethod { + match self { + Self::CopyOnly => CloudCopyVerificationMethod::CopiedByDiskSage, + Self::AdoptExistingCopy => CloudCopyVerificationMethod::AdoptedExisting, + } + } +} + +/// A fresh, human-attributed authorization for one exact candidate, destination, and action. +/// +/// The candidate review fingerprint binds the source, destination, provider/account scope, +/// production-time evidence, and displayed metadata. A generic confirmation such as `승인` can +/// never satisfy `exact_confirmation_phrase`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CloudCopyApproval { + /// Version of the approval record schema. + pub version: u32, + /// Integrity digest binding every field in this approval. + pub approval_id: String, + /// Exact copy or adoption action the reviewer authorized. + pub action: CloudCopyApprovalAction, + /// Metadata fingerprint of the candidate shown to the reviewer. + pub candidate_fingerprint: String, + /// Review fingerprint binding source, destination, scope, and displayed evidence. + pub review_fingerprint: String, + /// Cloud provider that will receive or already contains the destination object. + pub provider: CloudProvider, + /// Account boundary in which the destination is located. + pub destination_account_scope: CloudAccountScope, + /// Stable identifier of the reviewed cloud root. + pub cloud_root_id: String, + /// Millisecond Unix timestamp at which the reviewer approved the action. + pub approved_at_ms: u64, + /// Human-attributed reviewer identifier, such as `human:operator-id`. + pub approved_by: String, + /// Reviewer-authored explanation for approving this exact action. + pub rationale: String, + /// Exact candidate-specific phrase entered by the reviewer. + pub exact_confirmation_phrase: String, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct RemoteContentProof { @@ -109,6 +178,8 @@ pub struct CloudLineageSnapshot { pub duration_ms: Option, pub dataset_profile: Option, pub metadata_evidence: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_approval: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -170,6 +241,153 @@ fn absolute_without_parent(path: &Path) -> bool { .any(|component| matches!(component, std::path::Component::ParentDir)) } +fn valid_fingerprint(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn hash_copy_approval_value(hasher: &mut blake3::Hasher, value: &[u8]) { + hasher.update(&(value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +fn copy_approval_id_for(approval: &CloudCopyApproval) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"disksage-cloud-copy-approval-v1\0"); + hasher.update(&approval.version.to_le_bytes()); + for value in [ + approval.action.as_str().as_bytes(), + approval.candidate_fingerprint.as_bytes(), + approval.review_fingerprint.as_bytes(), + approval.provider.as_str().as_bytes(), + approval.destination_account_scope.as_str().as_bytes(), + approval.cloud_root_id.as_bytes(), + approval.approved_by.as_bytes(), + approval.rationale.as_bytes(), + approval.exact_confirmation_phrase.as_bytes(), + ] { + hash_copy_approval_value(&mut hasher, value); + } + hash_copy_approval_value(&mut hasher, &approval.approved_at_ms.to_le_bytes()); + hasher.finalize().to_hex().to_string() +} + +/// Build the exact phrase a human must enter for one candidate and action. +/// +/// The phrase includes the action and current review fingerprint, preventing a generic approval +/// from being replayed for a different source, destination, account scope, or operation. +pub fn cloud_copy_approval_phrase( + candidate: &CloudCandidate, + action: CloudCopyApprovalAction, +) -> String { + format!( + "DiskSage cloud {} {} 승인", + action.as_str(), + candidate.review_fingerprint + ) +} + +fn validate_cloud_copy_approval_integrity(approval: &CloudCopyApproval) -> Result<(), String> { + if approval.version != CLOUD_COPY_APPROVAL_VERSION { + return Err("cloud-copy-approval-version-unsupported".into()); + } + if !valid_fingerprint(&approval.approval_id) + || !valid_fingerprint(&approval.candidate_fingerprint) + || !valid_fingerprint(&approval.review_fingerprint) + { + return Err("cloud-copy-approval-fingerprint-invalid".into()); + } + validate_review_attribution(&approval.approved_by, &approval.rationale) + .map_err(|_| "cloud-copy-approval-attribution-invalid".to_string())?; + if approval.approved_at_ms == 0 { + return Err("cloud-copy-approval-time-invalid".into()); + } + if approval.cloud_root_id.trim().is_empty() { + return Err("cloud-copy-approval-root-id-missing".into()); + } + if approval.approval_id != copy_approval_id_for(approval) { + return Err("cloud-copy-approval-integrity-mismatch".into()); + } + Ok(()) +} + +/// Create an integrity-bound approval after validating the candidate, destination, actor, and phrase. +/// +/// This constructor fails closed when the candidate fingerprint is stale, the cloud root does not +/// match the candidate, the reviewer attribution is incomplete, or the exact phrase differs. +pub fn create_cloud_copy_approval( + candidate: &CloudCandidate, + cloud_root: &CloudRoot, + action: CloudCopyApprovalAction, + approved_at_ms: u64, + approved_by: &str, + rationale: &str, + exact_confirmation_phrase: &str, +) -> Result { + if candidate.review_fingerprint != candidate_review_fingerprint(candidate) + || !valid_fingerprint(&candidate.metadata_fingerprint) + || !valid_fingerprint(&candidate.review_fingerprint) + { + return Err("cloud-copy-approval-candidate-stale".into()); + } + if candidate.provider != cloud_root.provider + || candidate.destination_account_scope != cloud_root.account_scope + { + return Err("cloud-copy-approval-destination-mismatch".into()); + } + let expected_phrase = cloud_copy_approval_phrase(candidate, action); + if exact_confirmation_phrase != expected_phrase { + return Err("cloud-copy-exact-confirmation-phrase-mismatch".into()); + } + validate_review_attribution(approved_by, rationale) + .map_err(|_| "cloud-copy-approval-attribution-invalid".to_string())?; + let mut approval = CloudCopyApproval { + version: CLOUD_COPY_APPROVAL_VERSION, + approval_id: String::new(), + action, + candidate_fingerprint: candidate.metadata_fingerprint.clone(), + review_fingerprint: candidate.review_fingerprint.clone(), + provider: candidate.provider, + destination_account_scope: candidate.destination_account_scope, + cloud_root_id: cloud_root.id.clone(), + approved_at_ms, + approved_by: approved_by.to_string(), + rationale: rationale.to_string(), + exact_confirmation_phrase: exact_confirmation_phrase.to_string(), + }; + approval.approval_id = copy_approval_id_for(&approval); + validate_cloud_copy_approval_integrity(&approval)?; + Ok(approval) +} + +fn validate_cloud_copy_approval_for_action( + approval: &CloudCopyApproval, + candidate: &CloudCandidate, + cloud_root: &CloudRoot, + action: CloudCopyApprovalAction, + action_at_ms: u64, +) -> Result<(), String> { + validate_cloud_copy_approval_integrity(approval)?; + if approval.action != action + || approval.candidate_fingerprint != candidate.metadata_fingerprint + || approval.review_fingerprint != candidate.review_fingerprint + || approval.provider != candidate.provider + || approval.destination_account_scope != candidate.destination_account_scope + || approval.cloud_root_id != cloud_root.id + || approval.exact_confirmation_phrase != cloud_copy_approval_phrase(candidate, action) + { + return Err("cloud-copy-approval-context-mismatch".into()); + } + if approval.approved_at_ms > action_at_ms + || action_at_ms.saturating_sub(approval.approved_at_ms) > MAX_CLOUD_COPY_APPROVAL_AGE_MS + { + return Err("cloud-copy-approval-stale".into()); + } + Ok(()) +} + fn embedded_high_confidence(candidate: &CloudCandidate) -> bool { candidate.production_time_confidence == "high" && candidate.production_time_source.starts_with("embedded:") @@ -343,7 +561,7 @@ fn receipt_id_for( hasher.update(&source_modified_ms.to_le_bytes()); hasher.update(&copied_at_ms.to_le_bytes()); hasher.update(&[copy_verified as u8, provider_sync_confirmed as u8]); - if version >= RECEIPT_VERSION { + if version >= PRE_APPROVAL_RECEIPT_VERSION { hasher.update(b"\0lineage\0"); hasher.update(lineage_fingerprint.unwrap_or_default().as_bytes()); } @@ -354,6 +572,7 @@ fn lineage_snapshot( candidate: &CloudCandidate, review_decision: Option<&CloudReviewDecision>, copy_verification_method: CloudCopyVerificationMethod, + copy_approval: Option<&CloudCopyApproval>, ) -> CloudLineageSnapshot { CloudLineageSnapshot { candidate_fingerprint: candidate.metadata_fingerprint.clone(), @@ -386,6 +605,7 @@ fn lineage_snapshot( duration_ms: candidate.duration_ms, dataset_profile: candidate.dataset_profile.clone(), metadata_evidence: candidate.metadata_evidence.clone(), + copy_approval: copy_approval.cloned(), } } @@ -419,13 +639,49 @@ fn receipt_integrity_valid(receipt: &CloudCopyReceipt) -> bool { ) } +/// Validate the exact action approval embedded in a receipt without probing either path. +/// Version 3 receipts predate this approval and remain readable; version 4 requires it. +pub fn validate_receipt_copy_approval(receipt: &CloudCopyReceipt) -> Result<(), String> { + let lineage = receipt + .lineage + .as_ref() + .ok_or_else(|| "receipt-lineage-copy-approval-lineage-missing".to_string())?; + let approval = match lineage.copy_approval.as_ref() { + Some(approval) => approval, + None if receipt.version == PRE_APPROVAL_RECEIPT_VERSION => return Ok(()), + None => return Err("receipt-lineage-copy-approval-missing".into()), + }; + let expected_phrase = format!( + "DiskSage cloud {} {} 승인", + approval.action.as_str(), + lineage.review_fingerprint + ); + if validate_cloud_copy_approval_integrity(approval).is_err() + || approval.candidate_fingerprint != receipt.candidate_fingerprint + || approval.review_fingerprint != lineage.review_fingerprint + || approval.provider != receipt.provider + || approval.destination_account_scope != lineage.destination_account_scope + || approval.action.verification_method() != lineage.copy_verification_method + || approval.exact_confirmation_phrase != expected_phrase + || approval.approved_at_ms > receipt.copied_at_ms + || receipt.copied_at_ms.saturating_sub(approval.approved_at_ms) + > MAX_CLOUD_COPY_APPROVAL_AGE_MS + { + return Err("receipt-lineage-copy-approval-mismatch".into()); + } + Ok(()) +} + /// Validate a persisted copy receipt before any provider-specific filesystem or API probe. /// /// This function is read-only and deliberately excludes provider evidence. It prevents callers /// from trusting receipt-controlled paths before the receipt's structure and integrity pass. pub fn receipt_blockers(receipt: &CloudCopyReceipt) -> Vec { let mut blockers = Vec::new(); - if !matches!(receipt.version, LEGACY_RECEIPT_VERSION | RECEIPT_VERSION) { + if !matches!( + receipt.version, + LEGACY_RECEIPT_VERSION | PRE_APPROVAL_RECEIPT_VERSION | RECEIPT_VERSION + ) { blockers.push("receipt-version-unsupported".into()); } match receipt.version { @@ -434,50 +690,55 @@ pub fn receipt_blockers(receipt: &CloudCopyReceipt) -> Vec { blockers.push("legacy-receipt-lineage-unexpected".into()); } } - RECEIPT_VERSION => match (&receipt.lineage, &receipt.lineage_fingerprint) { - (Some(lineage), Some(fingerprint)) => { - if lineage.candidate_fingerprint != receipt.candidate_fingerprint { - blockers.push("receipt-lineage-candidate-mismatch".into()); - } - if lineage.modified_ms != receipt.source_modified_ms { - blockers.push("receipt-lineage-modified-time-mismatch".into()); - } - if lineage.review_fingerprint.len() != 64 - || !lineage - .review_fingerprint - .bytes() - .all(|byte| byte.is_ascii_hexdigit()) - { - blockers.push("receipt-lineage-review-fingerprint-invalid".into()); - } - let complete_review = lineage.review_decision_id.is_some() - && lineage.review_disposition.is_some() - && lineage.reviewed_at_ms.is_some(); - let empty_review = lineage.review_decision_id.is_none() - && lineage.review_disposition.is_none() - && lineage.reviewed_at_ms.is_none(); - let complete_attribution = - lineage.reviewed_by.is_some() && lineage.review_rationale.is_some(); - let empty_attribution = - lineage.reviewed_by.is_none() && lineage.review_rationale.is_none(); - if (lineage.requires_review && !complete_review) - || (!lineage.requires_review && !empty_review) - || (!complete_attribution && !empty_attribution) - { - blockers.push("receipt-lineage-review-decision-mismatch".into()); - } - let lineage_matches = lineage_fingerprint(lineage) - .map(|observed| observed == *fingerprint) - .unwrap_or(false); - if fingerprint.len() != 64 - || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) - || !lineage_matches - { - blockers.push("receipt-lineage-integrity-mismatch".into()); + PRE_APPROVAL_RECEIPT_VERSION | RECEIPT_VERSION => { + match (&receipt.lineage, &receipt.lineage_fingerprint) { + (Some(lineage), Some(fingerprint)) => { + if lineage.candidate_fingerprint != receipt.candidate_fingerprint { + blockers.push("receipt-lineage-candidate-mismatch".into()); + } + if lineage.modified_ms != receipt.source_modified_ms { + blockers.push("receipt-lineage-modified-time-mismatch".into()); + } + if lineage.review_fingerprint.len() != 64 + || !lineage + .review_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + blockers.push("receipt-lineage-review-fingerprint-invalid".into()); + } + let complete_review = lineage.review_decision_id.is_some() + && lineage.review_disposition.is_some() + && lineage.reviewed_at_ms.is_some(); + let empty_review = lineage.review_decision_id.is_none() + && lineage.review_disposition.is_none() + && lineage.reviewed_at_ms.is_none(); + let complete_attribution = + lineage.reviewed_by.is_some() && lineage.review_rationale.is_some(); + let empty_attribution = + lineage.reviewed_by.is_none() && lineage.review_rationale.is_none(); + if (lineage.requires_review && !complete_review) + || (!lineage.requires_review && !empty_review) + || (!complete_attribution && !empty_attribution) + { + blockers.push("receipt-lineage-review-decision-mismatch".into()); + } + if let Err(blocker) = validate_receipt_copy_approval(receipt) { + blockers.push(blocker); + } + let lineage_matches = lineage_fingerprint(lineage) + .map(|observed| observed == *fingerprint) + .unwrap_or(false); + if fingerprint.len() != 64 + || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) + || !lineage_matches + { + blockers.push("receipt-lineage-integrity-mismatch".into()); + } } + _ => blockers.push("receipt-lineage-missing".into()), } - _ => blockers.push("receipt-lineage-missing".into()), - }, + } _ => {} } if !receipt_integrity_valid(receipt) { @@ -918,11 +1179,17 @@ fn write_immutable_receipt( fn build_verified_receipt( candidate: &CloudCandidate, review_decision: Option<&CloudReviewDecision>, + copy_approval: &CloudCopyApproval, hashes: ContentDigests, verified_at_ms: u64, copy_verification_method: CloudCopyVerificationMethod, ) -> Result { - let lineage = lineage_snapshot(candidate, review_decision, copy_verification_method); + let lineage = lineage_snapshot( + candidate, + review_decision, + copy_verification_method, + Some(copy_approval), + ); let lineage_fingerprint = lineage_fingerprint(&lineage)?; let mut receipt = CloudCopyReceipt { version: RECEIPT_VERSION, @@ -961,29 +1228,43 @@ fn build_verified_receipt( Ok(receipt) } -/// Copy a pre-approved candidate into its cloud root and persist an immutable verification -/// receipt. The source is never removed, even when receipt persistence fails. +/// Copy a candidate only after validating both the optional metadata review decision and a fresh, +/// exact, human-attributed copy approval. The production entrypoint reads the live clock at the +/// mutation boundary so an earlier preflight cannot silently extend the approval lifetime. #[cfg(not(coverage))] -pub fn prepare_cloud_copy( +pub fn prepare_cloud_copy_with_approval( candidate: &CloudCandidate, cloud_root: &CloudRoot, receipt_dir: &Path, - copied_at_ms: u64, + review_decision: Option<&CloudReviewDecision>, + copy_approval: &CloudCopyApproval, ) -> Result<(CloudCopyReceipt, PathBuf), String> { - prepare_cloud_copy_with_review(candidate, cloud_root, receipt_dir, copied_at_ms, None) + prepare_cloud_copy_with_approval_at( + candidate, + cloud_root, + receipt_dir, + crate::cloud::system_now_ms(), + review_decision, + copy_approval, + ) } -/// Copy a candidate after validating an optional operator review decision. A current attributed -/// human approval can clear the exact review and production-time-confidence gates; every -/// path/provider/planner gate remains mandatory. #[cfg(not(coverage))] -pub fn prepare_cloud_copy_with_review( +fn prepare_cloud_copy_with_approval_at( candidate: &CloudCandidate, cloud_root: &CloudRoot, receipt_dir: &Path, copied_at_ms: u64, review_decision: Option<&CloudReviewDecision>, + copy_approval: &CloudCopyApproval, ) -> Result<(CloudCopyReceipt, PathBuf), String> { + validate_cloud_copy_approval_for_action( + copy_approval, + candidate, + cloud_root, + CloudCopyApprovalAction::CopyOnly, + copied_at_ms, + )?; let blockers = candidate_blockers_with_review(candidate, cloud_root, review_decision); if !blockers.is_empty() { return Err(blockers.join(",")); @@ -992,6 +1273,7 @@ pub fn prepare_cloud_copy_with_review( let receipt = build_verified_receipt( candidate, review_decision, + copy_approval, hashes, copied_at_ms, CloudCopyVerificationMethod::CopiedByDiskSage, @@ -1005,27 +1287,42 @@ pub fn prepare_cloud_copy_with_review( } } -/// Verify and adopt a destination that already exists under the selected cloud root. Neither file -/// is modified or removed. A receipt is issued only when the fresh planner reported exactly -/// `destination-exists` and all three content digests match. +/// Verify and adopt an existing destination only after the same exact human action approval. +/// The approval age is evaluated from a fresh live-clock read immediately before verification. #[cfg(not(coverage))] -pub fn adopt_existing_cloud_copy( +pub fn adopt_existing_cloud_copy_with_approval( candidate: &CloudCandidate, cloud_root: &CloudRoot, receipt_dir: &Path, - verified_at_ms: u64, + review_decision: Option<&CloudReviewDecision>, + copy_approval: &CloudCopyApproval, ) -> Result<(CloudCopyReceipt, PathBuf), String> { - adopt_existing_cloud_copy_with_review(candidate, cloud_root, receipt_dir, verified_at_ms, None) + adopt_existing_cloud_copy_with_approval_at( + candidate, + cloud_root, + receipt_dir, + crate::cloud::system_now_ms(), + review_decision, + copy_approval, + ) } #[cfg(not(coverage))] -pub fn adopt_existing_cloud_copy_with_review( +fn adopt_existing_cloud_copy_with_approval_at( candidate: &CloudCandidate, cloud_root: &CloudRoot, receipt_dir: &Path, verified_at_ms: u64, review_decision: Option<&CloudReviewDecision>, + copy_approval: &CloudCopyApproval, ) -> Result<(CloudCopyReceipt, PathBuf), String> { + validate_cloud_copy_approval_for_action( + copy_approval, + candidate, + cloud_root, + CloudCopyApprovalAction::AdoptExistingCopy, + verified_at_ms, + )?; let blockers = existing_copy_candidate_blockers_with_review(candidate, cloud_root, review_decision); if !blockers.is_empty() { @@ -1035,6 +1332,7 @@ pub fn adopt_existing_cloud_copy_with_review( let receipt = build_verified_receipt( candidate, review_decision, + copy_approval, hashes, verified_at_ms, CloudCopyVerificationMethod::AdoptedExisting, @@ -1043,6 +1341,84 @@ pub fn adopt_existing_cloud_copy_with_review( Ok((receipt, path)) } +#[cfg(test)] +fn test_copy_approval( + candidate: &CloudCandidate, + cloud_root: &CloudRoot, + action: CloudCopyApprovalAction, + approved_at_ms: u64, +) -> Result { + create_cloud_copy_approval( + candidate, + cloud_root, + action, + approved_at_ms, + "human:test", + "test-authorized exact candidate action", + &cloud_copy_approval_phrase(candidate, action), + ) +} + +/// Test-only compatibility helper that creates a valid exact approval before preparing a copy. +#[cfg(all(test, not(coverage)))] +pub fn prepare_cloud_copy( + candidate: &CloudCandidate, + cloud_root: &CloudRoot, + receipt_dir: &Path, + copied_at_ms: u64, +) -> Result<(CloudCopyReceipt, PathBuf), String> { + prepare_cloud_copy_with_review(candidate, cloud_root, receipt_dir, copied_at_ms, None) +} + +/// Test-only compatibility helper that combines metadata review and exact copy approval fixtures. +#[cfg(all(test, not(coverage)))] +pub fn prepare_cloud_copy_with_review( + candidate: &CloudCandidate, + cloud_root: &CloudRoot, + receipt_dir: &Path, + copied_at_ms: u64, + review_decision: Option<&CloudReviewDecision>, +) -> Result<(CloudCopyReceipt, PathBuf), String> { + let approval = test_copy_approval( + candidate, + cloud_root, + CloudCopyApprovalAction::CopyOnly, + copied_at_ms, + )?; + prepare_cloud_copy_with_approval_at( + candidate, + cloud_root, + receipt_dir, + copied_at_ms, + review_decision, + &approval, + ) +} + +/// Test-only compatibility helper that approves and verifies adoption of an existing copy. +#[cfg(all(test, not(coverage)))] +pub fn adopt_existing_cloud_copy( + candidate: &CloudCandidate, + cloud_root: &CloudRoot, + receipt_dir: &Path, + verified_at_ms: u64, +) -> Result<(CloudCopyReceipt, PathBuf), String> { + let approval = test_copy_approval( + candidate, + cloud_root, + CloudCopyApprovalAction::AdoptExistingCopy, + verified_at_ms, + )?; + adopt_existing_cloud_copy_with_approval_at( + candidate, + cloud_root, + receipt_dir, + verified_at_ms, + None, + &approval, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -1133,10 +1509,14 @@ mod tests { fn receipt() -> CloudCopyReceipt { let candidate = candidate(); + let approval = + test_copy_approval(&candidate, &root(), CloudCopyApprovalAction::CopyOnly, 100) + .unwrap(); let lineage = lineage_snapshot( &candidate, None, CloudCopyVerificationMethod::CopiedByDiskSage, + Some(&approval), ); let lineage_fingerprint = lineage_fingerprint(&lineage).unwrap(); let mut receipt = CloudCopyReceipt { @@ -1179,6 +1559,11 @@ mod tests { fn receipt_for(provider: CloudProvider) -> CloudCopyReceipt { let mut provider_receipt = receipt(); provider_receipt.provider = provider; + let lineage = provider_receipt.lineage.as_mut().unwrap(); + let approval = lineage.copy_approval.as_mut().unwrap(); + approval.provider = provider; + approval.approval_id = copy_approval_id_for(approval); + provider_receipt.lineage_fingerprint = Some(lineage_fingerprint(lineage).unwrap()); provider_receipt.receipt_id = receipt_id_for( provider_receipt.version, &provider_receipt.candidate_fingerprint, @@ -1198,6 +1583,31 @@ mod tests { provider_receipt } + fn pre_approval_receipt() -> CloudCopyReceipt { + let mut previous = receipt(); + previous.version = PRE_APPROVAL_RECEIPT_VERSION; + previous.lineage.as_mut().unwrap().copy_approval = None; + previous.lineage_fingerprint = + Some(lineage_fingerprint(previous.lineage.as_ref().unwrap()).unwrap()); + previous.receipt_id = receipt_id_for( + previous.version, + &previous.candidate_fingerprint, + previous.provider, + &previous.source, + &previous.destination, + previous.bytes, + &previous.blake3, + &previous.sha256, + &previous.quick_xor_base64, + previous.source_modified_ms, + previous.copied_at_ms, + previous.copy_verified, + previous.provider_sync_confirmed, + previous.lineage_fingerprint.as_deref(), + ); + previous + } + fn legacy_receipt() -> CloudCopyReceipt { let mut legacy = receipt(); legacy.version = LEGACY_RECEIPT_VERSION; @@ -1292,7 +1702,120 @@ mod tests { } #[test] - fn receipt_lineage_is_integrity_bound_and_legacy_v2_remains_valid() { + #[cfg(not(coverage))] + fn production_copy_entrypoints_recheck_approval_age_against_live_time() { + let candidate = candidate(); + let root = root(); + let copy_approval = + test_copy_approval(&candidate, &root, CloudCopyApprovalAction::CopyOnly, 1).unwrap(); + assert_eq!( + prepare_cloud_copy_with_approval( + &candidate, + &root, + std::path::Path::new("/unused"), + None, + ©_approval, + ) + .unwrap_err(), + "cloud-copy-approval-stale" + ); + + let adoption_approval = test_copy_approval( + &candidate, + &root, + CloudCopyApprovalAction::AdoptExistingCopy, + 1, + ) + .unwrap(); + assert_eq!( + adopt_existing_cloud_copy_with_approval( + &candidate, + &root, + std::path::Path::new("/unused"), + None, + &adoption_approval, + ) + .unwrap_err(), + "cloud-copy-approval-stale" + ); + } + + #[test] + fn copy_approval_requires_exact_phrase_human_attribution_context_and_freshness() { + let candidate = candidate(); + let root = root(); + let action = CloudCopyApprovalAction::CopyOnly; + assert_eq!( + create_cloud_copy_approval( + &candidate, + &root, + action, + 100, + "human:test", + "Exact source and cloud destination reviewed.", + "승인", + ) + .unwrap_err(), + "cloud-copy-exact-confirmation-phrase-mismatch" + ); + let phrase = cloud_copy_approval_phrase(&candidate, action); + let approval = create_cloud_copy_approval( + &candidate, + &root, + action, + 100, + "human:test", + "Exact source and cloud destination reviewed.", + &phrase, + ) + .unwrap(); + assert!(validate_cloud_copy_approval_for_action( + &approval, + &candidate, + &root, + action, + 100 + MAX_CLOUD_COPY_APPROVAL_AGE_MS, + ) + .is_ok()); + assert_eq!( + validate_cloud_copy_approval_for_action( + &approval, + &candidate, + &root, + action, + 101 + MAX_CLOUD_COPY_APPROVAL_AGE_MS, + ) + .unwrap_err(), + "cloud-copy-approval-stale" + ); + let mut wrong_root = root.clone(); + wrong_root.id = "icloud:other".into(); + assert_eq!( + validate_cloud_copy_approval_for_action( + &approval, + &candidate, + &wrong_root, + action, + 100, + ) + .unwrap_err(), + "cloud-copy-approval-context-mismatch" + ); + assert_eq!( + validate_cloud_copy_approval_for_action( + &approval, + &candidate, + &root, + CloudCopyApprovalAction::AdoptExistingCopy, + 100, + ) + .unwrap_err(), + "cloud-copy-approval-context-mismatch" + ); + } + + #[test] + fn receipt_lineage_is_integrity_bound_and_older_receipts_remain_valid() { let current = receipt(); assert!(receipt_blockers(¤t).is_empty()); let lineage = current.lineage.as_ref().unwrap(); @@ -1319,10 +1842,36 @@ mod tests { assert!(receipt_blockers(&inconsistent_time) .contains(&"receipt-lineage-modified-time-mismatch".to_string())); + let mut approval_missing = current.clone(); + approval_missing.lineage.as_mut().unwrap().copy_approval = None; + approval_missing.lineage_fingerprint = + Some(lineage_fingerprint(approval_missing.lineage.as_ref().unwrap()).unwrap()); + approval_missing.receipt_id = receipt_id_for( + approval_missing.version, + &approval_missing.candidate_fingerprint, + approval_missing.provider, + &approval_missing.source, + &approval_missing.destination, + approval_missing.bytes, + &approval_missing.blake3, + &approval_missing.sha256, + &approval_missing.quick_xor_base64, + approval_missing.source_modified_ms, + approval_missing.copied_at_ms, + approval_missing.copy_verified, + approval_missing.provider_sync_confirmed, + approval_missing.lineage_fingerprint.as_deref(), + ); + assert!(receipt_blockers(&approval_missing) + .contains(&"receipt-lineage-copy-approval-missing".to_string())); + let mut missing = current; missing.lineage = None; assert!(receipt_blockers(&missing).contains(&"receipt-lineage-missing".to_string())); + let previous = pre_approval_receipt(); + assert!(receipt_blockers(&previous).is_empty()); + let legacy = legacy_receipt(); assert!(receipt_blockers(&legacy).is_empty()); let encoded = serde_json::to_vec(&legacy).unwrap(); @@ -1359,6 +1908,7 @@ mod tests { &reviewed, Some(&approved), CloudCopyVerificationMethod::CopiedByDiskSage, + None, ); assert_eq!( reviewed_lineage.review_decision_id.as_deref(), @@ -1736,6 +2286,13 @@ mod tests { ); assert_eq!(lineage.metadata_evidence, test_candidate.metadata_evidence); assert_eq!(lineage.review_decision_id, None); + let approval = lineage.copy_approval.as_ref().unwrap(); + assert_eq!(approval.action, CloudCopyApprovalAction::CopyOnly); + assert_eq!( + approval.review_fingerprint, + test_candidate.review_fingerprint + ); + assert_eq!(approval.approved_by, "human:test"); let wrong_name = receipt_dir.join("wrong-name.json"); std::fs::copy(&receipt_path, &wrong_name).unwrap(); @@ -2018,10 +2575,18 @@ mod tests { access_issue: None, }; let content_hash = hash_file(&source).unwrap(); + let approval = test_copy_approval( + &test_candidate, + &test_root, + CloudCopyApprovalAction::CopyOnly, + 123, + ) + .unwrap(); let lineage = lineage_snapshot( &test_candidate, None, CloudCopyVerificationMethod::CopiedByDiskSage, + Some(&approval), ); let lineage_fingerprint = lineage_fingerprint(&lineage).unwrap(); let receipt_id = receipt_id_for( diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9c73f5b57..3bbf97923 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -16,8 +16,8 @@ use crate::organize; use crate::safety; #[cfg(not(coverage))] use crate::{ - cloud, cloud_eviction, cloud_local_eviction, cloud_review, cloud_transfer, dev_artifacts, dupes, - git_worktree, icloud_sync_health, provider_api_client, provider_capacity, + cloud, cloud_eviction, cloud_local_eviction, cloud_plan_view, cloud_review, cloud_transfer, + dev_artifacts, dupes, git_worktree, icloud_sync_health, provider_api_client, provider_capacity, provider_client_runtime, provider_evidence, provider_oauth, provider_sync, rules, }; @@ -53,7 +53,10 @@ pub struct NodeView { /// 스캔 결과 + 실시간 read_dir로 한 레벨을 조회 (순수 함수 — 테스트 대상) pub fn node_view(res: &ScanResult, path: &Path) -> Result { // '..'는 lexical starts_with를 우회해 루트 밖을 열람할 수 있음 — 컴포넌트 단위로 거부 - if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { return Err("path outside scanned root".into()); } if !path.starts_with(&res.root) { @@ -95,11 +98,7 @@ pub struct CleanResult { } /// 정리 실행의 순수 코어 — 결과는 항목별, 하나가 실패해도 나머지는 진행 (스펙 §8) -pub fn clean_paths_inner( - paths: &[PathBuf], - journal_path: &Path, - now_ms: u64, -) -> Vec { +pub fn clean_paths_inner(paths: &[PathBuf], journal_path: &Path, now_ms: u64) -> Vec { paths .iter() .map(|p| { @@ -146,12 +145,26 @@ pub fn parse_move_entry(path_field: &str) -> Option<(String, String)> { } /// MovePlan을 safety::move_file로 실행하는 순수 코어 — 항목별 결과, 하나 실패해도 나머지는 진행 (M2와 동일 원칙) -pub fn execute_moves_inner(plans: &[organize::MovePlan], journal_path: &Path, now_ms: u64) -> Vec { +pub fn execute_moves_inner( + plans: &[organize::MovePlan], + journal_path: &Path, + now_ms: u64, +) -> Vec { plans .iter() - .map(|p| match safety::move_file(Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms) { - Ok(()) => CleanResult { path: p.src.clone(), ok: true, error: String::new() }, - Err(e) => CleanResult { path: p.src.clone(), ok: false, error: e.to_string() }, + .map(|p| { + match safety::move_file(Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms) { + Ok(()) => CleanResult { + path: p.src.clone(), + ok: true, + error: String::new(), + }, + Err(e) => CleanResult { + path: p.src.clone(), + ok: false, + error: e.to_string(), + }, + } }) .collect() } @@ -167,9 +180,19 @@ pub fn undo_last_moves_inner(limit: usize, journal_path: &Path, now_ms: u64) -> .filter(|e| e.op == "move" && e.outcome == "ok") .take(limit) .filter_map(|e| parse_move_entry(&e.path)) - .map(|(src, dst)| match safety::move_file(Path::new(&dst), Path::new(&src), journal_path, now_ms) { - Ok(()) => CleanResult { path: src, ok: true, error: String::new() }, - Err(e) => CleanResult { path: src, ok: false, error: e.to_string() }, + .map(|(src, dst)| { + match safety::move_file(Path::new(&dst), Path::new(&src), journal_path, now_ms) { + Ok(()) => CleanResult { + path: src, + ok: true, + error: String::new(), + }, + Err(e) => CleanResult { + path: src, + ok: false, + error: e.to_string(), + }, + } }) .collect() } @@ -203,7 +226,9 @@ pub fn load_ontology_from(ttl: &str) -> Result String { use tauri::Manager; if let Ok(dir) = app.path().app_config_dir() { - if let Ok(s) = std::fs::read_to_string(dir.join("userrules.json")) { return s; } + if let Ok(s) = std::fs::read_to_string(dir.join("userrules.json")) { + return s; + } } "[]".to_string() } @@ -223,7 +248,10 @@ fn bundled_ontology_ttl(app: &AppHandle) -> Result { } let res = app .path() - .resolve("resources/ontology/default.ttl", tauri::path::BaseDirectory::Resource) + .resolve( + "resources/ontology/default.ttl", + tauri::path::BaseDirectory::Resource, + ) .map_err(|e| e.to_string())?; std::fs::read_to_string(&res).map_err(|e| e.to_string()) } @@ -236,7 +264,10 @@ pub fn get_ontology(app: AppHandle) -> Result #[cfg(not(coverage))] #[tauri::command(async)] -pub fn disk_inventory(root: String, app: AppHandle) -> Result { +pub fn disk_inventory( + root: String, + app: AppHandle, +) -> Result { let onto = load_ontology_from(&bundled_ontology_ttl(&app)?)?; let files = crate::dupes::collect_files(std::path::Path::new(&root)); Ok(crate::inventory::build_inventory(&files, &onto)) @@ -272,7 +303,10 @@ pub fn get_settings(app: AppHandle) -> Result /// online_mode 설정 후 영속. 반환은 저장된 설정. #[cfg(not(coverage))] #[tauri::command] -pub fn set_settings(online_mode: bool, app: AppHandle) -> Result { +pub fn set_settings( + online_mode: bool, + app: AppHandle, +) -> Result { let s = crate::settings::Settings { online_mode }; let path = settings_file_path(&app)?; std::fs::write(&path, crate::settings::serialize_settings(&s)).map_err(|e| e.to_string())?; @@ -375,7 +409,11 @@ pub fn list_dev_artifacts( root: String, min_age_days: u64, ) -> Result, String> { - Ok(dev_artifacts::find_artifacts(Path::new(&root), min_age_days, now_ms())) + Ok(dev_artifacts::find_artifacts( + Path::new(&root), + min_age_days, + now_ms(), + )) } #[cfg(not(coverage))] @@ -388,7 +426,10 @@ pub fn clean_paths(paths: Vec, app: AppHandle) -> Result Result, String> { +pub fn recent_operations( + limit: usize, + app: AppHandle, +) -> Result, String> { Ok(safety::journal_recent(&journal_file_path(&app)?, limit)) } @@ -396,7 +437,9 @@ pub fn recent_operations(limit: usize, app: AppHandle) -> Result Vec { // 카탈로그 경로로만 스코프 — 임의 디렉토리 열람 IPC가 되지 않도록 - let Some(bases) = rules::BaseDirs::from_env() else { return Vec::new() }; + let Some(bases) = rules::BaseDirs::from_env() else { + return Vec::new(); + }; let d = Path::new(&dir); if !rules::is_catalog_path(&bases, d) { return Vec::new(); @@ -742,12 +785,7 @@ pub async fn connect_cloud_provider( let connection_path = oauth_connections_path(&app)?; let connected_at_ms = cloud::system_now_ms(); tauri::async_runtime::spawn_blocking(move || { - provider_oauth::finish_authorization( - pending, - &selected, - &connection_path, - connected_at_ms, - ) + provider_oauth::finish_authorization(pending, &selected, &connection_path, connected_at_ms) }) .await .map_err(|_| "provider-oauth-task-failed".to_string())? @@ -757,10 +795,7 @@ pub async fn connect_cloud_provider( /// connection descriptor. This does not alter any cloud file. #[cfg(not(coverage))] #[tauri::command(async)] -pub async fn disconnect_cloud_provider( - cloud_root: String, - app: AppHandle, -) -> Result<(), String> { +pub async fn disconnect_cloud_provider(cloud_root: String, app: AppHandle) -> Result<(), String> { let selected = selected_cloud_root(&app, &cloud_root)?; if selected.provider == cloud::CloudProvider::Icloud { return Err("icloud-oauth-not-supported".into()); @@ -858,10 +893,7 @@ pub fn inspect_cloud_provider_client_runtime( pub fn inspect_icloud_new_copy_admission( app: AppHandle, ) -> Result { - icloud_sync_health::inspect_new_copy_admission( - &resolve_home(&app), - cloud::system_now_ms(), - ) + icloud_sync_health::inspect_new_copy_admission(&resolve_home(&app), cloud::system_now_ms()) } #[cfg(not(coverage))] @@ -882,7 +914,10 @@ fn cloud_plan_for_inputs( .cloned() .ok_or_else(|| "탐지된 클라우드 루트가 아님".to_string())?; cloud::validate_cloud_root_readable(&selected)?; - let excluded: Vec = discovered.iter().map(|root| PathBuf::from(&root.path)).collect(); + let excluded: Vec = discovered + .iter() + .map(|root| PathBuf::from(&root.path)) + .collect(); if excluded.iter().any(|cloud| root_path.starts_with(cloud)) { return Err("이미 클라우드 안에 있는 경로는 오프로드 원본으로 사용할 수 없음".into()); } @@ -921,10 +956,7 @@ fn cloud_plan_for_inputs( cloud::system_now_ms(), ) .ok(); - icloud_sync_health::attach_new_copy_admission_notice( - &mut report.notices, - health.as_ref(), - ); + icloud_sync_health::attach_new_copy_admission_notice(&mut report.notices, health.as_ref()); } Ok((selected, report)) } @@ -977,18 +1009,20 @@ fn attach_capacity_assessment( report .notices .retain(|notice| notice != "cloud-quota-unverified"); - report.notices.push(match assessment.can_fit { - Some(true) - if assessment.snapshot.evidence_kind - == provider_capacity::CapacityEvidenceKind::ProviderNativeStatus => - { - "cloud-quota-provider-native-verified" + report.notices.push( + match assessment.can_fit { + Some(true) + if assessment.snapshot.evidence_kind + == provider_capacity::CapacityEvidenceKind::ProviderNativeStatus => + { + "cloud-quota-provider-native-verified" + } + Some(true) => "cloud-quota-provider-api-verified", + Some(false) => "cloud-quota-insufficient-or-blocked", + None => "cloud-quota-unavailable", } - Some(true) => "cloud-quota-provider-api-verified", - Some(false) => "cloud-quota-insufficient-or-blocked", - None => "cloud-quota-unavailable", - } - .into()); + .into(), + ); report.capacity = Some(assessment); Ok(()) } @@ -1016,7 +1050,8 @@ fn require_capacity_for_copy( } /// Read-only cloud offload plan. The selected destination must be one of the roots discovered -/// on this machine; this command never creates a folder or moves a file. +/// on this machine; this command never creates a folder or moves a file. Candidate approval text +/// is generated by Rust and returned as presentation evidence, never reconstructed by the UI. #[cfg(not(coverage))] #[tauri::command(async)] pub async fn plan_cloud_archive( @@ -1026,11 +1061,11 @@ pub async fn plan_cloud_archive( min_age_days: u64, limit: usize, app: AppHandle, -) -> Result { +) -> Result { tauri::async_runtime::spawn_blocking(move || { let (_, report) = cloud_plan_for_inputs(&root, &cloud_root, min_size_mib, min_age_days, limit, &app)?; - Ok(report) + Ok(report.into()) }) .await .map_err(|_| "cloud-plan-task-failed".to_string())? @@ -1051,7 +1086,11 @@ fn local_human_reviewer() -> String { .collect(); format!( "human:local:{}", - if bounded.is_empty() { "unknown" } else { &bounded } + if bounded.is_empty() { + "unknown" + } else { + &bounded + } ) } @@ -1071,9 +1110,7 @@ pub fn review_cloud_candidate( state: State, ) -> Result { for fingerprint in [&metadata_fingerprint, &review_fingerprint] { - if fingerprint.len() != 64 - || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) - { + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("cloud-review-fingerprint-invalid".into()); } } @@ -1081,14 +1118,8 @@ pub fn review_cloud_candidate( .cloud_review .lock() .map_err(|_| "cloud-review-lock-poisoned".to_string())?; - let (_, report) = cloud_plan_for_inputs( - &root, - &cloud_root, - min_size_mib, - min_age_days, - limit, - &app, - )?; + let (_, report) = + cloud_plan_for_inputs(&root, &cloud_root, min_size_mib, min_age_days, limit, &app)?; let matches: Vec<_> = report .candidates .iter() @@ -1129,22 +1160,20 @@ fn create_cloud_candidate_receipt( min_size_mib: u64, min_age_days: u64, limit: usize, + exact_confirmation_phrase: &str, + approval_rationale: &str, app: &AppHandle, adopt_existing: bool, ) -> Result { if metadata_fingerprint.len() != 64 - || !metadata_fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) + || !metadata_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) { return Err("metadata-fingerprint-invalid".into()); } - let (selected, report) = cloud_plan_for_inputs( - root, - cloud_root, - min_size_mib, - min_age_days, - limit, - app, - )?; + let (selected, report) = + cloud_plan_for_inputs(root, cloud_root, min_size_mib, min_age_days, limit, app)?; let matches: Vec<_> = report .candidates .iter() @@ -1168,6 +1197,21 @@ fn create_cloud_candidate_receipt( } else { None }; + let action = if adopt_existing { + cloud_transfer::CloudCopyApprovalAction::AdoptExistingCopy + } else { + cloud_transfer::CloudCopyApprovalAction::CopyOnly + }; + let action_at_ms = cloud::system_now_ms(); + let copy_approval = cloud_transfer::create_cloud_copy_approval( + candidate, + &selected, + action, + action_at_ms, + &local_human_reviewer(), + approval_rationale.trim(), + exact_confirmation_phrase, + )?; if !adopt_existing { provider_client_runtime::require_provider_client_runtime( selected.provider, @@ -1188,20 +1232,20 @@ fn create_cloud_candidate_receipt( require_capacity_for_copy(candidate, &snapshot.snapshot)?; } let (receipt, receipt_path) = if adopt_existing { - cloud_transfer::adopt_existing_cloud_copy_with_review( + cloud_transfer::adopt_existing_cloud_copy_with_approval( candidate, &selected, &receipt_dir, - cloud::system_now_ms(), review_decision.as_ref(), + ©_approval, )? } else { - cloud_transfer::prepare_cloud_copy_with_review( + cloud_transfer::prepare_cloud_copy_with_approval( candidate, &selected, &receipt_dir, - cloud::system_now_ms(), review_decision.as_ref(), + ©_approval, )? }; Ok(CloudCopyOutput { @@ -1226,6 +1270,8 @@ pub fn copy_cloud_candidate( min_size_mib: u64, min_age_days: u64, limit: usize, + exact_confirmation_phrase: String, + approval_rationale: String, app: AppHandle, state: State, ) -> Result { @@ -1240,6 +1286,8 @@ pub fn copy_cloud_candidate( min_size_mib, min_age_days, limit, + &exact_confirmation_phrase, + &approval_rationale, &app, false, ) @@ -1256,6 +1304,8 @@ pub fn adopt_existing_cloud_candidate( min_size_mib: u64, min_age_days: u64, limit: usize, + exact_confirmation_phrase: String, + approval_rationale: String, app: AppHandle, state: State, ) -> Result { @@ -1270,6 +1320,8 @@ pub fn adopt_existing_cloud_candidate( min_size_mib, min_age_days, limit, + &exact_confirmation_phrase, + &approval_rationale, &app, true, ) @@ -1523,7 +1575,11 @@ pub async fn trash_verified_cloud_source( #[cfg(not(coverage))] #[cfg_attr(not(feature = "llm-engine"), allow(unused_variables))] #[tauri::command(async)] -pub fn plan_organize(root: String, app: AppHandle, state: State) -> Result, String> { +pub fn plan_organize( + root: String, + app: AppHandle, + state: State, +) -> Result, String> { let onto = load_ontology_from(&bundled_ontology_ttl(&app)?)?; let rules = crate::userrules::parse_rules(&user_rules_json(&app))?; // malformed → Err surfaced let files = dupes::collect_files(Path::new(&root)); @@ -1547,11 +1603,25 @@ pub fn plan_organize(root: String, app: AppHandle, state: State) -> Re let meta = file_meta_at(p, 0, 0); crate::llm::pick_class(engine, &meta, cands) }; - return Ok(organize::plan_moves_with(&files, &onto, &home, now_ms(), &rules, &pick)); + return Ok(organize::plan_moves_with( + &files, + &onto, + &home, + now_ms(), + &rules, + &pick, + )); } } } - Ok(organize::plan_moves_with(&files, &onto, &home, now_ms(), &rules, &|_, _| None)) + Ok(organize::plan_moves_with( + &files, + &onto, + &home, + now_ms(), + &rules, + &|_, _| None, + )) } /// 활성 사용자 규칙 조회(UI 표시용). 손상 파일은 Err. @@ -1564,7 +1634,10 @@ pub fn user_rules(app: AppHandle) -> Result, String> /// MovePlan을 safety::move_file로 실행 — 항목별 결과, 하나 실패해도 나머지는 진행 (M2와 동일 원칙) #[cfg(not(coverage))] #[tauri::command(async)] -pub fn execute_moves(plans: Vec, app: AppHandle) -> Result, String> { +pub fn execute_moves( + plans: Vec, + app: AppHandle, +) -> Result, String> { let jp = journal_file_path(&app)?; Ok(execute_moves_inner(&plans, &jp, now_ms())) } @@ -1585,23 +1658,37 @@ pub struct ModelStatus { /// 모델 파일 경로: /models/.gguf pub fn model_file_path(app_data_dir: &Path) -> PathBuf { - app_data_dir.join("models").join(format!("{}.gguf", crate::llm::DEFAULT.name)) + app_data_dir + .join("models") + .join(format!("{}.gguf", crate::llm::DEFAULT.name)) } /// 모델 존재 여부 + 이름. 없으면 앱은 규칙 기반으로 동작(배지 미판정). pub fn model_status_for(model_path: &Path) -> ModelStatus { - ModelStatus { present: model_path.exists(), name: crate::llm::DEFAULT.name.to_string() } + ModelStatus { + present: model_path.exists(), + name: crate::llm::DEFAULT.name.to_string(), + } } /// 경로 + (이미 읽은) size·age로 FileMeta 구성. name/parent는 경로에서, 없으면 빈 문자열(패닉 없음). pub fn file_meta_at(path: &Path, size: u64, mtime_days: u64) -> crate::llm::FileMeta { - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); let parent = path .parent() .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); - crate::llm::FileMeta { path: path.to_string_lossy().into_owned(), name, size, mtime_days, parent } + crate::llm::FileMeta { + path: path.to_string_lossy().into_owned(), + name, + size, + mtime_days, + parent, + } } /// 항목마다 캐시(path|size|mtime_ms) 확인 후 미스면 추론. 판정만 캐시(이유는 미스 시에만). @@ -1614,7 +1701,11 @@ pub fn verdicts_with( for (meta, mtime_ms) in items { let key = crate::llm::VerdictCache::key(&meta.path, meta.size, *mtime_ms); if let Some(v) = cache.get(&key) { - out.push(crate::llm::FileVerdict { path: meta.path.clone(), verdict: v, reason: String::new() }); + out.push(crate::llm::FileVerdict { + path: meta.path.clone(), + verdict: v, + reason: String::new(), + }); } else { let fv = crate::llm::verdict_for(engine, meta); cache.put(key, fv.verdict); @@ -1630,16 +1721,21 @@ pub fn verdicts_with( #[cfg(not(coverage))] fn meta_items(paths: &[String]) -> Vec<(crate::llm::FileMeta, u64)> { - paths.iter().filter_map(|p| { - let path = std::path::Path::new(p); - let md = std::fs::metadata(path).ok()?; - let mtime_ms = md.modified().ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let age_days = now_ms().saturating_sub(mtime_ms) / 86_400_000; // 실제 파일 나이(프롬프트용); 캐시 키는 원시 mtime_ms 사용 - Some((file_meta_at(path, md.len(), age_days), mtime_ms)) - }).collect() + paths + .iter() + .filter_map(|p| { + let path = std::path::Path::new(p); + let md = std::fs::metadata(path).ok()?; + let mtime_ms = md + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let age_days = now_ms().saturating_sub(mtime_ms) / 86_400_000; // 실제 파일 나이(프롬프트용); 캐시 키는 원시 mtime_ms 사용 + Some((file_meta_at(path, md.len(), age_days), mtime_ms)) + }) + .collect() } #[cfg(not(coverage))] @@ -1666,7 +1762,11 @@ pub fn download_model(app: AppHandle) -> Result<(), String> { #[cfg(not(coverage))] #[cfg_attr(not(feature = "llm-engine"), allow(unused_variables))] #[tauri::command(async)] -pub fn file_verdicts(paths: Vec, app: AppHandle, state: State) -> Result, String> { +pub fn file_verdicts( + paths: Vec, + app: AppHandle, + state: State, +) -> Result, String> { let items = meta_items(&paths); #[cfg(feature = "llm-engine")] @@ -1701,7 +1801,11 @@ pub fn file_verdicts(paths: Vec, app: AppHandle, state: State) #[cfg(not(coverage))] #[cfg_attr(not(feature = "llm-engine"), allow(unused_variables))] #[tauri::command(async)] -pub fn summarize_unknown_bucket(paths: Vec, app: AppHandle, state: State) -> Result, String> { +pub fn summarize_unknown_bucket( + paths: Vec, + app: AppHandle, + state: State, +) -> Result, String> { if paths.is_empty() { return Ok(None); } @@ -1742,8 +1846,14 @@ pub fn reason_unknown_extensions( // opt-in 웹: online_mode일 때만 DdgLookup, 아니면 None → build_insights의 웹 분기 절대 미실행(default offline) let settings = get_settings(app.clone())?; let ddg = crate::web::DdgLookup; - let web_fn = |ext: &str| -> Option { crate::web::WebLookup::file_type(&ddg, ext).ok().flatten() }; - let web: Option<&dyn Fn(&str) -> Option> = if settings.online_mode { Some(&web_fn) } else { None }; + let web_fn = |ext: &str| -> Option { + crate::web::WebLookup::file_type(&ddg, ext).ok().flatten() + }; + let web: Option<&dyn Fn(&str) -> Option> = if settings.online_mode { + Some(&web_fn) + } else { + None + }; // 오프라인 LLM(feature+모델+엔진 있으면 실제; 그 블록에서 반환). 없으면 아래 fallback로 낙하. #[cfg(feature = "llm-engine")] @@ -1753,8 +1863,11 @@ pub fn reason_unknown_extensions( if model_status_for(&model_file_path(&dir)).present { // 온톨로지 로드는 LLM 경로에서만 필요 — 여기로 이동해 기본/웹전용 빌드가 malformed ontology.ttl로 실패하지 않게 함 let onto = load_ontology_from(&bundled_ontology_ttl(&app)?)?; - let candidates: Vec = onto.classes.iter() - .map(|c| c.id.rsplit(['#', '/']).next().unwrap_or(&c.id).to_string()).collect(); + let candidates: Vec = onto + .classes + .iter() + .map(|c| c.id.rsplit(['#', '/']).next().unwrap_or(&c.id).to_string()) + .collect(); let cand_refs: Vec<&str> = candidates.iter().map(|s| s.as_str()).collect(); let mut guard = state.engine.lock().unwrap(); @@ -1786,7 +1899,10 @@ mod tests { // --- M5 LLM 커맨드 순수 헬퍼 --- use crate::llm::{InferenceEngine, Verdict, VerdictCache}; - struct CountingFake { out: String, calls: std::cell::Cell } + struct CountingFake { + out: String, + calls: std::cell::Cell, + } impl InferenceEngine for CountingFake { fn infer(&self, _p: &str) -> Result { self.calls.set(self.calls.get() + 1); @@ -1827,19 +1943,29 @@ mod tests { #[test] fn verdicts_with_caches_and_avoids_reinference() { - let engine = CountingFake { out: r#"{"verdict":"safe","reason":"r"}"#.into(), calls: std::cell::Cell::new(0) }; + let engine = CountingFake { + out: r#"{"verdict":"safe","reason":"r"}"#.into(), + calls: std::cell::Cell::new(0), + }; let mut cache = VerdictCache::new(); let meta = file_meta_at(std::path::Path::new("/x/a.bin"), 100, 1); let items = vec![(meta.clone(), 1700u64), (meta, 1700u64)]; // 같은 path|size|mtime → 두 번째는 캐시 히트 let out = verdicts_with(&engine, &mut cache, &items); assert_eq!(out.len(), 2); assert!(out.iter().all(|fv| fv.verdict == Verdict::Safe)); - assert_eq!(engine.calls.get(), 1, "두 번째 항목은 캐시 히트라 추론 1회만"); + assert_eq!( + engine.calls.get(), + 1, + "두 번째 항목은 캐시 히트라 추론 1회만" + ); } #[test] fn verdicts_with_distinct_items_infer_each() { - let engine = CountingFake { out: r#"{"verdict":"keep"}"#.into(), calls: std::cell::Cell::new(0) }; + let engine = CountingFake { + out: r#"{"verdict":"keep"}"#.into(), + calls: std::cell::Cell::new(0), + }; let mut cache = VerdictCache::new(); let a = (file_meta_at(std::path::Path::new("/x/a"), 1, 1), 10u64); let b = (file_meta_at(std::path::Path::new("/x/b"), 2, 2), 20u64); @@ -1989,9 +2115,14 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . let ok_file = tmp.path().join("disksage-clean-fixture-file.bin"); fs::write(&ok_file, vec![0u8; 16]).unwrap(); let missing = tmp.path().join("ghost"); - let protected = std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows" } else { "/usr" }); + let protected = + std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows" } else { "/usr" }); - let results = clean_paths_inner(&[ok_dir.clone(), ok_file.clone(), missing, protected], &jp, 7); + let results = clean_paths_inner( + &[ok_dir.clone(), ok_file.clone(), missing, protected], + &jp, + 7, + ); assert_eq!(results.len(), 4); assert!(results[0].ok); @@ -2011,7 +2142,10 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . .iter() .find(|e| e.outcome == "ok" && e.path.contains("disksage-clean-fixture-file")) .unwrap(); - assert_eq!(ok_file_entry.bytes, 16, "단일 파일은 metadata 크기로 저널링"); + assert_eq!( + ok_file_entry.bytes, 16, + "단일 파일은 metadata 크기로 저널링" + ); // 테스트 픽스처 휴지통 정리 (win/linux) #[cfg(any(windows, target_os = "linux"))] @@ -2021,7 +2155,8 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . .into_iter() .filter(|i| { let n = i.name.to_string_lossy(); - n.contains("disksage-clean-fixture-dir") || n.contains("disksage-clean-fixture-file") + n.contains("disksage-clean-fixture-dir") + || n.contains("disksage-clean-fixture-file") }) .collect(); trash::os_limited::purge_all(items).unwrap(); @@ -2037,8 +2172,16 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . let dst_ok = tmp.path().join("sub").join("a.bin"); // 하나는 성공(같은 볼륨 rename), 하나는 실패(존재하지 않는 src) let plans = vec![ - organize::MovePlan { src: src_ok.to_string_lossy().into(), dst: dst_ok.to_string_lossy().into(), class_id: "x".into() }, - organize::MovePlan { src: tmp.path().join("ghost").to_string_lossy().into(), dst: tmp.path().join("g2").to_string_lossy().into(), class_id: "x".into() }, + organize::MovePlan { + src: src_ok.to_string_lossy().into(), + dst: dst_ok.to_string_lossy().into(), + class_id: "x".into(), + }, + organize::MovePlan { + src: tmp.path().join("ghost").to_string_lossy().into(), + dst: tmp.path().join("g2").to_string_lossy().into(), + class_id: "x".into(), + }, ]; let results = execute_moves_inner(&plans, &jp, 1); assert_eq!(results.len(), 2); @@ -2056,7 +2199,11 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . std::fs::write(&a, vec![2u8; 8]).unwrap(); let a_moved = tmp.path().join("dest").join("a.bin"); // 먼저 이동 실행(저널에 move/ok 기록) - let plans = vec![organize::MovePlan { src: a.to_string_lossy().into(), dst: a_moved.to_string_lossy().into(), class_id: "x".into() }]; + let plans = vec![organize::MovePlan { + src: a.to_string_lossy().into(), + dst: a_moved.to_string_lossy().into(), + class_id: "x".into(), + }]; execute_moves_inner(&plans, &jp, 5); assert!(!a.exists()); assert!(a_moved.exists()); @@ -2077,10 +2224,22 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . let s = tmp.path().join(name); std::fs::write(&s, b"z").unwrap(); let d = tmp.path().join("d").join(name); - execute_moves_inner(&[organize::MovePlan { src: s.to_string_lossy().into(), dst: d.to_string_lossy().into(), class_id: "x".into() }], &jp, 1); + execute_moves_inner( + &[organize::MovePlan { + src: s.to_string_lossy().into(), + dst: d.to_string_lossy().into(), + class_id: "x".into(), + }], + &jp, + 1, + ); } let undone = undo_last_moves_inner(1, &jp, 9); - assert_eq!(undone.len(), 1, "filter-before-take: pending 라인이 실제 성공을 밀어내지 않음"); + assert_eq!( + undone.len(), + 1, + "filter-before-take: pending 라인이 실제 성공을 밀어내지 않음" + ); } #[test] @@ -2090,14 +2249,21 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . let a = tmp.path().join("a.bin"); std::fs::write(&a, vec![3u8; 4]).unwrap(); let a_moved = tmp.path().join("dest").join("a.bin"); - let plans = vec![organize::MovePlan { src: a.to_string_lossy().into(), dst: a_moved.to_string_lossy().into(), class_id: "x".into() }]; + let plans = vec![organize::MovePlan { + src: a.to_string_lossy().into(), + dst: a_moved.to_string_lossy().into(), + class_id: "x".into(), + }]; execute_moves_inner(&plans, &jp, 1); assert!(a_moved.exists()); // 원래 자리에 새 파일이 다시 생겨 되돌리기 목적지가 막힘 → move_file이 실패해야 함 std::fs::write(&a, b"blocker").unwrap(); let undone = undo_last_moves_inner(1, &jp, 2); assert_eq!(undone.len(), 1); - assert!(!undone[0].ok, "목적지 재점유 시 되돌리기 실패를 보고해야 함"); + assert!( + !undone[0].ok, + "목적지 재점유 시 되돌리기 실패를 보고해야 함" + ); assert!(a_moved.exists(), "실패 시 원본은 이동된 위치에 그대로 남음"); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81f6e2574..f98a44243 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,6 +34,8 @@ mod dataset_metadata; pub mod archive_git_tree; #[cfg_attr(coverage, allow(dead_code))] pub mod cloud; +/// Typed backend-authored presentation contract for cloud archive plans. +pub mod cloud_plan_view; pub mod cloud_local_inventory; pub mod cloud_local_eviction; pub mod cloud_local_eviction_batch; diff --git a/src-tauri/src/naruon_lineage.rs b/src-tauri/src/naruon_lineage.rs index a0f261545..2ebbb0765 100644 --- a/src-tauri/src/naruon_lineage.rs +++ b/src-tauri/src/naruon_lineage.rs @@ -13,13 +13,16 @@ use crate::cloud_review::{ organization_tenant_authority_attested, validate_decision, CloudReviewDecision, CloudReviewDisposition, DECISION_VERSION, }; -use crate::cloud_transfer::{CloudCopyReceipt, CloudCopyVerificationMethod, SyncEvidenceKind}; +use crate::cloud_transfer::{ + validate_receipt_copy_approval, CloudCopyApprovalAction, CloudCopyReceipt, + CloudCopyVerificationMethod, SyncEvidenceKind, +}; use crate::provider_evidence::{validate_sync_evidence_record, ProviderSyncEvidenceRecord}; #[cfg(test)] use crate::provider_sync::PROVIDER_SYNC_OVERDUE_AFTER_MS; use crate::provider_sync::{assess_provider_sync_timeliness, ProviderSyncTimeliness}; -pub const NARUON_FILE_LINEAGE_SCHEMA_VERSION: u32 = 1; +pub const NARUON_FILE_LINEAGE_SCHEMA_VERSION: u32 = 2; pub const NARUON_FILE_LINEAGE_SCHEMA_KIND: &str = "disksage.file-lineage"; const EVIDENCE_PRECEDENCE: [&str; 4] = [ @@ -69,6 +72,11 @@ pub struct NaruonCloudCopyLineage { pub destination: String, pub copied_at_ms: u64, pub copy_verification_method: CloudCopyVerificationMethod, + pub copy_approval_id: Option, + pub copy_approval_action: Option, + pub copy_approved_at_ms: Option, + pub copy_approved_by: Option, + pub copy_approval_rationale: Option, pub local_copy_verified: bool, /// DiskSage's local File Provider copy is not proof that a provider API write executed. pub provider_write_executed: bool, @@ -235,6 +243,8 @@ pub fn export_naruon_file_lineage( evidence_record: Option<&ProviderSyncEvidenceRecord>, ) -> Result { validate_receipt_shape(receipt)?; + validate_receipt_copy_approval(receipt) + .map_err(|_| "naruon-lineage-copy-approval-invalid".to_string())?; if let Some(record) = evidence_record { validate_evidence_binding(receipt, record)?; } @@ -303,6 +313,26 @@ pub fn export_naruon_file_lineage( destination: receipt.destination.clone(), copied_at_ms: receipt.copied_at_ms, copy_verification_method: lineage.copy_verification_method, + copy_approval_id: lineage + .copy_approval + .as_ref() + .map(|approval| approval.approval_id.clone()), + copy_approval_action: lineage + .copy_approval + .as_ref() + .map(|approval| approval.action), + copy_approved_at_ms: lineage + .copy_approval + .as_ref() + .map(|approval| approval.approved_at_ms), + copy_approved_by: lineage + .copy_approval + .as_ref() + .map(|approval| approval.approved_by.clone()), + copy_approval_rationale: lineage + .copy_approval + .as_ref() + .map(|approval| approval.rationale.clone()), local_copy_verified: receipt.copy_verified, provider_write_executed: false, provider_sync_confirmed: evidence.is_some_and(|item| item.sync_complete), @@ -338,7 +368,7 @@ mod tests { use crate::cloud_transfer::prepare_cloud_copy_with_review; use crate::cloud_transfer::{ CloudCopyReceipt, CloudCopyVerificationMethod, CloudLineageSnapshot, ProviderSyncEvidence, - SyncEvidenceKind, RECEIPT_VERSION, + SyncEvidenceKind, PRE_APPROVAL_RECEIPT_VERSION, RECEIPT_VERSION, }; use crate::provider_evidence::create_sync_evidence_record; @@ -394,7 +424,7 @@ mod tests { ) .unwrap(); CloudCopyReceipt { - version: RECEIPT_VERSION, + version: PRE_APPROVAL_RECEIPT_VERSION, receipt_id: "a".repeat(64), candidate_fingerprint: candidate.metadata_fingerprint.clone(), provider: candidate.provider, @@ -436,6 +466,7 @@ mod tests { duration_ms: candidate.duration_ms, dataset_profile: candidate.dataset_profile, metadata_evidence: candidate.metadata_evidence, + copy_approval: None, }), } } @@ -515,7 +546,7 @@ mod tests { let receipt = receipt(); let envelope = export_naruon_file_lineage(&receipt, Some(&evidence(&receipt))).unwrap(); - assert_eq!(envelope.schema_version, 1); + assert_eq!(envelope.schema_version, 2); assert_eq!(envelope.schema_kind, "disksage.file-lineage"); assert_eq!(envelope.source_filename, "report.pdf"); assert_eq!(envelope.raw_content_sha256, "d".repeat(64)); @@ -542,6 +573,7 @@ mod tests { ] ); assert!(envelope.cloud_copy.local_copy_verified); + assert_eq!(envelope.cloud_copy.copy_approval_id, None); assert!(envelope.cloud_copy.provider_sync_confirmed); assert!(!envelope.cloud_copy.provider_write_executed); assert!(envelope.cloud_copy.sync_evidence_record_id.is_some()); @@ -590,6 +622,36 @@ mod tests { envelope.review.reviewed_by.as_deref(), Some("human:local:test") ); + assert_eq!( + envelope.cloud_copy.copy_approval_action, + Some(CloudCopyApprovalAction::CopyOnly) + ); + assert_eq!( + envelope.cloud_copy.copy_approved_by.as_deref(), + Some("human:test") + ); + assert_eq!( + envelope + .cloud_copy + .copy_approval_id + .as_deref() + .map(str::len), + Some(64) + ); + + let mut tampered = receipt; + tampered + .lineage + .as_mut() + .unwrap() + .copy_approval + .as_mut() + .unwrap() + .rationale = "tampered".into(); + assert_eq!( + export_naruon_file_lineage(&tampered, None).unwrap_err(), + "naruon-lineage-copy-approval-invalid" + ); } #[test] @@ -631,6 +693,13 @@ mod tests { #[test] fn export_rejects_missing_lineage_bad_digest_and_mismatched_evidence() { + let mut missing_current_approval = receipt(); + missing_current_approval.version = RECEIPT_VERSION; + assert_eq!( + export_naruon_file_lineage(&missing_current_approval, None).unwrap_err(), + "naruon-lineage-copy-approval-invalid" + ); + let mut missing = receipt(); missing.lineage = None; assert_eq!( diff --git a/src-tauri/tests/cloud_transfer_coverage_contract.rs b/src-tauri/tests/cloud_transfer_coverage_contract.rs new file mode 100644 index 000000000..245a5d299 --- /dev/null +++ b/src-tauri/tests/cloud_transfer_coverage_contract.rs @@ -0,0 +1,95 @@ +//! Source contract for cloud-transfer coverage instrumentation. +//! +//! The approval fixture is required by ordinary Rust tests even when the central coverage runner +//! adds `--cfg coverage`. This regression prevents a future refactor from excluding the helper and +//! breaking the receipt-lineage tests before coverage can be measured. + +/// Verifies that the deterministic approval fixture remains compiled for every test build. +#[test] +fn approval_fixture_remains_available_during_coverage_builds() { + let source = include_str!("../src/cloud_transfer.rs"); + + assert!( + source.contains("#[cfg(test)]\nfn test_copy_approval("), + "test_copy_approval must remain available when cfg(coverage) is active" + ); + assert!( + !source.contains("#[cfg(all(test, not(coverage)))]\nfn test_copy_approval("), + "test_copy_approval must not be excluded from coverage-mode test compilation" + ); +} + +/// Verifies that exact-copy approval public APIs keep beginner-readable documentation. +#[test] +fn cloud_copy_approval_public_surfaces_remain_documented() { + let rust_source = include_str!("../src/cloud_transfer.rs"); + let typescript_source = include_str!("../../src/lib/api.ts"); + + for documented_declaration in [ + "/// Receipt schema version used before exact action approvals were embedded.\npub const PRE_APPROVAL_RECEIPT_VERSION", + "/// Current immutable cloud-copy receipt schema version.\npub const RECEIPT_VERSION", + "/// Schema version for one exact human cloud-copy approval.\npub const CLOUD_COPY_APPROVAL_VERSION", + "/// Maximum age accepted for an exact cloud-copy approval.\npub const MAX_CLOUD_COPY_APPROVAL_AGE_MS", + "/// Identifies the exact cloud-copy action authorized by a human reviewer.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"kebab-case\")]\npub enum CloudCopyApprovalAction", + "/// Return the stable kebab-case value stored in receipts and confirmation phrases.\n pub fn as_str", + "/// Build the exact phrase a human must enter for one candidate and action.\n///\n/// The phrase includes the action and current review fingerprint, preventing a generic approval\n/// from being replayed for a different source, destination, account scope, or operation.\npub fn cloud_copy_approval_phrase", + "/// Create an integrity-bound approval after validating the candidate, destination, actor, and phrase.\n///\n/// This constructor fails closed when the candidate fingerprint is stale, the cloud root does not\n/// match the candidate, the reviewer attribution is incomplete, or the exact phrase differs.\npub fn create_cloud_copy_approval", + ] { + assert!( + rust_source.contains(documented_declaration), + "missing required Rust public documentation contract: {documented_declaration}" + ); + } + + for documented_declaration in [ + "/** Identifies the exact cloud-copy action authorized by a human reviewer. */\nexport type CloudCopyApprovalAction", + "/** Records who approved one exact candidate, destination, and action, and when. */\nexport interface CloudCopyApproval", + "/** Returns the exact backend-authored phrase only for the matching candidate action. */\nexport const cloudCopyApprovalPhrase", + ] { + assert!( + typescript_source.contains(documented_declaration), + "missing required TypeScript public documentation contract: {documented_declaration}" + ); + } +} + +/// Verifies that plans expose backend-authored phrases and the frontend never reconstructs them. +#[test] +fn cloud_plan_exports_backend_authored_approval_phrase() { + let view_source = include_str!("../src/cloud_plan_view.rs"); + let command_source = include_str!("../src/commands.rs"); + let api_source = include_str!("../../src/lib/api.ts"); + let ui_source = include_str!("../../src/lib/CloudArchive.svelte"); + + for marker in [ + "pub struct CloudPlanCandidateView", + "pub copy_approval_action: Option", + "pub exact_copy_approval_phrase: Option", + "pub copy_approval_max_age_ms: u64", + "cloud_copy_approval_phrase(&candidate, action)", + ] { + assert!(view_source.contains(marker), "missing backend plan-view marker: {marker}"); + } + assert!( + command_source.contains("Result"), + "Tauri plan command must return the typed backend-authored view" + ); + for marker in [ + "copy_approval_action?: CloudCopyApprovalAction | null", + "exact_copy_approval_phrase?: string | null", + "copy_approval_max_age_ms?: number", + "/** Returns the exact backend-authored phrase only for the matching candidate action. */", + ] { + assert!(api_source.contains(marker), "missing frontend plan contract: {marker}"); + } + assert!( + !api_source.contains("`DiskSage cloud ${action} ${candidate.review_fingerprint} 승인`"), + "frontend must not reconstruct the authorization phrase" + ); + for marker in [ + "{@const copyApprovalPhrase = api.cloudCopyApprovalPhrase(candidate, \"copy-only\")}", + "{@const adoptApprovalPhrase = api.cloudCopyApprovalPhrase(candidate, \"adopt-existing-copy\")}", + ] { + assert!(ui_source.contains(marker), "approval phrase must be evaluated once: {marker}"); + } +} diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index 428eb3dff..66005c611 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -25,6 +25,8 @@ let reviewDecisions: api.CloudReviewDecision[] = $state([]); let reviewRationales: Record = $state({}); let reviewTenantAuthorities: Record = $state({}); + let copyConfirmations: Record = $state({}); + let copyRationales: Record = $state({}); let selectedRoot = $state(""); let minSizeMib = $state(256); let minAgeDays = $state(90); @@ -94,6 +96,8 @@ reviewReason = ""; reviewSort = "bytes-desc"; reviewPage = 1; + copyConfirmations = {}; + copyRationales = {}; try { const planned = await api.planCloudArchive( scannedRoot, @@ -120,10 +124,12 @@ const embeddedHighConfidence = candidate.production_time_confidence === "high" && candidate.production_time_source.startsWith("embedded:"); const capacityEvidenceAvailable = api.cloudCapacityAllowsCopy(report?.capacity); + const approvalPhrase = api.cloudCopyApprovalPhrase(candidate, "copy-only"); return candidate.blocked_reason === null && (!candidate.requires_review || exactApproval) && (embeddedHighConfidence || exactApproval) - && capacityEvidenceAvailable; + && capacityEvidenceAvailable + && approvalPhrase !== null; } function adoptEligible(candidate: api.CloudCandidate): boolean { @@ -131,9 +137,11 @@ const exactApproval = decision?.disposition === "approved"; const embeddedHighConfidence = candidate.production_time_confidence === "high" && candidate.production_time_source.startsWith("embedded:"); + const approvalPhrase = api.cloudCopyApprovalPhrase(candidate, "adopt-existing-copy"); return candidate.blocked_reason === "destination-exists" && (!candidate.requires_review || exactApproval) - && (embeddedHighConfidence || exactApproval); + && (embeddedHighConfidence || exactApproval) + && approvalPhrase !== null; } function reviewDecision(candidate: api.CloudCandidate): api.CloudReviewDecision | null { @@ -196,6 +204,14 @@ async function copyCandidate(candidate: api.CloudCandidate) { if (!scannedRoot || !selectedRoot || !copyEligible(candidate)) return; + const exactConfirmationPhrase = + (copyConfirmations[candidate.metadata_fingerprint] ?? "").trim(); + const approvalRationale = + (copyRationales[candidate.metadata_fingerprint] ?? "").trim(); + const expectedApprovalPhrase = api.cloudCopyApprovalPhrase(candidate, "copy-only"); + if (!expectedApprovalPhrase + || exactConfirmationPhrase !== expectedApprovalPhrase + || !approvalRationale) return; copyingFingerprint = candidate.metadata_fingerprint; loadError = ""; copied = null; @@ -209,6 +225,8 @@ scannedRoot, selectedRoot, candidate.metadata_fingerprint, + exactConfirmationPhrase, + approvalRationale, Math.max(1, Math.floor(minSizeMib)), Math.max(0, Math.floor(minAgeDays)), 200, @@ -222,6 +240,17 @@ async function adoptExistingCandidate(candidate: api.CloudCandidate) { if (!scannedRoot || !selectedRoot || !adoptEligible(candidate)) return; + const exactConfirmationPhrase = + (copyConfirmations[candidate.metadata_fingerprint] ?? "").trim(); + const approvalRationale = + (copyRationales[candidate.metadata_fingerprint] ?? "").trim(); + const expectedApprovalPhrase = api.cloudCopyApprovalPhrase( + candidate, + "adopt-existing-copy", + ); + if (!expectedApprovalPhrase + || exactConfirmationPhrase !== expectedApprovalPhrase + || !approvalRationale) return; copyingFingerprint = candidate.metadata_fingerprint; loadError = ""; copied = null; @@ -235,6 +264,8 @@ scannedRoot, selectedRoot, candidate.metadata_fingerprint, + exactConfirmationPhrase, + approvalRationale, Math.max(1, Math.floor(minSizeMib)), Math.max(0, Math.floor(minAgeDays)), 200, @@ -845,22 +876,98 @@ {/if} {#if copyEligible(candidate)} - + {@const copyApprovalPhrase = api.cloudCopyApprovalPhrase(candidate, "copy-only")} +
+
현재 메타데이터·출발지·목적지에 결부된 문구를 정확히 입력해야 합니다.
+ {copyApprovalPhrase ?? "현재 계획의 승인 문구를 확인할 수 없습니다."} + + + +
{/if} {#if adoptEligible(candidate)} - + {@const adoptApprovalPhrase = api.cloudCopyApprovalPhrase(candidate, "adopt-existing-copy")} +
+
기존 목적지 파일의 전체 해시 검증·채택도 정확한 별도 승인이 필요합니다.
+ {adoptApprovalPhrase ?? "현재 계획의 채택 승인 문구를 확인할 수 없습니다."} + + + +
{/if}
메타데이터 증거 {candidate.metadata_evidence.length}건 @@ -923,6 +1030,10 @@ .schema-columns { margin: 0.25rem 0; padding-left: 1.2rem; max-height: 10rem; overflow-y: auto; } .schema-columns em { margin-left: 0.4rem; color: #9a5b00; } .context { color: #777; font-size: 0.75rem; margin-top: 0.2rem; } + .copy-approval { margin-top: 0.55rem; padding: 0.55rem; border: 1px solid #c8d4df; border-radius: 4px; background: #f8fafc; display: grid; gap: 0.45rem; } + .copy-approval code { overflow-wrap: anywhere; font-size: 0.72rem; } + .copy-approval label { display: grid; gap: 0.2rem; font-size: 0.78rem; } + .copy-approval textarea { width: min(52rem, 88vw); min-height: 3.5rem; resize: vertical; } .copy { margin-top: 0.4rem; } details { margin-top: 0.3rem; color: #59636e; font-size: 0.75rem; } .evidence { margin: 0.25rem 0 0; padding-left: 1.2rem; } diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index b1fc626bb..34a3fbb88 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -65,10 +65,10 @@ describe("api wrappers", () => { [() => api.planCloudArchive("/scan", "/cloud", 10, 30, 5), "plan_cloud_archive", { root: "/scan", cloudRoot: "/cloud", minSizeMib: 10, minAgeDays: 30, limit: 5 }], [() => api.reviewCloudCandidate("/scan", "/cloud", "a".repeat(64), "b".repeat(64), "approved", "verified exact source"), "review_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "a".repeat(64), reviewFingerprint: "b".repeat(64), disposition: "approved", rationale: "verified exact source", minSizeMib: 256, minAgeDays: 90, limit: 200 }], [() => api.reviewCloudCandidate("/scan", "/cloud", "c".repeat(64), "d".repeat(64), "held", "needs another look", 10, 30, 5), "review_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "c".repeat(64), reviewFingerprint: "d".repeat(64), disposition: "held", rationale: "needs another look", minSizeMib: 10, minAgeDays: 30, limit: 5 }], - [() => api.copyCloudCandidate("/scan", "/cloud", "a".repeat(64)), "copy_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "a".repeat(64), minSizeMib: 256, minAgeDays: 90, limit: 200 }], - [() => api.copyCloudCandidate("/scan", "/cloud", "b".repeat(64), 10, 30, 5), "copy_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "b".repeat(64), minSizeMib: 10, minAgeDays: 30, limit: 5 }], - [() => api.adoptExistingCloudCandidate("/scan", "/cloud", "e".repeat(64)), "adopt_existing_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "e".repeat(64), minSizeMib: 256, minAgeDays: 90, limit: 200 }], - [() => api.adoptExistingCloudCandidate("/scan", "/cloud", "f".repeat(64), 10, 30, 5), "adopt_existing_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "f".repeat(64), minSizeMib: 10, minAgeDays: 30, limit: 5 }], + [() => api.copyCloudCandidate("/scan", "/cloud", "a".repeat(64), "exact copy", "reviewed exact copy"), "copy_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "a".repeat(64), exactConfirmationPhrase: "exact copy", approvalRationale: "reviewed exact copy", minSizeMib: 256, minAgeDays: 90, limit: 200 }], + [() => api.copyCloudCandidate("/scan", "/cloud", "b".repeat(64), "exact copy", "reviewed exact copy", 10, 30, 5), "copy_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "b".repeat(64), exactConfirmationPhrase: "exact copy", approvalRationale: "reviewed exact copy", minSizeMib: 10, minAgeDays: 30, limit: 5 }], + [() => api.adoptExistingCloudCandidate("/scan", "/cloud", "e".repeat(64), "exact adoption", "reviewed exact adoption"), "adopt_existing_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "e".repeat(64), exactConfirmationPhrase: "exact adoption", approvalRationale: "reviewed exact adoption", minSizeMib: 256, minAgeDays: 90, limit: 200 }], + [() => api.adoptExistingCloudCandidate("/scan", "/cloud", "f".repeat(64), "exact adoption", "reviewed exact adoption", 10, 30, 5), "adopt_existing_cloud_candidate", { root: "/scan", cloudRoot: "/cloud", metadataFingerprint: "f".repeat(64), exactConfirmationPhrase: "exact adoption", approvalRationale: "reviewed exact adoption", minSizeMib: 10, minAgeDays: 30, limit: 5 }], [() => api.attestCloudCopy("c".repeat(64)), "attest_cloud_copy", { receiptId: "c".repeat(64), objectId: null }], [() => api.attestCloudCopy("d".repeat(64), "remote-id"), "attest_cloud_copy", { receiptId: "d".repeat(64), objectId: "remote-id" }], [() => api.trashVerifiedCloudSource("e".repeat(64), "e".repeat(64), "verified exact source"), "trash_verified_cloud_source", { receiptId: "e".repeat(64), confirmationReceiptId: "e".repeat(64), rationale: "verified exact source", objectId: null }], @@ -139,6 +139,27 @@ describe("cloud root identity", () => { }); }); +describe("cloud copy approval phrase", () => { + const exactPhrase = `DiskSage cloud copy-only ${"a".repeat(64)} 승인`; + + it("returns only the backend-authored phrase for the matching action", () => { + const candidate = { + copy_approval_action: "copy-only" as const, + exact_copy_approval_phrase: exactPhrase, + }; + expect(api.cloudCopyApprovalPhrase(candidate, "copy-only")).toBe(exactPhrase); + expect(api.cloudCopyApprovalPhrase(candidate, "adopt-existing-copy")).toBeNull(); + }); + + it("fails closed when the backend omitted the action or exact phrase", () => { + expect(api.cloudCopyApprovalPhrase({}, "copy-only")).toBeNull(); + expect(api.cloudCopyApprovalPhrase({ + copy_approval_action: "copy-only", + exact_copy_approval_phrase: null, + }, "copy-only")).toBeNull(); + }); +}); + describe("cloud capacity copy gate", () => { const snapshot: api.CloudCapacitySnapshot = { schema_version: 2, diff --git a/src/lib/api.ts b/src/lib/api.ts index 16caea76a..9ec9951f3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -445,6 +445,12 @@ export interface CloudCandidate { dataset_profile: DatasetProfile | null; metadata_evidence: MetadataEvidence[]; blocked_reason: string | null; + /** Backend-selected action available for this candidate's current destination state. */ + copy_approval_action?: CloudCopyApprovalAction | null; + /** Exact candidate-specific approval phrase generated by Rust, or null when blocked. */ + exact_copy_approval_phrase?: string | null; + /** Maximum age in milliseconds accepted for an approval created from this plan. */ + copy_approval_max_age_ms?: number; } export type CloudReviewDisposition = "approved" | "held"; @@ -580,6 +586,24 @@ export interface CloudCopyReceipt { } export type CloudCopyVerificationMethod = "copied-by-disk-sage" | "adopted-existing"; +/** Identifies the exact cloud-copy action authorized by a human reviewer. */ +export type CloudCopyApprovalAction = "copy-only" | "adopt-existing-copy"; + +/** Records who approved one exact candidate, destination, and action, and when. */ +export interface CloudCopyApproval { + version: number; + approval_id: string; + action: CloudCopyApprovalAction; + candidate_fingerprint: string; + review_fingerprint: string; + provider: CloudProvider; + destination_account_scope: CloudAccountScope; + cloud_root_id: string; + approved_at_ms: number; + approved_by: string; + rationale: string; + exact_confirmation_phrase: string; +} export interface CloudLineageSnapshot { candidate_fingerprint: string; @@ -608,6 +632,7 @@ export interface CloudLineageSnapshot { duration_ms: number | null; dataset_profile: DatasetProfile | null; metadata_evidence: MetadataEvidence[]; + copy_approval?: CloudCopyApproval; } export interface CloudCopyOutput { @@ -809,6 +834,8 @@ export const copyCloudCandidate = ( root: string, cloudRoot: string, metadataFingerprint: string, + exactConfirmationPhrase: string, + approvalRationale: string, minSizeMib = 256, minAgeDays = 90, limit = 200, @@ -816,6 +843,8 @@ export const copyCloudCandidate = ( root, cloudRoot, metadataFingerprint, + exactConfirmationPhrase, + approvalRationale, minSizeMib, minAgeDays, limit, @@ -824,6 +853,8 @@ export const adoptExistingCloudCandidate = ( root: string, cloudRoot: string, metadataFingerprint: string, + exactConfirmationPhrase: string, + approvalRationale: string, minSizeMib = 256, minAgeDays = 90, limit = 200, @@ -831,10 +862,20 @@ export const adoptExistingCloudCandidate = ( root, cloudRoot, metadataFingerprint, + exactConfirmationPhrase, + approvalRationale, minSizeMib, minAgeDays, limit, }); + +/** Returns the exact backend-authored phrase only for the matching candidate action. */ +export const cloudCopyApprovalPhrase = ( + candidate: Pick, + action: CloudCopyApprovalAction, +): string | null => candidate.copy_approval_action === action + ? candidate.exact_copy_approval_phrase ?? null + : null; export const attestCloudCopy = ( receiptId: string, objectId: string | null = null,