1use crate::{Checksum, ChecksumAlgorithm, ContentRef, NamespaceId, UploadId};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
24#[serde(deny_unknown_fields)]
25pub struct UploadContentClaim {
26 pub size_bytes: u64,
28 pub checksum: Checksum,
30}
31
32#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub part_size_bytes: Option<u64>,
51}
52
53#[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 #[default]
60 ServiceProxied,
61 DirectPut,
63 DirectMultipart,
67}
68
69#[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 #[cfg_attr(feature = "openapi", schema(title = "BeginUploadServiceProxied"))]
81 ServiceProxied {},
82 #[cfg_attr(feature = "openapi", schema(title = "BeginUploadDirectPut"))]
86 DirectPut {
87 content: UploadContentClaim,
89 },
90 #[cfg_attr(feature = "openapi", schema(title = "BeginUploadDirectMultipart"))]
92 DirectMultipart {
93 #[serde(default, skip_serializing_if = "Option::is_none")]
97 multipart: Option<DirectMultipartUploadOptions>,
98 },
99}
100
101impl BeginUploadRequest {
102 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#[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 #[cfg_attr(
119 feature = "openapi",
120 schema(title = "ObjectTransferAccessPresignedUrl")
121 )]
122 PresignedUrl {
123 method: String,
125 url: String,
127 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
129 headers: BTreeMap<String, String>,
130 expires_at_ms: u64,
132 },
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
138pub struct DirectPutUpload {
139 pub content_ref: ContentRef,
143 pub access: ObjectTransferAccess,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
154pub struct DirectMultipartUpload {
155 pub part_size_bytes: u64,
158 pub checksum_algorithm: ChecksumAlgorithm,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
167#[serde(deny_unknown_fields)]
168pub struct UploadPartChecksumClaim {
169 pub part_number: u32,
171 pub checksum: Checksum,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
178#[serde(deny_unknown_fields)]
179pub struct SignUploadPartsRequest {
180 pub parts: Vec<UploadPartChecksumClaim>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
189pub struct SignedUploadPart {
190 pub part_number: u32,
192 pub access: ObjectTransferAccess,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
199pub struct SignUploadPartsResponse {
200 pub namespace_id: NamespaceId,
202 pub upload_id: UploadId,
204 pub parts: Vec<SignedUploadPart>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
215#[serde(deny_unknown_fields)]
216pub struct CompletedUploadPart {
217 pub part_number: u32,
219 pub etag: String,
221 pub checksum: Checksum,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
228#[serde(deny_unknown_fields)]
229pub struct ContentToken {
230 pub content_ref: ContentRef,
232 pub token: String,
234}
235
236#[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 #[cfg_attr(
246 feature = "openapi",
247 schema(title = "BeginUploadResponseServiceProxied")
248 )]
249 ServiceProxied {
250 namespace_id: NamespaceId,
252 upload_id: UploadId,
255 },
256 #[cfg_attr(feature = "openapi", schema(title = "BeginUploadResponseDirectPut"))]
258 DirectPut {
259 namespace_id: NamespaceId,
261 upload_id: UploadId,
263 direct_put: DirectPutUpload,
265 },
266 #[cfg_attr(
268 feature = "openapi",
269 schema(title = "BeginUploadResponseDirectMultipart")
270 )]
271 DirectMultipart {
272 namespace_id: NamespaceId,
274 upload_id: UploadId,
277 direct_multipart: DirectMultipartUpload,
279 },
280}
281
282impl BeginUploadResponse {
283 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
314pub struct UploadContentResponse {
315 pub namespace_id: NamespaceId,
317 pub upload_id: UploadId,
319 pub content_ref: ContentRef,
321}
322
323#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
333#[serde(deny_unknown_fields)]
334pub struct CompleteMultipartUploadRequest {
335 pub content: UploadContentClaim,
337 pub parts: Vec<CompletedUploadPart>,
339}
340
341#[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 #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusOpen"))]
353 Open {
354 expires_at_ms: u64,
357 },
358 #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusCompleted"))]
360 Completed {
361 completed_at_ms: u64,
363 content_ref: ContentRef,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
368 content_token: Option<ContentToken>,
369 },
370 #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusAborted"))]
372 Aborted {
373 aborted_at_ms: u64,
375 },
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
381pub struct UploadSessionResponse {
382 pub namespace_id: NamespaceId,
384 pub upload_id: UploadId,
386 pub mode: UploadMode,
388 #[serde(flatten)]
391 pub status: UploadSessionStatus,
392}
393
394impl UploadSessionResponse {
395 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}