1use 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
25const COMMIT_FINGERPRINT_DOMAIN: &str = "loonfs.commit.semantic.v0";
27
28const FINGERPRINT_SCHEME: &str = "v0:sha256";
35
36#[derive(Debug, Error)]
42#[error("failed to encode the commit fingerprint preimage: {0}")]
43pub struct SemanticFingerprintError(#[from] serde_json::Error);
44
45fn 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#[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 PutFile {
97 absolute_path: &'a str,
98 behavior: DestinationBehavior,
99 content_ref: ContentRefFingerprintInput<'a>,
100 expected_revision_no: Option<RevisionNo>,
101 },
102 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 absolute_path: Option<&'a str>,
132 },
133}
134
135#[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
163fn 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
233pub 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
260pub 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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}