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::ReleaseIndexPayload;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19use thiserror::Error;
20use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions};
21
22const MANIFEST_PATH: &str = "manifest.json";
23const CHECKSUMS_PATH: &str = "checksums.json";
24const SIGNATURE_PATH: &str = "META-INF/signature.ed25519";
25
26#[derive(Debug, Default, Clone)]
27pub struct TrustStore(pub BTreeMap<String, VerifyingKey>);
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub(crate) struct Checksums {
32    schema_version: u32,
33    files: BTreeMap<String, String>,
34}
35
36#[derive(Debug, Error)]
37#[non_exhaustive]
38pub enum ArchiveError {
39    #[error("zip error: {0}")]
40    Zip(#[from] zip::result::ZipError),
41    #[error("I/O error: {0}")]
42    Io(#[from] std::io::Error),
43    #[error("JSON error: {0}")]
44    Json(#[from] serde_json::Error),
45    #[error("unsafe package path {0}")]
46    UnsafePath(String),
47    #[error("forbidden package file {0}")]
48    Forbidden(String),
49    #[error("duplicate package file {0}")]
50    Duplicate(String),
51    #[error("package exceeded {0} limit")]
52    Limit(&'static str),
53    #[error("missing package file {0}")]
54    Missing(&'static str),
55    #[error("missing package file {0}")]
56    MissingOwned(String),
57    #[error("checksum coverage does not exactly match the payload")]
58    ChecksumCoverage,
59    #[error("checksum mismatch for {0}")]
60    Digest(String),
61    #[error("unknown publisher key")]
62    UnknownKey,
63    #[error("signature verification failed")]
64    Signature,
65}
66
67#[derive(Debug, Error)]
68#[non_exhaustive]
69pub enum ReleaseIndexError {
70    #[error("JSON error: {0}")]
71    Json(#[from] serde_json::Error),
72    #[error("invalid release index: {0}")]
73    Manifest(String),
74    #[error("unknown release-index publisher key")]
75    UnknownKey,
76    #[error("release-index signature verification failed")]
77    Signature,
78    #[error("canonical JSON error: {0}")]
79    Canonical(ArchiveError),
80}
81
82/// Limits for a specific pack type.
83#[derive(Debug, Clone, Copy)]
84pub(crate) struct ArchiveLimits {
85    pub max_files: usize,
86    pub max_file_bytes: u64,
87    pub max_total_bytes: u64,
88    pub max_compressed_total_bytes: u64,
89    pub max_compression_ratio: u64,
90}
91
92/// The canonical paths every pack must contain.
93pub(crate) struct PackPaths {
94    pub manifest: &'static str,
95    pub checksums: &'static str,
96    pub signature: &'static str,
97}
98
99pub(crate) const DEFAULT_PATHS: PackPaths = PackPaths {
100    manifest: MANIFEST_PATH,
101    checksums: CHECKSUMS_PATH,
102    signature: SIGNATURE_PATH,
103};
104
105pub fn canonical_json(bytes: &[u8]) -> Result<Vec<u8>, ArchiveError> {
106    fn canonical(value: Value) -> Value {
107        match value {
108            Value::Object(map) => {
109                // Explicitly sort object keys via BTreeMap so canonicalisation
110                // does not depend on serde_json's feature flags (preserve_order).
111                let sorted: BTreeMap<String, Value> = map
112                    .into_iter()
113                    .map(|(key, value)| (key, canonical(value)))
114                    .collect();
115                Value::Object(sorted.into_iter().collect())
116            }
117            Value::Array(items) => Value::Array(items.into_iter().map(canonical).collect()),
118            other => other,
119        }
120    }
121    let value: Value = serde_json::from_slice(bytes)?;
122    Ok(serde_json::to_vec(&canonical(value))?)
123}
124
125pub fn sign_release_index(
126    payload: ReleaseIndexPayload,
127    signing_key: &SigningKey,
128) -> Result<rill_runtime_protocol::SignedReleaseIndex, ReleaseIndexError> {
129    validate_release_payload(&payload)?;
130    let serialized = serde_json::to_vec(&payload)?;
131    let canonical = canonical_json(&serialized).map_err(ReleaseIndexError::Canonical)?;
132    let signature = hex::encode(signing_key.sign(&canonical).to_bytes());
133    Ok(rill_runtime_protocol::SignedReleaseIndex { payload, signature })
134}
135
136pub fn verify_release_index(
137    index: &rill_runtime_protocol::SignedReleaseIndex,
138    trust: &TrustStore,
139) -> Result<(), ReleaseIndexError> {
140    validate_release_payload(&index.payload)?;
141    let signature_bytes =
142        hex::decode(&index.signature).map_err(|_| ReleaseIndexError::Signature)?;
143    let signature =
144        Signature::from_slice(&signature_bytes).map_err(|_| ReleaseIndexError::Signature)?;
145    let key = trust
146        .0
147        .get(&index.payload.publisher_key_id)
148        .ok_or(ReleaseIndexError::UnknownKey)?;
149    let serialized = serde_json::to_vec(&index.payload)?;
150    let canonical = canonical_json(&serialized).map_err(ReleaseIndexError::Canonical)?;
151    key.verify(&canonical, &signature)
152        .map_err(|_| ReleaseIndexError::Signature)
153}
154
155fn validate_release_payload(payload: &ReleaseIndexPayload) -> Result<(), ReleaseIndexError> {
156    payload
157        .validate_shape()
158        .map_err(|message| ReleaseIndexError::Manifest(message.into()))?;
159    let mut identities = BTreeSet::new();
160    for artifact in &payload.artifacts {
161        semver::Version::parse(&artifact.version).map_err(|error| {
162            ReleaseIndexError::Manifest(format!("invalid artifact version: {error}"))
163        })?;
164        let identity = (
165            artifact.kind.clone(),
166            artifact.id.clone(),
167            artifact.target_os.clone(),
168            artifact.target_arch.clone(),
169            artifact.handler_api_version,
170        );
171        if !identities.insert(identity) {
172            return Err(ReleaseIndexError::Manifest(
173                "duplicate release artifact identity".into(),
174            ));
175        }
176    }
177    Ok(())
178}
179
180/// Read a ZIP archive and validate paths, file count, and size limits.
181/// Returns a map of file name → bytes for every non-directory entry.
182pub(crate) fn read_archive<R: Read + Seek>(
183    reader: R,
184    allowed: &[&str],
185    limits: ArchiveLimits,
186) -> Result<BTreeMap<String, Vec<u8>>, ArchiveError> {
187    let mut archive = ZipArchive::new(reader)?;
188    if archive.len() > limits.max_files {
189        return Err(ArchiveError::Limit("file count"));
190    }
191    let mut total = 0u64;
192    let mut compressed_total = 0u64;
193    let mut files = BTreeMap::new();
194    for index in 0..archive.len() {
195        let mut entry = archive.by_index(index)?;
196        if entry.is_dir() {
197            continue;
198        }
199        let name = entry.name().to_string();
200        validate_path(&name)?;
201        if !allowed.iter().any(|allowed| *allowed == name) {
202            return Err(ArchiveError::Forbidden(name));
203        }
204        if entry.size() > limits.max_file_bytes {
205            return Err(ArchiveError::Limit("file size"));
206        }
207        let compressed = entry.compressed_size();
208        // Use checked multiplication instead of integer division so the
209        // comparison is exact: ``size / compressed`` truncates and would
210        // accept an entry whose true ratio is just above the limit
211        // (e.g. size=10, compressed=3, limit=3 → 10/3=3, accepted even
212        // though 10 > 3*3). ``size > compressed * ratio`` avoids both the
213        // truncation and any floating-point rounding, and the checked
214        // product guards against u64 overflow on adversarial inputs.
215        if compressed > 0 {
216            let cap = compressed
217                .checked_mul(limits.max_compression_ratio)
218                .ok_or(ArchiveError::Limit("compression ratio"))?;
219            if entry.size() > cap {
220                return Err(ArchiveError::Limit("compression ratio"));
221            }
222        }
223        total = total
224            .checked_add(entry.size())
225            .ok_or(ArchiveError::Limit("total size"))?;
226        if total > limits.max_total_bytes {
227            return Err(ArchiveError::Limit("total size"));
228        }
229        compressed_total = compressed_total
230            .checked_add(compressed)
231            .ok_or(ArchiveError::Limit("compressed total size"))?;
232        if compressed_total > limits.max_compressed_total_bytes {
233            return Err(ArchiveError::Limit("compressed total size"));
234        }
235        let mut bytes = Vec::with_capacity(entry.size() as usize);
236        entry.read_to_end(&mut bytes)?;
237        if files.insert(name.clone(), bytes).is_some() {
238            return Err(ArchiveError::Duplicate(name));
239        }
240    }
241    Ok(files)
242}
243
244/// Verify checksums and signature for a pack.
245///
246/// `checksum_files` lists the payload file names that checksums.json must
247/// cover, in canonical order.
248pub(crate) fn verify_checksums_and_signature(
249    files: &BTreeMap<String, Vec<u8>>,
250    paths: &PackPaths,
251    checksum_payload_names: &[&str],
252    publisher_key_id: &str,
253    trust: &TrustStore,
254) -> Result<(), ArchiveError> {
255    let checksum_bytes = files
256        .get(paths.checksums)
257        .ok_or(ArchiveError::Missing(paths.checksums))?;
258    let checksums: Checksums = serde_json::from_slice(checksum_bytes)?;
259    if checksums.schema_version != 1 {
260        return Err(ArchiveError::Missing("checksum schema version"));
261    }
262    let mut expected_names: Vec<String> = checksum_payload_names
263        .iter()
264        .map(|s| s.to_string())
265        .collect();
266    expected_names.sort();
267    let actual_names: Vec<String> = checksums.files.keys().cloned().collect();
268    if actual_names != expected_names {
269        return Err(ArchiveError::ChecksumCoverage);
270    }
271    for (name, expected) in &checksums.files {
272        let bytes = files
273            .get(name)
274            .ok_or_else(|| ArchiveError::MissingOwned(name.clone()))?;
275        let actual = hex::encode(Sha256::digest(bytes));
276        if &actual != expected {
277            return Err(ArchiveError::Digest(name.clone()));
278        }
279    }
280    let raw_signature = files
281        .get(paths.signature)
282        .ok_or(ArchiveError::Missing(paths.signature))?;
283    let signature = Signature::from_slice(raw_signature).map_err(|_| ArchiveError::Signature)?;
284    let key = trust
285        .0
286        .get(publisher_key_id)
287        .ok_or(ArchiveError::UnknownKey)?;
288    let manifest_bytes = files
289        .get(paths.manifest)
290        .ok_or(ArchiveError::Missing(paths.manifest))?;
291    let mut message = canonical_json(manifest_bytes)?;
292    message.push(b'\n');
293    message.extend(canonical_json(checksum_bytes)?);
294    key.verify(&message, &signature)
295        .map_err(|_| ArchiveError::Signature)
296}
297
298/// Build a signed ZIP archive from manifest bytes, payload bytes, and a
299/// signing key. Returns the complete archive bytes.
300pub(crate) fn build_signed_archive(
301    manifest_bytes: &[u8],
302    payload_name: &str,
303    payload_bytes: &[u8],
304    signing_key: &SigningKey,
305) -> Result<Vec<u8>, ArchiveError> {
306    let checksums = Checksums {
307        schema_version: 1,
308        files: BTreeMap::from([
309            (
310                MANIFEST_PATH.into(),
311                hex::encode(Sha256::digest(manifest_bytes)),
312            ),
313            (
314                payload_name.into(),
315                hex::encode(Sha256::digest(payload_bytes)),
316            ),
317        ]),
318    };
319    let checksum_bytes = serde_json::to_vec_pretty(&checksums)?;
320    let mut message = canonical_json(manifest_bytes)?;
321    message.push(b'\n');
322    message.extend(canonical_json(&checksum_bytes)?);
323    let signature = signing_key.sign(&message).to_bytes();
324
325    let mut output = Cursor::new(Vec::new());
326    {
327        let mut archive = ZipWriter::new(&mut output);
328        let options = SimpleFileOptions::default()
329            .compression_method(zip::CompressionMethod::Deflated)
330            .unix_permissions(0o644);
331        for (name, bytes) in [
332            (MANIFEST_PATH, manifest_bytes),
333            (payload_name, payload_bytes),
334            (CHECKSUMS_PATH, checksum_bytes.as_slice()),
335            (SIGNATURE_PATH, signature.as_slice()),
336        ] {
337            archive.start_file(name, options)?;
338            archive.write_all(bytes)?;
339        }
340        archive.finish()?;
341    }
342    Ok(output.into_inner())
343}
344
345fn validate_path(name: &str) -> Result<(), ArchiveError> {
346    if name.starts_with('/')
347        || name.contains('\\')
348        || name
349            .split('/')
350            .any(|part| part.is_empty() || part == "." || part == "..")
351    {
352        return Err(ArchiveError::UnsafePath(name.into()));
353    }
354    Ok(())
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    /// CRC-32 of `data` (matching the value stored in each ZIP local header
362    /// and central-directory record).
363    fn crc32(data: &[u8]) -> u32 {
364        let mut crc: u32 = 0xFFFFFFFF;
365        for &byte in data {
366            crc ^= byte as u32;
367            for _ in 0..8 {
368                crc = (crc >> 1) ^ (0xEDB88320 & (0u32.wrapping_sub(crc & 1)));
369            }
370        }
371        !crc
372    }
373
374    /// Build a minimal stored (uncompressed) ZIP archive whose single entry
375    /// reports `uncompressed_size` and `compressed_size` independently in
376    /// both the local file header and the central directory.
377    ///
378    /// The zip crate's `ZipWriter` always sets both fields to `data.len()`,
379    /// which makes it impossible to exercise the compression-ratio check.
380    /// Writing the bytes by hand lets the tests pretend the entry compressed
381    /// to a different size than its payload.
382    fn build_zip_with_sizes(
383        name: &str,
384        data: &[u8],
385        uncompressed_size: u32,
386        compressed_size: u32,
387    ) -> Vec<u8> {
388        let crc = crc32(data);
389        let mut buf = Vec::new();
390        let local_offset = 0u32;
391
392        // Local file header.
393        buf.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
394        buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
395        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
396        buf.extend_from_slice(&0u16.to_le_bytes()); // method = stored
397        buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
398        buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
399        buf.extend_from_slice(&crc.to_le_bytes());
400        buf.extend_from_slice(&compressed_size.to_le_bytes());
401        buf.extend_from_slice(&uncompressed_size.to_le_bytes());
402        buf.extend_from_slice(&(name.len() as u16).to_le_bytes());
403        buf.extend_from_slice(&0u16.to_le_bytes()); // extra length
404        buf.extend_from_slice(name.as_bytes());
405        buf.extend_from_slice(data);
406
407        let cd_start = buf.len() as u32;
408
409        // Central directory file header.
410        buf.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
411        buf.extend_from_slice(&20u16.to_le_bytes()); // version made by
412        buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
413        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
414        buf.extend_from_slice(&0u16.to_le_bytes()); // method
415        buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
416        buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
417        buf.extend_from_slice(&crc.to_le_bytes());
418        buf.extend_from_slice(&compressed_size.to_le_bytes());
419        buf.extend_from_slice(&uncompressed_size.to_le_bytes());
420        buf.extend_from_slice(&(name.len() as u16).to_le_bytes());
421        buf.extend_from_slice(&0u16.to_le_bytes()); // extra length
422        buf.extend_from_slice(&0u16.to_le_bytes()); // comment length
423        buf.extend_from_slice(&0u16.to_le_bytes()); // disk number
424        buf.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
425        buf.extend_from_slice(&0u32.to_le_bytes()); // external attrs
426        buf.extend_from_slice(&local_offset.to_le_bytes());
427        buf.extend_from_slice(name.as_bytes());
428
429        let cd_size = buf.len() as u32 - cd_start;
430
431        // End of central directory record.
432        buf.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
433        buf.extend_from_slice(&0u16.to_le_bytes()); // disk number
434        buf.extend_from_slice(&0u16.to_le_bytes()); // disk with CD
435        buf.extend_from_slice(&1u16.to_le_bytes()); // entries on this disk
436        buf.extend_from_slice(&1u16.to_le_bytes()); // total entries
437        buf.extend_from_slice(&cd_size.to_le_bytes());
438        buf.extend_from_slice(&cd_start.to_le_bytes());
439        buf.extend_from_slice(&0u16.to_le_bytes()); // comment length
440
441        buf
442    }
443
444    fn limits_with_ratio(ratio: u64) -> ArchiveLimits {
445        ArchiveLimits {
446            max_files: 10,
447            max_file_bytes: 1024 * 1024,
448            max_total_bytes: 1024 * 1024,
449            max_compressed_total_bytes: 1024 * 1024,
450            max_compression_ratio: ratio,
451        }
452    }
453
454    #[test]
455    fn compression_ratio_accepts_exact_boundary() {
456        // size = compressed * ratio exactly. The previous integer-division
457        // implementation accepted this case, and the new checked-multiplication
458        // implementation must continue to accept it so the limit remains the
459        // boundary, not `ratio - 1`.
460        //
461        // For stored (uncompressed) entries the zip crate reads
462        // `compressed_size` bytes from the local header, so the data buffer
463        // must be exactly that long. `uncompressed_size` is reported
464        // independently by `entry.size()` and is what the ratio check uses.
465        let data = b"0123456789"; // 10 bytes
466        let zip = build_zip_with_sizes("payload.bin", data, 1000, 10);
467        let files = read_archive(
468            std::io::Cursor::new(&zip),
469            &["payload.bin"],
470            limits_with_ratio(100),
471        )
472        .expect("exact boundary must be accepted");
473        assert_eq!(files.get("payload.bin").map(Vec::as_slice), Some(&data[..]));
474    }
475
476    #[test]
477    fn compression_ratio_rejects_one_byte_over_boundary() {
478        // Regression for the integer-division truncation bug: with the old
479        // `size / compressed > ratio` check, size=1001/compressed=10/ratio=100
480        // evaluated to `100 > 100` = false and was accepted even though the
481        // true ratio is 100.1. The new check must reject it.
482        let data = b"0123456789"; // 10 bytes
483        let zip = build_zip_with_sizes("payload.bin", data, 1001, 10);
484        let result = read_archive(
485            std::io::Cursor::new(&zip),
486            &["payload.bin"],
487            limits_with_ratio(100),
488        );
489        assert!(
490            matches!(result, Err(ArchiveError::Limit("compression ratio"))),
491            "expected compression-ratio rejection, got: {result:?}"
492        );
493    }
494
495    #[test]
496    fn compression_ratio_skips_zero_compressed_size() {
497        // A zero compressed_size must not divide by zero or trigger the
498        // ratio check. The entry is accepted (the size limit still applies).
499        let zip = build_zip_with_sizes("payload.bin", b"", 0, 0);
500        let files = read_archive(
501            std::io::Cursor::new(&zip),
502            &["payload.bin"],
503            limits_with_ratio(100),
504        )
505        .expect("zero-size entry must be accepted");
506        assert!(files.get("payload.bin").map(Vec::is_empty).unwrap_or(false));
507    }
508
509    #[test]
510    fn compression_ratio_rejects_overflowing_product() {
511        // Adversarial compressed_size * ratio that overflows u64 must be
512        // rejected via checked_mul rather than wrapping around to a small
513        // value that would let the attack through.
514        //
515        // compressed_size = 2 (data buffer is 2 bytes), ratio = u64::MAX.
516        // 2 * u64::MAX overflows u64; without checked_mul the wrapping
517        // product would be u64::MAX - 1, and `entry.size() > u64::MAX - 1`
518        // would be false for any small size, letting the attack through.
519        let data = b"xy"; // 2 bytes
520        let zip = build_zip_with_sizes("payload.bin", data, 2, 2);
521        let limits = ArchiveLimits {
522            max_files: 10,
523            max_file_bytes: 1024 * 1024,
524            max_total_bytes: 1024 * 1024,
525            max_compressed_total_bytes: 1024 * 1024,
526            max_compression_ratio: u64::MAX,
527        };
528        let result = read_archive(std::io::Cursor::new(&zip), &["payload.bin"], limits);
529        assert!(
530            matches!(result, Err(ArchiveError::Limit("compression ratio"))),
531            "expected overflow rejection, got: {result:?}"
532        );
533    }
534}