Skip to main content

loonfs_api/
commit_identity.rs

1//! Generates stable fingerprints for filesystem mutations (format spec,
2//! "Commit identity fingerprints"). A fingerprint lets LoonFS determine
3//! whether two requests that use the same commit ID describe the same
4//! mutation.
5//!
6//! The runtime and HTTP client use the functions in this module so that they
7//! apply the same identity rules. The runtime stores a fingerprint in the
8//! commit receipt. A client can later recompute it when retrying a request.
9//!
10//! The commit ID is not part of the fingerprint input. The commit ID selects
11//! a receipt, while the fingerprint describes the mutation stored in that
12//! receipt.
13
14use crate::{
15    AbsolutePath, ActorKind, ActorRef, AttributeRevisionNo, ChangeSeq, CommitId, ContentEvidence,
16    ContentRef, DeleteDirectoryBehavior, DestinationBehavior, FilesystemOperation, InodeId,
17    NamespaceId, RevisionNo,
18};
19use serde::Serialize;
20use sha2::{Digest, Sha256};
21use std::collections::BTreeMap;
22use std::future::Future;
23use thiserror::Error;
24
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/// Error returned when the canonical fingerprint input cannot be encoded.
35///
36/// The input contains validated types, so this error indicates an internal
37/// encoding bug rather than invalid caller data.
38#[derive(Debug, Error)]
39#[error("failed to encode the commit fingerprint preimage: {0}")]
40pub struct SemanticFingerprintError(#[from] serde_json::Error);
41
42/// Encodes a canonical input and returns its stored fingerprint.
43///
44/// The result has the form `v1:sha256:<64 lowercase hex>`. Compact JSON is
45/// part of the durable format, so fixed-value tests detect encoding changes.
46fn fingerprint_digest<T>(preimage: &T) -> Result<String, SemanticFingerprintError>
47where
48    T: Serialize,
49{
50    let bytes = serde_json::to_vec(preimage)?;
51    Ok(fingerprint_bytes(&bytes))
52}
53
54fn fingerprint_bytes(bytes: &[u8]) -> String {
55    let digest = Sha256::digest(bytes);
56    format!(
57        "{FINGERPRINT_SCHEME}:{}",
58        crate::hex::hex_encode_bytes(&digest)
59    )
60}
61
62/// Canonical preimage for one operation inside a mutation fingerprint.
63///
64/// The serde representation is durable contract (format spec, "Commit
65/// identity fingerprints"): the same normalized request must fingerprint
66/// identically across releases. A pinned-value test below fails if the
67/// encoding drifts.
68///
69/// The variant names, the field names, and the field order below are all part
70/// of that preimage under the [`COMMIT_FINGERPRINT_DOMAIN`] tag, and none of
71/// them tracks the wire enum. They deliberately differ from it — `CreateDir`
72/// against the wire's `CreateDirectory`, `absolute_path` against its `path`,
73/// `behavior` ahead of `content_ref` in the put — because renaming a wire
74/// field must not silently restate every already-published commit's identity.
75/// [`operation_fingerprint_input`] is the one place the wire spelling is
76/// translated into this one; nothing else may name these variants. Change any
77/// of it and every stored fingerprint disagrees with its recomputed value,
78/// which the pinned tests below exist to catch.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80#[serde(tag = "kind", rename_all = "snake_case")]
81enum OperationFingerprintInput<'a> {
82    CreateDir {
83        absolute_path: &'a str,
84        parents: bool,
85    },
86    // The put guard joins the preimage for the same reason as the delete
87    // guard below: a changed expected revision is a different logical
88    // request and must conflict rather than replay a receipt.
89    PutFile {
90        absolute_path: &'a str,
91        behavior: DestinationBehavior,
92        content_ref: ContentRefFingerprintInput<'a>,
93        expected_revision_no: Option<RevisionNo>,
94    },
95    CreateDirByInode {
96        parent_inode_id: InodeId,
97        display_name: &'a str,
98    },
99    PutFileByInode {
100        parent_inode_id: InodeId,
101        display_name: &'a str,
102        content_ref: ContentRefFingerprintInput<'a>,
103    },
104    PutFileRevisionByInode {
105        inode_id: InodeId,
106        content_ref: ContentRefFingerprintInput<'a>,
107        expected_revision_no: RevisionNo,
108    },
109    MoveByInode {
110        inode_id: InodeId,
111        expected_binding_generation: &'a str,
112        to_parent_inode_id: InodeId,
113        to_display_name: &'a str,
114        behavior: DestinationBehavior,
115    },
116    DeleteByInode {
117        inode_id: InodeId,
118        expected_binding_generation: &'a str,
119        behavior: DeleteDirectoryBehavior,
120    },
121    // Identity covers the complete caller-visible logical request. A changed
122    // delete guard must conflict instead of replaying the old receipt
123    // without checking the new guard.
124    DeletePath {
125        absolute_path: &'a str,
126        behavior: DeleteDirectoryBehavior,
127        expected_inode_id: Option<InodeId>,
128    },
129    MovePath {
130        from_path: &'a str,
131        to_path: &'a str,
132        behavior: DestinationBehavior,
133    },
134    CopyFilePath {
135        from_path: &'a str,
136        to_path: &'a str,
137        behavior: DestinationBehavior,
138    },
139    RestoreRevision {
140        absolute_path: &'a str,
141        source_revision_no: RevisionNo,
142    },
143    Undelete {
144        inode_id: InodeId,
145        deleted_at_seq: ChangeSeq,
146        // Preimage-additive: `Some` serializes as the bare string it always
147        // was, so every stored undelete fingerprint is unchanged; `None`
148        // serializes as `null`, a new distinct preimage for the in-place
149        // form. Both shapes are pinned below.
150        absolute_path: Option<&'a str>,
151    },
152    // Both guards join the preimage for the same reason the delete guard
153    // does: a changed expectation is a different logical request. `set` is a
154    // map, so it serializes key-ordered whatever order the caller sent; the
155    // translation below sorts and deduplicates `remove` so two spellings of
156    // one removal set reach the same preimage.
157    UpdateAttrs {
158        absolute_path: &'a str,
159        set: BTreeMap<&'a str, &'a str>,
160        remove: Vec<&'a str>,
161        expected_inode_id: Option<InodeId>,
162        expected_attributes_revision_no: Option<AttributeRevisionNo>,
163    },
164}
165
166/// Canonical preimage for the content a put attaches.
167///
168/// Identity is *which object*, so the id and its length are the whole of it.
169/// The checksum is evidence about those bytes, pinned to the id by the
170/// verification every write and read already performs, and it is left out
171/// deliberately: a reference that named the same object with a differently
172/// spelled checksum would otherwise read as a different mutation.
173///
174/// The consequence is worth stating plainly. A retry that re-runs the whole
175/// operation, upload included, mints a new content object, so it is a
176/// different request and a reused commit id conflicts. Retrying a commit
177/// means sending the same `ContentRef` again — which replays — not uploading
178/// the bytes again.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
180struct ContentRefFingerprintInput<'a> {
181    kind: &'a str,
182    content_id: &'a str,
183    size_bytes: u64,
184}
185
186fn content_ref_fingerprint_input(content_ref: &ContentRef) -> ContentRefFingerprintInput<'_> {
187    ContentRefFingerprintInput {
188        kind: content_ref.kind.as_str(),
189        content_id: content_ref.content_id.as_str(),
190        size_bytes: content_ref.size_bytes,
191    }
192}
193
194/// Renames one wire operation into its durable preimage.
195///
196/// This is the whole of the wire-to-fingerprint translation. The left side
197/// follows [`FilesystemOperation`] and may be renamed with it; the right side
198/// is frozen (see [`OperationFingerprintInput`]).
199fn operation_fingerprint_input(operation: &FilesystemOperation) -> OperationFingerprintInput<'_> {
200    match operation {
201        FilesystemOperation::CreateDirectory { path, parents } => {
202            OperationFingerprintInput::CreateDir {
203                absolute_path: path.as_str(),
204                parents: *parents,
205            }
206        }
207        FilesystemOperation::PutFile {
208            path,
209            content_ref,
210            behavior,
211            expected_revision_no,
212        } => OperationFingerprintInput::PutFile {
213            absolute_path: path.as_str(),
214            behavior: *behavior,
215            content_ref: content_ref_fingerprint_input(content_ref),
216            expected_revision_no: *expected_revision_no,
217        },
218        FilesystemOperation::CreateDirectoryByInode {
219            parent_inode_id,
220            display_name,
221        } => OperationFingerprintInput::CreateDirByInode {
222            parent_inode_id: *parent_inode_id,
223            display_name: display_name.as_str(),
224        },
225        FilesystemOperation::PutFileByInode {
226            parent_inode_id,
227            display_name,
228            content_ref,
229        } => OperationFingerprintInput::PutFileByInode {
230            parent_inode_id: *parent_inode_id,
231            display_name: display_name.as_str(),
232            content_ref: content_ref_fingerprint_input(content_ref),
233        },
234        FilesystemOperation::PutFileRevisionByInode {
235            inode_id,
236            content_ref,
237            expected_revision_no,
238        } => OperationFingerprintInput::PutFileRevisionByInode {
239            inode_id: *inode_id,
240            content_ref: content_ref_fingerprint_input(content_ref),
241            expected_revision_no: *expected_revision_no,
242        },
243        FilesystemOperation::MoveByInode {
244            inode_id,
245            expected_binding_generation,
246            to_parent_inode_id,
247            to_display_name,
248            behavior,
249        } => OperationFingerprintInput::MoveByInode {
250            inode_id: *inode_id,
251            expected_binding_generation,
252            to_parent_inode_id: *to_parent_inode_id,
253            to_display_name: to_display_name.as_str(),
254            behavior: *behavior,
255        },
256        FilesystemOperation::DeleteByInode {
257            inode_id,
258            expected_binding_generation,
259            behavior,
260        } => OperationFingerprintInput::DeleteByInode {
261            inode_id: *inode_id,
262            expected_binding_generation,
263            behavior: *behavior,
264        },
265        FilesystemOperation::DeletePath {
266            path,
267            behavior,
268            expected_inode_id,
269        } => OperationFingerprintInput::DeletePath {
270            absolute_path: path.as_str(),
271            behavior: *behavior,
272            expected_inode_id: *expected_inode_id,
273        },
274        FilesystemOperation::MovePath {
275            from_path,
276            to_path,
277            behavior,
278        } => OperationFingerprintInput::MovePath {
279            from_path: from_path.as_str(),
280            to_path: to_path.as_str(),
281            behavior: *behavior,
282        },
283        FilesystemOperation::CopyPath {
284            from_path,
285            to_path,
286            behavior,
287        } => OperationFingerprintInput::CopyFilePath {
288            from_path: from_path.as_str(),
289            to_path: to_path.as_str(),
290            behavior: *behavior,
291        },
292        FilesystemOperation::RestoreRevision {
293            path,
294            source_revision_no,
295        } => OperationFingerprintInput::RestoreRevision {
296            absolute_path: path.as_str(),
297            source_revision_no: *source_revision_no,
298        },
299        FilesystemOperation::Undelete {
300            inode_id,
301            deletion_seq,
302            path,
303        } => OperationFingerprintInput::Undelete {
304            inode_id: *inode_id,
305            deleted_at_seq: *deletion_seq,
306            absolute_path: path.as_ref().map(AbsolutePath::as_str),
307        },
308        FilesystemOperation::UpdateAttributes {
309            path,
310            set,
311            remove,
312            expected_inode_id,
313            expected_attributes_revision_no,
314        } => {
315            // The wire type preserves the caller's list so validation can
316            // report duplicate keys. The fingerprint uses the sorted, unique
317            // set because order and duplicate entries do not change the
318            // requested mutation.
319            let mut remove: Vec<&str> = remove.iter().map(|key| key.as_str()).collect();
320            remove.sort_unstable();
321            remove.dedup();
322            OperationFingerprintInput::UpdateAttrs {
323                absolute_path: path.as_str(),
324                set: set
325                    .iter()
326                    .map(|(key, value)| (key.as_str(), value.as_str()))
327                    .collect(),
328                remove,
329                expected_inode_id: *expected_inode_id,
330                expected_attributes_revision_no: *expected_attributes_revision_no,
331            }
332        }
333    }
334}
335
336/// Computes the semantic fingerprint used to validate a reused commit ID.
337///
338/// A single-operation helper and a one-item batch produce the same input and
339/// therefore the same fingerprint.
340pub fn semantic_commit_fingerprint(
341    namespace_id: &NamespaceId,
342    actor: &ActorRef,
343    message: Option<&str>,
344    operations: &[FilesystemOperation],
345) -> Result<String, SemanticFingerprintError> {
346    #[derive(Serialize)]
347    struct CanonicalCommit<'a> {
348        domain: &'static str,
349        namespace_id: &'a str,
350        actor_kind: ActorKind,
351        actor_id: &'a str,
352        operations: Vec<OperationFingerprintInput<'a>>,
353        message: Option<&'a str>,
354    }
355
356    fingerprint_digest(&CanonicalCommit {
357        domain: COMMIT_FINGERPRINT_DOMAIN,
358        namespace_id: namespace_id.as_str(),
359        actor_kind: actor.kind,
360        actor_id: actor.id.as_str(),
361        operations: operations.iter().map(operation_fingerprint_input).collect(),
362        message,
363    })
364}
365
366/// Computes the fingerprint for a retried single-file PUT using the content
367/// reference from the original commit.
368///
369/// Retrying an upload creates a new content object, so its content ID differs
370/// from the ID stored by the original commit. This function substitutes the
371/// original content reference before computing the fingerprint. The path,
372/// destination behavior, expected revision, message, and operation count must
373/// still match. The caller must separately verify that both content objects
374/// contain the same bytes.
375pub fn put_retry_fingerprint(
376    namespace_id: &NamespaceId,
377    actor: &ActorRef,
378    path: &AbsolutePath,
379    behavior: DestinationBehavior,
380    expected_revision_no: Option<RevisionNo>,
381    message: Option<&str>,
382    committed_content_ref: &ContentRef,
383) -> Result<String, SemanticFingerprintError> {
384    let operation = FilesystemOperation::PutFile {
385        path: path.clone(),
386        content_ref: committed_content_ref.clone(),
387        behavior,
388        expected_revision_no,
389    };
390    semantic_commit_fingerprint(
391        namespace_id,
392        actor,
393        message,
394        std::slice::from_ref(&operation),
395    )
396}
397
398/// Receipt data needed to verify a PUT that reused a commit ID.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct PutRetryReceipt {
401    /// Sequence number assigned to the original commit.
402    pub committed_seq: ChangeSeq,
403    /// Semantic fingerprint stored in the original commit receipt.
404    pub committed_fingerprint: String,
405}
406
407/// Classification of an error encountered while verifying a retried PUT.
408#[derive(Debug, Clone, PartialEq, Eq)]
409#[non_exhaustive]
410pub enum PutRetryErrorClassification {
411    /// The commit ID was already used. The receipt is included when available.
412    CommitIdReuseConflict(Option<PutRetryReceipt>),
413    /// Retention removed the change record needed to verify the retry.
414    RebootstrapRequired,
415    /// Any error that does not have special handling during retry verification.
416    Other,
417}
418
419/// Details of the retried PUT being compared with an existing receipt.
420#[derive(Debug, Clone, Copy)]
421pub struct PutRetryAttempt<'a> {
422    /// Namespace targeted by the PUT.
423    pub namespace_id: &'a NamespaceId,
424    /// Absolute path targeted by the PUT.
425    pub path: &'a AbsolutePath,
426    /// Commit ID that was already used.
427    pub commit_id: &'a CommitId,
428    /// PUT options supplied by the caller.
429    pub options: &'a crate::options::PutFileOptions,
430    /// Checksum or byte evidence for the new upload.
431    pub staged: ContentEvidence<'a>,
432}
433
434/// Checks whether a PUT rejected for commit-ID reuse is an exact retry of an
435/// earlier successful PUT.
436///
437/// `read_change` receives the change-feed sequence immediately before the
438/// sequence in the receipt. It must return a page containing at most the
439/// expected change.
440///
441/// The function returns the original commit response only when both the
442/// request fingerprint and the uploaded content match the original commit.
443/// It returns the original conflict when the receipt or change record is
444/// missing, the retained history is unavailable, or either comparison fails.
445/// Other errors from `read_change` are returned unchanged.
446pub async fn reconcile_put_commit_id_reuse<E, ReadChange, ReadChangeFuture, ClassifyError>(
447    attempt: PutRetryAttempt<'_>,
448    conflict: E,
449    read_change: ReadChange,
450    classify_error: ClassifyError,
451) -> Result<crate::v0::CommitResponse, E>
452where
453    ReadChange: FnOnce(ChangeSeq) -> ReadChangeFuture,
454    ReadChangeFuture: Future<Output = Result<crate::v0::ListChangesResponse, E>>,
455    ClassifyError: Fn(&E) -> PutRetryErrorClassification,
456{
457    let PutRetryErrorClassification::CommitIdReuseConflict(Some(receipt)) =
458        classify_error(&conflict)
459    else {
460        return Err(conflict);
461    };
462    let after_seq = ChangeSeq(receipt.committed_seq.0.saturating_sub(1));
463    let page = match read_change(after_seq).await {
464        Ok(page) => page,
465        Err(error)
466            if matches!(
467                classify_error(&error),
468                PutRetryErrorClassification::RebootstrapRequired
469            ) =>
470        {
471            return Err(conflict);
472        }
473        Err(error) => return Err(error),
474    };
475    let Some(committed) = page.changes.into_iter().find(|change| {
476        change.committed_seq == receipt.committed_seq && &change.commit_id == attempt.commit_id
477    }) else {
478        return Err(conflict);
479    };
480    let Some(content_ref) = sole_committed_content_ref(&committed) else {
481        return Err(conflict);
482    };
483    let retried = put_retry_fingerprint(
484        attempt.namespace_id,
485        &attempt.options.commit.actor,
486        attempt.path,
487        attempt.options.behavior,
488        attempt.options.expected_revision_no,
489        attempt.options.commit.message.as_deref(),
490        content_ref,
491    );
492    if retried.ok().as_deref() != Some(receipt.committed_fingerprint.as_str())
493        || !content_ref.matches_evidence(attempt.staged)
494    {
495        return Err(conflict);
496    }
497    Ok(crate::v0::CommitResponse::from_committed_change(
498        attempt.namespace_id.clone(),
499        committed,
500    ))
501}
502
503/// Returns the content reference when a committed change wrote exactly one
504/// file.
505fn sole_committed_content_ref(change: &crate::v0::CommittedChange) -> Option<&ContentRef> {
506    let mut content = change.events.iter().filter_map(|event| match event {
507        crate::v0::FilesystemChange::FileCreated { content_ref, .. }
508        | crate::v0::FilesystemChange::ContentChanged { content_ref, .. } => Some(content_ref),
509        _ => None,
510    });
511    let only = content.next()?;
512    content.next().is_none().then_some(only)
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::{
519        ActorId, AttributeKey, AttributeValue, Checksum, ContentId, ContentRefKind, DisplayName,
520    };
521
522    fn test_actor() -> ActorRef {
523        ActorRef::user(ActorId::parse("test-actor").expect("valid test actor id"))
524    }
525
526    fn attribute_key(value: &str) -> AttributeKey {
527        AttributeKey::parse(value).expect("valid attribute key")
528    }
529
530    fn text(value: &str) -> AttributeValue {
531        AttributeValue::parse(value).expect("valid attribute value")
532    }
533
534    fn update_attributes(
535        set: impl IntoIterator<Item = (&'static str, AttributeValue)>,
536        remove: impl IntoIterator<Item = &'static str>,
537        expected_inode_id: Option<InodeId>,
538        expected_attributes_revision_no: Option<AttributeRevisionNo>,
539    ) -> FilesystemOperation {
540        FilesystemOperation::UpdateAttributes {
541            path: AbsolutePath::parse("/docs/report.txt").expect("path"),
542            set: set
543                .into_iter()
544                .map(|(key, value)| (attribute_key(key), value))
545                .collect(),
546            remove: remove.into_iter().map(attribute_key).collect(),
547            expected_inode_id,
548            expected_attributes_revision_no,
549        }
550    }
551
552    #[test]
553    fn update_attributes_fingerprint_value_is_pinned() {
554        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
555
556        let fingerprint = semantic_commit_fingerprint(
557            &namespace_id,
558            &test_actor(),
559            None,
560            &[update_attributes(
561                [("owner", text("ada")), ("tags", text("a,b"))],
562                ["draft"],
563                Some(InodeId(42)),
564                Some(AttributeRevisionNo(3)),
565            )],
566        )
567        .expect("fingerprint");
568
569        assert_eq!(
570            fingerprint,
571            "v1:sha256:bc41940773fa7df87aaeecf44b2fbd8205071e15fcb81705887ff1de0a9582bb"
572        );
573    }
574
575    #[test]
576    fn json_map_order_does_not_change_attribute_update_identity() {
577        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
578        let forward: FilesystemOperation = serde_json::from_str(
579            r#"{"kind":"update_attributes","path":"/docs/report.txt",
580                "set":{"a":"1","b":"2"}}"#,
581        )
582        .expect("forward operation");
583        let reversed: FilesystemOperation = serde_json::from_str(
584            r#"{"kind":"update_attributes","path":"/docs/report.txt",
585                "set":{"b":"2","a":"1"}}"#,
586        )
587        .expect("reversed operation");
588
589        assert_eq!(
590            semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[forward])
591                .expect("forward"),
592            semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[reversed])
593                .expect("reversed")
594        );
595    }
596
597    #[test]
598    fn remove_order_and_repeats_do_not_change_attribute_update_identity() {
599        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
600        let baseline = semantic_commit_fingerprint(
601            &namespace_id,
602            &test_actor(),
603            None,
604            &[update_attributes([], ["a", "b"], None, None)],
605        )
606        .expect("baseline");
607
608        for spelling in [vec!["b", "a"], vec!["a", "b", "a"]] {
609            assert_eq!(
610                semantic_commit_fingerprint(
611                    &namespace_id,
612                    &test_actor(),
613                    None,
614                    &[update_attributes([], spelling, None, None)]
615                )
616                .expect("variant"),
617                baseline
618            );
619        }
620    }
621
622    #[test]
623    fn attribute_update_fingerprint_changes_with_every_request_field() {
624        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
625        let baseline = semantic_commit_fingerprint(
626            &namespace_id,
627            &test_actor(),
628            None,
629            &[update_attributes(
630                [("owner", text("ada"))],
631                ["draft"],
632                None,
633                None,
634            )],
635        )
636        .expect("baseline");
637
638        for (label, variant) in [
639            (
640                "set value",
641                update_attributes([("owner", text("grace"))], ["draft"], None, None),
642            ),
643            (
644                "removed key",
645                update_attributes([("owner", text("ada"))], ["final"], None, None),
646            ),
647            (
648                "expected inode",
649                update_attributes([("owner", text("ada"))], ["draft"], Some(InodeId(42)), None),
650            ),
651            (
652                "expected attribute revision",
653                update_attributes(
654                    [("owner", text("ada"))],
655                    ["draft"],
656                    None,
657                    Some(AttributeRevisionNo(0)),
658                ),
659            ),
660        ] {
661            assert_ne!(
662                baseline,
663                semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[variant])
664                    .expect("variant fingerprint"),
665                "a changed {label} must change the fingerprint"
666            );
667        }
668    }
669
670    #[test]
671    fn binding_generation_changes_inode_mutation_identity() {
672        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
673        let operation = |expected_binding_generation: &str| FilesystemOperation::MoveByInode {
674            inode_id: InodeId(42),
675            expected_binding_generation: expected_binding_generation.to_owned(),
676            to_parent_inode_id: InodeId(7),
677            to_display_name: DisplayName::parse("report.txt").expect("display name"),
678            behavior: DestinationBehavior::NoReplace,
679        };
680
681        let fingerprint = |generation| {
682            semantic_commit_fingerprint(
683                &namespace_id,
684                &test_actor(),
685                None,
686                &[operation(generation)],
687            )
688            .expect("fingerprint")
689        };
690
691        assert_ne!(fingerprint("generation-a"), fingerprint("generation-b"));
692    }
693
694    #[test]
695    fn commit_fingerprint_value_is_pinned() {
696        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
697
698        let fingerprint =
699            semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[create_dir("/docs")])
700                .expect("fingerprint");
701
702        assert_eq!(
703            fingerprint,
704            "v1:sha256:dc41318564ff5329c73ba2f1af338f24bd323be7a56305a2b9b94cb24b95ec5a"
705        );
706    }
707
708    #[test]
709    fn actor_kind_and_id_are_distinct_canonical_identity_fields() {
710        let namespace_id = NamespaceId::parse("demo").expect("namespace id");
711        let operation = create_dir("/docs");
712        let user_x = ActorRef::user(ActorId::parse("x").expect("actor id"));
713        let user_y = ActorRef::user(ActorId::parse("y").expect("actor id"));
714        let service_x = ActorRef::service(ActorId::parse("x").expect("actor id"));
715
716        let fingerprint = |actor: &ActorRef| {
717            semantic_commit_fingerprint(
718                &namespace_id,
719                actor,
720                None,
721                std::slice::from_ref(&operation),
722            )
723            .expect("fingerprint")
724        };
725        assert_ne!(fingerprint(&user_x), fingerprint(&user_y));
726        assert_ne!(fingerprint(&user_x), fingerprint(&service_x));
727    }
728
729    #[test]
730    fn guarded_delete_fingerprint_value_is_pinned() {
731        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
732
733        let fingerprint = semantic_commit_fingerprint(
734            &namespace_id,
735            &test_actor(),
736            None,
737            &[FilesystemOperation::DeletePath {
738                path: AbsolutePath::parse("/docs").expect("path"),
739                behavior: DeleteDirectoryBehavior::NonRecursive,
740                expected_inode_id: Some(InodeId(42)),
741            }],
742        )
743        .expect("fingerprint");
744
745        assert_eq!(
746            fingerprint,
747            "v1:sha256:bd1dc71c8b7e0b1e503dbf0925b801275088b6f2598888f893787688f1f01d0f"
748        );
749    }
750
751    #[test]
752    fn undelete_fingerprint_value_is_pinned() {
753        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
754
755        let fingerprint = semantic_commit_fingerprint(
756            &namespace_id,
757            &test_actor(),
758            None,
759            &[FilesystemOperation::Undelete {
760                inode_id: InodeId(42),
761                deletion_seq: ChangeSeq(17),
762                path: Some(AbsolutePath::parse("/docs/report.txt").expect("path")),
763            }],
764        )
765        .expect("fingerprint");
766
767        // The mechanism behind "did not move": a present option serializes
768        // as the bare value, so wrapping the preimage field changed no
769        // stored byte.
770        assert_eq!(
771            serde_json::to_value(Some("/docs/report.txt")).expect("serialize"),
772            serde_json::to_value("/docs/report.txt").expect("serialize"),
773        );
774        assert_eq!(
775            fingerprint,
776            "v1:sha256:9146c9e675a2e132bb16adb32d235f73080a3ef065cbd2f5c82ccb83aee02e57"
777        );
778    }
779
780    #[test]
781    fn in_place_undelete_fingerprint_value_is_pinned() {
782        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
783
784        let fingerprint = semantic_commit_fingerprint(
785            &namespace_id,
786            &test_actor(),
787            None,
788            &[FilesystemOperation::Undelete {
789                inode_id: InodeId(42),
790                deletion_seq: ChangeSeq(17),
791                path: None,
792            }],
793        )
794        .expect("fingerprint");
795
796        assert_eq!(
797            fingerprint,
798            "v1:sha256:52e0be7cc080b08b6efb7dcabf474e795be9066dc30b77dac0cc1acd09f43bdb"
799        );
800    }
801
802    #[test]
803    fn put_file_fingerprint_value_is_pinned() {
804        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
805
806        let fingerprint = semantic_commit_fingerprint(
807            &namespace_id,
808            &test_actor(),
809            None,
810            &[FilesystemOperation::PutFile {
811                path: AbsolutePath::parse("/docs/report.txt").expect("path"),
812                content_ref: ContentRef::blob_v1(
813                    ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
814                    b"pinned put bytes",
815                ),
816                behavior: DestinationBehavior::NoReplace,
817                expected_revision_no: None,
818            }],
819        )
820        .expect("fingerprint");
821
822        assert_eq!(
823            fingerprint,
824            "v1:sha256:bc5ab43ea228015ee13ceb52bb074b3ec1f3026babeb007eec8f5512fb64a924"
825        );
826    }
827
828    #[test]
829    fn a_put_retry_reaches_the_pinned_fingerprint_under_every_checksum_algorithm() {
830        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
831        let content_id =
832            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id");
833        let bytes = b"pinned put bytes";
834
835        for content_ref in [
836            ContentRef::blob_v1(content_id.clone(), bytes),
837            ContentRef {
838                kind: ContentRefKind::BlobV1,
839                content_id: content_id.clone(),
840                size_bytes: bytes.len() as u64,
841                checksum: Checksum::crc32c(bytes),
842            },
843            ContentRef {
844                kind: ContentRefKind::BlobV1,
845                content_id: content_id.clone(),
846                size_bytes: bytes.len() as u64,
847                checksum: Checksum::crc64nvme(bytes),
848            },
849        ] {
850            assert_eq!(
851                put_retry_fingerprint(
852                    &namespace_id,
853                    &test_actor(),
854                    &AbsolutePath::parse("/docs/report.txt").expect("path"),
855                    DestinationBehavior::NoReplace,
856                    None,
857                    None,
858                    &content_ref,
859                )
860                .expect("retry fingerprint"),
861                "v1:sha256:bc5ab43ea228015ee13ceb52bb074b3ec1f3026babeb007eec8f5512fb64a924"
862            );
863        }
864    }
865
866    fn create_dir(path: &str) -> FilesystemOperation {
867        FilesystemOperation::CreateDirectory {
868            path: AbsolutePath::parse(path).expect("path"),
869            parents: false,
870        }
871    }
872
873    fn put(path: &str, content_ref: ContentRef) -> FilesystemOperation {
874        FilesystemOperation::PutFile {
875            path: AbsolutePath::parse(path).expect("path"),
876            content_ref,
877            behavior: DestinationBehavior::NoReplace,
878            expected_revision_no: None,
879        }
880    }
881
882    #[test]
883    fn a_different_content_object_changes_mutation_identity() {
884        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
885        let bytes = b"identical bytes, two uploads";
886        let first = ContentRef::blob_v1(ContentId::generate(), bytes);
887        let second = ContentRef::blob_v1(ContentId::generate(), bytes);
888
889        assert_ne!(
890            semantic_commit_fingerprint(
891                &namespace_id,
892                &test_actor(),
893                None,
894                &[put("/docs/report.txt", first)]
895            )
896            .expect("fingerprint"),
897            semantic_commit_fingerprint(
898                &namespace_id,
899                &test_actor(),
900                None,
901                &[put("/docs/report.txt", second)]
902            )
903            .expect("fingerprint")
904        );
905    }
906
907    #[test]
908    fn a_message_changes_mutation_identity() {
909        // The annotation is part of what the caller asked for: replaying a
910        // commit id with a different message must conflict, so the message
911        // joins the preimage.
912        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
913        let without =
914            semantic_commit_fingerprint(&namespace_id, &test_actor(), None, &[create_dir("/docs")])
915                .expect("fingerprint");
916        let with = semantic_commit_fingerprint(
917            &namespace_id,
918            &test_actor(),
919            Some("import batch"),
920            &[create_dir("/docs")],
921        )
922        .expect("fingerprint");
923
924        assert_ne!(without, with);
925    }
926
927    #[test]
928    fn operation_order_changes_mutation_identity() {
929        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
930
931        assert_ne!(
932            semantic_commit_fingerprint(
933                &namespace_id,
934                &test_actor(),
935                None,
936                &[create_dir("/a"), create_dir("/b")]
937            )
938            .expect("forward fingerprint"),
939            semantic_commit_fingerprint(
940                &namespace_id,
941                &test_actor(),
942                None,
943                &[create_dir("/b"), create_dir("/a")]
944            )
945            .expect("reversed fingerprint")
946        );
947    }
948
949    #[test]
950    fn put_retry_fingerprint_matches_the_equivalent_single_operation_request() {
951        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
952        let path = AbsolutePath::parse("/docs/report.txt").expect("path");
953        let content_ref = ContentRef::blob_v1(
954            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
955            b"pinned put bytes",
956        );
957
958        let by_hand = semantic_commit_fingerprint(
959            &namespace_id,
960            &test_actor(),
961            Some("import batch"),
962            &[FilesystemOperation::PutFile {
963                path: path.clone(),
964                content_ref: content_ref.clone(),
965                behavior: DestinationBehavior::Replace,
966                expected_revision_no: Some(RevisionNo(4)),
967            }],
968        )
969        .expect("hand-built fingerprint");
970
971        assert_eq!(
972            put_retry_fingerprint(
973                &namespace_id,
974                &test_actor(),
975                &path,
976                DestinationBehavior::Replace,
977                Some(RevisionNo(4)),
978                Some("import batch"),
979                &content_ref,
980            )
981            .expect("retry fingerprint"),
982            by_hand
983        );
984    }
985
986    #[test]
987    fn put_retry_fingerprint_changes_with_every_request_field() {
988        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
989        let path = AbsolutePath::parse("/a.txt").expect("path");
990        let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
991        let baseline = put_retry_fingerprint(
992            &namespace_id,
993            &test_actor(),
994            &path,
995            DestinationBehavior::Replace,
996            None,
997            None,
998            &content_ref,
999        )
1000        .expect("baseline");
1001
1002        for (label, variant) in [
1003            (
1004                "path",
1005                put_retry_fingerprint(
1006                    &namespace_id,
1007                    &test_actor(),
1008                    &AbsolutePath::parse("/b.txt").expect("path"),
1009                    DestinationBehavior::Replace,
1010                    None,
1011                    None,
1012                    &content_ref,
1013                ),
1014            ),
1015            (
1016                "behavior",
1017                put_retry_fingerprint(
1018                    &namespace_id,
1019                    &test_actor(),
1020                    &path,
1021                    DestinationBehavior::NoReplace,
1022                    None,
1023                    None,
1024                    &content_ref,
1025                ),
1026            ),
1027            (
1028                "expected revision",
1029                put_retry_fingerprint(
1030                    &namespace_id,
1031                    &test_actor(),
1032                    &path,
1033                    DestinationBehavior::Replace,
1034                    Some(RevisionNo(2)),
1035                    None,
1036                    &content_ref,
1037                ),
1038            ),
1039            (
1040                "message",
1041                put_retry_fingerprint(
1042                    &namespace_id,
1043                    &test_actor(),
1044                    &path,
1045                    DestinationBehavior::Replace,
1046                    None,
1047                    Some(""),
1048                    &content_ref,
1049                ),
1050            ),
1051            (
1052                "namespace",
1053                put_retry_fingerprint(
1054                    &NamespaceId::parse("other").expect("valid namespace id"),
1055                    &test_actor(),
1056                    &path,
1057                    DestinationBehavior::Replace,
1058                    None,
1059                    None,
1060                    &content_ref,
1061                ),
1062            ),
1063        ] {
1064            assert_ne!(
1065                baseline,
1066                variant.expect("variant fingerprint"),
1067                "a changed {label} must change the fingerprint"
1068            );
1069        }
1070    }
1071
1072    #[test]
1073    fn put_retry_reconciliation_agrees_on_receipt_mismatch_and_unavailable_evidence() {
1074        #[derive(Debug, Clone, PartialEq, Eq)]
1075        enum ReconciliationError {
1076            Conflict(PutRetryReceipt),
1077            EvidenceUnavailable,
1078        }
1079
1080        fn classify(error: &ReconciliationError) -> PutRetryErrorClassification {
1081            match error {
1082                ReconciliationError::Conflict(receipt) => {
1083                    PutRetryErrorClassification::CommitIdReuseConflict(Some(receipt.clone()))
1084                }
1085                ReconciliationError::EvidenceUnavailable => {
1086                    PutRetryErrorClassification::RebootstrapRequired
1087                }
1088            }
1089        }
1090
1091        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
1092        let path = AbsolutePath::parse("/report.txt").expect("valid path");
1093        let commit_id = CommitId::parse("pinned-put").expect("valid commit id");
1094        let committed_seq = ChangeSeq(7);
1095        let bytes = b"stable bytes";
1096        let content_ref = ContentRef::blob_v1(ContentId::generate(), bytes);
1097        let mut options = crate::options::PutFileOptions::new(test_actor());
1098        options.commit.commit_id = Some(commit_id.clone());
1099        let receipt = PutRetryReceipt {
1100            committed_seq,
1101            committed_fingerprint: put_retry_fingerprint(
1102                &namespace_id,
1103                &test_actor(),
1104                &path,
1105                options.behavior,
1106                options.expected_revision_no,
1107                options.commit.message.as_deref(),
1108                &content_ref,
1109            )
1110            .expect("fingerprint"),
1111        };
1112        let page = crate::v0::ListChangesResponse {
1113            namespace_id: namespace_id.clone(),
1114            after_seq: ChangeSeq(6),
1115            through_seq: committed_seq,
1116            next_after_seq: None,
1117            changes: vec![crate::v0::CommittedChange {
1118                committed_seq,
1119                commit_id: commit_id.clone(),
1120                committed_by: test_actor(),
1121                committed_at_ms: 1,
1122                message: None,
1123                events: vec![crate::v0::FilesystemChange::FileCreated {
1124                    inode_id: InodeId(2),
1125                    parent_inode_id: InodeId(1),
1126                    display_name: DisplayName::parse("report.txt").expect("valid display name"),
1127                    binding_generation: "generation".to_owned(),
1128                    revision_no: RevisionNo(1),
1129                    content_ref,
1130                }],
1131            }],
1132        };
1133
1134        let matching_attempt = PutRetryAttempt {
1135            namespace_id: &namespace_id,
1136            path: &path,
1137            commit_id: &commit_id,
1138            options: &options,
1139            staged: ContentEvidence::Bytes(bytes),
1140        };
1141        let reconciled = futures::executor::block_on(reconcile_put_commit_id_reuse(
1142            matching_attempt,
1143            ReconciliationError::Conflict(receipt.clone()),
1144            |after_seq| {
1145                assert_eq!(after_seq, ChangeSeq(6));
1146                std::future::ready(Ok(page.clone()))
1147            },
1148            classify,
1149        ))
1150        .expect("matching receipt and evidence reconcile");
1151        assert_eq!(reconciled.commit_id, commit_id);
1152        assert_eq!(reconciled.committed_seq, committed_seq);
1153
1154        let mismatch = futures::executor::block_on(reconcile_put_commit_id_reuse(
1155            PutRetryAttempt {
1156                staged: ContentEvidence::Bytes(b"different bytes"),
1157                ..matching_attempt
1158            },
1159            ReconciliationError::Conflict(receipt.clone()),
1160            |_| std::future::ready(Ok(page.clone())),
1161            classify,
1162        ));
1163        assert_eq!(
1164            mismatch,
1165            Err(ReconciliationError::Conflict(receipt.clone()))
1166        );
1167
1168        let unavailable = futures::executor::block_on(reconcile_put_commit_id_reuse(
1169            matching_attempt,
1170            ReconciliationError::Conflict(receipt.clone()),
1171            |_| std::future::ready(Err(ReconciliationError::EvidenceUnavailable)),
1172            classify,
1173        ));
1174        assert_eq!(unavailable, Err(ReconciliationError::Conflict(receipt)));
1175    }
1176}