Skip to main content

loonfs_api/v0/
uploads.rs

1//! Upload-session shapes for the v0 HTTP API: transport modes, session
2//! begin/append/complete requests and responses, and the direct-put
3//! presigned-access envelope. Content moves through these shapes; the
4//! metadata that later references it commits through [`super::commits`].
5
6use crate::{ContentRef, NamespaceId, UploadId};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10/// What a `direct_put` client promises about bytes it has not written yet.
11///
12/// The server mints the content object's identity — a client cannot name a
13/// key it has not been given — so a direct upload declares only what it can
14/// know about its own bytes. The server signs both into the provider write
15/// and verifies them again at completion.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18#[serde(deny_unknown_fields)]
19pub struct DirectPutContentClaim {
20    /// Complete byte length the client will write.
21    pub size_bytes: u64,
22    /// SHA-256 over the complete payload, lowercase hex.
23    pub sha256: String,
24}
25
26/// What a `direct_multipart` client says about the object it finished
27/// writing, supplied at completion rather than at begin.
28///
29/// The claim arrives last because that is the only place a one-pass
30/// uploader can produce it: a client that had to declare the length and
31/// digest up front would have to read its payload twice, and a client
32/// reading from a pipe could not start at all. Nothing is lost by waiting —
33/// the claim was never trusted, only verified, and verification happens at
34/// completion either way.
35///
36/// The digest is CRC-64/NVME rather than SHA-256 because that is the
37/// checksum an S3-compatible provider computes over a multipart object: it
38/// is the only full-object evidence the provider will ever be able to show
39/// back, so it is the only thing worth claiming at all.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
42#[serde(deny_unknown_fields)]
43pub struct DirectMultipartContentClaim {
44    /// Complete byte length the client wrote across every part.
45    pub size_bytes: u64,
46    /// CRC-64/NVME over the complete assembled payload, lowercase hex.
47    pub crc64nvme: String,
48}
49
50/// What a `direct_multipart` client asks for when it opens a session.
51///
52/// A begin request declares no length and no digest: the session exists to
53/// receive bytes whose length may not be known yet. All it settles is the
54/// geometry the client cuts to.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
57#[serde(deny_unknown_fields)]
58pub struct DirectMultipartUploadOptions {
59    /// Byte length of every part except the last, or `None` for the
60    /// server's default.
61    ///
62    /// The value bounds the object: a provider accepts at most 10,000
63    /// parts, so this session can carry at most `part_size_bytes × 10_000`
64    /// bytes. A client that knows its payload is very large asks for a
65    /// larger part size; one that does not know its length at all takes the
66    /// default and keeps asking for part URLs until its stream ends.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub part_size_bytes: Option<u64>,
69}
70
71/// Upload transport mode.
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
73#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
74#[serde(rename_all = "snake_case")]
75pub enum UploadMode {
76    /// The service receives bytes and writes content to object storage.
77    #[default]
78    ServiceProxied,
79    /// The service mints a short-lived presigned PUT URL for the content object.
80    DirectPut,
81    /// The service opens a provider multipart upload for the content object
82    /// and signs one PUT per part, so a large object crosses the network
83    /// once, in parallel, without passing through the server.
84    DirectMultipart,
85}
86
87/// Request for starting an upload session, tagged by the transport it asks
88/// for.
89///
90/// Each transport carries only what it needs, so the combinations a flat
91/// request could spell — a proxied begin carrying multipart geometry, a
92/// direct put with no claim to sign — are refused when the body is decoded
93/// rather than by a handler reading them back. `mode` is required: a
94/// request that does not say how it intends to move its bytes is not a
95/// request this API can answer.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
98#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
99pub enum BeginUploadRequest {
100    // The empty braces are load-bearing: serde lets a *unit* variant of a
101    // tagged enum swallow whatever else the body carried, so spelling this
102    // as `ServiceProxied` would quietly accept a proxied begin holding
103    // another transport's fields. A variant with no fields refuses them.
104    /// Send the bytes to the service, which writes the content object.
105    #[cfg_attr(feature = "openapi", schema(title = "BeginUploadServiceProxied"))]
106    ServiceProxied {},
107    /// Write the whole object through one presigned request. The server
108    /// signs exactly these bytes into the write it authorizes, so the claim
109    /// is required.
110    #[cfg_attr(feature = "openapi", schema(title = "BeginUploadDirectPut"))]
111    DirectPut {
112        /// Byte length and digest of the payload about to be written.
113        content: DirectPutContentClaim,
114    },
115    /// Write the object in parts through presigned part uploads.
116    #[cfg_attr(feature = "openapi", schema(title = "BeginUploadDirectMultipart"))]
117    DirectMultipart {
118        /// Selects the part geometry; absent takes the server's default.
119        /// A multipart upload claims its content at completion, so nothing
120        /// about the payload is declared here.
121        #[serde(default, skip_serializing_if = "Option::is_none")]
122        multipart: Option<DirectMultipartUploadOptions>,
123    },
124}
125
126impl BeginUploadRequest {
127    /// The transport this request asks for.
128    pub fn mode(&self) -> UploadMode {
129        match self {
130            Self::ServiceProxied {} => UploadMode::ServiceProxied,
131            Self::DirectPut { .. } => UploadMode::DirectPut,
132            Self::DirectMultipart { .. } => UploadMode::DirectMultipart,
133        }
134    }
135}
136
137/// Client-facing direct transfer capability.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
140#[serde(tag = "kind", rename_all = "snake_case")]
141pub enum ObjectTransferAccess {
142    /// Short-lived URL plus required headers for one object-store write.
143    #[cfg_attr(
144        feature = "openapi",
145        schema(title = "ObjectTransferAccessPresignedUrl")
146    )]
147    PresignedUrl {
148        /// HTTP method the client must use.
149        method: String,
150        /// Full presigned URL.
151        url: String,
152        /// Headers that are covered by the signature and must be sent.
153        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
154        headers: BTreeMap<String, String>,
155        /// Expiration timestamp in Unix milliseconds.
156        expires_at_ms: u64,
157    },
158}
159
160/// Presigned direct_put upload details. The raw object key is intentionally not public.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
163pub struct DirectPutUpload {
164    /// Immutable object identity the server minted, plus the byte length and
165    /// checksum covered by the signed request. Completion and the later
166    /// commit both name exactly this reference.
167    pub content_ref: ContentRef,
168    /// Short-lived write capability the client uses without learning the raw object key.
169    pub access: ObjectTransferAccess,
170}
171
172/// Direct multipart upload details: the geometry a client cuts its payload
173/// into parts with, and nothing else.
174///
175/// There is no content reference here, and no part count. The session has
176/// not been told what it is about to receive, so there is no identity to
177/// echo and no arithmetic to do — the server mints the content object
178/// behind the session and names it in the completion response. The
179/// provider's upload id is absent for the same reason it always was: a
180/// client asks this server for part URLs by part number and never talks to
181/// the provider's multipart API in its own words.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
184pub struct DirectMultipartUpload {
185    /// Byte length of every part except the last. At most 10,000 parts may
186    /// be uploaded, so this bounds the object at `part_size_bytes × 10_000`.
187    pub part_size_bytes: u64,
188}
189
190/// One part's checksum, supplied by the client so the server can sign it
191/// into that part's upload URL.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
194#[serde(deny_unknown_fields)]
195pub struct UploadPartChecksumClaim {
196    /// One-based part number, at most the provider's 10,000-part limit.
197    pub part_number: u32,
198    /// CRC-64/NVME over this part's bytes, lowercase hex.
199    pub crc64nvme: String,
200}
201
202/// Request for part-upload capabilities on an open multipart session.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
205#[serde(deny_unknown_fields)]
206pub struct SignUploadPartsRequest {
207    /// Parts to authorize, each with the checksum the provider will enforce
208    /// on it. Asking again for a part already uploaded is how a client
209    /// retries one: a repeated part is last-write-wins at the provider.
210    pub parts: Vec<UploadPartChecksumClaim>,
211}
212
213/// One authorized part upload.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
216pub struct SignedUploadPart {
217    /// Part number this capability writes.
218    pub part_number: u32,
219    /// Short-lived write capability for that part.
220    pub access: ObjectTransferAccess,
221}
222
223/// Response carrying one capability per requested part.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
226pub struct SignUploadPartsResponse {
227    /// Namespace that owns the upload session.
228    pub namespace_id: NamespaceId,
229    /// Session the parts belong to.
230    pub upload_id: UploadId,
231    /// Capabilities in the order the request asked for them.
232    pub parts: Vec<SignedUploadPart>,
233}
234
235/// One uploaded part, as the client observed the provider accept it.
236///
237/// The server keeps no durable record of any part. Part bookkeeping is the
238/// client's, exactly as it is in the provider's own multipart API, and this
239/// is where the client hands it back.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
242#[serde(deny_unknown_fields)]
243pub struct CompletedUploadPart {
244    /// One-based part number.
245    pub part_number: u32,
246    /// Entity tag the provider returned for the accepted part.
247    pub etag: String,
248    /// CRC-64/NVME the part was signed and accepted with, lowercase hex.
249    pub crc64nvme: String,
250}
251
252/// Stateless proof that a LoonFS server already validated a content ref.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
255pub struct ValidatedContentToken {
256    /// Content identity the server attests it already verified.
257    pub content_ref: ContentRef,
258    /// Opaque, server-signed token. Clients must not parse it.
259    pub token: String,
260}
261
262/// Response for starting an upload session.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
265pub struct BeginUploadResponse {
266    /// Namespace authorized to consume the eventual staged content.
267    pub namespace_id: NamespaceId,
268    /// Durable session identity used by subsequent append and completion calls.
269    pub upload_id: UploadId,
270    /// Transport selected after applying server capability and request validation.
271    pub mode: UploadMode,
272    /// Presigned write details for `DirectPut`, or `None` for `ServiceProxied`.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub direct_put: Option<DirectPutUpload>,
275    /// Part geometry for `DirectMultipart`, or `None` for every other mode.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub direct_multipart: Option<DirectMultipartUpload>,
278}
279
280/// Response after uploading bytes into a session.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
283pub struct UploadContentResponse {
284    /// Namespace that owns the upload session.
285    pub namespace_id: NamespaceId,
286    /// Session into which the service staged these bytes.
287    pub upload_id: UploadId,
288    /// Digest and byte length computed from the accepted body.
289    pub content_ref: ContentRef,
290}
291
292/// Request to complete an upload, tagged by which of its two shapes it is.
293///
294/// The shapes correspond to who knew the content identity first. A
295/// service-proxied or `direct_put` session was handed its reference before
296/// any byte moved, so its completion names that reference back. A
297/// `direct_multipart` session was never told one — there was nothing to
298/// tell — so its completion carries the claim and its parts instead, and
299/// the server builds the reference from the identity it has held all along.
300///
301/// The two share no fields, so a completion carrying one shape's fields
302/// under the other's tag does not decode. What decoding cannot settle is
303/// whether the shape matches the *session*, because only the server knows
304/// which transport the session was opened with; that one check is made
305/// against the durable record.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
308#[serde(tag = "completion", rename_all = "snake_case", deny_unknown_fields)]
309pub enum CompleteUploadRequest {
310    /// Completes a session the server named a content object for: the
311    /// caller names it back and the server proves the object matches.
312    #[cfg_attr(feature = "openapi", schema(title = "CompleteUploadContentRef"))]
313    ContentRef {
314        /// Content identity the caller expects the session to have settled
315        /// on.
316        content_ref: ContentRef,
317    },
318    /// Completes a `direct_multipart` session with what it uploaded.
319    #[cfg_attr(feature = "openapi", schema(title = "CompleteUploadMultipart"))]
320    Multipart {
321        /// The assembled object's length and CRC-64/NVME, which completion
322        /// verifies against the provider's own reading of the object.
323        multipart: DirectMultipartContentClaim,
324        /// Every part the client uploaded, in ascending part order. The
325        /// server holds no part records of its own, so this list is what it
326        /// assembles the object from.
327        parts: Vec<CompletedUploadPart>,
328    },
329}
330
331impl CompleteUploadRequest {
332    /// Completes a session that already knows its content reference.
333    pub fn for_content_ref(content_ref: ContentRef) -> Self {
334        Self::ContentRef { content_ref }
335    }
336
337    /// Completes a `direct_multipart` session with what it assembled.
338    pub fn for_multipart(
339        claim: DirectMultipartContentClaim,
340        parts: Vec<CompletedUploadPart>,
341    ) -> Self {
342        Self::Multipart {
343            multipart: claim,
344            parts,
345        }
346    }
347}
348
349/// Response after an upload session is completed.
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
352pub struct CompleteUploadResponse {
353    /// Namespace that owns the completed session.
354    pub namespace_id: NamespaceId,
355    /// Session whose result is now frozen for idempotent completion retries.
356    pub upload_id: UploadId,
357    /// Verified immutable content selected by the completed session.
358    pub content_ref: ContentRef,
359    /// Opaque server proof for a later commit, or `None` when the backend needs no token.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub validated_content_token: Option<String>,
362}
363
364/// Observed state of an upload session.
365///
366/// A session is `open`, then `completed` or `aborted`, and both of those are
367/// final. Reading a completed session mints a fresh receipt for content that
368/// is already durable, which is why losing a commit response never costs a
369/// retransfer.
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
372#[serde(tag = "state", rename_all = "snake_case")]
373pub enum UploadSessionStatus {
374    /// Accepting content until its lease passes.
375    #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusOpen"))]
376    Open {
377        /// Unix-millisecond instant after which the session is abandoned and
378        /// may be aborted by server-side cleanup.
379        expires_at_ms: u64,
380    },
381    /// Final: the content is durable and verified.
382    #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusCompleted"))]
383    Completed {
384        /// Unix-millisecond stamp of the completion.
385        completed_at_ms: u64,
386        /// Verified immutable content this session settled on.
387        content_ref: ContentRef,
388        /// Freshly minted proof for a following commit, or `None` once the
389        /// session has stopped minting them.
390        #[serde(default, skip_serializing_if = "Option::is_none")]
391        validated_content_token: Option<String>,
392    },
393    /// Final: the session selected no content and its object is gone.
394    #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusAborted"))]
395    Aborted {
396        /// Unix-millisecond stamp of the abort.
397        aborted_at_ms: u64,
398    },
399}
400
401/// Response for reading one upload session.
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
404pub struct UploadStatusResponse {
405    /// Namespace that owns the session.
406    pub namespace_id: NamespaceId,
407    /// Session that was read.
408    pub upload_id: UploadId,
409    /// The session's state, with a fresh receipt when it is completed.
410    pub status: UploadSessionStatus,
411}
412
413/// Response after aborting an upload session.
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
416pub struct AbortUploadResponse {
417    /// Namespace that owns the session.
418    pub namespace_id: NamespaceId,
419    /// Session that is now final.
420    pub upload_id: UploadId,
421    /// Unix-millisecond stamp of the abort that stands, which for a repeated
422    /// abort is the first one's.
423    pub aborted_at_ms: u64,
424}
425
426#[cfg(test)]
427mod tests {
428    use super::{
429        BeginUploadRequest, BeginUploadResponse, CompleteUploadRequest, DirectPutContentClaim,
430        DirectPutUpload, ObjectTransferAccess, UploadMode, UploadSessionStatus,
431    };
432    use crate::{ContentId, ContentRef, NamespaceId, UploadId};
433    use std::collections::BTreeMap;
434
435    #[test]
436    fn direct_put_upload_mode_serializes_as_expected() {
437        assert_eq!(
438            serde_json::to_string(&UploadMode::DirectPut).expect("serialize mode"),
439            r#""direct_put""#
440        );
441    }
442
443    /// A begin request says how it means to move its bytes, or it is not a
444    /// request. Nothing is inferred from what the body left out.
445    #[test]
446    fn a_begin_request_without_a_mode_does_not_decode() {
447        assert!(serde_json::from_str::<BeginUploadRequest>("{}").is_err());
448        assert_eq!(
449            serde_json::from_str::<BeginUploadRequest>(r#"{"mode":"service_proxied"}"#)
450                .expect("decode proxied begin request"),
451            BeginUploadRequest::ServiceProxied {}
452        );
453    }
454
455    /// The combinations a flat begin request could spell are refused where
456    /// the body is read, not by a handler comparing fields afterwards.
457    #[test]
458    fn a_begin_request_carrying_another_modes_fields_does_not_decode() {
459        for body in [
460            r#"{"mode":"service_proxied","multipart":{"part_size_bytes":8388608}}"#,
461            r#"{"mode":"service_proxied","content":{"size_bytes":5,"sha256":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}"#,
462            r#"{"mode":"direct_multipart","content":{"size_bytes":5,"sha256":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}"#,
463            // A direct put with nothing to sign is not a direct put.
464            r#"{"mode":"direct_put"}"#,
465        ] {
466            assert!(
467                serde_json::from_str::<BeginUploadRequest>(body).is_err(),
468                "decoded a begin request that mixes modes: {body}"
469            );
470        }
471    }
472
473    /// A completion carries one shape's fields under that shape's tag.
474    #[test]
475    fn a_completion_mixing_its_two_shapes_does_not_decode() {
476        for body in [
477            r#"{"completion":"multipart","multipart":{"size_bytes":5,"crc64nvme":"0123456789abcdef"},"parts":[],"content_ref":{"kind":"blob_v1","content_id":"con_0123456789abcdef0123456789abcdef","size_bytes":5,"storage_checksum":{"algorithm":"sha256","value":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}}"#,
478            // Neither multipart field stands without the other.
479            r#"{"completion":"multipart","multipart":{"size_bytes":5,"crc64nvme":"0123456789abcdef"}}"#,
480            r#"{"completion":"multipart","parts":[]}"#,
481            r#"{"completion":"content_ref"}"#,
482        ] {
483            assert!(
484                serde_json::from_str::<CompleteUploadRequest>(body).is_err(),
485                "decoded a completion that mixes shapes: {body}"
486            );
487        }
488    }
489
490    #[test]
491    fn direct_put_response_exposes_only_presigned_access() {
492        let response = BeginUploadResponse {
493            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
494            upload_id: UploadId::parse("upl_00000000000000000000000000000001")
495                .expect("valid upload id"),
496            mode: UploadMode::DirectPut,
497            direct_put: Some(DirectPutUpload {
498                content_ref: ContentRef::blob_v1(ContentId::generate(), b"hello"),
499                access: ObjectTransferAccess::PresignedUrl {
500                    method: "PUT".to_owned(),
501                    url: "https://bucket.example/object?X-Amz-Signature=abc".to_owned(),
502                    headers: BTreeMap::from([
503                        ("if-none-match".to_owned(), "*".to_owned()),
504                        (
505                            "x-provider-checksum".to_owned(),
506                            "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=".to_owned(),
507                        ),
508                    ]),
509                    expires_at_ms: 1,
510                },
511            }),
512            direct_multipart: None,
513        };
514
515        let json = serde_json::to_string(&response).expect("serialize response");
516        assert!(json.contains(r#""kind":"presigned_url""#));
517        assert!(!json.contains("object_key"));
518    }
519
520    /// A direct-put client declares what it is about to write; it cannot
521    /// declare *where*, because the server owns content identity.
522    #[test]
523    fn a_direct_put_claim_names_only_size_and_digest() {
524        let request: BeginUploadRequest = serde_json::from_str(
525            r#"{"mode":"direct_put","content":{"size_bytes":5,"sha256":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}"#,
526        )
527        .expect("decode direct-put begin request");
528        assert_eq!(
529            request,
530            BeginUploadRequest::DirectPut {
531                content: DirectPutContentClaim {
532                    size_bytes: 5,
533                    sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
534                        .to_owned(),
535                },
536            }
537        );
538
539        assert!(
540            serde_json::from_str::<DirectPutContentClaim>(
541                r#"{"size_bytes":5,"sha256":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824","content_id":"con_0123456789abcdef0123456789abcdef"}"#
542            )
543            .is_err(),
544            "a client must not be able to name the content object"
545        );
546    }
547
548    #[test]
549    fn upload_status_names_its_state_on_the_wire() {
550        let open = serde_json::to_value(UploadSessionStatus::Open {
551            expires_at_ms: 1_000,
552        })
553        .expect("serialize open status");
554        assert_eq!(open["state"], "open");
555
556        let aborted = serde_json::to_value(UploadSessionStatus::Aborted {
557            aborted_at_ms: 2_000,
558        })
559        .expect("serialize aborted status");
560        assert_eq!(aborted["state"], "aborted");
561
562        let completed = serde_json::to_value(UploadSessionStatus::Completed {
563            completed_at_ms: 3_000,
564            content_ref: ContentRef::blob_v1(ContentId::generate(), b"hello"),
565            validated_content_token: None,
566        })
567        .expect("serialize completed status");
568        assert_eq!(completed["state"], "completed");
569        assert!(
570            completed.get("validated_content_token").is_none(),
571            "a session past its receipt window reports no token at all"
572        );
573    }
574}