use std::path::{Component, Path};
use serde::{Deserialize, Serialize};
use crate::error::{RapidOcrError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelRole {
Detector,
Classifier,
Recognizer,
Dictionary,
Tokenizer,
FormulaDetector,
FormulaRecognizer,
TableStructure,
}
impl ModelRole {
pub const ALL: [Self; 8] = [
Self::Detector,
Self::Classifier,
Self::Recognizer,
Self::Dictionary,
Self::Tokenizer,
Self::FormulaDetector,
Self::FormulaRecognizer,
Self::TableStructure,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Detector => "detector",
Self::Classifier => "classifier",
Self::Recognizer => "recognizer",
Self::Dictionary => "dictionary",
Self::Tokenizer => "tokenizer",
Self::FormulaDetector => "formula_detector",
Self::FormulaRecognizer => "formula_recognizer",
Self::TableStructure => "table_structure",
}
}
}
impl std::fmt::Display for ModelRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelFileSpec {
pub name: String,
pub role: ModelRole,
pub size_bytes: Option<u64>,
pub sha256: String,
pub source_url: String,
}
impl ModelFileSpec {
pub fn new(
name: impl Into<String>,
role: ModelRole,
size_bytes: Option<u64>,
sha256: impl Into<String>,
source_url: impl Into<String>,
) -> Result<Self> {
let spec = Self {
name: name.into(),
role,
size_bytes,
sha256: sha256.into(),
source_url: source_url.into(),
};
spec.validate_name()?;
Ok(spec)
}
pub fn has_hash(&self) -> bool {
!self.sha256.trim().is_empty()
}
pub fn has_source_url(&self) -> bool {
!self.source_url.trim().is_empty()
}
pub fn validate_name(&self) -> Result<()> {
validate_model_file_name(&self.name)
}
pub fn state_in(&self, root: &Path) -> ModelFileState {
self.state_in_probed(root).0
}
pub fn state_in_probed(&self, root: &Path) -> (ModelFileState, bool) {
self.state_in_digested(root, &mut String::new())
}
pub fn state_in_digested(&self, root: &Path, digest: &mut String) -> (ModelFileState, bool) {
digest.clear();
if self.validate_name().is_err() {
return (
ModelFileState::Corrupt {
expected: self.sha256.clone(),
actual: format!(
"invalid file name `{}` escapes the model directory",
self.name
),
},
false,
);
}
let path = root.join(&self.name);
if !path.is_file() {
return (ModelFileState::Missing, false);
}
if !self.has_hash() {
return (ModelFileState::Present, false);
}
match crate::model_verify::verify_file(&path) {
Ok(outcome) => {
let state = if outcome.sha256.eq_ignore_ascii_case(&self.sha256) {
ModelFileState::Present
} else {
ModelFileState::Corrupt {
expected: self.sha256.clone(),
actual: outcome.sha256.clone(),
}
};
*digest = outcome.sha256;
(state, outcome.computed)
}
Err(error) => (
ModelFileState::Corrupt {
expected: self.sha256.clone(),
actual: format!("unreadable: {error}"),
},
false,
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelFileState {
Missing,
Present,
Corrupt {
expected: String,
actual: String,
},
}
impl ModelFileState {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Present => "present",
Self::Corrupt { .. } => "corrupt",
}
}
pub const fn is_present(&self) -> bool {
matches!(self, Self::Present)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelSet {
pub id: String,
pub family: String,
pub version: String,
pub files: Vec<ModelFileSpec>,
}
impl ModelSet {
pub fn validate(&self) -> Result<()> {
let mut seen: Vec<&str> = Vec::with_capacity(self.files.len());
for file in &self.files {
file.validate_name()?;
if seen.contains(&file.name.as_str()) {
return Err(RapidOcrError::ModelResolve(format!(
"model set `{}` lists `{}` more than once",
self.id, file.name
)));
}
seen.push(&file.name);
}
Ok(())
}
pub fn has_role(&self, role: ModelRole) -> bool {
self.files.iter().any(|file| file.role == role)
}
pub fn declared_roles(&self) -> Vec<ModelRole> {
ModelRole::ALL
.into_iter()
.filter(|role| self.has_role(*role))
.collect()
}
pub fn missing_roles(&self, required: &[ModelRole]) -> Vec<ModelRole> {
let mut missing = Vec::new();
for role in required {
if !self.has_role(*role) && !missing.contains(role) {
missing.push(*role);
}
}
missing
}
pub fn require_roles(&self, required: &[ModelRole]) -> Result<()> {
let missing = self.missing_roles(required);
if missing.is_empty() {
return Ok(());
}
let missing: Vec<&str> = missing.iter().map(|role| role.as_str()).collect();
let declared: Vec<&str> = self
.declared_roles()
.iter()
.map(|role| role.as_str())
.collect();
Err(RapidOcrError::ModelResolve(format!(
"model set `{}` is missing required roles: {} (declared roles: {})",
self.id,
missing.join(", "),
if declared.is_empty() {
"<none>".to_string()
} else {
declared.join(", ")
}
)))
}
pub fn status(&self, root: &Path) -> ModelSetStatus {
model_set_status(self, root)
}
pub fn status_probed(&self, root: &Path) -> (ModelSetStatus, usize) {
model_set_status_probed(self, root)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelSetStatus {
pub set_id: String,
pub files: Vec<(ModelFileSpec, ModelFileState)>,
pub complete: bool,
pub download_bytes_total: Option<u64>,
}
pub fn validate_model_file_name(name: &str) -> Result<()> {
let path = Path::new(name);
let rejects = |reason: &str| {
RapidOcrError::ModelResolve(format!(
"model file name `{name}` is not a bare relative file name ({reason})"
))
};
if name.trim().is_empty() {
return Err(rejects("it is empty"));
}
if name.contains('/') || name.contains('\\') {
return Err(rejects("it contains a path separator"));
}
if path.is_absolute() {
return Err(rejects("it is an absolute path"));
}
if path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(rejects(
"it contains a drive prefix, a root, `.` or `..` component",
));
}
Ok(())
}
pub fn validate_model_files(
files: &[ModelFileSpec],
root: &Path,
) -> Vec<(ModelFileSpec, ModelFileState)> {
validate_model_files_probed(files, root).0
}
pub fn validate_model_files_probed(
files: &[ModelFileSpec],
root: &Path,
) -> (Vec<(ModelFileSpec, ModelFileState)>, usize) {
let mut computed = 0_usize;
let statuses = files
.iter()
.map(|file| {
let (state, did_compute) = file.state_in_probed(root);
if did_compute {
computed += 1;
}
(file.clone(), state)
})
.collect();
(statuses, computed)
}
pub fn model_set_status(set: &ModelSet, root: &Path) -> ModelSetStatus {
model_set_status_probed(set, root).0
}
pub fn model_set_status_probed(set: &ModelSet, root: &Path) -> (ModelSetStatus, usize) {
let (files, computed) = validate_model_files_probed(&set.files, root);
let complete = !files.is_empty()
&& files
.iter()
.all(|(file, state)| state.is_present() && file.has_hash());
let download_bytes_total = missing_download_bytes(&files);
(
ModelSetStatus {
set_id: set.id.clone(),
files,
complete,
download_bytes_total,
},
computed,
)
}
fn missing_download_bytes(files: &[(ModelFileSpec, ModelFileState)]) -> Option<u64> {
let mut total: u64 = 0;
for (file, state) in files {
if matches!(state, ModelFileState::Missing) {
total = total.checked_add(file.size_bytes?)?;
}
}
Some(total)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::test_support::TempDir;
const HELLO_SHA256: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
fn spec(name: &str, role: ModelRole, sha256: &str, size_bytes: Option<u64>) -> ModelFileSpec {
ModelFileSpec::new(name, role, size_bytes, sha256, "https://example.com/x").expect("spec")
}
fn set(files: Vec<ModelFileSpec>) -> ModelSet {
ModelSet {
id: "test-set".to_string(),
family: "test".to_string(),
version: "v1".to_string(),
files,
}
}
#[test]
fn missing_present_and_corrupt_are_reported_per_file() {
let dir = TempDir::new("states");
dir.write("present.onnx", b"hello");
dir.write("corrupt.onnx", b"not-hello");
let files = vec![
spec("present.onnx", ModelRole::Detector, HELLO_SHA256, Some(5)),
spec("missing.onnx", ModelRole::Recognizer, HELLO_SHA256, None),
spec("corrupt.onnx", ModelRole::Dictionary, HELLO_SHA256, Some(9)),
];
let status = set(files).status(dir.path());
assert_eq!(status.set_id, "test-set");
assert_eq!(status.files.len(), 3);
assert_eq!(status.files[0].1, ModelFileState::Present);
assert_eq!(status.files[1].1, ModelFileState::Missing);
match &status.files[2].1 {
ModelFileState::Corrupt { expected, actual } => {
assert_eq!(expected, HELLO_SHA256);
assert_ne!(actual, HELLO_SHA256);
}
other => panic!("expected Corrupt, got {other:?}"),
}
assert!(!status.complete);
}
#[test]
fn a_set_with_an_unhashed_file_can_never_be_complete() {
let dir = TempDir::new("unhashed");
dir.write("unhashed.txt", b"hello");
dir.write("hashed.onnx", b"hello");
let files = vec![
spec("unhashed.txt", ModelRole::Dictionary, "", Some(5)),
spec("hashed.onnx", ModelRole::Recognizer, HELLO_SHA256, Some(5)),
];
let status = set(files).status(dir.path());
assert_eq!(status.files[0].1, ModelFileState::Present);
assert!(!status.files[0].0.has_hash());
assert_eq!(status.files[1].1, ModelFileState::Present);
assert!(
!status.complete,
"a set containing an unhashed file must never report complete"
);
let files = vec![
spec("unhashed.txt", ModelRole::Dictionary, HELLO_SHA256, Some(5)),
spec("hashed.onnx", ModelRole::Recognizer, HELLO_SHA256, Some(5)),
];
assert!(set(files).status(dir.path()).complete);
}
#[test]
fn an_empty_set_is_never_complete() {
assert!(!set(Vec::new()).status(Path::new(".")).complete);
}
#[test]
fn path_escape_is_rejected_by_the_shared_rule() {
for name in [
"../escape.onnx",
"..\\escape.onnx",
"sub/model.onnx",
"/abs/model.onnx",
"C:/abs/model.onnx",
"C:\\abs\\model.onnx",
"./model.onnx",
"",
] {
assert!(
validate_model_file_name(name).is_err(),
"`{name}` must be rejected as a model file name"
);
assert!(
ModelFileSpec::new(name, ModelRole::Detector, None, HELLO_SHA256, "").is_err(),
"`{name}` must be rejected when constructing a spec"
);
}
for name in [
"model.onnx",
"ppocr_keys_v1.txt",
"PP-OCRv6_det_medium.onnx",
] {
validate_model_file_name(name).expect("bare file names must be accepted");
}
let escaped = ModelFileSpec {
name: "../escape.onnx".to_string(),
role: ModelRole::Detector,
size_bytes: Some(7),
sha256: HELLO_SHA256.to_string(),
source_url: String::new(),
};
let status = set(vec![escaped]).status(Path::new("."));
assert!(matches!(status.files[0].1, ModelFileState::Corrupt { .. }));
assert!(!status.complete);
}
#[test]
fn every_file_is_reported_in_order_and_not_stopped_at_the_first_error() {
let dir = TempDir::new("order");
dir.write("c.txt", b"hello");
let files = vec![
spec("a.onnx", ModelRole::Detector, HELLO_SHA256, None),
ModelFileSpec {
name: "../b.onnx".to_string(),
role: ModelRole::Classifier,
size_bytes: None,
sha256: HELLO_SHA256.to_string(),
source_url: String::new(),
},
spec("c.txt", ModelRole::Dictionary, HELLO_SHA256, None),
spec("d.onnx", ModelRole::Recognizer, HELLO_SHA256, None),
];
let status = set(files).status(dir.path());
let order: Vec<&str> = status
.files
.iter()
.map(|(file, _)| file.name.as_str())
.collect();
assert_eq!(order, vec!["a.onnx", "../b.onnx", "c.txt", "d.onnx"]);
assert_eq!(status.files[0].1, ModelFileState::Missing);
assert!(matches!(status.files[1].1, ModelFileState::Corrupt { .. }));
assert_eq!(status.files[2].1, ModelFileState::Present);
assert_eq!(status.files[3].1, ModelFileState::Missing);
}
#[test]
fn download_bytes_total_sums_only_missing_files_with_known_sizes() {
let dir = TempDir::new("bytes");
dir.write("present.onnx", b"hello");
let present = spec("present.onnx", ModelRole::Detector, HELLO_SHA256, Some(5));
let missing_known = spec("a.onnx", ModelRole::Recognizer, HELLO_SHA256, Some(100));
let missing_known_2 = spec("b.onnx", ModelRole::Dictionary, HELLO_SHA256, Some(23));
let missing_unknown = spec("c.onnx", ModelRole::Classifier, HELLO_SHA256, None);
let total = |files: Vec<ModelFileSpec>| set(files).status(dir.path()).download_bytes_total;
assert_eq!(total(vec![present.clone()]), Some(0));
assert_eq!(
total(vec![
present.clone(),
missing_known.clone(),
missing_known_2.clone()
]),
Some(123)
);
assert_eq!(
total(vec![
present.clone(),
missing_known.clone(),
missing_unknown.clone()
]),
None
);
dir.write("c.onnx", b"hello");
assert_eq!(
total(vec![present, missing_known, missing_unknown]),
Some(100)
);
}
#[test]
fn require_roles_lists_every_missing_role() {
let collection = set(vec![
spec("d.onnx", ModelRole::Detector, HELLO_SHA256, None),
spec("r.onnx", ModelRole::Recognizer, HELLO_SHA256, None),
]);
collection
.require_roles(&[ModelRole::Detector, ModelRole::Recognizer])
.expect("declared roles must satisfy the request");
let error = collection
.require_roles(&[
ModelRole::Detector,
ModelRole::Dictionary,
ModelRole::FormulaRecognizer,
])
.expect_err("missing roles must fail");
let message = error.to_string();
assert!(message.contains("missing required roles"), "{message}");
assert!(message.contains("dictionary"), "{message}");
assert!(message.contains("formula_recognizer"), "{message}");
assert!(
message.contains("declared roles: detector, recognizer"),
"{message}"
);
}
#[test]
fn duplicate_file_names_are_rejected() {
let collection = set(vec![
spec("same.onnx", ModelRole::Detector, HELLO_SHA256, None),
spec("same.onnx", ModelRole::Recognizer, HELLO_SHA256, None),
]);
let error = collection.validate().expect_err("duplicates must fail");
assert!(error.to_string().contains("more than once"), "{error}");
}
#[test]
fn roles_round_trip_through_snake_case_json() {
for role in ModelRole::ALL {
let json = serde_json::to_string(&role).expect("role should serialize");
assert_eq!(json, format!("\"{}\"", role.as_str()));
let parsed: ModelRole = serde_json::from_str(&json).expect("role should deserialize");
assert_eq!(parsed, role);
assert_eq!(role.to_string(), role.as_str());
}
let error = serde_json::from_str::<ModelRole>("\"unknown_role\"")
.expect_err("an unknown role must not parse");
assert!(error.to_string().contains("unknown_role"), "{error}");
}
#[test]
fn the_table_structure_role_is_spelled_as_the_manifest_writes_it() {
assert_eq!(ModelRole::TableStructure.as_str(), "table_structure");
assert_eq!(
serde_json::to_string(&ModelRole::TableStructure).expect("role should serialize"),
"\"table_structure\""
);
assert_eq!(ModelRole::ALL.len(), 8);
assert!(ModelRole::ALL.contains(&ModelRole::TableStructure));
}
#[test]
fn state_labels_are_stable_for_the_frontend() {
assert_eq!(ModelFileState::Missing.as_str(), "missing");
assert_eq!(ModelFileState::Present.as_str(), "present");
assert_eq!(
ModelFileState::Corrupt {
expected: "a".into(),
actual: "b".into()
}
.as_str(),
"corrupt"
);
assert!(ModelFileState::Present.is_present());
assert!(!ModelFileState::Missing.is_present());
}
}