use crate::{
AbsolutePath, ChangeSeq, ContentRef, DeleteDirectoryBehavior, DestinationBehavior,
FilesystemOperation, InodeId, NamespaceId, RevisionNo,
};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
use thiserror::Error;
const COMMIT_FINGERPRINT_DOMAIN: &str = "loonfs.commit.semantic.v0";
const FINGERPRINT_SCHEME: &str = "v0:sha256";
#[derive(Debug, Error)]
#[error("failed to encode the commit fingerprint preimage: {0}")]
pub struct SemanticFingerprintError(#[from] serde_json::Error);
fn fingerprint_digest<T>(preimage: &T) -> Result<String, SemanticFingerprintError>
where
T: Serialize,
{
let bytes = serde_json::to_vec(preimage)?;
Ok(fingerprint_bytes(&bytes))
}
fn fingerprint_bytes(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut value = String::with_capacity(FINGERPRINT_SCHEME.len() + 1 + digest.len() * 2);
value.push_str(FINGERPRINT_SCHEME);
value.push(':');
for byte in digest {
write!(&mut value, "{byte:02x}").expect("writing to a String should not fail");
}
value
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum OperationFingerprintInput<'a> {
CreateDir {
absolute_path: &'a str,
parents: bool,
},
PutFile {
absolute_path: &'a str,
behavior: DestinationBehavior,
content_ref: ContentRefFingerprintInput<'a>,
expected_revision_no: Option<RevisionNo>,
},
DeletePath {
absolute_path: &'a str,
behavior: DeleteDirectoryBehavior,
expected_inode_id: Option<InodeId>,
},
MovePath {
from_path: &'a str,
to_path: &'a str,
behavior: DestinationBehavior,
},
CopyFilePath {
from_path: &'a str,
to_path: &'a str,
behavior: DestinationBehavior,
},
RestoreRevision {
absolute_path: &'a str,
source_revision_no: RevisionNo,
},
Undelete {
inode_id: InodeId,
deleted_at_seq: ChangeSeq,
absolute_path: Option<&'a str>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ContentRefFingerprintInput<'a> {
kind: &'a str,
content_id: &'a str,
size_bytes: u64,
}
fn content_ref_fingerprint_input(content_ref: &ContentRef) -> ContentRefFingerprintInput<'_> {
ContentRefFingerprintInput {
kind: content_ref.kind.as_str(),
content_id: content_ref.content_id.as_str(),
size_bytes: content_ref.size_bytes,
}
}
fn operation_fingerprint_input(operation: &FilesystemOperation) -> OperationFingerprintInput<'_> {
match operation {
FilesystemOperation::CreateDirectory { path, parents } => {
OperationFingerprintInput::CreateDir {
absolute_path: path.as_str(),
parents: *parents,
}
}
FilesystemOperation::PutFile {
path,
content_ref,
behavior,
expected_revision_no,
} => OperationFingerprintInput::PutFile {
absolute_path: path.as_str(),
behavior: *behavior,
content_ref: content_ref_fingerprint_input(content_ref),
expected_revision_no: *expected_revision_no,
},
FilesystemOperation::DeletePath {
path,
behavior,
expected_inode_id,
} => OperationFingerprintInput::DeletePath {
absolute_path: path.as_str(),
behavior: *behavior,
expected_inode_id: *expected_inode_id,
},
FilesystemOperation::MovePath {
from_path,
to_path,
behavior,
} => OperationFingerprintInput::MovePath {
from_path: from_path.as_str(),
to_path: to_path.as_str(),
behavior: *behavior,
},
FilesystemOperation::CopyPath {
from_path,
to_path,
behavior,
} => OperationFingerprintInput::CopyFilePath {
from_path: from_path.as_str(),
to_path: to_path.as_str(),
behavior: *behavior,
},
FilesystemOperation::RestoreRevision {
path,
source_revision_no,
} => OperationFingerprintInput::RestoreRevision {
absolute_path: path.as_str(),
source_revision_no: *source_revision_no,
},
FilesystemOperation::Undelete {
inode_id,
deleted_at_seq,
path,
} => OperationFingerprintInput::Undelete {
inode_id: *inode_id,
deleted_at_seq: *deleted_at_seq,
absolute_path: path.as_ref().map(|path| path.as_str()),
},
}
}
pub fn semantic_commit_fingerprint(
namespace_id: &NamespaceId,
message: Option<&str>,
operations: &[FilesystemOperation],
) -> Result<String, SemanticFingerprintError> {
#[derive(Serialize)]
struct CanonicalCommit<'a> {
domain: &'static str,
namespace_id: &'a str,
operations: Vec<OperationFingerprintInput<'a>>,
message: Option<&'a str>,
}
fingerprint_digest(&CanonicalCommit {
domain: COMMIT_FINGERPRINT_DOMAIN,
namespace_id: namespace_id.as_str(),
operations: operations.iter().map(operation_fingerprint_input).collect(),
message,
})
}
pub fn put_retry_fingerprint(
namespace_id: &NamespaceId,
path: &AbsolutePath,
behavior: DestinationBehavior,
expected_revision_no: Option<RevisionNo>,
message: Option<&str>,
committed_content_ref: &ContentRef,
) -> Result<String, SemanticFingerprintError> {
let operation = FilesystemOperation::PutFile {
path: path.clone(),
content_ref: committed_content_ref.clone(),
behavior,
expected_revision_no,
};
semantic_commit_fingerprint(namespace_id, message, std::slice::from_ref(&operation))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ContentId;
#[test]
fn commit_fingerprint_value_is_pinned() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let fingerprint = semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/docs")])
.expect("fingerprint");
assert_eq!(
fingerprint,
"v0:sha256:85894f53a16c2c0be95afc39b245280101f3e2a414f044c87be8eb9f1980dbcd"
);
}
#[test]
fn guarded_delete_fingerprint_value_is_pinned() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let fingerprint = semantic_commit_fingerprint(
&namespace_id,
None,
&[FilesystemOperation::DeletePath {
path: AbsolutePath::parse("/docs").expect("path"),
behavior: DeleteDirectoryBehavior::NonRecursive,
expected_inode_id: Some(InodeId(42)),
}],
)
.expect("fingerprint");
assert_eq!(
fingerprint,
"v0:sha256:edc8e06bd0a651e9470198875ec44c8fcd7d9b95f162fe1d7ca46011c27e2818"
);
}
#[test]
fn undelete_fingerprint_value_is_pinned() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let fingerprint = semantic_commit_fingerprint(
&namespace_id,
None,
&[FilesystemOperation::Undelete {
inode_id: InodeId(42),
deleted_at_seq: ChangeSeq(17),
path: Some(AbsolutePath::parse("/docs/report.txt").expect("path")),
}],
)
.expect("fingerprint");
assert_eq!(
serde_json::to_value(Some("/docs/report.txt")).expect("serialize"),
serde_json::to_value("/docs/report.txt").expect("serialize"),
);
assert_eq!(
fingerprint,
"v0:sha256:1f4fa76d65aa64903a7d44cead91600a97c0bac9ec3a01ac51f0cd1130eff3d6"
);
}
#[test]
fn in_place_undelete_fingerprint_value_is_pinned() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let fingerprint = semantic_commit_fingerprint(
&namespace_id,
None,
&[FilesystemOperation::Undelete {
inode_id: InodeId(42),
deleted_at_seq: ChangeSeq(17),
path: None,
}],
)
.expect("fingerprint");
assert_eq!(
fingerprint,
"v0:sha256:4d7737cdc3888e3613dad0ec7d752e8daac089c8b528301cf0eba9307fa1cc4c"
);
}
#[test]
fn put_file_fingerprint_value_is_pinned() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let fingerprint = semantic_commit_fingerprint(
&namespace_id,
None,
&[FilesystemOperation::PutFile {
path: AbsolutePath::parse("/docs/report.txt").expect("path"),
content_ref: ContentRef::blob_v1(
ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
b"pinned put bytes",
),
behavior: DestinationBehavior::NoReplace,
expected_revision_no: None,
}],
)
.expect("fingerprint");
assert_eq!(
fingerprint,
"v0:sha256:3febc279ebb36c013f734095bebdba3c0a59bf8cbd82d205b53adbf00c112d59"
);
}
fn create_dir(path: &str) -> FilesystemOperation {
FilesystemOperation::CreateDirectory {
path: AbsolutePath::parse(path).expect("path"),
parents: false,
}
}
fn put(path: &str, content_ref: ContentRef) -> FilesystemOperation {
FilesystemOperation::PutFile {
path: AbsolutePath::parse(path).expect("path"),
content_ref,
behavior: DestinationBehavior::NoReplace,
expected_revision_no: None,
}
}
#[test]
fn checksum_evidence_is_outside_mutation_identity() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let content_ref = ContentRef::blob_v1(
ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
b"pinned put bytes",
);
let without_trusted_digest = ContentRef {
whole_file_sha256: None,
..content_ref.clone()
};
assert_eq!(
semantic_commit_fingerprint(
&namespace_id,
None,
&[put("/docs/report.txt", content_ref)]
)
.expect("fingerprint"),
semantic_commit_fingerprint(
&namespace_id,
None,
&[put("/docs/report.txt", without_trusted_digest)]
)
.expect("fingerprint")
);
}
#[test]
fn a_different_content_object_changes_mutation_identity() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let bytes = b"identical bytes, two uploads";
let first = ContentRef::blob_v1(ContentId::generate(), bytes);
let second = ContentRef::blob_v1(ContentId::generate(), bytes);
assert_ne!(
semantic_commit_fingerprint(&namespace_id, None, &[put("/docs/report.txt", first)])
.expect("fingerprint"),
semantic_commit_fingerprint(&namespace_id, None, &[put("/docs/report.txt", second)])
.expect("fingerprint")
);
}
#[test]
fn a_message_changes_mutation_identity() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let without = semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/docs")])
.expect("fingerprint");
let with = semantic_commit_fingerprint(
&namespace_id,
Some("import batch"),
&[create_dir("/docs")],
)
.expect("fingerprint");
assert_ne!(without, with);
}
#[test]
fn commit_fingerprint_changes_when_logical_inputs_change() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let baseline = semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/docs")])
.expect("baseline");
let changed = semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/drafts")])
.expect("changed");
assert_ne!(baseline, changed);
}
#[test]
fn operation_order_changes_mutation_identity() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
assert_ne!(
semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/a"), create_dir("/b")])
.expect("forward fingerprint"),
semantic_commit_fingerprint(&namespace_id, None, &[create_dir("/b"), create_dir("/a")])
.expect("reversed fingerprint")
);
}
#[test]
fn put_retry_fingerprint_matches_the_equivalent_single_operation_request() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let path = AbsolutePath::parse("/docs/report.txt").expect("path");
let content_ref = ContentRef::blob_v1(
ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
b"pinned put bytes",
);
let by_hand = semantic_commit_fingerprint(
&namespace_id,
Some("import batch"),
&[FilesystemOperation::PutFile {
path: path.clone(),
content_ref: content_ref.clone(),
behavior: DestinationBehavior::Replace,
expected_revision_no: Some(RevisionNo(4)),
}],
)
.expect("hand-built fingerprint");
assert_eq!(
put_retry_fingerprint(
&namespace_id,
&path,
DestinationBehavior::Replace,
Some(RevisionNo(4)),
Some("import batch"),
&content_ref,
)
.expect("retry fingerprint"),
by_hand
);
}
#[test]
fn put_retry_fingerprint_changes_with_every_request_field() {
let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
let path = AbsolutePath::parse("/a.txt").expect("path");
let content_ref = ContentRef::blob_v1(ContentId::generate(), b"hello");
let baseline = put_retry_fingerprint(
&namespace_id,
&path,
DestinationBehavior::Replace,
None,
None,
&content_ref,
)
.expect("baseline");
for (label, variant) in [
(
"path",
put_retry_fingerprint(
&namespace_id,
&AbsolutePath::parse("/b.txt").expect("path"),
DestinationBehavior::Replace,
None,
None,
&content_ref,
),
),
(
"behavior",
put_retry_fingerprint(
&namespace_id,
&path,
DestinationBehavior::NoReplace,
None,
None,
&content_ref,
),
),
(
"expected revision",
put_retry_fingerprint(
&namespace_id,
&path,
DestinationBehavior::Replace,
Some(RevisionNo(2)),
None,
&content_ref,
),
),
(
"message",
put_retry_fingerprint(
&namespace_id,
&path,
DestinationBehavior::Replace,
None,
Some(""),
&content_ref,
),
),
(
"namespace",
put_retry_fingerprint(
&NamespaceId::parse("other").expect("valid namespace id"),
&path,
DestinationBehavior::Replace,
None,
None,
&content_ref,
),
),
] {
assert_ne!(
baseline,
variant.expect("variant fingerprint"),
"a changed {label} must change the fingerprint"
);
}
}
}