Skip to main content

loonfs_api/
commit_identity.rs

1//! Generates stable fingerprints for filesystem mutations (format spec,
2//! "Semantic commit fingerprints"). A fingerprint lets LoonFS determine
3//! whether two requests that use the same commit ID describe the same
4//! mutation.
5//!
6//! The publisher stores this fingerprint in the commit receipt and compares
7//! it when the same commit ID is submitted again.
8//!
9//! The commit ID is not part of the fingerprint input. The commit ID selects
10//! a receipt, while the fingerprint describes the mutation stored in that
11//! receipt.
12
13use 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
25/// Domain separator included in every mutation fingerprint input.
26const COMMIT_FINGERPRINT_DOMAIN: &str = "loonfs.commit.semantic.v1";
27
28/// Format version and hash algorithm stored with each fingerprint.
29///
30/// Storing both values lets a later format use different encoding rules or a
31/// different hash without changing existing fingerprints.
32const FINGERPRINT_SCHEME: &str = "v1:sha256";
33
34/// The semantic identity of one mutation request.
35#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
36pub struct CommitFingerprint(String);
37
38impl CommitFingerprint {
39    /// Returns the stored fingerprint string.
40    pub fn as_str(&self) -> &str {
41        &self.0
42    }
43}
44
45/// A request cannot be represented by the version 1 fingerprint scheme.
46#[derive(Debug, Error)]
47#[non_exhaustive]
48pub enum SemanticFingerprintError {
49    /// An operation supplies both content sources or neither.
50    #[error("exactly one of `content_ref` and `inline_content` is required")]
51    InvalidContentSource,
52    /// Canonical JSON encoding failed.
53    #[error("failed to encode the commit fingerprint preimage: {0}")]
54    Encode(#[from] serde_json::Error),
55    /// Inline identity requires a SHA-256 checksum.
56    #[error("inline content `{content_id}` requires `sha256`, found `{actual_algorithm}`")]
57    InlineChecksumAlgorithm {
58        /// Content whose reference cannot supply the digest.
59        content_id: ContentId,
60        /// Algorithm present on the reference.
61        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/// Canonical preimage for one operation inside a mutation fingerprint.
74///
75/// The serde representation is durable contract (format spec, "Semantic
76/// commit fingerprints"): the same normalized request must fingerprint
77/// identically across releases. A pinned-value test below fails if the
78/// encoding drifts.
79///
80/// The serialized variant names, the field names, and the field order below
81/// are all part of that preimage under the [`COMMIT_FINGERPRINT_DOMAIN`] tag.
82/// Operation and field names follow [`FilesystemOperation`]. Optional fields
83/// are always present, using `null` when unset. This explicit representation
84/// keeps request serialization defaults and transport details out of identity.
85#[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    // Identity covers the complete caller-visible logical request. A changed
128    // delete precondition must conflict instead of replaying the old receipt
129    // without checking the new precondition.
130    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    // Both preconditions join the preimage for the same reason the delete precondition
159    // does: a changed expectation is a different logical request. `set` is a
160    // map, so it serializes key-ordered whatever order the caller sent; the
161    // translation below sorts and deduplicates `remove` so two spellings of
162    // one removal set reach the same preimage.
163    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/// Canonical preimage for the content a put attaches.
255///
256/// For the reference form, identity is *which object*, so the id and its
257/// length are the whole of it. The checksum is evidence about those bytes,
258/// pinned to the id by the verification every write and read already performs,
259/// and it is left out deliberately: a reference that named the same object
260/// with a differently spelled checksum would otherwise read as a different mutation.
261///
262/// For that form, a retry that re-runs the whole operation, upload included,
263/// creates a new content object, so it is a different request and a reused
264/// commit id conflicts. Retrying a commit means sending the same `ContentRef`
265/// again, which replays, rather than uploading the bytes again.
266///
267/// Inline content has no object to name, so its bytes identify it. The form
268/// follows how the request supplied the content, never where the bytes end up.
269#[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
322/// Normalizes one operation into its durable semantic representation.
323/// Attribute removals are a sorted set; content checksums are verification
324/// evidence and excluded from identity in the reference form.
325fn 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            // The wire type preserves the caller's list so validation can
468            // report duplicate keys. The fingerprint uses the sorted, unique
469            // set because order and duplicate entries do not change the
470            // requested mutation.
471            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
510/// Computes the semantic fingerprint used to validate a reused commit ID.
511///
512/// A single-operation helper and a one-item batch produce the same input and
513/// therefore the same fingerprint.
514///
515/// IDs in `inline_content_ids` use the SHA-256 checksum from their reference
516/// as content identity. Other references keep their object identity.
517pub 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        // The annotation is part of what the caller asked for: replaying a
1148        // commit id with a different message must conflict, so the message
1149        // joins the preimage.
1150        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}