use crate::LixError;
pub(crate) const COMMIT_SCOPE_DIGEST_BYTES: usize = 32;
const COMMIT_SCOPE_DIGEST_HASHES: usize = 4;
const COMMIT_SCOPE_DIGEST_CONTEXT: &str = "lix per-commit touched collection scope v1";
pub(crate) mod state {
pub(crate) const ABSENT: u8 = 0;
pub(crate) const OPAQUE: u8 = 1;
pub(crate) const EXACT: u8 = 2;
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct CommitScopeKey {
pub(crate) schema_key: String,
pub(crate) file_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, musli::Encode, musli::Decode)]
#[musli(packed)]
pub(crate) struct CommitTouchedScopeDigest {
pub(crate) state: u8,
#[musli(bytes)]
pub(crate) bits: Vec<u8>,
}
impl CommitTouchedScopeDigest {
pub(crate) fn absent() -> Self {
Self {
state: state::ABSENT,
bits: Vec::new(),
}
}
pub(crate) fn opaque() -> Self {
Self {
state: state::OPAQUE,
bits: Vec::new(),
}
}
pub(crate) fn exact<'a>(scopes: impl IntoIterator<Item = &'a CommitScopeKey>) -> Self {
let mut bits = vec![0u8; COMMIT_SCOPE_DIGEST_BYTES];
for scope in scopes {
for token in scope_tokens(scope) {
for bit in digest_bits(&token) {
bits[bit / 8] |= 1 << (bit % 8);
}
}
}
Self {
state: state::EXACT,
bits,
}
}
pub(crate) fn is_absent(&self) -> bool {
self.state == state::ABSENT
}
pub(crate) fn is_exact(&self) -> bool {
self.state == state::EXACT
}
pub(crate) fn proves_absent(&self, scope: &CommitScopeKey) -> bool {
if !self.is_exact() || self.bits.len() != COMMIT_SCOPE_DIGEST_BYTES {
return false;
}
let token = scope_token(scope);
digest_bits(&token)
.into_iter()
.any(|bit| self.bits[bit / 8] & (1 << (bit % 8)) == 0)
}
pub(crate) fn validate(&self) -> Result<(), LixError> {
match self.state {
state::EXACT => {
if self.bits.len() != COMMIT_SCOPE_DIGEST_BYTES {
return Err(LixError::unknown(
"exact commit touched-scope digest has the wrong length",
));
}
}
state::ABSENT | state::OPAQUE => {
if !self.bits.is_empty() {
return Err(LixError::unknown(
"unauthoritative commit touched-scope digest carries bits",
));
}
}
other => {
return Err(LixError::unknown(format!(
"commit touched-scope digest has unknown state {other}"
)));
}
}
Ok(())
}
}
fn scope_tokens(scope: &CommitScopeKey) -> [Vec<u8>; 2] {
[
scope_token(&CommitScopeKey {
schema_key: scope.schema_key.clone(),
file_id: None,
}),
scope_token(scope),
]
}
fn scope_token(scope: &CommitScopeKey) -> Vec<u8> {
let mut token = Vec::with_capacity(
scope.schema_key.len() + scope.file_id.as_ref().map_or(0, String::len) + 2,
);
token.extend_from_slice(scope.schema_key.as_bytes());
token.push(0);
if let Some(file_id) = scope.file_id.as_ref() {
token.push(1);
token.extend_from_slice(file_id.as_bytes());
}
token
}
fn digest_bits(token: &[u8]) -> [usize; COMMIT_SCOPE_DIGEST_HASHES] {
let digest = blake3::Hasher::new_derive_key(COMMIT_SCOPE_DIGEST_CONTEXT)
.update(&(token.len() as u64).to_be_bytes())
.update(token)
.finalize();
let bytes = digest.as_bytes();
std::array::from_fn(|index| {
let offset = index * 8;
let hash = u64::from_be_bytes(
bytes[offset..offset + 8]
.try_into()
.expect("BLAKE3 supplies four u64 digest hashes"),
);
hash as usize % (COMMIT_SCOPE_DIGEST_BYTES * 8)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn scope(schema_key: &str, file_id: Option<&str>) -> CommitScopeKey {
CommitScopeKey {
schema_key: schema_key.to_string(),
file_id: file_id.map(str::to_string),
}
}
#[test]
fn exact_digest_never_proves_a_present_scope_absent() {
let scopes = vec![
scope("lix_file_descriptor", None),
scope("lix_binary_blob_ref", Some("file-a")),
scope("lix_key_value", None),
];
let digest = CommitTouchedScopeDigest::exact(scopes.iter());
for scope in &scopes {
assert!(!digest.proves_absent(scope));
assert!(!digest.proves_absent(&CommitScopeKey {
schema_key: scope.schema_key.clone(),
file_id: None,
}));
}
}
#[test]
fn exact_digest_proves_untouched_schema_family_absent() {
let digest =
CommitTouchedScopeDigest::exact([&scope("lix_binary_blob_ref", Some("file-a"))]);
assert!(digest.proves_absent(&scope("lix_directory_descriptor", None)));
assert!(digest.proves_absent(&scope("lix_key_value", None)));
}
#[test]
fn exact_digest_proves_untouched_file_scope_absent() {
let digest =
CommitTouchedScopeDigest::exact([&scope("lix_binary_blob_ref", Some("file-a"))]);
assert!(digest.proves_absent(&scope("lix_binary_blob_ref", Some("file-b"))));
assert!(!digest.proves_absent(&scope("lix_binary_blob_ref", None)));
}
#[test]
fn absent_and_opaque_digests_prove_nothing() {
for digest in [
CommitTouchedScopeDigest::absent(),
CommitTouchedScopeDigest::opaque(),
] {
assert!(!digest.proves_absent(&scope("lix_key_value", None)));
digest.validate().expect("state is valid");
}
assert!(CommitTouchedScopeDigest::absent().is_absent());
assert!(!CommitTouchedScopeDigest::opaque().is_absent());
}
#[test]
fn empty_commit_digest_proves_every_scope_absent() {
let digest = CommitTouchedScopeDigest::exact(std::iter::empty());
assert!(digest.is_exact());
assert!(digest.proves_absent(&scope("lix_file_descriptor", None)));
assert_eq!(digest.bits.len(), COMMIT_SCOPE_DIGEST_BYTES);
}
#[test]
fn adding_the_base_commit_id_field_is_a_hard_cut_in_both_directions() {
use crate::common::LixTimestamp;
#[derive(Debug, musli::Encode, musli::Decode)]
#[musli(packed)]
struct CommitRecordV5 {
format_version: u32,
commit_id: crate::changelog::CommitId,
generation: u64,
parent_commit_ids: Vec<crate::changelog::CommitId>,
first_parent_jump_commit_id: crate::changelog::CommitId,
first_parent_jump_span: u64,
account_id: String,
created_at: LixTimestamp,
touched_scope_digest: CommitTouchedScopeDigest,
}
let commit_id = crate::changelog::CommitId::for_test_label("hard-cut-commit");
let created_at = LixTimestamp::expect_parse("hard cut test", "2026-08-12T00:00:00Z");
let old = CommitRecordV5 {
format_version: 5,
commit_id,
generation: 3,
parent_commit_ids: Vec::new(),
first_parent_jump_commit_id: commit_id,
first_parent_jump_span: 0,
account_id: "account".to_string(),
created_at,
touched_scope_digest: CommitTouchedScopeDigest::exact([&CommitScopeKey {
schema_key: "lix_file_descriptor".to_string(),
file_id: None,
}]),
};
let old_bytes = crate::storage_codec::encode("v5 commit record", &old).expect("encode v5");
let forward =
crate::storage_codec::decode::<crate::changelog::CommitRecord>("v6 read", &old_bytes);
assert!(
forward.is_err(),
"a v6 reader must not silently accept a v5 record"
);
let new = crate::changelog::CommitRecord {
format_version: crate::changelog::COMMIT_RECORD_FORMAT_VERSION,
base_commit_id: None,
commit_id,
generation: 3,
parent_commit_ids: Vec::new(),
first_parent_jump_commit_id: commit_id,
first_parent_jump_span: 0,
account_id: "account".to_string(),
created_at,
touched_scope_digest: CommitTouchedScopeDigest::exact([&CommitScopeKey {
schema_key: "lix_file_descriptor".to_string(),
file_id: None,
}]),
};
let new_bytes = crate::storage_codec::encode("v6 commit record", &new).expect("encode v6");
let backward = crate::storage_codec::decode::<CommitRecordV5>("v5 read", &new_bytes);
assert!(
backward.is_err(),
"a v5 reader must not silently accept a v6 record"
);
}
#[test]
fn digest_round_trips_through_the_record_codec() {
let digest = CommitTouchedScopeDigest::exact([&scope("lix_file_descriptor", None)]);
let encoded =
crate::storage_codec::encode("commit scope digest", &digest).expect("encode digest");
let decoded: CommitTouchedScopeDigest =
crate::storage_codec::decode("commit scope digest", &encoded).expect("decode digest");
assert_eq!(decoded, digest);
}
}