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}