use std::ffi::CStr;
use super::cell::slice;
use super::sys;
use crate::contract::schema::{self, SCHEMAS};
pub(super) fn drift(
digest: sys::SchemaDigestFn,
introspect: Option<(sys::SchemaCountFn, sys::SchemaGetFn)>,
) -> Option<String> {
let live = cstr(unsafe { digest() });
if live == schema::DIGEST {
return None;
}
let table = introspect.and_then(|(count, get)| engine_table(count, get));
Some(message(&live, table.as_deref()))
}
pub(super) struct Declared {
pub(super) name: String,
pub(super) fields: Vec<(String, u32, u32)>,
}
fn engine_table(count: sys::SchemaCountFn, get: sys::SchemaGetFn) -> Option<Vec<Declared>> {
(1..=unsafe { count() })
.map(|id| {
let mut out = sys::Schema {
struct_size: super::struct_size::<sys::Schema>(),
id: 0,
name: std::ptr::null(),
nfields: 0,
reserved: 0,
fields: std::ptr::null(),
};
if unsafe { get(id, &raw mut out) } != sys::OK || out.name.is_null() {
return None;
}
let fields = unsafe { slice(out.fields, out.nfields as usize) }
.iter()
.map(|f| (cstr(f.name), f.tag, f.nested))
.collect();
Some(Declared {
name: cstr(out.name),
fields,
})
})
.collect()
}
fn cstr(p: *const std::os::raw::c_char) -> String {
if p.is_null() {
return String::new();
}
unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
}
pub(super) fn message(live_digest: &str, engine: Option<&[Declared]>) -> String {
let named = engine
.and_then(first_disagreement)
.unwrap_or_else(|| "no schema-level detail available".to_owned());
format!(
"engine digest {live_digest} != generated {generated}; {named}. Regenerate the bindings \
(`python3 tools/build_schema_tables.py`) or rebuild libirgx \
so both sides speak the same [row_schemas].",
generated = schema::DIGEST
)
}
pub(super) fn first_disagreement(engine: &[Declared]) -> Option<String> {
if engine.len() != SCHEMAS.len() {
return Some(format!(
"engine declares {} schemas, this build knows {}",
engine.len(),
SCHEMAS.len()
));
}
for (live, known) in engine.iter().zip(SCHEMAS) {
if live.name != known.name {
return Some(format!(
"schema {} is `{}` in the engine, `{}` here",
known.id, live.name, known.name
));
}
if live.fields.len() != known.fields.len() {
return Some(format!(
"schema `{}` has {} fields in the engine, {} here",
known.name,
live.fields.len(),
known.fields.len()
));
}
for ((lname, ltag, lnested), k) in live.fields.iter().zip(known.fields) {
if lname != k.name || *ltag != k.tag || *lnested != k.nested {
return Some(format!(
"schema `{}` field `{lname}` (tag {ltag}, nested {lnested}) where `{}` \
(tag {}, nested {}) is declared",
known.name, k.name, k.tag, k.nested
));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn agreeing() -> Vec<Declared> {
SCHEMAS
.iter()
.map(|s| Declared {
name: s.name.to_owned(),
fields: s
.fields
.iter()
.map(|f| (f.name.to_owned(), f.tag, f.nested))
.collect(),
})
.collect()
}
#[test]
fn a_renamed_schema_is_named_in_the_failure() {
let mut table = agreeing();
let victim = SCHEMAS.first().expect("the contract declares schemas");
table[0].name = "regions".to_owned();
let msg = message("deadbeef", Some(&table));
assert!(msg.contains("deadbeef"), "{msg}");
assert!(msg.contains(schema::DIGEST), "{msg}");
assert!(msg.contains("`regions`"), "{msg}");
assert!(msg.contains(victim.name), "{msg}");
}
#[test]
fn a_retyped_field_is_named_with_both_tags() {
let at = SCHEMAS
.iter()
.position(|s| s.name == "similar")
.expect("the contract declares `similar`");
let declared = SCHEMAS[at]
.fields
.iter()
.find(|f| f.name == "distance")
.expect("the contract declares similar.distance");
let mut table = agreeing();
for cell in &mut table[at].fields {
if cell.0 == declared.name {
cell.1 = sys::VAL_I64;
}
}
let msg = message("deadbeef", Some(&table));
assert!(msg.contains("similar"), "{msg}");
assert!(msg.contains("distance"), "{msg}");
assert!(msg.contains(&format!("tag {}", sys::VAL_I64)), "{msg}");
assert!(msg.contains(&format!("tag {}", declared.tag)), "{msg}");
}
#[test]
fn a_truncated_engine_table_is_counted_not_zipped_past() {
let mut table = agreeing();
table.pop();
let msg = message("deadbeef", Some(&table));
assert!(
msg.contains(&format!("this build knows {}", SCHEMAS.len())),
"{msg}"
);
}
#[test]
fn an_unexplainable_digest_mismatch_still_fails_loud() {
for engine in [None, Some(agreeing())] {
let msg = message("deadbeef", engine.as_deref());
assert!(msg.contains("deadbeef"), "{msg}");
assert!(msg.contains("Regenerate the bindings"), "{msg}");
}
assert!(first_disagreement(&agreeing()).is_none());
}
#[test]
fn the_generated_digest_is_a_real_fingerprint() {
assert_eq!(schema::DIGEST.len(), 32, "the digest is an md5 hex string");
assert!(schema::DIGEST.chars().all(|c| c.is_ascii_hexdigit()));
}
}