Skip to main content

rill_runtime/
handler_package.rs

1//! Signed handler-pack (`.rillhandler`) construction and verification.
2
3use std::io::{Read, Seek};
4
5use ed25519_dalek::SigningKey;
6use rill_runtime_protocol::HandlerPackManifest;
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9use thiserror::Error;
10
11use crate::archive::{
12    ArchiveError, ArchiveLimits, DEFAULT_PATHS, TrustStore, build_signed_archive, read_archive,
13    verify_checksums_and_signature,
14};
15
16const MODULE_PATH: &str = "handler.wasm";
17
18const HANDLER_PACK_LIMITS: ArchiveLimits = ArchiveLimits {
19    max_files: 8,
20    max_file_bytes: 4 * 1024 * 1024,
21    max_total_bytes: 16 * 1024 * 1024,
22    max_compressed_total_bytes: 8 * 1024 * 1024,
23    max_compression_ratio: 100,
24};
25
26const HANDLER_PACK_ALLOWED: &[&str] = &[
27    "manifest.json",
28    MODULE_PATH,
29    "checksums.json",
30    "META-INF/signature.ed25519",
31];
32
33#[derive(Debug, Clone)]
34pub struct LoadedHandlerPack {
35    pub manifest: HandlerPackManifest,
36    pub module: Vec<u8>,
37}
38
39#[derive(Debug, Clone, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct HandlerPackInspection {
42    pub id: String,
43    pub version: String,
44    pub publisher_key_id: String,
45    pub handler_api_version: u32,
46    pub min_runtime_version: String,
47    pub capabilities: Vec<String>,
48    pub module_sha256: String,
49    pub module_size: u64,
50    pub signature_verified: bool,
51}
52
53#[derive(Debug, Error)]
54#[non_exhaustive]
55pub enum HandlerPackError {
56    #[error(transparent)]
57    Archive(#[from] ArchiveError),
58    #[error(transparent)]
59    Json(#[from] serde_json::Error),
60    #[error("invalid handler manifest: {0}")]
61    Manifest(String),
62    #[error("runtime {actual} is older than handler requirement {minimum}")]
63    RuntimeTooOld { minimum: String, actual: String },
64    #[error("handler module SHA-256 mismatch")]
65    ModuleDigestMismatch,
66}
67
68pub fn load_handler_pack<R: Read + Seek>(
69    reader: R,
70    trust: &TrustStore,
71) -> Result<(LoadedHandlerPack, HandlerPackInspection), HandlerPackError> {
72    let files = read_archive(reader, HANDLER_PACK_ALLOWED, HANDLER_PACK_LIMITS)?;
73    let manifest_bytes = files
74        .get(DEFAULT_PATHS.manifest)
75        .ok_or(ArchiveError::Missing(DEFAULT_PATHS.manifest))?;
76    let manifest: HandlerPackManifest = serde_json::from_slice(manifest_bytes)?;
77    manifest
78        .validate_shape()
79        .map_err(|message| HandlerPackError::Manifest(message.into()))?;
80    semver::Version::parse(&manifest.version)
81        .map_err(|error| HandlerPackError::Manifest(format!("invalid handler version: {error}")))?;
82    let minimum = semver::Version::parse(&manifest.min_runtime_version)
83        .map_err(|error| HandlerPackError::Manifest(format!("invalid minimum runtime: {error}")))?;
84    let runtime = semver::Version::parse(env!("CARGO_PKG_VERSION"))
85        .map_err(|error| HandlerPackError::Manifest(format!("invalid runtime version: {error}")))?;
86    if runtime < minimum {
87        return Err(HandlerPackError::RuntimeTooOld {
88            minimum: minimum.to_string(),
89            actual: runtime.to_string(),
90        });
91    }
92    verify_checksums_and_signature(
93        &files,
94        &DEFAULT_PATHS,
95        &[DEFAULT_PATHS.manifest, MODULE_PATH],
96        &manifest.publisher_key_id,
97        trust,
98    )?;
99
100    let module = files
101        .get(MODULE_PATH)
102        .ok_or(ArchiveError::Missing(MODULE_PATH))?
103        .clone();
104
105    // Verify the manifest's moduleSha256 matches the actual module bytes.
106    let actual_digest = hex::encode(Sha256::digest(&module));
107    if actual_digest != manifest.module_sha256 {
108        return Err(HandlerPackError::ModuleDigestMismatch);
109    }
110    if module.len() as u64 != manifest.module_size {
111        return Err(HandlerPackError::Manifest("module size mismatch".into()));
112    }
113
114    let inspection = HandlerPackInspection {
115        id: manifest.id.clone(),
116        version: manifest.version.clone(),
117        publisher_key_id: manifest.publisher_key_id.clone(),
118        handler_api_version: manifest.handler_api_version,
119        min_runtime_version: manifest.min_runtime_version.clone(),
120        capabilities: manifest.capabilities.clone(),
121        module_sha256: manifest.module_sha256.clone(),
122        module_size: manifest.module_size,
123        signature_verified: true,
124    };
125    Ok((LoadedHandlerPack { manifest, module }, inspection))
126}
127
128pub fn build_signed_handler_pack(
129    manifest: &HandlerPackManifest,
130    module: &[u8],
131    signing_key: &SigningKey,
132) -> Result<Vec<u8>, HandlerPackError> {
133    manifest
134        .validate_shape()
135        .map_err(|message| HandlerPackError::Manifest(message.into()))?;
136    // Verify moduleSha256 and moduleSize match the actual module.
137    let actual_digest = hex::encode(Sha256::digest(module));
138    if actual_digest != manifest.module_sha256 {
139        return Err(HandlerPackError::ModuleDigestMismatch);
140    }
141    if module.len() as u64 != manifest.module_size {
142        return Err(HandlerPackError::Manifest("module size mismatch".into()));
143    }
144    let manifest_bytes = serde_json::to_vec_pretty(manifest)?;
145    let archive = build_signed_archive(&manifest_bytes, MODULE_PATH, module, signing_key)?;
146    Ok(archive)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use rill_runtime_protocol::{HANDLER_API_VERSION, HANDLER_PACKAGE_FORMAT_VERSION};
153    use std::collections::BTreeMap;
154
155    fn manifest(key_id: &str, module: &[u8]) -> HandlerPackManifest {
156        HandlerPackManifest {
157            format_version: HANDLER_PACKAGE_FORMAT_VERSION,
158            id: "org.example.handler".into(),
159            version: "1.0.0".into(),
160            handler_api_version: HANDLER_API_VERSION,
161            min_runtime_version: "0.7.0".into(),
162            publisher_key_id: key_id.into(),
163            capabilities: vec!["org.example.predict".into()],
164            module_sha256: hex::encode(Sha256::digest(module)),
165            module_size: module.len() as u64,
166        }
167    }
168
169    #[test]
170    fn handler_pack_roundtrip_and_tamper_rejection() {
171        let signing = SigningKey::from_bytes(&[11; 32]);
172        let key_id = "handler-test-key";
173        let module = b"\0asm\x01\x00\x00\x00test module bytes";
174        let bytes = build_signed_handler_pack(&manifest(key_id, module), module, &signing).unwrap();
175        let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
176        let (loaded, inspection) = load_handler_pack(std::io::Cursor::new(&bytes), &trust).unwrap();
177        assert_eq!(loaded.manifest.id, "org.example.handler");
178        assert_eq!(loaded.module, module);
179        assert!(inspection.signature_verified);
180
181        let wrong = SigningKey::from_bytes(&[12; 32]);
182        let wrong_trust = TrustStore(BTreeMap::from([(key_id.into(), wrong.verifying_key())]));
183        assert!(matches!(
184            load_handler_pack(std::io::Cursor::new(bytes), &wrong_trust),
185            Err(HandlerPackError::Archive(ArchiveError::Signature))
186        ));
187    }
188
189    #[test]
190    fn handler_pack_rejects_wrong_module_digest() {
191        let signing = SigningKey::from_bytes(&[13; 32]);
192        let key_id = "digest-test";
193        let real_module = b"real module";
194        let fake_module = b"fake module";
195        let mut bad_manifest = manifest(key_id, fake_module);
196        // Set the digest to the real module's digest, but provide the fake module.
197        bad_manifest.module_sha256 = hex::encode(Sha256::digest(real_module));
198        bad_manifest.module_size = real_module.len() as u64;
199        assert!(matches!(
200            build_signed_handler_pack(&bad_manifest, fake_module, &signing),
201            Err(HandlerPackError::ModuleDigestMismatch)
202        ));
203    }
204
205    #[test]
206    fn handler_pack_rejects_model_trust_key() {
207        // A handler signed with one key must not verify against a different key.
208        let handler_signing = SigningKey::from_bytes(&[14; 32]);
209        let model_signing = SigningKey::from_bytes(&[15; 32]);
210        let key_id = "shared-key-id";
211        let module = b"test module";
212        let bytes =
213            build_signed_handler_pack(&manifest(key_id, module), module, &handler_signing).unwrap();
214        // Model trust store uses a different key under the same key id.
215        let model_trust = TrustStore(BTreeMap::from([(
216            key_id.into(),
217            model_signing.verifying_key(),
218        )]));
219        assert!(matches!(
220            load_handler_pack(std::io::Cursor::new(&bytes), &model_trust),
221            Err(HandlerPackError::Archive(ArchiveError::Signature))
222        ));
223    }
224
225    use std::io::{Cursor, Write};
226    use zip::{ZipWriter, write::SimpleFileOptions};
227
228    fn build_malicious_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
229        let mut output = Cursor::new(Vec::new());
230        {
231            let mut zip = ZipWriter::new(&mut output);
232            let options = SimpleFileOptions::default();
233            for (name, data) in files {
234                zip.start_file(name, options).unwrap();
235                zip.write_all(data).unwrap();
236            }
237            zip.finish().unwrap();
238        }
239        output.into_inner()
240    }
241
242    #[test]
243    fn handler_pack_rejects_extra_file() {
244        let files: &[(&str, &[u8])] = &[
245            ("manifest.json", b"{}"),
246            ("handler.wasm", b"wasm"),
247            ("checksums.json", b"{}"),
248            ("META-INF/signature.ed25519", b"sig"),
249            ("evil.txt", b"evil"),
250        ];
251        let bytes = build_malicious_zip(files);
252        let trust = TrustStore(BTreeMap::new());
253        assert!(matches!(
254            load_handler_pack(std::io::Cursor::new(&bytes), &trust),
255            Err(HandlerPackError::Archive(ArchiveError::Forbidden(_)))
256        ));
257    }
258
259    #[test]
260    fn handler_pack_rejects_path_traversal() {
261        let bytes = build_malicious_zip(&[("../escape.txt", b"escape")]);
262        let trust = TrustStore(BTreeMap::new());
263        assert!(matches!(
264            load_handler_pack(std::io::Cursor::new(&bytes), &trust),
265            Err(HandlerPackError::Archive(ArchiveError::UnsafePath(_)))
266        ));
267    }
268
269    #[test]
270    fn handler_pack_rejects_absolute_path() {
271        let bytes = build_malicious_zip(&[("/etc/passwd", b"root")]);
272        let trust = TrustStore(BTreeMap::new());
273        assert!(matches!(
274            load_handler_pack(std::io::Cursor::new(&bytes), &trust),
275            Err(HandlerPackError::Archive(ArchiveError::UnsafePath(_)))
276        ));
277    }
278
279    fn crc32(data: &[u8]) -> u32 {
280        let mut crc: u32 = 0xFFFFFFFF;
281        for &byte in data {
282            crc ^= byte as u32;
283            for _ in 0..8 {
284                crc = (crc >> 1) ^ (0xEDB88320 & (0u32.wrapping_sub(crc & 1)));
285            }
286        }
287        !crc
288    }
289
290    // Builds a ZIP from raw bytes so duplicate entry names can be emitted,
291    // which the zip crate's ZipWriter refuses to create.
292    fn build_raw_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
293        let mut buf = Vec::new();
294        let mut offsets: Vec<u32> = Vec::new();
295        for (name, data) in files {
296            let crc = crc32(data);
297            offsets.push(buf.len() as u32);
298            // Local file header
299            buf.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
300            buf.extend_from_slice(&20u16.to_le_bytes());
301            buf.extend_from_slice(&0u16.to_le_bytes());
302            buf.extend_from_slice(&0u16.to_le_bytes());
303            buf.extend_from_slice(&0u16.to_le_bytes());
304            buf.extend_from_slice(&0u16.to_le_bytes());
305            buf.extend_from_slice(&crc.to_le_bytes());
306            buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
307            buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
308            buf.extend_from_slice(&(name.len() as u16).to_le_bytes());
309            buf.extend_from_slice(&0u16.to_le_bytes());
310            buf.extend_from_slice(name.as_bytes());
311            buf.extend_from_slice(data);
312        }
313        let cd_start = buf.len() as u32;
314        for (i, (name, data)) in files.iter().enumerate() {
315            let crc = crc32(data);
316            // Central directory file header
317            buf.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
318            buf.extend_from_slice(&20u16.to_le_bytes());
319            buf.extend_from_slice(&20u16.to_le_bytes());
320            buf.extend_from_slice(&0u16.to_le_bytes());
321            buf.extend_from_slice(&0u16.to_le_bytes());
322            buf.extend_from_slice(&0u16.to_le_bytes());
323            buf.extend_from_slice(&0u16.to_le_bytes());
324            buf.extend_from_slice(&crc.to_le_bytes());
325            buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
326            buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
327            buf.extend_from_slice(&(name.len() as u16).to_le_bytes());
328            buf.extend_from_slice(&0u16.to_le_bytes());
329            buf.extend_from_slice(&0u16.to_le_bytes());
330            buf.extend_from_slice(&0u16.to_le_bytes());
331            buf.extend_from_slice(&0u16.to_le_bytes());
332            buf.extend_from_slice(&0u32.to_le_bytes());
333            buf.extend_from_slice(&offsets[i].to_le_bytes());
334            buf.extend_from_slice(name.as_bytes());
335        }
336        let cd_size = buf.len() as u32 - cd_start;
337        // End of central directory record
338        buf.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
339        buf.extend_from_slice(&0u16.to_le_bytes());
340        buf.extend_from_slice(&0u16.to_le_bytes());
341        buf.extend_from_slice(&(files.len() as u16).to_le_bytes());
342        buf.extend_from_slice(&(files.len() as u16).to_le_bytes());
343        buf.extend_from_slice(&cd_size.to_le_bytes());
344        buf.extend_from_slice(&cd_start.to_le_bytes());
345        buf.extend_from_slice(&0u16.to_le_bytes());
346        buf
347    }
348
349    #[test]
350    fn handler_pack_rejects_duplicate_entry() {
351        // The zip crate deduplicates same-named entries in its IndexMap at
352        // read time, so ArchiveError::Duplicate is defensive. Build a raw ZIP
353        // with two manifest.json entries and verify the archive is rejected.
354        let bytes = build_raw_zip(&[("manifest.json", b"first"), ("manifest.json", b"second")]);
355        let trust = TrustStore(BTreeMap::new());
356        assert!(load_handler_pack(std::io::Cursor::new(&bytes), &trust).is_err());
357    }
358
359    #[test]
360    fn handler_pack_rejects_invalid_semver() {
361        // validate_shape() only checks that `version` is non-empty and <=48
362        // chars; the actual semver rejection happens in load_handler_pack via
363        // semver::Version::parse, so exercise that path here.
364        let signing = SigningKey::from_bytes(&[20; 32]);
365        let key_id = "semver-test";
366        let module = b"semver module";
367        let mut bad_manifest = manifest(key_id, module);
368        bad_manifest.version = "not-semver".into();
369        let bytes = build_signed_handler_pack(&bad_manifest, module, &signing).unwrap();
370        let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
371        assert!(matches!(
372            load_handler_pack(std::io::Cursor::new(&bytes), &trust),
373            Err(HandlerPackError::Manifest(_))
374        ));
375    }
376
377    #[test]
378    fn handler_pack_rejects_duplicate_capabilities() {
379        let module = b"dup cap module";
380        let mut bad_manifest = manifest("dup-cap-test", module);
381        bad_manifest.capabilities = vec!["dup".into(), "dup".into()];
382        assert!(bad_manifest.validate_shape().is_err());
383    }
384
385    #[test]
386    fn handler_pack_rejects_unknown_format_version() {
387        let module = b"format module";
388        let mut bad_manifest = manifest("format-test", module);
389        bad_manifest.format_version = 99;
390        assert!(bad_manifest.validate_shape().is_err());
391    }
392
393    #[test]
394    fn handler_pack_rejects_unknown_handler_api_version() {
395        let module = b"api module";
396        let mut bad_manifest = manifest("api-test", module);
397        bad_manifest.handler_api_version = 99;
398        assert!(bad_manifest.validate_shape().is_err());
399    }
400
401    #[test]
402    fn handler_pack_rejects_empty_capabilities() {
403        let module = b"empty cap module";
404        let mut bad_manifest = manifest("empty-cap-test", module);
405        bad_manifest.capabilities = vec![];
406        assert!(bad_manifest.validate_shape().is_err());
407    }
408
409    #[test]
410    fn handler_pack_rejects_oversized_module() {
411        let module = b"oversized module";
412        let mut bad_manifest = manifest("oversized-test", module);
413        bad_manifest.module_size = 5 * 1024 * 1024;
414        assert!(bad_manifest.validate_shape().is_err());
415    }
416
417    // ----- R-019: handler package attack tests -----
418
419    use std::io::Read;
420
421    /// Read all (name → bytes) entries from a ZIP archive.
422    fn read_zip_entries(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
423        let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap();
424        let mut entries = BTreeMap::new();
425        for index in 0..archive.len() {
426            let mut file = archive.by_index(index).unwrap();
427            let name = file.name().to_string();
428            let mut data = Vec::new();
429            file.read_to_end(&mut data).unwrap();
430            entries.insert(name, data);
431        }
432        entries
433    }
434
435    /// Rebuild a signed pack with one file's bytes tampered at the midpoint.
436    fn rebuild_with_tamper(bytes: &[u8], name_to_tamper: &str) -> Vec<u8> {
437        let mut entries = read_zip_entries(bytes);
438        let data = entries.get_mut(name_to_tamper).unwrap();
439        let idx = data.len() / 2;
440        data[idx] ^= 0xFF;
441        let files: Vec<(&str, &[u8])> = entries
442            .iter()
443            .map(|(k, v)| (k.as_str(), v.as_slice()))
444            .collect();
445        build_malicious_zip(&files)
446    }
447
448    #[test]
449    fn handler_pack_rejects_tampered_manifest() {
450        let signing = SigningKey::from_bytes(&[31; 32]);
451        let key_id = "tamper-manifest";
452        let module = b"tamper manifest module";
453        let bytes = build_signed_handler_pack(&manifest(key_id, module), module, &signing).unwrap();
454        let tampered = rebuild_with_tamper(&bytes, "manifest.json");
455        let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
456        // Tampering manifest bytes breaks either JSON parsing (→ Json error)
457        // or the checksum digest (→ Digest error). Either rejection is correct.
458        let result = load_handler_pack(std::io::Cursor::new(&tampered), &trust);
459        assert!(result.is_err(), "tampered manifest must be rejected");
460    }
461
462    #[test]
463    fn handler_pack_rejects_tampered_module() {
464        let signing = SigningKey::from_bytes(&[32; 32]);
465        let key_id = "tamper-module";
466        let module = b"tamper module bytes";
467        let bytes = build_signed_handler_pack(&manifest(key_id, module), module, &signing).unwrap();
468        let tampered = rebuild_with_tamper(&bytes, "handler.wasm");
469        let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
470        assert!(matches!(
471            load_handler_pack(std::io::Cursor::new(&tampered), &trust),
472            Err(HandlerPackError::Archive(ArchiveError::Digest(_)))
473        ));
474    }
475
476    #[test]
477    fn handler_pack_rejects_tampered_checksums() {
478        let signing = SigningKey::from_bytes(&[33; 32]);
479        let key_id = "tamper-checksums";
480        let module = b"tamper checksums module";
481        let bytes = build_signed_handler_pack(&manifest(key_id, module), module, &signing).unwrap();
482        let tampered = rebuild_with_tamper(&bytes, "checksums.json");
483        let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
484        // Tampering checksums bytes breaks JSON parsing, digest values, or
485        // signature verification (signature covers canonical checksums). Any
486        // of these rejections is correct.
487        let result = load_handler_pack(std::io::Cursor::new(&tampered), &trust);
488        assert!(result.is_err(), "tampered checksums must be rejected");
489    }
490
491    #[test]
492    fn handler_pack_rejects_tampered_signature() {
493        let signing = SigningKey::from_bytes(&[34; 32]);
494        let key_id = "tamper-signature";
495        let module = b"tamper signature module";
496        let bytes = build_signed_handler_pack(&manifest(key_id, module), module, &signing).unwrap();
497        let tampered = rebuild_with_tamper(&bytes, "META-INF/signature.ed25519");
498        let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
499        assert!(matches!(
500            load_handler_pack(std::io::Cursor::new(&tampered), &trust),
501            Err(HandlerPackError::Archive(ArchiveError::Signature))
502        ));
503    }
504
505    #[test]
506    fn handler_pack_rejects_unknown_publisher_key() {
507        let signing = SigningKey::from_bytes(&[35; 32]);
508        let key_id = "unknown-key-test";
509        let module = b"unknown key module";
510        let bytes = build_signed_handler_pack(&manifest(key_id, module), module, &signing).unwrap();
511        // Empty trust store — key_id is not present at all.
512        let trust = TrustStore(BTreeMap::new());
513        assert!(matches!(
514            load_handler_pack(std::io::Cursor::new(&bytes), &trust),
515            Err(HandlerPackError::Archive(ArchiveError::UnknownKey))
516        ));
517    }
518
519    #[test]
520    fn handler_pack_skips_directory_entries() {
521        let signing = SigningKey::from_bytes(&[36; 32]);
522        let key_id = "dir-entry-test";
523        let module = b"dir entry module";
524        let bytes = build_signed_handler_pack(&manifest(key_id, module), module, &signing).unwrap();
525
526        // Read entries from the valid pack.
527        let entries = read_zip_entries(&bytes);
528        let files: Vec<(&str, &[u8])> = entries
529            .iter()
530            .map(|(k, v)| (k.as_str(), v.as_slice()))
531            .collect();
532
533        // Rebuild with a directory entry prepended. The `is_dir()` skip logic
534        // in read_archive must allow the pack to load despite the extra
535        // `META-INF/` directory entry (which is not in the allowed list).
536        let mut output = Cursor::new(Vec::new());
537        {
538            let mut zip = ZipWriter::new(&mut output);
539            let options = SimpleFileOptions::default();
540            zip.add_directory("META-INF/", options).unwrap();
541            for (name, data) in &files {
542                zip.start_file(name, options).unwrap();
543                zip.write_all(data).unwrap();
544            }
545            zip.finish().unwrap();
546        }
547        let rebuilt = output.into_inner();
548
549        let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
550        let result = load_handler_pack(std::io::Cursor::new(&rebuilt), &trust);
551        assert!(
552            result.is_ok(),
553            "directory entry should be skipped: {:?}",
554            result.err()
555        );
556    }
557
558    #[test]
559    fn handler_pack_rejects_oversized_file_in_zip() {
560        // Build a ZIP with a handler.wasm file exceeding the 4 MiB
561        // max_file_bytes limit. read_archive must reject it before manifest
562        // validation runs.
563        let oversized: Vec<u8> = vec![0u8; (4 * 1024 * 1024) + 1];
564        let files: &[(&str, &[u8])] = &[
565            ("manifest.json", b"{}"),
566            ("handler.wasm", &oversized),
567            ("checksums.json", b"{}"),
568            ("META-INF/signature.ed25519", &[0u8; 64]),
569        ];
570        let bytes = build_malicious_zip(files);
571        let trust = TrustStore(BTreeMap::new());
572        assert!(matches!(
573            load_handler_pack(std::io::Cursor::new(&bytes), &trust),
574            Err(HandlerPackError::Archive(ArchiveError::Limit("file size")))
575        ));
576    }
577
578    #[test]
579    fn handler_pack_rejects_compression_bomb() {
580        // Build a ZIP with a highly compressible handler.wasm (1 MiB of zeros,
581        // Deflated → ~1 KiB). The compression ratio ~1000:1 exceeds the
582        // max_compression_ratio limit of 100.
583        let uncompressed_size: usize = 1024 * 1024;
584        let bomb_payload: Vec<u8> = vec![0u8; uncompressed_size];
585        let mut output = Cursor::new(Vec::new());
586        {
587            let mut zip = ZipWriter::new(&mut output);
588            let options =
589                SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
590            zip.start_file("handler.wasm", options).unwrap();
591            zip.write_all(&bomb_payload).unwrap();
592            zip.finish().unwrap();
593        }
594        let bytes = output.into_inner();
595        let trust = TrustStore(BTreeMap::new());
596        assert!(matches!(
597            load_handler_pack(std::io::Cursor::new(&bytes), &trust),
598            Err(HandlerPackError::Archive(ArchiveError::Limit(
599                "compression ratio"
600            )))
601        ));
602    }
603
604    #[test]
605    fn handler_pack_rejects_load_time_module_digest_mismatch() {
606        // Build a pack where manifest.moduleSha256 points to real_module but
607        // the archive contains fake_module. checksums.json will correctly
608        // cover fake_module (so checksum + signature verification passes), but
609        // the load-time manifest-vs-module digest check triggers
610        // ModuleDigestMismatch.
611        let signing = SigningKey::from_bytes(&[37; 32]);
612        let key_id = "load-digest-test";
613        let real_module = b"real module for load digest test";
614        let fake_module = b"fake module for load digest test";
615
616        let manifest_json = serde_json::json!({
617            "formatVersion": HANDLER_PACKAGE_FORMAT_VERSION,
618            "id": "org.example.handler",
619            "version": "1.0.0",
620            "handlerApiVersion": HANDLER_API_VERSION,
621            "minRuntimeVersion": "0.7.0",
622            "publisherKeyId": key_id,
623            "capabilities": ["org.example.predict"],
624            "moduleSha256": hex::encode(Sha256::digest(real_module)),
625            "moduleSize": real_module.len() as u64,
626        });
627        let manifest_bytes = serde_json::to_vec_pretty(&manifest_json).unwrap();
628        let archive =
629            build_signed_archive(&manifest_bytes, MODULE_PATH, fake_module, &signing).unwrap();
630
631        let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
632        assert!(matches!(
633            load_handler_pack(std::io::Cursor::new(&archive), &trust),
634            Err(HandlerPackError::ModuleDigestMismatch)
635        ));
636    }
637
638    // ----- R-021: compatibility tests -----
639
640    #[test]
641    fn handler_pack_rejects_runtime_too_old() {
642        // A handler requiring a newer runtime than the current version must be
643        // rejected at load time with HandlerPackError::RuntimeTooOld.
644        let signing = SigningKey::from_bytes(&[41; 32]);
645        let key_id = "runtime-too-old-test";
646        let module = b"module for runtime too old test";
647        let mut bad_manifest = manifest(key_id, module);
648        // Require a runtime version far in the future.
649        bad_manifest.min_runtime_version = "999.0.0".into();
650        let bytes = build_signed_handler_pack(&bad_manifest, module, &signing).unwrap();
651        let trust = TrustStore(BTreeMap::from([(key_id.into(), signing.verifying_key())]));
652        assert!(matches!(
653            load_handler_pack(std::io::Cursor::new(&bytes), &trust),
654            Err(HandlerPackError::RuntimeTooOld { .. })
655        ));
656    }
657}