1use crate::{
14 AbsolutePath, AccessRevisionNo, AccessRight, ActorId, AttributeRevisionNo, ChangeSeq,
15 ChecksumAlgorithm, CommitPrecondition, ContentId, ContentRef, ContentRefKind,
16 DeleteDirectoryBehavior, DestinationBehavior, FilesystemOperation, InodeId, NamespaceId,
17 RevisionNo, SubjectId,
18};
19use serde::Serialize;
20use sha2::{Digest, Sha256};
21use std::borrow::Cow;
22use std::collections::{BTreeMap, BTreeSet};
23use thiserror::Error;
24
25const COMMIT_FINGERPRINT_DOMAIN: &str = "loonfs.commit.semantic.v1";
27
28const FINGERPRINT_SCHEME: &str = "v1:sha256";
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
36pub struct CommitFingerprint(String);
37
38impl CommitFingerprint {
39 pub fn as_str(&self) -> &str {
41 &self.0
42 }
43}
44
45#[derive(Debug, Error)]
47#[non_exhaustive]
48pub enum SemanticFingerprintError {
49 #[error("exactly one of `content_ref` and `inline_content` is required")]
51 InvalidContentSource,
52 #[error("failed to encode the commit fingerprint preimage: {0}")]
54 Encode(#[from] serde_json::Error),
55 #[error("inline content `{content_id}` requires `sha256`, found `{actual_algorithm}`")]
57 InlineChecksumAlgorithm {
58 content_id: ContentId,
60 actual_algorithm: ChecksumAlgorithm,
62 },
63}
64
65fn fingerprint_bytes(bytes: &[u8]) -> CommitFingerprint {
66 let digest = Sha256::digest(bytes);
67 CommitFingerprint(format!(
68 "{FINGERPRINT_SCHEME}:{}",
69 crate::hex::hex_encode_bytes(&digest)
70 ))
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86#[serde(tag = "kind", rename_all = "snake_case")]
87enum OperationFingerprintInput<'a> {
88 CreateDirectory {
89 path: &'a str,
90 parents: bool,
91 },
92 PutFile {
93 path: &'a str,
94 behavior: DestinationBehavior,
95 content_ref: ContentRefFingerprintInput<'a>,
96 expected_inode_id: Option<InodeId>,
97 expected_revision_no: Option<RevisionNo>,
98 },
99 CreateDirectoryByInode {
100 parent_inode_id: InodeId,
101 display_name: &'a str,
102 },
103 CreateFileByInode {
104 parent_inode_id: InodeId,
105 display_name: &'a str,
106 content_ref: ContentRefFingerprintInput<'a>,
107 },
108 PutFileRevisionByInode {
109 inode_id: InodeId,
110 content_ref: ContentRefFingerprintInput<'a>,
111 expected_revision_no: RevisionNo,
112 },
113 MoveByInode {
114 inode_id: InodeId,
115 expected_binding_generation: &'a str,
116 destination_parent_inode_id: InodeId,
117 destination_display_name: &'a str,
118 behavior: DestinationBehavior,
119 expected_destination_inode_id: Option<InodeId>,
120 expected_destination_revision_no: Option<RevisionNo>,
121 },
122 DeleteByInode {
123 inode_id: InodeId,
124 expected_binding_generation: &'a str,
125 behavior: DeleteDirectoryBehavior,
126 },
127 DeletePath {
131 path: &'a str,
132 behavior: DeleteDirectoryBehavior,
133 expected_inode_id: Option<InodeId>,
134 },
135 MovePath {
136 source_path: &'a str,
137 destination_path: &'a str,
138 behavior: DestinationBehavior,
139 expected_destination_inode_id: Option<InodeId>,
140 expected_destination_revision_no: Option<RevisionNo>,
141 },
142 CopyPath {
143 source_path: &'a str,
144 destination_path: &'a str,
145 behavior: DestinationBehavior,
146 expected_destination_inode_id: Option<InodeId>,
147 expected_destination_revision_no: Option<RevisionNo>,
148 },
149 RestoreRevision {
150 path: &'a str,
151 source_revision_no: RevisionNo,
152 },
153 Undelete {
154 inode_id: InodeId,
155 deletion_seq: ChangeSeq,
156 destination_path: Option<&'a str>,
157 },
158 UpdateAttributes {
164 path: &'a str,
165 set: BTreeMap<&'a str, &'a str>,
166 remove: Vec<&'a str>,
167 expected_inode_id: Option<InodeId>,
168 expected_attributes_revision_no: Option<AttributeRevisionNo>,
169 },
170 UpdateAccess {
171 path: &'a str,
172 boundary: bool,
173 grants: BTreeMap<&'a str, Vec<&'static str>>,
174 expected_inode_id: Option<InodeId>,
175 expected_access_revision_no: Option<AccessRevisionNo>,
176 },
177}
178
179#[derive(Serialize)]
180#[serde(tag = "kind", rename_all = "snake_case")]
181enum PreconditionFingerprintInput<'a> {
182 NamespaceHead {
183 expected_head_seq: ChangeSeq,
184 },
185 FileRevision {
186 inode_id: InodeId,
187 expected_revision_no: RevisionNo,
188 },
189 PathBinding {
190 path: &'a str,
191 expected_inode_id: InodeId,
192 expected_binding_generation: Option<&'a str>,
193 },
194 PathAbsence {
195 path: &'a str,
196 },
197 AttributesRevision {
198 inode_id: InodeId,
199 expected_attributes_revision_no: AttributeRevisionNo,
200 },
201 AccessRevision {
202 inode_id: InodeId,
203 expected_access_revision_no: AccessRevisionNo,
204 },
205}
206
207fn precondition_fingerprint_input(
208 precondition: &CommitPrecondition,
209) -> PreconditionFingerprintInput<'_> {
210 match precondition {
211 CommitPrecondition::NamespaceHead { expected_head_seq } => {
212 PreconditionFingerprintInput::NamespaceHead {
213 expected_head_seq: *expected_head_seq,
214 }
215 }
216 CommitPrecondition::FileRevision {
217 inode_id,
218 expected_revision_no,
219 } => PreconditionFingerprintInput::FileRevision {
220 inode_id: *inode_id,
221 expected_revision_no: *expected_revision_no,
222 },
223 CommitPrecondition::PathBinding {
224 path,
225 expected_inode_id,
226 expected_binding_generation,
227 } => PreconditionFingerprintInput::PathBinding {
228 path: path.as_str(),
229 expected_inode_id: *expected_inode_id,
230 expected_binding_generation: expected_binding_generation
231 .as_ref()
232 .map(|value| value.as_str()),
233 },
234 CommitPrecondition::PathAbsence { path } => PreconditionFingerprintInput::PathAbsence {
235 path: path.as_str(),
236 },
237 CommitPrecondition::AttributesRevision {
238 inode_id,
239 expected_attributes_revision_no,
240 } => PreconditionFingerprintInput::AttributesRevision {
241 inode_id: *inode_id,
242 expected_attributes_revision_no: *expected_attributes_revision_no,
243 },
244 CommitPrecondition::AccessRevision {
245 inode_id,
246 expected_access_revision_no,
247 } => PreconditionFingerprintInput::AccessRevision {
248 inode_id: *inode_id,
249 expected_access_revision_no: *expected_access_revision_no,
250 },
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
270#[serde(tag = "kind", rename_all = "snake_case")]
271enum ContentRefFingerprintInput<'a> {
272 BlobV1 {
273 content_id: &'a str,
274 size_bytes: u64,
275 },
276 InlineV1 {
277 sha256: Cow<'a, str>,
278 size_bytes: u64,
279 },
280}
281
282fn content_ref_fingerprint_input<'a>(
283 content_ref: &'a ContentRef,
284 inline_content_ids: &BTreeSet<ContentId>,
285) -> Result<ContentRefFingerprintInput<'a>, SemanticFingerprintError> {
286 if inline_content_ids.contains(&content_ref.content_id) {
287 if content_ref.checksum.algorithm != ChecksumAlgorithm::Sha256 {
288 return Err(SemanticFingerprintError::InlineChecksumAlgorithm {
289 content_id: content_ref.content_id.clone(),
290 actual_algorithm: content_ref.checksum.algorithm,
291 });
292 }
293 Ok(ContentRefFingerprintInput::InlineV1 {
294 sha256: Cow::Borrowed(&content_ref.checksum.value),
295 size_bytes: content_ref.size_bytes,
296 })
297 } else {
298 match content_ref.kind {
299 ContentRefKind::BlobV1 => Ok(ContentRefFingerprintInput::BlobV1 {
300 content_id: content_ref.content_id.as_str(),
301 size_bytes: content_ref.size_bytes,
302 }),
303 }
304 }
305}
306
307fn content_fingerprint_input<'a>(
308 content_ref: Option<&'a ContentRef>,
309 inline_content: Option<&[u8]>,
310 inline_content_ids: &BTreeSet<ContentId>,
311) -> Result<ContentRefFingerprintInput<'a>, SemanticFingerprintError> {
312 match (content_ref, inline_content) {
313 (Some(reference), None) => content_ref_fingerprint_input(reference, inline_content_ids),
314 (None, Some(bytes)) => Ok(ContentRefFingerprintInput::InlineV1 {
315 sha256: Cow::Owned(crate::Checksum::sha256(bytes).value),
316 size_bytes: bytes.len() as u64,
317 }),
318 _ => Err(SemanticFingerprintError::InvalidContentSource),
319 }
320}
321
322fn operation_fingerprint_input<'a>(
326 operation: &'a FilesystemOperation,
327 inline_content_ids: &BTreeSet<ContentId>,
328) -> Result<OperationFingerprintInput<'a>, SemanticFingerprintError> {
329 Ok(match operation {
330 FilesystemOperation::CreateDirectory { path, parents } => {
331 OperationFingerprintInput::CreateDirectory {
332 path: path.as_str(),
333 parents: *parents,
334 }
335 }
336 FilesystemOperation::PutFile {
337 path,
338 content_ref,
339 inline_content,
340 behavior,
341 expected_inode_id,
342 expected_revision_no,
343 } => OperationFingerprintInput::PutFile {
344 path: path.as_str(),
345 behavior: *behavior,
346 content_ref: content_fingerprint_input(
347 content_ref.as_ref(),
348 inline_content.as_deref(),
349 inline_content_ids,
350 )?,
351 expected_inode_id: *expected_inode_id,
352 expected_revision_no: *expected_revision_no,
353 },
354 FilesystemOperation::CreateDirectoryByInode {
355 parent_inode_id,
356 display_name,
357 } => OperationFingerprintInput::CreateDirectoryByInode {
358 parent_inode_id: *parent_inode_id,
359 display_name: display_name.as_str(),
360 },
361 FilesystemOperation::CreateFileByInode {
362 parent_inode_id,
363 display_name,
364 content_ref,
365 inline_content,
366 } => OperationFingerprintInput::CreateFileByInode {
367 parent_inode_id: *parent_inode_id,
368 display_name: display_name.as_str(),
369 content_ref: content_fingerprint_input(
370 content_ref.as_ref(),
371 inline_content.as_deref(),
372 inline_content_ids,
373 )?,
374 },
375 FilesystemOperation::PutFileRevisionByInode {
376 inode_id,
377 content_ref,
378 inline_content,
379 expected_revision_no,
380 } => OperationFingerprintInput::PutFileRevisionByInode {
381 inode_id: *inode_id,
382 content_ref: content_fingerprint_input(
383 content_ref.as_ref(),
384 inline_content.as_deref(),
385 inline_content_ids,
386 )?,
387 expected_revision_no: *expected_revision_no,
388 },
389 FilesystemOperation::MoveByInode {
390 inode_id,
391 expected_binding_generation,
392 destination_parent_inode_id,
393 destination_display_name,
394 precondition,
395 } => OperationFingerprintInput::MoveByInode {
396 inode_id: *inode_id,
397 expected_binding_generation: expected_binding_generation.as_str(),
398 destination_parent_inode_id: *destination_parent_inode_id,
399 destination_display_name: destination_display_name.as_str(),
400 behavior: precondition.behavior,
401 expected_destination_inode_id: precondition.expected_inode_id,
402 expected_destination_revision_no: precondition.expected_revision_no,
403 },
404 FilesystemOperation::DeleteByInode {
405 inode_id,
406 expected_binding_generation,
407 behavior,
408 } => OperationFingerprintInput::DeleteByInode {
409 inode_id: *inode_id,
410 expected_binding_generation: expected_binding_generation.as_str(),
411 behavior: *behavior,
412 },
413 FilesystemOperation::DeletePath {
414 path,
415 behavior,
416 expected_inode_id,
417 } => OperationFingerprintInput::DeletePath {
418 path: path.as_str(),
419 behavior: *behavior,
420 expected_inode_id: *expected_inode_id,
421 },
422 FilesystemOperation::MovePath {
423 source_path,
424 destination_path,
425 precondition,
426 } => OperationFingerprintInput::MovePath {
427 source_path: source_path.as_str(),
428 destination_path: destination_path.as_str(),
429 behavior: precondition.behavior,
430 expected_destination_inode_id: precondition.expected_inode_id,
431 expected_destination_revision_no: precondition.expected_revision_no,
432 },
433 FilesystemOperation::CopyPath {
434 source_path,
435 destination_path,
436 precondition,
437 } => OperationFingerprintInput::CopyPath {
438 source_path: source_path.as_str(),
439 destination_path: destination_path.as_str(),
440 behavior: precondition.behavior,
441 expected_destination_inode_id: precondition.expected_inode_id,
442 expected_destination_revision_no: precondition.expected_revision_no,
443 },
444 FilesystemOperation::RestoreRevision {
445 path,
446 source_revision_no,
447 } => OperationFingerprintInput::RestoreRevision {
448 path: path.as_str(),
449 source_revision_no: *source_revision_no,
450 },
451 FilesystemOperation::Undelete {
452 inode_id,
453 deletion_seq,
454 destination_path,
455 } => OperationFingerprintInput::Undelete {
456 inode_id: *inode_id,
457 deletion_seq: *deletion_seq,
458 destination_path: destination_path.as_ref().map(AbsolutePath::as_str),
459 },
460 FilesystemOperation::UpdateAttributes {
461 path,
462 set,
463 remove,
464 expected_inode_id,
465 expected_attributes_revision_no,
466 } => {
467 let mut remove: Vec<&str> = remove.iter().map(|key| key.as_str()).collect();
472 remove.sort_unstable();
473 remove.dedup();
474 OperationFingerprintInput::UpdateAttributes {
475 path: path.as_str(),
476 set: set
477 .iter()
478 .map(|(key, value)| (key.as_str(), value.as_str()))
479 .collect(),
480 remove,
481 expected_inode_id: *expected_inode_id,
482 expected_attributes_revision_no: *expected_attributes_revision_no,
483 }
484 }
485 FilesystemOperation::UpdateAccess {
486 path,
487 boundary,
488 grants,
489 expected_inode_id,
490 expected_access_revision_no,
491 } => OperationFingerprintInput::UpdateAccess {
492 path: path.as_str(),
493 boundary: *boundary,
494 grants: grants
495 .as_map()
496 .iter()
497 .map(|(principal, rights)| {
498 (
499 principal.as_str(),
500 rights.iter().map(AccessRight::as_str).collect(),
501 )
502 })
503 .collect(),
504 expected_inode_id: *expected_inode_id,
505 expected_access_revision_no: *expected_access_revision_no,
506 },
507 })
508}
509
510pub fn semantic_commit_fingerprint(
518 namespace_id: &NamespaceId,
519 actor: &ActorId,
520 subject_id: Option<&SubjectId>,
521 message: Option<&str>,
522 operations: &[FilesystemOperation],
523 preconditions: &[CommitPrecondition],
524 inline_content_ids: &BTreeSet<ContentId>,
525) -> Result<CommitFingerprint, SemanticFingerprintError> {
526 Ok(fingerprint_bytes(&canonical_commit_bytes(
527 namespace_id,
528 actor,
529 subject_id,
530 message,
531 operations,
532 preconditions,
533 inline_content_ids,
534 )?))
535}
536
537fn canonical_commit_bytes(
538 namespace_id: &NamespaceId,
539 actor: &ActorId,
540 subject_id: Option<&SubjectId>,
541 message: Option<&str>,
542 operations: &[FilesystemOperation],
543 preconditions: &[CommitPrecondition],
544 inline_content_ids: &BTreeSet<ContentId>,
545) -> Result<Vec<u8>, SemanticFingerprintError> {
546 #[derive(Serialize)]
547 struct CanonicalCommit<'a> {
548 domain: &'static str,
549 namespace_id: &'a str,
550 actor_id: &'a str,
551 subject_id: Option<&'a str>,
552 operations: Vec<OperationFingerprintInput<'a>>,
553 message: Option<&'a str>,
554 preconditions: Vec<PreconditionFingerprintInput<'a>>,
555 }
556
557 Ok(serde_json::to_vec(&CanonicalCommit {
558 domain: COMMIT_FINGERPRINT_DOMAIN,
559 namespace_id: namespace_id.as_str(),
560 actor_id: actor.as_str(),
561 subject_id: subject_id.map(SubjectId::as_str),
562 operations: operations
563 .iter()
564 .map(|operation| operation_fingerprint_input(operation, inline_content_ids))
565 .collect::<Result<_, _>>()?,
566 message,
567 preconditions: preconditions
568 .iter()
569 .map(precondition_fingerprint_input)
570 .collect(),
571 })?)
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::options::PutFileOptions;
578 use crate::{
579 ActorId, AttributeKey, AttributeValue, Checksum, ContentId, ContentRefKind, DisplayName,
580 };
581
582 #[test]
583 fn canonical_bytes_and_digests_match_shared_vectors() {
584 #[derive(Serialize, serde::Deserialize)]
585 struct Vector {
586 name: String,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 subject_id: Option<SubjectId>,
589 operation: FilesystemOperation,
590 #[serde(default)]
591 preconditions: Vec<crate::CommitPrecondition>,
592 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
593 inline_content_ids: BTreeSet<ContentId>,
594 canonical_json: String,
595 fingerprint: String,
596 }
597 let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
598 .join("tests/golden/commit_fingerprints_v1.json");
599 let mut vectors: Vec<Vector> = serde_json::from_str(
600 &std::fs::read_to_string(&fixture_path).expect("read fingerprint vectors"),
601 )
602 .expect("fingerprint vectors");
603 let update = std::env::var_os("UPDATE_GOLDEN").is_some();
604 for vector in &mut vectors {
605 let namespace = NamespaceId::parse("demo").expect("namespace");
606 let operations = [vector.operation.clone()];
607 let bytes = canonical_commit_bytes(
608 &namespace,
609 &test_actor(),
610 vector.subject_id.as_ref(),
611 None,
612 &operations,
613 &vector.preconditions,
614 &vector.inline_content_ids,
615 )
616 .expect("canonical bytes");
617 if update {
618 vector.canonical_json = String::from_utf8(bytes.clone()).expect("canonical UTF-8");
619 vector.fingerprint = fingerprint_bytes(&bytes).as_str().to_owned();
620 }
621 assert_eq!(
622 bytes,
623 vector.canonical_json.as_bytes(),
624 "{} canonical bytes",
625 vector.name
626 );
627 let fingerprint = semantic_commit_fingerprint(
628 &namespace,
629 &test_actor(),
630 vector.subject_id.as_ref(),
631 None,
632 &operations,
633 &vector.preconditions,
634 &vector.inline_content_ids,
635 )
636 .expect("fingerprint");
637 assert_eq!(
638 fingerprint.as_str(),
639 vector.fingerprint,
640 "{} digest",
641 vector.name
642 );
643 }
644 if update {
645 let json =
646 serde_json::to_string_pretty(&vectors).expect("serialize fingerprint vectors");
647 std::fs::write(fixture_path, format!("{json}\n")).expect("write fingerprint vectors");
648 }
649 }
650
651 #[test]
652 #[allow(
653 clippy::panic,
654 reason = "unexpected results need precise test diagnostics"
655 )]
656 fn inline_identity_rejects_other_checksum_algorithms() {
657 let namespace_id = NamespaceId::parse("demo").expect("namespace");
658 let content_id = ContentId::generate();
659 let mut content_ref =
660 ContentRef::blob_v1(namespace_id.clone(), content_id.clone(), b"bytes");
661 content_ref.checksum = Checksum::crc32c(b"bytes");
662 let operation = FilesystemOperation::PutFileRevisionByInode {
663 inode_id: InodeId(2),
664 content_ref: Some(content_ref),
665 inline_content: None,
666 expected_revision_no: RevisionNo(1),
667 };
668 match semantic_commit_fingerprint(
669 &namespace_id,
670 &test_actor(),
671 None,
672 None,
673 &[operation],
674 &[],
675 &BTreeSet::from([content_id.clone()]),
676 ) {
677 Err(SemanticFingerprintError::InlineChecksumAlgorithm {
678 content_id: actual_content_id,
679 actual_algorithm,
680 }) => {
681 assert_eq!(actual_content_id, content_id);
682 assert_eq!(actual_algorithm, ChecksumAlgorithm::Crc32c);
683 }
684 other => panic!("expected InlineChecksumAlgorithm, got {other:?}"),
685 }
686 }
687
688 fn test_actor() -> ActorId {
689 ActorId::parse("test-actor").expect("valid test actor id")
690 }
691
692 fn attribute_key(value: &str) -> AttributeKey {
693 AttributeKey::parse(value).expect("valid attribute key")
694 }
695
696 fn text(value: &str) -> AttributeValue {
697 AttributeValue::parse(value).expect("valid attribute value")
698 }
699
700 fn fingerprint(operation: FilesystemOperation) -> String {
701 semantic_commit_fingerprint(
702 &NamespaceId::parse("demo").expect("valid namespace id"),
703 &test_actor(),
704 None,
705 None,
706 &[operation],
707 &[],
708 &BTreeSet::new(),
709 )
710 .expect("fingerprint")
711 .as_str()
712 .to_owned()
713 }
714
715 fn update_attributes(
716 set: impl IntoIterator<Item = (&'static str, AttributeValue)>,
717 remove: impl IntoIterator<Item = &'static str>,
718 expected_inode_id: Option<InodeId>,
719 expected_attributes_revision_no: Option<AttributeRevisionNo>,
720 ) -> FilesystemOperation {
721 FilesystemOperation::UpdateAttributes {
722 path: AbsolutePath::parse("/docs/report.txt").expect("path"),
723 set: set
724 .into_iter()
725 .map(|(key, value)| (attribute_key(key), value))
726 .collect(),
727 remove: remove.into_iter().map(attribute_key).collect(),
728 expected_inode_id,
729 expected_attributes_revision_no,
730 }
731 }
732
733 #[test]
734 fn json_map_order_does_not_change_attribute_update_identity() {
735 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
736 let forward: FilesystemOperation = serde_json::from_str(
737 r#"{"kind":"update_attributes","path":"/docs/report.txt",
738 "set":{"a":"1","b":"2"}}"#,
739 )
740 .expect("forward operation");
741 let reversed: FilesystemOperation = serde_json::from_str(
742 r#"{"kind":"update_attributes","path":"/docs/report.txt",
743 "set":{"b":"2","a":"1"}}"#,
744 )
745 .expect("reversed operation");
746
747 assert_eq!(
748 semantic_commit_fingerprint(
749 &namespace_id,
750 &test_actor(),
751 None,
752 None,
753 &[forward],
754 &[],
755 &BTreeSet::new()
756 )
757 .expect("forward"),
758 semantic_commit_fingerprint(
759 &namespace_id,
760 &test_actor(),
761 None,
762 None,
763 &[reversed],
764 &[],
765 &BTreeSet::new()
766 )
767 .expect("reversed")
768 );
769 }
770
771 #[test]
772 fn remove_order_and_repeats_do_not_change_attribute_update_identity() {
773 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
774 let baseline = semantic_commit_fingerprint(
775 &namespace_id,
776 &test_actor(),
777 None,
778 None,
779 &[update_attributes([], ["a", "b"], None, None)],
780 &[],
781 &BTreeSet::new(),
782 )
783 .expect("baseline");
784
785 for spelling in [vec!["b", "a"], vec!["a", "b", "a"]] {
786 assert_eq!(
787 semantic_commit_fingerprint(
788 &namespace_id,
789 &test_actor(),
790 None,
791 None,
792 &[update_attributes([], spelling, None, None)],
793 &[],
794 &BTreeSet::new()
795 )
796 .expect("variant"),
797 baseline
798 );
799 }
800 }
801
802 #[test]
803 fn attribute_update_fingerprint_changes_with_every_request_field() {
804 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
805 let baseline = semantic_commit_fingerprint(
806 &namespace_id,
807 &test_actor(),
808 None,
809 None,
810 &[update_attributes(
811 [("owner", text("ada"))],
812 ["draft"],
813 None,
814 None,
815 )],
816 &[],
817 &BTreeSet::new(),
818 )
819 .expect("baseline");
820
821 for (label, variant) in [
822 (
823 "set value",
824 update_attributes([("owner", text("grace"))], ["draft"], None, None),
825 ),
826 (
827 "removed key",
828 update_attributes([("owner", text("ada"))], ["final"], None, None),
829 ),
830 (
831 "expected inode",
832 update_attributes([("owner", text("ada"))], ["draft"], Some(InodeId(42)), None),
833 ),
834 (
835 "expected attribute revision",
836 update_attributes(
837 [("owner", text("ada"))],
838 ["draft"],
839 None,
840 Some(AttributeRevisionNo(0)),
841 ),
842 ),
843 ] {
844 assert_ne!(
845 baseline,
846 semantic_commit_fingerprint(
847 &namespace_id,
848 &test_actor(),
849 None,
850 None,
851 &[variant],
852 &[],
853 &BTreeSet::new()
854 )
855 .expect("variant fingerprint"),
856 "a changed {label} must change the fingerprint"
857 );
858 }
859 }
860
861 #[test]
862 fn access_update_fingerprint_changes_with_every_request_field() {
863 let baseline = serde_json::json!({
864 "kind": "update_access",
865 "path": "/docs/secret",
866 "boundary": true,
867 "grants": {"prn_ada": ["read", "write"]},
868 "expected_inode_id": "ino_9",
869 "expected_access_revision_no": 2
870 });
871 let baseline_fingerprint =
872 fingerprint(serde_json::from_value(baseline.clone()).expect("operation"));
873 for (field, value) in [
874 ("path", serde_json::json!("/docs/other")),
875 ("boundary", serde_json::json!(false)),
876 ("grants", serde_json::json!({"prn_ada": ["read"]})),
877 ("expected_inode_id", serde_json::json!("ino_10")),
878 ("expected_access_revision_no", serde_json::json!(3)),
879 ] {
880 let mut variant = baseline.clone();
881 variant[field] = value;
882 assert_ne!(
883 baseline_fingerprint,
884 fingerprint(serde_json::from_value(variant).expect("variant")),
885 "a changed {field} must change the fingerprint"
886 );
887 }
888 }
889
890 #[test]
891 fn binding_generation_changes_inode_mutation_identity() {
892 let namespace_id = NamespaceId::parse("demo").expect("namespace id");
893 let operation = |expected_binding_generation: &str| FilesystemOperation::MoveByInode {
894 inode_id: InodeId(42),
895 expected_binding_generation: crate::BindingGeneration::parse(
896 expected_binding_generation,
897 )
898 .expect("binding generation"),
899 destination_parent_inode_id: InodeId(7),
900 destination_display_name: DisplayName::parse("report.txt").expect("display name"),
901 precondition: crate::DestinationPrecondition {
902 behavior: DestinationBehavior::NoReplace,
903 expected_inode_id: None,
904 expected_revision_no: None,
905 },
906 };
907
908 let fingerprint = |generation| {
909 semantic_commit_fingerprint(
910 &namespace_id,
911 &test_actor(),
912 None,
913 None,
914 &[operation(generation)],
915 &[],
916 &BTreeSet::new(),
917 )
918 .expect("fingerprint")
919 };
920
921 assert_ne!(fingerprint("aaaa"), fingerprint("bbbb"));
922 }
923
924 #[test]
925 fn changed_actor_id_changes_the_fingerprint() {
926 let namespace_id = NamespaceId::parse("demo").expect("namespace id");
927 let operation = create_dir("/docs");
928 let actor_x = ActorId::parse("x").expect("actor id");
929 let actor_y = ActorId::parse("y").expect("actor id");
930
931 let fingerprint = |actor: &ActorId| {
932 semantic_commit_fingerprint(
933 &namespace_id,
934 actor,
935 None,
936 None,
937 std::slice::from_ref(&operation),
938 &[],
939 &BTreeSet::new(),
940 )
941 .expect("fingerprint")
942 };
943 assert_ne!(fingerprint(&actor_x), fingerprint(&actor_y));
944 }
945
946 #[test]
947 fn changed_subject_id_changes_the_fingerprint() {
948 let namespace_id = NamespaceId::parse("demo").expect("namespace id");
949 let operation = create_dir("/docs");
950 let subject_x = SubjectId::parse("x").expect("subject id");
951 let subject_y = SubjectId::parse("y").expect("subject id");
952 let fingerprint = |subject_id| {
953 semantic_commit_fingerprint(
954 &namespace_id,
955 &test_actor(),
956 subject_id,
957 None,
958 std::slice::from_ref(&operation),
959 &[],
960 &BTreeSet::new(),
961 )
962 .expect("fingerprint")
963 };
964 assert_ne!(fingerprint(Some(&subject_x)), fingerprint(Some(&subject_y)));
965 assert_ne!(fingerprint(None), fingerprint(Some(&subject_x)));
966 }
967
968 #[test]
969 fn put_file_preconditions_change_the_fingerprint_deterministically() {
970 let content_ref = ContentRef::blob_v1(
971 crate::NamespaceId::parse("demo").expect("namespace id"),
972 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
973 b"put bytes",
974 );
975 let operation = |expected_inode_id, expected_revision_no| FilesystemOperation::PutFile {
976 path: AbsolutePath::parse("/docs/report.txt").expect("path"),
977 content_ref: Some(content_ref.clone()),
978 inline_content: None,
979 behavior: DestinationBehavior::Replace,
980 expected_inode_id,
981 expected_revision_no,
982 };
983
984 let without_preconditions = fingerprint(operation(None, None));
985 let inode_only = fingerprint(operation(Some(InodeId(7)), None));
986 let first_revision = fingerprint(operation(Some(InodeId(7)), Some(RevisionNo(3))));
987 let next_revision = fingerprint(operation(Some(InodeId(7)), Some(RevisionNo(4))));
988
989 assert_ne!(without_preconditions, inode_only);
990 assert_ne!(inode_only, first_revision);
991 assert_ne!(first_revision, next_revision);
992 assert_eq!(
993 first_revision,
994 fingerprint(operation(Some(InodeId(7)), Some(RevisionNo(3))))
995 );
996 }
997
998 fn assert_destination_preconditions_change_fingerprint(
999 operation: impl Fn(Option<InodeId>, Option<RevisionNo>) -> FilesystemOperation,
1000 ) {
1001 let without_preconditions = fingerprint(operation(None, None));
1002 let first_inode = fingerprint(operation(Some(InodeId(7)), None));
1003 let other_inode = fingerprint(operation(Some(InodeId(8)), None));
1004 let first_revision = fingerprint(operation(Some(InodeId(7)), Some(RevisionNo(3))));
1005 let other_revision = fingerprint(operation(Some(InodeId(7)), Some(RevisionNo(4))));
1006
1007 assert_ne!(without_preconditions, first_inode);
1008 assert_ne!(first_inode, other_inode);
1009 assert_ne!(first_inode, first_revision);
1010 assert_ne!(first_revision, other_revision);
1011 assert_eq!(
1012 first_revision,
1013 fingerprint(operation(Some(InodeId(7)), Some(RevisionNo(3))))
1014 );
1015 }
1016
1017 #[test]
1018 fn move_and_copy_destination_preconditions_change_the_fingerprint_deterministically() {
1019 assert_destination_preconditions_change_fingerprint(|inode_id, revision_no| {
1020 FilesystemOperation::MovePath {
1021 source_path: AbsolutePath::parse("/docs/source.txt").expect("path"),
1022 destination_path: AbsolutePath::parse("/docs/destination.txt").expect("path"),
1023 precondition: crate::DestinationPrecondition {
1024 behavior: DestinationBehavior::Replace,
1025 expected_inode_id: inode_id,
1026 expected_revision_no: revision_no,
1027 },
1028 }
1029 });
1030 assert_destination_preconditions_change_fingerprint(|inode_id, revision_no| {
1031 FilesystemOperation::CopyPath {
1032 source_path: AbsolutePath::parse("/docs/source.txt").expect("path"),
1033 destination_path: AbsolutePath::parse("/docs/destination.txt").expect("path"),
1034 precondition: crate::DestinationPrecondition {
1035 behavior: DestinationBehavior::Replace,
1036 expected_inode_id: inode_id,
1037 expected_revision_no: revision_no,
1038 },
1039 }
1040 });
1041 }
1042
1043 #[test]
1044 fn a_put_has_the_pinned_fingerprint_under_every_checksum_algorithm() {
1045 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
1046 let content_id =
1047 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id");
1048 let bytes = b"pinned put bytes";
1049
1050 for content_ref in [
1051 ContentRef::blob_v1(
1052 crate::NamespaceId::parse("demo").expect("namespace id"),
1053 content_id.clone(),
1054 bytes,
1055 ),
1056 ContentRef {
1057 kind: ContentRefKind::BlobV1,
1058 owner_namespace_id: crate::NamespaceId::parse("demo").expect("namespace id"),
1059 content_id: content_id.clone(),
1060 size_bytes: bytes.len() as u64,
1061 checksum: Checksum::crc32c(bytes),
1062 },
1063 ContentRef {
1064 kind: ContentRefKind::BlobV1,
1065 owner_namespace_id: crate::NamespaceId::parse("demo").expect("namespace id"),
1066 content_id: content_id.clone(),
1067 size_bytes: bytes.len() as u64,
1068 checksum: Checksum::crc64nvme(bytes),
1069 },
1070 ] {
1071 assert_eq!(
1072 semantic_commit_fingerprint(
1073 &namespace_id,
1074 &test_actor(),
1075 None,
1076 None,
1077 &[put("/docs/report.txt", content_ref)],
1078 &[],
1079 &BTreeSet::new()
1080 )
1081 .expect("retry fingerprint")
1082 .as_str(),
1083 "v1:sha256:713de4c58dac816a19e7fb4439074b8103d1bf377f08176027cf39eaa7dc3d2d"
1084 );
1085 }
1086 }
1087
1088 fn create_dir(path: &str) -> FilesystemOperation {
1089 FilesystemOperation::CreateDirectory {
1090 path: AbsolutePath::parse(path).expect("path"),
1091 parents: false,
1092 }
1093 }
1094
1095 fn put(path: &str, content_ref: ContentRef) -> FilesystemOperation {
1096 FilesystemOperation::PutFile {
1097 path: AbsolutePath::parse(path).expect("path"),
1098 content_ref: Some(content_ref),
1099 inline_content: None,
1100 behavior: DestinationBehavior::NoReplace,
1101 expected_inode_id: None,
1102 expected_revision_no: None,
1103 }
1104 }
1105
1106 #[test]
1107 fn a_different_content_object_changes_mutation_identity() {
1108 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
1109 let bytes = b"identical bytes, two uploads";
1110 let first = ContentRef::blob_v1(
1111 crate::NamespaceId::parse("demo").expect("namespace id"),
1112 ContentId::generate(),
1113 bytes,
1114 );
1115 let second = ContentRef::blob_v1(
1116 crate::NamespaceId::parse("demo").expect("namespace id"),
1117 ContentId::generate(),
1118 bytes,
1119 );
1120
1121 assert_ne!(
1122 semantic_commit_fingerprint(
1123 &namespace_id,
1124 &test_actor(),
1125 None,
1126 None,
1127 &[put("/docs/report.txt", first)],
1128 &[],
1129 &BTreeSet::new()
1130 )
1131 .expect("fingerprint"),
1132 semantic_commit_fingerprint(
1133 &namespace_id,
1134 &test_actor(),
1135 None,
1136 None,
1137 &[put("/docs/report.txt", second)],
1138 &[],
1139 &BTreeSet::new()
1140 )
1141 .expect("fingerprint")
1142 );
1143 }
1144
1145 #[test]
1146 fn a_message_changes_mutation_identity() {
1147 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
1151 let without = semantic_commit_fingerprint(
1152 &namespace_id,
1153 &test_actor(),
1154 None,
1155 None,
1156 &[create_dir("/docs")],
1157 &[],
1158 &BTreeSet::new(),
1159 )
1160 .expect("fingerprint");
1161 let with = semantic_commit_fingerprint(
1162 &namespace_id,
1163 &test_actor(),
1164 None,
1165 Some("import batch"),
1166 &[create_dir("/docs")],
1167 &[],
1168 &BTreeSet::new(),
1169 )
1170 .expect("fingerprint");
1171
1172 assert_ne!(without, with);
1173 }
1174
1175 #[test]
1176 fn operation_order_changes_mutation_identity() {
1177 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
1178
1179 assert_ne!(
1180 semantic_commit_fingerprint(
1181 &namespace_id,
1182 &test_actor(),
1183 None,
1184 None,
1185 &[create_dir("/a"), create_dir("/b")],
1186 &[],
1187 &BTreeSet::new()
1188 )
1189 .expect("forward fingerprint"),
1190 semantic_commit_fingerprint(
1191 &namespace_id,
1192 &test_actor(),
1193 None,
1194 None,
1195 &[create_dir("/b"), create_dir("/a")],
1196 &[],
1197 &BTreeSet::new()
1198 )
1199 .expect("reversed fingerprint")
1200 );
1201 }
1202
1203 #[test]
1204 fn put_fingerprint_changes_with_every_request_field() {
1205 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
1206 let path = AbsolutePath::parse("/a.txt").expect("path");
1207 let content_ref = ContentRef::blob_v1(
1208 crate::NamespaceId::parse("demo").expect("namespace id"),
1209 ContentId::generate(),
1210 b"hello",
1211 );
1212 let mut options = PutFileOptions::new(test_actor());
1213 options.behavior = DestinationBehavior::Replace;
1214 let fingerprint =
1215 |namespace_id: &NamespaceId, path: &AbsolutePath, options: &PutFileOptions| {
1216 semantic_commit_fingerprint(
1217 namespace_id,
1218 &options.commit.actor_id,
1219 None,
1220 options.commit.message.as_deref(),
1221 &[FilesystemOperation::PutFile {
1222 path: path.clone(),
1223 content_ref: Some(content_ref.clone()),
1224 inline_content: None,
1225 behavior: options.behavior,
1226 expected_inode_id: options.expected_inode_id,
1227 expected_revision_no: options.expected_revision_no,
1228 }],
1229 &[],
1230 &BTreeSet::new(),
1231 )
1232 .expect("retry fingerprint")
1233 };
1234 let baseline = fingerprint(&namespace_id, &path, &options);
1235
1236 let mut changed_behavior = options.clone();
1237 changed_behavior.behavior = DestinationBehavior::NoReplace;
1238 let mut changed_inode = options.clone();
1239 changed_inode.expected_inode_id = Some(InodeId(2));
1240 let mut changed_revision = options.clone();
1241 changed_revision.expected_inode_id = Some(InodeId(2));
1242 changed_revision.expected_revision_no = Some(RevisionNo(2));
1243 let mut changed_message = options.clone();
1244 changed_message.commit.message = Some(String::new());
1245
1246 for (label, fingerprint) in [
1247 (
1248 "path",
1249 fingerprint(
1250 &namespace_id,
1251 &AbsolutePath::parse("/b.txt").expect("path"),
1252 &options,
1253 ),
1254 ),
1255 (
1256 "behavior",
1257 fingerprint(&namespace_id, &path, &changed_behavior),
1258 ),
1259 (
1260 "expected inode",
1261 fingerprint(&namespace_id, &path, &changed_inode),
1262 ),
1263 (
1264 "expected revision",
1265 fingerprint(&namespace_id, &path, &changed_revision),
1266 ),
1267 (
1268 "message",
1269 fingerprint(&namespace_id, &path, &changed_message),
1270 ),
1271 (
1272 "namespace",
1273 fingerprint(
1274 &NamespaceId::parse("other").expect("valid namespace id"),
1275 &path,
1276 &options,
1277 ),
1278 ),
1279 ] {
1280 assert_ne!(
1281 baseline, fingerprint,
1282 "changed {label} must change identity"
1283 );
1284 }
1285 }
1286}