1use crate::{Checksum, ChecksumAlgorithm, ContentRef, NamespaceId, UploadId};
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(deny_unknown_fields)]
14pub struct UploadContentClaim {
15 pub size_bytes: u64,
17 pub checksum: Checksum,
19}
20
21#[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 #[default]
28 ServiceProxied,
29 DirectPut,
31 DirectMultipart,
33}
34
35impl UploadMode {
36 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#[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 #[cfg_attr(feature = "openapi", schema(title = "BeginUploadServiceProxied"))]
58 ServiceProxied {},
59 #[cfg_attr(feature = "openapi", schema(title = "BeginUploadDirectPut"))]
61 DirectPut {
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 #[cfg_attr(feature = "openapi", schema(nullable = false))]
65 size_bytes: Option<u64>,
66 },
67 #[cfg_attr(feature = "openapi", schema(title = "BeginUploadDirectMultipart"))]
69 DirectMultipart {
70 #[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 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#[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 #[cfg_attr(
96 feature = "openapi",
97 schema(title = "ObjectTransferAccessPresignedUrl")
98 )]
99 PresignedUrl {
100 method: String,
102 url: String,
104 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
106 headers: BTreeMap<String, String>,
107 expires_at_ms: u64,
109 },
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
116#[serde(deny_unknown_fields)]
117pub struct UploadPartChecksumClaim {
118 pub part_number: u32,
120 pub checksum: Checksum,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
127#[serde(deny_unknown_fields)]
128pub struct SignUploadPartsRequest {
129 pub parts: Vec<UploadPartChecksumClaim>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
137pub struct SignedUploadPart {
138 pub part_number: u32,
140 pub access: ObjectTransferAccess,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
147pub struct SignUploadPartsResponse {
148 pub namespace_id: NamespaceId,
150 pub upload_id: UploadId,
152 pub parts: Vec<SignedUploadPart>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
163#[serde(deny_unknown_fields)]
164pub struct CompletedUploadPart {
165 pub part_number: u32,
167 pub etag: String,
169 pub checksum: Checksum,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
176#[serde(deny_unknown_fields)]
177pub struct ContentToken {
178 pub content_ref: ContentRef,
180 pub token: String,
182}
183
184#[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 #[cfg_attr(
194 feature = "openapi",
195 schema(title = "BeginUploadResponseServiceProxied")
196 )]
197 ServiceProxied {
198 namespace_id: NamespaceId,
200 upload_id: UploadId,
203 },
204 #[cfg_attr(feature = "openapi", schema(title = "BeginUploadResponseDirectPut"))]
206 DirectPut {
207 namespace_id: NamespaceId,
209 upload_id: UploadId,
211 checksum_algorithm: ChecksumAlgorithm,
213 access: ObjectTransferAccess,
215 },
216 #[cfg_attr(
218 feature = "openapi",
219 schema(title = "BeginUploadResponseDirectMultipart")
220 )]
221 DirectMultipart {
222 namespace_id: NamespaceId,
224 upload_id: UploadId,
227 part_size_bytes: u64,
231 checksum_algorithm: ChecksumAlgorithm,
233 },
234}
235
236impl BeginUploadResponse {
237 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
268pub struct UploadContentResponse {
269 pub namespace_id: NamespaceId,
271 pub upload_id: UploadId,
273 pub content_ref: ContentRef,
275}
276
277#[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 #[cfg_attr(feature = "openapi", schema(title = "CompleteUploadServiceProxied"))]
287 ServiceProxied {},
288 #[cfg_attr(feature = "openapi", schema(title = "CompleteUploadDirectPut"))]
290 DirectPut {
291 content: UploadContentClaim,
293 },
294 #[cfg_attr(feature = "openapi", schema(title = "CompleteUploadDirectMultipart"))]
296 DirectMultipart {
297 content: UploadContentClaim,
299 parts: Vec<CompletedUploadPart>,
301 },
302}
303
304impl CompleteUploadRequest {
305 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
318#[serde(deny_unknown_fields)]
319pub struct CompleteMultipartUploadRequest {
320 pub content: UploadContentClaim,
322 pub parts: Vec<CompletedUploadPart>,
324}
325
326#[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 #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusOpen"))]
338 Open {
339 expires_at_ms: u64,
342 },
343 #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusCompleted"))]
345 Completed {
346 completed_at_ms: u64,
348 content_ref: ContentRef,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
353 #[cfg_attr(feature = "openapi", schema(nullable = false))]
354 content_token: Option<ContentToken>,
355 },
356 #[cfg_attr(feature = "openapi", schema(title = "UploadSessionStatusAborted"))]
358 Aborted {
359 aborted_at_ms: u64,
361 },
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
367pub struct UploadSession {
368 pub namespace_id: NamespaceId,
370 pub upload_id: UploadId,
372 pub mode: UploadMode,
374 #[serde(flatten)]
377 pub status: UploadSessionStatus,
378}
379
380impl UploadSession {
381 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 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}