use std::fmt;
use std::path::Path;
use std::sync::Mutex;
pub const MAX_SUPPORTED_ZNIPPY_FORMAT: u32 = 3;
pub const ZNIPPY_FORMAT_VERSION_KEY: &str = "znippy_format_version";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveFormatError {
pub path: String,
pub recorded: String,
pub max_supported: u32,
}
impl fmt::Display for ArchiveFormatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"holger refuses znippy archive {}: it records format version {}, \
which this build does not read (supports up to v{}) — re-pack the \
archive with a matching znippy, or upgrade holger",
self.path, self.recorded, self.max_supported
)
}
}
impl std::error::Error for ArchiveFormatError {}
pub fn recorded_format_version(path: &Path) -> Option<String> {
let path = path.to_path_buf();
std::panic::catch_unwind(move || read_recorded_version(&path))
.ok()
.flatten()
}
fn read_recorded_version(path: &Path) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
use znippy_common::arrow::ipc::reader::StreamReader;
let entries = znippy_common::read_znippy_manifest(path).ok()?;
let mut file = std::fs::File::open(path).ok()?;
let file_len = file.metadata().ok()?.len();
for entry in &entries {
if znippy_common::is_reserved_module(&entry.module_name) {
continue;
}
let end = entry.index_offset.checked_add(entry.index_len)?;
if end > file_len {
return None;
}
file.seek(SeekFrom::Start(entry.index_offset)).ok()?;
let mut bytes = vec![0u8; entry.index_len as usize];
file.read_exact(&mut bytes).ok()?;
let reader = StreamReader::try_new(std::io::Cursor::new(bytes), None).ok()?;
return reader
.schema()
.metadata()
.get(ZNIPPY_FORMAT_VERSION_KEY)
.cloned();
}
None
}
pub fn ensure_supported_format(path: &Path) -> Result<(), ArchiveFormatError> {
let Some(recorded) = recorded_format_version(path) else {
return Ok(());
};
let refuse = || ArchiveFormatError {
path: path.display().to_string(),
recorded: recorded.clone(),
max_supported: MAX_SUPPORTED_ZNIPPY_FORMAT,
};
match recorded.parse::<u32>() {
Ok(v) if v <= MAX_SUPPORTED_ZNIPPY_FORMAT => Ok(()),
_ => Err(refuse()),
}
}
#[derive(Debug, Default)]
pub struct FormatGate {
passed: Mutex<Option<(u64, i128)>>,
}
impl FormatGate {
pub fn new() -> Self {
Self::default()
}
pub fn check(&self, path: &Path) -> Result<(), ArchiveFormatError> {
let stamp = file_stamp(path);
if let Some(s) = stamp {
if self.passed.lock().is_ok_and(|g| *g == Some(s)) {
return Ok(());
}
}
ensure_supported_format(path)?;
if let (Some(s), Ok(mut g)) = (stamp, self.passed.lock()) {
*g = Some(s);
}
Ok(())
}
}
fn file_stamp(path: &Path) -> Option<(u64, i128)> {
let md = std::fs::metadata(path).ok()?;
let mtime = md
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or(-1);
Some((md.len(), mtime))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_or_garbage_archive_is_undetermined_not_refused() {
assert_eq!(recorded_format_version(Path::new("/nonexistent/x.znippy")), None);
assert!(ensure_supported_format(Path::new("/nonexistent/x.znippy")).is_ok());
let dir = tempfile::tempdir().unwrap();
let junk = dir.path().join("junk.znippy");
std::fs::write(&junk, b"this is not a znippy archive at all").unwrap();
assert_eq!(recorded_format_version(&junk), None);
assert!(ensure_supported_format(&junk).is_ok());
}
#[test]
fn the_pin_refuses_newer_and_admits_current_and_older() {
let verdict = |raw: &str| -> bool {
raw.parse::<u32>()
.map(|v| v <= MAX_SUPPORTED_ZNIPPY_FORMAT)
.unwrap_or(false)
};
assert!(verdict(&MAX_SUPPORTED_ZNIPPY_FORMAT.to_string()));
assert!(verdict("1"));
assert!(!verdict(&(MAX_SUPPORTED_ZNIPPY_FORMAT + 1).to_string()));
assert!(!verdict("99"));
assert!(!verdict("not-a-version"));
}
#[test]
fn the_refusal_names_the_archive_the_version_and_the_ceiling() {
let e = ArchiveFormatError {
path: "/srv/holger/drift.znippy".into(),
recorded: "39".into(),
max_supported: MAX_SUPPORTED_ZNIPPY_FORMAT,
};
let msg = e.to_string();
assert!(msg.contains("holger refuses"), "{msg}");
assert!(msg.contains("/srv/holger/drift.znippy"), "{msg}");
assert!(msg.contains("39"), "{msg}");
assert!(msg.contains(&format!("v{MAX_SUPPORTED_ZNIPPY_FORMAT}")), "{msg}");
let boxed: Box<dyn std::error::Error> = Box::new(e);
assert!(boxed.downcast_ref::<ArchiveFormatError>().is_some());
}
#[test]
fn the_gate_memo_is_keyed_on_the_file_not_the_path() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("a.znippy");
std::fs::write(&p, b"garbage-v1").unwrap();
let gate = FormatGate::new();
assert!(gate.check(&p).is_ok());
let first = *gate.passed.lock().unwrap();
assert!(first.is_some(), "a stat-able file must be memoised");
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&p, b"garbage-v2-longer").unwrap();
assert!(gate.check(&p).is_ok());
assert_ne!(first, *gate.passed.lock().unwrap(), "a changed file must be re-gated");
}
}