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}