scirs2_io/cloud/gcs.rs
1//! GCS Resumable Upload state machine (simulation mode).
2//!
3//! Implements the Google Cloud Storage resumable upload protocol as a pure-Rust
4//! state machine. When the `google-cloud-storage` Cargo feature is enabled a
5//! real HTTP implementation (OAuth2 + service-account JSON credentials) would be
6//! wired in. Without the feature every operation executes in a local simulation
7//! mode suitable for testing and offline development.
8//!
9//! # Protocol outline (real GCS)
10//!
11//! 1. POST to `https://storage.googleapis.com/upload/storage/v1/b/{bucket}/o`
12//! with `uploadType=resumable` → receive a `Location` header containing the
13//! **resumable session URI**.
14//! 2. PUT each byte range to the session URI using a `Content-Range` header.
15//! 3. The final PUT (with `Content-Range: bytes N-M/TOTAL`) returns `200 OK`
16//! and the completed object JSON.
17//! 4. Query progress at any time with a zero-length PUT and a
18//! `Content-Range: bytes */{total_or_*}` header → `308 Resume Incomplete`
19//! with `Range: bytes=0-{last_received}`.
20//! 5. Abort by sending a DELETE to the session URI.
21//!
22//! # Simulation mode
23//!
24//! In simulation mode no network calls are made. An in-memory `Vec<u8>` stores
25//! the assembled object data. The `upload_id` plays the role of the resumable
26//! session URI (it is a UUID-v4-like string).
27//!
28//! # Example
29//!
30//! ```rust
31//! use scirs2_io::cloud::gcs::{GcsResumableUpload, UploadStatus};
32//!
33//! let mut upload = GcsResumableUpload::initiate("my-bucket", "data/file.bin", Some(6));
34//! assert!(!upload.upload_id().is_empty());
35//!
36//! let status = upload.upload_chunk(0, b"hello ").expect("chunk");
37//! assert!(matches!(status, UploadStatus::Complete { total_bytes: 6 }));
38//!
39//! let total = upload.finalize().expect("finalize");
40//! assert_eq!(total, 6);
41//! assert_eq!(upload.assembled_data(), b"hello ");
42//! ```
43
44use std::sync::atomic::{AtomicU64, Ordering};
45
46// ---------------------------------------------------------------------------
47// Error type
48// ---------------------------------------------------------------------------
49
50/// Errors returned by GCS resumable upload operations.
51#[derive(Debug, thiserror::Error)]
52pub enum GcsError {
53 /// The upload session has been aborted; no further operations are possible.
54 #[error("upload aborted")]
55 Aborted,
56 /// The upload has already been finalized; no further mutations are allowed.
57 #[error("already finalized")]
58 AlreadyFinalized,
59 /// The supplied byte offset does not match the server's received byte count.
60 ///
61 /// GCS requires that each PUT starts exactly where the previous one ended.
62 #[error("chunk out of range: offset {offset} but server has {server_bytes} bytes")]
63 ChunkOutOfRange {
64 /// The offset supplied by the caller.
65 offset: usize,
66 /// The number of bytes the server (simulation) has received.
67 server_bytes: usize,
68 },
69 /// The total uploaded size differs from the value declared at initiation.
70 #[error("size mismatch: declared {declared}, uploaded {uploaded}")]
71 SizeMismatch {
72 /// Size declared at `initiate`.
73 declared: usize,
74 /// Actual bytes uploaded before `finalize` was called.
75 uploaded: usize,
76 },
77}
78
79// ---------------------------------------------------------------------------
80// UploadStatus
81// ---------------------------------------------------------------------------
82
83/// Status returned after uploading a chunk.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum UploadStatus {
86 /// Upload is still in progress.
87 Incomplete {
88 /// Total bytes received so far (cumulative across all chunks).
89 bytes_received: usize,
90 },
91 /// Upload is fully streamed (all declared bytes received).
92 Complete {
93 /// Total bytes in the object.
94 total_bytes: usize,
95 },
96}
97
98// ---------------------------------------------------------------------------
99// GcsResumableUpload
100// ---------------------------------------------------------------------------
101
102/// Simulation-mode GCS resumable upload session.
103///
104/// Holds all in-memory state for a single upload. Chunks must be delivered in
105/// order (each chunk's `offset` must equal `query_status()`).
106pub struct GcsResumableUpload {
107 /// Simulated resumable session URI (UUID-like identifier).
108 upload_id: String,
109 /// Destination object name within the bucket.
110 object_name: String,
111 /// Destination bucket name.
112 bucket: String,
113 /// Uploaded chunks stored in the order they were received.
114 chunks: Vec<Vec<u8>>,
115 /// Optional total object size declared at initiation (in bytes).
116 total_size: Option<usize>,
117 /// Whether the upload has been finalized.
118 finalized: bool,
119 /// Whether the upload has been aborted.
120 aborted: bool,
121}
122
123impl GcsResumableUpload {
124 // -----------------------------------------------------------------------
125 // Construction
126 // -----------------------------------------------------------------------
127
128 /// Initiate a new resumable upload session.
129 ///
130 /// `total_size` is the number of bytes that will be uploaded in total, or
131 /// `None` if the size is not known in advance.
132 pub fn initiate(bucket: &str, object_name: &str, total_size: Option<usize>) -> Self {
133 Self {
134 upload_id: generate_session_id(),
135 object_name: object_name.to_owned(),
136 bucket: bucket.to_owned(),
137 chunks: Vec::new(),
138 total_size,
139 finalized: false,
140 aborted: false,
141 }
142 }
143
144 // -----------------------------------------------------------------------
145 // Mutating operations
146 // -----------------------------------------------------------------------
147
148 /// Upload a chunk starting at byte `offset`.
149 ///
150 /// `offset` **must** equal the number of bytes already received
151 /// (`self.query_status()`). GCS enforces this invariant so that chunks
152 /// are always contiguous — there are no holes in the object.
153 ///
154 /// Returns `UploadStatus::Complete` when all `total_size` bytes (if
155 /// declared) have been received, otherwise returns
156 /// `UploadStatus::Incomplete`.
157 ///
158 /// # Errors
159 ///
160 /// - [`GcsError::Aborted`] — session has been aborted.
161 /// - [`GcsError::AlreadyFinalized`] — session is already closed.
162 /// - [`GcsError::ChunkOutOfRange`] — `offset` does not match `query_status()`.
163 pub fn upload_chunk(&mut self, offset: usize, data: &[u8]) -> Result<UploadStatus, GcsError> {
164 self.guard_active()?;
165
166 let server_bytes = self.query_status();
167 if offset != server_bytes {
168 return Err(GcsError::ChunkOutOfRange {
169 offset,
170 server_bytes,
171 });
172 }
173
174 if !data.is_empty() {
175 self.chunks.push(data.to_vec());
176 }
177
178 let bytes_received = self.query_status();
179 match self.total_size {
180 Some(total) if bytes_received >= total => Ok(UploadStatus::Complete {
181 total_bytes: bytes_received,
182 }),
183 _ => Ok(UploadStatus::Incomplete { bytes_received }),
184 }
185 }
186
187 /// Finalize the upload and return the total number of bytes.
188 ///
189 /// If `total_size` was declared at initiation the method verifies that the
190 /// uploaded byte count matches; returns [`GcsError::SizeMismatch`] if not.
191 ///
192 /// # Errors
193 ///
194 /// - [`GcsError::Aborted`]
195 /// - [`GcsError::AlreadyFinalized`]
196 /// - [`GcsError::SizeMismatch`]
197 pub fn finalize(&mut self) -> Result<usize, GcsError> {
198 self.guard_active()?;
199
200 let uploaded = self.query_status();
201
202 if let Some(declared) = self.total_size {
203 if uploaded != declared {
204 return Err(GcsError::SizeMismatch { declared, uploaded });
205 }
206 }
207
208 self.finalized = true;
209 Ok(uploaded)
210 }
211
212 /// Abort the upload, discarding all buffered data.
213 ///
214 /// After a successful abort the session is permanently closed; any
215 /// subsequent call will return [`GcsError::Aborted`].
216 ///
217 /// # Errors
218 ///
219 /// Returns [`GcsError::AlreadyFinalized`] if the upload was already
220 /// successfully finalized.
221 pub fn abort(&mut self) -> Result<(), GcsError> {
222 if self.finalized {
223 return Err(GcsError::AlreadyFinalized);
224 }
225 self.chunks.clear();
226 self.aborted = true;
227 Ok(())
228 }
229
230 // -----------------------------------------------------------------------
231 // Query / read operations
232 // -----------------------------------------------------------------------
233
234 /// Return the total number of bytes received so far.
235 ///
236 /// This mirrors the GCS `308 Resume Incomplete` response, which includes a
237 /// `Range: bytes=0-{last_received}` header.
238 pub fn query_status(&self) -> usize {
239 self.chunks.iter().map(|c| c.len()).sum()
240 }
241
242 /// Assemble and return the full object data from all uploaded chunks.
243 ///
244 /// The result is the concatenation of all chunks in the order they were
245 /// uploaded. Returns an empty `Vec` if no chunks have been uploaded.
246 pub fn assembled_data(&self) -> Vec<u8> {
247 let total = self.query_status();
248 let mut out = Vec::with_capacity(total);
249 for chunk in &self.chunks {
250 out.extend_from_slice(chunk);
251 }
252 out
253 }
254
255 /// Return the simulated session URI / upload ID.
256 pub fn upload_id(&self) -> &str {
257 &self.upload_id
258 }
259
260 /// Return the destination bucket name.
261 pub fn bucket(&self) -> &str {
262 &self.bucket
263 }
264
265 /// Return the destination object name.
266 pub fn object_name(&self) -> &str {
267 &self.object_name
268 }
269
270 /// Return `true` if the upload has been successfully finalized.
271 pub fn is_finalized(&self) -> bool {
272 self.finalized
273 }
274
275 /// Return `true` if the upload has been aborted.
276 pub fn is_aborted(&self) -> bool {
277 self.aborted
278 }
279
280 // -----------------------------------------------------------------------
281 // Internal helpers
282 // -----------------------------------------------------------------------
283
284 /// Return an error if the session is in a terminal state.
285 fn guard_active(&self) -> Result<(), GcsError> {
286 if self.aborted {
287 return Err(GcsError::Aborted);
288 }
289 if self.finalized {
290 return Err(GcsError::AlreadyFinalized);
291 }
292 Ok(())
293 }
294}
295
296// ---------------------------------------------------------------------------
297// Helpers
298// ---------------------------------------------------------------------------
299
300/// Generate a unique session identifier (simulates a resumable session URI).
301///
302/// Uses a monotonic counter combined with the current nanosecond timestamp to
303/// produce a 48-hex-character string that is unique within a process.
304fn generate_session_id() -> String {
305 static COUNTER: AtomicU64 = AtomicU64::new(0);
306 let count = COUNTER.fetch_add(1, Ordering::Relaxed);
307 let ts = std::time::SystemTime::now()
308 .duration_since(std::time::UNIX_EPOCH)
309 .map(|d| d.as_nanos())
310 .unwrap_or(0);
311 format!("gcs-sim-{ts:032x}{count:016x}")
312}
313
314// ---------------------------------------------------------------------------
315// Tests
316// ---------------------------------------------------------------------------
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 /// Initiate → upload single chunk → finalize → assembled_data correct.
323 #[test]
324 fn test_single_chunk_roundtrip() {
325 let data = b"hello, gcs!";
326 let mut upload =
327 GcsResumableUpload::initiate("my-bucket", "obj/data.bin", Some(data.len()));
328
329 assert!(!upload.upload_id().is_empty());
330 assert_eq!(upload.bucket(), "my-bucket");
331 assert_eq!(upload.object_name(), "obj/data.bin");
332
333 let status = upload.upload_chunk(0, data).expect("upload_chunk");
334 assert_eq!(
335 status,
336 UploadStatus::Complete {
337 total_bytes: data.len()
338 }
339 );
340
341 let total = upload.finalize().expect("finalize");
342 assert_eq!(total, data.len());
343 assert_eq!(upload.assembled_data(), data);
344 assert!(upload.is_finalized());
345 }
346
347 /// Declaring a total_size and uploading a different size returns SizeMismatch.
348 #[test]
349 fn test_size_mismatch_on_finalize() {
350 let mut upload = GcsResumableUpload::initiate("b", "k", Some(100));
351 upload
352 .upload_chunk(0, b"only 9 bytes")
353 .expect("upload chunk");
354 let err = upload.finalize().expect_err("should fail");
355 assert!(matches!(err, GcsError::SizeMismatch { declared: 100, .. }));
356 }
357
358 /// After abort, further upload_chunk and finalize calls return errors.
359 #[test]
360 fn test_operations_after_abort_fail() {
361 let mut upload = GcsResumableUpload::initiate("b", "k", None);
362 upload.upload_chunk(0, b"some data").expect("first chunk");
363 upload.abort().expect("abort");
364
365 assert!(upload.is_aborted());
366
367 let err_chunk = upload.upload_chunk(9, b"more").expect_err("should fail");
368 assert!(matches!(err_chunk, GcsError::Aborted));
369
370 let err_finalize = upload.finalize().expect_err("should fail");
371 assert!(matches!(err_finalize, GcsError::Aborted));
372 }
373
374 /// Operations after finalize return AlreadyFinalized.
375 #[test]
376 fn test_operations_after_finalize_fail() {
377 let data = b"finalized";
378 let mut upload = GcsResumableUpload::initiate("b", "k", Some(data.len()));
379 upload.upload_chunk(0, data).expect("upload");
380 upload.finalize().expect("first finalize");
381
382 let err_upload = upload
383 .upload_chunk(data.len(), b"x")
384 .expect_err("should fail");
385 assert!(matches!(err_upload, GcsError::AlreadyFinalized));
386
387 let err_finalize = upload.finalize().expect_err("should fail");
388 assert!(matches!(err_finalize, GcsError::AlreadyFinalized));
389
390 // Abort after finalize also errors.
391 let err_abort = upload
392 .abort()
393 .expect_err("abort after finalize should fail");
394 assert!(matches!(err_abort, GcsError::AlreadyFinalized));
395 }
396
397 /// query_status returns cumulative bytes uploaded so far.
398 #[test]
399 fn test_query_status_tracks_bytes() {
400 let mut upload = GcsResumableUpload::initiate("b", "k", None);
401 assert_eq!(upload.query_status(), 0);
402
403 upload.upload_chunk(0, b"abc").expect("chunk 1");
404 assert_eq!(upload.query_status(), 3);
405
406 upload.upload_chunk(3, b"defgh").expect("chunk 2");
407 assert_eq!(upload.query_status(), 8);
408 }
409
410 /// Two sequential chunks are assembled in order.
411 #[test]
412 fn test_two_chunks_assemble_in_order() {
413 let mut upload = GcsResumableUpload::initiate("b", "k", None);
414
415 let part1 = b"FIRST_";
416 let part2 = b"SECOND";
417 upload.upload_chunk(0, part1).expect("chunk 1");
418 upload.upload_chunk(part1.len(), part2).expect("chunk 2");
419
420 upload.finalize().expect("finalize");
421
422 let assembled = upload.assembled_data();
423 let expected: Vec<u8> = [part1.as_ref(), part2.as_ref()].concat();
424 assert_eq!(assembled, expected);
425 }
426
427 /// Out-of-order chunk (wrong offset) returns ChunkOutOfRange.
428 #[test]
429 fn test_wrong_offset_returns_chunk_out_of_range() {
430 let mut upload = GcsResumableUpload::initiate("b", "k", None);
431 upload.upload_chunk(0, b"first").expect("chunk 1");
432
433 // Wrong offset — should be 5.
434 let err = upload.upload_chunk(99, b"second").expect_err("should fail");
435 assert!(matches!(
436 err,
437 GcsError::ChunkOutOfRange {
438 offset: 99,
439 server_bytes: 5
440 }
441 ));
442 }
443
444 /// Unknown total size: can finalize at any point with no mismatch check.
445 #[test]
446 fn test_unknown_total_size_finalize() {
447 let mut upload = GcsResumableUpload::initiate("b", "k", None);
448 upload.upload_chunk(0, b"anything").expect("chunk");
449 let total = upload.finalize().expect("finalize");
450 assert_eq!(total, 8);
451 }
452
453 /// Multiple chunks: UploadStatus::Incomplete until all bytes arrive.
454 #[test]
455 fn test_incomplete_status_until_last_chunk() {
456 let total_size = 10usize;
457 let mut upload = GcsResumableUpload::initiate("b", "k", Some(total_size));
458
459 let s1 = upload.upload_chunk(0, b"hello").expect("chunk 1");
460 assert_eq!(s1, UploadStatus::Incomplete { bytes_received: 5 });
461
462 let s2 = upload.upload_chunk(5, b"world").expect("chunk 2");
463 assert_eq!(s2, UploadStatus::Complete { total_bytes: 10 });
464 }
465}