Skip to main content

alef_component_runtime/
cache.rs

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
15/// Maximum time allowed to establish a connection to a component's download URL.
16const DOWNLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
17/// Maximum time allowed for an entire component download, connection included.
18const 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    /// Disable network downloads: [`Self::install`] fails a missing artifact with
50    /// [`ComponentError::Offline`] instead of attempting a request. An artifact already
51    /// verified on disk (or served via a `file://` URL) is unaffected.
52    #[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
287/// Generous ceiling for the manifest and signature entries: both are small JSON documents,
288/// orders of magnitude smaller than any native component library, so a declared size above
289/// this already means the archive is not one `alef component package` produced.
290const METADATA_ENTRY_SIZE_CEILING: u64 = 64 * 1024;
291/// Headroom added to the manifest's declared library size before an oversized decompressed
292/// library entry is rejected, covering tar block padding around the real content.
293const LIBRARY_SIZE_HEADROOM: u64 = 4096;
294/// `alef component package` always writes at most three archive entries: the manifest, an
295/// optional signature, and the library. Anything else is not an archive this loader made.
296const MAX_ARCHIVE_ENTRIES: usize = 3;
297
298/// Extract a downloaded component archive, bounding how much a corrupted or malicious
299/// archive can force this to decompress, and rejecting anything that is not exactly the
300/// shape `alef component package` produces.
301///
302/// The manifest must be the first entry (`component.json`) because its declared
303/// `library.size` is what bounds the decompressed size of the library entry that follows --
304/// without reading it first there is nothing to size the library entry's ceiling against,
305/// and an attacker-supplied archive could otherwise reorder entries to smuggle an oversized
306/// library past a check performed too late. Every entry must be a regular file at a safe
307/// relative path (no absolute paths, `..`, or symlinks/hardlinks), and the total entry count
308/// is capped so a maliciously padded archive cannot force unbounded header parsing either.
309fn 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
362/// Read a small, fully-buffered entry (the manifest or its signature), rejecting it before
363/// any bytes are copied if its declared size already exceeds `ceiling`.
364fn 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
377/// Stream the library entry straight to disk in fixed-size chunks -- rather than buffering
378/// the whole (potentially large) decompressed library in memory -- hashing it as it is
379/// written so the digest is verified in the same pass instead of a second read from disk.
380fn 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    /// Regression for the padding disagreement between this loader, `alef`'s config
527    /// validation, and `alef`'s artifact verification: config accepted unpadded base64
528    /// keys while the loader accepted only standard-padded base64, so a key that passed
529    /// config validation and was baked into the lock would fail every load here.
530    #[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    /// A component archive can only ever grow to the size the manifest itself declares (plus
717    /// a small fixed headroom): a library entry that decompresses to far more than that is
718    /// rejected before the whole thing is buffered or written to disk, bounding how much a
719    /// crafted or corrupted archive can force this loader to decompress.
720    #[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    /// The library entry must be a regular file: a symlink (which could point anywhere on
744    /// the filesystem once `dlopen`ed) is rejected outright, never followed or written.
745    #[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    /// A packaged component archive never has more than three entries (manifest, optional
775    /// signature, library); a fourth is not an archive this loader made.
776    #[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}