1use 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#[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
90pub(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 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
178pub(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 {
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
242pub(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
296pub(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 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 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 buf.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
392 buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); 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()); buf.extend_from_slice(name.as_bytes());
403 buf.extend_from_slice(data);
404
405 let cd_start = buf.len() as u32;
406
407 buf.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
409 buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); 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()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); 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 buf.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
431 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); 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()); 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 let data = b"0123456789"; 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 let data = b"0123456789"; 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 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 let data = b"xy"; 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}