From c59990e8bb889d69e937dcb46b56c8a0f494c21b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 16 Jul 2026 23:38:17 +0900 Subject: [PATCH] feat: add metadata review decisions --- ...026-07-16-cloud-review-decisions-design.md | 43 +++ src-tauri/src/cloud.rs | 70 +++- src-tauri/src/cloud_review.rs | 363 ++++++++++++++++++ src-tauri/src/cloud_transfer.rs | 133 ++++++- src-tauri/src/commands.rs | 93 ++++- src-tauri/src/lib.rs | 3 + src/lib/CloudArchive.svelte | 76 +++- src/lib/api.ts | 33 ++ 8 files changed, 800 insertions(+), 14 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-16-cloud-review-decisions-design.md create mode 100644 src-tauri/src/cloud_review.rs diff --git a/docs/superpowers/specs/2026-07-16-cloud-review-decisions-design.md b/docs/superpowers/specs/2026-07-16-cloud-review-decisions-design.md new file mode 100644 index 000000000..1d689debb --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-cloud-review-decisions-design.md @@ -0,0 +1,43 @@ +# Cloud metadata review decisions + +## Goal + +Let an operator inspect the embedded metadata already shown in the cloud archive preview and record +an explicit `approved` or `held` decision for candidates that require review. The workflow remains +copy-only; it does not expose source deletion. + +## Safety invariants + +- A filename date remains low-confidence context and cannot satisfy the embedded high-confidence + production-date gate. +- Approval clears only `review-required`. Planner, path, provider, destination-conflict, and + embedded-date gates remain mandatory. +- A decision is bound to both the candidate metadata fingerprint and a second review fingerprint + covering the provider, destination, production-time evidence, title/authors/context, dataset + profile, metadata evidence, and review reasons shown to the operator. +- Any change to that evidence makes the previous decision stale. +- The command rebuilds the plan before accepting a decision, so a stale UI cannot approve changed + evidence. +- Decision writes and review-gated copies share an application lock. A concurrent `held` decision + cannot race an already-read approval. +- Decision files are append-only, integrity-bound, read-only JSON records. They contain hashes, + disposition, and time only; no file path or metadata value is persisted. +- The existing CLI has no review override and therefore continues to reject review-required + candidates. + +## Flow + +1. The planner probes bounded embedded metadata and computes `review_fingerprint`. +2. The UI displays the evidence and review reasons. +3. The operator chooses approve or hold. +4. The backend rebuilds the plan and requires both fingerprints to match before appending a record. +5. A copy rebuilds the plan again, loads the latest immutable decision, and runs every blocker. +6. Copy receipts and provider synchronization attestation remain unchanged; the source stays local. + +## Verification + +- Pure tests prove evidence changes expire an approval. +- Gate tests prove `held`, stale, and invalid decisions block copying. +- Gate tests prove an approval cannot make a filename-derived production date eligible. +- Persistence tests prove immutable round trips, latest-decision selection, and integrity failure. +- Rust all-target checks and frontend type checks cover command/API wiring. diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 43b4baa85..8b255169b 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -125,6 +125,8 @@ impl Default for CloudPlanOptions { pub struct CloudCandidate { /// Stable metadata fingerprint. This is not a content hash. pub metadata_fingerprint: String, + /// Stable digest of the metadata evidence shown to an operator for an approve/hold decision. + pub review_fingerprint: String, pub src: String, pub dst: String, pub provider: CloudProvider, @@ -1520,6 +1522,67 @@ fn metadata_fingerprint(file: &FileFact, relative: &Path) -> String { blake3::hash(input.as_bytes()).to_hex().to_string() } +fn hash_review_value(hasher: &mut blake3::Hasher, value: &[u8]) { + hasher.update(&(value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +/// Bind an operator review to the exact metadata evidence and destination context they saw. +/// Volatile fields such as plan generation time and `age_days` are intentionally excluded. +pub fn candidate_review_fingerprint(candidate: &CloudCandidate) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"disksage-cloud-review-v1\0"); + for value in [ + candidate.metadata_fingerprint.as_bytes(), + candidate.provider.as_str().as_bytes(), + candidate.src.as_bytes(), + candidate.dst.as_bytes(), + candidate.kind.folder().as_bytes(), + candidate.production_time_source.as_bytes(), + candidate.production_time_confidence.as_bytes(), + candidate.source_root.as_bytes(), + candidate.relative_path.as_bytes(), + candidate.source_context.as_bytes(), + if candidate.requires_review { b"1" } else { b"0" }, + ] { + hash_review_value(&mut hasher, value); + } + hash_review_value(&mut hasher, &candidate.bytes.to_le_bytes()); + hash_review_value(&mut hasher, &candidate.created_ms.to_le_bytes()); + hash_review_value(&mut hasher, &candidate.modified_ms.to_le_bytes()); + hash_review_value(&mut hasher, &candidate.production_time_ms.to_le_bytes()); + for reason in &candidate.review_reasons { + hash_review_value(&mut hasher, reason.as_bytes()); + } + hash_review_value( + &mut hasher, + candidate.content_title.as_deref().unwrap_or_default().as_bytes(), + ); + for author in &candidate.content_authors { + hash_review_value(&mut hasher, author.as_bytes()); + } + for context in &candidate.content_context { + hash_review_value(&mut hasher, context.as_bytes()); + } + hash_review_value( + &mut hasher, + &candidate.duration_ms.unwrap_or_default().to_le_bytes(), + ); + let dataset = serde_json::to_vec(&candidate.dataset_profile).unwrap_or_default(); + hash_review_value(&mut hasher, &dataset); + for evidence in &candidate.metadata_evidence { + for value in [ + evidence.field.as_bytes(), + evidence.value.as_bytes(), + evidence.source.as_bytes(), + evidence.confidence.as_bytes(), + ] { + hash_review_value(&mut hasher, value); + } + } + hasher.finalize().to_hex().to_string() +} + /// Build a dry-run report. No filesystem mutation occurs. pub fn plan_cloud_archive( files: &[FileFact], @@ -1669,8 +1732,9 @@ pub fn plan_cloud_archive( } review_reasons.sort(); review_reasons.dedup(); - candidates.push(CloudCandidate { + let mut candidate = CloudCandidate { metadata_fingerprint: metadata_fingerprint(file, relative), + review_fingerprint: String::new(), src: file.path.to_string_lossy().into_owned(), dst: dst.to_string_lossy().into_owned(), provider: cloud_root.provider, @@ -1694,7 +1758,9 @@ pub fn plan_cloud_archive( dataset_profile: lineage_metadata.dataset_profile, metadata_evidence: lineage_metadata.evidence, blocked_reason, - }); + }; + candidate.review_fingerprint = candidate_review_fingerprint(&candidate); + candidates.push(candidate); } candidates.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.src.cmp(&b.src))); candidates.truncate(options.limit); diff --git a/src-tauri/src/cloud_review.rs b/src-tauri/src/cloud_review.rs new file mode 100644 index 000000000..e7e304a70 --- /dev/null +++ b/src-tauri/src/cloud_review.rs @@ -0,0 +1,363 @@ +//! Append-only operator decisions for cloud candidates that require metadata review. +//! +//! Decisions contain no file paths or metadata values. They are bound to both the stable candidate +//! fingerprint and the exact review-evidence fingerprint produced by the fresh planner. + +use crate::cloud::{candidate_review_fingerprint, CloudCandidate}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const DECISION_VERSION: u32 = 1; +#[cfg(not(coverage))] +const MAX_DECISION_BYTES: u64 = 8 * 1024; +#[cfg(not(coverage))] +const MAX_DECISION_FILES: usize = 10_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CloudReviewDisposition { + Approved, + Held, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CloudReviewDecision { + pub version: u32, + pub decision_id: String, + pub candidate_fingerprint: String, + pub review_fingerprint: String, + pub disposition: CloudReviewDisposition, + pub reviewed_at_ms: u64, +} + +fn valid_fingerprint(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn decision_id_for( + candidate_fingerprint: &str, + review_fingerprint: &str, + disposition: CloudReviewDisposition, + reviewed_at_ms: u64, +) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(&DECISION_VERSION.to_le_bytes()); + hasher.update(candidate_fingerprint.as_bytes()); + hasher.update(&[0]); + hasher.update(review_fingerprint.as_bytes()); + hasher.update(&[0]); + hasher.update(match disposition { + CloudReviewDisposition::Approved => b"approved", + CloudReviewDisposition::Held => b"held", + }); + hasher.update(&reviewed_at_ms.to_le_bytes()); + hasher.finalize().to_hex().to_string() +} + +pub(crate) fn validate_decision(decision: &CloudReviewDecision) -> Result<(), String> { + if decision.version != DECISION_VERSION { + return Err("cloud-review-decision-version-unsupported".into()); + } + if !valid_fingerprint(&decision.candidate_fingerprint) + || !valid_fingerprint(&decision.review_fingerprint) + || !valid_fingerprint(&decision.decision_id) + { + return Err("cloud-review-decision-fingerprint-invalid".into()); + } + if decision.decision_id + != decision_id_for( + &decision.candidate_fingerprint, + &decision.review_fingerprint, + decision.disposition, + decision.reviewed_at_ms, + ) + { + return Err("cloud-review-decision-integrity-mismatch".into()); + } + Ok(()) +} + +pub fn create_decision( + candidate: &CloudCandidate, + disposition: CloudReviewDisposition, + reviewed_at_ms: u64, +) -> Result { + if !candidate.requires_review { + return Err("cloud-review-not-required".into()); + } + if !valid_fingerprint(&candidate.metadata_fingerprint) + || !valid_fingerprint(&candidate.review_fingerprint) + { + return Err("cloud-review-candidate-fingerprint-invalid".into()); + } + if candidate.review_fingerprint != candidate_review_fingerprint(candidate) { + return Err("cloud-review-fingerprint-mismatch".into()); + } + let decision_id = decision_id_for( + &candidate.metadata_fingerprint, + &candidate.review_fingerprint, + disposition, + reviewed_at_ms, + ); + Ok(CloudReviewDecision { + version: DECISION_VERSION, + decision_id, + candidate_fingerprint: candidate.metadata_fingerprint.clone(), + review_fingerprint: candidate.review_fingerprint.clone(), + disposition, + reviewed_at_ms, + }) +} + +#[cfg(not(coverage))] +fn decision_filename(decision: &CloudReviewDecision) -> String { + format!( + "{}-{:020}-{}.json", + decision.candidate_fingerprint, decision.reviewed_at_ms, decision.decision_id + ) +} + +#[cfg(not(coverage))] +fn secure_decision_directory(path: &Path) -> Result<(), String> { + std::fs::create_dir_all(path).map_err(|_| "cloud-review-directory-create-failed")?; + let metadata = + std::fs::symlink_metadata(path).map_err(|_| "cloud-review-directory-metadata-failed")?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("cloud-review-directory-unsafe".into()); + } + Ok(()) +} + +#[cfg(not(coverage))] +pub fn write_immutable_decision( + directory: &Path, + decision: &CloudReviewDecision, +) -> Result { + use std::io::Write; + + validate_decision(decision)?; + secure_decision_directory(directory)?; + let path = directory.join(decision_filename(decision)); + let encoded = serde_json::to_vec_pretty(decision) + .map_err(|_| "cloud-review-decision-json-invalid".to_string())?; + if encoded.len() as u64 > MAX_DECISION_BYTES { + return Err("cloud-review-decision-too-large".into()); + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|_| "cloud-review-decision-create-failed".to_string())?; + let result = (|| -> Result<(), String> { + file.write_all(&encoded) + .and_then(|_| file.sync_all()) + .map_err(|_| "cloud-review-decision-write-failed".to_string())?; + let mut permissions = file + .metadata() + .map_err(|_| "cloud-review-decision-metadata-failed".to_string())? + .permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&path, permissions) + .map_err(|_| "cloud-review-decision-permissions-failed".to_string())?; + #[cfg(unix)] + std::fs::File::open(directory) + .and_then(|dir| dir.sync_all()) + .map_err(|_| "cloud-review-directory-sync-failed".to_string())?; + Ok(()) + })(); + if let Err(error) = result { + drop(file); + let _ = std::fs::remove_file(&path); + return Err(error); + } + Ok(path) +} + +#[cfg(not(coverage))] +fn same_decision_file_identity(expected: &std::fs::Metadata, observed: &std::fs::Metadata) -> bool { + let common = expected.file_type().is_file() + && observed.file_type().is_file() + && !expected.file_type().is_symlink() + && !observed.file_type().is_symlink() + && expected.len() == observed.len() + && expected.permissions().readonly() + && observed.permissions().readonly() + && expected.modified().ok() == observed.modified().ok(); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + common && expected.dev() == observed.dev() && expected.ino() == observed.ino() + } + #[cfg(not(unix))] + { + common + } +} + +#[cfg(not(coverage))] +fn read_immutable_decision(path: &Path) -> Result { + use std::io::Read; + + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| "cloud-review-decision-metadata-failed".to_string())?; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || !metadata.permissions().readonly() + { + return Err("cloud-review-decision-must-be-read-only-regular-file".into()); + } + if metadata.len() > MAX_DECISION_BYTES { + return Err("cloud-review-decision-too-large".into()); + } + let mut file = + std::fs::File::open(path).map_err(|_| "cloud-review-decision-open-failed".to_string())?; + let opened = file + .metadata() + .map_err(|_| "cloud-review-decision-metadata-failed".to_string())?; + if !same_decision_file_identity(&metadata, &opened) { + return Err("cloud-review-decision-changed-during-read".into()); + } + let mut encoded = Vec::with_capacity(metadata.len() as usize); + std::io::Read::by_ref(&mut file) + .take(MAX_DECISION_BYTES + 1) + .read_to_end(&mut encoded) + .map_err(|_| "cloud-review-decision-read-failed".to_string())?; + if encoded.len() as u64 > MAX_DECISION_BYTES { + return Err("cloud-review-decision-too-large".into()); + } + let after = std::fs::symlink_metadata(path) + .map_err(|_| "cloud-review-decision-metadata-failed".to_string())?; + if !same_decision_file_identity(&metadata, &after) { + return Err("cloud-review-decision-changed-during-read".into()); + } + let decision: CloudReviewDecision = serde_json::from_slice(&encoded) + .map_err(|_| "cloud-review-decision-json-invalid".to_string())?; + validate_decision(&decision)?; + if path.file_name().and_then(|name| name.to_str()) != Some(&decision_filename(&decision)) { + return Err("cloud-review-decision-filename-mismatch".into()); + } + Ok(decision) +} + +#[cfg(not(coverage))] +pub fn load_latest_decisions(directory: &Path) -> Result, String> { + if !directory.exists() { + return Ok(Vec::new()); + } + secure_decision_directory(directory)?; + let mut paths = Vec::new(); + for entry in std::fs::read_dir(directory) + .map_err(|_| "cloud-review-directory-read-failed".to_string())? + { + let path = entry + .map_err(|_| "cloud-review-directory-entry-failed".to_string())? + .path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + if paths.len() == MAX_DECISION_FILES { + return Err("cloud-review-decision-limit-exceeded".into()); + } + paths.push(path); + } + paths.sort(); + let mut latest = std::collections::BTreeMap::::new(); + for path in paths { + let decision = read_immutable_decision(&path)?; + let replace = latest + .get(&decision.candidate_fingerprint) + .map(|current| { + (decision.reviewed_at_ms, &decision.decision_id) + > (current.reviewed_at_ms, ¤t.decision_id) + }) + .unwrap_or(true); + if replace { + latest.insert(decision.candidate_fingerprint.clone(), decision); + } + } + Ok(latest.into_values().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cloud::{ArchiveKind, CloudProvider, MetadataEvidence}; + + fn candidate() -> CloudCandidate { + let mut candidate = CloudCandidate { + metadata_fingerprint: "a".repeat(64), + review_fingerprint: String::new(), + src: "/source/report.pdf".into(), + dst: "/cloud/report.pdf".into(), + provider: CloudProvider::Icloud, + kind: ArchiveKind::Document, + bytes: 12, + age_days: 90, + created_ms: 1, + modified_ms: 2, + production_time_ms: 3, + production_time_source: "embedded:exiftool:CreateDate".into(), + production_time_confidence: "high".into(), + source_root: "/source".into(), + relative_path: "report.pdf".into(), + source_context: ".".into(), + requires_review: true, + review_reasons: vec!["embedded-metadata-probe-incomplete".into()], + content_title: Some("Report".into()), + content_authors: vec!["Author".into()], + content_context: Vec::new(), + duration_ms: None, + dataset_profile: None, + metadata_evidence: vec![MetadataEvidence { + field: "production-date".into(), + value: "2026-01-01".into(), + source: "embedded:exiftool:CreateDate".into(), + confidence: "high".into(), + }], + blocked_reason: None, + }; + candidate.review_fingerprint = candidate_review_fingerprint(&candidate); + candidate + } + + #[test] + fn decision_is_bound_to_exact_review_evidence() { + let original = candidate(); + let decision = create_decision(&original, CloudReviewDisposition::Approved, 10).unwrap(); + assert_eq!( + decision.candidate_fingerprint, + original.metadata_fingerprint + ); + assert_eq!(decision.review_fingerprint, original.review_fingerprint); + + let mut changed = original; + changed.review_reasons.push("new-evidence-warning".into()); + assert_ne!( + decision.review_fingerprint, + candidate_review_fingerprint(&changed) + ); + } + + #[cfg(not(coverage))] + #[test] + fn append_only_decisions_round_trip_and_latest_wins() { + let temp = tempfile::tempdir().unwrap(); + let candidate = candidate(); + let approved = create_decision(&candidate, CloudReviewDisposition::Approved, 10).unwrap(); + let held = create_decision(&candidate, CloudReviewDisposition::Held, 11).unwrap(); + let approved_path = write_immutable_decision(temp.path(), &approved).unwrap(); + write_immutable_decision(temp.path(), &held).unwrap(); + assert!(approved_path.metadata().unwrap().permissions().readonly()); + assert_eq!( + load_latest_decisions(temp.path()).unwrap(), + vec![held.clone()] + ); + + let mut tampered = held; + tampered.reviewed_at_ms = 12; + assert_eq!( + validate_decision(&tampered).unwrap_err(), + "cloud-review-decision-integrity-mismatch" + ); + } +} diff --git a/src-tauri/src/cloud_transfer.rs b/src-tauri/src/cloud_transfer.rs index 50f49a976..abdd146c5 100644 --- a/src-tauri/src/cloud_transfer.rs +++ b/src-tauri/src/cloud_transfer.rs @@ -4,7 +4,10 @@ //! provider-native synchronization attestation matches the immutable copy receipt. This module //! produces an eviction permit but intentionally exposes no source deletion API. -use crate::cloud::{CloudCandidate, CloudProvider, CloudRoot}; +use crate::cloud::{candidate_review_fingerprint, CloudCandidate, CloudProvider, CloudRoot}; +use crate::cloud_review::{ + validate_decision, CloudReviewDecision, CloudReviewDisposition, +}; use std::path::Path; #[cfg(not(coverage))] @@ -100,14 +103,45 @@ fn embedded_high_confidence(candidate: &CloudCandidate) -> bool { /// Validate that a dry-run candidate is still eligible to enter the copy-only phase. /// /// The function collects every reason so the UI can explain why a candidate remains blocked. -pub fn candidate_blockers(candidate: &CloudCandidate, cloud_root: &CloudRoot) -> Vec { +pub fn candidate_blockers_with_review( + candidate: &CloudCandidate, + cloud_root: &CloudRoot, + review_decision: Option<&CloudReviewDecision>, +) -> Vec { let source = Path::new(&candidate.src); let destination = Path::new(&candidate.dst); let root = Path::new(&cloud_root.path); let mut blockers = Vec::new(); + if candidate.review_fingerprint.len() != 64 + || !candidate + .review_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + blockers.push("review-fingerprint-invalid".into()); + } else if candidate.review_fingerprint != candidate_review_fingerprint(candidate) { + blockers.push("review-fingerprint-mismatch".into()); + } if candidate.requires_review { - blockers.push("review-required".into()); + match review_decision { + None => blockers.push("review-required".into()), + Some(decision) if validate_decision(decision).is_err() => { + blockers.push("review-decision-invalid".into()); + } + Some(decision) + if decision.candidate_fingerprint != candidate.metadata_fingerprint => + { + blockers.push("review-decision-candidate-mismatch".into()); + } + Some(decision) if decision.review_fingerprint != candidate.review_fingerprint => { + blockers.push("review-decision-stale".into()); + } + Some(decision) if decision.disposition == CloudReviewDisposition::Held => { + blockers.push("review-held".into()); + } + Some(_) => {} + } } if candidate.blocked_reason.is_some() { blockers.push("planner-blocked".into()); @@ -117,6 +151,13 @@ pub fn candidate_blockers(candidate: &CloudCandidate, cloud_root: &CloudRoot) -> } if candidate.metadata_fingerprint.trim().is_empty() { blockers.push("metadata-fingerprint-missing".into()); + } else if candidate.metadata_fingerprint.len() != 64 + || !candidate + .metadata_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + blockers.push("metadata-fingerprint-invalid".into()); } if candidate.provider != cloud_root.provider { blockers.push("provider-mismatch".into()); @@ -142,6 +183,10 @@ pub fn candidate_blockers(candidate: &CloudCandidate, cloud_root: &CloudRoot) -> blockers } +pub fn candidate_blockers(candidate: &CloudCandidate, cloud_root: &CloudRoot) -> Vec { + candidate_blockers_with_review(candidate, cloud_root, None) +} + fn receipt_id_for( candidate_fingerprint: &str, provider: CloudProvider, @@ -542,7 +587,21 @@ pub fn prepare_cloud_copy( receipt_dir: &Path, copied_at_ms: u64, ) -> Result<(CloudCopyReceipt, PathBuf), String> { - let blockers = candidate_blockers(candidate, cloud_root); + prepare_cloud_copy_with_review(candidate, cloud_root, receipt_dir, copied_at_ms, None) +} + +/// Copy a candidate after validating an optional operator review decision. Approval can clear only +/// the `review-required` gate; embedded high-confidence dates and all path/provider/planner gates +/// remain mandatory. +#[cfg(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 blockers = candidate_blockers_with_review(candidate, cloud_root, review_decision); if !blockers.is_empty() { return Err(blockers.join(",")); } @@ -614,8 +673,9 @@ mod tests { } fn candidate() -> CloudCandidate { - CloudCandidate { - metadata_fingerprint: "metadata-fingerprint".into(), + let mut candidate = CloudCandidate { + metadata_fingerprint: "a".repeat(64), + review_fingerprint: String::new(), src: SOURCE.into(), dst: DESTINATION.into(), provider: CloudProvider::Icloud, @@ -644,7 +704,13 @@ mod tests { confidence: "high".into(), }], blocked_reason: None, - } + }; + candidate.review_fingerprint = candidate_review_fingerprint(&candidate); + candidate + } + + fn refresh_review_fingerprint(candidate: &mut CloudCandidate) { + candidate.review_fingerprint = candidate_review_fingerprint(candidate); } fn receipt() -> CloudCopyReceipt { @@ -765,6 +831,54 @@ mod tests { .contains(&"source-already-in-cloud-root".to_string())); } + #[test] + fn operator_decision_clears_only_the_matching_review_gate() { + let mut reviewed = candidate(); + reviewed.metadata_fingerprint = "a".repeat(64); + reviewed.requires_review = true; + reviewed.review_reasons = vec!["embedded-metadata-probe-incomplete".into()]; + reviewed.review_fingerprint = crate::cloud::candidate_review_fingerprint(&reviewed); + let approved = crate::cloud_review::create_decision( + &reviewed, + CloudReviewDisposition::Approved, + 10, + ) + .unwrap(); + assert!(candidate_blockers_with_review(&reviewed, &root(), Some(&approved)).is_empty()); + + let held = crate::cloud_review::create_decision( + &reviewed, + CloudReviewDisposition::Held, + 11, + ) + .unwrap(); + assert!(candidate_blockers_with_review(&reviewed, &root(), Some(&held)) + .contains(&"review-held".to_string())); + + let mut changed = reviewed.clone(); + changed.review_reasons.push("new-warning".into()); + changed.review_fingerprint = crate::cloud::candidate_review_fingerprint(&changed); + assert!(candidate_blockers_with_review(&changed, &root(), Some(&approved)) + .contains(&"review-decision-stale".to_string())); + + let mut tampered = reviewed.clone(); + tampered.content_title = Some("Changed after review".into()); + assert!(candidate_blockers_with_review(&tampered, &root(), Some(&approved)) + .contains(&"review-fingerprint-mismatch".to_string())); + + reviewed.production_time_source = "filename-date".into(); + reviewed.production_time_confidence = "low".into(); + reviewed.review_fingerprint = crate::cloud::candidate_review_fingerprint(&reviewed); + let filename_approval = crate::cloud_review::create_decision( + &reviewed, + CloudReviewDisposition::Approved, + 12, + ) + .unwrap(); + assert!(candidate_blockers_with_review(&reviewed, &root(), Some(&filename_approval)) + .contains(&"embedded-high-confidence-date-required".to_string())); + } + #[test] fn provider_sync_evidence_is_required_before_eviction_permit() { let valid_receipt = receipt(); @@ -916,6 +1030,7 @@ mod tests { test_candidate.dst = destination.to_string_lossy().into_owned(); test_candidate.bytes = metadata.len(); test_candidate.modified_ms = modified_ms(&metadata).unwrap(); + refresh_review_fingerprint(&mut test_candidate); let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, @@ -957,6 +1072,7 @@ mod tests { test_candidate.dst = destination.to_string_lossy().into_owned(); test_candidate.bytes = 999; test_candidate.modified_ms = modified_ms(&std::fs::metadata(&source).unwrap()).unwrap(); + refresh_review_fingerprint(&mut test_candidate); let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, @@ -969,6 +1085,7 @@ mod tests { "source-changed-since-plan" ); test_candidate.bytes = std::fs::metadata(&source).unwrap().len(); + refresh_review_fingerprint(&mut test_candidate); std::fs::write(&destination, b"existing").unwrap(); assert_eq!( prepare_cloud_copy(&test_candidate, &test_root, &receipt_dir, 124).unwrap_err(), @@ -996,6 +1113,7 @@ mod tests { test_candidate.dst = destination.to_string_lossy().into_owned(); test_candidate.bytes = metadata.len(); test_candidate.modified_ms = modified_ms(&metadata).unwrap(); + refresh_review_fingerprint(&mut test_candidate); let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, @@ -1033,6 +1151,7 @@ mod tests { test_candidate.dst = destination.to_string_lossy().into_owned(); test_candidate.bytes = metadata.len(); test_candidate.modified_ms = modified_ms(&metadata).unwrap(); + refresh_review_fingerprint(&mut test_candidate); let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 32b94633e..67f2f59af 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_transfer, dev_artifacts, dupes, provider_api_client, provider_oauth, - provider_sync, rules, + cloud, cloud_review, cloud_transfer, dev_artifacts, dupes, provider_api_client, + provider_oauth, provider_sync, rules, }; #[derive(Default)] @@ -25,6 +25,8 @@ pub struct AppState { pub result: Arc>>, pub cancel: Arc, pub scanning: Arc, + /// Serialize review writes with review-gated copies so a later hold cannot race a copy. + pub cloud_review: Arc>, // 엔진은 최초 사용 시 한 번만 로드해 보관(모델 로드는 ~1GB — 호출마다 재로드 금지). feature off/coverage에서는 필드 자체가 없음. #[cfg(all(not(coverage), feature = "llm-engine"))] pub engine: Arc>>, @@ -447,6 +449,15 @@ fn oauth_connections_path(app: &AppHandle) -> Result { .map_err(|_| "app-data-directory-unavailable".to_string()) } +#[cfg(not(coverage))] +fn cloud_review_directory(app: &AppHandle) -> Result { + use tauri::Manager; + app.path() + .app_data_dir() + .map(|directory| directory.join("cloud-review-decisions")) + .map_err(|_| "app-data-directory-unavailable".to_string()) +} + /// Return non-secret OAuth connection descriptors. Refresh tokens remain in the OS credential /// store and this command never reads or returns them. #[cfg(not(coverage))] @@ -457,6 +468,15 @@ pub fn list_cloud_provider_connections( provider_oauth::load_connections(&oauth_connections_path(&app)?) } +/// Return only the latest non-secret approve/hold decision for each candidate fingerprint. +#[cfg(not(coverage))] +#[tauri::command] +pub fn list_cloud_review_decisions( + app: AppHandle, +) -> Result, String> { + cloud_review::load_latest_decisions(&cloud_review_directory(&app)?) +} + /// Start a native browser authorization-code flow with PKCE and a random loopback port. The /// provider refresh token is committed to the OS credential store only after state validation and /// a successful token exchange. Client IDs are public desktop-app identifiers, not secrets. @@ -564,6 +584,60 @@ pub fn plan_cloud_archive( .map(|(_, report)| report) } +/// Rebuild the plan and append an immutable approve/hold decision for the exact evidence shown by +/// the UI. A stale UI cannot approve a changed metadata snapshot. +#[cfg(not(coverage))] +#[tauri::command(async)] +pub fn review_cloud_candidate( + root: String, + cloud_root: String, + metadata_fingerprint: String, + review_fingerprint: String, + disposition: cloud_review::CloudReviewDisposition, + min_size_mib: u64, + min_age_days: u64, + limit: usize, + app: AppHandle, + state: State, +) -> Result { + for fingerprint in [&metadata_fingerprint, &review_fingerprint] { + if fingerprint.len() != 64 + || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err("cloud-review-fingerprint-invalid".into()); + } + } + let _guard = state + .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 matches: Vec<_> = report + .candidates + .iter() + .filter(|candidate| candidate.metadata_fingerprint == metadata_fingerprint) + .collect(); + let candidate = match matches.as_slice() { + [only] => *only, + [] => return Err("fresh-plan-candidate-not-found".into()), + _ => return Err("fresh-plan-candidate-ambiguous".into()), + }; + if candidate.review_fingerprint != review_fingerprint { + return Err("fresh-plan-review-fingerprint-mismatch".into()); + } + let decision = + cloud_review::create_decision(candidate, disposition, cloud::system_now_ms())?; + cloud_review::write_immutable_decision(&cloud_review_directory(&app)?, &decision)?; + Ok(decision) +} + #[cfg(not(coverage))] #[derive(serde::Serialize)] pub struct CloudCopyOutput { @@ -583,12 +657,17 @@ pub fn copy_cloud_candidate( min_age_days: u64, limit: usize, app: AppHandle, + state: State, ) -> Result { if metadata_fingerprint.len() != 64 || !metadata_fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("metadata-fingerprint-invalid".into()); } + let _guard = state + .cloud_review + .lock() + .map_err(|_| "cloud-review-lock-poisoned".to_string())?; let (selected, report) = cloud_plan_for_inputs( &root, &cloud_root, @@ -613,11 +692,19 @@ pub fn copy_cloud_candidate( .app_data_dir() .map_err(|_| "app-data-directory-unavailable".to_string())? .join("cloud-receipts"); - let (receipt, receipt_path) = cloud_transfer::prepare_cloud_copy( + let review_decision = if candidate.requires_review { + cloud_review::load_latest_decisions(&cloud_review_directory(&app)?)? + .into_iter() + .find(|decision| decision.candidate_fingerprint == candidate.metadata_fingerprint) + } else { + None + }; + let (receipt, receipt_path) = cloud_transfer::prepare_cloud_copy_with_review( candidate, &selected, &receipt_dir, cloud::system_now_ms(), + review_decision.as_ref(), )?; Ok(CloudCopyOutput { receipt, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8133bec3c..71f904074 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ mod reasoning; mod dataset_metadata; #[cfg_attr(coverage, allow(dead_code))] pub mod cloud; +pub mod cloud_review; pub mod cloud_transfer; pub mod content_digest; pub mod provider_api_client; @@ -73,9 +74,11 @@ pub fn run() { commands::reason_unknown_extensions, commands::list_cloud_roots, commands::list_cloud_provider_connections, + commands::list_cloud_review_decisions, commands::connect_cloud_provider, commands::disconnect_cloud_provider, commands::plan_cloud_archive, + commands::review_cloud_candidate, commands::copy_cloud_candidate, commands::attest_cloud_copy ]) diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index e226592b2..29a7b4bd2 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -7,6 +7,7 @@ let roots: api.CloudRoot[] = $state([]); let connections: api.OAuthConnection[] = $state([]); + let reviewDecisions: api.CloudReviewDecision[] = $state([]); let selectedRoot = $state(""); let minSizeMib = $state(256); let minAgeDays = $state(90); @@ -14,6 +15,7 @@ let loadError = $state(""); let report: api.CloudPlanReport | null = $state(null); let copyingFingerprint = $state(""); + let reviewingFingerprint = $state(""); let copied: api.CloudCopyOutput | null = $state(null); let attesting = $state(false); let attestation: api.CloudAttestationOutput | null = $state(null); @@ -26,6 +28,7 @@ try { roots = await api.listCloudRoots(); connections = await api.listCloudProviderConnections(); + reviewDecisions = await api.listCloudReviewDecisions(); selectedRoot = roots[0]?.path ?? ""; } catch (e) { loadError = String(e); @@ -56,12 +59,55 @@ } function copyEligible(candidate: api.CloudCandidate): boolean { + const decision = matchingReviewDecision(candidate); return candidate.blocked_reason === null - && !candidate.requires_review + && (!candidate.requires_review || decision?.disposition === "approved") && candidate.production_time_confidence === "high" && candidate.production_time_source.startsWith("embedded:"); } + function reviewDecision(candidate: api.CloudCandidate): api.CloudReviewDecision | null { + return reviewDecisions.find((decision) => + decision.candidate_fingerprint === candidate.metadata_fingerprint + ) ?? null; + } + + function matchingReviewDecision(candidate: api.CloudCandidate): api.CloudReviewDecision | null { + const decision = reviewDecision(candidate); + return decision?.review_fingerprint === candidate.review_fingerprint ? decision : null; + } + + async function reviewCandidate( + candidate: api.CloudCandidate, + disposition: api.CloudReviewDisposition, + ) { + if (!scannedRoot || !selectedRoot || !candidate.requires_review) return; + reviewingFingerprint = candidate.metadata_fingerprint; + loadError = ""; + try { + const decision = await api.reviewCloudCandidate( + scannedRoot, + selectedRoot, + candidate.metadata_fingerprint, + candidate.review_fingerprint, + disposition, + Math.max(1, Math.floor(minSizeMib)), + Math.max(0, Math.floor(minAgeDays)), + 200, + ); + reviewDecisions = [ + ...reviewDecisions.filter((entry) => + entry.candidate_fingerprint !== decision.candidate_fingerprint + ), + decision, + ]; + } catch (e) { + loadError = String(e); + } finally { + reviewingFingerprint = ""; + } + } + async function copyCandidate(candidate: api.CloudCandidate) { if (!scannedRoot || !selectedRoot || !copyEligible(candidate)) return; copyingFingerprint = candidate.metadata_fingerprint; @@ -254,7 +300,7 @@ 충돌 제외 잠재 회수 {fmtBytes(report.potentially_reclaimable_bytes)}

