1use crate::keys::decode_public_key;
2use crate::manifest::{COMPONENT_ABI_VERSION, COMPONENT_MANIFEST_SCHEMA};
3use crate::{ComponentError, ComponentLockEntry, ComponentManifest, ComponentSignature};
4use base64::Engine as _;
5use ed25519_dalek::{Signature, Verifier as _};
6use flate2::read::GzDecoder;
7use fs2::FileExt as _;
8use sha2::{Digest as _, Sha256};
9use std::collections::BTreeMap;
10use std::fs::{self, File, OpenOptions};
11use std::io::{Read, Seek as _, SeekFrom, Write as _};
12use std::path::{Component, Path, PathBuf};
13use std::time::Duration;
14
15const DOWNLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
17const DOWNLOAD_TOTAL_TIMEOUT: Duration = Duration::from_secs(300);
19
20#[derive(Clone, Debug)]
21pub enum TrustPolicy {
22 DigestOnly,
23 EmbeddedKeys(BTreeMap<String, String>),
24}
25
26#[derive(Clone, Debug)]
27pub struct ArtifactCache {
28 root: PathBuf,
29 trust: TrustPolicy,
30 offline: bool,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct CachedArtifact {
35 pub root: PathBuf,
36 pub library: PathBuf,
37 pub manifest: ComponentManifest,
38}
39
40impl ArtifactCache {
41 pub fn new(root: impl Into<PathBuf>, trust: TrustPolicy) -> Self {
42 Self {
43 root: root.into(),
44 trust,
45 offline: false,
46 }
47 }
48
49 #[must_use]
53 pub fn offline(mut self, offline: bool) -> Self {
54 self.offline = offline;
55 self
56 }
57
58 #[must_use]
59 pub fn root(&self) -> &Path {
60 &self.root
61 }
62
63 pub fn object_path(&self, entry: &ComponentLockEntry) -> Result<PathBuf, ComponentError> {
64 decode_digest(&entry.sha256)?;
65 Ok(self.root.join("sha256").join(&entry.sha256))
66 }
67
68 pub fn install(&self, entry: &ComponentLockEntry) -> Result<CachedArtifact, ComponentError> {
69 let destination = self.object_path(entry)?;
70 if destination.is_dir()
71 && let Ok(cached) = self.verify_installation(entry, &destination)
72 {
73 return Ok(cached);
74 }
75 if let Some(path) = entry.url.strip_prefix("file://") {
76 return self.install_from_reader(entry, File::open(path)?);
77 }
78 if self.offline {
79 return Err(ComponentError::Offline {
80 component: entry.identity.component.clone(),
81 url: entry.url.clone(),
82 });
83 }
84 let response = ureq::get(&entry.url)
85 .config()
86 .timeout_connect(Some(DOWNLOAD_CONNECT_TIMEOUT))
87 .timeout_global(Some(DOWNLOAD_TOTAL_TIMEOUT))
88 .build()
89 .header(
90 "User-Agent",
91 concat!("alef-component-runtime/", env!("CARGO_PKG_VERSION")),
92 )
93 .call()
94 .map_err(|error| ComponentError::Download {
95 url: entry.url.clone(),
96 message: error.to_string(),
97 })?;
98 self.install_from_reader(entry, response.into_body().into_reader())
99 }
100
101 pub fn install_from_reader(
102 &self,
103 entry: &ComponentLockEntry,
104 mut reader: impl Read,
105 ) -> Result<CachedArtifact, ComponentError> {
106 let expected = decode_digest(&entry.sha256)?;
107 let objects = self.root.join("sha256");
108 fs::create_dir_all(&objects)?;
109 let lock_path = objects.join(format!("{}.lock", entry.sha256));
110 let lock = OpenOptions::new()
111 .read(true)
112 .write(true)
113 .create(true)
114 .truncate(false)
115 .open(lock_path)?;
116 lock.lock_exclusive()?;
117
118 let destination = self.object_path(entry)?;
119 if destination.is_dir()
120 && let Ok(cached) = self.verify_installation(entry, &destination)
121 {
122 return Ok(cached);
123 }
124
125 let mut archive = tempfile::NamedTempFile::new_in(&objects)?;
126 let mut hasher = Sha256::new();
127 let mut copied = 0_u64;
128 let mut buffer = [0_u8; 64 * 1024];
129 loop {
130 let count = reader.read(&mut buffer)?;
131 if count == 0 {
132 break;
133 }
134 archive.write_all(&buffer[..count])?;
135 hasher.update(&buffer[..count]);
136 copied += count as u64;
137 if copied > entry.size {
138 return Err(ComponentError::SizeMismatch {
139 expected: entry.size,
140 actual: copied,
141 });
142 }
143 }
144 if entry.size != copied {
145 return Err(ComponentError::SizeMismatch {
146 expected: entry.size,
147 actual: copied,
148 });
149 }
150 let actual: [u8; 32] = hasher.finalize().into();
151 if actual != expected {
152 return Err(ComponentError::DigestMismatch {
153 expected: entry.sha256.clone(),
154 actual: hex::encode(actual),
155 });
156 }
157
158 archive.as_file_mut().seek(SeekFrom::Start(0))?;
159 let staging = tempfile::Builder::new().prefix(".install-").tempdir_in(&objects)?;
160 extract_safely(archive.as_file_mut(), staging.path())?;
161 let cached = self.verify_installation(entry, staging.path())?;
162
163 if destination.exists() {
164 fs::remove_dir_all(&destination)?;
165 }
166 let staging_path = staging.keep();
167 if let Err(error) = fs::rename(&staging_path, &destination) {
168 let _ = fs::remove_dir_all(&staging_path);
169 return Err(ComponentError::Io(error));
170 }
171 Ok(CachedArtifact {
172 root: destination.clone(),
173 library: destination.join(&cached.manifest.library.file),
174 manifest: cached.manifest,
175 })
176 }
177
178 fn verify_installation(&self, entry: &ComponentLockEntry, root: &Path) -> Result<CachedArtifact, ComponentError> {
179 let manifest_bytes = fs::read(root.join("component.json"))?;
180 if sha256(&manifest_bytes) != decode_digest(&entry.manifest_sha256)? {
181 return Err(ComponentError::ManifestDigestMismatch);
182 }
183 let manifest: ComponentManifest = serde_json::from_slice(&manifest_bytes)?;
184 if manifest.canonical_bytes()? != manifest_bytes {
185 return Err(ComponentError::NonCanonicalManifest);
186 }
187 if manifest.schema_version != COMPONENT_MANIFEST_SCHEMA || manifest.abi_version != COMPONENT_ABI_VERSION {
188 return Err(ComponentError::UnsupportedManifest);
189 }
190 if manifest.identity != entry.identity {
191 return Err(ComponentError::ManifestIdentityMismatch);
192 }
193 let library_relative = safe_relative_path(&manifest.library.file)?;
194 let library = root.join(&library_relative);
195 let library_bytes = fs::read(&library).map_err(|error| {
196 if error.kind() == std::io::ErrorKind::NotFound {
197 ComponentError::MissingLibrary(library.clone())
198 } else {
199 ComponentError::Io(error)
200 }
201 })?;
202 if library_bytes.len() as u64 != manifest.library.size {
203 return Err(ComponentError::SizeMismatch {
204 expected: manifest.library.size,
205 actual: library_bytes.len() as u64,
206 });
207 }
208 let expected_library = decode_digest(&manifest.library.sha256)?;
209 let actual_library = sha256(&library_bytes);
210 if expected_library != actual_library {
211 return Err(ComponentError::DigestMismatch {
212 expected: manifest.library.sha256.clone(),
213 actual: hex::encode(actual_library),
214 });
215 }
216 self.verify_manifest_signature(entry, root, &manifest_bytes)?;
217 Ok(CachedArtifact {
218 root: root.to_owned(),
219 library,
220 manifest,
221 })
222 }
223
224 fn verify_manifest_signature(
225 &self,
226 entry: &ComponentLockEntry,
227 root: &Path,
228 manifest_bytes: &[u8],
229 ) -> Result<(), ComponentError> {
230 let TrustPolicy::EmbeddedKeys(keys) = &self.trust else {
231 return Ok(());
232 };
233 if entry.key_id.is_empty() && keys.is_empty() {
234 return Ok(());
235 }
236 let signature_bytes = fs::read(root.join("component.sig")).map_err(|error| {
237 if error.kind() == std::io::ErrorKind::NotFound {
238 ComponentError::SignatureRequired
239 } else {
240 ComponentError::Io(error)
241 }
242 })?;
243 let signature: ComponentSignature = serde_json::from_slice(&signature_bytes)?;
244 if signature.algorithm != "ed25519" {
245 return Err(ComponentError::UnsupportedSignatureAlgorithm(signature.algorithm));
246 }
247 if signature.key_id != entry.key_id {
248 return Err(ComponentError::SignatureKeyMismatch);
249 }
250 let encoded_key = keys
251 .get(&signature.key_id)
252 .ok_or_else(|| ComponentError::UnknownSignatureKey(signature.key_id.clone()))?;
253 let key = decode_public_key(encoded_key)?;
254 let raw_signature = base64::engine::general_purpose::STANDARD
255 .decode(&signature.signature)
256 .map_err(|_| ComponentError::InvalidSignatureEncoding)?;
257 let signature = Signature::from_slice(&raw_signature).map_err(|_| ComponentError::InvalidSignatureEncoding)?;
258 key.verify(manifest_bytes, &signature)
259 .map_err(|_| ComponentError::SignatureVerification)
260 }
261}
262
263fn decode_digest(value: &str) -> Result<[u8; 32], ComponentError> {
264 let bytes = hex::decode(value).map_err(|_| ComponentError::InvalidDigest(value.to_owned()))?;
265 bytes
266 .try_into()
267 .map_err(|_| ComponentError::InvalidDigest(value.to_owned()))
268}
269
270fn sha256(bytes: &[u8]) -> [u8; 32] {
271 Sha256::digest(bytes).into()
272}
273
274fn safe_relative_path(value: &str) -> Result<PathBuf, ComponentError> {
275 let path = Path::new(value);
276 let safe = !path.as_os_str().is_empty()
277 && path
278 .components()
279 .all(|component| matches!(component, Component::Normal(_)));
280 if safe {
281 Ok(path.to_owned())
282 } else {
283 Err(ComponentError::UnsafeLibraryPath(value.to_owned()))
284 }
285}
286
287const METADATA_ENTRY_SIZE_CEILING: u64 = 64 * 1024;
291const LIBRARY_SIZE_HEADROOM: u64 = 4096;
294const MAX_ARCHIVE_ENTRIES: usize = 3;
297
298fn extract_safely(reader: impl Read, destination: &Path) -> Result<(), ComponentError> {
310 let decoder = GzDecoder::new(reader);
311 let mut archive = tar::Archive::new(decoder);
312 let mut entries = archive.entries()?;
313
314 let mut first = entries
315 .next()
316 .ok_or_else(|| ComponentError::UnsafeArchiveEntry("archive is empty".to_owned()))??;
317 let first_path = first.path()?.into_owned();
318 if first_path != Path::new("component.json") || !first.header().entry_type().is_file() {
319 return Err(ComponentError::UnsafeArchiveEntry(first_path.display().to_string()));
320 }
321 let manifest_bytes = read_bounded_entry(&mut first, METADATA_ENTRY_SIZE_CEILING)?;
322 fs::write(destination.join("component.json"), &manifest_bytes)?;
323 let manifest: ComponentManifest = serde_json::from_slice(&manifest_bytes)?;
324 let library_relative = safe_relative_path(&manifest.library.file)?;
325 let library_ceiling = manifest.library.size.saturating_add(LIBRARY_SIZE_HEADROOM);
326
327 let mut seen_library = false;
328 let mut entry_count = 1_usize;
329 for entry in entries {
330 entry_count += 1;
331 if entry_count > MAX_ARCHIVE_ENTRIES {
332 return Err(ComponentError::TooManyArchiveEntries {
333 max: MAX_ARCHIVE_ENTRIES,
334 actual: entry_count,
335 });
336 }
337 let mut entry = entry?;
338 let path = entry.path()?.into_owned();
339 let is_safe_relative = !path.as_os_str().is_empty()
340 && path
341 .components()
342 .all(|component| matches!(component, Component::Normal(_)));
343 if !is_safe_relative || !entry.header().entry_type().is_file() {
344 return Err(ComponentError::UnsafeArchiveEntry(path.display().to_string()));
345 }
346 if path == Path::new("component.sig") {
347 let bytes = read_bounded_entry(&mut entry, METADATA_ENTRY_SIZE_CEILING)?;
348 fs::write(destination.join("component.sig"), bytes)?;
349 } else if path == library_relative && !seen_library {
350 seen_library = true;
351 stream_library_entry(&mut entry, destination, &library_relative, library_ceiling, &manifest)?;
352 } else {
353 return Err(ComponentError::UnsafeArchiveEntry(path.display().to_string()));
354 }
355 }
356 if !seen_library {
357 return Err(ComponentError::MissingLibrary(destination.join(&library_relative)));
358 }
359 Ok(())
360}
361
362fn read_bounded_entry<R: Read>(entry: &mut tar::Entry<'_, R>, ceiling: u64) -> Result<Vec<u8>, ComponentError> {
365 let declared = entry.header().size()?;
366 if declared > ceiling {
367 return Err(ComponentError::ExtractedSizeExceeded {
368 limit: ceiling,
369 actual: declared,
370 });
371 }
372 let mut buffer = Vec::new();
373 entry.read_to_end(&mut buffer)?;
374 Ok(buffer)
375}
376
377fn stream_library_entry<R: Read>(
381 entry: &mut tar::Entry<'_, R>,
382 destination: &Path,
383 relative: &Path,
384 ceiling: u64,
385 manifest: &ComponentManifest,
386) -> Result<(), ComponentError> {
387 let declared = entry.header().size()?;
388 if declared > ceiling {
389 return Err(ComponentError::ExtractedSizeExceeded {
390 limit: ceiling,
391 actual: declared,
392 });
393 }
394 let target = destination.join(relative);
395 if let Some(parent) = target.parent() {
396 fs::create_dir_all(parent)?;
397 }
398 let mut file = File::create(&target)?;
399 let mut hasher = Sha256::new();
400 let mut buffer = [0_u8; 64 * 1024];
401 let mut copied = 0_u64;
402 loop {
403 let count = entry.read(&mut buffer)?;
404 if count == 0 {
405 break;
406 }
407 copied += count as u64;
408 if copied > ceiling {
409 return Err(ComponentError::ExtractedSizeExceeded {
410 limit: ceiling,
411 actual: copied,
412 });
413 }
414 hasher.update(&buffer[..count]);
415 file.write_all(&buffer[..count])?;
416 }
417 if copied != manifest.library.size {
418 return Err(ComponentError::SizeMismatch {
419 expected: manifest.library.size,
420 actual: copied,
421 });
422 }
423 let actual: [u8; 32] = hasher.finalize().into();
424 let expected = decode_digest(&manifest.library.sha256)?;
425 if actual != expected {
426 return Err(ComponentError::DigestMismatch {
427 expected: manifest.library.sha256.clone(),
428 actual: hex::encode(actual),
429 });
430 }
431 Ok(())
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::{ComponentIdentity, ComponentLibrary};
438 use ed25519_dalek::{Signer as _, SigningKey};
439 use std::io::Cursor;
440
441 fn package(signing: Option<(&SigningKey, &str)>) -> (Vec<u8>, ComponentLockEntry) {
442 let library = b"native";
443 let identity = ComponentIdentity {
444 crate_name: "demo-core".into(),
445 component: "fast".into(),
446 version: "1.0.0".into(),
447 target: "test-target".into(),
448 feature_hash: hex::encode([1; 32]),
449 contract_hash: hex::encode([2; 32]),
450 };
451 let manifest = ComponentManifest {
452 schema_version: COMPONENT_MANIFEST_SCHEMA,
453 abi_version: 1,
454 identity: identity.clone(),
455 provides: vec![crate::ComponentProvidedContract {
456 contract: "engine".into(),
457 interface_version: 1,
458 contract_hash: hex::encode([2; 32]),
459 implementation: "demo::Fast".into(),
460 }],
461 features: vec!["fast".into()],
462 default_features: false,
463 library: ComponentLibrary {
464 file: "libdemo.so".into(),
465 sha256: hex::encode(sha256(library)),
466 size: library.len() as u64,
467 },
468 };
469 let manifest_bytes = manifest.canonical_bytes().unwrap();
470 let signature = signing.map(|(key, id)| ComponentSignature {
471 algorithm: "ed25519".into(),
472 key_id: id.into(),
473 signature: base64::engine::general_purpose::STANDARD.encode(key.sign(&manifest_bytes).to_bytes()),
474 });
475 let mut compressed = Vec::new();
476 {
477 let encoder = flate2::write::GzEncoder::new(&mut compressed, flate2::Compression::default());
478 let mut builder = tar::Builder::new(encoder);
479 append(&mut builder, "component.json", &manifest_bytes);
480 if let Some(signature) = &signature {
481 append(
482 &mut builder,
483 "component.sig",
484 &crate::canonical_json(signature).unwrap(),
485 );
486 }
487 append(&mut builder, "libdemo.so", library);
488 builder.into_inner().unwrap().finish().unwrap();
489 }
490 let entry = ComponentLockEntry {
491 identity,
492 provides: manifest.provides.clone(),
493 mode: crate::ComponentDeliveryMode::Download,
494 url: "unused".into(),
495 sha256: hex::encode(sha256(&compressed)),
496 size: compressed.len() as u64,
497 manifest_sha256: hex::encode(sha256(&manifest_bytes)),
498 key_id: signature.map(|value| value.key_id).unwrap_or_default(),
499 };
500 (compressed, entry)
501 }
502
503 fn append(builder: &mut tar::Builder<flate2::write::GzEncoder<&mut Vec<u8>>>, path: &str, content: &[u8]) {
504 let mut header = tar::Header::new_gnu();
505 header.set_size(content.len() as u64);
506 header.set_mode(0o644);
507 header.set_cksum();
508 builder.append_data(&mut header, path, Cursor::new(content)).unwrap();
509 }
510
511 #[test]
512 fn installs_and_verifies_full_trust_chain() {
513 let signing = SigningKey::from_bytes(&[7; 32]);
514 let (bytes, entry) = package(Some((&signing, "release")));
515 let keys = BTreeMap::from([(
516 "release".into(),
517 base64::engine::general_purpose::STANDARD.encode(signing.verifying_key().to_bytes()),
518 )]);
519 let dir = tempfile::tempdir().unwrap();
520 let cache = ArtifactCache::new(dir.path(), TrustPolicy::EmbeddedKeys(keys));
521 let installed = cache.install_from_reader(&entry, Cursor::new(bytes)).unwrap();
522 assert_eq!(fs::read(installed.library).unwrap(), b"native");
523 assert_eq!(installed.manifest.identity, entry.identity);
524 }
525
526 #[test]
531 fn installs_and_verifies_full_trust_chain_with_unpadded_base64_key() {
532 let signing = SigningKey::from_bytes(&[7; 32]);
533 let (bytes, entry) = package(Some((&signing, "release")));
534 let keys = BTreeMap::from([(
535 "release".into(),
536 base64::engine::general_purpose::STANDARD_NO_PAD.encode(signing.verifying_key().to_bytes()),
537 )]);
538 let dir = tempfile::tempdir().unwrap();
539 let cache = ArtifactCache::new(dir.path(), TrustPolicy::EmbeddedKeys(keys));
540 let installed = cache.install_from_reader(&entry, Cursor::new(bytes)).unwrap();
541 assert_eq!(fs::read(installed.library).unwrap(), b"native");
542 assert_eq!(installed.manifest.identity, entry.identity);
543 }
544
545 #[test]
546 fn reuses_verified_cache_when_source_is_unavailable() {
547 let (bytes, mut entry) = package(None);
548 let dir = tempfile::tempdir().unwrap();
549 let cache = ArtifactCache::new(dir.path(), TrustPolicy::DigestOnly);
550 let installed = cache.install_from_reader(&entry, Cursor::new(bytes)).unwrap();
551 entry.url = "file:///source/does/not/exist".into();
552
553 let reused = cache.install(&entry).unwrap();
554 assert_eq!(reused, installed);
555 }
556
557 #[test]
558 fn rejects_unsigned_archive_when_lock_names_a_key() {
559 let (bytes, mut entry) = package(None);
560 entry.key_id = "release".into();
561 let keys = BTreeMap::from([(
562 "release".into(),
563 base64::engine::general_purpose::STANDARD
564 .encode(SigningKey::from_bytes(&[7; 32]).verifying_key().to_bytes()),
565 )]);
566 let dir = tempfile::tempdir().unwrap();
567 let cache = ArtifactCache::new(dir.path(), TrustPolicy::EmbeddedKeys(keys));
568 assert!(matches!(
569 cache.install_from_reader(&entry, Cursor::new(bytes)),
570 Err(ComponentError::SignatureRequired)
571 ));
572 }
573
574 #[test]
575 fn rejects_archive_digest_mismatch() {
576 let (bytes, mut entry) = package(None);
577 entry.sha256 = "00".repeat(32);
578 let dir = tempfile::tempdir().unwrap();
579 let cache = ArtifactCache::new(dir.path(), TrustPolicy::DigestOnly);
580 assert!(matches!(
581 cache.install_from_reader(&entry, Cursor::new(bytes)),
582 Err(ComponentError::DigestMismatch { .. })
583 ));
584 }
585
586 #[test]
587 fn stops_when_download_exceeds_pinned_size() {
588 let (bytes, mut entry) = package(None);
589 entry.size = 1;
590 let dir = tempfile::tempdir().unwrap();
591 let cache = ArtifactCache::new(dir.path(), TrustPolicy::DigestOnly);
592 assert!(matches!(
593 cache.install_from_reader(&entry, Cursor::new(bytes)),
594 Err(ComponentError::SizeMismatch { expected: 1, .. })
595 ));
596 }
597
598 #[test]
599 fn rejects_invalid_signature_without_installing() {
600 let signing = SigningKey::from_bytes(&[7; 32]);
601 let wrong_key = SigningKey::from_bytes(&[8; 32]);
602 let (bytes, entry) = package(Some((&signing, "release")));
603 let keys = BTreeMap::from([(
604 "release".into(),
605 base64::engine::general_purpose::STANDARD.encode(wrong_key.verifying_key().to_bytes()),
606 )]);
607 let dir = tempfile::tempdir().unwrap();
608 let cache = ArtifactCache::new(dir.path(), TrustPolicy::EmbeddedKeys(keys));
609 let destination = cache.object_path(&entry).unwrap();
610
611 assert!(matches!(
612 cache.install_from_reader(&entry, Cursor::new(bytes)),
613 Err(ComponentError::SignatureVerification)
614 ));
615 assert!(!destination.exists());
616 }
617
618 #[test]
619 fn offline_mode_rejects_a_missing_artifact_without_a_network_attempt() {
620 let (_bytes, mut entry) = package(None);
621 entry.url = "https://example.invalid/component.tar.gz".into();
622 let dir = tempfile::tempdir().unwrap();
623 let cache = ArtifactCache::new(dir.path(), TrustPolicy::DigestOnly).offline(true);
624
625 let error = cache.install(&entry).unwrap_err();
626
627 assert!(matches!(
628 error,
629 ComponentError::Offline { ref component, ref url }
630 if component == "fast" && url == "https://example.invalid/component.tar.gz"
631 ));
632 }
633
634 #[test]
635 fn offline_mode_still_reuses_an_already_verified_cache_entry() {
636 let (bytes, entry) = package(None);
637 let dir = tempfile::tempdir().unwrap();
638 let cache = ArtifactCache::new(dir.path(), TrustPolicy::DigestOnly);
639 let installed = cache.install_from_reader(&entry, Cursor::new(bytes)).unwrap();
640
641 let offline_cache = ArtifactCache::new(dir.path(), TrustPolicy::DigestOnly).offline(true);
642 let reused = offline_cache.install(&entry).unwrap();
643
644 assert_eq!(reused, installed);
645 }
646
647 #[test]
648 fn offline_mode_does_not_block_a_file_url() {
649 let (bytes, mut entry) = package(None);
650 let dir = tempfile::tempdir().unwrap();
651 let source = dir.path().join("source.tar.gz");
652 fs::write(&source, &bytes).unwrap();
653 entry.url = format!("file://{}", source.display());
654
655 let cache_root = tempfile::tempdir().unwrap();
656 let cache = ArtifactCache::new(cache_root.path(), TrustPolicy::DigestOnly).offline(true);
657
658 assert!(cache.install(&entry).is_ok());
659 }
660
661 #[test]
662 fn rejects_parent_traversal_archive_entry() {
663 let mut tar_bytes = Vec::new();
664 {
665 let mut builder = tar::Builder::new(&mut tar_bytes);
666 let mut header = tar::Header::new_gnu();
667 header.set_size(1);
668 header.set_mode(0o644);
669 header.as_mut_bytes()[..7].copy_from_slice(b"../evil");
670 header.set_cksum();
671 builder.append(&header, Cursor::new(b"x")).unwrap();
672 builder.finish().unwrap();
673 }
674 let mut compressed = Vec::new();
675 {
676 let mut encoder = flate2::write::GzEncoder::new(&mut compressed, flate2::Compression::default());
677 encoder.write_all(&tar_bytes).unwrap();
678 encoder.finish().unwrap();
679 }
680 let dir = tempfile::tempdir().unwrap();
681 assert!(matches!(
682 extract_safely(Cursor::new(compressed), dir.path()),
683 Err(ComponentError::UnsafeArchiveEntry(path)) if path == "../evil"
684 ));
685 assert!(!dir.path().parent().unwrap().join("evil").exists());
686 }
687
688 fn manifest_declaring_library_size(size: u64) -> ComponentManifest {
689 ComponentManifest {
690 schema_version: COMPONENT_MANIFEST_SCHEMA,
691 abi_version: 1,
692 identity: ComponentIdentity {
693 crate_name: "demo-core".into(),
694 component: "fast".into(),
695 version: "1.0.0".into(),
696 target: "test-target".into(),
697 feature_hash: hex::encode([1; 32]),
698 contract_hash: hex::encode([2; 32]),
699 },
700 provides: vec![crate::ComponentProvidedContract {
701 contract: "engine".into(),
702 interface_version: 1,
703 contract_hash: hex::encode([2; 32]),
704 implementation: "demo::Fast".into(),
705 }],
706 features: vec!["fast".into()],
707 default_features: false,
708 library: ComponentLibrary {
709 file: "libdemo.so".into(),
710 sha256: hex::encode(sha256(b"native")),
711 size,
712 },
713 }
714 }
715
716 #[test]
721 fn rejects_library_entry_exceeding_the_manifest_declared_size() {
722 let manifest = manifest_declaring_library_size(6);
723 let manifest_bytes = manifest.canonical_bytes().unwrap();
724 let oversized_library = vec![b'x'; (LIBRARY_SIZE_HEADROOM + 4096) as usize];
725
726 let mut compressed = Vec::new();
727 {
728 let encoder = flate2::write::GzEncoder::new(&mut compressed, flate2::Compression::default());
729 let mut builder = tar::Builder::new(encoder);
730 append(&mut builder, "component.json", &manifest_bytes);
731 append(&mut builder, "libdemo.so", &oversized_library);
732 builder.into_inner().unwrap().finish().unwrap();
733 }
734
735 let dir = tempfile::tempdir().unwrap();
736 assert!(matches!(
737 extract_safely(Cursor::new(compressed), dir.path()),
738 Err(ComponentError::ExtractedSizeExceeded { .. })
739 ));
740 assert!(!dir.path().join("libdemo.so").exists());
741 }
742
743 #[test]
746 fn rejects_symlink_library_entry() {
747 let manifest = manifest_declaring_library_size(6);
748 let manifest_bytes = manifest.canonical_bytes().unwrap();
749
750 let mut compressed = Vec::new();
751 {
752 let encoder = flate2::write::GzEncoder::new(&mut compressed, flate2::Compression::default());
753 let mut builder = tar::Builder::new(encoder);
754 append(&mut builder, "component.json", &manifest_bytes);
755 let mut header = tar::Header::new_gnu();
756 header.set_size(0);
757 header.set_mode(0o777);
758 header.set_entry_type(tar::EntryType::Symlink);
759 header.set_link_name("/etc/passwd").unwrap();
760 header.set_path("libdemo.so").unwrap();
761 header.set_cksum();
762 builder.append(&header, Cursor::new(&[][..])).unwrap();
763 builder.into_inner().unwrap().finish().unwrap();
764 }
765
766 let dir = tempfile::tempdir().unwrap();
767 assert!(matches!(
768 extract_safely(Cursor::new(compressed), dir.path()),
769 Err(ComponentError::UnsafeArchiveEntry(path)) if path == "libdemo.so"
770 ));
771 assert!(!dir.path().join("libdemo.so").exists());
772 }
773
774 #[test]
777 fn rejects_archive_with_too_many_entries() {
778 let manifest = manifest_declaring_library_size(6);
779 let manifest_bytes = manifest.canonical_bytes().unwrap();
780
781 let mut compressed = Vec::new();
782 {
783 let encoder = flate2::write::GzEncoder::new(&mut compressed, flate2::Compression::default());
784 let mut builder = tar::Builder::new(encoder);
785 append(&mut builder, "component.json", &manifest_bytes);
786 append(&mut builder, "component.sig", b"{}");
787 append(&mut builder, "libdemo.so", b"native");
788 append(&mut builder, "unexpected.txt", b"extra");
789 builder.into_inner().unwrap().finish().unwrap();
790 }
791
792 let dir = tempfile::tempdir().unwrap();
793 assert!(matches!(
794 extract_safely(Cursor::new(compressed), dir.path()),
795 Err(ComponentError::TooManyArchiveEntries {
796 max: MAX_ARCHIVE_ENTRIES,
797 ..
798 })
799 ));
800 }
801}