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