1use crate::{
15 AbsolutePath, ActorKind, ActorRef, AttributeRevisionNo, ChangeSeq, CommitId, ContentEvidence,
16 ContentRef, DeleteDirectoryBehavior, DestinationBehavior, FilesystemOperation, InodeId,
17 NamespaceId, RevisionNo,
18};
19use serde::Serialize;
20use sha2::{Digest, Sha256};
21use std::collections::BTreeMap;
22use std::future::Future;
23use thiserror::Error;
24
25const COMMIT_FINGERPRINT_DOMAIN: &str = "loonfs.commit.semantic.v1";
27
28const FINGERPRINT_SCHEME: &str = "v1:sha256";
33
34#[derive(Debug, Error)]
39#[error("failed to encode the commit fingerprint preimage: {0}")]
40pub struct SemanticFingerprintError(#[from] serde_json::Error);
41
42fn fingerprint_digest<T>(preimage: &T) -> Result<String, SemanticFingerprintError>
47where
48 T: Serialize,
49{
50 let bytes = serde_json::to_vec(preimage)?;
51 Ok(fingerprint_bytes(&bytes))
52}
53
54fn fingerprint_bytes(bytes: &[u8]) -> String {
55 let digest = Sha256::digest(bytes);
56 format!(
57 "{FINGERPRINT_SCHEME}:{}",
58 crate::hex::hex_encode_bytes(&digest)
59 )
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80#[serde(tag = "kind", rename_all = "snake_case")]
81enum OperationFingerprintInput<'a> {
82 CreateDir {
83 absolute_path: &'a str,
84 parents: bool,
85 },
86 PutFile {
90 absolute_path: &'a str,
91 behavior: DestinationBehavior,
92 content_ref: ContentRefFingerprintInput<'a>,
93 expected_revision_no: Option<RevisionNo>,
94 },
95 CreateDirByInode {
96 parent_inode_id: InodeId,
97 display_name: &'a str,
98 },
99 PutFileByInode {
100 parent_inode_id: InodeId,
101 display_name: &'a str,
102 content_ref: ContentRefFingerprintInput<'a>,
103 },
104 PutFileRevisionByInode {
105 inode_id: InodeId,
106 content_ref: ContentRefFingerprintInput<'a>,
107 expected_revision_no: RevisionNo,
108 },
109 MoveByInode {
110 inode_id: InodeId,
111 expected_binding_generation: &'a str,
112 to_parent_inode_id: InodeId,
113 to_display_name: &'a str,
114 behavior: DestinationBehavior,
115 },
116 DeleteByInode {
117 inode_id: InodeId,
118 expected_binding_generation: &'a str,
119 behavior: DeleteDirectoryBehavior,
120 },
121 DeletePath {
125 absolute_path: &'a str,
126 behavior: DeleteDirectoryBehavior,
127 expected_inode_id: Option<InodeId>,
128 },
129 MovePath {
130 from_path: &'a str,
131 to_path: &'a str,
132 behavior: DestinationBehavior,
133 },
134 CopyFilePath {
135 from_path: &'a str,
136 to_path: &'a str,
137 behavior: DestinationBehavior,
138 },
139 RestoreRevision {
140 absolute_path: &'a str,
141 source_revision_no: RevisionNo,
142 },
143 Undelete {
144 inode_id: InodeId,
145 deleted_at_seq: ChangeSeq,
146 absolute_path: Option<&'a str>,
151 },
152 UpdateAttrs {
158 absolute_path: &'a str,
159 set: BTreeMap<&'a str, &'a str>,
160 remove: Vec<&'a str>,
161 expected_inode_id: Option<InodeId>,
162 expected_attributes_revision_no: Option<AttributeRevisionNo>,
163 },
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
180struct ContentRefFingerprintInput<'a> {
181 kind: &'a str,
182 content_id: &'a str,
183 size_bytes: u64,
184}
185
186fn content_ref_fingerprint_input(content_ref: &ContentRef) -> ContentRefFingerprintInput<'_> {
187 ContentRefFingerprintInput {
188 kind: content_ref.kind.as_str(),
189 content_id: content_ref.content_id.as_str(),
190 size_bytes: content_ref.size_bytes,
191 }
192}
193
194fn operation_fingerprint_input(operation: &FilesystemOperation) -> OperationFingerprintInput<'_> {
200 match operation {
201 FilesystemOperation::CreateDirectory { path, parents } => {
202 OperationFingerprintInput::CreateDir {
203 absolute_path: path.as_str(),
204 parents: *parents,
205 }
206 }
207 FilesystemOperation::PutFile {
208 path,
209 content_ref,
210 behavior,
211 expected_revision_no,
212 } => OperationFingerprintInput::PutFile {
213 absolute_path: path.as_str(),
214 behavior: *behavior,
215 content_ref: content_ref_fingerprint_input(content_ref),
216 expected_revision_no: *expected_revision_no,
217 },
218 FilesystemOperation::CreateDirectoryByInode {
219 parent_inode_id,
220 display_name,
221 } => OperationFingerprintInput::CreateDirByInode {
222 parent_inode_id: *parent_inode_id,
223 display_name: display_name.as_str(),
224 },
225 FilesystemOperation::PutFileByInode {
226 parent_inode_id,
227 display_name,
228 content_ref,
229 } => OperationFingerprintInput::PutFileByInode {
230 parent_inode_id: *parent_inode_id,
231 display_name: display_name.as_str(),
232 content_ref: content_ref_fingerprint_input(content_ref),
233 },
234 FilesystemOperation::PutFileRevisionByInode {
235 inode_id,
236 content_ref,
237 expected_revision_no,
238 } => OperationFingerprintInput::PutFileRevisionByInode {
239 inode_id: *inode_id,
240 content_ref: content_ref_fingerprint_input(content_ref),
241 expected_revision_no: *expected_revision_no,
242 },
243 FilesystemOperation::MoveByInode {
244 inode_id,
245 expected_binding_generation,
246 to_parent_inode_id,
247 to_display_name,
248 behavior,
249 } => OperationFingerprintInput::MoveByInode {
250 inode_id: *inode_id,
251 expected_binding_generation,
252 to_parent_inode_id: *to_parent_inode_id,
253 to_display_name: to_display_name.as_str(),
254 behavior: *behavior,
255 },
256 FilesystemOperation::DeleteByInode {
257 inode_id,
258 expected_binding_generation,
259 behavior,
260 } => OperationFingerprintInput::DeleteByInode {
261 inode_id: *inode_id,
262 expected_binding_generation,
263 behavior: *behavior,
264 },
265 FilesystemOperation::DeletePath {
266 path,
267 behavior,
268 expected_inode_id,
269 } => OperationFingerprintInput::DeletePath {
270 absolute_path: path.as_str(),
271 behavior: *behavior,
272 expected_inode_id: *expected_inode_id,
273 },
274 FilesystemOperation::MovePath {
275 from_path,
276 to_path,
277 behavior,
278 } => OperationFingerprintInput::MovePath {
279 from_path: from_path.as_str(),
280 to_path: to_path.as_str(),
281 behavior: *behavior,
282 },
283 FilesystemOperation::CopyPath {
284 from_path,
285 to_path,
286 behavior,
287 } => OperationFingerprintInput::CopyFilePath {
288 from_path: from_path.as_str(),
289 to_path: to_path.as_str(),
290 behavior: *behavior,
291 },
292 FilesystemOperation::RestoreRevision {
293 path,
294 source_revision_no,
295 } => OperationFingerprintInput::RestoreRevision {
296 absolute_path: path.as_str(),
297 source_revision_no: *source_revision_no,
298 },
299 FilesystemOperation::Undelete {
300 inode_id,
301 deletion_seq,
302 path,
303 } => OperationFingerprintInput::Undelete {
304 inode_id: *inode_id,
305 deleted_at_seq: *deletion_seq,
306 absolute_path: path.as_ref().map(AbsolutePath::as_str),
307 },
308 FilesystemOperation::UpdateAttributes {
309 path,
310 set,
311 remove,
312 expected_inode_id,
313 expected_attributes_revision_no,
314 } => {
315 let mut remove: Vec<&str> = remove.iter().map(|key| key.as_str()).collect();
320 remove.sort_unstable();
321 remove.dedup();
322 OperationFingerprintInput::UpdateAttrs {
323 absolute_path: path.as_str(),
324 set: set
325 .iter()
326 .map(|(key, value)| (key.as_str(), value.as_str()))
327 .collect(),
328 remove,
329 expected_inode_id: *expected_inode_id,
330 expected_attributes_revision_no: *expected_attributes_revision_no,
331 }
332 }
333 }
334}
335
336pub fn semantic_commit_fingerprint(
341 namespace_id: &NamespaceId,
342 actor: &ActorRef,
343 message: Option<&str>,
344 operations: &[FilesystemOperation],
345) -> Result<String, SemanticFingerprintError> {
346 #[derive(Serialize)]
347 struct CanonicalCommit<'a> {
348 domain: &'static str,
349 namespace_id: &'a str,
350 actor_kind: ActorKind,
351 actor_id: &'a str,
352 operations: Vec<OperationFingerprintInput<'a>>,
353 message: Option<&'a str>,
354 }
355
356 fingerprint_digest(&CanonicalCommit {
357 domain: COMMIT_FINGERPRINT_DOMAIN,
358 namespace_id: namespace_id.as_str(),
359 actor_kind: actor.kind,
360 actor_id: actor.id.as_str(),
361 operations: operations.iter().map(operation_fingerprint_input).collect(),
362 message,
363 })
364}
365
366pub fn put_retry_fingerprint(
376 namespace_id: &NamespaceId,
377 actor: &ActorRef,
378 path: &AbsolutePath,
379 behavior: DestinationBehavior,
380 expected_revision_no: Option<RevisionNo>,
381 message: Option<&str>,
382 committed_content_ref: &ContentRef,
383) -> Result<String, SemanticFingerprintError> {
384 let operation = FilesystemOperation::PutFile {
385 path: path.clone(),
386 content_ref: committed_content_ref.clone(),
387 behavior,
388 expected_revision_no,
389 };
390 semantic_commit_fingerprint(
391 namespace_id,
392 actor,
393 message,
394 std::slice::from_ref(&operation),
395 )
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct PutRetryReceipt {
401 pub committed_seq: ChangeSeq,
403 pub committed_fingerprint: String,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
409#[non_exhaustive]
410pub enum PutRetryErrorClassification {
411 CommitIdReuseConflict(Option<PutRetryReceipt>),
413 RebootstrapRequired,
415 Other,
417}
418
419#[derive(Debug, Clone, Copy)]
421pub struct PutRetryAttempt<'a> {
422 pub namespace_id: &'a NamespaceId,
424 pub path: &'a AbsolutePath,
426 pub commit_id: &'a CommitId,
428 pub options: &'a crate::options::PutFileOptions,
430 pub staged: ContentEvidence<'a>,
432}
433
434pub async fn reconcile_put_commit_id_reuse<E, ReadChange, ReadChangeFuture, ClassifyError>(
447 attempt: PutRetryAttempt<'_>,
448 conflict: E,
449 read_change: ReadChange,
450 classify_error: ClassifyError,
451) -> Result<crate::v0::CommitResponse, E>
452where
453 ReadChange: FnOnce(ChangeSeq) -> ReadChangeFuture,
454 ReadChangeFuture: Future<Output = Result<crate::v0::ListChangesResponse, E>>,
455 ClassifyError: Fn(&E) -> PutRetryErrorClassification,
456{
457 let PutRetryErrorClassification::CommitIdReuseConflict(Some(receipt)) =
458 classify_error(&conflict)
459 else {
460 return Err(conflict);
461 };
462 let after_seq = ChangeSeq(receipt.committed_seq.0.saturating_sub(1));
463 let page = match read_change(after_seq).await {
464 Ok(page) => page,
465 Err(error)
466 if matches!(
467 classify_error(&error),
468 PutRetryErrorClassification::RebootstrapRequired
469 ) =>
470 {
471 return Err(conflict);
472 }
473 Err(error) => return Err(error),
474 };
475 let Some(committed) = page.changes.into_iter().find(|change| {
476 change.committed_seq == receipt.committed_seq && &change.commit_id == attempt.commit_id
477 }) else {
478 return Err(conflict);
479 };
480 let Some(content_ref) = sole_committed_content_ref(&committed) else {
481 return Err(conflict);
482 };
483 let retried = put_retry_fingerprint(
484 attempt.namespace_id,
485 &attempt.options.commit.actor,
486 attempt.path,
487 attempt.options.behavior,
488 attempt.options.expected_revision_no,
489 attempt.options.commit.message.as_deref(),
490 content_ref,
491 );
492 if retried.ok().as_deref() != Some(receipt.committed_fingerprint.as_str())
493 || !content_ref.matches_evidence(attempt.staged)
494 {
495 return Err(conflict);
496 }
497 Ok(crate::v0::CommitResponse::from_committed_change(
498 attempt.namespace_id.clone(),
499 committed,
500 ))
501}
502
503fn sole_committed_content_ref(change: &crate::v0::CommittedChange) -> Option<&ContentRef> {
506 let mut content = change.events.iter().filter_map(|event| match event {
507 crate::v0::FilesystemChange::FileCreated { content_ref, .. }
508 | crate::v0::FilesystemChange::ContentChanged { content_ref, .. } => Some(content_ref),
509 _ => None,
510 });
511 let only = content.next()?;
512 content.next().is_none().then_some(only)
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::{
519 ActorId, AttributeKey, AttributeValue, Checksum, ContentId, ContentRefKind, DisplayName,
520 };
521
522 fn test_actor() -> ActorRef {
523 ActorRef::user(ActorId::parse("test-actor").expect("valid test actor id"))
524 }
525
526 fn attribute_key(value: &str) -> AttributeKey {
527 AttributeKey::parse(value).expect("valid attribute key")
528 }
529
530 fn text(value: &str) -> AttributeValue {
531 AttributeValue::parse(value).expect("valid attribute value")
532 }
533
534 fn update_attributes(
535 set: impl IntoIterator<Item = (&'static str, AttributeValue)>,
536 remove: impl IntoIterator<Item = &'static str>,
537 expected_inode_id: Option<InodeId>,
538 expected_attributes_revision_no: Option<AttributeRevisionNo>,
539 ) -> FilesystemOperation {
540 FilesystemOperation::UpdateAttributes {
541 path: AbsolutePath::parse("/docs/report.txt").expect("path"),
542 set: set
543 .into_iter()
544 .map(|(key, value)| (attribute_key(key), value))
545 .collect(),
546 remove: remove.into_iter().map(attribute_key).collect(),
547 expected_inode_id,
548 expected_attributes_revision_no,
549 }
550 }
551
552 #[test]
553 fn update_attributes_fingerprint_value_is_pinned() {
554 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
555
556 let fingerprint = semantic_commit_fingerprint(
557 &namespace_id,
558 &test_actor(),
559 None,
560 &[update_attributes(
561 [("owner", text("ada")), ("tags", text("a,b"))],
562 ["draft"],
563 Some(InodeId(42)),
564 Some(AttributeRevisionNo(3)),
565 )],
566 )
567 .expect("fingerprint");
568
569 assert_eq!(
570 fingerprint,
571 "v1:sha256:bc41940773fa7df87aaeecf44b2fbd8205071e15fcb81705887ff1de0a9582bb"
572 );
573 }
574
575 #[test]
576 fn json_map_order_does_not_change_attribute_update_identity() {
577 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
578 let forward: FilesystemOperation = serde_json::from_str(
579 r#"{"kind":"update_attributes","path":"/docs/report.txt",
580 "set":{"a":"1","b":"2"}}"#,
581 )
582 .expect("forward operation");
583 let reversed: FilesystemOperation = serde_json::from_str(
584 r#"{"kind":"update_attributes","path":"/docs/report.txt",
585 "set":{"b":"2","a":"1"}}"#,
586 )
587 .expect("reversed operation");
588
589 assert_eq!(
590 semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[forward])
591 .expect("forward"),
592 semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[reversed])
593 .expect("reversed")
594 );
595 }
596
597 #[test]
598 fn remove_order_and_repeats_do_not_change_attribute_update_identity() {
599 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
600 let baseline = semantic_commit_fingerprint(
601 &namespace_id,
602 &test_actor(),
603 None,
604 &[update_attributes([], ["a", "b"], None, None)],
605 )
606 .expect("baseline");
607
608 for spelling in [vec!["b", "a"], vec!["a", "b", "a"]] {
609 assert_eq!(
610 semantic_commit_fingerprint(
611 &namespace_id,
612 &test_actor(),
613 None,
614 &[update_attributes([], spelling, None, None)]
615 )
616 .expect("variant"),
617 baseline
618 );
619 }
620 }
621
622 #[test]
623 fn attribute_update_fingerprint_changes_with_every_request_field() {
624 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
625 let baseline = semantic_commit_fingerprint(
626 &namespace_id,
627 &test_actor(),
628 None,
629 &[update_attributes(
630 [("owner", text("ada"))],
631 ["draft"],
632 None,
633 None,
634 )],
635 )
636 .expect("baseline");
637
638 for (label, variant) in [
639 (
640 "set value",
641 update_attributes([("owner", text("grace"))], ["draft"], None, None),
642 ),
643 (
644 "removed key",
645 update_attributes([("owner", text("ada"))], ["final"], None, None),
646 ),
647 (
648 "expected inode",
649 update_attributes([("owner", text("ada"))], ["draft"], Some(InodeId(42)), None),
650 ),
651 (
652 "expected attribute revision",
653 update_attributes(
654 [("owner", text("ada"))],
655 ["draft"],
656 None,
657 Some(AttributeRevisionNo(0)),
658 ),
659 ),
660 ] {
661 assert_ne!(
662 baseline,
663 semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[variant])
664 .expect("variant fingerprint"),
665 "a changed {label} must change the fingerprint"
666 );
667 }
668 }
669
670 #[test]
671 fn binding_generation_changes_inode_mutation_identity() {
672 let namespace_id = NamespaceId::parse("demo").expect("namespace id");
673 let operation = |expected_binding_generation: &str| FilesystemOperation::MoveByInode {
674 inode_id: InodeId(42),
675 expected_binding_generation: expected_binding_generation.to_owned(),
676 to_parent_inode_id: InodeId(7),
677 to_display_name: DisplayName::parse("report.txt").expect("display name"),
678 behavior: DestinationBehavior::NoReplace,
679 };
680
681 let fingerprint = |generation| {
682 semantic_commit_fingerprint(
683 &namespace_id,
684 &test_actor(),
685 None,
686 &[operation(generation)],
687 )
688 .expect("fingerprint")
689 };
690
691 assert_ne!(fingerprint("generation-a"), fingerprint("generation-b"));
692 }
693
694 #[test]
695 fn commit_fingerprint_value_is_pinned() {
696 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
697
698 let fingerprint =
699 semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[create_dir("/docs")])
700 .expect("fingerprint");
701
702 assert_eq!(
703 fingerprint,
704 "v1:sha256:dc41318564ff5329c73ba2f1af338f24bd323be7a56305a2b9b94cb24b95ec5a"
705 );
706 }
707
708 #[test]
709 fn actor_kind_and_id_are_distinct_canonical_identity_fields() {
710 let namespace_id = NamespaceId::parse("demo").expect("namespace id");
711 let operation = create_dir("/docs");
712 let user_x = ActorRef::user(ActorId::parse("x").expect("actor id"));
713 let user_y = ActorRef::user(ActorId::parse("y").expect("actor id"));
714 let service_x = ActorRef::service(ActorId::parse("x").expect("actor id"));
715
716 let fingerprint = |actor: &ActorRef| {
717 semantic_commit_fingerprint(
718 &namespace_id,
719 actor,
720 None,
721 std::slice::from_ref(&operation),
722 )
723 .expect("fingerprint")
724 };
725 assert_ne!(fingerprint(&user_x), fingerprint(&user_y));
726 assert_ne!(fingerprint(&user_x), fingerprint(&service_x));
727 }
728
729 #[test]
730 fn guarded_delete_fingerprint_value_is_pinned() {
731 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
732
733 let fingerprint = semantic_commit_fingerprint(
734 &namespace_id,
735 &test_actor(),
736 None,
737 &[FilesystemOperation::DeletePath {
738 path: AbsolutePath::parse("/docs").expect("path"),
739 behavior: DeleteDirectoryBehavior::NonRecursive,
740 expected_inode_id: Some(InodeId(42)),
741 }],
742 )
743 .expect("fingerprint");
744
745 assert_eq!(
746 fingerprint,
747 "v1:sha256:bd1dc71c8b7e0b1e503dbf0925b801275088b6f2598888f893787688f1f01d0f"
748 );
749 }
750
751 #[test]
752 fn undelete_fingerprint_value_is_pinned() {
753 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
754
755 let fingerprint = semantic_commit_fingerprint(
756 &namespace_id,
757 &test_actor(),
758 None,
759 &[FilesystemOperation::Undelete {
760 inode_id: InodeId(42),
761 deletion_seq: ChangeSeq(17),
762 path: Some(AbsolutePath::parse("/docs/report.txt").expect("path")),
763 }],
764 )
765 .expect("fingerprint");
766
767 assert_eq!(
771 serde_json::to_value(Some("/docs/report.txt")).expect("serialize"),
772 serde_json::to_value("/docs/report.txt").expect("serialize"),
773 );
774 assert_eq!(
775 fingerprint,
776 "v1:sha256:9146c9e675a2e132bb16adb32d235f73080a3ef065cbd2f5c82ccb83aee02e57"
777 );
778 }
779
780 #[test]
781 fn in_place_undelete_fingerprint_value_is_pinned() {
782 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
783
784 let fingerprint = semantic_commit_fingerprint(
785 &namespace_id,
786 &test_actor(),
787 None,
788 &[FilesystemOperation::Undelete {
789 inode_id: InodeId(42),
790 deletion_seq: ChangeSeq(17),
791 path: None,
792 }],
793 )
794 .expect("fingerprint");
795
796 assert_eq!(
797 fingerprint,
798 "v1:sha256:52e0be7cc080b08b6efb7dcabf474e795be9066dc30b77dac0cc1acd09f43bdb"
799 );
800 }
801
802 #[test]
803 fn put_file_fingerprint_value_is_pinned() {
804 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
805
806 let fingerprint = semantic_commit_fingerprint(
807 &namespace_id,
808 &test_actor(),
809 None,
810 &[FilesystemOperation::PutFile {
811 path: AbsolutePath::parse("/docs/report.txt").expect("path"),
812 content_ref: ContentRef::blob_v1(
813 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
814 b"pinned put bytes",
815 ),
816 behavior: DestinationBehavior::NoReplace,
817 expected_revision_no: None,
818 }],
819 )
820 .expect("fingerprint");
821
822 assert_eq!(
823 fingerprint,
824 "v1:sha256:bc5ab43ea228015ee13ceb52bb074b3ec1f3026babeb007eec8f5512fb64a924"
825 );
826 }
827
828 #[test]
829 fn a_put_retry_reaches_the_pinned_fingerprint_under_every_checksum_algorithm() {
830 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
831 let content_id =
832 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id");
833 let bytes = b"pinned put bytes";
834
835 for content_ref in [
836 ContentRef::blob_v1(content_id.clone(), bytes),
837 ContentRef {
838 kind: ContentRefKind::BlobV1,
839 content_id: content_id.clone(),
840 size_bytes: bytes.len() as u64,
841 checksum: Checksum::crc32c(bytes),
842 },
843 ContentRef {
844 kind: ContentRefKind::BlobV1,
845 content_id: content_id.clone(),
846 size_bytes: bytes.len() as u64,
847 checksum: Checksum::crc64nvme(bytes),
848 },
849 ] {
850 assert_eq!(
851 put_retry_fingerprint(
852 &namespace_id,
853 &test_actor(),
854 &AbsolutePath::parse("/docs/report.txt").expect("path"),
855 DestinationBehavior::NoReplace,
856 None,
857 None,
858 &content_ref,
859 )
860 .expect("retry fingerprint"),
861 "v1:sha256:bc5ab43ea228015ee13ceb52bb074b3ec1f3026babeb007eec8f5512fb64a924"
862 );
863 }
864 }
865
866 fn create_dir(path: &str) -> FilesystemOperation {
867 FilesystemOperation::CreateDirectory {
868 path: AbsolutePath::parse(path).expect("path"),
869 parents: false,
870 }
871 }
872
873 fn put(path: &str, content_ref: ContentRef) -> FilesystemOperation {
874 FilesystemOperation::PutFile {
875 path: AbsolutePath::parse(path).expect("path"),
876 content_ref,
877 behavior: DestinationBehavior::NoReplace,
878 expected_revision_no: None,
879 }
880 }
881
882 #[test]
883 fn a_different_content_object_changes_mutation_identity() {
884 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
885 let bytes = b"identical bytes, two uploads";
886 let first = ContentRef::blob_v1(ContentId::generate(), bytes);
887 let second = ContentRef::blob_v1(ContentId::generate(), bytes);
888
889 assert_ne!(
890 semantic_commit_fingerprint(
891 &namespace_id,
892 &test_actor(),
893 None,
894 &[put("/docs/report.txt", first)]
895 )
896 .expect("fingerprint"),
897 semantic_commit_fingerprint(
898 &namespace_id,
899 &test_actor(),
900 None,
901 &[put("/docs/report.txt", second)]
902 )
903 .expect("fingerprint")
904 );
905 }
906
907 #[test]
908 fn a_message_changes_mutation_identity() {
909 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
913 let without =
914 semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[create_dir("/docs")])
915 .expect("fingerprint");
916 let with = semantic_commit_fingerprint(
917 &namespace_id,
918 &test_actor(),
919 Some("import batch"),
920 &[create_dir("/docs")],
921 )
922 .expect("fingerprint");
923
924 assert_ne!(without, with);
925 }
926
927 #[test]
928 fn operation_order_changes_mutation_identity() {
929 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
930
931 assert_ne!(
932 semantic_commit_fingerprint(
933 &namespace_id,
934 &test_actor(),
935 None,
936 &[create_dir("/a"), create_dir("/b")]
937 )
938 .expect("forward fingerprint"),
939 semantic_commit_fingerprint(
940 &namespace_id,
941 &test_actor(),
942 None,
943 &[create_dir("/b"), create_dir("/a")]
944 )
945 .expect("reversed fingerprint")
946 );
947 }
948
949 #[test]
950 fn put_retry_fingerprint_matches_the_equivalent_single_operation_request() {
951 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
952 let path = AbsolutePath::parse("/docs/report.txt").expect("path");
953 let content_ref = ContentRef::blob_v1(
954 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
955 b"pinned put bytes",
956 );
957
958 let by_hand = semantic_commit_fingerprint(
959 &namespace_id,
960 &test_actor(),
961 Some("import batch"),
962 &[FilesystemOperation::PutFile {
963 path: path.clone(),
964 content_ref: content_ref.clone(),
965 behavior: DestinationBehavior::Replace,
966 expected_revision_no: Some(RevisionNo(4)),
967 }],
968 )
969 .expect("hand-built fingerprint");
970
971 assert_eq!(
972 put_retry_fingerprint(
973 &namespace_id,
974 &test_actor(),
975 &path,
976 DestinationBehavior::Replace,
977 Some(RevisionNo(4)),
978 Some("import batch"),
979 &content_ref,
980 )
981 .expect("retry fingerprint"),
982 by_hand
983 );
984 }
985
986 #[test]
987 fn put_retry_fingerprint_changes_with_every_request_field() {
988 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
989 let path = AbsolutePath::parse("/a.txt").expect("path");
990 let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
991 let baseline = put_retry_fingerprint(
992 &namespace_id,
993 &test_actor(),
994 &path,
995 DestinationBehavior::Replace,
996 None,
997 None,
998 &content_ref,
999 )
1000 .expect("baseline");
1001
1002 for (label, variant) in [
1003 (
1004 "path",
1005 put_retry_fingerprint(
1006 &namespace_id,
1007 &test_actor(),
1008 &AbsolutePath::parse("/b.txt").expect("path"),
1009 DestinationBehavior::Replace,
1010 None,
1011 None,
1012 &content_ref,
1013 ),
1014 ),
1015 (
1016 "behavior",
1017 put_retry_fingerprint(
1018 &namespace_id,
1019 &test_actor(),
1020 &path,
1021 DestinationBehavior::NoReplace,
1022 None,
1023 None,
1024 &content_ref,
1025 ),
1026 ),
1027 (
1028 "expected revision",
1029 put_retry_fingerprint(
1030 &namespace_id,
1031 &test_actor(),
1032 &path,
1033 DestinationBehavior::Replace,
1034 Some(RevisionNo(2)),
1035 None,
1036 &content_ref,
1037 ),
1038 ),
1039 (
1040 "message",
1041 put_retry_fingerprint(
1042 &namespace_id,
1043 &test_actor(),
1044 &path,
1045 DestinationBehavior::Replace,
1046 None,
1047 Some(""),
1048 &content_ref,
1049 ),
1050 ),
1051 (
1052 "namespace",
1053 put_retry_fingerprint(
1054 &NamespaceId::parse("other").expect("valid namespace id"),
1055 &test_actor(),
1056 &path,
1057 DestinationBehavior::Replace,
1058 None,
1059 None,
1060 &content_ref,
1061 ),
1062 ),
1063 ] {
1064 assert_ne!(
1065 baseline,
1066 variant.expect("variant fingerprint"),
1067 "a changed {label} must change the fingerprint"
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn put_retry_reconciliation_agrees_on_receipt_mismatch_and_unavailable_evidence() {
1074 #[derive(Debug, Clone, PartialEq, Eq)]
1075 enum ReconciliationError {
1076 Conflict(PutRetryReceipt),
1077 EvidenceUnavailable,
1078 }
1079
1080 fn classify(error: &ReconciliationError) -> PutRetryErrorClassification {
1081 match error {
1082 ReconciliationError::Conflict(receipt) => {
1083 PutRetryErrorClassification::CommitIdReuseConflict(Some(receipt.clone()))
1084 }
1085 ReconciliationError::EvidenceUnavailable => {
1086 PutRetryErrorClassification::RebootstrapRequired
1087 }
1088 }
1089 }
1090
1091 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
1092 let path = AbsolutePath::parse("/report.txt").expect("valid path");
1093 let commit_id = CommitId::parse("pinned-put").expect("valid commit id");
1094 let committed_seq = ChangeSeq(7);
1095 let bytes = b"stable bytes";
1096 let content_ref = ContentRef::blob_v1(ContentId::generate(), bytes);
1097 let mut options = crate::options::PutFileOptions::new(test_actor());
1098 options.commit.commit_id = Some(commit_id.clone());
1099 let receipt = PutRetryReceipt {
1100 committed_seq,
1101 committed_fingerprint: put_retry_fingerprint(
1102 &namespace_id,
1103 &test_actor(),
1104 &path,
1105 options.behavior,
1106 options.expected_revision_no,
1107 options.commit.message.as_deref(),
1108 &content_ref,
1109 )
1110 .expect("fingerprint"),
1111 };
1112 let page = crate::v0::ListChangesResponse {
1113 namespace_id: namespace_id.clone(),
1114 after_seq: ChangeSeq(6),
1115 through_seq: committed_seq,
1116 next_after_seq: None,
1117 changes: vec![crate::v0::CommittedChange {
1118 committed_seq,
1119 commit_id: commit_id.clone(),
1120 committed_by: test_actor(),
1121 committed_at_ms: 1,
1122 message: None,
1123 events: vec![crate::v0::FilesystemChange::FileCreated {
1124 inode_id: InodeId(2),
1125 parent_inode_id: InodeId(1),
1126 display_name: DisplayName::parse("report.txt").expect("valid display name"),
1127 binding_generation: "generation".to_owned(),
1128 revision_no: RevisionNo(1),
1129 content_ref,
1130 }],
1131 }],
1132 };
1133
1134 let matching_attempt = PutRetryAttempt {
1135 namespace_id: &namespace_id,
1136 path: &path,
1137 commit_id: &commit_id,
1138 options: &options,
1139 staged: ContentEvidence::Bytes(bytes),
1140 };
1141 let reconciled = futures::executor::block_on(reconcile_put_commit_id_reuse(
1142 matching_attempt,
1143 ReconciliationError::Conflict(receipt.clone()),
1144 |after_seq| {
1145 assert_eq!(after_seq, ChangeSeq(6));
1146 std::future::ready(Ok(page.clone()))
1147 },
1148 classify,
1149 ))
1150 .expect("matching receipt and evidence reconcile");
1151 assert_eq!(reconciled.commit_id, commit_id);
1152 assert_eq!(reconciled.committed_seq, committed_seq);
1153
1154 let mismatch = futures::executor::block_on(reconcile_put_commit_id_reuse(
1155 PutRetryAttempt {
1156 staged: ContentEvidence::Bytes(b"different bytes"),
1157 ..matching_attempt
1158 },
1159 ReconciliationError::Conflict(receipt.clone()),
1160 |_| std::future::ready(Ok(page.clone())),
1161 classify,
1162 ));
1163 assert_eq!(
1164 mismatch,
1165 Err(ReconciliationError::Conflict(receipt.clone()))
1166 );
1167
1168 let unavailable = futures::executor::block_on(reconcile_put_commit_id_reuse(
1169 matching_attempt,
1170 ReconciliationError::Conflict(receipt.clone()),
1171 |_| std::future::ready(Err(ReconciliationError::EvidenceUnavailable)),
1172 classify,
1173 ));
1174 assert_eq!(unavailable, Err(ReconciliationError::Conflict(receipt)));
1175 }
1176}