use std::path::Path;
use crate::{
error::{RapidOcrError, Result},
model_set::ModelRole,
model_source::ModelManifest,
model_verify,
};
pub const TABLE_MODEL_ROLE: ModelRole = ModelRole::TableStructure;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisteredTableModel {
pub name: String,
pub sha256: String,
pub size_bytes: Option<u64>,
pub source_url: Option<String>,
}
pub fn registration_for(model_path: &Path) -> Result<Option<RegisteredTableModel>> {
let directory = model_path.parent().ok_or_else(|| {
RapidOcrError::Config(format!(
"table structure model path `{}` has no parent directory, so its registration cannot be found",
model_path.display()
))
})?;
let Some(manifest) = ModelManifest::load_from_dir(directory)? else {
return Ok(None);
};
let file_name = model_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
RapidOcrError::Config(format!(
"table structure model path `{}` has no usable file name",
model_path.display()
))
})?;
let Some(entry) = manifest.files.iter().find(|file| file.name == file_name) else {
let registered: Vec<&str> = manifest
.files
.iter()
.map(|file| file.name.as_str())
.collect();
return Err(RapidOcrError::Config(format!(
"the manifest in `{}` does not register `{file_name}` (registered: {}); \
a model that sits next to a manifest is governed by it",
directory.display(),
if registered.is_empty() {
"none".to_string()
} else {
registered.join(", ")
}
)));
};
if entry.role != TABLE_MODEL_ROLE {
return Err(RapidOcrError::Config(format!(
"`{file_name}` is registered as role `{}` in `{}`, but this loader needs `{}`",
entry.role,
directory.display(),
TABLE_MODEL_ROLE
)));
}
if entry.sha256.trim().is_empty() {
return Err(RapidOcrError::Config(format!(
"`{file_name}` is registered in `{}` without a SHA-256; a local manifest must describe \
every file completely, otherwise the registration cannot be checked",
directory.display()
)));
}
Ok(Some(RegisteredTableModel {
name: entry.name.clone(),
sha256: entry.sha256.trim().to_ascii_lowercase(),
size_bytes: entry.size_bytes,
source_url: entry.source_url.clone(),
}))
}
pub fn registered_model_hash(model_path: &Path) -> Result<Option<String>> {
Ok(registration_for(model_path)?.map(|registration| registration.sha256))
}
pub fn require_registered_model_hash(model_path: &Path) -> Result<String> {
registered_model_hash(model_path)?.ok_or_else(|| {
RapidOcrError::Config(format!(
"no manifest registers the table structure model `{}`; add one next to the model so its \
SHA-256 can be verified at load time (see models/table/manifest.json)",
model_path.display()
))
})
}
pub fn resolve_registered_expected(
model_path: &Path,
expected_sha256: Option<&str>,
) -> Result<Option<String>> {
match expected_sha256 {
Some(hash) if !hash.trim().is_empty() => Ok(Some(hash.trim().to_string())),
_ => registered_model_hash(model_path),
}
}
pub fn verified_registered_model(
model_path: &Path,
expected_sha256: Option<&str>,
) -> Result<model_verify::VerifiedModel> {
let expected = resolve_registered_expected(model_path, expected_sha256)?;
model_verify::VerifiedModel::verify(model_path, expected.as_deref())
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicUsize, Ordering},
};
use super::*;
use crate::error::RapidOcrError;
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
struct Scratch {
dir: PathBuf,
}
impl Scratch {
fn new(label: &str) -> Self {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"rapid-ocr-table-registration-{label}-{}-{id}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("scratch dir should be creatable");
Self { dir }
}
fn model(&self, name: &str, contents: &[u8]) -> PathBuf {
let path = self.dir.join(name);
fs::write(&path, contents).expect("model file should be writable");
path
}
fn manifest(&self, json: &str) -> PathBuf {
let path = self.dir.join("manifest.json");
fs::write(&path, json).expect("manifest should be writable");
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn manifest_json(name: &str, role: &str, sha256: &str, size_bytes: Option<u64>) -> String {
let size = match size_bytes {
Some(bytes) => format!(",\n \"size_bytes\": {bytes}"),
None => String::new(),
};
format!(
r#"{{
"schema_version": 1,
"id": "scratch",
"family": "SLANet",
"version": "v2.0.0",
"files": [
{{ "name": "{name}", "role": "{role}", "sha256": "{sha256}"{size} }}
]
}}"#
)
}
const HELLO_SHA256: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
#[test]
fn a_directory_without_a_manifest_reports_no_registration() {
let scratch = Scratch::new("no-manifest");
let model = scratch.model("slanet-plus.onnx", b"hello");
assert_eq!(
registration_for(&model).expect("no manifest is not an error"),
None
);
assert_eq!(
registered_model_hash(&model).expect("no manifest is not an error"),
None
);
}
#[test]
fn a_registered_model_is_found_by_its_file_name() {
let scratch = Scratch::new("found");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"slanet-plus.onnx",
"table_structure",
HELLO_SHA256,
Some(5),
));
let registration = registration_for(&model)
.expect("a well formed manifest should parse")
.expect("the model is registered");
assert_eq!(registration.name, "slanet-plus.onnx");
assert_eq!(registration.sha256, HELLO_SHA256);
assert_eq!(registration.size_bytes, Some(5));
assert_eq!(registration.source_url, None);
}
#[test]
fn a_model_that_sits_next_to_a_manifest_must_be_registered_in_it() {
let scratch = Scratch::new("unregistered");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"some-other.onnx",
"table_structure",
HELLO_SHA256,
Some(5),
));
let error = registration_for(&model).expect_err("an unregistered model must be refused");
let text = error.to_string();
assert!(
text.contains("does not register `slanet-plus.onnx`"),
"{text}"
);
assert!(text.contains("some-other.onnx"), "{text}");
}
#[test]
fn a_model_registered_under_another_role_is_refused() {
let scratch = Scratch::new("wrong-role");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"slanet-plus.onnx",
"detector",
HELLO_SHA256,
Some(5),
));
let error = registration_for(&model).expect_err("a role mismatch must be refused");
let text = error.to_string();
assert!(text.contains("registered as role `detector`"), "{text}");
assert!(text.contains("needs `table_structure`"), "{text}");
}
#[test]
fn a_registration_without_a_hash_is_refused_rather_than_treated_as_unhashed() {
let scratch = Scratch::new("no-hash");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"slanet-plus.onnx",
"table_structure",
"",
Some(5),
));
let error = registration_for(&model).expect_err("an empty hash must be refused");
assert!(error.to_string().contains("without a SHA-256"), "{error}");
}
#[test]
fn a_matching_hash_is_verified_at_load_time() {
let scratch = Scratch::new("verify-ok");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"slanet-plus.onnx",
"table_structure",
HELLO_SHA256,
Some(5),
));
let credential =
verified_registered_model(&model, None).expect("a matching hash should verify");
assert!(
credential.is_verified(),
"the registration supplies an expected hash"
);
assert_eq!(credential.actual_sha256(), Some(HELLO_SHA256));
}
#[test]
fn a_tampered_model_is_refused_at_load_time() {
let scratch = Scratch::new("verify-tampered");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"slanet-plus.onnx",
"table_structure",
HELLO_SHA256,
Some(5),
));
scratch.model("slanet-plus.onnx", b"HELLO");
let error = verified_registered_model(&model, None).expect_err("tampering must be refused");
assert!(
matches!(error, RapidOcrError::HashMismatch { .. }),
"the error must be locatable, got {error:?}"
);
}
#[test]
fn an_explicit_hash_overrides_the_registration() {
let scratch = Scratch::new("override");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"slanet-plus.onnx",
"table_structure",
"0000000000000000000000000000000000000000000000000000000000000000",
Some(5),
));
verified_registered_model(&model, Some(HELLO_SHA256)).expect("the explicit hash is right");
let error = verified_registered_model(&model, Some("deadbeef"))
.expect_err("an explicit mismatch must be refused");
match error {
RapidOcrError::HashMismatch { expected, .. } => assert_eq!(expected, "deadbeef"),
other => panic!("expected a hash mismatch, got {other:?}"),
}
}
#[test]
fn an_unregistered_model_has_no_hash_to_require() {
let scratch = Scratch::new("require-none");
let model = scratch.model("slanet-plus.onnx", b"hello");
let error =
require_registered_model_hash(&model).expect_err("a missing registration must fail");
assert!(
error.to_string().contains("no manifest registers"),
"{error}"
);
}
#[test]
fn a_registered_model_has_a_hash_to_require() {
let scratch = Scratch::new("require-some");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"slanet-plus.onnx",
"table_structure",
HELLO_SHA256,
Some(5),
));
assert_eq!(
require_registered_model_hash(&model).expect("the registration supplies a hash"),
HELLO_SHA256
);
}
#[test]
fn the_registered_hash_is_normalized_to_lower_case() {
let scratch = Scratch::new("upper-case");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"slanet-plus.onnx",
"table_structure",
&HELLO_SHA256.to_ascii_uppercase(),
Some(5),
));
let registration = registration_for(&model)
.expect("manifest should parse")
.expect("registered");
assert_eq!(registration.sha256, HELLO_SHA256);
verified_registered_model(&model, None).expect("case must not matter");
}
#[test]
fn a_broken_manifest_is_an_error_rather_than_a_missing_registration() {
let scratch = Scratch::new("broken-manifest");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest("{ not json");
let error = registration_for(&model).expect_err("a broken manifest must not be ignored");
assert!(
error.to_string().contains("manifest"),
"the error should name the manifest, got {error}"
);
}
#[test]
fn a_table_only_manifest_does_not_borrow_roles_from_the_default_table() {
let scratch = Scratch::new("single-source");
let model = scratch.model("slanet-plus.onnx", b"hello");
scratch.manifest(&manifest_json(
"slanet-plus.onnx",
"table_structure",
HELLO_SHA256,
Some(5),
));
let manifest = ModelManifest::load_from_dir(&scratch.dir)
.expect("manifest should parse")
.expect("the directory has a manifest");
assert_eq!(manifest.files.len(), 1);
assert_eq!(manifest.files[0].role, TABLE_MODEL_ROLE);
assert_eq!(
registration_for(&model)
.expect("manifest should parse")
.expect("registered")
.name,
manifest.files[0].name
);
}
fn _assert_signature(_: fn(&Path, Option<&str>) -> Result<crate::model_verify::VerifiedModel>) {
}
#[test]
fn the_public_surface_is_the_one_the_loader_calls() {
_assert_signature(verified_registered_model);
}
}