Skip to main content

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}