1use crate::error::{Error, Result};
14use sha2::{Digest, Sha256};
15use std::collections::BTreeMap;
16use std::io::Read;
17use std::path::Path;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DecoderMode {
22 Plain,
23 Kv,
24}
25
26#[derive(Debug, Clone)]
29pub struct Manifest {
30 pub id: String,
31 pub task: String,
32 pub decoder: DecoderMode,
33 pub precision: String,
34 pub opset: u32,
35 pub sha256: BTreeMap<String, String>,
38}
39
40#[derive(Debug)]
43pub struct ArtifactGraphs {
44 pub manifest: Manifest,
45 pub graphs: BTreeMap<String, Vec<u8>>,
48}
49
50impl ArtifactGraphs {
51 pub fn decoder_member(&self) -> Result<&str> {
55 match self.manifest.decoder {
56 DecoderMode::Kv if self.graphs.contains_key("decoder-kv.onnx") => Ok("decoder-kv.onnx"),
57 DecoderMode::Kv => Ok("decoder.onnx"),
58 DecoderMode::Plain => Ok("decoder.onnx"),
59 }
60 }
61}
62
63fn model_err(what: impl Into<String>) -> Error {
64 Error::Model(what.into())
65}
66
67fn sha256_hex(data: &[u8]) -> String {
68 let mut hasher = Sha256::new();
69 hasher.update(data);
70 to_hex(&hasher.finalize())
71}
72
73pub(crate) fn to_hex(bytes: &[u8]) -> String {
74 let mut out = String::with_capacity(bytes.len() * 2);
75 for b in bytes {
76 out.push_str(&format!("{b:02x}"));
77 }
78 out
79}
80
81fn normalize_digest(s: &str) -> Result<String> {
84 let lower = s.trim().to_lowercase();
85 if lower.len() != 64 || !lower.bytes().all(|b| b.is_ascii_hexdigit()) {
86 return Err(model_err(format!("malformed sha256 digest {s:?}")));
87 }
88 Ok(lower)
89}
90
91pub fn verify_and_read(zip_path: &Path) -> Result<ArtifactGraphs> {
93 let manifest = load_manifest(zip_path)?;
94 let file = std::fs::File::open(zip_path).map_err(|e| model_err(format!(
95 "cannot open artifact {}: {e}",
96 zip_path.display()
97 )))?;
98 let mut zf = zip::ZipArchive::new(file)
99 .map_err(|e| model_err(format!("{} is not a readable zip: {e}", zip_path.display())))?;
100
101 let names: Vec<String> = (0..zf.len())
102 .filter_map(|i| {
103 zf.by_index(i)
104 .ok()
105 .and_then(|f| f.name().ok().map(|n| n.into_owned()))
106 })
107 .collect();
108
109 let mut graphs = BTreeMap::new();
110 for name in names.iter().filter(|n| n.ends_with(".onnx")) {
111 let mut member = zf.by_name(name).map_err(|e| model_err(format!(
112 "member {name:?} unreadable: {e}"
113 )))?;
114 let mut bytes = Vec::with_capacity(member.size() as usize);
115 member
116 .read_to_end(&mut bytes)
117 .map_err(|e| model_err(format!("member {name:?} read failed: {e}")))?;
118
119 let recorded = manifest.sha256.get(name).ok_or_else(|| {
121 model_err(format!("{name} is not covered by metadata sha256"))
122 })?;
123 let actual = sha256_hex(&bytes);
124 if actual != normalize_digest(recorded)? {
125 return Err(model_err(format!(
126 "{name} sha256 mismatch: zip has {actual}, metadata says {recorded}"
127 )));
128 }
129 graphs.insert(name.clone(), bytes);
130 }
131
132 if !graphs.contains_key("encoder.onnx") || !graphs.contains_key("decoder.onnx") {
133 return Err(model_err(format!(
134 "{} is missing verified encoder/decoder members",
135 zip_path.display()
136 )));
137 }
138 Ok(ArtifactGraphs { manifest, graphs })
139}
140
141pub fn load_manifest(zip_path: &Path) -> Result<Manifest> {
144 let file = std::fs::File::open(zip_path).map_err(|e| model_err(format!(
145 "cannot open artifact {}: {e}",
146 zip_path.display()
147 )))?;
148 let mut zf = zip::ZipArchive::new(file)
149 .map_err(|e| model_err(format!("{} is not a readable zip: {e}", zip_path.display())))?;
150
151 for required in ["metadata.yaml", "encoder.onnx", "decoder.onnx"] {
152 if zf.by_name(required).is_err() {
153 return Err(model_err(format!("missing required file: {required}")));
154 }
155 }
156
157 let mut meta = zf.by_name("metadata.yaml").map_err(|e| {
158 model_err(format!("metadata.yaml unreadable: {e}"))
159 })?;
160 let mut raw = String::with_capacity(meta.size() as usize);
161 meta.read_to_string(&mut raw)
162 .map_err(|e| model_err(format!("metadata.yaml read failed: {e}")))?;
163
164 #[derive(serde::Deserialize)]
165 struct MetadataYaml {
166 format: String,
167 id: String,
168 task: String,
169 tokenizer: String,
170 #[serde(default = "default_decoder")]
171 decoder: String,
172 #[serde(default = "default_precision")]
173 precision: String,
174 #[serde(default = "default_opset")]
175 opset: u32,
176 #[serde(default)]
177 sha256: BTreeMap<String, String>,
178 }
179 fn default_decoder() -> String {
180 "plain".into()
181 }
182 fn default_precision() -> String {
183 "fp32".into()
184 }
185 fn default_opset() -> u32 {
186 14
187 }
188
189 let raw: MetadataYaml = serde_yaml::from_str(&raw)
190 .map_err(|e| model_err(format!("metadata.yaml parse failed: {e}")))?;
191
192 if raw.format != "imf-v1" {
194 return Err(model_err(format!("unsupported format: {:?}", raw.format)));
195 }
196 if raw.tokenizer != "bytes" {
197 return Err(model_err(format!(
198 "tokenizer {:?}: this runtime is byte-level only",
199 raw.tokenizer
200 )));
201 }
202 let decoder = match raw.decoder.as_str() {
203 "plain" => DecoderMode::Plain,
204 "kv" => DecoderMode::Kv,
205 other => return Err(model_err(format!("unknown decoder mode {other:?}"))),
206 };
207
208 let mut sha256 = BTreeMap::new();
209 for (k, v) in raw.sha256 {
210 sha256.insert(k, normalize_digest(&v)?);
211 }
212
213 Ok(Manifest {
214 id: raw.id,
215 task: raw.task,
216 decoder,
217 precision: raw.precision,
218 opset: raw.opset,
219 sha256,
220 })
221}
222
223pub fn sha256_file(path: &Path) -> Result<String> {
226 let mut file = std::fs::File::open(path)
227 .map_err(|e| model_err(format!("cannot open {}: {e}", path.display())))?;
228 let mut hasher = Sha256::new();
229 let mut buf = vec![0u8; 1024 * 1024];
230 loop {
231 let n = file
232 .read(&mut buf)
233 .map_err(|e| model_err(format!("read failed on {}: {e}", path.display())))?;
234 if n == 0 {
235 break;
236 }
237 hasher.update(&buf[..n]);
238 }
239 Ok(to_hex(&hasher.finalize()))
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use std::io::Write;
246
247 fn write_test_zip(path: &Path, encoder: &[u8], decoder: &[u8], decoder_digest: Option<&[u8]>) {
251 let file = std::fs::File::create(path).unwrap();
252 let mut zf = zip::ZipWriter::new(file);
253 let opts: zip::write::SimpleFileOptions = Default::default();
254 let digest = match decoder_digest {
255 Some(data) => sha256_hex(data),
256 None => sha256_hex(decoder),
257 };
258 let metadata = format!(
259 "format: imf-v1\nid: test-1.0\ntask: g2p\ntokenizer: bytes\ndecoder: plain\nopset: 14\nsha256:\n encoder.onnx: {}\n decoder.onnx: {}\n",
260 sha256_hex(encoder),
261 digest
262 );
263 zf.start_file("metadata.yaml", opts).unwrap();
264 zf.write_all(metadata.as_bytes()).unwrap();
265 zf.start_file("encoder.onnx", opts).unwrap();
266 zf.write_all(encoder).unwrap();
267 zf.start_file("decoder.onnx", opts).unwrap();
268 zf.write_all(decoder).unwrap();
269 zf.finish().unwrap();
270 }
271
272 #[test]
273 fn verifies_members_and_manifest() {
274 let dir = std::env::temp_dir().join("iscrs-loader-test");
275 std::fs::create_dir_all(&dir).unwrap();
276 let zip_path = dir.join("ok.zip");
277 write_test_zip(&zip_path, b"encoder-bytes", b"decoder-bytes", None);
278
279 let art = verify_and_read(&zip_path).unwrap();
280 assert_eq!(art.manifest.id, "test-1.0");
281 assert_eq!(art.manifest.decoder, DecoderMode::Plain);
282 assert_eq!(art.graphs["encoder.onnx"], b"encoder-bytes");
283 }
284
285 #[test]
286 fn rejects_uncovered_and_mismatched_members() {
287 let dir = std::env::temp_dir().join("iscrs-loader-test");
288 std::fs::create_dir_all(&dir).unwrap();
289
290 let bad = dir.join("bad.zip");
293 write_test_zip(&bad, b"encoder-bytes", b"other", Some(b"decoder-bytes"));
294 match verify_and_read(&bad) {
295 Err(Error::Model(msg)) => assert!(msg.contains("sha256 mismatch"), "{msg}"),
296 other => panic!("expected sha256 mismatch, got {other:?}"),
297 }
298
299 let uncovered = dir.join("uncovered.zip");
302 let file = std::fs::File::create(&uncovered).unwrap();
303 let mut zf = zip::ZipWriter::new(file);
304 let opts: zip::write::SimpleFileOptions = Default::default();
305 let metadata = format!(
306 "format: imf-v1\nid: test-1.0\ntask: g2p\ntokenizer: bytes\nsha256:\n encoder.onnx: {}\n decoder.onnx: {}\n",
307 sha256_hex(b"e"),
308 sha256_hex(b"d")
309 );
310 zf.start_file("metadata.yaml", opts).unwrap();
311 zf.write_all(metadata.as_bytes()).unwrap();
312 zf.start_file("encoder.onnx", opts).unwrap();
313 zf.write_all(b"e").unwrap();
314 zf.start_file("decoder.onnx", opts).unwrap();
315 zf.write_all(b"d").unwrap();
316 zf.start_file("decoder-kv.onnx", opts).unwrap();
317 zf.write_all(b"kv").unwrap();
318 zf.finish().unwrap();
319 match verify_and_read(&uncovered) {
320 Err(Error::Model(msg)) => assert!(msg.contains("not covered"), "{msg}"),
321 other => panic!("expected uncovered-member error, got {other:?}"),
322 }
323 }
324
325 #[test]
326 fn rejects_non_imf_format_and_non_bytes_tokenizer() {
327 let dir = std::env::temp_dir().join("iscrs-loader-test");
328 std::fs::create_dir_all(&dir).unwrap();
329
330 let file = std::fs::File::create(dir.join("fmt.zip")).unwrap();
331 let mut zf = zip::ZipWriter::new(file);
332 let opts: zip::write::SimpleFileOptions = Default::default();
333 zf.start_file("metadata.yaml", opts).unwrap();
334 zf.write_all(b"format: imf-v2\nid: x\ntask: t\ntokenizer: bytes\n").unwrap();
335 zf.start_file("encoder.onnx", opts).unwrap();
336 zf.write_all(b"e").unwrap();
337 zf.start_file("decoder.onnx", opts).unwrap();
338 zf.write_all(b"d").unwrap();
339 zf.finish().unwrap();
340
341 match load_manifest(&dir.join("fmt.zip")) {
342 Err(Error::Model(msg)) => assert!(msg.contains("unsupported format"), "{msg}"),
343 other => panic!("expected format rejection, got {other:?}"),
344 }
345 }
346}