max / makenotwork
6 files changed,
+725 insertions,
-13 deletions
| @@ -33,7 +33,7 @@ sha2 = "0.10" | |||
| 33 | 33 | # HTTP | |
| 34 | 34 | reqwest = { version = "0.13", features = ["json", "native-tls", "stream", "form"] } | |
| 35 | 35 | bytes = "1" | |
| 36 | - | tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } | |
| 36 | + | tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs", "io-util"] } | |
| 37 | 37 | tokio-stream = "0.1" | |
| 38 | 38 | ||
| 39 | 39 | # Serialization |
| @@ -251,6 +251,28 @@ base64) to avoid the ~33% base64 overhead on large files. v2 overhead is 44 byte | |||
| 251 | 251 | per blob: a 4-byte `sk2:` wire tag + 24-byte XChaCha20 nonce + 16-byte Poly1305 | |
| 252 | 252 | tag. | |
| 253 | 253 | ||
| 254 | + | ### Large blobs: the multipart path | |
| 255 | + | ||
| 256 | + | Steps 1 and 2 buffer the whole ciphertext, and the server refuses a one-shot PUT | |
| 257 | + | above its own ceiling. For a blob already on disk, `blob_upload_streaming(hash, | |
| 258 | + | path)` replaces both with a multipart session and holds one part at a time: | |
| 259 | + | ||
| 260 | + | 1. It computes the exact ciphertext length from the file size alone | |
| 261 | + | (`blob_encrypted_len`) and declares it to `blobs/multipart/start`, which | |
| 262 | + | returns the part geometry both sides then derive identically. | |
| 263 | + | 2. It seals 1 MiB v3 chunks straight out of the file, staging them until a full | |
| 264 | + | part is ready, and PUTs each part to a presigned URL signed for that exact | |
| 265 | + | `Content-Length`. Presigned part URLs are pulled 100 at a time as the upload | |
| 266 | + | progresses. | |
| 267 | + | 3. It recomputes the SHA-256 as it reads, so a file that changed since the | |
| 268 | + | caller hashed it fails rather than landing under a stale content address. | |
| 269 | + | 4. Any failure past `start` aborts the session, since uploaded parts bill until | |
| 270 | + | released. `blob_confirm` is still the caller's next step, unchanged. | |
| 271 | + | ||
| 272 | + | It works at any size (a small blob is a single part), so prefer it whenever the | |
| 273 | + | blob is file-backed. Not yet resumable across a process restart: the session id | |
| 274 | + | is not persisted. | |
| 275 | + | ||
| 254 | 276 | ## Keychain integration | |
| 255 | 277 | ||
| 256 | 278 | The `keychain` feature (default on) uses the `keyring` crate (v3) to store |
| @@ -5,8 +5,12 @@ | |||
| 5 | 5 | //! downloads are re-hashed on arrival and discarded on any mismatch, so a | |
| 6 | 6 | //! server that swaps or rolls back a blob cannot slip it past the client. | |
| 7 | 7 | ||
| 8 | + | use std::collections::VecDeque; | |
| 9 | + | use std::path::Path; | |
| 10 | + | ||
| 8 | 11 | use bytes::{Buf, Bytes, BytesMut}; | |
| 9 | 12 | use sha2::{Digest, Sha256}; | |
| 13 | + | use tokio::io::AsyncReadExt; | |
| 10 | 14 | use tokio_stream::StreamExt; | |
| 11 | 15 | use tracing::instrument; | |
| 12 | 16 | ||
| @@ -24,6 +28,13 @@ use super::helpers::{check_response, Idempotency}; | |||
| 24 | 28 | /// Generous (4 GiB) so legitimate large media still flow. | |
| 25 | 29 | const MAX_BLOB_BYTES: usize = 4 * 1024 * 1024 * 1024; | |
| 26 | 30 | ||
| 31 | + | /// How many presigned part URLs [`SyncKitClient::blob_upload_streaming`] pulls | |
| 32 | + | /// per request. Must not exceed the server's own window bound | |
| 33 | + | /// (`BLOB_MULTIPART_PART_URL_WINDOW`), which rejects a larger `count` outright. | |
| 34 | + | /// Pulling as the upload progresses keeps the number of live upload credentials | |
| 35 | + | /// small for a transfer that may never finish. | |
| 36 | + | const PART_URL_WINDOW: u32 = 100; | |
| 37 | + | ||
| 27 | 38 | impl SyncKitClient { | |
| 28 | 39 | /// Request a presigned upload URL for a blob. | |
| 29 | 40 | /// Returns (upload_url, already_exists). If `already_exists` is true, | |
| @@ -69,16 +80,11 @@ impl SyncKitClient { | |||
| 69 | 80 | /// content address. The plaintext is never sent to the server, preserving | |
| 70 | 81 | /// the E2E encryption guarantee. | |
| 71 | 82 | /// | |
| 72 | - | /// The input is capped at [`MAX_BLOB_BYTES`]: without it, a caller (or a | |
| 73 | - | /// hostile local integration) could hand in an arbitrarily large `data`, | |
| 74 | - | /// which this method would encrypt into an equally large ciphertext and | |
| 75 | - | /// buffer whole — an unbounded memory (OOM) lever. Bounded-memory uploads of | |
| 76 | - | /// blobs *larger* than the cap need chunk-at-a-time streaming to S3, which in | |
| 77 | - | /// turn needs multipart upload (a single presigned `PUT` requires a known, | |
| 78 | - | /// signed `Content-Length`, and `reqwest`'s streaming body sends chunked with | |
| 79 | - | /// none). That multipart path is a separate, deferred server+client feature; | |
| 80 | - | /// until it lands, blobs above the cap are rejected here rather than risking | |
| 81 | - | /// the OOM. | |
| 83 | + | /// This is the in-memory path: it buffers the whole ciphertext, so the input | |
| 84 | + | /// is capped at [`MAX_BLOB_BYTES`] rather than risking an unbounded | |
| 85 | + | /// allocation. For anything file-backed, prefer | |
| 86 | + | /// [`Self::blob_upload_streaming`], which holds one part at a time and is | |
| 87 | + | /// the only path the server accepts above its one-shot PUT ceiling. | |
| 82 | 88 | #[instrument(skip(self, presigned_url, data))] | |
| 83 | 89 | pub async fn blob_upload(&self, hash: &str, presigned_url: &str, data: Vec<u8>) -> Result<()> { | |
| 84 | 90 | if data.len() > MAX_BLOB_BYTES { | |
| @@ -94,8 +100,8 @@ impl SyncKitClient { | |||
| 94 | 100 | // Release the plaintext before the (potentially long, multi-GB) network | |
| 95 | 101 | // send so the sustained peak during transfer is ~1x the blob, not ~2x. | |
| 96 | 102 | // The per-attempt `encrypted.clone()` below is a cheap `Bytes` refcount | |
| 97 | - | // bump, not a copy. True 1x streaming (never materializing the full | |
| 98 | - | // ciphertext) arrives with the resumable multipart upload in a later pass. | |
| 103 | + | // bump, not a copy. Never materializing the full ciphertext at all is | |
| 104 | + | // what `blob_upload_streaming` does, for callers holding a file. | |
| 99 | 105 | drop(data); | |
| 100 | 106 | ||
| 101 | 107 | self.retry_request(Idempotency::IdempotentWrite { on: "content hash (S3 key is content-addressed)" }, || { | |
| @@ -111,6 +117,308 @@ impl SyncKitClient { | |||
| 111 | 117 | Ok(()) | |
| 112 | 118 | } | |
| 113 | 119 | ||
| 120 | + | /// Upload a file-backed blob through the multipart session, never holding | |
| 121 | + | /// more than one part in memory. | |
| 122 | + | /// | |
| 123 | + | /// This is the bounded-memory counterpart to [`Self::blob_upload`], and the | |
| 124 | + | /// only path for blobs above the server's one-shot PUT ceiling. It works at | |
| 125 | + | /// any size (a small blob is a single part), so it is the better default | |
| 126 | + | /// whenever the blob is already a file on disk. | |
| 127 | + | /// | |
| 128 | + | /// The v3 chunk geometry is what makes it possible. A presigned PUT needs a | |
| 129 | + | /// signed `Content-Length` up front, and `blob_encrypted_len` gives the | |
| 130 | + | /// exact ciphertext length from the plaintext length alone — so the server | |
| 131 | + | /// signs every part boundary before a single byte has been read, and this | |
| 132 | + | /// method fills those boundaries by sealing 1 MiB chunks as it streams the | |
| 133 | + | /// file. Peak memory is one part plus one chunk, not the whole blob. | |
| 134 | + | /// | |
| 135 | + | /// `hash` must be the SHA-256 of the file's plaintext, as elsewhere. It is | |
| 136 | + | /// recomputed while streaming and the upload is abandoned on mismatch: the | |
| 137 | + | /// caller hashed the file in an earlier pass, so a file edited in between | |
| 138 | + | /// would otherwise be stored under an address that does not describe it. | |
| 139 | + | /// | |
| 140 | + | /// The caller still calls [`Self::blob_confirm`] afterwards, exactly as with | |
| 141 | + | /// the one-shot path — this method replaces the transport only. | |
| 142 | + | #[instrument(skip(self, path), fields(path = %path.display()))] | |
| 143 | + | pub async fn blob_upload_streaming(&self, hash: &str, path: &Path) -> Result<()> { | |
| 144 | + | let master_key = self.require_master_key()?; | |
| 145 | + | ||
| 146 | + | let meta = tokio::fs::metadata(path).await.map_err(|e| { | |
| 147 | + | SyncKitError::Internal(format!("stat blob file {}: {e}", path.display())) | |
| 148 | + | })?; | |
| 149 | + | let plaintext_len = usize::try_from(meta.len()) | |
| 150 | + | .map_err(|_| SyncKitError::InvalidArgument("blob file is larger than usize".into()))?; | |
| 151 | + | if plaintext_len > MAX_BLOB_BYTES { | |
| 152 | + | return Err(SyncKitError::InvalidArgument(format!( | |
| 153 | + | "blob is {plaintext_len} bytes, over the {MAX_BLOB_BYTES}-byte client cap" | |
| 154 | + | ))); | |
| 155 | + | } | |
| 156 | + | // The session is sized in ciphertext, which is knowable from the | |
| 157 | + | // plaintext length alone — that is the whole trick. | |
| 158 | + | let size_bytes = i64::try_from(crypto::blob_encrypted_len(plaintext_len)) | |
| 159 | + | .map_err(|_| SyncKitError::InvalidArgument("blob ciphertext exceeds i64".into()))?; | |
| 160 | + | ||
| 161 | + | let token = self.require_token()?; | |
| 162 | + | let start_body = Bytes::from(serde_json::to_vec(&BlobMultipartStartRequest { | |
| 163 | + | hash: hash.to_string(), | |
| 164 | + | size_bytes, | |
| 165 | + | })?); | |
| 166 | + | let start: BlobMultipartStartResponse = self | |
| 167 | + | .retry_request_json(Idempotency::ReadOnly, || { | |
| 168 | + | let req = self | |
| 169 | + | .http | |
| 170 | + | .post(&self.endpoints.blobs_multipart_start) | |
| 171 | + | .bearer_auth(&token) | |
| 172 | + | .header("content-type", "application/json") | |
| 173 | + | .body(start_body.clone()); | |
| 174 | + | async move { check_response(req.send().await?).await } | |
| 175 | + | }) | |
| 176 | + | .await?; | |
| 177 | + | ||
| 178 | + | if start.already_exists { | |
| 179 | + | return Ok(()); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // Every failure past this point leaves uploaded parts that S3 bills for | |
| 183 | + | // until the session is aborted, so the abort lives in exactly one place: | |
| 184 | + | // here, around the whole transfer. The server-side orphan reaper is the | |
| 185 | + | // backstop for a client that dies outright. | |
| 186 | + | let transfer = self | |
| 187 | + | .stream_blob_parts(hash, &start, path, plaintext_len, size_bytes, &master_key) | |
| 188 | + | .await; | |
| 189 | + | let parts = match transfer { | |
| 190 | + | Ok(parts) => parts, | |
| 191 | + | Err(e) => { | |
| 192 | + | if let Err(abort_err) = self.blob_multipart_abort(hash, &start.upload_id).await { | |
| 193 | + | tracing::warn!( | |
| 194 | + | error = %abort_err, | |
| 195 | + | "failed to abort multipart blob session; the server reaper will release it" | |
| 196 | + | ); | |
| 197 | + | } | |
| 198 | + | return Err(e); | |
| 199 | + | } | |
| 200 | + | }; | |
| 201 | + | ||
| 202 | + | let complete_body = Bytes::from(serde_json::to_vec(&BlobMultipartCompleteRequest { | |
| 203 | + | hash: hash.to_string(), | |
| 204 | + | upload_id: start.upload_id.clone(), | |
| 205 | + | parts, | |
| 206 | + | })?); | |
| 207 | + | self.retry_request( | |
| 208 | + | Idempotency::IdempotentWrite { on: "upload_id (completing twice assembles the same object)" }, | |
| 209 | + | || { | |
| 210 | + | let req = self | |
| 211 | + | .http | |
| 212 | + | .post(&self.endpoints.blobs_multipart_complete) | |
| 213 | + | .bearer_auth(&token) | |
| 214 | + | .header("content-type", "application/json") | |
| 215 | + | .body(complete_body.clone()); | |
| 216 | + | async move { check_response(req.send().await?).await } | |
| 217 | + | }, | |
| 218 | + | ) | |
| 219 | + | .await?; | |
| 220 | + | ||
| 221 | + | Ok(()) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// Seal the file chunk by chunk, cutting the ciphertext at the server's | |
| 225 | + | /// signed part boundaries and PUTting each part. Returns the completed | |
| 226 | + | /// `(part_number, etag)` pairs. | |
| 227 | + | /// | |
| 228 | + | /// Peak memory is one part plus one chunk: the staging buffer never holds | |
| 229 | + | /// more than a part's worth, because a full part is drained and sent the | |
| 230 | + | /// moment it is complete. | |
| 231 | + | async fn stream_blob_parts( | |
| 232 | + | &self, | |
| 233 | + | hash: &str, | |
| 234 | + | start: &BlobMultipartStartResponse, | |
| 235 | + | path: &Path, | |
| 236 | + | plaintext_len: usize, | |
| 237 | + | size_bytes: i64, | |
| 238 | + | master_key: &[u8; 32], | |
| 239 | + | ) -> Result<Vec<BlobMultipartCompletedPart>> { | |
| 240 | + | let part_size = usize::try_from(start.part_size).map_err(|_| { | |
| 241 | + | SyncKitError::Internal(format!("server part_size {} exceeds usize", start.part_size)) | |
| 242 | + | })?; | |
| 243 | + | if part_size == 0 || start.part_count == 0 { | |
| 244 | + | return Err(SyncKitError::Internal( | |
| 245 | + | "server returned an empty multipart plan".into(), | |
| 246 | + | )); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | let mut file = tokio::fs::File::open(path).await.map_err(|e| { | |
| 250 | + | SyncKitError::Internal(format!("open blob file {}: {e}", path.display())) | |
| 251 | + | })?; | |
| 252 | + | ||
| 253 | + | let chunk_count = crypto::blob_chunk_count_for(plaintext_len); | |
| 254 | + | let mut hasher = Sha256::new(); | |
| 255 | + | let mut plain = vec![0u8; crypto::BLOB_CHUNK_SIZE]; | |
| 256 | + | let mut staged = BytesMut::with_capacity(part_size + crypto::BLOB_CHUNK_SIZE); | |
| 257 | + | staged.extend_from_slice(&crypto::blob_header_bytes(plaintext_len)); | |
| 258 | + | ||
| 259 | + | let mut urls: VecDeque<BlobMultipartPartUrl> = VecDeque::new(); | |
| 260 | + | let mut completed = Vec::with_capacity(start.part_count as usize); | |
| 261 | + | let mut next_part = 1u32; | |
| 262 | + | ||
| 263 | + | for index in 0..chunk_count { | |
| 264 | + | let offset = index as usize * crypto::BLOB_CHUNK_SIZE; | |
| 265 | + | let want = (plaintext_len - offset).min(crypto::BLOB_CHUNK_SIZE); | |
| 266 | + | file.read_exact(&mut plain[..want]).await.map_err(|e| { | |
| 267 | + | SyncKitError::Internal(format!( | |
| 268 | + | "read blob file {} at offset {offset}: {e}", | |
| 269 | + | path.display() | |
| 270 | + | )) | |
| 271 | + | })?; | |
| 272 | + | hasher.update(&plain[..want]); | |
| 273 | + | staged.extend_from_slice(&crypto::seal_blob_chunk( | |
| 274 | + | &plain[..want], | |
| 275 | + | master_key, | |
| 276 | + | hash, | |
| 277 | + | index, | |
| 278 | + | chunk_count, | |
| 279 | + | )?); | |
| 280 | + | ||
| 281 | + | // Every part but the last is exactly `part_size`; the remainder is | |
| 282 | + | // the final part, so it is never sent from inside this loop. | |
| 283 | + | while staged.len() >= part_size && next_part < start.part_count { | |
| 284 | + | let body = staged.split_to(part_size).freeze(); | |
| 285 | + | let etag = self | |
| 286 | + | .upload_blob_part(hash, start, size_bytes, next_part, &mut urls, body) | |
| 287 | + | .await?; | |
| 288 | + | completed.push(BlobMultipartCompletedPart { | |
| 289 | + | part_number: next_part as i32, | |
| 290 | + | etag, | |
| 291 | + | }); | |
| 292 | + | next_part += 1; | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // Verify the content address before the object can be assembled. A | |
| 297 | + | // mismatch here aborts the session, so a file that changed after the | |
| 298 | + | // caller hashed it is never stored under the stale address. | |
| 299 | + | let actual = format!("{:x}", hasher.finalize()); | |
| 300 | + | if actual != hash { | |
| 301 | + | return Err(SyncKitError::IntegrityFailed { | |
| 302 | + | expected: hash.to_string(), | |
| 303 | + | actual, | |
| 304 | + | }); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | if next_part != start.part_count { | |
| 308 | + | return Err(SyncKitError::Internal(format!( | |
| 309 | + | "sealed stream produced {} parts, server planned {}", | |
| 310 | + | next_part, start.part_count | |
| 311 | + | ))); | |
| 312 | + | } | |
| 313 | + | let body = staged.split().freeze(); | |
| 314 | + | let etag = self | |
| 315 | + | .upload_blob_part(hash, start, size_bytes, next_part, &mut urls, body) | |
| 316 | + | .await?; | |
| 317 | + | completed.push(BlobMultipartCompletedPart { | |
| 318 | + | part_number: next_part as i32, | |
| 319 | + | etag, | |
| 320 | + | }); | |
| 321 | + | ||
| 322 | + | Ok(completed) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | /// PUT one part to its presigned URL, refilling the URL window as needed, | |
| 326 | + | /// and return the ETag S3 assigned it. | |
| 327 | + | async fn upload_blob_part( | |
| 328 | + | &self, | |
| 329 | + | hash: &str, | |
| 330 | + | start: &BlobMultipartStartResponse, | |
| 331 | + | size_bytes: i64, | |
| 332 | + | part_number: u32, | |
| 333 | + | urls: &mut VecDeque<BlobMultipartPartUrl>, | |
| 334 | + | body: Bytes, | |
| 335 | + | ) -> Result<String> { | |
| 336 | + | if urls.is_empty() { | |
| 337 | + | let token = self.require_token()?; | |
| 338 | + | let req_body = Bytes::from(serde_json::to_vec(&BlobMultipartPartsRequest { | |
| 339 | + | hash: hash.to_string(), | |
| 340 | + | upload_id: start.upload_id.clone(), | |
| 341 | + | size_bytes, | |
| 342 | + | first_part: part_number, | |
| 343 | + | count: PART_URL_WINDOW.min(start.part_count - part_number + 1), | |
| 344 | + | })?); | |
| 345 | + | let window: BlobMultipartPartsResponse = self | |
| 346 | + | .retry_request_json(Idempotency::ReadOnly, || { | |
| 347 | + | let req = self | |
| 348 | + | .http | |
| 349 | + | .post(&self.endpoints.blobs_multipart_parts) | |
| 350 | + | .bearer_auth(&token) | |
| 351 | + | .header("content-type", "application/json") | |
| 352 | + | .body(req_body.clone()); | |
| 353 | + | async move { check_response(req.send().await?).await } | |
| 354 | + | }) | |
| 355 | + | .await?; | |
| 356 | + | urls.extend(window.parts); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | let part = urls.pop_front().ok_or_else(|| { | |
| 360 | + | SyncKitError::Internal(format!("server returned no URL for part {part_number}")) | |
| 361 | + | })?; | |
| 362 | + | // The two sides derive the plan independently, so a disagreement means | |
| 363 | + | // one of them is wrong about the geometry. Sending anyway would fail the | |
| 364 | + | // signed Content-Length at S3 with a far less legible error. | |
| 365 | + | if part.part_number != part_number as i32 || part.content_length != body.len() as u64 { | |
| 366 | + | return Err(SyncKitError::Internal(format!( | |
| 367 | + | "part geometry mismatch: server signed part {} for {} bytes, client has part {part_number} of {} bytes", | |
| 368 | + | part.part_number, | |
| 369 | + | part.content_length, | |
| 370 | + | body.len() | |
| 371 | + | ))); | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | let resp = self | |
| 375 | + | .retry_request( | |
| 376 | + | Idempotency::IdempotentWrite { on: "(upload_id, part_number) — re-PUTting a part replaces it" }, | |
| 377 | + | || { | |
| 378 | + | let req = self | |
| 379 | + | .http_stream | |
| 380 | + | .put(&part.url) | |
| 381 | + | .header("content-type", "application/octet-stream") | |
| 382 | + | .body(body.clone()); | |
| 383 | + | async move { check_response(req.send().await?).await } | |
| 384 | + | }, | |
| 385 | + | ) | |
| 386 | + | .await?; | |
| 387 | + | ||
| 388 | + | // S3 identifies each part by the ETag it returns; complete cannot be | |
| 389 | + | // assembled without it. | |
| 390 | + | resp.headers() | |
| 391 | + | .get(reqwest::header::ETAG) | |
| 392 | + | .and_then(|v| v.to_str().ok()) | |
| 393 | + | .map(|s| s.to_string()) | |
| 394 | + | .ok_or_else(|| { | |
| 395 | + | SyncKitError::Internal(format!("part {part_number} upload returned no ETag")) | |
| 396 | + | }) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | /// Release an abandoned multipart session and the parts it holds. | |
| 400 | + | async fn blob_multipart_abort(&self, hash: &str, upload_id: &str) -> Result<()> { | |
| 401 | + | let token = self.require_token()?; | |
| 402 | + | let body = Bytes::from(serde_json::to_vec(&BlobMultipartAbortRequest { | |
| 403 | + | hash: hash.to_string(), | |
| 404 | + | upload_id: upload_id.to_string(), | |
| 405 | + | })?); | |
| 406 | + | self.retry_request( | |
| 407 | + | Idempotency::IdempotentWrite { on: "upload_id (aborting twice is a no-op)" }, | |
| 408 | + | || { | |
| 409 | + | let req = self | |
| 410 | + | .http | |
| 411 | + | .post(&self.endpoints.blobs_multipart_abort) | |
| 412 | + | .bearer_auth(&token) | |
| 413 | + | .header("content-type", "application/json") | |
| 414 | + | .body(body.clone()); | |
| 415 | + | async move { check_response(req.send().await?).await } | |
| 416 | + | }, | |
| 417 | + | ) | |
| 418 | + | .await?; | |
| 419 | + | Ok(()) | |
| 420 | + | } | |
| 421 | + | ||
| 114 | 422 | /// Confirm that a blob upload completed successfully. | |
| 115 | 423 | /// The server verifies the object exists in S3 and records it. | |
| 116 | 424 | #[instrument(skip(self))] |
| @@ -114,6 +114,10 @@ struct Endpoints { | |||
| 114 | 114 | blobs_upload: String, | |
| 115 | 115 | blobs_confirm: String, | |
| 116 | 116 | blobs_download: String, | |
| 117 | + | blobs_multipart_start: String, | |
| 118 | + | blobs_multipart_parts: String, | |
| 119 | + | blobs_multipart_complete: String, | |
| 120 | + | blobs_multipart_abort: String, | |
| 117 | 121 | subscription: String, | |
| 118 | 122 | subscription_checkout: String, | |
| 119 | 123 | subscription_quote: String, | |
| @@ -143,6 +147,10 @@ impl Endpoints { | |||
| 143 | 147 | blobs_upload: format!("{base}/api/v1/sync/blobs/upload"), | |
| 144 | 148 | blobs_confirm: format!("{base}/api/v1/sync/blobs/confirm"), | |
| 145 | 149 | blobs_download: format!("{base}/api/v1/sync/blobs/download"), | |
| 150 | + | blobs_multipart_start: format!("{base}/api/v1/sync/blobs/multipart/start"), | |
| 151 | + | blobs_multipart_parts: format!("{base}/api/v1/sync/blobs/multipart/parts"), | |
| 152 | + | blobs_multipart_complete: format!("{base}/api/v1/sync/blobs/multipart/complete"), | |
| 153 | + | blobs_multipart_abort: format!("{base}/api/v1/sync/blobs/multipart/abort"), | |
| 146 | 154 | subscription: format!("{base}/api/v1/sync/subscription"), | |
| 147 | 155 | subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"), | |
| 148 | 156 | subscription_quote: format!("{base}/api/v1/sync/subscription/quote"), |
| @@ -530,6 +530,71 @@ pub(crate) struct BlobConfirmRequest { | |||
| 530 | 530 | pub size_bytes: i64, | |
| 531 | 531 | } | |
| 532 | 532 | ||
| 533 | + | // ── Blob multipart session ── | |
| 534 | + | // | |
| 535 | + | // The chunked transport for blobs above the server's one-shot PUT ceiling. | |
| 536 | + | // `size_bytes` throughout is the *ciphertext* length (`blob_encrypted_len` of | |
| 537 | + | // the plaintext), which both sides need to derive the same part geometry. | |
| 538 | + | ||
| 539 | + | #[derive(Serialize)] | |
| 540 | + | pub(crate) struct BlobMultipartStartRequest { | |
| 541 | + | pub hash: String, | |
| 542 | + | pub size_bytes: i64, | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | #[derive(Deserialize)] | |
| 546 | + | pub(crate) struct BlobMultipartStartResponse { | |
| 547 | + | pub upload_id: String, | |
| 548 | + | /// Ciphertext bytes per part; every part but the last is exactly this long. | |
| 549 | + | pub part_size: u64, | |
| 550 | + | pub part_count: u32, | |
| 551 | + | /// Server already holds this content address — no session was opened. Same | |
| 552 | + | /// consumer contract as [`BlobUploadUrlResponse::already_exists`]. | |
| 553 | + | pub already_exists: bool, | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | #[derive(Serialize)] | |
| 557 | + | pub(crate) struct BlobMultipartPartsRequest { | |
| 558 | + | pub hash: String, | |
| 559 | + | pub upload_id: String, | |
| 560 | + | pub size_bytes: i64, | |
| 561 | + | pub first_part: u32, | |
| 562 | + | pub count: u32, | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | #[derive(Deserialize)] | |
| 566 | + | pub(crate) struct BlobMultipartPartsResponse { | |
| 567 | + | pub parts: Vec<BlobMultipartPartUrl>, | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | #[derive(Deserialize)] | |
| 571 | + | pub(crate) struct BlobMultipartPartUrl { | |
| 572 | + | pub part_number: i32, | |
| 573 | + | /// The `Content-Length` this URL was signed for. The PUT must send exactly | |
| 574 | + | /// this many bytes. | |
| 575 | + | pub content_length: u64, | |
| 576 | + | pub url: String, | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | #[derive(Serialize)] | |
| 580 | + | pub(crate) struct BlobMultipartCompleteRequest { | |
| 581 | + | pub hash: String, | |
| 582 | + | pub upload_id: String, | |
| 583 | + | pub parts: Vec<BlobMultipartCompletedPart>, | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | #[derive(Serialize)] | |
| 587 | + | pub(crate) struct BlobMultipartCompletedPart { | |
| 588 | + | pub part_number: i32, | |
| 589 | + | pub etag: String, | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | #[derive(Serialize)] | |
| 593 | + | pub(crate) struct BlobMultipartAbortRequest { | |
| 594 | + | pub hash: String, | |
| 595 | + | pub upload_id: String, | |
| 596 | + | } | |
| 597 | + | ||
| 533 | 598 | #[derive(Serialize)] | |
| 534 | 599 | pub(crate) struct BlobDownloadUrlRequest { | |
| 535 | 600 | pub hash: String, |
| @@ -3124,3 +3124,312 @@ mod rotation_e2e { | |||
| 3124 | 3124 | ); | |
| 3125 | 3125 | } | |
| 3126 | 3126 | } | |
| 3127 | + | ||
| 3128 | + | // ── Multipart blob upload (streaming) ── | |
| 3129 | + | // | |
| 3130 | + | // The transport for blobs above the server's one-shot PUT ceiling. What matters | |
| 3131 | + | // here is that the client never buffers the whole ciphertext yet still produces | |
| 3132 | + | // a byte-exact v3 blob: it seals 1 MiB chunks as it reads the file and cuts the | |
| 3133 | + | // sealed stream at the part boundaries the server signed, which it could only | |
| 3134 | + | // pick because `blob_encrypted_len` predicts the ciphertext size up front. | |
| 3135 | + | mod blob_multipart { | |
| 3136 | + | use super::*; | |
| 3137 | + | use std::path::PathBuf; | |
| 3138 | + | ||
| 3139 | + | const START_PATH: &str = "/api/v1/sync/blobs/multipart/start"; | |
| 3140 | + | const PARTS_PATH: &str = "/api/v1/sync/blobs/multipart/parts"; | |
| 3141 | + | const COMPLETE_PATH: &str = "/api/v1/sync/blobs/multipart/complete"; | |
| 3142 | + | const ABORT_PATH: &str = "/api/v1/sync/blobs/multipart/abort"; | |
| 3143 | + | const PART_PUT_PATH: &str = "/s3/part"; | |
| 3144 | + | ||
| 3145 | + | fn temp_blob(name: &str, contents: &[u8]) -> PathBuf { | |
| 3146 | + | use std::sync::atomic::{AtomicU64, Ordering}; | |
| 3147 | + | static N: AtomicU64 = AtomicU64::new(0); | |
| 3148 | + | let mut p = std::env::temp_dir(); | |
| 3149 | + | p.push(format!( | |
| 3150 | + | "synckit_mp_{}_{}_{name}", | |
| 3151 | + | std::process::id(), | |
| 3152 | + | N.fetch_add(1, Ordering::Relaxed) | |
| 3153 | + | )); | |
| 3154 | + | std::fs::write(&p, contents).unwrap(); | |
| 3155 | + | p | |
| 3156 | + | } | |
| 3157 | + | ||
| 3158 | + | /// Mount the whole session: start (with the given plan), a parts window | |
| 3159 | + | /// whose URLs all point at one PUT path, and complete. | |
| 3160 | + | async fn mount_session(server: &MockServer, cipher_len: usize, part_size: usize) -> u32 { | |
| 3161 | + | let part_count = cipher_len.div_ceil(part_size) as u32; | |
| 3162 | + | let parts: Vec<serde_json::Value> = (1..=part_count) | |
| 3163 | + | .map(|n| { | |
| 3164 | + | let content_length = if n == part_count { | |
| 3165 | + | cipher_len - part_size * (part_count as usize - 1) | |
| 3166 | + | } else { | |
| 3167 | + | part_size | |
| 3168 | + | }; | |
| 3169 | + | json!({ | |
| 3170 | + | "part_number": n, | |
| 3171 | + | "content_length": content_length, | |
| 3172 | + | "url": format!("{}{PART_PUT_PATH}?partNumber={n}", server.uri()), | |
| 3173 | + | }) | |
| 3174 | + | }) | |
| 3175 | + | .collect(); | |
| 3176 | + | ||
| 3177 | + | Mock::given(method("POST")) | |
| 3178 | + | .and(path(START_PATH)) | |
| 3179 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 3180 | + | "upload_id": "test-upload-id", | |
| 3181 | + | "part_size": part_size, | |
| 3182 | + | "part_count": part_count, | |
| 3183 | + | "already_exists": false, | |
| 3184 | + | }))) | |
| 3185 | + | .mount(server) | |
| 3186 | + | .await; | |
| 3187 | + | Mock::given(method("POST")) | |
| 3188 | + | .and(path(PARTS_PATH)) | |
| 3189 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "parts": parts }))) | |
| 3190 | + | .mount(server) | |
| 3191 | + | .await; | |
| 3192 | + | Mock::given(method("PUT")) | |
| 3193 | + | .and(path(PART_PUT_PATH)) | |
| 3194 | + | .respond_with(ResponseTemplate::new(200).append_header("ETag", "\"part-etag\"")) | |
| 3195 | + | .mount(server) | |
| 3196 | + | .await; | |
| 3197 | + | Mock::given(method("POST")) | |
| 3198 | + | .and(path(COMPLETE_PATH)) | |
| 3199 | + | .respond_with(ResponseTemplate::new(204)) | |
| 3200 | + | .mount(server) | |
| 3201 | + | .await; | |
| 3202 | + | ||
| 3203 | + | part_count | |
| 3204 | + | } | |
| 3205 | + | ||
| 3206 | + | fn hits(reqs: &[wiremock::Request], p: &str) -> usize { | |
| 3207 | + | reqs.iter().filter(|r| r.url.path() == p).count() | |
| 3208 | + | } | |
| 3209 | + | ||
| 3210 | + | #[tokio::test] | |
| 3211 | + | async fn streaming_upload_tiles_the_parts_into_a_valid_blob() { | |
| 3212 | + | let server = MockServer::start().await; | |
| 3213 | + | let client = authed_client(&server); | |
| 3214 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 3215 | + | client.set_master_key_raw(key); | |
| 3216 | + | ||
| 3217 | + | // Spans four 1 MiB chunks (three full plus a remainder), so sealed | |
| 3218 | + | // chunks straddle part boundaries rather than lining up with them. | |
| 3219 | + | let plaintext: Vec<u8> = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE * 3 + 7)) | |
| 3220 | + | .map(|i| i as u8) | |
| 3221 | + | .collect(); | |
| 3222 | + | let hash = format!("{:x}", sha2::Sha256::digest(&plaintext)); | |
| 3223 | + | let file = temp_blob("big.bin", &plaintext); | |
| 3224 | + | ||
| 3225 | + | let cipher_len = synckit_client::crypto::blob_encrypted_len(plaintext.len()); | |
| 3226 | + | let part_size = 1024 * 1024; | |
| 3227 | + | let part_count = mount_session(&server, cipher_len, part_size).await; | |
| 3228 | + | assert!(part_count > 1, "the fixture must actually be multipart"); | |
| 3229 | + | ||
| 3230 | + | client.blob_upload_streaming(&hash, &file).await.unwrap(); | |
| 3231 | + | ||
| 3232 | + | let reqs = server.received_requests().await.unwrap(); | |
| 3233 | + | ||
| 3234 | + | // The session was sized in ciphertext, predicted from the plaintext. | |
| 3235 | + | let start: serde_json::Value = reqs | |
| 3236 | + | .iter() | |
| 3237 | + | .find(|r| r.url.path() == START_PATH) | |
| 3238 | + | .unwrap() | |
| 3239 | + | .body_json() | |
| 3240 | + | .unwrap(); | |
| 3241 | + | assert_eq!(start["size_bytes"].as_u64().unwrap(), cipher_len as u64); | |
| 3242 | + | assert_eq!(start["hash"].as_str().unwrap(), hash); | |
| 3243 | + | ||
| 3244 | + | // Every part carried exactly the length the server signed for it. | |
| 3245 | + | let puts: Vec<&wiremock::Request> = | |
| 3246 | + | reqs.iter().filter(|r| r.url.path() == PART_PUT_PATH).collect(); | |
| 3247 | + | assert_eq!(puts.len() as u32, part_count, "one PUT per planned part"); | |
| 3248 | + | for (i, put) in puts.iter().enumerate() { | |
| 3249 | + | let expected = if i as u32 == part_count - 1 { | |
| 3250 | + | cipher_len - part_size * (part_count as usize - 1) | |
| 3251 | + | } else { | |
| 3252 | + | part_size | |
| 3253 | + | }; | |
| 3254 | + | assert_eq!(put.body.len(), expected, "part {} length", i + 1); | |
| 3255 | + | } | |
| 3256 | + | ||
| 3257 | + | // The concatenated parts are a valid v3 blob for this content address — | |
| 3258 | + | // proof that streaming produced the same wire format as the in-memory | |
| 3259 | + | // encrypt, boundaries and all. | |
| 3260 | + | let assembled: Vec<u8> = puts.iter().flat_map(|r| r.body.clone()).collect(); | |
| 3261 | + | assert_eq!(assembled.len(), cipher_len); | |
| 3262 | + | let decrypted = | |
| 3263 | + | synckit_client::crypto::decrypt_blob_chunked(&assembled, &key, &hash).unwrap(); | |
| 3264 | + | assert_eq!(decrypted, plaintext, "streamed blob must round-trip"); | |
| 3265 | + | ||
| 3266 | + | // Complete named every part, in order, with the ETag S3 returned. | |
| 3267 | + | let complete: serde_json::Value = reqs | |
| 3268 | + | .iter() | |
| 3269 | + | .find(|r| r.url.path() == COMPLETE_PATH) | |
| 3270 | + | .unwrap() | |
| 3271 | + | .body_json() | |
| 3272 | + | .unwrap(); | |
| 3273 | + | let named = complete["parts"].as_array().unwrap(); | |
| 3274 | + | assert_eq!(named.len() as u32, part_count); | |
| 3275 | + | for (i, part) in named.iter().enumerate() { | |
| 3276 | + | assert_eq!(part["part_number"].as_u64().unwrap(), i as u64 + 1); | |
| 3277 | + | assert_eq!(part["etag"].as_str().unwrap(), "\"part-etag\""); | |
| 3278 | + | } | |
| 3279 | + | assert_eq!(hits(&reqs, ABORT_PATH), 0, "a clean upload must not abort"); | |
| 3280 | + | ||
| 3281 | + | std::fs::remove_file(&file).ok(); | |
| 3282 | + | } | |
| 3283 | + | ||
| 3284 | + | #[tokio::test] | |
| 3285 | + | async fn streaming_upload_handles_an_empty_file() { | |
| 3286 | + | let server = MockServer::start().await; | |
| 3287 | + | let client = authed_client(&server); | |
| 3288 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 3289 | + | client.set_master_key_raw(key); | |
| 3290 | + | ||
| 3291 | + | let hash = format!("{:x}", sha2::Sha256::digest(b"")); | |
| 3292 | + | let file = temp_blob("empty.bin", b""); | |
| 3293 | + | let cipher_len = synckit_client::crypto::blob_encrypted_len(0); | |
| 3294 | + | mount_session(&server, cipher_len, 1024 * 1024).await; | |
| 3295 | + | ||
| 3296 | + | client.blob_upload_streaming(&hash, &file).await.unwrap(); | |
| 3297 | + | ||
| 3298 | + | let reqs = server.received_requests().await.unwrap(); | |
| 3299 | + | let put = reqs.iter().find(|r| r.url.path() == PART_PUT_PATH).unwrap(); | |
| 3300 | + | assert_eq!(put.body.len(), cipher_len, "one part carries the whole blob"); | |
| 3301 | + | assert_eq!( | |
| 3302 | + | synckit_client::crypto::decrypt_blob_chunked(&put.body, &key, &hash).unwrap(), | |
| 3303 | + | Vec::<u8>::new(), | |
| 3304 | + | "an empty blob is still an authenticated single chunk" | |
| 3305 | + | ); | |
| 3306 | + | ||
| 3307 | + | std::fs::remove_file(&file).ok(); | |
| 3308 | + | } | |
| 3309 | + | ||
| 3310 | + | #[tokio::test] | |
| 3311 | + | async fn streaming_upload_skips_when_the_server_already_has_the_content() { | |
| 3312 | + | let server = MockServer::start().await; | |
| 3313 | + | let client = authed_client(&server); | |
| 3314 | + | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 3315 | + | ||
| 3316 | + | Mock::given(method("POST")) | |
| 3317 | + | .and(path(START_PATH)) | |
| 3318 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 3319 | + | "upload_id": "", | |
| 3320 | + | "part_size": 0, | |
| 3321 | + | "part_count": 0, | |
| 3322 | + | "already_exists": true, | |
| 3323 | + | }))) | |
| 3324 | + | .mount(&server) | |
| 3325 | + | .await; | |
| 3326 | + | ||
| 3327 | + | let plaintext = b"content the server already holds"; | |
| 3328 | + | let hash = format!("{:x}", sha2::Sha256::digest(plaintext)); | |
| 3329 | + | let file = temp_blob("dedup.bin", plaintext); | |
| 3330 | + | ||
| 3331 | + | client.blob_upload_streaming(&hash, &file).await.unwrap(); | |
| 3332 | + | ||
| 3333 | + | // Dedup must cost nothing: no file bytes read out to the wire, no | |
| 3334 | + | // session to clean up. | |
| 3335 | + | let reqs = server.received_requests().await.unwrap(); | |
| 3336 | + | assert_eq!(hits(&reqs, PART_PUT_PATH), 0, "dedup must not upload parts"); | |
| 3337 | + | assert_eq!(hits(&reqs, COMPLETE_PATH), 0); | |
| 3338 | + | assert_eq!(hits(&reqs, ABORT_PATH), 0); | |
| 3339 | + | ||
| 3340 | + | std::fs::remove_file(&file).ok(); | |
| 3341 | + | } | |
| 3342 | + | ||
| 3343 | + | #[tokio::test] | |
| 3344 | + | async fn streaming_upload_aborts_when_the_file_no_longer_matches_its_hash() { | |
| 3345 | + | // The caller hashed the file in an earlier pass. If it changed since, | |
| 3346 | + | // storing it under the stale content address would poison the address: | |
| 3347 | + | // every later download would re-hash and reject it. Fail here instead, | |
| 3348 | + | // and release the parts. | |
| 3349 | + | let server = MockServer::start().await; | |
| 3350 | + | let client = authed_client(&server); | |
| 3351 | + | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 3352 | + | ||
| 3353 | + | let plaintext = b"the bytes actually on disk"; | |
| 3354 | + | let stale_hash = format!("{:x}", sha2::Sha256::digest(b"what the caller hashed earlier")); | |
| 3355 | + | let file = temp_blob("changed.bin", plaintext); | |
| 3356 | + | ||
| 3357 | + | let cipher_len = synckit_client::crypto::blob_encrypted_len(plaintext.len()); | |
| 3358 | + | mount_session(&server, cipher_len, 1024 * 1024).await; | |
| 3359 | + | Mock::given(method("POST")) | |
| 3360 | + | .and(path(ABORT_PATH)) | |
| 3361 | + | .respond_with(ResponseTemplate::new(204)) | |
| 3362 | + | .mount(&server) | |
| 3363 | + | .await; | |
| 3364 | + | ||
| 3365 | + | let err = client | |
| 3366 | + | .blob_upload_streaming(&stale_hash, &file) | |
| 3367 | + | .await | |
| 3368 | + | .expect_err("a hash mismatch must not be uploaded"); | |
| 3369 | + | assert!( | |
| 3370 | + | matches!(err, SyncKitError::IntegrityFailed { .. }), | |
| 3371 | + | "expected IntegrityFailed, got {err:?}" | |
| 3372 | + | ); | |
| 3373 | + | ||
| 3374 | + | let reqs = server.received_requests().await.unwrap(); | |
| 3375 | + | assert_eq!(hits(&reqs, COMPLETE_PATH), 0, "a mismatched blob must not be assembled"); | |
| 3376 | + | assert_eq!(hits(&reqs, ABORT_PATH), 1, "the session must be released"); | |
| 3377 | + | ||
| 3378 | + | std::fs::remove_file(&file).ok(); | |
| 3379 | + | } | |
| 3380 | + | ||
| 3381 | + | #[tokio::test] | |
| 3382 | + | async fn streaming_upload_aborts_when_a_part_upload_fails() { | |
| 3383 | + | // Parts already sent are billed until the session is aborted, so any | |
| 3384 | + | // failure past `start` has to release it. | |
| 3385 | + | let server = MockServer::start().await; | |
| 3386 | + | let client = authed_client(&server); | |
| 3387 | + | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 3388 | + | ||
| 3389 | + | let plaintext = b"a blob whose part upload will fail"; | |
| 3390 | + | let hash = format!("{:x}", sha2::Sha256::digest(plaintext)); | |
| 3391 | + | let file = temp_blob("failing.bin", plaintext); | |
| 3392 | + | let cipher_len = synckit_client::crypto::blob_encrypted_len(plaintext.len()); | |
| 3393 | + | ||
| 3394 | + | Mock::given(method("POST")) | |
| 3395 | + | .and(path(START_PATH)) | |
| 3396 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 3397 | + | "upload_id": "test-upload-id", | |
| 3398 | + | "part_size": cipher_len, | |
| 3399 | + | "part_count": 1, | |
| 3400 | + | "already_exists": false, | |
| 3401 | + | }))) | |
| 3402 | + | .mount(&server) | |
| 3403 | + | .await; | |
| 3404 | + | Mock::given(method("POST")) | |
| 3405 | + | .and(path(PARTS_PATH)) | |
| 3406 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 3407 | + | "parts": [{ | |
| 3408 | + | "part_number": 1, | |
| 3409 | + | "content_length": cipher_len, | |
| 3410 | + | "url": format!("{}{PART_PUT_PATH}", server.uri()), | |
| 3411 | + | }] | |
| 3412 | + | }))) | |
| 3413 | + | .mount(&server) | |
| 3414 | + | .await; | |
| 3415 | + | Mock::given(method("PUT")) | |
| 3416 | + | .and(path(PART_PUT_PATH)) | |
| 3417 | + | .respond_with(ResponseTemplate::new(403)) | |
| 3418 | + | .mount(&server) | |
| 3419 | + | .await; | |
| 3420 | + | Mock::given(method("POST")) | |
| 3421 | + | .and(path(ABORT_PATH)) | |
| 3422 | + | .respond_with(ResponseTemplate::new(204)) | |
| 3423 | + | .mount(&server) | |
| 3424 | + | .await; | |
| 3425 | + | ||
| 3426 | + | let err = client.blob_upload_streaming(&hash, &file).await.unwrap_err(); | |
| 3427 | + | assert!(matches!(err, SyncKitError::Server { status: 403, .. }), "got {err:?}"); | |
| 3428 | + | ||
| 3429 | + | let reqs = server.received_requests().await.unwrap(); | |
| 3430 | + | assert_eq!(hits(&reqs, COMPLETE_PATH), 0); | |
| 3431 | + | assert_eq!(hits(&reqs, ABORT_PATH), 1, "a failed transfer must release its parts"); | |
| 3432 | + | ||
| 3433 | + | std::fs::remove_file(&file).ok(); | |
| 3434 | + | } | |
| 3435 | + | } |