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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
- 🗂 **Ontology-based organizing** — files classified into an OWL taxonomy you can edit
- 📊 **Disk inventory** — "what is on my disk?", aggregated by category, unknowns surfaced
- 🧠 **On-device LLM advisor** — embedded llama.cpp model judges delete-safety, fully offline
- ☁️ **Metadata-first cloud archive** — detects iCloud Drive, OneDrive, and Google Drive; inspects embedded file metadata plus bounded CSV/TSV/JSONL schemas without retaining cell values; performs gated copy-plus-hash verification; and verifies provider metadata with native PKCE OAuth while retaining the source
- ☁️ **Metadata-first cloud archive** — detects iCloud Drive, OneDrive, and Google Drive; inspects embedded file metadata plus bounded CSV/TSV/JSONL schemas without retaining cell values; performs gated copy-plus-hash verification; and verifies macOS File Provider status first with native PKCE OAuth checksum fallback while retaining the source

## Safety first

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Native File Provider Attestation Design

## Goal

Verify that a DiskSage copy in OneDrive or Google Drive is uploaded without requiring OAuth for
the common macOS case. The result must remain bound to the immutable copy receipt and must never
hydrate, evict, upload, delete, or otherwise mutate either file.

## Evidence order

1. Validate the immutable receipt before trusting its paths.
2. Confirm that the receipt destination remains inside the currently discovered root for the same
provider.
3. Ask macOS File Provider for per-file status with a bounded, argument-only
`/usr/bin/fileproviderctl evaluate` invocation.
4. Require the destination to be downloaded, not downloading, and the most recent local version
before reading it. A cloud-only placeholder therefore fails closed instead of being hydrated.
5. Hash the already-local file and require its size and BLAKE3 to match the receipt.
6. Ask for status again, and reject a changed file identity, size, or modification time.
7. Mark native evidence complete only when the file is uploaded, not uploading, not excluded from
synchronization, and synchronization is not paused.
8. If native evidence is incomplete or unavailable and the user supplied a provider object ID,
fall back to the existing read-only OAuth API revision and checksum proof.

## Trust boundaries

- Native evidence is accepted for iCloud, OneDrive, and Google Drive only when it contains no
synthetic remote-content proof. API evidence remains mandatory when remote-content fields are
present.
- The command uses no shell, has a five-second timeout, caps output at 256 KiB, suppresses stderr,
and parses every required boolean field fail-closed.
- Native evidence records the provider, receipt ID, destination, observed bytes, destination hash,
status bits, and confirmation time in its evidence identifier.
- A successful attestation creates only a local-eviction permit value. DiskSage still retains the
source and performs no removal action.

## User experience

OneDrive and Google Drive item IDs are optional. The UI explains that File Provider metadata is
checked first and that an item ID plus an existing OS-keychain OAuth connection is used only as an
API fallback. The headless `--attest-receipt` path supports all three providers using native status.

## Why this remains metadata-first

Filenames and filename-like dates do not establish provenance or successful upload. Candidate
selection continues to prioritize embedded metadata, bounded dataset schemas, acquisition origin,
and explicit review. This attestation slice adds provider-owned synchronization metadata and binds
it to content hashes; it does not upgrade filename dates into production evidence.
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 13 additions & 8 deletions src-tauri/src/bin/disksage-cloud-plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,18 +158,23 @@ fn validate_action_args(args: &Args) -> Result<(), String> {
}

