Skip to main content

interscript_rs/imf/
loader.rs

1//! IMF v1 zip loading: manifest parse + sha256 member verification
2//! (`interscript-models/runtime/src/interscript_ml/loader.py` parity,
3//! SPEC.md §3).
4//!
5//! Conformance points implemented here:
6//! * C3 — every `.onnx` member is verified against the manifest sha256
7//!   map on load; members not covered by the manifest MUST NOT load.
8//! * §3 — rejects any other `format`, any `tokenizer` other than
9//!   `bytes`, and missing required members (`metadata.yaml`,
10//!   `encoder.onnx`, `decoder.onnx`).
11//! * Zip-level integrity is never trusted (the manifest decides).
12
13use crate::error::{Error, Result};
14use sha2::{Digest, Sha256};
15use std::collections::BTreeMap;
16use std::io::Read;
17use std::path::Path;
18
19/// `decoder: plain | kv` — kv iff `decoder-kv.onnx` is present.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DecoderMode {
22    Plain,
23    Kv,
24}
25
26/// `metadata.yaml` as parsed from the artifact (Manifest dataclass
27/// parity).
28#[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    /// Member name -> recorded hex digest. Every `.onnx` member MUST be
36    /// covered.
37    pub sha256: BTreeMap<String, String>,
38}
39
40/// A loaded, checksum-verified artifact: the manifest plus the
41/// verified ONNX graphs keyed by member name.
42#[derive(Debug)]
43pub struct ArtifactGraphs {
44    pub manifest: Manifest,
45    /// Verified `.onnx` member bytes (`encoder.onnx`, `decoder.onnx`,
46    /// and `decoder-kv.onnx` when present).
47    pub graphs: BTreeMap<String, Vec<u8>>,
48}
49
50impl ArtifactGraphs {
51    /// The member the decoder session must load: `decoder-kv.onnx` for
52    /// kv artifacts that carry it, else `decoder.onnx` (model.py
53    /// decoder_name parity).
54    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
81/// Hex-normalize for comparison: the manifest digests are lowercase hex
82/// in released metadata; accept either case but nothing else.
83fn 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
91/// Load and verify every `.onnx` member of an IMF v1 zip.
92pub 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        // C3: uncovered members MUST NOT load.
120        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
141/// Parse `metadata.yaml` from the artifact zip and enforce the §3
142/// format contract (load_manifest parity).
143pub 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    // §3: any other format / tokenizer MUST be rejected.
193    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
223/// Whole-artifact sha256 (streaming; SPEC.md C2 — cache hits are
224/// re-verified, never trusted blindly).
225pub 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    /// Build a minimal IMF v1 zip for loader tests. `decoder_digest`
248    /// overrides the recorded decoder digest source (to fabricate a
249    /// mismatch: record the digest of DIFFERENT bytes than the member).
250    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        // member carries "other" while the manifest records the digest
291        // of "decoder-bytes" -> C3 mismatch.
292        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        // an .onnx member NOT covered by the manifest (decoder-kv.onnx
300        // with no sha256 entry) -> MUST NOT load.
301        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}