use serde::{Deserialize, Serialize};
pub const SCHEMA_SNAPSHOT_REPORT_SCHEMA_VERSION: u16 = 1;
pub type SnapshotHash = [u8; 32];
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaSnapshot {
pub stable_id: String,
pub snapshot_schema_version: u16,
pub schema_hash: SnapshotHash,
pub fixture_hash: SnapshotHash,
}
impl SchemaSnapshot {
#[must_use]
pub fn from_hashes(
stable_id: impl Into<String>,
schema_hash: SnapshotHash,
fixture_hash: SnapshotHash,
) -> Self {
Self {
stable_id: stable_id.into(),
snapshot_schema_version: SCHEMA_SNAPSHOT_REPORT_SCHEMA_VERSION,
schema_hash,
fixture_hash,
}
}
#[cfg(feature = "blake3")]
#[cfg_attr(all(docsrs, not(batpak_stable_docs)), doc(cfg(feature = "blake3")))]
#[must_use]
pub fn from_bytes(
stable_id: impl Into<String>,
schema_bytes: &[u8],
fixture_bytes: &[u8],
) -> Self {
Self::from_hashes(
stable_id,
crate::event::hash::compute_hash(schema_bytes),
crate::event::hash::compute_hash(fixture_bytes),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SchemaChangeClass {
Unchanged,
Changed,
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum SchemaSnapshotFinding {
StableIdMismatch,
SnapshotSchemaVersionMismatch,
SchemaHashMismatch,
FixtureHashMismatch,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaSnapshotReportBody {
pub stable_id: String,
pub observed_stable_id: String,
pub schema_version: u16,
pub expected_snapshot_schema_version: u16,
pub observed_snapshot_schema_version: u16,
pub expected_schema_hash: SnapshotHash,
pub observed_schema_hash: SnapshotHash,
pub expected_fixture_hash: SnapshotHash,
pub observed_fixture_hash: SnapshotHash,
pub change_class: SchemaChangeClass,
pub findings: Vec<SchemaSnapshotFinding>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaSnapshotEvidenceReport {
pub body: SchemaSnapshotReportBody,
pub body_hash: SnapshotHash,
pub generated_at_unix_ms: Option<u64>,
pub batpak_version: Option<String>,
pub diagnostics: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SchemaSnapshotReportError {
BodyEncoding {
message: String,
},
}
impl std::fmt::Display for SchemaSnapshotReportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BodyEncoding { message } => {
write!(f, "schema snapshot report body encoding failed: {message}")
}
}
}
}
impl std::error::Error for SchemaSnapshotReportError {}
pub fn compare_schema_snapshot(
expected: &SchemaSnapshot,
observed: &SchemaSnapshot,
) -> Result<SchemaSnapshotEvidenceReport, SchemaSnapshotReportError> {
let mut findings = Vec::new();
if expected.stable_id != observed.stable_id {
findings.push(SchemaSnapshotFinding::StableIdMismatch);
}
if expected.snapshot_schema_version != observed.snapshot_schema_version {
findings.push(SchemaSnapshotFinding::SnapshotSchemaVersionMismatch);
}
if expected.schema_hash != observed.schema_hash {
findings.push(SchemaSnapshotFinding::SchemaHashMismatch);
}
if expected.fixture_hash != observed.fixture_hash {
findings.push(SchemaSnapshotFinding::FixtureHashMismatch);
}
crate::evidence::sort_findings(&mut findings);
let change_class = if findings.is_empty() {
SchemaChangeClass::Unchanged
} else {
SchemaChangeClass::Unknown
};
let body = SchemaSnapshotReportBody {
stable_id: expected.stable_id.clone(),
observed_stable_id: observed.stable_id.clone(),
schema_version: SCHEMA_SNAPSHOT_REPORT_SCHEMA_VERSION,
expected_snapshot_schema_version: expected.snapshot_schema_version,
observed_snapshot_schema_version: observed.snapshot_schema_version,
expected_schema_hash: expected.schema_hash,
observed_schema_hash: observed.schema_hash,
expected_fixture_hash: expected.fixture_hash,
observed_fixture_hash: observed.fixture_hash,
change_class,
findings,
};
let body_hash = report_body_hash(&body)?;
Ok(SchemaSnapshotEvidenceReport {
body,
body_hash,
generated_at_unix_ms: None,
batpak_version: None,
diagnostics: Vec::new(),
})
}
fn report_body_hash(
body: &SchemaSnapshotReportBody,
) -> Result<SnapshotHash, SchemaSnapshotReportError> {
crate::evidence::report_body_hash(body, |message| SchemaSnapshotReportError::BodyEncoding {
message,
})
}
#[cfg(test)]
mod tests {
use super::{
compare_schema_snapshot, SchemaChangeClass, SchemaSnapshot, SchemaSnapshotFinding,
};
use std::error::Error;
type TestResult = Result<(), Box<dyn Error>>;
fn hash(fill: u8) -> [u8; 32] {
[fill; 32]
}
#[test]
fn unchanged_snapshot_reports_unchanged_and_stable_hash() -> TestResult {
let expected = SchemaSnapshot::from_hashes("event.user.v1", hash(1), hash(2));
let observed = SchemaSnapshot::from_hashes("event.user.v1", hash(1), hash(2));
let first = compare_schema_snapshot(&expected, &observed)?;
let second = compare_schema_snapshot(&expected, &observed)?;
assert_eq!(first.body.change_class, SchemaChangeClass::Unchanged);
assert!(first.body.findings.is_empty());
assert_eq!(
first.body_hash, second.body_hash,
"PROPERTY: deterministic report body hash must remain stable for unchanged snapshots",
);
assert_eq!(first.body, second.body);
Ok(())
}
#[test]
fn changed_fixture_reports_deterministic_hash_mismatch() -> TestResult {
let expected = SchemaSnapshot::from_hashes("event.user.v1", hash(1), hash(2));
let observed = SchemaSnapshot::from_hashes("event.user.v1", hash(1), hash(9));
let report = compare_schema_snapshot(&expected, &observed)?;
assert_eq!(report.body.change_class, SchemaChangeClass::Unknown);
assert_eq!(
report.body.findings,
vec![SchemaSnapshotFinding::FixtureHashMismatch]
);
assert_eq!(report.body.expected_schema_hash, hash(1));
assert_eq!(report.body.observed_schema_hash, hash(1));
assert_eq!(report.body.expected_fixture_hash, hash(2));
assert_eq!(report.body.observed_fixture_hash, hash(9));
Ok(())
}
#[test]
fn drift_defaults_to_unknown_and_findings_order_is_deterministic() -> TestResult {
let expected = SchemaSnapshot::from_hashes("event.user.v1", hash(1), hash(2));
let observed = SchemaSnapshot::from_hashes("event.user.v2", hash(7), hash(9));
let report = compare_schema_snapshot(&expected, &observed)?;
assert_eq!(report.body.change_class, SchemaChangeClass::Unknown);
assert_eq!(
report.body.findings,
vec![
SchemaSnapshotFinding::StableIdMismatch,
SchemaSnapshotFinding::SchemaHashMismatch,
SchemaSnapshotFinding::FixtureHashMismatch,
],
"PROPERTY: findings must be emitted in deterministic order",
);
assert_eq!(report.body.stable_id, "event.user.v1");
assert_eq!(report.body.observed_stable_id, "event.user.v2");
assert!(!report.body.stable_id.contains("->"));
Ok(())
}
}