- 복사는 내부 메타데이터가 고신뢰이고 별도 검토 사유가 없는 후보만 가능합니다. 원본 삭제 기능은 제공하지 않으며, 업로드 증거가 확인되어도 허가 정보만 표시합니다. + 복사는 내부 메타데이터가 고신뢰이고, 검토 사유가 있으면 현재 증거에 결박된 명시적 승인이 있는 후보만 가능합니다. 원본 삭제 기능은 제공하지 않으며, 업로드 증거가 확인되어도 허가 정보만 표시합니다.

{#if copied}
@@ -343,6 +389,29 @@ {/if}
→ {candidate.dst}
맥락: {candidate.source_context} · lineage: {candidate.metadata_fingerprint.slice(0, 12)}
+ {#if candidate.requires_review} +
+ {#if matchingReviewDecision(candidate)?.disposition === "approved"} + 현재 메타데이터 증거 검토 승인됨 + {:else if matchingReviewDecision(candidate)?.disposition === "held"} + 현재 메타데이터 증거 보류됨 + {:else if reviewDecision(candidate)} + 메타데이터 증거가 바뀌어 이전 결정이 만료됨 + {:else} + 아래 증거를 확인한 뒤 승인 또는 보류하세요. + {/if} + + +
+ {/if} {#if copyEligible(candidate)}