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