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