Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
195 changes: 137 additions & 58 deletions src-tauri/src/bin/disksage-cloud-plan.rs

Large diffs are not rendered by default.

221 changes: 221 additions & 0 deletions src-tauri/src/cloud_plan_view.rs
Original file line number Diff line number Diff line change
@@ -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<CloudCopyApprovalAction>,
/// Candidate-specific confirmation phrase generated by Rust for the available action.
pub exact_copy_approval_phrase: Option<String>,
/// Maximum age, in milliseconds, accepted for an approval created from this plan.
pub copy_approval_max_age_ms: u64,
}

impl From<CloudCandidate> 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<CloudPlanOptions>,
/// Candidate evidence enriched with backend-authored approval presentation.
pub candidates: Vec<CloudPlanCandidateView>,
/// 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<CloudCapacityAssessment>,
/// Stable operator notices produced by the planner and provider gates.
pub notices: Vec<String>,
}

impl From<CloudPlanReport> 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()]
);
}
}
Loading
Loading