Skip to main content

rill_runtime/
archive.rs

1//! Common safe-archive skeleton shared by model packs (`.rillpack`) and
2//! handler packs (`.rillhandler`).
3//!
4//! Both pack formats use the same ZIP structure: a manifest, one payload file,
5//! a checksums file, and an Ed25519 signature. This module centralises the
6//! path validation, size limits, checksum verification and signature logic so
7//! that the two pack types cannot drift apart.
8
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    io::{Cursor, Read, Seek, Write},
12};
13
14use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
15use rill_runtime_protocol::{
16    ReleaseIndexPayload, SignedReleaseIndexWithGenerationV1, TrustMetadataV1,
17};
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use sha2::{Digest, Sha256};
21use thiserror::Error;
22use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions};
23
24const MANIFEST_PATH: &str = "manifest.json";
25const CHECKSUMS_PATH: &str = "checksums.json";
26const SIGNATURE_PATH: &str = "META-INF/signature.ed25519";
27
28#[derive(Debug, Default, Clone)]
29pub struct TrustStore(pub BTreeMap<String, VerifyingKey>);
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase", deny_unknown_fields)]
33pub(crate) struct Checksums {
34    schema_version: u32,
35    files: BTreeMap<String, String>,
36}
37
38#[derive(Debug, Error)]
39#[non_exhaustive]
40pub enum ArchiveError {
41    #[error("zip error: {0}")]
42    Zip(#[from] zip::result::ZipError),
43    #[error("I/O error: {0}")]
44    Io(#[from] std::io::Error),
45    #[error("JSON error: {0}")]
46    Json(#[from] serde_json::Error),
47    #[error("unsafe package path {0}")]
48    UnsafePath(String),
49    #[error("forbidden package file {0}")]
50    Forbidden(String),
51    #[error("duplicate package file {0}")]
52    Duplicate(String),
53    #[error("package exceeded {0} limit")]
54    Limit(&'static str),
55    #[error("missing package file {0}")]
56    Missing(&'static str),
57    #[error("missing package file {0}")]
58    MissingOwned(String),
59    #[error("checksum coverage does not exactly match the payload")]
60    ChecksumCoverage,
61    #[error("checksum mismatch for {0}")]
62    Digest(String),
63    #[error("unknown publisher key")]
64    UnknownKey,
65    #[error("signature verification failed")]
66    Signature,
67}
68
69#[derive(Debug, Error)]
70#[non_exhaustive]
71pub enum ReleaseIndexError {
72    #[error("JSON error: {0}")]
73    Json(#[from] serde_json::Error),
74    #[error("invalid release index: {0}")]
75    Manifest(String),
76    #[error("unknown release-index publisher key")]
77    UnknownKey,
78    #[error("release-index signature verification failed")]
79    Signature,
80    #[error("canonical JSON error: {0}")]
81    Canonical(ArchiveError),
82    #[error("invalid trust metadata: {0}")]
83    TrustMetadata(String),
84    #[error("trust metadata generation is older than the consumer metadata floor")]
85    MetadataDowngrade,
86    #[error("trust metadata content conflicts with the consumer metadata floor")]
87    MetadataConflict,
88    #[error("release index generation is older than the consumer rollback floor")]
89    Downgrade,
90}
91
92/// Limits for a specific pack type.
93#[derive(Debug, Clone, Copy)]
94pub(crate) struct ArchiveLimits {
95    pub max_files: usize,
96    pub max_file_bytes: u64,
97    pub max_total_bytes: u64,
98    pub max_compressed_total_bytes: u64,
99    pub max_compression_ratio: u64,
100}
101
102/// The canonical paths every pack must contain.
103pub(crate) struct PackPaths {
104    pub manifest: &'static str,
105    pub checksums: &'static str,
106    pub signature: &'static str,
107}
108
109pub(crate) const DEFAULT_PATHS: PackPaths = PackPaths {
110    manifest: MANIFEST_PATH,
111    checksums: CHECKSUMS_PATH,
112    signature: SIGNATURE_PATH,
113};
114
115pub fn canonical_json(bytes: &[u8]) -> Result<Vec<u8>, ArchiveError> {
116    fn canonical(value: Value) -> Value {
117        match value {
118            Value::Object(map) => {
119                // Explicitly sort object keys via BTreeMap so canonicalisation
120                // does not depend on serde_json's feature flags (preserve_order).
121                let sorted: BTreeMap<String, Value> = map
122                    .into_iter()
123                    .map(|(key, value)| (key, canonical(value)))
124                    .collect();
125                Value::Object(sorted.into_iter().collect())
126            }
127            Value::Array(items) => Value::Array(items.into_iter().map(canonical).collect()),
128            other => other,
129        }
130    }
131    let value: Value = serde_json::from_slice(bytes)?;
132    Ok(serde_json::to_vec(&canonical(value))?)
133}
134
135pub fn sign_release_index(
136    payload: ReleaseIndexPayload,
137    signing_key: &SigningKey,
138) -> Result<rill_runtime_protocol::SignedReleaseIndex, ReleaseIndexError> {
139    validate_release_payload(&payload)?;
140    let serialized = serde_json::to_vec(&payload)?;
141    let canonical = canonical_json(&serialized).map_err(ReleaseIndexError::Canonical)?;
142    let signature = hex::encode(signing_key.sign(&canonical).to_bytes());
143    Ok(rill_runtime_protocol::SignedReleaseIndex { payload, signature })
144}
145
146pub fn verify_release_index(
147    index: &rill_runtime_protocol::SignedReleaseIndex,
148    trust: &TrustStore,
149) -> Result<(), ReleaseIndexError> {
150    validate_release_payload(&index.payload)?;
151    let signature_bytes =
152        hex::decode(&index.signature).map_err(|_| ReleaseIndexError::Signature)?;
153    let signature =
154        Signature::from_slice(&signature_bytes).map_err(|_| ReleaseIndexError::Signature)?;
155    let key = trust
156        .0
157        .get(&index.payload.publisher_key_id)
158        .ok_or(ReleaseIndexError::UnknownKey)?;
159    let serialized = serde_json::to_vec(&index.payload)?;
160    let canonical = canonical_json(&serialized).map_err(ReleaseIndexError::Canonical)?;
161    key.verify(&canonical, &signature)
162        .map_err(|_| ReleaseIndexError::Signature)
163}
164
165/// Sign a v3 release index together with a monotonic generation envelope.
166///
167/// The v3 payload itself is unchanged. Consumers that opt into key lifecycle
168/// metadata verify this envelope and enforce its generation floor.
169pub fn sign_release_index_with_generation(
170    payload: ReleaseIndexPayload,
171    release_generation: u64,
172    signing_key: &SigningKey,
173) -> Result<SignedReleaseIndexWithGenerationV1, ReleaseIndexError> {
174    let index = sign_release_index(payload, signing_key)?;
175    let mut envelope = SignedReleaseIndexWithGenerationV1 {
176        schema_version: rill_runtime_protocol::RELEASE_INDEX_LIFECYCLE_SCHEMA_VERSION,
177        release_generation,
178        index,
179        lifecycle_signature: String::new(),
180    };
181    let canonical = lifecycle_signing_bytes(&envelope)?;
182    envelope.lifecycle_signature = hex::encode(signing_key.sign(&canonical).to_bytes());
183    Ok(envelope)
184}
185
186/// Verify a signed v3 index against rotating trust metadata.
187///
188/// This is a new opt-in reader. The legacy `verify_release_index` path remains
189/// exactly the v3 reader used by existing 1.x consumers. Trust metadata is
190/// fail-closed: malformed, future, expired, revoked, emergency-revoked,
191/// unknown-key and downgraded generations are rejected.
192pub fn verify_release_index_with_trust_metadata(
193    envelope: &SignedReleaseIndexWithGenerationV1,
194    metadata: &TrustMetadataV1,
195    floor: &rill_runtime_protocol::TrustVerificationFloorV1,
196    now_unix_ms: u64,
197) -> Result<(), ReleaseIndexError> {
198    envelope
199        .validate_shape()
200        .map_err(|error| ReleaseIndexError::TrustMetadata(error.into()))?;
201    metadata
202        .validate_shape()
203        .map_err(|error| ReleaseIndexError::TrustMetadata(error.into()))?;
204    if metadata.metadata_generation < floor.minimum_metadata_generation {
205        return Err(ReleaseIndexError::MetadataDowngrade);
206    }
207    if metadata.metadata_generation == floor.minimum_metadata_generation {
208        let expected = floor
209            .metadata_digest
210            .as_deref()
211            .ok_or(ReleaseIndexError::MetadataConflict)?;
212        if !is_sha256_hex(expected) || trust_metadata_digest(metadata)? != expected {
213            return Err(ReleaseIndexError::MetadataConflict);
214        }
215    }
216    if envelope.release_generation < floor.minimum_release_generation
217        || envelope.release_generation < metadata.minimum_release_generation
218    {
219        return Err(ReleaseIndexError::Downgrade);
220    }
221    let active = metadata
222        .active_keys_at(now_unix_ms)
223        .map_err(|error| ReleaseIndexError::TrustMetadata(error.into()))?;
224    let mut keys = BTreeMap::new();
225    for key in active {
226        let bytes = hex::decode(&key.public_key_hex)
227            .map_err(|_| ReleaseIndexError::TrustMetadata("invalid trust public key".into()))?;
228        let bytes: [u8; 32] = bytes
229            .try_into()
230            .map_err(|_| ReleaseIndexError::TrustMetadata("invalid trust public key".into()))?;
231        let public_key = VerifyingKey::from_bytes(&bytes)
232            .map_err(|_| ReleaseIndexError::TrustMetadata("invalid trust public key".into()))?;
233        keys.insert(key.key_id.clone(), public_key);
234    }
235    let publisher_key = keys
236        .get(&envelope.index.payload.publisher_key_id)
237        .ok_or(ReleaseIndexError::UnknownKey)?;
238    let lifecycle_signature = hex::decode(&envelope.lifecycle_signature)
239        .map_err(|_| ReleaseIndexError::TrustMetadata("invalid lifecycle signature".into()))?;
240    let lifecycle_signature = Signature::from_slice(&lifecycle_signature)
241        .map_err(|_| ReleaseIndexError::TrustMetadata("invalid lifecycle signature".into()))?;
242    let canonical = lifecycle_signing_bytes(envelope)?;
243    publisher_key
244        .verify(&canonical, &lifecycle_signature)
245        .map_err(|_| ReleaseIndexError::Signature)?;
246    verify_release_index(&envelope.index, &TrustStore(keys))
247}
248
249/// Return the canonical SHA-256 identity a consumer may persist alongside a
250/// metadata generation in [`rill_runtime_protocol::TrustVerificationFloorV1`].
251pub fn trust_metadata_digest(metadata: &TrustMetadataV1) -> Result<String, ReleaseIndexError> {
252    metadata
253        .validate_shape()
254        .map_err(|error| ReleaseIndexError::TrustMetadata(error.into()))?;
255    let serialized = serde_json::to_vec(metadata)?;
256    let canonical = canonical_json(&serialized).map_err(ReleaseIndexError::Canonical)?;
257    Ok(hex::encode(Sha256::digest(canonical)))
258}
259
260fn is_sha256_hex(value: &str) -> bool {
261    value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
262}
263
264#[derive(Serialize)]
265#[serde(rename_all = "camelCase")]
266struct LifecycleSigningView<'a> {
267    schema_version: u32,
268    release_generation: u64,
269    index: &'a rill_runtime_protocol::SignedReleaseIndex,
270}
271
272fn lifecycle_signing_bytes(
273    envelope: &SignedReleaseIndexWithGenerationV1,
274) -> Result<Vec<u8>, ReleaseIndexError> {
275    let view = LifecycleSigningView {
276        schema_version: envelope.schema_version,
277        release_generation: envelope.release_generation,
278        index: &envelope.index,
279    };
280    let serialized = serde_json::to_vec(&view)?;
281    canonical_json(&serialized).map_err(ReleaseIndexError::Canonical)
282}
283
284fn validate_release_payload(payload: &ReleaseIndexPayload) -> Result<(), ReleaseIndexError> {
285    payload
286        .validate_shape()
287        .map_err(|message| ReleaseIndexError::Manifest(message.into()))?;
288    let mut identities = BTreeSet::new();
289    for artifact in &payload.artifacts {
290        semver::Version::parse(&artifact.version).map_err(|error| {
291            ReleaseIndexError::Manifest(format!("invalid artifact version: {error}"))
292        })?;
293        let identity = (
294            artifact.kind.clone(),
295            artifact.id.clone(),
296            artifact.target_os.clone(),
297            artifact.target_arch.clone(),
298            artifact.handler_api_version,
299        );
300        if !identities.insert(identity) {
301            return Err(ReleaseIndexError::Manifest(
302                "duplicate release artifact identity".into(),
303            ));
304        }
305    }
306    Ok(())
307}
308
309/// Read a ZIP archive and validate paths, file count, and size limits.
310/// Returns a map of file name → bytes for every non-directory entry.
311pub(crate) fn read_archive<R: Read + Seek>(
312    reader: R,
313    allowed: &[&str],
314    limits: ArchiveLimits,
315) -> Result<BTreeMap<String, Vec<u8>>, ArchiveError> {
316    let mut archive = ZipArchive::new(reader)?;
317    if archive.len() > limits.max_files {
318        return Err(ArchiveError::Limit("file count"));
319    }
320    let mut total = 0u64;
321    let mut compressed_total = 0u64;
322    let mut files = BTreeMap::new();
323    for index in 0..archive.len() {
324        let mut entry = archive.by_index(index)?;
325        if entry.is_dir() {
326            continue;
327        }
328        let name = entry.name().to_string();
329        validate_path(&name)?;
330        if !allowed.iter().any(|allowed| *allowed == name) {
331            return Err(ArchiveError::Forbidden(name));
332        }
333        if entry.size() > limits.max_file_bytes {
334            return Err(ArchiveError::Limit("file size"));
335        }
336        let compressed = entry.compressed_size();
337        // Use checked multiplication instead of integer division so the
338        // comparison is exact: ``size / compressed`` truncates and would
339        // accept an entry whose true ratio is just above the limit
340        // (e.g. size=10, compressed=3, limit=3 → 10/3=3, accepted even
341        // though 10 > 3*3). ``size > compressed * ratio`` avoids both the
342        // truncation and any floating-point rounding, and the checked
343        // product guards against u64 overflow on adversarial inputs.
344        if compressed > 0 {
345            let cap = compressed
346                .checked_mul(limits.max_compression_ratio)
347                .ok_or(ArchiveError::Limit("compression ratio"))?;
348            if entry.size() > cap {
349                return Err(ArchiveError::Limit("compression ratio"));
350            }
351        }
352        total = total
353            .checked_add(entry.size())
354            .ok_or(ArchiveError::Limit("total size"))?;
355        if total > limits.max_total_bytes {
356            return Err(ArchiveError::Limit("total size"));
357        }
358        compressed_total = compressed_total
359            .checked_add(compressed)
360            .ok_or(ArchiveError::Limit("compressed total size"))?;
361        if compressed_total > limits.max_compressed_total_bytes {
362            return Err(ArchiveError::Limit("compressed total size"));
363        }
364        let mut bytes = Vec::with_capacity(entry.size() as usize);
365        entry.read_to_end(&mut bytes)?;
366        if files.insert(name.clone(), bytes).is_some() {
367            return Err(ArchiveError::Duplicate(name));
368        }
369    }
370    Ok(files)
371}
372
373/// Verify checksums and signature for a pack.
374///
375/// `checksum_files` lists the payload file names that checksums.json must
376/// cover, in canonical order.
377pub(crate) fn verify_checksums_and_signature(
378    files: &BTreeMap<String, Vec<u8>>,
379    paths: &PackPaths,
380    checksum_payload_names: &[&str],
381    publisher_key_id: &str,
382    trust: &TrustStore,
383) -> Result<(), ArchiveError> {
384    let checksum_bytes = files
385        .get(paths.checksums)
386        .ok_or(ArchiveError::Missing(paths.checksums))?;
387    let checksums: Checksums = serde_json::from_slice(checksum_bytes)?;
388    if checksums.schema_version != 1 {
389        return Err(ArchiveError::Missing("checksum schema version"));
390    }
391    let mut expected_names: Vec<String> = checksum_payload_names
392        .iter()
393        .map(|s| s.to_string())
394        .collect();
395    expected_names.sort();
396    let actual_names: Vec<String> = checksums.files.keys().cloned().collect();
397    if actual_names != expected_names {
398        return Err(ArchiveError::ChecksumCoverage);
399    }
400    for (name, expected) in &checksums.files {
401        let bytes = files
402            .get(name)
403            .ok_or_else(|| ArchiveError::MissingOwned(name.clone()))?;
404        let actual = hex::encode(Sha256::digest(bytes));
405        if &actual != expected {
406            return Err(ArchiveError::Digest(name.clone()));
407        }
408    }
409    let raw_signature = files
410        .get(paths.signature)
411        .ok_or(ArchiveError::Missing(paths.signature))?;
412    let signature = Signature::from_slice(raw_signature).map_err(|_| ArchiveError::Signature)?;
413    let key = trust
414        .0
415        .get(publisher_key_id)
416        .ok_or(ArchiveError::UnknownKey)?;
417    let manifest_bytes = files
418        .get(paths.manifest)
419        .ok_or(ArchiveError::Missing(paths.manifest))?;
420    let mut message = canonical_json(manifest_bytes)?;
421    message.push(b'\n');
422    message.extend(canonical_json(checksum_bytes)?);
423    key.verify(&message, &signature)
424        .map_err(|_| ArchiveError::Signature)
425}
426
427/// Build a signed ZIP archive from manifest bytes, payload bytes, and a
428/// signing key. Returns the complete archive bytes.
429pub(crate) fn build_signed_archive(
430    manifest_bytes: &[u8],
431    payload_name: &str,
432    payload_bytes: &[u8],
433    signing_key: &SigningKey,
434) -> Result<Vec<u8>, ArchiveError> {
435    let checksums = Checksums {
436        schema_version: 1,
437        files: BTreeMap::from([
438            (
439                MANIFEST_PATH.into(),
440                hex::encode(Sha256::digest(manifest_bytes)),
441            ),
442            (
443                payload_name.into(),
444                hex::encode(Sha256::digest(payload_bytes)),
445            ),
446        ]),
447    };
448    let checksum_bytes = serde_json::to_vec_pretty(&checksums)?;
449    let mut message = canonical_json(manifest_bytes)?;
450    message.push(b'\n');
451    message.extend(canonical_json(&checksum_bytes)?);
452    let signature = signing_key.sign(&message).to_bytes();
453
454    let mut output = Cursor::new(Vec::new());
455    {
456        let mut archive = ZipWriter::new(&mut output);
457        let options = SimpleFileOptions::default()
458            .compression_method(zip::CompressionMethod::Deflated)
459            .unix_permissions(0o644);
460        for (name, bytes) in [
461            (MANIFEST_PATH, manifest_bytes),
462            (payload_name, payload_bytes),
463            (CHECKSUMS_PATH, checksum_bytes.as_slice()),
464            (SIGNATURE_PATH, signature.as_slice()),
465        ] {
466            archive.start_file(name, options)?;
467            archive.write_all(bytes)?;
468        }
469        archive.finish()?;
470    }
471    Ok(output.into_inner())
472}
473
474fn validate_path(name: &str) -> Result<(), ArchiveError> {
475    if name.starts_with('/')
476        || name.contains('\\')
477        || name
478            .split('/')
479            .any(|part| part.is_empty() || part == "." || part == "..")
480    {
481        return Err(ArchiveError::UnsafePath(name.into()));
482    }
483    Ok(())
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    /// CRC-32 of `data` (matching the value stored in each ZIP local header
491    /// and central-directory record).
492    fn crc32(data: &[u8]) -> u32 {
493        let mut crc: u32 = 0xFFFFFFFF;
494        for &byte in data {
495            crc ^= byte as u32;
496            for _ in 0..8 {
497                crc = (crc >> 1) ^ (0xEDB88320 & (0u32.wrapping_sub(crc & 1)));
498            }
499        }
500        !crc
501    }
502
503    /// Build a minimal stored (uncompressed) ZIP archive whose single entry
504    /// reports `uncompressed_size` and `compressed_size` independently in
505    /// both the local file header and the central directory.
506    ///
507    /// The zip crate's `ZipWriter` always sets both fields to `data.len()`,
508    /// which makes it impossible to exercise the compression-ratio check.
509    /// Writing the bytes by hand lets the tests pretend the entry compressed
510    /// to a different size than its payload.
511    fn build_zip_with_sizes(
512        name: &str,
513        data: &[u8],
514        uncompressed_size: u32,
515        compressed_size: u32,
516    ) -> Vec<u8> {
517        let crc = crc32(data);
518        let mut buf = Vec::new();
519        let local_offset = 0u32;
520
521        // Local file header.
522        buf.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
523        buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
524        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
525        buf.extend_from_slice(&0u16.to_le_bytes()); // method = stored
526        buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
527        buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
528        buf.extend_from_slice(&crc.to_le_bytes());
529        buf.extend_from_slice(&compressed_size.to_le_bytes());
530        buf.extend_from_slice(&uncompressed_size.to_le_bytes());
531        buf.extend_from_slice(&(name.len() as u16).to_le_bytes());
532        buf.extend_from_slice(&0u16.to_le_bytes()); // extra length
533        buf.extend_from_slice(name.as_bytes());
534        buf.extend_from_slice(data);
535
536        let cd_start = buf.len() as u32;
537
538        // Central directory file header.
539        buf.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
540        buf.extend_from_slice(&20u16.to_le_bytes()); // version made by
541        buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
542        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
543        buf.extend_from_slice(&0u16.to_le_bytes()); // method
544        buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
545        buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
546        buf.extend_from_slice(&crc.to_le_bytes());
547        buf.extend_from_slice(&compressed_size.to_le_bytes());
548        buf.extend_from_slice(&uncompressed_size.to_le_bytes());
549        buf.extend_from_slice(&(name.len() as u16).to_le_bytes());
550        buf.extend_from_slice(&0u16.to_le_bytes()); // extra length
551        buf.extend_from_slice(&0u16.to_le_bytes()); // comment length
552        buf.extend_from_slice(&0u16.to_le_bytes()); // disk number
553        buf.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
554        buf.extend_from_slice(&0u32.to_le_bytes()); // external attrs
555        buf.extend_from_slice(&local_offset.to_le_bytes());
556        buf.extend_from_slice(name.as_bytes());
557
558        let cd_size = buf.len() as u32 - cd_start;
559
560        // End of central directory record.
561        buf.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
562        buf.extend_from_slice(&0u16.to_le_bytes()); // disk number
563        buf.extend_from_slice(&0u16.to_le_bytes()); // disk with CD
564        buf.extend_from_slice(&1u16.to_le_bytes()); // entries on this disk
565        buf.extend_from_slice(&1u16.to_le_bytes()); // total entries
566        buf.extend_from_slice(&cd_size.to_le_bytes());
567        buf.extend_from_slice(&cd_start.to_le_bytes());
568        buf.extend_from_slice(&0u16.to_le_bytes()); // comment length
569
570        buf
571    }
572
573    fn limits_with_ratio(ratio: u64) -> ArchiveLimits {
574        ArchiveLimits {
575            max_files: 10,
576            max_file_bytes: 1024 * 1024,
577            max_total_bytes: 1024 * 1024,
578            max_compressed_total_bytes: 1024 * 1024,
579            max_compression_ratio: ratio,
580        }
581    }
582
583    #[test]
584    fn compression_ratio_accepts_exact_boundary() {
585        // size = compressed * ratio exactly. The previous integer-division
586        // implementation accepted this case, and the new checked-multiplication
587        // implementation must continue to accept it so the limit remains the
588        // boundary, not `ratio - 1`.
589        //
590        // For stored (uncompressed) entries the zip crate reads
591        // `compressed_size` bytes from the local header, so the data buffer
592        // must be exactly that long. `uncompressed_size` is reported
593        // independently by `entry.size()` and is what the ratio check uses.
594        let data = b"0123456789"; // 10 bytes
595        let zip = build_zip_with_sizes("payload.bin", data, 1000, 10);
596        let files = read_archive(
597            std::io::Cursor::new(&zip),
598            &["payload.bin"],
599            limits_with_ratio(100),
600        )
601        .expect("exact boundary must be accepted");
602        assert_eq!(files.get("payload.bin").map(Vec::as_slice), Some(&data[..]));
603    }
604
605    #[test]
606    fn compression_ratio_rejects_one_byte_over_boundary() {
607        // Regression for the integer-division truncation bug: with the old
608        // `size / compressed > ratio` check, size=1001/compressed=10/ratio=100
609        // evaluated to `100 > 100` = false and was accepted even though the
610        // true ratio is 100.1. The new check must reject it.
611        let data = b"0123456789"; // 10 bytes
612        let zip = build_zip_with_sizes("payload.bin", data, 1001, 10);
613        let result = read_archive(
614            std::io::Cursor::new(&zip),
615            &["payload.bin"],
616            limits_with_ratio(100),
617        );
618        assert!(
619            matches!(result, Err(ArchiveError::Limit("compression ratio"))),
620            "expected compression-ratio rejection, got: {result:?}"
621        );
622    }
623
624    #[test]
625    fn compression_ratio_skips_zero_compressed_size() {
626        // A zero compressed_size must not divide by zero or trigger the
627        // ratio check. The entry is accepted (the size limit still applies).
628        let zip = build_zip_with_sizes("payload.bin", b"", 0, 0);
629        let files = read_archive(
630            std::io::Cursor::new(&zip),
631            &["payload.bin"],
632            limits_with_ratio(100),
633        )
634        .expect("zero-size entry must be accepted");
635        assert!(files.get("payload.bin").map(Vec::is_empty).unwrap_or(false));
636    }
637
638    #[test]
639    fn compression_ratio_rejects_overflowing_product() {
640        // Adversarial compressed_size * ratio that overflows u64 must be
641        // rejected via checked_mul rather than wrapping around to a small
642        // value that would let the attack through.
643        //
644        // compressed_size = 2 (data buffer is 2 bytes), ratio = u64::MAX.
645        // 2 * u64::MAX overflows u64; without checked_mul the wrapping
646        // product would be u64::MAX - 1, and `entry.size() > u64::MAX - 1`
647        // would be false for any small size, letting the attack through.
648        let data = b"xy"; // 2 bytes
649        let zip = build_zip_with_sizes("payload.bin", data, 2, 2);
650        let limits = ArchiveLimits {
651            max_files: 10,
652            max_file_bytes: 1024 * 1024,
653            max_total_bytes: 1024 * 1024,
654            max_compressed_total_bytes: 1024 * 1024,
655            max_compression_ratio: u64::MAX,
656        };
657        let result = read_archive(std::io::Cursor::new(&zip), &["payload.bin"], limits);
658        assert!(
659            matches!(result, Err(ArchiveError::Limit("compression ratio"))),
660            "expected overflow rejection, got: {result:?}"
661        );
662    }
663
664    fn lifecycle_payload() -> ReleaseIndexPayload {
665        use rill_runtime_protocol::{
666            RELEASE_INDEX_SCHEMA_VERSION, RUNTIME_API_VERSION, RUNTIME_ARTIFACT_ID,
667            ReleaseArtifact, ReleaseArtifactKind,
668        };
669        ReleaseIndexPayload {
670            schema_version: RELEASE_INDEX_SCHEMA_VERSION,
671            channel: "stable".into(),
672            generated_at: "2026-08-22T00:00:00Z".into(),
673            publisher_key_id: "current".into(),
674            artifacts: vec![ReleaseArtifact {
675                kind: ReleaseArtifactKind::Runtime,
676                id: RUNTIME_ARTIFACT_ID.into(),
677                version: "1.3.0".into(),
678                runtime_api_version: RUNTIME_API_VERSION,
679                target_os: Some("linux".into()),
680                target_arch: Some("x86_64".into()),
681                target_libc: Some("gnu".into()),
682                handler_api_version: None,
683                min_runtime_version: None,
684                pm_adapter_protocol_version: None,
685                url: "https://example.invalid/runtime".into(),
686                sha256: "ab".repeat(32),
687                size: 1024,
688            }],
689        }
690    }
691
692    fn metadata(signing: &SigningKey) -> rill_runtime_protocol::TrustMetadataV1 {
693        use rill_runtime_protocol::{
694            TRUST_METADATA_SCHEMA_VERSION, TrustKeyMetadataV1, TrustKeyRole,
695        };
696        rill_runtime_protocol::TrustMetadataV1 {
697            schema_version: TRUST_METADATA_SCHEMA_VERSION,
698            metadata_generation: 1,
699            minimum_release_generation: 1,
700            keys: vec![TrustKeyMetadataV1 {
701                key_id: "current".into(),
702                public_key_hex: hex::encode(signing.verifying_key().to_bytes()),
703                role: TrustKeyRole::Current,
704                not_before_unix_ms: 0,
705                not_after_unix_ms: None,
706                revoked_at_unix_ms: None,
707                emergency_revoked: false,
708            }],
709        }
710    }
711
712    fn floor(
713        metadata: &rill_runtime_protocol::TrustMetadataV1,
714    ) -> rill_runtime_protocol::TrustVerificationFloorV1 {
715        rill_runtime_protocol::TrustVerificationFloorV1 {
716            minimum_metadata_generation: metadata.metadata_generation,
717            minimum_release_generation: metadata.minimum_release_generation,
718            metadata_digest: Some(trust_metadata_digest(metadata).unwrap()),
719        }
720    }
721
722    #[test]
723    fn trust_metadata_current_key_passes() {
724        let signing = SigningKey::from_bytes(&[71; 32]);
725        let envelope =
726            sign_release_index_with_generation(lifecycle_payload(), 1, &signing).unwrap();
727        let trust = metadata(&signing);
728        verify_release_index_with_trust_metadata(&envelope, &trust, &floor(&trust), 100).unwrap();
729    }
730
731    #[test]
732    fn trust_metadata_overlap_next_key_passes() {
733        use rill_runtime_protocol::{TrustKeyMetadataV1, TrustKeyRole};
734        let current = SigningKey::from_bytes(&[72; 32]);
735        let next = SigningKey::from_bytes(&[73; 32]);
736        let mut trust = metadata(&current);
737        trust.keys.push(TrustKeyMetadataV1 {
738            key_id: "next".into(),
739            public_key_hex: hex::encode(next.verifying_key().to_bytes()),
740            role: TrustKeyRole::Next,
741            not_before_unix_ms: 50,
742            not_after_unix_ms: None,
743            revoked_at_unix_ms: None,
744            emergency_revoked: false,
745        });
746        let mut payload = lifecycle_payload();
747        payload.publisher_key_id = "next".into();
748        let envelope = sign_release_index_with_generation(payload, 2, &next).unwrap();
749        verify_release_index_with_trust_metadata(&envelope, &trust, &floor(&trust), 100).unwrap();
750    }
751
752    #[test]
753    fn trust_metadata_rejects_future_expired_revoked_and_emergency_keys() {
754        let signing = SigningKey::from_bytes(&[74; 32]);
755        let envelope =
756            sign_release_index_with_generation(lifecycle_payload(), 1, &signing).unwrap();
757        let mut future = metadata(&signing);
758        future.keys[0].not_before_unix_ms = 101;
759        let future_floor = floor(&future);
760        assert!(
761            verify_release_index_with_trust_metadata(&envelope, &future, &future_floor, 100)
762                .is_err()
763        );
764        let mut expired = metadata(&signing);
765        expired.keys[0].not_after_unix_ms = Some(100);
766        let expired_floor = floor(&expired);
767        assert!(
768            verify_release_index_with_trust_metadata(&envelope, &expired, &expired_floor, 100)
769                .is_err()
770        );
771        let mut revoked = metadata(&signing);
772        revoked.keys[0].revoked_at_unix_ms = Some(100);
773        let revoked_floor = floor(&revoked);
774        assert!(
775            verify_release_index_with_trust_metadata(&envelope, &revoked, &revoked_floor, 100)
776                .is_err()
777        );
778        let mut emergency = metadata(&signing);
779        emergency.keys[0].emergency_revoked = true;
780        let emergency_floor = floor(&emergency);
781        assert!(
782            verify_release_index_with_trust_metadata(&envelope, &emergency, &emergency_floor, 100)
783                .is_err()
784        );
785    }
786
787    #[test]
788    fn trust_metadata_rejects_unknown_damaged_and_downgraded() {
789        use rill_runtime_protocol::TrustKeyMetadataV1;
790        let signing = SigningKey::from_bytes(&[75; 32]);
791        let envelope =
792            sign_release_index_with_generation(lifecycle_payload(), 1, &signing).unwrap();
793        let other = SigningKey::from_bytes(&[76; 32]);
794        let mut unknown = metadata(&other);
795        unknown.keys[0].key_id = "other".into();
796        assert!(matches!(
797            verify_release_index_with_trust_metadata(&envelope, &unknown, &floor(&unknown), 100),
798            Err(ReleaseIndexError::UnknownKey)
799        ));
800        let accepted = metadata(&signing);
801        let accepted_floor = floor(&accepted);
802        let mut damaged = metadata(&signing);
803        damaged.keys.push(TrustKeyMetadataV1 {
804            key_id: "current".into(),
805            public_key_hex: hex::encode(signing.verifying_key().to_bytes()),
806            role: rill_runtime_protocol::TrustKeyRole::Next,
807            not_before_unix_ms: 0,
808            not_after_unix_ms: None,
809            revoked_at_unix_ms: None,
810            emergency_revoked: false,
811        });
812        assert!(matches!(
813            verify_release_index_with_trust_metadata(&envelope, &damaged, &accepted_floor, 100),
814            Err(ReleaseIndexError::TrustMetadata(_))
815        ));
816        let mut downgrade = metadata(&signing);
817        downgrade.minimum_release_generation = 2;
818        assert!(matches!(
819            verify_release_index_with_trust_metadata(
820                &envelope,
821                &downgrade,
822                &floor(&downgrade),
823                100
824            ),
825            Err(ReleaseIndexError::Downgrade)
826        ));
827    }
828
829    #[test]
830    fn trust_metadata_rejects_generation_tampering() {
831        let signing = SigningKey::from_bytes(&[77; 32]);
832        let mut envelope =
833            sign_release_index_with_generation(lifecycle_payload(), 1, &signing).unwrap();
834        envelope.release_generation = 2;
835        let trust = metadata(&signing);
836        assert!(matches!(
837            verify_release_index_with_trust_metadata(&envelope, &trust, &floor(&trust), 100),
838            Err(ReleaseIndexError::Signature)
839        ));
840    }
841
842    #[test]
843    fn trust_metadata_rejects_old_authenticated_document_after_revocation() {
844        use rill_runtime_protocol::{TrustKeyMetadataV1, TrustKeyRole};
845        let current = SigningKey::from_bytes(&[78; 32]);
846        let revoked = SigningKey::from_bytes(&[79; 32]);
847        let mut accepted = metadata(&current);
848        accepted.metadata_generation = 10;
849        accepted.keys.push(TrustKeyMetadataV1 {
850            key_id: "revoked".into(),
851            public_key_hex: hex::encode(revoked.verifying_key().to_bytes()),
852            role: TrustKeyRole::Next,
853            not_before_unix_ms: 0,
854            not_after_unix_ms: None,
855            revoked_at_unix_ms: Some(100),
856            emergency_revoked: false,
857        });
858        let consumer_floor = floor(&accepted);
859
860        let mut old = accepted.clone();
861        old.metadata_generation = 9;
862        old.keys[1].revoked_at_unix_ms = None;
863        let mut payload = lifecycle_payload();
864        payload.publisher_key_id = "revoked".into();
865        let envelope = sign_release_index_with_generation(payload, 2, &revoked).unwrap();
866
867        assert!(matches!(
868            verify_release_index_with_trust_metadata(&envelope, &old, &consumer_floor, 100),
869            Err(ReleaseIndexError::MetadataDowngrade)
870        ));
871    }
872
873    #[test]
874    fn trust_metadata_rejects_same_generation_content_conflict() {
875        let signing = SigningKey::from_bytes(&[80; 32]);
876        let accepted = metadata(&signing);
877        let floor = floor(&accepted);
878        let mut conflicting = accepted.clone();
879        conflicting.keys[0].emergency_revoked = true;
880        let envelope =
881            sign_release_index_with_generation(lifecycle_payload(), 1, &signing).unwrap();
882
883        assert!(matches!(
884            verify_release_index_with_trust_metadata(&envelope, &conflicting, &floor, 100),
885            Err(ReleaseIndexError::MetadataConflict)
886        ));
887    }
888
889    #[test]
890    fn trust_metadata_accepts_future_generation_for_consumer_promotion() {
891        let signing = SigningKey::from_bytes(&[81; 32]);
892        let accepted = metadata(&signing);
893        let floor = floor(&accepted);
894        let mut future = accepted.clone();
895        future.metadata_generation = accepted.metadata_generation + 1;
896        let envelope =
897            sign_release_index_with_generation(lifecycle_payload(), 2, &signing).unwrap();
898
899        verify_release_index_with_trust_metadata(&envelope, &future, &floor, 100).unwrap();
900    }
901}