Skip to main content

loonfs_api/v0/
uploads.rs

1//! Upload requests and responses for the v0 HTTP API.
2
3use crate::{Checksum, ChecksumAlgorithm, ContentRef, NamespaceId, UploadId};
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7/// Size and checksum reported by the client for a complete payload.
8///
9/// Direct uploads provide this at completion. The server verifies it against
10/// the object stored by the provider.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(deny_unknown_fields)]
14pub struct UploadContentClaim {
15    /// Complete payload size in bytes.
16    pub size_bytes: u64,
17    /// Whole-payload checksum in the algorithm required by this operation.
18    pub checksum: Checksum,
19}
20
21/// Upload transport mode.
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
24#[serde(rename_all = "snake_case")]
25pub enum UploadMode {
26    /// The service receives bytes and writes content to object storage.
27    #[default]
28    ServiceProxied,
29    /// The service mints a short-lived presigned PUT URL for the content object.
30    DirectPut,
31    /// The client uploads parts directly to object storage.
32    DirectMultipart,
33}
34
35impl UploadMode {
36    /// Returns the serialized value.
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::ServiceProxied => "service_proxied",
40            Self::DirectPut => "direct_put",
41            Self::DirectMultipart => "direct_multipart",
42        }
43    }
44}
45
46/// Request to start an upload session, tagged by transport mode.
47///
48/// Each variant contains only fields valid for that transport, so invalid
49/// combinations are rejected during decoding. The `mode` field is required.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
52#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
53pub enum BeginUploadRequest {
54    // Empty braces make serde reject fields from another transport. A unit
55    // variant would silently ignore them.
56    /// Send the bytes to the service, which writes the content object.
57    #[cfg_attr(feature = "openapi", schema(title = "BeginUploadServiceProxied"))]
58    ServiceProxied {},
59    /// Write the whole object through one presigned request.
60    #[cfg_attr(feature = "openapi", schema(title = "BeginUploadDirectPut"))]
61    DirectPut {
62        /// Advisory byte length for an early provider-limit check.
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        #[cfg_attr(feature = "openapi", schema(nullable = false))]
65        size_bytes: Option<u64>,
66    },
67    /// Write the object in parts through presigned part uploads.
68    #[cfg_attr(feature = "openapi", schema(title = "BeginUploadDirectMultipart"))]
69    DirectMultipart {
70        /// Byte length of every part except the last. The server uses its
71        /// default when this is omitted.
72        #[serde(default, skip_serializing_if = "Option::is_none")]
73        #[cfg_attr(feature = "openapi", schema(nullable = false))]
74        part_size_bytes: Option<u64>,
75    },
76}
77
78impl BeginUploadRequest {
79    /// The transport this request asks for.
80    pub fn mode(&self) -> UploadMode {
81        match self {
82            Self::ServiceProxied {} => UploadMode::ServiceProxied,
83            Self::DirectPut { .. } => UploadMode::DirectPut,
84            Self::DirectMultipart { .. } => UploadMode::DirectMultipart,
85        }
86    }
87}
88
89/// Client-facing direct transfer capability.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92#[serde(tag = "kind", rename_all = "snake_case")]
93pub enum ObjectTransferAccess {
94    /// Short-lived URL plus required headers for one object-store write.
95    #[cfg_attr(
96        feature = "openapi",
97        schema(title = "ObjectTransferAccessPresignedUrl")
98    )]
99    PresignedUrl {
100        /// HTTP method the client must use.
101        method: String,
102        /// Full presigned URL.
103        url: String,
104        /// Headers that are covered by the signature and must be sent.
105        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
106        headers: BTreeMap<String, String>,
107        /// Expiration timestamp in Unix milliseconds.
108        expires_at_ms: u64,
109    },
110}
111
112/// One part's checksum, supplied by the client so the server can sign it
113/// into that part's upload URL.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
116#[serde(deny_unknown_fields)]
117pub struct UploadPartChecksumClaim {
118    /// One-based part number, at most the provider's 10,000-part limit.
119    pub part_number: u32,
120    /// Checksum over this part's bytes.
121    pub checksum: Checksum,
122}
123
124/// Request for part-upload capabilities on an open multipart session.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
127#[serde(deny_unknown_fields)]
128pub struct SignUploadPartsRequest {
129    /// Parts to authorize and the checksum for each part. Requesting a part
130    /// again replaces the previous upload for that part number.
131    pub parts: Vec<UploadPartChecksumClaim>,
132}
133
134/// One authorized part upload.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
137pub struct SignedUploadPart {
138    /// Part number this capability writes.
139    pub part_number: u32,
140    /// Short-lived write capability for that part.
141    pub access: ObjectTransferAccess,
142}
143
144/// Response carrying one capability per requested part.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
147pub struct SignUploadPartsResponse {
148    /// Namespace that owns the upload session.
149    pub namespace_id: NamespaceId,
150    /// Session the parts belong to.
151    pub upload_id: UploadId,
152    /// Capabilities in the order the request asked for them.
153    pub parts: Vec<SignedUploadPart>,
154}
155
156/// One uploaded part, as the client observed the provider accept it.
157///
158/// The server keeps no durable record of any part. Part bookkeeping is the
159/// client's, exactly as it is in the provider's own multipart API, and this
160/// is where the client hands it back.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
163#[serde(deny_unknown_fields)]
164pub struct CompletedUploadPart {
165    /// One-based part number.
166    pub part_number: u32,
167    /// Entity tag the provider returned for the accepted part.
168    pub etag: String,
169    /// Checksum the part was signed and accepted with.
170    pub checksum: Checksum,
171}
172
173/// Proof that a specific `content_ref` may be used in a later commit.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
176#[serde(deny_unknown_fields)]
177pub struct ContentToken {
178    /// Content authorized by this token.
179    pub content_ref: ContentRef,
180    /// Opaque, server-signed token. Clients must not parse it.
181    pub token: String,
182}
183
184/// Response to starting an upload session, tagged by transport mode.
185///
186/// Each variant contains only the fields needed by that transport. Unknown
187/// response fields are accepted for forward compatibility.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
190#[serde(tag = "mode", rename_all = "snake_case")]
191pub enum BeginUploadResponse {
192    /// The service will receive the bytes and write the content object.
193    #[cfg_attr(
194        feature = "openapi",
195        schema(title = "BeginUploadResponseServiceProxied")
196    )]
197    ServiceProxied {
198        /// Namespace authorized to consume the eventual staged content.
199        namespace_id: NamespaceId,
200        /// Durable session identity used by subsequent append and completion
201        /// calls.
202        upload_id: UploadId,
203    },
204    /// One presigned request writes the whole object.
205    #[cfg_attr(feature = "openapi", schema(title = "BeginUploadResponseDirectPut"))]
206    DirectPut {
207        /// Namespace authorized to consume the eventual staged content.
208        namespace_id: NamespaceId,
209        /// Durable session identity used by subsequent completion calls.
210        upload_id: UploadId,
211        /// Checksum algorithm the client must use for its completion claim.
212        checksum_algorithm: ChecksumAlgorithm,
213        /// Short-lived permission to write the object.
214        access: ObjectTransferAccess,
215    },
216    /// Presigned part uploads assemble the object.
217    #[cfg_attr(
218        feature = "openapi",
219        schema(title = "BeginUploadResponseDirectMultipart")
220    )]
221    DirectMultipart {
222        /// Namespace authorized to consume the eventual staged content.
223        namespace_id: NamespaceId,
224        /// Durable session identity used by subsequent part-signing and
225        /// completion calls.
226        upload_id: UploadId,
227        /// Byte length of every part except the last. At most 10,000 parts
228        /// may be uploaded, so this bounds the object at 10,000 times the
229        /// part size.
230        part_size_bytes: u64,
231        /// Checksum algorithm for every part and for the complete payload.
232        checksum_algorithm: ChecksumAlgorithm,
233    },
234}
235
236impl BeginUploadResponse {
237    /// Namespace that owns the session.
238    pub fn namespace_id(&self) -> &NamespaceId {
239        match self {
240            Self::ServiceProxied { namespace_id, .. }
241            | Self::DirectPut { namespace_id, .. }
242            | Self::DirectMultipart { namespace_id, .. } => namespace_id,
243        }
244    }
245
246    /// Session the later append, part, completion, and abort calls name.
247    pub fn upload_id(&self) -> &UploadId {
248        match self {
249            Self::ServiceProxied { upload_id, .. }
250            | Self::DirectPut { upload_id, .. }
251            | Self::DirectMultipart { upload_id, .. } => upload_id,
252        }
253    }
254
255    /// The transport this session was opened with.
256    pub fn mode(&self) -> UploadMode {
257        match self {
258            Self::ServiceProxied { .. } => UploadMode::ServiceProxied,
259            Self::DirectPut { .. } => UploadMode::DirectPut,
260            Self::DirectMultipart { .. } => UploadMode::DirectMultipart,
261        }
262    }
263}
264
265/// Response after uploading bytes into a session.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
268pub struct UploadContentResponse {
269    /// Namespace that owns the upload session.
270    pub namespace_id: NamespaceId,
271    /// Session into which the service staged these bytes.
272    pub upload_id: UploadId,
273    /// Digest and byte length computed from the accepted body.
274    pub content_ref: ContentRef,
275}
276
277/// Request to complete an upload session.
278///
279/// `mode` must match the mode used to start the session. Direct uploads
280/// include the expected content details. Multipart also includes its parts.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
283#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
284pub enum CompleteUploadRequest {
285    /// Complete a service-proxied upload.
286    #[cfg_attr(feature = "openapi", schema(title = "CompleteUploadServiceProxied"))]
287    ServiceProxied {},
288    /// Complete a direct-PUT upload.
289    #[cfg_attr(feature = "openapi", schema(title = "CompleteUploadDirectPut"))]
290    DirectPut {
291        /// Expected length and checksum of the stored object.
292        content: UploadContentClaim,
293    },
294    /// Complete a direct multipart upload.
295    #[cfg_attr(feature = "openapi", schema(title = "CompleteUploadDirectMultipart"))]
296    DirectMultipart {
297        /// Expected length and checksum of the assembled object.
298        content: UploadContentClaim,
299        /// Uploaded parts in ascending part order.
300        parts: Vec<CompletedUploadPart>,
301    },
302}
303
304impl CompleteUploadRequest {
305    /// Returns the upload mode in this request.
306    pub const fn mode(&self) -> UploadMode {
307        match self {
308            Self::ServiceProxied {} => UploadMode::ServiceProxied,
309            Self::DirectPut { .. } => UploadMode::DirectPut,
310            Self::DirectMultipart { .. } => UploadMode::DirectMultipart,
311        }
312    }
313}
314
315/// Information required to complete a `direct_multipart` upload.
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
318#[serde(deny_unknown_fields)]
319pub struct CompleteMultipartUploadRequest {
320    /// Expected length and checksum of the assembled object.
321    pub content: UploadContentClaim,
322    /// Uploaded parts in ascending part order.
323    pub parts: Vec<CompletedUploadPart>,
324}
325
326/// Observed state of an upload session.
327///
328/// A session starts as `Open` and ends as either `Completed` or `Aborted`.
329/// Both final states are permanent. Reading a completed session issues a new
330/// receipt for the durable content, so a lost commit response does not require
331/// the content to be uploaded again.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
334#[serde(tag = "status", rename_all = "snake_case")]
335pub enum UploadSessionStatus {
336    /// Accepting content until its lease passes.
337    #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusOpen"))]
338    Open {
339        /// Unix-millisecond instant after which the session is abandoned and
340        /// may be aborted by server-side cleanup.
341        expires_at_ms: u64,
342    },
343    /// Final: the content is durable and verified.
344    #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusCompleted"))]
345    Completed {
346        /// Unix-millisecond stamp of the completion.
347        completed_at_ms: u64,
348        /// Verified content selected by this session.
349        content_ref: ContentRef,
350        /// Fresh proof for a later commit. This is absent after the token
351        /// minting window closes, while `content_ref` remains available.
352        #[serde(default, skip_serializing_if = "Option::is_none")]
353        #[cfg_attr(feature = "openapi", schema(nullable = false))]
354        content_token: Option<ContentToken>,
355    },
356    /// Final: the session selected no content and its object is gone.
357    #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusAborted"))]
358    Aborted {
359        /// Unix-millisecond stamp of the abort.
360        aborted_at_ms: u64,
361    },
362}
363
364/// Current view of one upload session.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
367pub struct UploadSession {
368    /// Namespace that owns the session.
369    pub namespace_id: NamespaceId,
370    /// Session represented by this view.
371    pub upload_id: UploadId,
372    /// Transport selected when the session began.
373    pub mode: UploadMode,
374    /// The session's lifecycle and state-specific fields. Completed HTTP
375    /// responses carry a fresh receipt while the minting window remains open.
376    #[serde(flatten)]
377    pub status: UploadSessionStatus,
378}
379
380impl UploadSession {
381    /// Returns the completed content reference, or `None` before completion.
382    pub const fn content_ref(&self) -> Option<&ContentRef> {
383        match &self.status {
384            UploadSessionStatus::Completed { content_ref, .. } => Some(content_ref),
385            UploadSessionStatus::Open { .. } | UploadSessionStatus::Aborted { .. } => None,
386        }
387    }
388
389    /// Returns the completed session's current content token, when present.
390    pub const fn content_token(&self) -> Option<&ContentToken> {
391        match &self.status {
392            UploadSessionStatus::Completed { content_token, .. } => content_token.as_ref(),
393            UploadSessionStatus::Open { .. } | UploadSessionStatus::Aborted { .. } => None,
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::{
401        BeginUploadRequest, BeginUploadResponse, CompleteUploadRequest, ContentToken,
402        ObjectTransferAccess, UploadContentClaim, UploadMode, UploadSession, UploadSessionStatus,
403    };
404    use crate::{Checksum, ChecksumAlgorithm, ContentId, ContentRef, NamespaceId, UploadId};
405    use std::collections::BTreeMap;
406
407    #[test]
408    fn a_begin_request_without_a_mode_does_not_decode() {
409        assert!(serde_json::from_str::<BeginUploadRequest>("{}").is_err());
410        assert_eq!(
411            serde_json::from_str::<BeginUploadRequest>(r#"{"mode":"service_proxied"}"#)
412                .expect("decode proxied begin request"),
413            BeginUploadRequest::ServiceProxied {}
414        );
415    }
416
417    #[test]
418    fn a_begin_request_carrying_another_modes_fields_does_not_decode() {
419        for body in [
420            r#"{"mode":"service_proxied","part_size_bytes":8388608}"#,
421            r#"{"mode":"service_proxied","content":{"size_bytes":5,"checksum":{"algorithm":"sha256","value":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}}"#,
422            r#"{"mode":"direct_multipart","content":{"size_bytes":5,"checksum":{"algorithm":"sha256","value":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}}"#,
423            r#"{"mode":"direct_put","content":{"size_bytes":5,"checksum":{"algorithm":"sha256","value":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}}}"#,
424            r#"{"mode":"direct_put","part_size_bytes":8388608}"#,
425            r#"{"mode":"direct_multipart","size_bytes":5}"#,
426        ] {
427            assert!(
428                serde_json::from_str::<BeginUploadRequest>(body).is_err(),
429                "decoded a begin request that mixes modes: {body}"
430            );
431        }
432    }
433
434    #[test]
435    fn a_multipart_begin_names_its_part_size_beside_the_mode() {
436        assert_eq!(
437            serde_json::from_str::<BeginUploadRequest>(
438                r#"{"mode":"direct_multipart","part_size_bytes":8388608}"#
439            )
440            .expect("decode multipart begin request"),
441            BeginUploadRequest::DirectMultipart {
442                part_size_bytes: Some(8 * 1024 * 1024),
443            }
444        );
445        assert_eq!(
446            serde_json::from_str::<BeginUploadRequest>(r#"{"mode":"direct_multipart"}"#)
447                .expect("decode multipart begin without a part size"),
448            BeginUploadRequest::DirectMultipart {
449                part_size_bytes: None,
450            }
451        );
452        assert_eq!(
453            serde_json::to_value(BeginUploadRequest::DirectMultipart {
454                part_size_bytes: None,
455            })
456            .expect("serialize multipart begin request"),
457            serde_json::json!({ "mode": "direct_multipart" })
458        );
459    }
460
461    #[test]
462    fn completion_requests_are_tagged_and_mode_specific() {
463        assert_eq!(
464            serde_json::from_str::<CompleteUploadRequest>(r#"{"mode":"service_proxied"}"#)
465                .expect("decode proxied completion"),
466            CompleteUploadRequest::ServiceProxied {}
467        );
468        let direct_put = CompleteUploadRequest::DirectPut {
469            content: UploadContentClaim {
470                size_bytes: 5,
471                checksum: Checksum::crc32c(b"hello"),
472            },
473        };
474        assert_eq!(
475            serde_json::to_value(&direct_put).expect("encode direct-put completion"),
476            serde_json::json!({
477                "mode": "direct_put",
478                "content": {
479                    "size_bytes": 5,
480                    "checksum": Checksum::crc32c(b"hello"),
481                },
482            })
483        );
484        for body in [
485            r#"{}"#,
486            r#"{"mode":"service_proxied","content":{"size_bytes":5,"checksum":{"algorithm":"crc64nvme","value":"0123456789abcdef"}},"parts":[]}"#,
487            r#"{"mode":"direct_put"}"#,
488            r#"{"mode":"direct_multipart"}"#,
489        ] {
490            assert!(
491                serde_json::from_str::<CompleteUploadRequest>(body).is_err(),
492                "decoded an invalid completion request: {body}"
493            );
494        }
495
496        let missing_parts = r#"{"mode":"direct_multipart","content":{"size_bytes":5,"checksum":{"algorithm":"crc64nvme","value":"0123456789abcdef"}}}"#;
497        let error = serde_json::from_str::<CompleteUploadRequest>(missing_parts)
498            .expect_err("multipart parts are required");
499        assert!(
500            error.to_string().contains("parts"),
501            "the rejection should name the missing field: {error}"
502        );
503
504        let multipart = CompleteUploadRequest::DirectMultipart {
505            content: UploadContentClaim {
506                size_bytes: 5,
507                checksum: Checksum::crc64nvme(b"hello"),
508            },
509            parts: Vec::new(),
510        };
511        let encoded = serde_json::to_string(&multipart).expect("encode multipart completion");
512        assert_eq!(
513            serde_json::from_str::<serde_json::Value>(&encoded).expect("decode multipart JSON"),
514            serde_json::json!({
515                "mode": "direct_multipart",
516                "content": {
517                    "size_bytes": 5,
518                    "checksum": Checksum::crc64nvme(b"hello"),
519                },
520                "parts": [],
521            })
522        );
523    }
524
525    #[test]
526    fn a_begin_response_carries_only_its_transports_fields() {
527        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
528        let upload_id =
529            UploadId::parse("upl_00000000000000000000000000000001").expect("valid upload id");
530        assert_eq!(
531            serde_json::to_value(BeginUploadResponse::ServiceProxied {
532                namespace_id: namespace_id.clone(),
533                upload_id: upload_id.clone(),
534            })
535            .expect("serialize proxied response"),
536            serde_json::json!({
537                "mode": "service_proxied",
538                "namespace_id": "demo",
539                "upload_id": "upl_00000000000000000000000000000001"
540            })
541        );
542
543        assert_eq!(
544            serde_json::to_value(BeginUploadResponse::DirectPut {
545                namespace_id: namespace_id.clone(),
546                upload_id: upload_id.clone(),
547                checksum_algorithm: ChecksumAlgorithm::Crc64nvme,
548                access: ObjectTransferAccess::PresignedUrl {
549                    method: "PUT".to_owned(),
550                    url: "https://bucket.example/object".to_owned(),
551                    headers: BTreeMap::new(),
552                    expires_at_ms: 1,
553                },
554            })
555            .expect("serialize direct-put response"),
556            serde_json::json!({
557                "mode": "direct_put",
558                "namespace_id": "demo",
559                "upload_id": "upl_00000000000000000000000000000001",
560                "checksum_algorithm": "crc64nvme",
561                "access": {
562                    "kind": "presigned_url",
563                    "method": "PUT",
564                    "url": "https://bucket.example/object",
565                    "expires_at_ms": 1
566                }
567            })
568        );
569
570        assert_eq!(
571            serde_json::to_value(BeginUploadResponse::DirectMultipart {
572                namespace_id,
573                upload_id,
574                part_size_bytes: 8 * 1024 * 1024,
575                checksum_algorithm: ChecksumAlgorithm::Crc64nvme,
576            })
577            .expect("serialize multipart response"),
578            serde_json::json!({
579                "mode": "direct_multipart",
580                "namespace_id": "demo",
581                "upload_id": "upl_00000000000000000000000000000001",
582                "part_size_bytes": 8 * 1024 * 1024,
583                "checksum_algorithm": "crc64nvme"
584            })
585        );
586    }
587
588    #[test]
589    fn a_begin_response_carrying_a_later_servers_field_still_decodes() {
590        assert_eq!(
591            serde_json::from_str::<BeginUploadResponse>(
592                r#"{"mode":"service_proxied","namespace_id":"demo","upload_id":"upl_00000000000000000000000000000001","invented_later":true}"#
593            )
594            .expect("decode a proxied response carrying an unknown field"),
595            BeginUploadResponse::ServiceProxied {
596                namespace_id: NamespaceId::parse("demo").expect("namespace id"),
597                upload_id: UploadId::parse("upl_00000000000000000000000000000001")
598                    .expect("valid upload id"),
599            }
600        );
601    }
602
603    #[test]
604    fn an_upload_content_claim_names_only_size_and_checksum() {
605        let request: BeginUploadRequest =
606            serde_json::from_str(r#"{"mode":"direct_put","size_bytes":5}"#)
607                .expect("decode direct-put begin request");
608        assert_eq!(
609            request,
610            BeginUploadRequest::DirectPut {
611                size_bytes: Some(5),
612            }
613        );
614        assert_eq!(
615            serde_json::from_str::<BeginUploadRequest>(r#"{"mode":"direct_put"}"#)
616                .expect("decode direct-put begin without a size"),
617            BeginUploadRequest::DirectPut { size_bytes: None }
618        );
619
620        assert!(
621            serde_json::from_str::<UploadContentClaim>(
622                r#"{"size_bytes":5,"checksum":{"algorithm":"sha256","value":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"},"content_id":"con_0123456789abcdef0123456789abcdef"}"#
623            )
624            .is_err(),
625            "a client must not be able to name the content object"
626        );
627    }
628
629    #[test]
630    fn an_upload_session_is_flat_and_uses_one_status_vocabulary() {
631        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
632        let upload_id = UploadId::parse("upl_00000000000000000000000000000001").expect("upload id");
633        let open = serde_json::to_value(UploadSession {
634            namespace_id: namespace_id.clone(),
635            upload_id: upload_id.clone(),
636            mode: UploadMode::DirectMultipart,
637            status: UploadSessionStatus::Open {
638                expires_at_ms: 1_000,
639            },
640        })
641        .expect("serialize open status");
642        assert_eq!(
643            open,
644            serde_json::json!({
645                "namespace_id": "demo",
646                "upload_id": "upl_00000000000000000000000000000001",
647                "mode": "direct_multipart",
648                "status": "open",
649                "expires_at_ms": 1_000,
650            })
651        );
652
653        let aborted = serde_json::to_value(UploadSession {
654            namespace_id: namespace_id.clone(),
655            upload_id: upload_id.clone(),
656            mode: UploadMode::ServiceProxied,
657            status: UploadSessionStatus::Aborted {
658                aborted_at_ms: 2_000,
659            },
660        })
661        .expect("serialize aborted status");
662        assert_eq!(aborted["status"], "aborted");
663        assert_eq!(aborted["mode"], "service_proxied");
664        assert_eq!(aborted["aborted_at_ms"], 2_000);
665        assert!(aborted.get("state").is_none());
666
667        let completed = serde_json::to_value(UploadSession {
668            namespace_id,
669            upload_id,
670            mode: UploadMode::DirectPut,
671            status: UploadSessionStatus::Completed {
672                completed_at_ms: 3_000,
673                content_ref: ContentRef::blob_v1(ContentId::generate(), b"hello"),
674                content_token: None,
675            },
676        })
677        .expect("serialize completed status");
678        assert_eq!(completed["status"], "completed");
679        assert_eq!(completed["mode"], "direct_put");
680        assert!(completed.get("state").is_none());
681        assert!(completed.get("status").is_some());
682        assert!(
683            completed.get("content_token").is_none(),
684            "a session past its receipt window reports no token at all"
685        );
686    }
687
688    #[test]
689    fn completion_status_and_commit_share_the_exact_content_token_shape() {
690        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
691        let upload_id = UploadId::parse("upl_00000000000000000000000000000001").expect("upload id");
692        let content_ref = ContentRef::blob_v1(
693            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
694            b"hello",
695        );
696        let content_token = ContentToken {
697            content_ref: content_ref.clone(),
698            token: "opaque-server-token".to_owned(),
699        };
700        let completion = serde_json::to_value(UploadSession {
701            namespace_id: namespace_id.clone(),
702            upload_id,
703            mode: UploadMode::ServiceProxied,
704            status: UploadSessionStatus::Completed {
705                completed_at_ms: 3_000,
706                content_ref: content_ref.clone(),
707                content_token: Some(content_token.clone()),
708            },
709        })
710        .expect("serialize completion");
711        let status = serde_json::to_value(UploadSessionStatus::Completed {
712            completed_at_ms: 3_000,
713            content_ref,
714            content_token: Some(content_token),
715        })
716        .expect("serialize completed status");
717
718        let completion_token = completion["content_token"].clone();
719        let status_token = status["content_token"].clone();
720        assert_eq!(completion_token, status_token);
721        assert_eq!(
722            completion_token,
723            serde_json::json!({
724                "content_ref": {
725                    "kind": "blob_v1",
726                    "content_id": "con_0123456789abcdef0123456789abcdef",
727                    "size_bytes": 5,
728                    "checksum": {
729                        "algorithm": "sha256",
730                        "value": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
731                    }
732                },
733                "token": "opaque-server-token"
734            })
735        );
736
737        let request: crate::v0::CommitRequest = serde_json::from_value(serde_json::json!({
738            "commit_id": "same-token-shape",
739            "actor": crate::ActorRef::loonfs_system(),
740            "content_tokens": [completion_token],
741            "operations": [{
742                "kind": "create_directory",
743                "path": "/proof",
744                "parents": false
745            }]
746        }))
747        .expect("completion token decodes unchanged in a commit request");
748        assert_eq!(
749            serde_json::to_value(&request.content_tokens[0]).expect("serialize commit token"),
750            status_token
751        );
752    }
753
754    #[test]
755    fn a_content_token_rejects_unknown_fields() {
756        let token = serde_json::json!({
757            "content_ref": {
758                "kind": "blob_v1",
759                "content_id": "con_0123456789abcdef0123456789abcdef",
760                "size_bytes": 5,
761                "checksum": {
762                    "algorithm": "sha256",
763                    "value": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
764                }
765            },
766            "token": "opaque-server-token",
767            "expires_at_ms": 1
768        });
769        assert!(serde_json::from_value::<ContentToken>(token).is_err());
770    }
771}