use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq)]
pub struct AssignmentDigestInput<'a> {
pub scan_id: &'a str,
pub tenant_uuid: uuid::Uuid,
pub namespace: &'a str,
pub table: &'a str,
pub schema_fingerprint_hex: &'a str,
pub scribe_cut: Option<&'a crate::vala::api::ScribeProviderCut>,
pub required_columns: &'a [String],
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AssignmentDigestError {
#[error("schema fingerprint must be 64 lowercase hex characters, got {0:?}")]
InvalidSchemaFingerprint(String),
#[error("{field} exceeds the u32 length domain encodable by the digest")]
LengthOverflow {
field: &'static str,
},
}
const ASSIGNMENT_AUTHORITY_DOMAIN: &[u8] = b"wyrd.oracle.assignment-authority.v9\0";
fn push_string(buffer: &mut Vec<u8>, value: &str) -> Result<(), AssignmentDigestError> {
push_bytes(buffer, value.as_bytes(), "string length")
}
fn push_bytes(
buffer: &mut Vec<u8>,
bytes: &[u8],
field: &'static str,
) -> Result<(), AssignmentDigestError> {
let len =
u32::try_from(bytes.len()).map_err(|_| AssignmentDigestError::LengthOverflow { field })?;
buffer.extend_from_slice(&len.to_be_bytes());
buffer.extend_from_slice(bytes);
Ok(())
}
fn push_count(
buffer: &mut Vec<u8>,
count: usize,
field: &'static str,
) -> Result<(), AssignmentDigestError> {
let count =
u32::try_from(count).map_err(|_| AssignmentDigestError::LengthOverflow { field })?;
buffer.extend_from_slice(&count.to_be_bytes());
Ok(())
}
fn push_option<T>(
buffer: &mut Vec<u8>,
value: Option<T>,
write: impl FnOnce(&mut Vec<u8>, T) -> Result<(), AssignmentDigestError>,
) -> Result<(), AssignmentDigestError> {
match value {
None => buffer.push(0),
Some(inner) => {
buffer.push(1);
write(buffer, inner)?;
}
}
Ok(())
}
fn push_time_partition(buffer: &mut Vec<u8>, partition: crate::vala::api::TimePartitionWire) {
buffer.push(partition.granularity_tag());
buffer.extend_from_slice(&partition.start_unix_micros().to_be_bytes());
}
#[expect(
clippy::unnecessary_wraps,
reason = "the Result is the callback shape push_option requires"
)]
fn push_scribe_cut(
buffer: &mut Vec<u8>,
cut: &crate::vala::api::ScribeProviderCut,
) -> Result<(), AssignmentDigestError> {
buffer.extend_from_slice(&cut.writer_epoch.to_be_bytes());
push_time_partition(buffer, cut.start_partition);
push_time_partition(buffer, cut.end_partition);
Ok(())
}
fn decode_schema_fingerprint(hex: &str) -> Result<[u8; 32], AssignmentDigestError> {
if hex.len() != 64
|| !hex
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(AssignmentDigestError::InvalidSchemaFingerprint(
hex.to_string(),
));
}
let mut out = [0u8; 32];
for (index, chunk) in out.iter_mut().enumerate() {
let byte_str = &hex[index * 2..index * 2 + 2];
*chunk = u8::from_str_radix(byte_str, 16)
.map_err(|_| AssignmentDigestError::InvalidSchemaFingerprint(hex.to_string()))?;
}
Ok(out)
}
fn push_assignment(
buffer: &mut Vec<u8>,
assignment: &AssignmentDigestInput<'_>,
) -> Result<(), AssignmentDigestError> {
push_string(buffer, assignment.scan_id)?;
buffer.extend_from_slice(assignment.tenant_uuid.as_bytes());
push_string(buffer, assignment.namespace)?;
push_string(buffer, assignment.table)?;
let fingerprint = decode_schema_fingerprint(assignment.schema_fingerprint_hex)?;
buffer.extend_from_slice(&fingerprint);
push_option(buffer, assignment.scribe_cut, push_scribe_cut)?;
push_count(
buffer,
assignment.required_columns.len(),
"required_columns",
)?;
for column in assignment.required_columns {
push_string(buffer, column)?;
}
Ok(())
}
pub fn encode_assignment_authority_bytes(
assignments: &[AssignmentDigestInput<'_>],
) -> Result<Vec<u8>, AssignmentDigestError> {
let mut buffer = Vec::new();
buffer.extend_from_slice(ASSIGNMENT_AUTHORITY_DOMAIN);
push_count(&mut buffer, assignments.len(), "assignment_count")?;
for assignment in assignments {
push_assignment(&mut buffer, assignment)?;
}
Ok(buffer)
}
pub fn assignment_authority_digest(
assignments: &[AssignmentDigestInput<'_>],
) -> Result<String, AssignmentDigestError> {
let bytes = encode_assignment_authority_bytes(assignments)?;
let digest = Sha256::digest(&bytes);
Ok(hex_lower(&digest))
}
#[must_use]
pub fn hex_lower(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vala::api::{ScribeProviderCut, TimeGranularityWire, TimePartitionWire};
fn normative_scribe_cut() -> ScribeProviderCut {
ScribeProviderCut {
writer_epoch: 7,
start_partition: hour_partition(1_787_493_600_000_000),
end_partition: hour_partition(1_787_497_200_000_000),
}
}
fn hour_partition(start_unix_micros: i64) -> TimePartitionWire {
let start = chrono::DateTime::from_timestamp_micros(start_unix_micros)
.expect("fixture start is representable");
TimePartitionWire::new(TimeGranularityWire::Hour, start)
.expect("fixture start is canonical")
}
fn base_vector() -> (String, uuid::Uuid, Vec<String>) {
let fingerprint = hex::encode((0u8..32).collect::<Vec<u8>>());
let tenant_uuid = uuid::Uuid::parse_str("00112233-4455-6677-8899-aabbccddeeff")
.expect("fixture tenant parses");
let required_columns = vec![
"service_name".to_string(),
"wyrd_ingested_at".to_string(),
"data_tenant_id".to_string(),
];
(fingerprint, tenant_uuid, required_columns)
}
fn digest_of(assignment: &AssignmentDigestInput<'_>) -> String {
assignment_authority_digest(std::slice::from_ref(assignment)).expect("fixture encodes")
}
#[test]
fn every_assignment_field_changes_the_digest() {
let (fingerprint, tenant_uuid, required_columns) = base_vector();
let cut = normative_scribe_cut();
let baseline = AssignmentDigestInput {
scan_id: "scan-1",
tenant_uuid,
namespace: "logs",
table: "records",
schema_fingerprint_hex: &fingerprint,
scribe_cut: Some(&cut),
required_columns: &required_columns,
};
let mut other_fingerprint = fingerprint.clone();
other_fingerprint.replace_range(0..2, "ff");
let mut other_epoch = cut.clone();
other_epoch.writer_epoch = 2;
let mut other_start = cut.clone();
other_start.start_partition = hour_partition(1_787_490_000_000_000);
let reordered = vec![
"wyrd_ingested_at".to_string(),
"service_name".to_string(),
"data_tenant_id".to_string(),
];
let digests = [
digest_of(&baseline),
digest_of(&AssignmentDigestInput {
scan_id: "scan-2",
..baseline.clone()
}),
digest_of(&AssignmentDigestInput {
tenant_uuid: uuid::Uuid::from_u128(1),
..baseline.clone()
}),
digest_of(&AssignmentDigestInput {
namespace: "logz",
..baseline.clone()
}),
digest_of(&AssignmentDigestInput {
table: "recordz",
..baseline.clone()
}),
digest_of(&AssignmentDigestInput {
schema_fingerprint_hex: &other_fingerprint,
..baseline.clone()
}),
digest_of(&AssignmentDigestInput {
scribe_cut: None,
..baseline.clone()
}),
digest_of(&AssignmentDigestInput {
scribe_cut: Some(&other_epoch),
..baseline.clone()
}),
digest_of(&AssignmentDigestInput {
scribe_cut: Some(&other_start),
..baseline.clone()
}),
digest_of(&AssignmentDigestInput {
required_columns: &reordered,
..baseline.clone()
}),
];
let distinct: std::collections::BTreeSet<_> = digests.iter().collect();
assert_eq!(distinct.len(), digests.len(), "every field is bound");
}
#[test]
fn noncanonical_partition_starts_are_unrepresentable() {
let off_hour = chrono::DateTime::from_timestamp_micros(1_787_493_600_000_001)
.expect("fixture start is representable");
assert!(TimePartitionWire::new(TimeGranularityWire::Hour, off_hour).is_err());
let mid_day = chrono::DateTime::from_timestamp_micros(1_787_493_600_000_000)
.expect("fixture start is representable");
assert!(TimePartitionWire::new(TimeGranularityWire::Day, mid_day).is_err());
}
#[test]
fn rejects_non_canonical_schema_fingerprint() {
let (_, tenant_uuid, required_columns) = base_vector();
let assignment = AssignmentDigestInput {
scan_id: "scan-1",
tenant_uuid,
namespace: "logs",
table: "records",
schema_fingerprint_hex: "not-hex",
scribe_cut: None,
required_columns: &required_columns,
};
assert!(matches!(
assignment_authority_digest(std::slice::from_ref(&assignment)),
Err(AssignmentDigestError::InvalidSchemaFingerprint(_))
));
}
}