use crate::error::{Error, Result};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecoderMode {
Plain,
Kv,
}
#[derive(Debug, Clone)]
pub struct Manifest {
pub id: String,
pub task: String,
pub decoder: DecoderMode,
pub precision: String,
pub opset: u32,
pub sha256: BTreeMap<String, String>,
}
#[derive(Debug)]
pub struct ArtifactGraphs {
pub manifest: Manifest,
pub graphs: BTreeMap<String, Vec<u8>>,
}
impl ArtifactGraphs {
pub fn decoder_member(&self) -> Result<&str> {
match self.manifest.decoder {
DecoderMode::Kv if self.graphs.contains_key("decoder-kv.onnx") => Ok("decoder-kv.onnx"),
DecoderMode::Kv => Ok("decoder.onnx"),
DecoderMode::Plain => Ok("decoder.onnx"),
}
}
}
fn model_err(what: impl Into<String>) -> Error {
Error::Model(what.into())
}
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
to_hex(&hasher.finalize())
}
pub(crate) fn to_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
fn normalize_digest(s: &str) -> Result<String> {
let lower = s.trim().to_lowercase();
if lower.len() != 64 || !lower.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(model_err(format!("malformed sha256 digest {s:?}")));
}
Ok(lower)
}
pub fn verify_and_read(zip_path: &Path) -> Result<ArtifactGraphs> {
let manifest = load_manifest(zip_path)?;
let file = std::fs::File::open(zip_path).map_err(|e| model_err(format!(
"cannot open artifact {}: {e}",
zip_path.display()
)))?;
let mut zf = zip::ZipArchive::new(file)
.map_err(|e| model_err(format!("{} is not a readable zip: {e}", zip_path.display())))?;
let names: Vec<String> = (0..zf.len())
.filter_map(|i| {
zf.by_index(i)
.ok()
.and_then(|f| f.name().ok().map(|n| n.into_owned()))
})
.collect();
let mut graphs = BTreeMap::new();
for name in names.iter().filter(|n| n.ends_with(".onnx")) {
let mut member = zf.by_name(name).map_err(|e| model_err(format!(
"member {name:?} unreadable: {e}"
)))?;
let mut bytes = Vec::with_capacity(member.size() as usize);
member
.read_to_end(&mut bytes)
.map_err(|e| model_err(format!("member {name:?} read failed: {e}")))?;
let recorded = manifest.sha256.get(name).ok_or_else(|| {
model_err(format!("{name} is not covered by metadata sha256"))
})?;
let actual = sha256_hex(&bytes);
if actual != normalize_digest(recorded)? {
return Err(model_err(format!(
"{name} sha256 mismatch: zip has {actual}, metadata says {recorded}"
)));
}
graphs.insert(name.clone(), bytes);
}
if !graphs.contains_key("encoder.onnx") || !graphs.contains_key("decoder.onnx") {
return Err(model_err(format!(
"{} is missing verified encoder/decoder members",
zip_path.display()
)));
}
Ok(ArtifactGraphs { manifest, graphs })
}
pub fn load_manifest(zip_path: &Path) -> Result<Manifest> {
let file = std::fs::File::open(zip_path).map_err(|e| model_err(format!(
"cannot open artifact {}: {e}",
zip_path.display()
)))?;
let mut zf = zip::ZipArchive::new(file)
.map_err(|e| model_err(format!("{} is not a readable zip: {e}", zip_path.display())))?;
for required in ["metadata.yaml", "encoder.onnx", "decoder.onnx"] {
if zf.by_name(required).is_err() {
return Err(model_err(format!("missing required file: {required}")));
}
}
let mut meta = zf.by_name("metadata.yaml").map_err(|e| {
model_err(format!("metadata.yaml unreadable: {e}"))
})?;
let mut raw = String::with_capacity(meta.size() as usize);
meta.read_to_string(&mut raw)
.map_err(|e| model_err(format!("metadata.yaml read failed: {e}")))?;
#[derive(serde::Deserialize)]
struct MetadataYaml {
format: String,
id: String,
task: String,
tokenizer: String,
#[serde(default = "default_decoder")]
decoder: String,
#[serde(default = "default_precision")]
precision: String,
#[serde(default = "default_opset")]
opset: u32,
#[serde(default)]
sha256: BTreeMap<String, String>,
}
fn default_decoder() -> String {
"plain".into()
}
fn default_precision() -> String {
"fp32".into()
}
fn default_opset() -> u32 {
14
}
let raw: MetadataYaml = serde_yaml::from_str(&raw)
.map_err(|e| model_err(format!("metadata.yaml parse failed: {e}")))?;
if raw.format != "imf-v1" {
return Err(model_err(format!("unsupported format: {:?}", raw.format)));
}
if raw.tokenizer != "bytes" {
return Err(model_err(format!(
"tokenizer {:?}: this runtime is byte-level only",
raw.tokenizer
)));
}
let decoder = match raw.decoder.as_str() {
"plain" => DecoderMode::Plain,
"kv" => DecoderMode::Kv,
other => return Err(model_err(format!("unknown decoder mode {other:?}"))),
};
let mut sha256 = BTreeMap::new();
for (k, v) in raw.sha256 {
sha256.insert(k, normalize_digest(&v)?);
}
Ok(Manifest {
id: raw.id,
task: raw.task,
decoder,
precision: raw.precision,
opset: raw.opset,
sha256,
})
}
pub fn sha256_file(path: &Path) -> Result<String> {
let mut file = std::fs::File::open(path)
.map_err(|e| model_err(format!("cannot open {}: {e}", path.display())))?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 1024 * 1024];
loop {
let n = file
.read(&mut buf)
.map_err(|e| model_err(format!("read failed on {}: {e}", path.display())))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(to_hex(&hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_test_zip(path: &Path, encoder: &[u8], decoder: &[u8], decoder_digest: Option<&[u8]>) {
let file = std::fs::File::create(path).unwrap();
let mut zf = zip::ZipWriter::new(file);
let opts: zip::write::SimpleFileOptions = Default::default();
let digest = match decoder_digest {
Some(data) => sha256_hex(data),
None => sha256_hex(decoder),
};
let metadata = format!(
"format: imf-v1\nid: test-1.0\ntask: g2p\ntokenizer: bytes\ndecoder: plain\nopset: 14\nsha256:\n encoder.onnx: {}\n decoder.onnx: {}\n",
sha256_hex(encoder),
digest
);
zf.start_file("metadata.yaml", opts).unwrap();
zf.write_all(metadata.as_bytes()).unwrap();
zf.start_file("encoder.onnx", opts).unwrap();
zf.write_all(encoder).unwrap();
zf.start_file("decoder.onnx", opts).unwrap();
zf.write_all(decoder).unwrap();
zf.finish().unwrap();
}
#[test]
fn verifies_members_and_manifest() {
let dir = std::env::temp_dir().join("iscrs-loader-test");
std::fs::create_dir_all(&dir).unwrap();
let zip_path = dir.join("ok.zip");
write_test_zip(&zip_path, b"encoder-bytes", b"decoder-bytes", None);
let art = verify_and_read(&zip_path).unwrap();
assert_eq!(art.manifest.id, "test-1.0");
assert_eq!(art.manifest.decoder, DecoderMode::Plain);
assert_eq!(art.graphs["encoder.onnx"], b"encoder-bytes");
}
#[test]
fn rejects_uncovered_and_mismatched_members() {
let dir = std::env::temp_dir().join("iscrs-loader-test");
std::fs::create_dir_all(&dir).unwrap();
let bad = dir.join("bad.zip");
write_test_zip(&bad, b"encoder-bytes", b"other", Some(b"decoder-bytes"));
match verify_and_read(&bad) {
Err(Error::Model(msg)) => assert!(msg.contains("sha256 mismatch"), "{msg}"),
other => panic!("expected sha256 mismatch, got {other:?}"),
}
let uncovered = dir.join("uncovered.zip");
let file = std::fs::File::create(&uncovered).unwrap();
let mut zf = zip::ZipWriter::new(file);
let opts: zip::write::SimpleFileOptions = Default::default();
let metadata = format!(
"format: imf-v1\nid: test-1.0\ntask: g2p\ntokenizer: bytes\nsha256:\n encoder.onnx: {}\n decoder.onnx: {}\n",
sha256_hex(b"e"),
sha256_hex(b"d")
);
zf.start_file("metadata.yaml", opts).unwrap();
zf.write_all(metadata.as_bytes()).unwrap();
zf.start_file("encoder.onnx", opts).unwrap();
zf.write_all(b"e").unwrap();
zf.start_file("decoder.onnx", opts).unwrap();
zf.write_all(b"d").unwrap();
zf.start_file("decoder-kv.onnx", opts).unwrap();
zf.write_all(b"kv").unwrap();
zf.finish().unwrap();
match verify_and_read(&uncovered) {
Err(Error::Model(msg)) => assert!(msg.contains("not covered"), "{msg}"),
other => panic!("expected uncovered-member error, got {other:?}"),
}
}
#[test]
fn rejects_non_imf_format_and_non_bytes_tokenizer() {
let dir = std::env::temp_dir().join("iscrs-loader-test");
std::fs::create_dir_all(&dir).unwrap();
let file = std::fs::File::create(dir.join("fmt.zip")).unwrap();
let mut zf = zip::ZipWriter::new(file);
let opts: zip::write::SimpleFileOptions = Default::default();
zf.start_file("metadata.yaml", opts).unwrap();
zf.write_all(b"format: imf-v2\nid: x\ntask: t\ntokenizer: bytes\n").unwrap();
zf.start_file("encoder.onnx", opts).unwrap();
zf.write_all(b"e").unwrap();
zf.start_file("decoder.onnx", opts).unwrap();
zf.write_all(b"d").unwrap();
zf.finish().unwrap();
match load_manifest(&dir.join("fmt.zip")) {
Err(Error::Model(msg)) => assert!(msg.contains("unsupported format"), "{msg}"),
other => panic!("expected format rejection, got {other:?}"),
}
}
}