use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Instant, SystemTime};
use sha2::{Digest, Sha256};
use crate::error::{RapidOcrError, Result};
use crate::model_store::sha256_file;
pub const PARTIAL_DIGEST_WINDOW_BYTES: u64 = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PartialDigest {
size: u64,
head: [u8; 32],
tail: [u8; 32],
}
impl PartialDigest {
pub fn compute(path: &Path, size: u64) -> Result<Self> {
let mut file = std::fs::File::open(path)?;
let head = read_window(&mut file, 0, size)?;
let tail_start = size.saturating_sub(PARTIAL_DIGEST_WINDOW_BYTES);
let tail = read_window(&mut file, tail_start, size)?;
Ok(Self {
size,
head: Sha256::digest(&head).into(),
tail: Sha256::digest(&tail).into(),
})
}
pub fn size(&self) -> u64 {
self.size
}
fn head(&self) -> &[u8; 32] {
&self.head
}
fn tail(&self) -> &[u8; 32] {
&self.tail
}
pub fn describe(&self) -> String {
format!(
"size={} B, head={}, tail={}",
self.size,
hex16(self.head()),
hex16(self.tail())
)
}
}
fn read_window(file: &mut std::fs::File, offset: u64, size: u64) -> Result<Vec<u8>> {
if size == 0 || offset >= size {
return Ok(Vec::new());
}
let wanted = (size - offset).min(PARTIAL_DIGEST_WINDOW_BYTES);
file.seek(SeekFrom::Start(offset))?;
let mut window = Vec::with_capacity(wanted as usize);
file.take(wanted).read_to_end(&mut window)?;
Ok(window)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileIdentity {
path: PathBuf,
size: u64,
modified: Option<SystemTime>,
partial: PartialDigest,
}
impl FileIdentity {
pub fn of(path: &Path) -> Result<Self> {
let metadata = std::fs::metadata(path)?;
let size = metadata.len();
Ok(Self {
path: path.to_path_buf(),
size,
modified: metadata.modified().ok(),
partial: PartialDigest::compute(path, size)?,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn size(&self) -> u64 {
self.size
}
pub fn modified(&self) -> Option<SystemTime> {
self.modified
}
pub fn partial(&self) -> &PartialDigest {
&self.partial
}
pub fn describe(&self) -> String {
format!(
"size={} B, mtime={}, {}",
self.size,
match self.modified {
Some(value) => format!("{value:?}"),
None => "<unavailable>".to_string(),
},
self.partial.describe()
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReverifyCause {
FirstSight,
StatChanged,
ContentChanged,
CacheHit,
}
impl ReverifyCause {
pub const fn as_str(self) -> &'static str {
match self {
Self::FirstSight => "first_sight",
Self::StatChanged => "stat_changed",
Self::ContentChanged => "content_changed",
Self::CacheHit => "cache_hit",
}
}
pub const fn computed(self) -> bool {
!matches!(self, Self::CacheHit)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerificationOutcome {
pub sha256: String,
pub computed: bool,
pub identity: FileIdentity,
pub cause: ReverifyCause,
}
#[derive(Debug, Clone)]
struct CachedDigest {
size: u64,
modified: Option<SystemTime>,
partial: PartialDigest,
sha256: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerificationStats {
pub cold_verifications: u64,
pub cache_hits: u64,
pub partial_mismatches: u64,
pub partial_reads: u64,
pub last_cold_micros: Option<u64>,
pub last_cold_bytes: u64,
pub entries: usize,
}
impl VerificationStats {
pub fn last_cold_ms(&self) -> Option<f64> {
self.last_cold_micros.map(|micros| micros as f64 / 1000.0)
}
}
fn cache() -> &'static Mutex<HashMap<PathBuf, CachedDigest>> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, CachedDigest>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
static COLD_VERIFICATIONS: AtomicU64 = AtomicU64::new(0);
static CACHE_HITS: AtomicU64 = AtomicU64::new(0);
static PARTIAL_MISMATCHES: AtomicU64 = AtomicU64::new(0);
static PARTIAL_READS: AtomicU64 = AtomicU64::new(0);
static LAST_COLD_MICROS: AtomicU64 = AtomicU64::new(0);
static LAST_COLD_BYTES: AtomicU64 = AtomicU64::new(0);
static HAVE_COLD: AtomicU64 = AtomicU64::new(0);
pub fn verify_file(path: &Path) -> Result<VerificationOutcome> {
let identity = FileIdentity::of(path)?;
return_with_cache(path, identity, false)
}
pub fn force_verify_file(path: &Path) -> Result<VerificationOutcome> {
let identity = FileIdentity::of(path)?;
return_with_cache(path, identity, true)
}
fn return_with_cache(
path: &Path,
identity: FileIdentity,
force: bool,
) -> Result<VerificationOutcome> {
PARTIAL_READS.fetch_add(1, Ordering::Relaxed);
if !force {
match lookup(&identity) {
Lookup::Hit(sha256) => {
CACHE_HITS.fetch_add(1, Ordering::Relaxed);
refresh_stat_identity(&identity);
return Ok(VerificationOutcome {
sha256,
computed: false,
identity,
cause: ReverifyCause::CacheHit,
});
}
Lookup::Miss(cause) => {
if cause == ReverifyCause::ContentChanged {
PARTIAL_MISMATCHES.fetch_add(1, Ordering::Relaxed);
}
return hashed(path, identity, cause);
}
}
}
hashed(path, identity, ReverifyCause::FirstSight)
}
fn hashed(
path: &Path,
identity: FileIdentity,
cause: ReverifyCause,
) -> Result<VerificationOutcome> {
let started = Instant::now();
let sha256 = sha256_file(path)?;
let elapsed = started.elapsed();
store(&identity, &sha256);
COLD_VERIFICATIONS.fetch_add(1, Ordering::Relaxed);
LAST_COLD_MICROS.store(
u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
LAST_COLD_BYTES.store(identity.size, Ordering::Relaxed);
HAVE_COLD.store(1, Ordering::Relaxed);
Ok(VerificationOutcome {
sha256,
computed: true,
identity,
cause,
})
}
pub fn sha256_file_cached(path: &Path) -> Result<String> {
Ok(verify_file(path)?.sha256)
}
pub fn verify_sha256(path: &Path, expected: Option<&str>) -> Result<Option<String>> {
let Some(expected) = expected else {
return Ok(None);
};
let outcome = verify_file(path)?;
if !outcome.sha256.eq_ignore_ascii_case(expected) {
return Err(RapidOcrError::HashMismatch {
path: path.to_path_buf(),
expected: expected.to_string(),
actual: outcome.sha256,
});
}
Ok(Some(outcome.sha256))
}
pub fn normalize_expected_sha256(expected: &str) -> String {
expected.to_ascii_lowercase()
}
#[derive(Clone)]
pub struct VerifiedModel {
path: PathBuf,
expected_sha256: Option<String>,
actual_sha256: Option<String>,
content: Arc<[u8]>,
}
impl core::fmt::Debug for VerifiedModel {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("VerifiedModel")
.field("path", &self.path)
.field("expected_sha256", &self.expected_sha256)
.field("actual_sha256", &self.actual_sha256)
.field("content_bytes", &self.content.len())
.finish()
}
}
impl VerifiedModel {
pub fn verify(path: &Path, expected: Option<&str>) -> Result<Self> {
if !path.is_file() {
return Err(RapidOcrError::FileNotFound(path.to_path_buf()));
}
let content: Arc<[u8]> = std::fs::read(path)?.into();
let actual_sha256 = expected
.map(|expected| {
let actual = format!("{:x}", Sha256::digest(&content));
invalidate_if_falsified(path, &actual);
if !actual.eq_ignore_ascii_case(expected) {
return Err(RapidOcrError::HashMismatch {
path: path.to_path_buf(),
expected: expected.to_string(),
actual,
});
}
Ok(actual)
})
.transpose()?;
Ok(Self {
path: path.to_path_buf(),
expected_sha256: expected.map(|value| value.to_string()),
actual_sha256,
content,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn content(&self) -> &[u8] {
&self.content
}
pub(crate) fn content_arc(&self) -> Arc<[u8]> {
Arc::clone(&self.content)
}
pub fn expected_sha256(&self) -> Option<&str> {
self.expected_sha256.as_deref()
}
pub fn actual_sha256(&self) -> Option<&str> {
self.actual_sha256.as_deref()
}
pub fn is_verified(&self) -> bool {
self.actual_sha256.is_some()
}
}
pub(crate) fn missing_model_path_error(domain: &str) -> RapidOcrError {
RapidOcrError::InvalidInput(format!(
"{domain} policy requires `model_path` when enabled"
))
}
pub fn verification_stats() -> VerificationStats {
let entries = cache().lock().map(|guard| guard.len()).unwrap_or_default();
VerificationStats {
cold_verifications: COLD_VERIFICATIONS.load(Ordering::Relaxed),
cache_hits: CACHE_HITS.load(Ordering::Relaxed),
partial_mismatches: PARTIAL_MISMATCHES.load(Ordering::Relaxed),
partial_reads: PARTIAL_READS.load(Ordering::Relaxed),
last_cold_micros: (HAVE_COLD.load(Ordering::Relaxed) == 1)
.then(|| LAST_COLD_MICROS.load(Ordering::Relaxed)),
last_cold_bytes: LAST_COLD_BYTES.load(Ordering::Relaxed),
entries,
}
}
pub fn clear_verification_cache() {
if let Ok(mut guard) = cache().lock() {
guard.clear();
}
COLD_VERIFICATIONS.store(0, Ordering::Relaxed);
CACHE_HITS.store(0, Ordering::Relaxed);
PARTIAL_MISMATCHES.store(0, Ordering::Relaxed);
PARTIAL_READS.store(0, Ordering::Relaxed);
LAST_COLD_MICROS.store(0, Ordering::Relaxed);
LAST_COLD_BYTES.store(0, Ordering::Relaxed);
HAVE_COLD.store(0, Ordering::Relaxed);
}
enum Lookup {
Hit(String),
Miss(ReverifyCause),
}
fn lookup(identity: &FileIdentity) -> Lookup {
let Ok(guard) = cache().lock() else {
return Lookup::Miss(ReverifyCause::FirstSight);
};
let Some(entry) = guard.get(&identity.path) else {
return Lookup::Miss(ReverifyCause::FirstSight);
};
if entry.size != identity.size {
return Lookup::Miss(ReverifyCause::StatChanged);
}
if entry.partial != identity.partial {
return Lookup::Miss(ReverifyCause::ContentChanged);
}
Lookup::Hit(entry.sha256.clone())
}
fn refresh_stat_identity(identity: &FileIdentity) {
let Ok(mut guard) = cache().lock() else {
return;
};
if let Some(entry) = guard.get_mut(&identity.path) {
entry.size = identity.size;
entry.modified = identity.modified;
}
}
fn store(identity: &FileIdentity, sha256: &str) {
let Ok(mut guard) = cache().lock() else {
return;
};
guard.insert(
identity.path.clone(),
CachedDigest {
size: identity.size,
modified: identity.modified,
partial: identity.partial.clone(),
sha256: sha256.to_string(),
},
);
}
fn invalidate_if_falsified(path: &Path, actual_sha256: &str) -> bool {
let Ok(mut guard) = cache().lock() else {
return false;
};
let Some(entry) = guard.get(path) else {
return false;
};
if entry.sha256.eq_ignore_ascii_case(actual_sha256) {
return false;
}
guard.remove(path);
true
}
fn hex16(bytes: &[u8; 32]) -> String {
bytes[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[cfg(test)]
fn lookup_for_test(path: &Path) -> std::result::Result<ReverifyCause, RapidOcrError> {
let identity = FileIdentity::of(path)?;
Ok(match lookup(&identity) {
Lookup::Hit(_) => ReverifyCause::CacheHit,
Lookup::Miss(cause) => cause,
})
}
#[cfg(test)]
mod tests {
use std::io::{Seek, SeekFrom, Write};
use std::path::Path;
use super::{
FileIdentity, PARTIAL_DIGEST_WINDOW_BYTES, PartialDigest, ReverifyCause, VerifiedModel,
clear_verification_cache, force_verify_file, invalidate_if_falsified, lookup_for_test,
normalize_expected_sha256, sha256_file_cached, verification_stats, verify_file,
verify_sha256,
};
use crate::error::RapidOcrError;
use crate::model_store::sha256_file;
use crate::test_support::TempDir;
const HELLO_SHA256: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
fn serial() -> std::sync::MutexGuard<'static, ()> {
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner());
clear_verification_cache();
guard
}
fn pattern(len: usize, tag: u8) -> Vec<u8> {
(0..len)
.map(|index| index.wrapping_mul(7).wrapping_add(tag as usize) as u8)
.collect()
}
fn write(path: &Path, bytes: &[u8]) {
std::fs::write(path, bytes).expect("write the fixture");
}
fn rewrite_same_identity(
path: &Path,
offset: usize,
replacement: &[u8],
) -> std::time::SystemTime {
let before = std::fs::metadata(path)
.expect("metadata")
.modified()
.expect("mtime");
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(path)
.expect("open for rewrite");
file.seek(SeekFrom::Start(offset as u64))
.expect("seek to the edit");
file.write_all(replacement).expect("rewrite in place");
file.flush().expect("flush");
}
let file = std::fs::OpenOptions::new()
.write(true)
.open(path)
.expect("open to restore the timestamp");
file.set_modified(before).expect("write the old mtime back");
drop(file);
std::fs::metadata(path)
.expect("metadata")
.modified()
.expect("mtime")
}
#[test]
fn the_cached_digest_equals_the_uncached_one_and_is_only_computed_once() {
let _serial = serial();
let dir = TempDir::new("verify-once");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
let first = verify_file(&path).expect("hash");
assert_eq!(first.sha256, HELLO_SHA256);
assert_eq!(
first.sha256,
sha256_file(&path).expect("the uncached digest")
);
assert!(first.computed, "the first ask must hash");
assert_eq!(first.cause, ReverifyCause::FirstSight);
assert_eq!(first.identity.size(), 5);
assert_eq!(first.identity.partial().size(), 5);
for _ in 0..5 {
let again = verify_file(&path).expect("hash");
assert_eq!(again.sha256, HELLO_SHA256);
assert!(
!again.computed,
"a cache hit must not re-hash (the per-call flag is the instrumentation)"
);
assert_eq!(again.cause, ReverifyCause::CacheHit);
assert_eq!(again.identity, first.identity);
}
assert_eq!(sha256_file_cached(&path).expect("hash"), HELLO_SHA256);
}
#[test]
fn a_size_change_forces_a_reverification() {
let _serial = serial();
let dir = TempDir::new("verify-size");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
assert!(verify_file(&path).expect("hash").computed);
std::fs::write(&path, b"hello world").expect("replace with a longer body");
let replaced = verify_file(&path).expect("hash");
assert_eq!(replaced.sha256, sha256_file(&path).expect("uncached"));
assert_ne!(replaced.sha256, HELLO_SHA256);
assert!(replaced.computed, "a new identity must re-hash");
assert_eq!(replaced.cause, ReverifyCause::StatChanged);
}
#[test]
fn a_mtime_change_forces_a_reverification() {
let _serial = serial();
let dir = TempDir::new("verify-mtime");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
let before = FileIdentity::of(&path).expect("identity");
assert!(verify_file(&path).expect("hash").computed);
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(&path)
.expect("open for rewrite");
file.write_all(b"HELLO").expect("rewrite the same length");
file.set_modified(before.modified().expect("mtime") + std::time::Duration::from_secs(5))
.expect("bump mtime");
drop(file);
let after = FileIdentity::of(&path).expect("identity");
assert_eq!(after.size(), before.size(), "the size must be unchanged");
assert_ne!(after, before, "the identity must differ");
let digest = verify_file(&path).expect("hash");
assert_eq!(digest.sha256, sha256_file(&path).expect("uncached"));
assert!(digest.computed, "a changed identity must re-hash");
assert_eq!(
digest.cause,
ReverifyCause::ContentChanged,
"the content really changed, and that is the reason to report"
);
}
#[test]
fn a_mismatch_is_a_locating_error_and_a_match_passes_through() {
let dir = TempDir::new("verify-mismatch");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
verify_sha256(&path, None).expect("no expectation is allowed and records as unverified");
assert_eq!(
verify_sha256(&path, Some(HELLO_SHA256)).expect("match"),
Some(HELLO_SHA256.to_string())
);
let error = verify_sha256(&path, Some("deadbeef")).expect_err("a mismatch must fail");
match error {
RapidOcrError::HashMismatch {
path: reported,
expected,
actual,
} => {
assert_eq!(reported, path);
assert_eq!(expected, "deadbeef");
assert_eq!(actual, HELLO_SHA256);
}
other => panic!("expected HashMismatch, got {other:?}"),
}
}
#[test]
fn a_credential_matches_and_records_the_actual_digest() {
let dir = TempDir::new("credential-match");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
let credential = VerifiedModel::verify(&path, Some(HELLO_SHA256)).expect("match");
assert!(credential.is_verified());
assert_eq!(credential.expected_sha256(), Some(HELLO_SHA256));
assert_eq!(credential.actual_sha256(), Some(HELLO_SHA256));
assert_eq!(credential.path(), path);
}
#[test]
fn a_credential_mismatch_is_locating_and_yields_no_credential() {
let dir = TempDir::new("credential-mismatch");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
let error = VerifiedModel::verify(&path, Some("deadbeef")).expect_err("mismatch must fail");
match error {
RapidOcrError::HashMismatch {
path: reported,
expected,
actual,
} => {
assert_eq!(reported, path);
assert_eq!(expected, "deadbeef");
assert_eq!(actual, HELLO_SHA256);
}
other => panic!("expected HashMismatch, got {other:?}"),
}
}
#[test]
fn a_credential_without_an_expectation_is_recorded_as_unverified() {
let dir = TempDir::new("credential-unverified");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
let credential = VerifiedModel::verify(&path, None).expect("no expectation is allowed");
assert!(
!credential.is_verified(),
"no expectation => recorded as unverified"
);
assert_eq!(credential.expected_sha256(), None);
assert_eq!(credential.actual_sha256(), None);
assert_eq!(credential.path(), path);
assert_eq!(credential.content(), b"hello");
}
#[test]
fn the_key_normalization_only_folds_what_verification_accepts() {
assert_eq!(normalize_expected_sha256(HELLO_SHA256), HELLO_SHA256);
let upper = HELLO_SHA256.to_uppercase();
assert_ne!(upper, HELLO_SHA256, "the two spellings differ in case");
assert_eq!(normalize_expected_sha256(&upper), HELLO_SHA256);
let dir = TempDir::new("normalize-credential");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
let normalized = normalize_expected_sha256(&upper);
VerifiedModel::verify(&path, Some(&normalized))
.expect("the normalized spelling must still verify");
let padded = format!(" {HELLO_SHA256}");
assert_ne!(
normalize_expected_sha256(HELLO_SHA256),
normalize_expected_sha256(&padded),
"a padded declaration must not fold onto the clean one"
);
let error = VerifiedModel::verify(&path, Some(&padded))
.expect_err("a padded declaration is a different, failing expectation");
assert!(
matches!(error, RapidOcrError::HashMismatch { .. }),
"{error}"
);
}
#[test]
fn the_credential_snapshots_the_content_it_verified() {
let dir = TempDir::new("credential-snapshot");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
let credential = VerifiedModel::verify(&path, Some(HELLO_SHA256)).expect("match");
assert_eq!(credential.content(), b"hello");
std::fs::write(&path, b"totally different bytes").expect("replace after construction");
assert_eq!(
credential.content(),
b"hello",
"the bound content must be the bytes that were verified, not whatever the path holds now"
);
let error = VerifiedModel::verify(&path, Some(HELLO_SHA256))
.expect_err("a re-verification after replacement must refuse");
assert!(
matches!(error, RapidOcrError::HashMismatch { .. }),
"{error}"
);
}
#[test]
fn a_missing_file_is_file_not_found_even_with_an_expectation() {
let dir = TempDir::new("credential-missing");
let path = dir.path().join("no_such_model.onnx");
for expected in [None, Some(HELLO_SHA256)] {
let error = VerifiedModel::verify(&path, expected)
.expect_err("a missing file must fail regardless of the expectation");
assert!(
matches!(error, RapidOcrError::FileNotFound(_)),
"expected FileNotFound, got {error}"
);
}
}
#[test]
fn the_missing_model_path_message_is_a_single_point() {
use super::missing_model_path_error;
use crate::api::{FormulaPolicy, TablePolicy};
let formula = FormulaPolicy {
enabled: true,
model_path: None,
..Default::default()
};
assert_eq!(
formula.validate().unwrap_err().to_string(),
missing_model_path_error("formula").to_string(),
);
let table = TablePolicy {
enabled: true,
model_path: None,
..Default::default()
};
assert_eq!(
table.validate().unwrap_err().to_string(),
missing_model_path_error("table").to_string(),
);
}
#[test]
fn a_missing_file_is_an_error_and_is_not_cached() {
let dir = TempDir::new("verify-missing");
let path = dir.path().join("not-here.onnx");
assert!(verify_file(&path).is_err());
assert!(sha256_file_cached(&path).is_err());
assert!(force_verify_file(&path).is_err());
dir.write("not-here.onnx", b"hello");
let outcome = verify_file(&path).expect("hash after the file appears");
assert_eq!(outcome.sha256, HELLO_SHA256);
assert!(outcome.computed);
}
#[test]
fn clearing_the_cache_only_costs_another_read() {
let _serial = serial();
let dir = TempDir::new("verify-clear");
dir.write("model.onnx", b"hello");
let path = dir.path().join("model.onnx");
assert!(verify_file(&path).expect("hash").computed);
clear_verification_cache();
let after = verify_file(&path).expect("hash");
assert_eq!(after.sha256, HELLO_SHA256);
assert!(after.computed, "a cleared cache must re-read");
let stats = verification_stats();
assert!(stats.last_cold_ms().is_some());
assert_eq!(stats.last_cold_bytes, 5);
}
#[test]
fn a_same_size_same_mtime_edit_in_the_head_is_detected_and_rehashed() {
let _serial = serial();
let dir = TempDir::new("verify-head-edit");
let path = dir.path().join("model.onnx");
let size = 256 * 1024;
write(&path, &pattern(size, 1));
let first = verify_file(&path).expect("hash");
assert!(first.computed);
let stale = first.sha256.clone();
let _restored = rewrite_same_identity(&path, 4096, b"\xff\xff\xff\xff");
let after = FileIdentity::of(&path).expect("identity");
assert_eq!(after.size(), first.identity.size(), "the size is unchanged");
assert_eq!(
after.modified(),
first.identity.modified(),
"the mtime was written back: the stat identity is unchanged"
);
assert_ne!(
after.partial(),
first.identity.partial(),
"the head window must notice the edit"
);
let second = verify_file(&path).expect("hash");
assert!(second.computed, "the cache must be invalidated");
assert_eq!(second.cause, ReverifyCause::ContentChanged);
assert_ne!(second.sha256, stale, "the stale digest must not be reused");
assert_eq!(
second.sha256,
sha256_file(&path).expect("uncached"),
"the fresh digest must be the real one"
);
}
#[test]
fn a_same_size_same_mtime_edit_in_the_tail_is_detected_and_rehashed() {
let _serial = serial();
let dir = TempDir::new("verify-tail-edit");
let path = dir.path().join("model.onnx");
let size = 256 * 1024;
write(&path, &pattern(size, 2));
let first = verify_file(&path).expect("hash");
assert!(first.computed);
let restored = rewrite_same_identity(&path, size - 8192, b"\x00\x00\x00\x00");
let after = FileIdentity::of(&path).expect("identity");
assert_eq!(after.size(), first.identity.size());
assert_eq!(
after.modified(),
first.identity.modified(),
"the stat identity is unchanged"
);
assert_eq!(after.modified(), Some(restored));
assert_ne!(
after.partial(),
first.identity.partial(),
"the tail window must notice the edit"
);
let second = verify_file(&path).expect("hash");
assert!(second.computed);
assert_eq!(second.cause, ReverifyCause::ContentChanged);
assert_eq!(second.sha256, sha256_file(&path).expect("uncached"));
}
#[test]
fn a_same_size_same_mtime_edit_confined_to_the_middle_is_not_detected() {
let _serial = serial();
let dir = TempDir::new("verify-middle-edit");
let path = dir.path().join("model.onnx");
let size = 256 * 1024;
write(&path, &pattern(size, 3));
let first = verify_file(&path).expect("hash");
assert!(first.computed);
let stale = first.sha256.clone();
let offset = (PARTIAL_DIGEST_WINDOW_BYTES + 1024) as usize;
rewrite_same_identity(&path, offset, b"\xaa\xbb\xcc\xdd");
let after = FileIdentity::of(&path).expect("identity");
assert_eq!(after.size(), first.identity.size());
assert_eq!(after.modified(), first.identity.modified());
assert_eq!(
after.partial(),
first.identity.partial(),
"the head and tail windows are byte-identical by construction"
);
assert_ne!(
sha256_file(&path).expect("uncached"),
stale,
"the file content really did change (the full digest differs)"
);
let second = verify_file(&path).expect("hash");
assert!(
!second.computed,
"DOCUMENTED LIMITATION: a middle-only edit with an identical stat identity and \
identical head/tail windows is deliberately not detected"
);
assert_eq!(second.cause, ReverifyCause::CacheHit);
assert_eq!(
second.sha256, stale,
"the cache answers with the digest of the previous content: this is the narrowed \
blind spot, not a bug"
);
let forced = force_verify_file(&path).expect("hash");
assert!(
forced.computed,
"force_verify_file never consults the cache"
);
assert_eq!(forced.cause, ReverifyCause::FirstSight);
assert_eq!(forced.sha256, sha256_file(&path).expect("uncached"));
assert_eq!(
verify_file(&path).expect("hash").sha256,
forced.sha256,
"the forced re-verification corrected the cached conclusion"
);
}
#[test]
fn a_middle_only_edit_is_falsified_by_a_verified_load_and_the_next_status_rehashes() {
let _serial = serial();
let dir = TempDir::new("verify-falsify-on-load");
let path = dir.path().join("model.onnx");
let size = 256 * 1024;
write(&path, &pattern(size, 11));
let first = verify_file(&path).expect("hash");
assert!(first.computed);
let stale = first.sha256.clone();
let offset = (PARTIAL_DIGEST_WINDOW_BYTES + 1024) as usize;
rewrite_same_identity(&path, offset, b"\x10\x20\x30\x40");
assert_eq!(
lookup_for_test(&path).expect("the cache answers"),
ReverifyCause::CacheHit,
"the identity still cannot see this edit — that is the previous case's point"
);
let current = sha256_file(&path).expect("uncached");
assert_ne!(current, stale, "the content really did change");
let credential = VerifiedModel::verify(&path, Some(¤t))
.expect("the load-time verification sees the current content");
assert_eq!(credential.actual_sha256(), Some(current.as_str()));
let second = verify_file(&path).expect("hash");
assert!(
second.computed,
"a load that falsified the cached digest must not leave a hot entry behind"
);
assert_eq!(second.cause, ReverifyCause::FirstSight);
assert_eq!(
second.sha256, current,
"the reported state now matches the disk"
);
let third = verify_file(&path).expect("hash");
assert!(
!third.computed,
"the convergence is not a one-shot invalidation loop"
);
assert_eq!(third.sha256, current);
}
#[test]
fn a_refused_load_also_falsifies_the_stale_cache_entry() {
let _serial = serial();
let dir = TempDir::new("verify-falsify-on-refusal");
let path = dir.path().join("model.onnx");
let size = 256 * 1024;
write(&path, &pattern(size, 12));
let first = verify_file(&path).expect("hash");
assert!(first.computed);
let stale = first.sha256.clone();
let offset = (PARTIAL_DIGEST_WINDOW_BYTES + 2048) as usize;
rewrite_same_identity(&path, offset, b"\x01\x02\x03\x04\x05\x06");
let error = VerifiedModel::verify(&path, Some(&stale))
.expect_err("the current content is not the declared one");
match error {
RapidOcrError::HashMismatch {
expected, actual, ..
} => {
assert_eq!(expected, stale);
assert_ne!(actual, stale, "the mismatch is a real content change");
}
other => panic!("expected a hash mismatch, got {other:?}"),
}
let second = verify_file(&path).expect("hash");
assert!(
second.computed,
"a refused load falsifies the entry all the same"
);
assert_eq!(second.sha256, sha256_file(&path).expect("uncached"));
}
#[test]
fn an_unverified_credential_leaves_the_cache_alone() {
let _serial = serial();
let dir = TempDir::new("verify-unverified-keeps-cache");
let path = dir.path().join("model.onnx");
let size = 256 * 1024;
write(&path, &pattern(size, 13));
let first = verify_file(&path).expect("hash");
assert!(first.computed);
let offset = (PARTIAL_DIGEST_WINDOW_BYTES + 512) as usize;
rewrite_same_identity(&path, offset, b"\x7f\x7e");
let credential = VerifiedModel::verify(&path, None).expect("no expectation is allowed");
assert!(!credential.is_verified());
assert_eq!(
lookup_for_test(&path).expect("the cache answers"),
ReverifyCause::CacheHit,
"DOCUMENTED LIMITATION: an unverified load produces no digest and is not evidence"
);
let second = verify_file(&path).expect("hash");
assert!(!second.computed);
assert_eq!(second.sha256, first.sha256);
}
#[test]
fn falsification_only_removes_an_entry_the_full_digest_contradicts() {
let _serial = serial();
let dir = TempDir::new("verify-falsify-rule");
let path = dir.path().join("model.onnx");
write(&path, b"hello");
let first = verify_file(&path).expect("hash");
assert!(first.computed);
assert_eq!(first.sha256, HELLO_SHA256);
let unrelated = dir.path().join("absent.onnx");
assert!(
!invalidate_if_falsified(&unrelated, HELLO_SHA256),
"there is no entry for a path that was never verified"
);
assert!(
!invalidate_if_falsified(&path, &first.sha256.to_uppercase()),
"an agreeing digest leaves the entry alone, case-insensitively like the verdict"
);
assert_eq!(
lookup_for_test(&path).expect("the cache answers"),
ReverifyCause::CacheHit
);
assert!(
invalidate_if_falsified(&path, "deadbeef"),
"a contradicting digest drops the entry"
);
assert_eq!(
lookup_for_test(&path).expect("the cache answers"),
ReverifyCause::FirstSight,
"the entry is gone, not overwritten with a never-observed identity"
);
assert!(
!invalidate_if_falsified(&path, "deadbeef"),
"there is nothing left to drop the second time"
);
}
#[test]
fn a_file_smaller_than_the_window_is_fully_covered_by_the_partial_digest() {
let _serial = serial();
let dir = TempDir::new("verify-small-file");
let path = dir.path().join("small.onnx");
let size = 1024;
write(&path, &pattern(size, 4));
let first = verify_file(&path).expect("hash");
assert!(first.computed);
rewrite_same_identity(&path, size / 2, b"\x11\x22");
let after = FileIdentity::of(&path).expect("identity");
assert_eq!(after.size(), first.identity.size());
assert_eq!(after.modified(), first.identity.modified());
assert_ne!(
after.partial(),
first.identity.partial(),
"for size <= 128 KiB the head and tail windows overlap and cover the whole file"
);
let second = verify_file(&path).expect("hash");
assert!(second.computed);
assert_eq!(second.cause, ReverifyCause::ContentChanged);
assert_eq!(second.sha256, sha256_file(&path).expect("uncached"));
}
#[test]
fn an_mtime_only_change_still_hits_and_is_not_reported_as_a_content_change() {
let _serial = serial();
let dir = TempDir::new("verify-mtime-only");
let path = dir.path().join("model.onnx");
write(&path, &pattern(4096, 8));
let first = verify_file(&path).expect("hash");
assert!(first.computed);
let before = verification_stats();
assert_eq!(before.partial_mismatches, 0);
let file = std::fs::OpenOptions::new()
.write(true)
.open(&path)
.expect("open to touch the timestamp");
file.set_modified(
first.identity.modified().expect("mtime") + std::time::Duration::from_secs(7),
)
.expect("bump mtime");
drop(file);
assert_eq!(
lookup_for_test(&path).expect("the cache answers"),
ReverifyCause::CacheHit,
"metadata alone must not invalidate a digest whose content windows are identical"
);
let second = verify_file(&path).expect("hash");
assert!(!second.computed, "no full hash for a metadata-only change");
assert_eq!(second.cause, ReverifyCause::CacheHit);
assert_eq!(second.sha256, first.sha256);
let after = verification_stats();
assert_eq!(
after.partial_mismatches, 0,
"a metadata-only change is not a content change"
);
assert_eq!(
after.cold_verifications, before.cold_verifications,
"no full hash happened"
);
rewrite_same_identity(&path, 1024, b"\x99");
assert_eq!(
lookup_for_test(&path).expect("the cache answers"),
ReverifyCause::ContentChanged,
"the recorded cause must describe the real reason"
);
}
#[test]
fn empty_and_exactly_window_sized_files_are_well_defined() {
let _serial = serial();
let dir = TempDir::new("verify-boundary");
let empty = dir.path().join("empty.onnx");
dir.write("empty.onnx", b"");
let identity = FileIdentity::of(&empty).expect("identity of an empty file");
assert_eq!(identity.size(), 0);
assert_eq!(identity.partial().size(), 0);
assert_eq!(
identity.partial(),
&PartialDigest::compute(&empty, 0).expect("again")
);
assert!(verify_file(&empty).expect("hash").computed);
let exact = dir.path().join("exact.onnx");
let size = (2 * PARTIAL_DIGEST_WINDOW_BYTES) as usize;
write(&exact, &pattern(size, 5));
let before = FileIdentity::of(&exact).expect("identity");
assert_eq!(before.size(), size as u64);
rewrite_same_identity(&exact, 0, b"\x01");
let head_changed = FileIdentity::of(&exact).expect("identity");
assert_ne!(head_changed.partial(), before.partial());
rewrite_same_identity(&exact, size - 1, b"\x02");
let tail_changed = FileIdentity::of(&exact).expect("identity");
assert_ne!(tail_changed.partial(), head_changed.partial());
rewrite_same_identity(&exact, (PARTIAL_DIGEST_WINDOW_BYTES as usize) + 8, b"\x03");
let middle_changed = FileIdentity::of(&exact).expect("identity");
assert_ne!(middle_changed.partial(), tail_changed.partial());
}
#[test]
fn a_cache_hit_reads_only_the_windows_and_never_rehashes() {
let _serial = serial();
let dir = TempDir::new("verify-hit-cost");
let path = dir.path().join("model.onnx");
write(&path, &pattern(64 * 1024, 6));
let cold = verify_file(&path).expect("hash");
assert!(cold.computed);
assert_eq!(verification_stats().last_cold_bytes, 64 * 1024);
for _ in 0..3 {
let hit = verify_file(&path).expect("hash");
assert!(
!hit.computed,
"DOCUMENTED COST: a hit must not perform a full hash"
);
assert_eq!(hit.cause, ReverifyCause::CacheHit);
assert_eq!(
hit.sha256, cold.sha256,
"a hit must answer with the digest that was actually computed"
);
assert_eq!(hit.identity, cold.identity);
}
}
#[test]
fn the_partial_digest_binds_the_first_and_last_window_only() {
let _serial = serial();
let dir = TempDir::new("verify-partial-binds");
let path = dir.path().join("model.onnx");
let size = 200 * 1024;
write(&path, &pattern(size, 7));
let base = PartialDigest::compute(&path, size as u64).expect("partial");
rewrite_same_identity(&path, 0, b"\x7f");
let head = PartialDigest::compute(&path, size as u64).expect("partial");
assert_ne!(head, base, "the first byte is inside the head window");
rewrite_same_identity(&path, size - 1, b"\x7e");
let tail = PartialDigest::compute(&path, size as u64).expect("partial");
assert_ne!(tail, head, "the last byte is inside the tail window");
let full_before_middle = sha256_file(&path).expect("uncached");
rewrite_same_identity(&path, size / 2, b"\x7d");
let middle = PartialDigest::compute(&path, size as u64).expect("partial");
assert_eq!(
middle, tail,
"a middle-only edit does not move the partial digest (the documented heuristic)"
);
assert_ne!(
sha256_file(&path).expect("uncached"),
full_before_middle,
"the full digest must have changed, otherwise the assertion above proves nothing"
);
}
}