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