//! Blob sync: upload local blobs to SyncKit, download missing blobs from SyncKit. use std::path::Path; use goingson_core::CoreError; use sha2::{Digest, Sha256}; use sqlx::SqlitePool; use synckit_client::SyncKitClient; use tracing::{debug, info, warn}; use crate::commands::attachment::blob_path; use crate::state::DESKTOP_USER_ID; /// First 8 chars of a blob hash for log lines. Panic-safe: a `blob_hash` can arrive /// via sync and need not be a clean 64-hex string, so a raw `&hash[..8]` could panic /// on a short or multibyte value (ultra-fuzz Run #27 Data minor). fn short(hash: &str) -> &str { hash.get(..8).unwrap_or(hash) } /// Upload local blobs that haven't been synced to the server yet. /// /// Queries all distinct blob hashes from attachments, checks which ones have local /// files, and uploads them via the SyncKit blob API (presigned S3 + E2E encryption). #[tracing::instrument(skip_all)] pub async fn upload_pending_blobs( pool: &SqlitePool, data_dir: &Path, client: &SyncKitClient, ) -> Result { let hashes: Vec<(String, i64)> = sqlx::query_as( "SELECT DISTINCT a.blob_hash, a.file_size FROM attachments a WHERE a.user_id = ?" ) .bind(DESKTOP_USER_ID.to_string()) .fetch_all(pool) .await .map_err(CoreError::database)?; let mut uploaded = 0i64; for (hash, size) in &hashes { let path = blob_path(data_dir, hash); if !tokio::fs::try_exists(&path).await.unwrap_or(false) { continue; // No local blob to upload } // Request upload URL — server tells us if blob already exists let upload_resp = match client.blob_upload_url(hash, *size).await { Ok(r) => r, Err(e) => { warn!("Failed to get upload URL for blob {}: {}", short(hash), e); continue; } }; if upload_resp.already_exists { debug!("Blob {} already on server, skipping", short(hash)); continue; } // Read and upload let data = match tokio::fs::read(&path).await { Ok(d) => d, Err(e) => { warn!("Failed to read blob {}: {}", short(hash), e); continue; } }; if let Err(e) = client.blob_upload(hash, &upload_resp.upload_url, data).await { warn!("Failed to upload blob {}: {}", short(hash), e); continue; } // Confirm upload if let Err(e) = client.blob_confirm(hash, *size).await { warn!("Failed to confirm blob {}: {}", short(hash), e); continue; } uploaded += 1; debug!("Uploaded blob {}", short(hash)); } if uploaded > 0 { info!("Uploaded {} blobs", uploaded); } Ok(uploaded) } /// Download blobs that exist in attachment records but not on local disk. /// /// After metadata sync pulls attachment records from other devices, this function /// downloads the actual blob data from SyncKit (presigned S3 + E2E decryption). #[tracing::instrument(skip_all)] pub async fn download_missing_blobs( pool: &SqlitePool, data_dir: &Path, client: &SyncKitClient, ) -> Result { let hashes: Vec<(String,)> = sqlx::query_as( "SELECT DISTINCT blob_hash FROM attachments WHERE user_id = ?" ) .bind(DESKTOP_USER_ID.to_string()) .fetch_all(pool) .await .map_err(CoreError::database)?; let blobs_dir = data_dir.join("blobs"); tokio::fs::create_dir_all(&blobs_dir) .await .map_err(|e| CoreError::internal(format!("Failed to create blobs dir: {}", e)))?; let mut downloaded = 0i64; for (hash,) in &hashes { let path = blob_path(data_dir, hash); if tokio::fs::try_exists(&path).await.unwrap_or(false) { continue; // Already have it locally } // Get download URL let download_url = match client.blob_download_url(hash).await { Ok(url) => url, Err(e) => { warn!("Failed to get download URL for blob {}: {}", short(hash), e); continue; } }; // Download and decrypt. The SDK now AAD-binds and re-verifies the content // hash itself; the local check below stays as belt-and-braces before the // file is committed under its hash name. let data = match client.blob_download(hash, &download_url).await { Ok(d) => d, Err(e) => { warn!("Failed to download blob {}: {}", short(hash), e); continue; } }; // Verify content-addressed integrity before committing the file. The store's // invariant is "the file at blobs/ hashes to " — enforced on write // (add_attachment) but previously only assumed on read. E2E AEAD stops a network // attacker, but a server-side corruption or mis-bound ciphertext would otherwise // land wrong bytes under a trusted name and be handed to open::that() // (ultra-fuzz Run #28 S1). Hashing here, before the rename, keeps the store clean. let actual = format!("{:x}", Sha256::digest(&data)); if actual != *hash { warn!( "Blob {} failed integrity check (content hashed to {}); discarding download", short(hash), short(&actual) ); continue; } // Write to disk atomically (tmp + rename) to prevent corrupt partial files let tmp_path = path.with_extension("tmp"); if let Err(e) = tokio::fs::write(&tmp_path, &data).await { warn!("Failed to write blob {}: {}", short(hash), e); continue; } if let Err(e) = tokio::fs::rename(&tmp_path, &path).await { warn!("Failed to rename blob {}: {}", short(hash), e); let _ = tokio::fs::remove_file(&tmp_path).await; continue; } downloaded += 1; debug!("Downloaded blob {}", short(hash)); } if downloaded > 0 { info!("Downloaded {} blobs", downloaded); } Ok(downloaded) }