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)]
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#[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
92pub(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 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
180pub(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 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
244pub(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
298pub(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 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 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 buf.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
394 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());
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()); buf.extend_from_slice(name.as_bytes());
405 buf.extend_from_slice(data);
406
407 let cd_start = buf.len() as u32;
408
409 buf.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
411 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());
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()); 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());
427 buf.extend_from_slice(name.as_bytes());
428
429 let cd_size = buf.len() as u32 - cd_start;
430
431 buf.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
433 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());
438 buf.extend_from_slice(&cd_start.to_le_bytes());
439 buf.extend_from_slice(&0u16.to_le_bytes()); 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 let data = b"0123456789"; 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 let data = b"0123456789"; 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 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 let data = b"xy"; 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}