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