diff --git a/README.md b/README.md index 2296420f0..62d043907 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/superpowers/specs/2026-07-17-native-file-provider-attestation-design.md b/docs/superpowers/specs/2026-07-17-native-file-provider-attestation-design.md new file mode 100644 index 000000000..22d52648c --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-native-file-provider-attestation-design.md @@ -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. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 98cfd113c..4458f4b27 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -977,6 +977,7 @@ dependencies = [ "objc2-foundation", "oxrdf", "oxttl", + "plist", "serde", "serde_json", "sha2 0.11.0", diff --git a/src-tauri/src/bin/disksage-cloud-plan.rs b/src-tauri/src/bin/disksage-cloud-plan.rs index e0ee31e22..03a215042 100644 --- a/src-tauri/src/bin/disksage-cloud-plan.rs +++ b/src-tauri/src/bin/disksage-cloud-plan.rs @@ -158,18 +158,23 @@ fn validate_action_args(args: &Args) -> Result<(), String> { } #[cfg(not(coverage))] -fn attest_icloud_receipt(path: &Path) -> Result { +fn attest_native_receipt(path: &Path) -> Result { 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, @@ -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(()); @@ -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")); } diff --git a/src-tauri/src/cloud_transfer.rs b/src-tauri/src/cloud_transfer.rs index 88aa469e7..d558618e7 100644 --- a/src-tauri/src/cloud_transfer.rs +++ b/src-tauri/src/cloud_transfer.rs @@ -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()); @@ -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))] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 67f2f59af..275a2494e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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() @@ -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) { diff --git a/src-tauri/src/provider_sync.rs b/src-tauri/src/provider_sync.rs index a1bc150e2..5462fbc45 100644 --- a/src-tauri/src/provider_sync.rs +++ b/src-tauri/src/provider_sync.rs @@ -73,6 +73,133 @@ pub fn evidence_from_icloud_snapshot( }) } +const FILE_PROVIDER_CTL_EVALUATE: &str = "fileproviderctl:evaluate"; + +/// Provider-neutral facts exposed by macOS File Provider for third-party cloud roots. +/// +/// Acquisition of the facts is platform-specific, while this value and its decision policy stay +/// deterministic and unit-testable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileProviderStatusSnapshot { + pub is_downloaded: bool, + pub is_downloading: bool, + pub is_most_recent_version_downloaded: bool, + pub is_uploaded: bool, + pub is_uploading: bool, + pub is_excluded_from_sync: bool, + pub is_sync_paused: bool, + pub observed_bytes: u64, + pub destination_blake3: String, +} + +impl FileProviderStatusSnapshot { + fn is_local_current(&self) -> bool { + self.is_downloaded && !self.is_downloading && self.is_most_recent_version_downloaded + } + + fn is_sync_complete(&self) -> bool { + self.is_local_current() + && self.is_uploaded + && !self.is_uploading + && !self.is_excluded_from_sync + && !self.is_sync_paused + } +} + +fn file_provider_evidence_id( + receipt: &CloudCopyReceipt, + snapshot: &FileProviderStatusSnapshot, + confirmed_at_ms: u64, +) -> String { + let mut hasher = blake3::Hasher::new(); + for value in [ + receipt.receipt_id.as_str(), + receipt.provider.as_str(), + FILE_PROVIDER_CTL_EVALUATE, + snapshot.destination_blake3.as_str(), + ] { + hasher.update(value.as_bytes()); + hasher.update(&[0]); + } + hasher.update(&[ + snapshot.is_downloaded as u8, + snapshot.is_downloading as u8, + snapshot.is_most_recent_version_downloaded as u8, + snapshot.is_uploaded as u8, + snapshot.is_uploading as u8, + snapshot.is_excluded_from_sync as u8, + snapshot.is_sync_paused as u8, + ]); + hasher.update(&snapshot.observed_bytes.to_le_bytes()); + hasher.update(&confirmed_at_ms.to_le_bytes()); + format!("file-provider:{}", hasher.finalize().to_hex()) +} + +/// Convert third-party File Provider status into hash-bound native evidence. +pub fn evidence_from_file_provider_snapshot( + receipt: &CloudCopyReceipt, + snapshot: &FileProviderStatusSnapshot, + confirmed_at_ms: u64, +) -> Result { + if !matches!( + receipt.provider, + CloudProvider::Onedrive | CloudProvider::GoogleDrive + ) { + return Err("third-party-file-provider-receipt-required".into()); + } + if receipt.destination.trim().is_empty() { + return Err("destination-missing".into()); + } + Ok(ProviderSyncEvidence { + receipt_id: receipt.receipt_id.clone(), + provider: receipt.provider, + destination: receipt.destination.clone(), + observed_bytes: snapshot.observed_bytes, + destination_blake3: snapshot.destination_blake3.clone(), + confirmed_at_ms, + kind: SyncEvidenceKind::ProviderNativeStatus, + evidence_id: file_provider_evidence_id(receipt, snapshot, confirmed_at_ms), + sync_complete: snapshot.is_sync_complete(), + remote_content: None, + }) +} + +fn file_provider_status_bool(output: &str, key: &str) -> Result { + let prefix = format!("{key} = "); + let value = output + .lines() + .map(str::trim) + .find_map(|line| line.strip_prefix(&prefix)) + .map(|value| value.trim().trim_end_matches(';')) + .ok_or_else(|| format!("file-provider-status-field-missing:{key}"))?; + match value { + "1" => Ok(true), + "0" => Ok(false), + _ => Err(format!("file-provider-status-field-invalid:{key}")), + } +} + +pub fn parse_file_providerctl_snapshot( + output: &str, + observed_bytes: u64, + destination_blake3: &str, +) -> Result { + Ok(FileProviderStatusSnapshot { + is_downloaded: file_provider_status_bool(output, "isDownloaded")?, + is_downloading: file_provider_status_bool(output, "isDownloading")?, + is_most_recent_version_downloaded: file_provider_status_bool( + output, + "isMostRecentVersionDownloaded", + )?, + is_uploaded: file_provider_status_bool(output, "isUploaded")?, + is_uploading: file_provider_status_bool(output, "isUploading")?, + is_excluded_from_sync: file_provider_status_bool(output, "isExcludedFromSync")?, + is_sync_paused: file_provider_status_bool(output, "isSyncPaused")?, + observed_bytes, + destination_blake3: destination_blake3.into(), + }) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderApiSnapshot { pub provider: CloudProvider, @@ -323,6 +450,123 @@ fn hash_file(path: &std::path::Path) -> Result { Ok(hasher.finalize().to_hex().to_string()) } +#[cfg(all(target_os = "macos", not(coverage)))] +fn file_providerctl_status(path: &str) -> Result { + use std::io::Read; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + const TIMEOUT: Duration = Duration::from_secs(5); + const OUTPUT_LIMIT: u64 = 256 * 1_024; + + let mut child = Command::new("/usr/bin/fileproviderctl") + .arg("evaluate") + .arg(path) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| "file-provider-status-command-unavailable".to_string())?; + let deadline = Instant::now() + TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(25)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("file-provider-status-command-timeout".into()); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("file-provider-status-command-wait-failed".into()); + } + } + }; + let mut output = Vec::new(); + child + .stdout + .take() + .ok_or_else(|| "file-provider-status-output-missing".to_string())? + .take(OUTPUT_LIMIT + 1) + .read_to_end(&mut output) + .map_err(|_| "file-provider-status-output-read-failed".to_string())?; + if !status.success() { + return Err("file-provider-status-command-failed".into()); + } + if output.len() as u64 > OUTPUT_LIMIT { + return Err("file-provider-status-output-too-large".into()); + } + String::from_utf8(output).map_err(|_| "file-provider-status-output-not-utf8".into()) +} + +/// Read macOS File Provider status for a OneDrive or Google Drive destination and bind it to the +/// verified local copy. This never hydrates, evicts, uploads, or mutates the file. +#[cfg(all(target_os = "macos", not(coverage)))] +pub fn collect_file_provider_sync_evidence( + receipt: &CloudCopyReceipt, + confirmed_at_ms: u64, +) -> Result { + use std::os::unix::fs::MetadataExt; + use std::path::Path; + + if !matches!( + receipt.provider, + CloudProvider::Onedrive | CloudProvider::GoogleDrive + ) { + return Err("third-party-file-provider-receipt-required".into()); + } + let destination = Path::new(&receipt.destination); + let metadata = std::fs::symlink_metadata(destination).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("file-provider-destination-must-be-regular-file".into()); + } + let before_modified = metadata.modified().map_err(|error| error.to_string())?; + let path = destination + .to_str() + .ok_or_else(|| "file-provider-destination-not-unicode".to_string())?; + let before = parse_file_providerctl_snapshot( + &file_providerctl_status(path)?, + metadata.len(), + "hash-pending", + )?; + if !before.is_local_current() { + return Err("file-provider-destination-not-local-current".into()); + } + + // Hash only after File Provider says the latest version is already local, avoiding hydration. + let destination_hash = hash_file(destination)?; + if metadata.len() != receipt.bytes || destination_hash != receipt.blake3 { + return Err("file-provider-destination-content-mismatch".into()); + } + let after_status = file_providerctl_status(path)?; + let after = std::fs::symlink_metadata(destination).map_err(|error| error.to_string())?; + if after.file_type().is_symlink() + || !after.is_file() + || after.len() != metadata.len() + || after.dev() != metadata.dev() + || after.ino() != metadata.ino() + || after.modified().map_err(|error| error.to_string())? != before_modified + { + return Err("file-provider-destination-changed-during-status-check".into()); + } + let snapshot = parse_file_providerctl_snapshot(&after_status, after.len(), &destination_hash)?; + if !snapshot.is_local_current() { + return Err("file-provider-destination-status-changed-during-check".into()); + } + evidence_from_file_provider_snapshot(receipt, &snapshot, confirmed_at_ms) +} + +#[cfg(any(not(target_os = "macos"), coverage))] +pub fn collect_file_provider_sync_evidence( + _receipt: &CloudCopyReceipt, + _confirmed_at_ms: u64, +) -> Result { + Err("file-provider-native-status-unsupported-platform".into()) +} + /// Read Apple's per-file ubiquitous-item flags and produce provider-native evidence. /// /// This function is read-only. It does not start a download, evict a local file, or mutate the @@ -441,6 +685,63 @@ mod tests { assert_eq!(evidence.remote_content, None); } + fn uploaded_file_provider_output() -> &'static str { + r#" + isDownloaded = 1; + isDownloading = 0; + isMostRecentVersionDownloaded = 1; + isUploaded = 1; + isUploading = 0; + isExcludedFromSync = 0; + isSyncPaused = 0; + "# + } + + #[test] + fn third_party_file_provider_status_becomes_complete_native_evidence() { + let snapshot = + parse_file_providerctl_snapshot(uploaded_file_provider_output(), 42, "content-hash") + .unwrap(); + assert!(snapshot.is_local_current()); + assert!(snapshot.is_sync_complete()); + + for provider in [CloudProvider::Onedrive, CloudProvider::GoogleDrive] { + let evidence = + evidence_from_file_provider_snapshot(&receipt(provider), &snapshot, 30).unwrap(); + assert!(evidence.sync_complete); + assert_eq!(evidence.provider, provider); + assert_eq!(evidence.kind, SyncEvidenceKind::ProviderNativeStatus); + assert!(evidence.evidence_id.starts_with("file-provider:")); + assert_eq!(evidence.remote_content, None); + } + assert_eq!( + evidence_from_file_provider_snapshot(&receipt(CloudProvider::Icloud), &snapshot, 30,) + .unwrap_err(), + "third-party-file-provider-receipt-required" + ); + } + + #[test] + fn file_provider_status_fails_closed_on_upload_locality_or_policy_flags() { + for (field, replacement) in [ + ("isDownloaded = 1", "isDownloaded = 0"), + ("isDownloading = 0", "isDownloading = 1"), + ( + "isMostRecentVersionDownloaded = 1", + "isMostRecentVersionDownloaded = 0", + ), + ("isUploaded = 1", "isUploaded = 0"), + ("isUploading = 0", "isUploading = 1"), + ("isExcludedFromSync = 0", "isExcludedFromSync = 1"), + ("isSyncPaused = 0", "isSyncPaused = 1"), + ] { + let output = uploaded_file_provider_output().replace(field, replacement); + let snapshot = parse_file_providerctl_snapshot(&output, 42, "content-hash").unwrap(); + assert!(!snapshot.is_sync_complete(), "{field}"); + } + assert!(parse_file_providerctl_snapshot("isUploaded = maybe;", 42, "hash").is_err()); + } + fn api_snapshot(provider: CloudProvider, checksum: &str) -> ProviderApiSnapshot { ProviderApiSnapshot { provider, diff --git a/src/lib/CloudArchive.svelte b/src/lib/CloudArchive.svelte index bfa53561e..5d4b87359 100644 --- a/src/lib/CloudArchive.svelte +++ b/src/lib/CloudArchive.svelte @@ -136,14 +136,13 @@ async function attestCopy() { if (!copied) return; const isIcloud = copied.receipt.provider === "icloud"; - if (!isIcloud && (!objectId.trim() || !connectionForCopiedReceipt())) return; attesting = true; loadError = ""; attestation = null; try { attestation = await api.attestCloudCopy( copied.receipt.receipt_id, - isIcloud ? null : objectId.trim(), + isIcloud ? null : objectId.trim() || null, ); } catch (e) { loadError = String(e); @@ -166,19 +165,6 @@ ) ?? null; } - function connectionForCopiedReceipt(): api.OAuthConnection | null { - if (!copied || copied.receipt.provider === "icloud") return null; - const destination = copied.receipt.destination; - return connections - .filter((connection) => { - if (connection.provider !== copied?.receipt.provider) return false; - const separator = connection.cloud_root_path.includes("\\") ? "\\" : "/"; - return destination === connection.cloud_root_path - || destination.startsWith(`${connection.cloud_root_path}${separator}`); - }) - .sort((left, right) => right.cloud_root_path.length - left.cloud_root_path.length)[0] ?? null; - } - async function connectProvider() { const root = selectedRootDetails(); if (!root || root.provider === "icloud" || !oauthClientId.trim()) return; @@ -312,21 +298,22 @@ {#if copied.receipt.provider !== "icloud"}
-

access token์€ OS ๋ณด์•ˆ ์ €์žฅ์†Œ์˜ refresh token์œผ๋กœ Rust ๋‚ด๋ถ€์—์„œ ํ•œ ๋ฒˆ๋งŒ ๊ฐฑ์‹ ํ•˜๋ฉฐ UIยท์„ค์ •ยท์˜์ˆ˜์ฆ์— ๋…ธ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

+

๋จผ์ € macOS File Provider์˜ ์—…๋กœ๋“œยท์ตœ์‹  ๋ฒ„์ „ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. item/file ID๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ๋„ค์ดํ‹ฐ๋ธŒ ์ฆ๊ฑฐ๊ฐ€ ๋ถˆ์™„์ „ํ•  ๋•Œ๋งŒ OAuth API ์ฒดํฌ์„ฌ ๊ฒ€์ฆ์œผ๋กœ ๋ณด์™„ํ•ฉ๋‹ˆ๋‹ค.

+

API ๋ณด์™„ ์‹œ access token์€ OS ๋ณด์•ˆ ์ €์žฅ์†Œ์˜ refresh token์œผ๋กœ Rust ๋‚ด๋ถ€์—์„œ ํ•œ ๋ฒˆ๋งŒ ๊ฐฑ์‹ ํ•˜๋ฉฐ UIยท์„ค์ •ยท์˜์ˆ˜์ฆ์— ๋…ธ์ถœํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

{/if} {#if attestation} {#if attestation.permit} -

์—…๋กœ๋“œยท์›๊ฒฉ ์ฒดํฌ์„ฌ ๊ฒ€์ฆ ์™„๋ฃŒ. ๋กœ์ปฌ ์ œ๊ฑฐ ํ—ˆ๊ฐ€ ์ฆ๊ฑฐ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์ง€๋งŒ ํŒŒ์ผ์€ ๊ทธ๋Œ€๋กœ ๋ณด์กด๋ฉ๋‹ˆ๋‹ค.

+

์—…๋กœ๋“œ ์ƒํƒœ์™€ ๋ณต์‚ฌ ์ฝ˜ํ…์ธ  ๊ฒ€์ฆ ์™„๋ฃŒ. ๋กœ์ปฌ ์ œ๊ฑฐ ํ—ˆ๊ฐ€ ์ฆ๊ฑฐ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์ง€๋งŒ ํŒŒ์ผ์€ ๊ทธ๋Œ€๋กœ ๋ณด์กด๋ฉ๋‹ˆ๋‹ค.

{:else}

์•„์ง ์ œ๊ฑฐ ๋ถˆ๊ฐ€: {attestation.blockers.join(", ")}

{/if}