Skip to main content

loonfs_api/
commit_identity.rs

1//! Commit identity fingerprints (format spec, "Commit identity
2//! fingerprints"): a stable digest over a mutation's semantic content, used
3//! to decide whether a reused commit id carries the same mutation or a
4//! conflicting one.
5//!
6//! This lives beside [`FilesystemOperation`], the one operation language it
7//! hashes, because every surface has to compute the same value from it: the
8//! engine stamps it on a commit receipt, and a client reconciling a
9//! reused-id conflict recomputes it to prove the retry is the same request.
10//! One function is the authority; nobody re-derives the rules.
11//!
12//! The commit id is not in the preimage. The id is the key a mutation is
13//! filed under; the fingerprint is what was filed. Comparing the two is the
14//! whole of the reuse check.
15
16use crate::{
17    AbsolutePath, ChangeSeq, ContentRef, DeleteDirectoryBehavior, DestinationBehavior,
18    FilesystemOperation, InodeId, NamespaceId, RevisionNo,
19};
20use serde::Serialize;
21use sha2::{Digest, Sha256};
22use std::fmt::Write as _;
23use thiserror::Error;
24
25/// Domain separator for the one mutation fingerprint preimage.
26const COMMIT_FINGERPRINT_DOMAIN: &str = "loonfs.commit.semantic.v0";
27
28/// Scheme-and-algorithm tag carried by every stored fingerprint value.
29///
30/// `v0` names the canonicalization rules (domain string plus the frozen v0
31/// preimage encoding; format spec, "Commit identity fingerprints") and
32/// `sha256` the digest algorithm, so either can change later without
33/// re-interpreting values already stored in WAL records and commit receipts.
34const FINGERPRINT_SCHEME: &str = "v0:sha256";
35
36/// A canonical preimage that could not be encoded.
37///
38/// The preimage is built here from validated types with no encoding failure
39/// modes, so this reports a bug in the encoder rather than anything a caller
40/// did.
41#[derive(Debug, Error)]
42#[error("failed to encode the commit fingerprint preimage: {0}")]
43pub struct SemanticFingerprintError(#[from] serde_json::Error);
44
45/// Computes a stored fingerprint value (`v0:sha256:<64 lowercase hex>`) from
46/// a canonical preimage.
47///
48/// The preimage's compact JSON encoding is the durable contract: the
49/// pinned-value tests below fail if it drifts.
50fn fingerprint_digest<T>(preimage: &T) -> Result<String, SemanticFingerprintError>
51where
52    T: Serialize,
53{
54    let bytes = serde_json::to_vec(preimage)?;
55    Ok(fingerprint_bytes(&bytes))
56}
57
58fn fingerprint_bytes(bytes: &[u8]) -> String {
59    let digest = Sha256::digest(bytes);
60    let mut value = String::with_capacity(FINGERPRINT_SCHEME.len() + 1 + digest.len() * 2);
61    value.push_str(FINGERPRINT_SCHEME);
62    value.push(':');
63    for byte in digest {
64        write!(&mut value, "{byte:02x}").expect("writing to a String should not fail");
65    }
66    value
67}
68
69/// Canonical preimage for one operation inside a mutation fingerprint.
70///
71/// The serde representation is durable contract (format spec, "Commit
72/// identity fingerprints"): the same normalized request must fingerprint
73/// identically across releases. A pinned-value test below fails if the
74/// encoding drifts.
75///
76/// The variant names, the field names, and the field order below are all part
77/// of that preimage under the [`COMMIT_FINGERPRINT_DOMAIN`] tag, and none of
78/// them tracks the wire enum. They deliberately differ from it — `CreateDir`
79/// against the wire's `CreateDirectory`, `absolute_path` against its `path`,
80/// `behavior` ahead of `content_ref` in the put — because renaming a wire
81/// field must not silently restate every already-published commit's identity.
82/// [`operation_fingerprint_input`] is the one place the wire spelling is
83/// translated into this one; nothing else may name these variants. Change any
84/// of it and every stored fingerprint disagrees with its recomputed value,
85/// which the pinned tests below exist to catch.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87#[serde(tag = "kind", rename_all = "snake_case")]
88enum OperationFingerprintInput<'a> {
89    CreateDir {
90        absolute_path: &'a str,
91        parents: bool,
92    },
93    // The put guard joins the preimage for the same reason as the delete
94    // guard below: a changed expected revision is a different logical
95    // request and must conflict rather than replay a receipt.
96    PutFile {
97        absolute_path: &'a str,
98        behavior: DestinationBehavior,
99        content_ref: ContentRefFingerprintInput<'a>,
100        expected_revision_no: Option<RevisionNo>,
101    },
102    // Identity covers the complete caller-visible logical request. A changed
103    // delete guard must conflict instead of replaying the old receipt
104    // without checking the new guard.
105    DeletePath {
106        absolute_path: &'a str,
107        behavior: DeleteDirectoryBehavior,
108        expected_inode_id: Option<InodeId>,
109    },
110    MovePath {
111        from_path: &'a str,
112        to_path: &'a str,
113        behavior: DestinationBehavior,
114    },
115    CopyFilePath {
116        from_path: &'a str,
117        to_path: &'a str,
118        behavior: DestinationBehavior,
119    },
120    RestoreRevision {
121        absolute_path: &'a str,
122        source_revision_no: RevisionNo,
123    },
124    Undelete {
125        inode_id: InodeId,
126        deleted_at_seq: ChangeSeq,
127        // Preimage-additive: `Some` serializes as the bare string it always
128        // was, so every stored undelete fingerprint is unchanged; `None`
129        // serializes as `null`, a new distinct preimage for the in-place
130        // form. Both shapes are pinned below.
131        absolute_path: Option<&'a str>,
132    },
133}
134
135/// Canonical preimage for the content a put attaches.
136///
137/// Identity is *which object*, so the id and its length are the whole of it.
138/// The checksums are evidence about those bytes, pinned to the id by the
139/// verification every write and read already performs, and they are left out
140/// deliberately: a reference that named the same object with a differently
141/// spelled checksum would otherwise read as a different mutation.
142///
143/// The consequence is worth stating plainly. A retry that re-runs the whole
144/// operation, upload included, mints a new content object, so it is a
145/// different request and a reused commit id conflicts. Retrying a commit
146/// means sending the same `ContentRef` again — which replays — not uploading
147/// the bytes again.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
149struct ContentRefFingerprintInput<'a> {
150    kind: &'a str,
151    content_id: &'a str,
152    size_bytes: u64,
153}
154
155fn content_ref_fingerprint_input(content_ref: &ContentRef) -> ContentRefFingerprintInput<'_> {
156    ContentRefFingerprintInput {
157        kind: content_ref.kind.as_str(),
158        content_id: content_ref.content_id.as_str(),
159        size_bytes: content_ref.size_bytes,
160    }
161}
162
163/// Renames one wire operation into its durable preimage.
164///
165/// This is the whole of the wire-to-fingerprint translation. The left side
166/// follows [`FilesystemOperation`] and may be renamed with it; the right side
167/// is frozen (see [`OperationFingerprintInput`]).
168fn operation_fingerprint_input(operation: &FilesystemOperation) -> OperationFingerprintInput<'_> {
169    match operation {
170        FilesystemOperation::CreateDirectory { path, parents } => {
171            OperationFingerprintInput::CreateDir {
172                absolute_path: path.as_str(),
173                parents: *parents,
174            }
175        }
176        FilesystemOperation::PutFile {
177            path,
178            content_ref,
179            behavior,
180            expected_revision_no,
181        } => OperationFingerprintInput::PutFile {
182            absolute_path: path.as_str(),
183            behavior: *behavior,
184            content_ref: content_ref_fingerprint_input(content_ref),
185            expected_revision_no: *expected_revision_no,
186        },
187        FilesystemOperation::DeletePath {
188            path,
189            behavior,
190            expected_inode_id,
191        } => OperationFingerprintInput::DeletePath {
192            absolute_path: path.as_str(),
193            behavior: *behavior,
194            expected_inode_id: *expected_inode_id,
195        },
196        FilesystemOperation::MovePath {
197            from_path,
198            to_path,
199            behavior,
200        } => OperationFingerprintInput::MovePath {
201            from_path: from_path.as_str(),
202            to_path: to_path.as_str(),
203            behavior: *behavior,
204        },
205        FilesystemOperation::CopyPath {
206            from_path,
207            to_path,
208            behavior,
209        } => OperationFingerprintInput::CopyFilePath {
210            from_path: from_path.as_str(),
211            to_path: to_path.as_str(),
212            behavior: *behavior,
213        },
214        FilesystemOperation::RestoreRevision {
215            path,
216            source_revision_no,
217        } => OperationFingerprintInput::RestoreRevision {
218            absolute_path: path.as_str(),
219            source_revision_no: *source_revision_no,
220        },
221        FilesystemOperation::Undelete {
222            inode_id,
223            deleted_at_seq,
224            path,
225        } => OperationFingerprintInput::Undelete {
226            inode_id: *inode_id,
227            deleted_at_seq: *deleted_at_seq,
228            absolute_path: path.as_ref().map(|path| path.as_str()),
229        },
230    }
231}
232
233/// The semantic identity of one mutation request: what a reused commit id is
234/// compared against.
235///
236/// A one-operation convenience call and a one-element batch are the same
237/// request, so they reach this function with the same shape and fingerprint
238/// identically; there is no separate single-operation form to keep in step.
239pub fn semantic_commit_fingerprint(
240    namespace_id: &NamespaceId,
241    message: Option<&str>,
242    operations: &[FilesystemOperation],
243) -> Result<String, SemanticFingerprintError> {
244    #[derive(Serialize)]
245    struct CanonicalCommit<'a> {
246        domain: &'static str,
247        namespace_id: &'a str,
248        operations: Vec<OperationFingerprintInput<'a>>,
249        message: Option<&'a str>,
250    }
251
252    fingerprint_digest(&CanonicalCommit {
253        domain: COMMIT_FINGERPRINT_DOMAIN,
254        namespace_id: namespace_id.as_str(),
255        operations: operations.iter().map(operation_fingerprint_input).collect(),
256        message,
257    })
258}
259
260/// The fingerprint the original request must have had, if this retry is the
261/// same single put with only the content id renamed.
262///
263/// Rerunning a whole upload-then-commit sequence stages a fresh content
264/// object, so the retry's own fingerprint can never match a landed one. This
265/// substitutes the committed reference for the staged one and fingerprints
266/// the request that results: everything else a put can ask for — the path,
267/// the replacement behavior, the expected revision, the annotation, and that
268/// the commit was this one operation and nothing more — has to agree for the
269/// value to match. Whether the two content objects hold the same bytes is a
270/// separate question, answered by digest evidence rather than by this.
271pub fn put_retry_fingerprint(
272    namespace_id: &NamespaceId,
273    path: &AbsolutePath,
274    behavior: DestinationBehavior,
275    expected_revision_no: Option<RevisionNo>,
276    message: Option<&str>,
277    committed_content_ref: &ContentRef,
278) -> Result<String, SemanticFingerprintError> {
279    let operation = FilesystemOperation::PutFile {
280        path: path.clone(),
281        content_ref: committed_content_ref.clone(),
282        behavior,
283        expected_revision_no,
284    };
285    semantic_commit_fingerprint(namespace_id, message, std::slice::from_ref(&operation))
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::ContentId;
292
293    /// Pins the exact stored fingerprint for a fixed one-operation request.
294    ///
295    /// If this fails, the canonical preimage changed (format spec, "Commit
296    /// identity fingerprints") and every persisted fingerprint would disagree
297    /// with recomputed ones, breaking retry idempotency across versions. Do
298    /// not update the literal without bumping the fingerprint scheme tag.
299    #[test]
300    fn commit_fingerprint_value_is_pinned() {
301        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
302
303        let fingerprint = semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/docs")])
304            .expect("fingerprint");
305
306        assert_eq!(
307            fingerprint,
308            "v0:sha256:85894f53a16c2c0be95afc39b245280101f3e2a414f044c87be8eb9f1980dbcd"
309        );
310    }
311
312    /// Pins the exact stored fingerprint encoding for a guarded delete.
313    #[test]
314    fn guarded_delete_fingerprint_value_is_pinned() {
315        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
316
317        let fingerprint = semantic_commit_fingerprint(
318            &namespace_id,
319            None,
320            &[FilesystemOperation::DeletePath {
321                path: AbsolutePath::parse("/docs").expect("path"),
322                behavior: DeleteDirectoryBehavior::NonRecursive,
323                expected_inode_id: Some(InodeId(42)),
324            }],
325        )
326        .expect("fingerprint");
327
328        assert_eq!(
329            fingerprint,
330            "v0:sha256:edc8e06bd0a651e9470198875ec44c8fcd7d9b95f162fe1d7ca46011c27e2818"
331        );
332    }
333
334    /// Pins the exact stored fingerprint for an undelete with a destination
335    /// path.
336    ///
337    /// This literal is what proves the in-place form was preimage-additive:
338    /// the path became optional and this value did not move, because a
339    /// present path serializes as the bare string it always was.
340    #[test]
341    fn undelete_fingerprint_value_is_pinned() {
342        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
343
344        let fingerprint = semantic_commit_fingerprint(
345            &namespace_id,
346            None,
347            &[FilesystemOperation::Undelete {
348                inode_id: InodeId(42),
349                deleted_at_seq: ChangeSeq(17),
350                path: Some(AbsolutePath::parse("/docs/report.txt").expect("path")),
351            }],
352        )
353        .expect("fingerprint");
354
355        // The mechanism behind "did not move": a present option serializes
356        // as the bare value, so wrapping the preimage field changed no
357        // stored byte.
358        assert_eq!(
359            serde_json::to_value(Some("/docs/report.txt")).expect("serialize"),
360            serde_json::to_value("/docs/report.txt").expect("serialize"),
361        );
362        assert_eq!(
363            fingerprint,
364            "v0:sha256:1f4fa76d65aa64903a7d44cead91600a97c0bac9ec3a01ac51f0cd1130eff3d6"
365        );
366    }
367
368    /// Pins the exact stored fingerprint for an in-place undelete, whose
369    /// absent path serializes as `null` — a distinct preimage from every
370    /// pathed form.
371    #[test]
372    fn in_place_undelete_fingerprint_value_is_pinned() {
373        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
374
375        let fingerprint = semantic_commit_fingerprint(
376            &namespace_id,
377            None,
378            &[FilesystemOperation::Undelete {
379                inode_id: InodeId(42),
380                deleted_at_seq: ChangeSeq(17),
381                path: None,
382            }],
383        )
384        .expect("fingerprint");
385
386        assert_eq!(
387            fingerprint,
388            "v0:sha256:4d7737cdc3888e3613dad0ec7d752e8daac089c8b528301cf0eba9307fa1cc4c"
389        );
390    }
391
392    /// Pins the exact stored fingerprint for a put, which is the only
393    /// operation whose preimage embeds a content reference.
394    ///
395    /// The literal covers the canonical content-ref form — kind, content id,
396    /// size, and nothing else. Adding a checksum to that form, or reordering
397    /// it, would change this value and silently break replay for every
398    /// already-published put.
399    #[test]
400    fn put_file_fingerprint_value_is_pinned() {
401        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
402
403        let fingerprint = semantic_commit_fingerprint(
404            &namespace_id,
405            None,
406            &[FilesystemOperation::PutFile {
407                path: AbsolutePath::parse("/docs/report.txt").expect("path"),
408                content_ref: ContentRef::blob_v1(
409                    ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
410                    b"pinned put bytes",
411                ),
412                behavior: DestinationBehavior::NoReplace,
413                expected_revision_no: None,
414            }],
415        )
416        .expect("fingerprint");
417
418        assert_eq!(
419            fingerprint,
420            "v0:sha256:3febc279ebb36c013f734095bebdba3c0a59bf8cbd82d205b53adbf00c112d59"
421        );
422    }
423
424    fn create_dir(path: &str) -> FilesystemOperation {
425        FilesystemOperation::CreateDirectory {
426            path: AbsolutePath::parse(path).expect("path"),
427            parents: false,
428        }
429    }
430
431    fn put(path: &str, content_ref: ContentRef) -> FilesystemOperation {
432        FilesystemOperation::PutFile {
433            path: AbsolutePath::parse(path).expect("path"),
434            content_ref,
435            behavior: DestinationBehavior::NoReplace,
436            expected_revision_no: None,
437        }
438    }
439
440    /// Two references to the same object with different checksum evidence
441    /// are the same mutation: identity is which object a put attaches, and
442    /// the checksums are pinned to that object by verification elsewhere.
443    #[test]
444    fn checksum_evidence_is_outside_mutation_identity() {
445        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
446        let content_ref = ContentRef::blob_v1(
447            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
448            b"pinned put bytes",
449        );
450        let without_trusted_digest = ContentRef {
451            whole_file_sha256: None,
452            ..content_ref.clone()
453        };
454
455        assert_eq!(
456            semantic_commit_fingerprint(
457                &namespace_id,
458                None,
459                &[put("/docs/report.txt", content_ref)]
460            )
461            .expect("fingerprint"),
462            semantic_commit_fingerprint(
463                &namespace_id,
464                None,
465                &[put("/docs/report.txt", without_trusted_digest)]
466            )
467            .expect("fingerprint")
468        );
469    }
470
471    /// A different content object is a different mutation, which is what
472    /// makes a re-upload under a used commit id conflict instead of replay.
473    #[test]
474    fn a_different_content_object_changes_mutation_identity() {
475        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
476        let bytes = b"identical bytes, two uploads";
477        let first = ContentRef::blob_v1(ContentId::generate(), bytes);
478        let second = ContentRef::blob_v1(ContentId::generate(), bytes);
479
480        assert_ne!(
481            semantic_commit_fingerprint(&namespace_id, None, &[put("/docs/report.txt", first)])
482                .expect("fingerprint"),
483            semantic_commit_fingerprint(&namespace_id, None, &[put("/docs/report.txt", second)])
484                .expect("fingerprint")
485        );
486    }
487
488    #[test]
489    fn a_message_changes_mutation_identity() {
490        // The annotation is part of what the caller asked for: replaying a
491        // commit id with a different message must conflict, so the message
492        // joins the preimage.
493        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
494        let without = semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/docs")])
495            .expect("fingerprint");
496        let with = semantic_commit_fingerprint(
497            &namespace_id,
498            Some("import batch"),
499            &[create_dir("/docs")],
500        )
501        .expect("fingerprint");
502
503        assert_ne!(without, with);
504    }
505
506    #[test]
507    fn commit_fingerprint_changes_when_logical_inputs_change() {
508        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
509        let baseline = semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/docs")])
510            .expect("baseline");
511        let changed = semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/drafts")])
512            .expect("changed");
513
514        assert_ne!(baseline, changed);
515    }
516
517    /// Operation order is part of the request: reordering is a different
518    /// logical mutation, so it must not replay the first one's receipt.
519    #[test]
520    fn operation_order_changes_mutation_identity() {
521        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
522
523        assert_ne!(
524            semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/a"), create_dir("/b")])
525                .expect("forward fingerprint"),
526            semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/b"), create_dir("/a")])
527                .expect("reversed fingerprint")
528        );
529    }
530
531    /// The retry helper is not a second spelling of the preimage: it builds
532    /// the same single-put request a caller would have sent and hands it to
533    /// the same function.
534    #[test]
535    fn put_retry_fingerprint_matches_the_equivalent_single_operation_request() {
536        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
537        let path = AbsolutePath::parse("/docs/report.txt").expect("path");
538        let content_ref = ContentRef::blob_v1(
539            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
540            b"pinned put bytes",
541        );
542
543        let by_hand = semantic_commit_fingerprint(
544            &namespace_id,
545            Some("import batch"),
546            &[FilesystemOperation::PutFile {
547                path: path.clone(),
548                content_ref: content_ref.clone(),
549                behavior: DestinationBehavior::Replace,
550                expected_revision_no: Some(RevisionNo(4)),
551            }],
552        )
553        .expect("hand-built fingerprint");
554
555        assert_eq!(
556            put_retry_fingerprint(
557                &namespace_id,
558                &path,
559                DestinationBehavior::Replace,
560                Some(RevisionNo(4)),
561                Some("import batch"),
562                &content_ref,
563            )
564            .expect("retry fingerprint"),
565            by_hand
566        );
567    }
568
569    /// Everything a put can ask for beyond its content is inside the value,
570    /// which is what makes comparing the whole fingerprint a complete proof
571    /// rather than a partial one.
572    #[test]
573    fn put_retry_fingerprint_changes_with_every_request_field() {
574        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
575        let path = AbsolutePath::parse("/a.txt").expect("path");
576        let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
577        let baseline = put_retry_fingerprint(
578            &namespace_id,
579            &path,
580            DestinationBehavior::Replace,
581            None,
582            None,
583            &content_ref,
584        )
585        .expect("baseline");
586
587        for (label, variant) in [
588            (
589                "path",
590                put_retry_fingerprint(
591                    &namespace_id,
592                    &AbsolutePath::parse("/b.txt").expect("path"),
593                    DestinationBehavior::Replace,
594                    None,
595                    None,
596                    &content_ref,
597                ),
598            ),
599            (
600                "behavior",
601                put_retry_fingerprint(
602                    &namespace_id,
603                    &path,
604                    DestinationBehavior::NoReplace,
605                    None,
606                    None,
607                    &content_ref,
608                ),
609            ),
610            (
611                "expected revision",
612                put_retry_fingerprint(
613                    &namespace_id,
614                    &path,
615                    DestinationBehavior::Replace,
616                    Some(RevisionNo(2)),
617                    None,
618                    &content_ref,
619                ),
620            ),
621            (
622                "message",
623                put_retry_fingerprint(
624                    &namespace_id,
625                    &path,
626                    DestinationBehavior::Replace,
627                    None,
628                    Some(""),
629                    &content_ref,
630                ),
631            ),
632            (
633                "namespace",
634                put_retry_fingerprint(
635                    &NamespaceId::parse("other").expect("valid namespace id"),
636                    &path,
637                    DestinationBehavior::Replace,
638                    None,
639                    None,
640                    &content_ref,
641                ),
642            ),
643        ] {
644            assert_ne!(
645                baseline,
646                variant.expect("variant fingerprint"),
647                "a changed {label} must change the fingerprint"
648            );
649        }
650    }
651}