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        if compressed > 0 && entry.size() / compressed > limits.max_compression_ratio {
207            return Err(ArchiveError::Limit("compression ratio"));
208        }
209        total = total
210            .checked_add(entry.size())
211            .ok_or(ArchiveError::Limit("total size"))?;
212        if total > limits.max_total_bytes {
213            return Err(ArchiveError::Limit("total size"));
214        }
215        compressed_total = compressed_total
216            .checked_add(compressed)
217            .ok_or(ArchiveError::Limit("compressed total size"))?;
218        if compressed_total > limits.max_compressed_total_bytes {
219            return Err(ArchiveError::Limit("compressed total size"));
220        }
221        let mut bytes = Vec::with_capacity(entry.size() as usize);
222        entry.read_to_end(&mut bytes)?;
223        if files.insert(name.clone(), bytes).is_some() {
224            return Err(ArchiveError::Duplicate(name));
225        }
226    }
227    Ok(files)
228}
229
230/// Verify checksums and signature for a pack.
231///
232/// `checksum_files` lists the payload file names that checksums.json must
233/// cover, in canonical order.
234pub(crate) fn verify_checksums_and_signature(
235    files: &BTreeMap<String, Vec<u8>>,
236    paths: &PackPaths,
237    checksum_payload_names: &[&str],
238    publisher_key_id: &str,
239    trust: &TrustStore,
240) -> Result<(), ArchiveError> {
241    let checksum_bytes = files
242        .get(paths.checksums)
243        .ok_or(ArchiveError::Missing(paths.checksums))?;
244    let checksums: Checksums = serde_json::from_slice(checksum_bytes)?;
245    if checksums.schema_version != 1 {
246        return Err(ArchiveError::Missing("checksum schema version"));
247    }
248    let mut expected_names: Vec<String> = checksum_payload_names
249        .iter()
250        .map(|s| s.to_string())
251        .collect();
252    expected_names.sort();
253    let actual_names: Vec<String> = checksums.files.keys().cloned().collect();
254    if actual_names != expected_names {
255        return Err(ArchiveError::ChecksumCoverage);
256    }
257    for (name, expected) in &checksums.files {
258        let bytes = files
259            .get(name)
260            .ok_or_else(|| ArchiveError::MissingOwned(name.clone()))?;
261        let actual = hex::encode(Sha256::digest(bytes));
262        if &actual != expected {
263            return Err(ArchiveError::Digest(name.clone()));
264        }
265    }
266    let raw_signature = files
267        .get(paths.signature)
268        .ok_or(ArchiveError::Missing(paths.signature))?;
269    let signature = Signature::from_slice(raw_signature).map_err(|_| ArchiveError::Signature)?;
270    let key = trust
271        .0
272        .get(publisher_key_id)
273        .ok_or(ArchiveError::UnknownKey)?;
274    let manifest_bytes = files
275        .get(paths.manifest)
276        .ok_or(ArchiveError::Missing(paths.manifest))?;
277    let mut message = canonical_json(manifest_bytes)?;
278    message.push(b'\n');
279    message.extend(canonical_json(checksum_bytes)?);
280    key.verify(&message, &signature)
281        .map_err(|_| ArchiveError::Signature)
282}
283
284/// Build a signed ZIP archive from manifest bytes, payload bytes, and a
285/// signing key. Returns the complete archive bytes.
286pub(crate) fn build_signed_archive(
287    manifest_bytes: &[u8],
288    payload_name: &str,
289    payload_bytes: &[u8],
290    signing_key: &SigningKey,
291) -> Result<Vec<u8>, ArchiveError> {
292    let checksums = Checksums {
293        schema_version: 1,
294        files: BTreeMap::from([
295            (
296                MANIFEST_PATH.into(),
297                hex::encode(Sha256::digest(manifest_bytes)),
298            ),
299            (
300                payload_name.into(),
301                hex::encode(Sha256::digest(payload_bytes)),
302            ),
303        ]),
304    };
305    let checksum_bytes = serde_json::to_vec_pretty(&checksums)?;
306    let mut message = canonical_json(manifest_bytes)?;
307    message.push(b'\n');
308    message.extend(canonical_json(&checksum_bytes)?);
309    let signature = signing_key.sign(&message).to_bytes();
310
311    let mut output = Cursor::new(Vec::new());
312    {
313        let mut archive = ZipWriter::new(&mut output);
314        let options = SimpleFileOptions::default()
315            .compression_method(zip::CompressionMethod::Deflated)
316            .unix_permissions(0o644);
317        for (name, bytes) in [
318            (MANIFEST_PATH, manifest_bytes),
319            (payload_name, payload_bytes),
320            (CHECKSUMS_PATH, checksum_bytes.as_slice()),
321            (SIGNATURE_PATH, signature.as_slice()),
322        ] {
323            archive.start_file(name, options)?;
324            archive.write_all(bytes)?;
325        }
326        archive.finish()?;
327    }
328    Ok(output.into_inner())
329}
330
331fn validate_path(name: &str) -> Result<(), ArchiveError> {
332    if name.starts_with('/')
333        || name.contains('\\')
334        || name
335            .split('/')
336            .any(|part| part.is_empty() || part == "." || part == "..")
337    {
338        return Err(ArchiveError::UnsafePath(name.into()));
339    }
340    Ok(())
341}