use serde::{Deserialize, Serialize, de::DeserializeOwned};
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
use crate::seams::SourceBatch;
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
format!("sha256:{digest:x}")
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct Fingerprint([u8; 32]);
impl Fingerprint {
pub fn of<T: Serialize + ?Sized>(value: &T) -> Result<Fingerprint> {
let bytes = serde_json::to_vec(value)?;
Ok(Self(Sha256::digest(&bytes).into()))
}
pub fn combine(parts: &[Fingerprint]) -> Fingerprint {
let mut hasher = Sha256::new();
hasher.update((parts.len() as u64).to_le_bytes());
for part in parts {
hasher.update(part.0);
}
Self(hasher.finalize().into())
}
pub fn bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl std::fmt::Display for Fingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "sha256:")?;
for byte in self.0 {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}
impl std::fmt::Debug for Fingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Fingerprint({self})")
}
}
impl std::str::FromStr for Fingerprint {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
let hex = s
.strip_prefix("sha256:")
.ok_or_else(|| Error::validation("fingerprint must use the sha256: scheme"))?;
if hex.len() != 64 {
return Err(Error::validation(format!(
"fingerprint digest must be 64 hex chars, got {}",
hex.len()
)));
}
let mut bytes = [0u8; 32];
for (i, chunk) in hex.as_bytes().chunks_exact(2).enumerate() {
let hi = hex_val(chunk[0])?;
let lo = hex_val(chunk[1])?;
bytes[i] = (hi << 4) | lo;
}
Ok(Self(bytes))
}
}
fn hex_val(c: u8) -> Result<u8> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(Error::validation("fingerprint digest must be hex")),
}
}
impl From<Fingerprint> for String {
fn from(fp: Fingerprint) -> String {
fp.to_string()
}
}
impl TryFrom<String> for Fingerprint {
type Error = Error;
fn try_from(s: String) -> Result<Self> {
s.parse()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SourceFingerprint(Fingerprint);
impl SourceFingerprint {
pub fn of(batch: &SourceBatch) -> Result<SourceFingerprint> {
if batch.items().iter().any(|item| !item.is_finite()) {
return Err(Error::NonFinite {
context: "source batch fingerprint",
});
}
Ok(Self(Fingerprint::of(batch)?))
}
pub fn fingerprint(&self) -> Fingerprint {
self.0
}
}
impl std::fmt::Display for SourceFingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SchemaVersion(pub u32);
impl std::fmt::Display for SchemaVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "v{}", self.0)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SidecarEnvelope<M> {
pub schema: SchemaVersion,
pub meta: M,
}
impl<M: Serialize> SidecarEnvelope<M> {
pub fn encode(schema: SchemaVersion, meta: &M) -> Result<Vec<u8>>
where
M: Sized,
{
#[derive(Serialize)]
struct Borrowed<'a, M> {
schema: SchemaVersion,
meta: &'a M,
}
Ok(serde_json::to_vec_pretty(&Borrowed { schema, meta })?)
}
}
impl<M: DeserializeOwned> SidecarEnvelope<M> {
pub fn decode_slice(bytes: &[u8], expected: SchemaVersion) -> Result<M> {
#[derive(Deserialize)]
struct VersionOnly {
schema: Option<SchemaVersion>,
}
let version: VersionOnly = serde_json::from_slice(bytes)?;
match version.schema {
Some(found) if found == expected => {
let envelope: SidecarEnvelope<M> = serde_json::from_slice(bytes)?;
Ok(envelope.meta)
}
Some(found) => Err(Error::Checkpoint(format!(
"sidecar schema {found} does not match expected {expected}; migrate explicitly"
))),
None => Err(Error::Checkpoint(format!(
"sidecar carries no schema version (expected {expected})"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tensor::Tensor;
#[test]
fn fingerprint_displays_and_parses() {
let fp = Fingerprint::of(&"hello").unwrap();
let shown = fp.to_string();
assert!(shown.starts_with("sha256:"));
assert_eq!(shown.len(), 7 + 64);
assert_eq!(shown.parse::<Fingerprint>().unwrap(), fp);
assert!("sha256:short".parse::<Fingerprint>().is_err());
assert!("md5:0000".parse::<Fingerprint>().is_err());
}
#[test]
fn of_is_deterministic_and_value_sensitive() {
let a = Fingerprint::of(&[1u32, 2, 3]).unwrap();
let b = Fingerprint::of(&[1u32, 2, 3]).unwrap();
let c = Fingerprint::of(&[1u32, 2, 4]).unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn combine_is_order_and_length_sensitive() {
let a = Fingerprint::of(&1u8).unwrap();
let b = Fingerprint::of(&2u8).unwrap();
assert_ne!(Fingerprint::combine(&[a, b]), Fingerprint::combine(&[b, a]));
assert_ne!(Fingerprint::combine(&[a]), Fingerprint::combine(&[a, a]));
}
#[test]
fn source_fingerprint_rejects_non_finite() {
let good = SourceBatch::new(vec![Tensor::vector([0.5, -0.5])]);
assert!(SourceFingerprint::of(&good).is_ok());
let bad = SourceBatch::new(vec![Tensor::vector([f32::NAN])]);
assert!(matches!(
SourceFingerprint::of(&bad),
Err(Error::NonFinite { .. })
));
}
#[test]
fn envelope_round_trips_and_gates_versions() {
#[derive(Debug, PartialEq, Serialize, serde::Deserialize)]
struct Meta {
name: String,
}
let meta = Meta { name: "run".into() };
let bytes = SidecarEnvelope::encode(SchemaVersion(2), &meta).unwrap();
let decoded: Meta = SidecarEnvelope::decode_slice(&bytes, SchemaVersion(2)).unwrap();
assert_eq!(decoded, meta);
let err = SidecarEnvelope::<Meta>::decode_slice(&bytes, SchemaVersion(3)).unwrap_err();
assert!(matches!(err, Error::Checkpoint(_)));
let unversioned = br#"{"meta":{"name":"run"}}"#;
assert!(SidecarEnvelope::<Meta>::decode_slice(unversioned, SchemaVersion(2)).is_err());
}
#[test]
fn sha256_hex_matches_known_vector() {
assert_eq!(
sha256_hex(b""),
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}