Skip to main content

objectstore_types/
resumable.rs

1//! Types shared by resumable upload clients and servers.
2//!
3//! A resumable upload writes one object across multiple requests. The client first creates a
4//! session, declaring the object's complete size with [`HEADER_UPLOAD_LENGTH`]. The server returns
5//! a [`CreateSessionResponse`] containing an opaque [`SessionToken`] that identifies the upload.
6//!
7//! The client then sends chunks with [`HEADER_UPLOAD_OFFSET`] set to the byte position at which
8//! each chunk starts. If an upload is interrupted, the client can send the wildcard offset
9//! [`UploadOffset::Unknown`] to query the server's authoritative position before resuming. The
10//! request that completes the upload returns a [`CompleteUploadResponse`].
11//!
12//! Session tokens contain the canonical object path and backend state protected by the storage
13//! service, and clients must treat their contents as opaque. The token bytes are encoded as
14//! unpadded base64url when the token is placed in a request's `session` query parameter.
15
16use std::str::FromStr;
17use std::{borrow::Cow, fmt};
18
19use base64::Engine as _;
20use base64::engine::general_purpose::URL_SAFE_NO_PAD;
21use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
22
23/// Request header declaring the total size of the object, in bytes.
24///
25/// Required when creating a session.
26pub const HEADER_UPLOAD_LENGTH: &str = "upload-length";
27
28/// Header carrying the byte offset of a chunk, or the offset the server holds.
29///
30/// On a request this is the offset of the chunk's first byte, or `*` to query the
31/// server's authoritative offset. On a response it is the offset the server has
32/// persisted. See [`UploadOffset`].
33pub const HEADER_UPLOAD_OFFSET: &str = "upload-offset";
34
35/// The wildcard [`HEADER_UPLOAD_OFFSET`] value that queries the server's offset.
36const OFFSET_WILDCARD: &str = "*";
37
38/// Identifier for an in-progress resumable upload session.
39///
40/// Internally, this is an opaque byte string interpreted by the storage service. At the HTTP API
41/// boundary it serializes as canonical unpadded base64url, so the serialized value can be placed
42/// directly in a subsequent request URL.
43#[derive(Clone, PartialEq, Eq)]
44pub struct SessionToken(Vec<u8>);
45
46impl SessionToken {
47    /// Wraps opaque session-token bytes.
48    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
49        Self(bytes.into())
50    }
51
52    /// Returns the opaque token bytes.
53    pub fn as_bytes(&self) -> &[u8] {
54        &self.0
55    }
56
57    /// Consumes the token and returns its opaque bytes.
58    pub fn into_bytes(self) -> Vec<u8> {
59        self.0
60    }
61
62    /// Parses the canonical unpadded-base64url representation used by the HTTP API.
63    pub fn from_base64url(encoded: &str) -> Result<Self, InvalidSessionToken> {
64        let bytes = URL_SAFE_NO_PAD
65            .decode(encoded)
66            .map_err(|_| InvalidSessionToken)?;
67        if URL_SAFE_NO_PAD.encode(&bytes) != encoded {
68            return Err(InvalidSessionToken);
69        }
70        Ok(Self(bytes))
71    }
72
73    /// Encodes this token for the HTTP API.
74    pub fn to_base64url(&self) -> String {
75        URL_SAFE_NO_PAD.encode(&self.0)
76    }
77}
78
79impl fmt::Debug for SessionToken {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str("SessionToken")
82    }
83}
84
85impl Serialize for SessionToken {
86    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
87    where
88        S: Serializer,
89    {
90        serializer.serialize_str(&self.to_base64url())
91    }
92}
93
94impl<'de> Deserialize<'de> for SessionToken {
95    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
96    where
97        D: Deserializer<'de>,
98    {
99        let encoded = Cow::<'static, String>::deserialize(deserializer)?;
100        Self::from_base64url(&encoded).map_err(de::Error::custom)
101    }
102}
103
104/// Error returned for a non-canonical or malformed external session token.
105#[derive(Debug, thiserror::Error)]
106#[error("session token must use unpadded base64url encoding")]
107pub struct InvalidSessionToken;
108
109/// The value of the [`HEADER_UPLOAD_OFFSET`] request header.
110///
111/// In a request, a concrete offset submits a chunk starting at that byte,
112/// while [`UploadOffset::Unknown`] asks the server which offset it holds.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum UploadOffset {
115    /// Denotes a chunk whose first byte sits at this offset.
116    At(u64),
117    /// Used to query the server for its authoritative offset.
118    Unknown,
119}
120
121/// Error returned when an [`UploadOffset`] header value cannot be parsed.
122#[derive(Debug, thiserror::Error)]
123#[error("invalid {HEADER_UPLOAD_OFFSET} value: {0}")]
124pub struct InvalidUploadOffset(String);
125
126impl FromStr for UploadOffset {
127    type Err = InvalidUploadOffset;
128
129    fn from_str(s: &str) -> Result<Self, Self::Err> {
130        if s == OFFSET_WILDCARD {
131            return Ok(Self::Unknown);
132        }
133
134        // Rejects the `+` sign and leading whitespace that `u64::from_str` would
135        // otherwise be lenient about, keeping the header canonical.
136        if !s.bytes().all(|b| b.is_ascii_digit()) {
137            return Err(InvalidUploadOffset(s.to_owned()));
138        }
139
140        let offset = s.parse().map_err(|_| InvalidUploadOffset(s.to_owned()))?;
141        Ok(Self::At(offset))
142    }
143}
144
145impl fmt::Display for UploadOffset {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        match self {
148            Self::At(offset) => offset.fmt(f),
149            Self::Unknown => f.write_str(OFFSET_WILDCARD),
150        }
151    }
152}
153
154/// How far a resumable upload has progressed.
155///
156/// Both a chunk write and an offset query can observe that an upload is complete, so both
157/// operations have the same two outcomes. Completion is relative to the backend handling the
158/// operation: it means the session is terminal and the object is available through that backend's
159/// normal read methods. A backend that composes another backend must finish its own publication
160/// work before returning [`UploadProgress::Complete`].
161#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162pub enum UploadProgress {
163    /// More bytes are expected. The client continues from `offset`.
164    ///
165    /// This offset is authoritative and may be lower than the end of the chunk that was just
166    /// written: backends can persist only a prefix and discard the remainder. It must remain below
167    /// the session's total length; once every byte has landed, the backend completes the upload or
168    /// returns an error instead.
169    Incomplete {
170        /// The offset the backend has persisted.
171        offset: u64,
172    },
173    /// The session is terminal and the object is available through the backend's normal reads.
174    ///
175    /// This is an observable status rather than a one-time event. A later offset query can return
176    /// `Complete` again, for example when the response to the final chunk was lost.
177    Complete,
178}
179
180/// Response from creating a resumable upload session.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct CreateSessionResponse {
183    /// The object key (server-generated or client-provided).
184    pub key: String,
185    /// The opaque session token that identifies the session.
186    pub session: SessionToken,
187}
188
189/// Response from the request that completes the upload.
190///
191/// This is either the chunk carrying the last byte, or an offset query against a
192/// session whose final chunk completed but whose response was not observed.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CompleteUploadResponse {
195    /// The object key.
196    pub key: String,
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn create_session_response_encodes_token_once() -> Result<(), serde_json::Error> {
205        let response = CreateSessionResponse {
206            key: "key".into(),
207            session: SessionToken::new(b"../opaque +? \xc3\xbc"),
208        };
209
210        assert_eq!(
211            serde_json::to_string(&response)?,
212            r#"{"key":"key","session":"Li4vb3BhcXVlICs_IMO8"}"#
213        );
214        Ok(())
215    }
216
217    #[test]
218    fn session_token_round_trips_arbitrary_bytes() -> Result<(), serde_json::Error> {
219        let token = SessionToken::new([0, 1, 2, 0xfe, 0xff]);
220        let json = serde_json::to_string(&token)?;
221        assert_eq!(json, r#""AAEC_v8""#);
222        assert_eq!(serde_json::from_str::<SessionToken>(&json)?, token);
223        Ok(())
224    }
225
226    #[test]
227    fn session_token_rejects_noncanonical_encodings() {
228        for invalid in ["%%%", "dG9rM24="] {
229            assert!(
230                SessionToken::from_base64url(invalid).is_err(),
231                "accepted {invalid:?}"
232            );
233        }
234    }
235
236    #[test]
237    fn upload_offset_parses_wildcard_and_offsets() -> Result<(), InvalidUploadOffset> {
238        assert_eq!("*".parse::<UploadOffset>()?, UploadOffset::Unknown);
239        assert_eq!("0".parse::<UploadOffset>()?, UploadOffset::At(0));
240        assert_eq!("262144".parse::<UploadOffset>()?, UploadOffset::At(262144));
241        Ok(())
242    }
243
244    #[test]
245    fn upload_offset_rejects_malformed_values() {
246        for invalid in ["", "-1", "+1", " 1", "1 ", "1.5", "0x10", "**", "abc"] {
247            assert!(
248                invalid.parse::<UploadOffset>().is_err(),
249                "expected {invalid:?} to be rejected"
250            );
251        }
252    }
253
254    #[test]
255    fn upload_offset_round_trips_through_display() -> Result<(), InvalidUploadOffset> {
256        for offset in [
257            UploadOffset::Unknown,
258            UploadOffset::At(0),
259            UploadOffset::At(7),
260        ] {
261            assert_eq!(offset.to_string().parse::<UploadOffset>()?, offset);
262        }
263        Ok(())
264    }
265}