max / makenotwork
8 files changed,
+95 insertions,
-60 deletions
| @@ -233,12 +233,19 @@ Pulling with cursor 0 fetches from the beginning. | |||
| 233 | 233 | Binary blobs (files, images, audio samples) are encrypted client-side before | |
| 234 | 234 | upload: | |
| 235 | 235 | ||
| 236 | - | 1. `blob_upload_url(hash, size)` requests a presigned S3 PUT URL from the server. | |
| 237 | - | If the blob already exists (content-addressed by hash), no upload is needed. | |
| 236 | + | 1. `blob_upload_url(hash, plaintext_size)` requests a presigned S3 PUT URL from | |
| 237 | + | the server. If the blob already exists (content-addressed by hash), no upload | |
| 238 | + | is needed. The declared size is converted to the ciphertext length before it | |
| 239 | + | is sent, because the server signs it as `Content-Length` and the PUT carries | |
| 240 | + | sealed bytes. | |
| 238 | 241 | 2. `blob_upload(hash, presigned_url, data)` encrypts the plaintext bytes with the | |
| 239 | 242 | master key, **binding the content hash as AEAD associated data**, and PUTs the | |
| 240 | 243 | ciphertext to S3. | |
| 241 | 244 | 3. `blob_confirm(hash, size)` tells the server the upload completed. | |
| 245 | + | ||
| 246 | + | Steps 1 and 2 are the in-memory path, for bytes with no file behind them. Every | |
| 247 | + | consumer app instead calls `blob_upload_streaming(hash, path)` (below), which | |
| 248 | + | covers the same ground with bounded memory and no size ceiling, then step 3. | |
| 242 | 249 | 4. `blob_download_url(hash)` requests a presigned GET URL. | |
| 243 | 250 | 5. `blob_download(expected_hash, presigned_url)` fetches the ciphertext, decrypts | |
| 244 | 251 | with the hash as AAD, then **re-verifies** that the plaintext hashes to | |
| @@ -621,8 +628,8 @@ pub trait BlobPolicy: Send + Sync { | |||
| 621 | 628 | pub struct BlobRef { pub hash: String, pub ext: String, pub size: u64 } | |
| 622 | 629 | ``` | |
| 623 | 630 | ||
| 624 | - | The engine owns the invariant parts: the `blob_upload_url` → dedup-skip-if-exists | |
| 625 | - | → skip-if-`local_path`-absent → `blob_upload` → `blob_confirm` dance, the | |
| 631 | + | The engine owns the invariant parts: the skip-if-`local_path`-absent → | |
| 632 | + | `upload_file` (which streams and dedups server-side) → `blob_confirm` dance, the | |
| 626 | 633 | `blob_download_url` → skip-if-`local_path`-present → `blob_download` → **SHA-256 | |
| 627 | 634 | re-verify** → atomic temp+rename write, and the single-item `download_one(hash)` | |
| 628 | 635 | path for a GUI "download now" action. The policy trait only answers "which blobs," |
| @@ -162,16 +162,22 @@ let (changes, new_cursor, has_more) = client.pull(device_id, cursor).await?; | |||
| 162 | 162 | ### Blob Operations (audiofiles only) | |
| 163 | 163 | ||
| 164 | 164 | ```rust | |
| 165 | - | // Upload | |
| 166 | - | let resp = client.blob_upload_url(hash, size).await?; | |
| 167 | - | client.blob_upload(resp.url, data).await?; | |
| 168 | - | client.blob_confirm(hash, size).await?; | |
| 165 | + | // Upload a file-backed blob. Streams it a part at a time, so peak memory is | |
| 166 | + | // one part rather than the whole file, and dedups server-side before reading. | |
| 167 | + | if client.blob_upload_streaming(hash, &path).await? == BlobUploadOutcome::Uploaded { | |
| 168 | + | client.blob_confirm(hash, size).await?; | |
| 169 | + | } | |
| 169 | 170 | ||
| 170 | 171 | // Download | |
| 171 | 172 | let url = client.blob_download_url(hash).await?; | |
| 172 | 173 | let data = client.blob_download(url).await?; | |
| 173 | 174 | ``` | |
| 174 | 175 | ||
| 176 | + | For bytes already in memory (no file to stream), the one-shot pair still exists: | |
| 177 | + | `blob_upload_url(hash, plaintext_size)` then `blob_upload(hash, url, data)`, then | |
| 178 | + | `blob_confirm`. It buffers the whole ciphertext and the server refuses it above | |
| 179 | + | its one-shot ceiling, so prefer the streaming call whenever there is a path. | |
| 180 | + | ||
| 175 | 181 | --- | |
| 176 | 182 | ||
| 177 | 183 | ## First-Run Sequence |
| @@ -28,6 +28,21 @@ use super::helpers::{check_response, Idempotency}; | |||
| 28 | 28 | /// Generous (4 GiB) so legitimate large media still flow. | |
| 29 | 29 | const MAX_BLOB_BYTES: usize = 4 * 1024 * 1024 * 1024; | |
| 30 | 30 | ||
| 31 | + | /// What [`SyncKitClient::blob_upload_streaming`] did. | |
| 32 | + | /// | |
| 33 | + | /// The caller needs to tell the two apart: only an upload that sent bytes has | |
| 34 | + | /// anything to confirm. | |
| 35 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 36 | + | pub enum BlobUploadOutcome { | |
| 37 | + | /// The parts were uploaded and assembled. Call [`SyncKitClient::blob_confirm`] | |
| 38 | + | /// next to record the blob server-side. | |
| 39 | + | Uploaded, | |
| 40 | + | /// The server already held this content address, so no bytes were sent and | |
| 41 | + | /// there is nothing to confirm. Server-asserted, not verified — see | |
| 42 | + | /// [`BlobUploadUrlResponse::already_exists`] for what a wrong `true` costs. | |
| 43 | + | AlreadyPresent, | |
| 44 | + | } | |
| 45 | + | ||
| 31 | 46 | impl SyncKitClient { | |
| 32 | 47 | /// Request a presigned upload URL for a blob. | |
| 33 | 48 | /// Returns (upload_url, already_exists). If `already_exists` is true, | |
| @@ -136,10 +151,18 @@ impl SyncKitClient { | |||
| 136 | 151 | /// caller hashed the file in an earlier pass, so a file edited in between | |
| 137 | 152 | /// would otherwise be stored under an address that does not describe it. | |
| 138 | 153 | /// | |
| 139 | - | /// The caller still calls [`Self::blob_confirm`] afterwards, exactly as with | |
| 140 | - | /// the one-shot path — this method replaces the transport only. | |
| 154 | + | /// Returns [`BlobUploadOutcome`]: on `Uploaded` the caller calls | |
| 155 | + | /// [`Self::blob_confirm`] next, exactly as with the one-shot path (this | |
| 156 | + | /// method replaces the transport only); on `AlreadyPresent` the server | |
| 157 | + | /// already held the content, nothing was read or sent, and there is nothing | |
| 158 | + | /// to confirm. The dedup check happens before any file read or sealing, so | |
| 159 | + | /// a re-sync of content the server already has costs one round trip. | |
| 141 | 160 | #[instrument(skip(self, path), fields(path = %path.display()))] | |
| 142 | - | pub async fn blob_upload_streaming(&self, hash: &str, path: &Path) -> Result<()> { | |
| 161 | + | pub async fn blob_upload_streaming( | |
| 162 | + | &self, | |
| 163 | + | hash: &str, | |
| 164 | + | path: &Path, | |
| 165 | + | ) -> Result<BlobUploadOutcome> { | |
| 143 | 166 | let master_key = self.require_master_key()?; | |
| 144 | 167 | ||
| 145 | 168 | let meta = tokio::fs::metadata(path).await.map_err(|e| { | |
| @@ -175,7 +198,7 @@ impl SyncKitClient { | |||
| 175 | 198 | .await?; | |
| 176 | 199 | ||
| 177 | 200 | if start.already_exists { | |
| 178 | - | return Ok(()); | |
| 201 | + | return Ok(BlobUploadOutcome::AlreadyPresent); | |
| 179 | 202 | } | |
| 180 | 203 | ||
| 181 | 204 | // Every failure past this point leaves uploaded parts that S3 bills for | |
| @@ -217,7 +240,7 @@ impl SyncKitClient { | |||
| 217 | 240 | ) | |
| 218 | 241 | .await?; | |
| 219 | 242 | ||
| 220 | - | Ok(()) | |
| 243 | + | Ok(BlobUploadOutcome::Uploaded) | |
| 221 | 244 | } | |
| 222 | 245 | ||
| 223 | 246 | /// Seal the file chunk by chunk, cutting the ciphertext at the server's |
| @@ -18,8 +18,11 @@ | |||
| 18 | 18 | //! [`list_devices`](SyncKitClient::list_devices). | |
| 19 | 19 | //! - **Push/Pull sync**: [`push`](SyncKitClient::push), [`pull`](SyncKitClient::pull), | |
| 20 | 20 | //! [`status`](SyncKitClient::status). | |
| 21 | - | //! - **Blob storage**: [`blob_upload_url`](SyncKitClient::blob_upload_url), | |
| 22 | - | //! [`blob_upload`](SyncKitClient::blob_upload), [`blob_confirm`](SyncKitClient::blob_confirm), | |
| 21 | + | //! - **Blob storage**: [`blob_upload_streaming`](SyncKitClient::blob_upload_streaming) | |
| 22 | + | //! (file-backed, bounded memory, the path for large blobs), | |
| 23 | + | //! [`blob_upload_url`](SyncKitClient::blob_upload_url) + | |
| 24 | + | //! [`blob_upload`](SyncKitClient::blob_upload) (in-memory), | |
| 25 | + | //! [`blob_confirm`](SyncKitClient::blob_confirm), | |
| 23 | 26 | //! [`blob_download_url`](SyncKitClient::blob_download_url), | |
| 24 | 27 | //! [`blob_download`](SyncKitClient::blob_download). | |
| 25 | 28 | //! | |
| @@ -51,6 +54,7 @@ | |||
| 51 | 54 | ||
| 52 | 55 | mod auth; | |
| 53 | 56 | mod blob; | |
| 57 | + | pub use blob::BlobUploadOutcome; | |
| 54 | 58 | mod encryption; | |
| 55 | 59 | pub(crate) mod helpers; | |
| 56 | 60 | mod ota; |
| @@ -80,7 +80,7 @@ pub mod types; | |||
| 80 | 80 | ||
| 81 | 81 | // Re-exports for convenience | |
| 82 | 82 | pub use client::{validate_api_key, SecretToken, SessionInfo, SyncKitClient, SyncKitConfig, SyncNotifyStream}; | |
| 83 | - | pub use client::{OtaArtifactUpload, OtaManifest, OtaRelease}; | |
| 83 | + | pub use client::{BlobUploadOutcome, OtaArtifactUpload, OtaManifest, OtaRelease}; | |
| 84 | 84 | pub use oauth::{generate_oauth_state, generate_pkce, states_match, Pkce}; | |
| 85 | 85 | pub use client::subscription::{ | |
| 86 | 86 | AccountInfo, AppPricing, BillingInterval, Cents, CheckoutResponse, PriceQuote, |
| @@ -12,14 +12,14 @@ | |||
| 12 | 12 | //! and `SyncKitClient` satisfies it in production. | |
| 13 | 13 | ||
| 14 | 14 | use std::future::Future; | |
| 15 | - | use std::path::PathBuf; | |
| 15 | + | use std::path::{Path, PathBuf}; | |
| 16 | 16 | use std::sync::Arc; | |
| 17 | 17 | ||
| 18 | 18 | use rusqlite::Connection; | |
| 19 | 19 | use sha2::{Digest, Sha256}; | |
| 20 | 20 | ||
| 21 | 21 | use super::db::DbSource; | |
| 22 | - | use crate::client::SyncKitClient; | |
| 22 | + | use crate::client::{BlobUploadOutcome, SyncKitClient}; | |
| 23 | 23 | use crate::error::{Result, SyncKitError}; | |
| 24 | 24 | ||
| 25 | 25 | /// A content-addressed blob: its SHA-256 hash (the address), file extension, and | |
| @@ -31,27 +31,19 @@ pub struct BlobRef { | |||
| 31 | 31 | pub size: u64, | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | - | /// A presigned upload target plus whether the server already has the blob. | |
| 35 | - | pub struct BlobUploadUrl { | |
| 36 | - | pub upload_url: String, | |
| 37 | - | pub already_exists: bool, | |
| 38 | - | } | |
| 39 | - | ||
| 40 | 34 | /// The blob operations the engine needs from a server. A seam for testing and | |
| 41 | 35 | /// the reference a future non-Rust SDK mirrors; [`SyncKitClient`] forwards to its | |
| 42 | 36 | /// inherent `blob_*` methods. | |
| 43 | 37 | pub trait BlobTransport: Sync { | |
| 44 | - | fn upload_url( | |
| 45 | - | &self, | |
| 46 | - | hash: &str, | |
| 47 | - | size: u64, | |
| 48 | - | ) -> impl Future<Output = Result<BlobUploadUrl>> + Send; | |
| 49 | - | fn upload( | |
| 38 | + | /// Upload the file at `path` under the content address `hash`, or report | |
| 39 | + | /// that the server already holds it. Takes a path rather than bytes so the | |
| 40 | + | /// implementation can stream: the engine must never be the reason a blob is | |
| 41 | + | /// read whole into memory. | |
| 42 | + | fn upload_file( | |
| 50 | 43 | &self, | |
| 51 | 44 | hash: &str, | |
| 52 | - | url: &str, | |
| 53 | - | data: Vec<u8>, | |
| 54 | - | ) -> impl Future<Output = Result<()>> + Send; | |
| 45 | + | path: &Path, | |
| 46 | + | ) -> impl Future<Output = Result<BlobUploadOutcome>> + Send; | |
| 55 | 47 | fn confirm(&self, hash: &str, size: u64) -> impl Future<Output = Result<()>> + Send; | |
| 56 | 48 | fn download_url(&self, hash: &str) -> impl Future<Output = Result<String>> + Send; | |
| 57 | 49 | fn download(&self, hash: &str, url: &str) -> impl Future<Output = Result<Vec<u8>>> + Send; | |
| @@ -60,12 +52,8 @@ pub trait BlobTransport: Sync { | |||
| 60 | 52 | // The trait declares `-> impl Future + Send`; implementing with `async fn` is | |
| 61 | 53 | // equivalent (the Send bound is enforced by the trait) and reads cleaner. | |
| 62 | 54 | impl BlobTransport for SyncKitClient { | |
| 63 | - | async fn upload_url(&self, hash: &str, size: u64) -> Result<BlobUploadUrl> { | |
| 64 | - | let r = SyncKitClient::blob_upload_url(self, hash, size as i64).await?; | |
| 65 | - | Ok(BlobUploadUrl { upload_url: r.upload_url, already_exists: r.already_exists }) | |
| 66 | - | } | |
| 67 | - | async fn upload(&self, hash: &str, url: &str, data: Vec<u8>) -> Result<()> { | |
| 68 | - | SyncKitClient::blob_upload(self, hash, url, data).await | |
| 55 | + | async fn upload_file(&self, hash: &str, path: &Path) -> Result<BlobUploadOutcome> { | |
| 56 | + | SyncKitClient::blob_upload_streaming(self, hash, path).await | |
| 69 | 57 | } | |
| 70 | 58 | async fn confirm(&self, hash: &str, size: u64) -> Result<()> { | |
| 71 | 59 | SyncKitClient::blob_confirm(self, hash, size as i64).await | |
| @@ -181,8 +169,10 @@ async fn upload_one<T: BlobTransport>( | |||
| 181 | 169 | if !tokio::fs::try_exists(&path).await.unwrap_or(false) { | |
| 182 | 170 | return Ok(false); // no local file to upload | |
| 183 | 171 | } | |
| 184 | - | let target = client.upload_url(&blob.hash, blob.size).await?; | |
| 185 | - | if target.already_exists { | |
| 172 | + | // The transport streams from the path, so a blob is never read whole into | |
| 173 | + | // memory here regardless of size, and it runs its own dedup check before | |
| 174 | + | // touching the file. | |
| 175 | + | if client.upload_file(&blob.hash, &path).await? == BlobUploadOutcome::AlreadyPresent { | |
| 186 | 176 | // Content-addressed dedup: the server says it holds this hash, so the | |
| 187 | 177 | // bytes stay home. Two consequences worth knowing, both bounded: | |
| 188 | 178 | // - The claim is unverified. A server that answers `true` wrongly | |
| @@ -191,14 +181,12 @@ async fn upload_one<T: BlobTransport>( | |||
| 191 | 181 | // - `on_uploaded` is deliberately not fired: nothing was uploaded, | |
| 192 | 182 | // and the confirm call that pairs with it never happened. A policy | |
| 193 | 183 | // using that hook to clear a presence flag will keep re-offering | |
| 194 | - | // the blob on every pass, costing one `upload_url` round trip each | |
| 195 | - | // time. Harmless for the no-op default (the only implementors | |
| 196 | - | // today), but a policy with an explicit flag needs its own way to | |
| 197 | - | // settle the dedup case. | |
| 184 | + | // the blob on every pass, costing one round trip each time. | |
| 185 | + | // Harmless for the no-op default (the only implementors today), but | |
| 186 | + | // a policy with an explicit flag needs its own way to settle the | |
| 187 | + | // dedup case. | |
| 198 | 188 | return Ok(true); | |
| 199 | 189 | } | |
| 200 | - | let data = tokio::fs::read(&path).await.map_err(|e| io_err("read", e))?; | |
| 201 | - | client.upload(&blob.hash, &target.upload_url, data).await?; | |
| 202 | 190 | client.confirm(&blob.hash, blob.size).await?; | |
| 203 | 191 | ||
| 204 | 192 | let blob = blob.clone(); | |
| @@ -286,12 +274,13 @@ mod tests { | |||
| 286 | 274 | } | |
| 287 | 275 | } | |
| 288 | 276 | impl BlobTransport for FakeBlobs { | |
| 289 | - | async fn upload_url(&self, hash: &str, _size: u64) -> Result<BlobUploadUrl> { | |
| 290 | - | Ok(BlobUploadUrl { upload_url: "fake://put".into(), already_exists: self.has(hash) }) | |
| 291 | - | } | |
| 292 | - | async fn upload(&self, hash: &str, _url: &str, data: Vec<u8>) -> Result<()> { | |
| 277 | + | async fn upload_file(&self, hash: &str, path: &Path) -> Result<BlobUploadOutcome> { | |
| 278 | + | if self.has(hash) { | |
| 279 | + | return Ok(BlobUploadOutcome::AlreadyPresent); | |
| 280 | + | } | |
| 281 | + | let data = std::fs::read(path).map_err(|e| io_err("read", e))?; | |
| 293 | 282 | self.store.lock().unwrap().insert(hash.to_string(), data); | |
| 294 | - | Ok(()) | |
| 283 | + | Ok(BlobUploadOutcome::Uploaded) | |
| 295 | 284 | } | |
| 296 | 285 | async fn confirm(&self, _hash: &str, _size: u64) -> Result<()> { | |
| 297 | 286 | Ok(()) |
| @@ -340,7 +340,8 @@ impl SyncStore<SyncKitClient> { | |||
| 340 | 340 | ||
| 341 | 341 | #[cfg(test)] | |
| 342 | 342 | mod tests { | |
| 343 | - | use super::super::blob::{BlobTransport, BlobUploadUrl}; | |
| 343 | + | use super::super::blob::BlobTransport; | |
| 344 | + | use crate::client::BlobUploadOutcome; | |
| 344 | 345 | use super::super::schema::SyncTable; | |
| 345 | 346 | use super::*; | |
| 346 | 347 | use crate::ids::{AppId, DeviceId, UserId}; | |
| @@ -421,13 +422,18 @@ mod tests { | |||
| 421 | 422 | } | |
| 422 | 423 | ||
| 423 | 424 | impl BlobTransport for Fake { | |
| 424 | - | async fn upload_url(&self, hash: &str, _size: u64) -> Result<BlobUploadUrl> { | |
| 425 | - | let exists = self.blobs.lock().unwrap().contains_key(hash); | |
| 426 | - | Ok(BlobUploadUrl { upload_url: "fake".into(), already_exists: exists }) | |
| 427 | - | } | |
| 428 | - | async fn upload(&self, hash: &str, _url: &str, data: Vec<u8>) -> Result<()> { | |
| 425 | + | async fn upload_file( | |
| 426 | + | &self, | |
| 427 | + | hash: &str, | |
| 428 | + | path: &std::path::Path, | |
| 429 | + | ) -> Result<BlobUploadOutcome> { | |
| 430 | + | if self.blobs.lock().unwrap().contains_key(hash) { | |
| 431 | + | return Ok(BlobUploadOutcome::AlreadyPresent); | |
| 432 | + | } | |
| 433 | + | let data = std::fs::read(path) | |
| 434 | + | .map_err(|e| crate::error::SyncKitError::Internal(e.to_string()))?; | |
| 429 | 435 | self.blobs.lock().unwrap().insert(hash.to_string(), data); | |
| 430 | - | Ok(()) | |
| 436 | + | Ok(BlobUploadOutcome::Uploaded) | |
| 431 | 437 | } | |
| 432 | 438 | async fn confirm(&self, _hash: &str, _size: u64) -> Result<()> { | |
| 433 | 439 | Ok(()) |
| @@ -29,7 +29,7 @@ pub mod sync; | |||
| 29 | 29 | pub use apply::{apply_remote_changes, ApplyOutcome}; | |
| 30 | 30 | pub use blob::{ | |
| 31 | 31 | download_blobs, download_one, sync_blobs, upload_blobs, BlobOutcome, BlobPolicy, BlobRef, | |
| 32 | - | BlobTransport, BlobUploadUrl, | |
| 32 | + | BlobTransport, | |
| 33 | 33 | }; | |
| 34 | 34 | pub use facade::{SchedulerHandle, SyncConfig, SyncOutcome, SyncStore, SyncStoreBuilder}; | |
| 35 | 35 | pub use scheduler::{NoopObserver, SyncObserver, SyncState}; |