#[cfg(not(coverage))]
fn attest_icloud_receipt(path: &Path) -> Result<AttestationOutput, String> {
fn attest_native_receipt(path: &Path) -> Result<AttestationOutput, String> {
let receipt = cloud_transfer::read_immutable_receipt(path)?;
if receipt.provider != CloudProvider::Icloud {
return Err("--attest-receipt는 현재 iCloud 영수증만 지원함".into());
}
let evidence = provider_sync::collect_icloud_sync_evidence(&receipt, cloud::system_now_ms())?;
let confirmed_at_ms = cloud::system_now_ms();
let evidence = match receipt.provider {
CloudProvider::Icloud => {
provider_sync::collect_icloud_sync_evidence(&receipt, confirmed_at_ms)?
}
CloudProvider::Onedrive | CloudProvider::GoogleDrive => {
provider_sync::collect_file_provider_sync_evidence(&receipt, confirmed_at_ms)?
}
};
let (permit, blockers) = match cloud_transfer::approve_local_eviction(&receipt, &evidence) {
Ok(permit) => (Some(permit), Vec::new()),
Err(blockers) => (None, blockers),
};
Ok(AttestationOutput {
action: "attest-icloud",
action: "attest-provider-native",
receipt_id: receipt.receipt_id,
evidence,
permit,
Expand Down Expand Up @@ -213,7 +218,7 @@ fn run() -> Result<(), String> {
if let Some(receipt_path) = &args.attest_receipt {
println!(
"{}",
serde_json::to_string_pretty(&attest_icloud_receipt(receipt_path)?)
serde_json::to_string_pretty(&attest_native_receipt(receipt_path)?)
.map_err(|error| error.to_string())?
);
return Ok(());
Expand Down Expand Up @@ -438,7 +443,7 @@ mod tests {
permissions.set_readonly(true);
std::fs::set_permissions(&path, permissions).unwrap();

let error = attest_icloud_receipt(&path).unwrap_err();
let error = attest_native_receipt(&path).unwrap_err();
assert!(error.contains("receipt-integrity-mismatch"));
assert!(!error.contains("No such file"));
}
Expand Down
16 changes: 12 additions & 4 deletions src-tauri/src/cloud_transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,9 +379,9 @@ pub fn approve_local_eviction(
blockers.push("sync-evidence-id-missing".into());
}
match (evidence.kind, receipt.provider, &evidence.remote_content) {
(SyncEvidenceKind::ProviderNativeStatus, CloudProvider::Icloud, None) => {}
(SyncEvidenceKind::ProviderNativeStatus, _, _) => {
blockers.push("native-status-provider-unsupported".into());
(SyncEvidenceKind::ProviderNativeStatus, _, None) => {}
(SyncEvidenceKind::ProviderNativeStatus, _, Some(_)) => {
blockers.push("native-status-remote-content-unexpected".into());
}
(SyncEvidenceKind::ProviderApi, CloudProvider::Icloud, _) => {
blockers.push("icloud-provider-api-unsupported".into());
Expand Down Expand Up @@ -1028,9 +1028,17 @@ mod tests {

api_evidence.kind = SyncEvidenceKind::ProviderNativeStatus;
api_evidence.remote_content = None;
assert!(approve_local_eviction(&provider_receipt, &api_evidence).is_ok());

api_evidence.remote_content = Some(RemoteContentProof {
object_id: "remote-id".into(),
revision: "revision-1".into(),
algorithm: RemoteChecksumAlgorithm::QuickXor,
checksum: "quick-xor".into(),
});
assert!(approve_local_eviction(&provider_receipt, &api_evidence)
.unwrap_err()
.contains(&"native-status-provider-unsupported".to_string()));
.contains(&"native-status-remote-content-unexpected".to_string()));
}

#[cfg(not(coverage))]
Expand Down
60 changes: 38 additions & 22 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,16 +749,15 @@ pub async fn attest_cloud_copy(
let confirmed_at_ms = cloud::system_now_ms();
let evidence = match receipt.provider {
cloud::CloudProvider::Icloud => {
if object_id.as_deref().is_some_and(|value| !value.trim().is_empty())
if object_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
return Err("icloud-provider-object-id-not-accepted".into());
}
provider_sync::collect_icloud_sync_evidence(&receipt, confirmed_at_ms)?
}
cloud::CloudProvider::Onedrive | cloud::CloudProvider::GoogleDrive => {
let object_id = object_id
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "provider-object-id-missing".to_string())?;
let destination = Path::new(&receipt.destination);
let selected_root = cloud_roots
.iter()
Expand All @@ -769,25 +768,42 @@ pub async fn attest_cloud_copy(
.max_by_key(|root| Path::new(&root.path).components().count())
.cloned()
.ok_or_else(|| "receipt-cloud-root-unavailable".to_string())?;
let access_token =
provider_oauth::refreshed_access_token(&connection_path, &selected_root)?;
let locator = match receipt.provider {
cloud::CloudProvider::Onedrive => {
provider_api_client::ProviderRemoteLocator::OneDriveItemId(object_id)
}
cloud::CloudProvider::GoogleDrive => {
provider_api_client::ProviderRemoteLocator::GoogleDriveFileId(object_id)
let object_id = object_id.filter(|value| !value.trim().is_empty());
let has_object_id = object_id.is_some();
match provider_sync::collect_file_provider_sync_evidence(&receipt, confirmed_at_ms)
{
Ok(evidence) if evidence.sync_complete || !has_object_id => evidence,
Err(error) if !has_object_id => return Err(error),
Ok(_) | Err(_) => {
let object_id = object_id.expect("object id checked above");
let access_token = provider_oauth::refreshed_access_token(
&connection_path,
&selected_root,
)?;
let locator = match receipt.provider {
cloud::CloudProvider::Onedrive => {
provider_api_client::ProviderRemoteLocator::OneDriveItemId(
object_id,
)
}
cloud::CloudProvider::GoogleDrive => {
provider_api_client::ProviderRemoteLocator::GoogleDriveFileId(
object_id,
)
}
cloud::CloudProvider::Icloud => unreachable!(),
};
let client =
provider_api_client::FixedHostProviderMetadataClient::default();
provider_api_client::collect_authenticated_provider_api_evidence(
&receipt,
&locator,
access_token.as_str(),
&client,
confirmed_at_ms,
)?
}
cloud::CloudProvider::Icloud => unreachable!(),
};
let client = provider_api_client::FixedHostProviderMetadataClient::default();
provider_api_client::collect_authenticated_provider_api_evidence(
&receipt,
&locator,
access_token.as_str(),
&client,
confirmed_at_ms,
)?
}
}
};
let (permit, blockers) = match cloud_transfer::approve_local_eviction(&receipt, &evidence) {
Expand Down
Loading
Loading