1use crate::loader::{decode_hash, validate_manifest};
2use crate::manifest::COMPONENT_MANIFEST_SCHEMA;
3use crate::{
4 ArtifactCache, CachedArtifact, ComponentDeliveryMode, ComponentError, ComponentLock, ComponentLockEntry,
5 ComponentRequirements, LoadedComponent, TrustPolicy,
6};
7use alef_component_abi::AlefHostApiV1;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex, PoisonError};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum ComponentStatus {
19 Ready,
21 Cached,
23 NotDownloaded,
25 Bundled,
28 Unsupported { reason: String },
30}
31
32pub struct ComponentManager {
33 cache: ArtifactCache,
34 target: String,
35 entries: HashMap<String, ComponentLockEntry>,
36 loaded: Mutex<HashMap<String, Arc<LoadedComponent>>>,
37 key_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
42 verified_artifacts: Mutex<HashMap<String, CachedArtifact>>,
49 lock_is_empty: bool,
56 host: AlefHostApiV1,
57}
58
59unsafe impl Send for ComponentManager {}
63unsafe impl Sync for ComponentManager {}
64
65impl ComponentManager {
66 pub fn from_lock(
67 lock: ComponentLock,
68 cache_root: impl Into<PathBuf>,
69 target: impl Into<String>,
70 host: AlefHostApiV1,
71 ) -> Result<Self, ComponentError> {
72 if lock.schema_version != COMPONENT_MANIFEST_SCHEMA {
73 return Err(ComponentError::UnsupportedLockSchema(lock.schema_version));
74 }
75 let lock_is_empty = lock.artifacts.is_empty();
76 let target = target.into();
77 let mut entries = HashMap::new();
78 for artifact in lock
79 .artifacts
80 .into_iter()
81 .filter(|artifact| artifact.identity.target == target)
82 {
83 let id = artifact.identity.component.clone();
84 if entries.insert(id.clone(), artifact).is_some() {
85 return Err(ComponentError::DuplicateLockEntry {
86 component_id: id,
87 target,
88 });
89 }
90 }
91 Ok(Self {
92 cache: ArtifactCache::new(cache_root, TrustPolicy::EmbeddedKeys(lock.public_keys)),
93 target,
94 entries,
95 loaded: Mutex::new(HashMap::new()),
96 key_locks: Mutex::new(HashMap::new()),
97 verified_artifacts: Mutex::new(HashMap::new()),
98 lock_is_empty,
99 host,
100 })
101 }
102
103 #[must_use]
108 pub fn offline(mut self, offline: bool) -> Self {
109 self.cache = self.cache.offline(offline);
110 self
111 }
112
113 pub fn ensure_contract(
126 &self,
127 component_id: &str,
128 contract_name: &str,
129 entry_symbol: &[u8],
130 ) -> Result<Arc<LoadedComponent>, ComponentError> {
131 let cache_key = loaded_cache_key(component_id, contract_name);
132 if let Some(component) = self.loaded_component(&cache_key) {
133 return Ok(component);
134 }
135
136 let key_lock = self.contract_lock(cache_key.clone());
137 let _guard = key_lock.lock().unwrap_or_else(PoisonError::into_inner);
138 if let Some(component) = self.loaded_component(&cache_key) {
141 return Ok(component);
142 }
143
144 let entry = self.entry(component_id)?;
145 let requirements = contract_requirements(entry, contract_name)?;
146 let cached = self.cached_artifact(entry)?;
147 validate_manifest(&cached.manifest, &requirements)?;
148 let component = Arc::new(LoadedComponent::load(
149 cached.library,
150 entry_symbol,
151 &requirements,
152 self.host,
153 )?);
154 self.insert_loaded(cache_key, Arc::clone(&component));
155 Ok(component)
156 }
157
158 pub fn prefetch(&self, component_ids: &[&str]) -> Result<Vec<CachedArtifact>, ComponentError> {
159 component_ids
160 .iter()
161 .map(|component_id| self.cached_artifact(self.entry(component_id)?))
162 .collect()
163 }
164
165 fn loaded_component(&self, cache_key: &str) -> Option<Arc<LoadedComponent>> {
166 self.loaded
167 .lock()
168 .unwrap_or_else(PoisonError::into_inner)
169 .get(cache_key)
170 .cloned()
171 }
172
173 fn insert_loaded(&self, cache_key: String, component: Arc<LoadedComponent>) {
174 self.loaded
175 .lock()
176 .unwrap_or_else(PoisonError::into_inner)
177 .insert(cache_key, component);
178 }
179
180 fn contract_lock(&self, cache_key: String) -> Arc<Mutex<()>> {
184 Arc::clone(
185 self.key_locks
186 .lock()
187 .unwrap_or_else(PoisonError::into_inner)
188 .entry(cache_key)
189 .or_insert_with(|| Arc::new(Mutex::new(()))),
190 )
191 }
192
193 fn cached_artifact(&self, entry: &ComponentLockEntry) -> Result<CachedArtifact, ComponentError> {
196 if let Some(cached) = self
197 .verified_artifacts
198 .lock()
199 .unwrap_or_else(PoisonError::into_inner)
200 .get(&entry.sha256)
201 .cloned()
202 {
203 return Ok(cached);
204 }
205 let cached = self.cache.install(entry)?;
206 self.verified_artifacts
207 .lock()
208 .unwrap_or_else(PoisonError::into_inner)
209 .insert(entry.sha256.clone(), cached.clone());
210 Ok(cached)
211 }
212
213 pub fn status(&self, component_id: &str) -> Result<ComponentStatus, ComponentError> {
218 let entry = self.lookup(component_id)?;
219 if entry.mode == ComponentDeliveryMode::Bundled {
220 return Ok(ComponentStatus::Bundled);
221 }
222 let loaded_prefix = format!("{component_id}::");
223 if self
224 .loaded
225 .lock()
226 .unwrap_or_else(PoisonError::into_inner)
227 .keys()
228 .any(|key| key.starts_with(&loaded_prefix))
229 {
230 return Ok(ComponentStatus::Ready);
231 }
232 let root = self.cache.object_path(entry)?;
233 if root.join("component.json").is_file() {
234 Ok(ComponentStatus::Cached)
235 } else {
236 Ok(ComponentStatus::NotDownloaded)
237 }
238 }
239
240 pub fn cache_path(&self, component_id: &str) -> Result<PathBuf, ComponentError> {
241 self.cache.object_path(self.entry(component_id)?)
242 }
243
244 #[must_use]
245 pub fn cache_root(&self) -> &Path {
246 self.cache.root()
247 }
248
249 #[must_use]
250 pub fn target(&self) -> &str {
251 &self.target
252 }
253
254 fn lookup(&self, component_id: &str) -> Result<&ComponentLockEntry, ComponentError> {
263 self.entries.get(component_id).ok_or_else(|| {
264 if self.lock_is_empty {
265 ComponentError::ComponentsNotLocked {
266 component_id: component_id.to_owned(),
267 }
268 } else {
269 ComponentError::ArtifactNotFound {
270 component_id: component_id.to_owned(),
271 target: self.target.clone(),
272 }
273 }
274 })
275 }
276
277 fn entry(&self, component_id: &str) -> Result<&ComponentLockEntry, ComponentError> {
282 let entry = self.lookup(component_id)?;
283 if entry.mode == ComponentDeliveryMode::Bundled {
284 return Err(ComponentError::BundledComponentNotLoadable {
285 component: component_id.to_owned(),
286 });
287 }
288 Ok(entry)
289 }
290}
291
292fn loaded_cache_key(component_id: &str, contract_name: &str) -> String {
293 format!("{component_id}::{contract_name}")
294}
295
296fn contract_requirements(
297 entry: &ComponentLockEntry,
298 contract_name: &str,
299) -> Result<ComponentRequirements, ComponentError> {
300 let provided = entry
301 .provides
302 .iter()
303 .find(|provided| provided.contract == contract_name)
304 .ok_or_else(|| ComponentError::ContractNotProvided {
305 component_id: entry.identity.component.clone(),
306 contract: contract_name.to_owned(),
307 })?;
308 Ok(ComponentRequirements {
309 component_id: entry.identity.component.clone(),
310 contract_name: contract_name.to_owned(),
311 contract_hash: decode_hash(&provided.contract_hash)?,
312 feature_set_hash: Some(decode_hash(&entry.identity.feature_hash)?),
313 })
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::ComponentIdentity;
320 use std::collections::BTreeMap;
321 use std::ffi::c_void;
322
323 fn host() -> AlefHostApiV1 {
324 AlefHostApiV1 {
325 struct_size: 0,
326 abi_major: 0,
327 abi_minor: 0,
328 context: core::ptr::null_mut::<c_void>(),
329 log: None,
330 }
331 }
332
333 fn lock(target: &str) -> ComponentLock {
334 lock_with_mode(target, ComponentDeliveryMode::Download)
335 }
336
337 fn lock_with_mode(target: &str, mode: ComponentDeliveryMode) -> ComponentLock {
338 ComponentLock {
339 schema_version: COMPONENT_MANIFEST_SCHEMA,
340 public_keys: BTreeMap::new(),
341 artifacts: vec![ComponentLockEntry {
342 identity: ComponentIdentity {
343 crate_name: "demo-core".into(),
344 component: "demo".into(),
345 version: "1.0.0".into(),
346 target: target.into(),
347 feature_hash: hex::encode([2; 32]),
348 contract_hash: hex::encode([1; 32]),
349 },
350 provides: vec![crate::ComponentProvidedContract {
351 contract: "engine".into(),
352 interface_version: 1,
353 contract_hash: hex::encode([1; 32]),
354 implementation: "demo::Demo".into(),
355 }],
356 mode,
357 url: "file:///does/not/exist".into(),
358 sha256: hex::encode([3; 32]),
359 size: 0,
360 manifest_sha256: hex::encode([4; 32]),
361 key_id: String::new(),
362 }],
363 }
364 }
365
366 #[test]
367 fn bootstrap_empty_lock_reports_components_not_locked_instead_of_artifact_not_found() {
368 let dir = tempfile::tempdir().unwrap();
369 let empty = ComponentLock {
370 schema_version: COMPONENT_MANIFEST_SCHEMA,
371 public_keys: BTreeMap::new(),
372 artifacts: Vec::new(),
373 };
374 let manager = ComponentManager::from_lock(empty, dir.path(), "target-a", host()).unwrap();
375
376 assert!(matches!(
377 manager.status("demo"),
378 Err(ComponentError::ComponentsNotLocked { .. })
379 ));
380 assert!(matches!(
381 manager.ensure_contract("demo", "engine", b"irrelevant\0"),
382 Err(ComponentError::ComponentsNotLocked { .. })
383 ));
384 assert!(matches!(
385 manager.prefetch(&["demo"]),
386 Err(ComponentError::ComponentsNotLocked { .. })
387 ));
388 assert!(matches!(
389 manager.cache_path("demo"),
390 Err(ComponentError::ComponentsNotLocked { .. })
391 ));
392 }
393
394 #[test]
395 fn manager_filters_lock_to_exact_target() {
396 let dir = tempfile::tempdir().unwrap();
397 let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-b", host()).unwrap();
398 assert!(matches!(
399 manager.status("demo"),
400 Err(ComponentError::ArtifactNotFound { .. })
401 ));
402 }
403
404 #[test]
405 fn manager_reports_content_address_before_download() {
406 let dir = tempfile::tempdir().unwrap();
407 let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap();
408 assert_eq!(manager.status("demo").unwrap(), ComponentStatus::NotDownloaded);
409 assert!(
410 manager
411 .cache_path("demo")
412 .unwrap()
413 .ends_with(format!("sha256/{}", hex::encode([3; 32])))
414 );
415 }
416
417 #[test]
418 fn duplicate_target_entry_is_rejected() {
419 let dir = tempfile::tempdir().unwrap();
420 let mut lock = lock("target-a");
421 lock.artifacts.push(lock.artifacts[0].clone());
422 assert!(matches!(
423 ComponentManager::from_lock(lock, dir.path(), "target-a", host()),
424 Err(ComponentError::DuplicateLockEntry { .. })
425 ));
426 }
427
428 #[test]
429 fn ensure_contract_rejects_a_contract_the_component_does_not_provide() {
430 let dir = tempfile::tempdir().unwrap();
431 let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap();
432 assert!(matches!(
433 manager.ensure_contract("demo", "missing-contract", b"irrelevant\0"),
434 Err(ComponentError::ContractNotProvided { .. })
435 ));
436 }
437
438 #[test]
439 fn ensure_contract_reaches_the_download_step_for_a_provided_contract() {
440 let dir = tempfile::tempdir().unwrap();
441 let mut two_contracts = lock("target-a");
442 two_contracts.artifacts[0]
443 .provides
444 .push(crate::ComponentProvidedContract {
445 contract: "second".into(),
446 interface_version: 1,
447 contract_hash: hex::encode([9; 32]),
448 implementation: "demo::Second".into(),
449 });
450 let manager = ComponentManager::from_lock(two_contracts, dir.path(), "target-a", host()).unwrap();
451 assert!(matches!(
455 manager.ensure_contract("demo", "second", b"alef_component_entry_v1_second\0"),
456 Err(ComponentError::Io(_))
457 ));
458 }
459
460 #[test]
461 fn bundled_component_is_rejected_before_touching_the_cache() {
462 let dir = tempfile::tempdir().unwrap();
463 let manager = ComponentManager::from_lock(
464 lock_with_mode("target-a", ComponentDeliveryMode::Bundled),
465 dir.path(),
466 "target-a",
467 host(),
468 )
469 .unwrap();
470
471 assert!(matches!(
472 manager.ensure_contract("demo", "engine", b"alef_component_entry_v1_engine\0"),
473 Err(ComponentError::BundledComponentNotLoadable { .. })
474 ));
475 assert_eq!(manager.status("demo").unwrap(), ComponentStatus::Bundled);
479 assert!(matches!(
480 manager.prefetch(&["demo"]),
481 Err(ComponentError::BundledComponentNotLoadable { .. })
482 ));
483 assert!(matches!(
484 manager.cache_path("demo"),
485 Err(ComponentError::BundledComponentNotLoadable { .. })
486 ));
487 assert!(
488 std::fs::read_dir(dir.path()).unwrap().next().is_none(),
489 "a bundled component must never touch the cache directory"
490 );
491 }
492
493 #[test]
494 fn prefetch_reuses_an_already_verified_artifact_without_reinstalling() {
495 let dir = tempfile::tempdir().unwrap();
496 let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap();
497 let entry = manager.entries.get("demo").unwrap().clone();
498
499 let fake = CachedArtifact {
500 root: dir.path().join("fake-root"),
501 library: dir.path().join("fake-root/libdemo.so"),
502 manifest: crate::ComponentManifest {
503 schema_version: COMPONENT_MANIFEST_SCHEMA,
504 abi_version: 1,
505 identity: entry.identity.clone(),
506 provides: entry.provides.clone(),
507 features: Vec::new(),
508 default_features: false,
509 library: crate::ComponentLibrary {
510 file: "libdemo.so".into(),
511 sha256: hex::encode([0; 32]),
512 size: 0,
513 },
514 },
515 };
516 manager
517 .verified_artifacts
518 .lock()
519 .unwrap()
520 .insert(entry.sha256.clone(), fake.clone());
521
522 let prefetched = manager.prefetch(&["demo"]).unwrap();
526 assert_eq!(prefetched, vec![fake]);
527 }
528
529 #[test]
530 fn contract_lock_coalesces_the_same_key_and_separates_different_keys() {
531 let dir = tempfile::tempdir().unwrap();
532 let manager = ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap();
533
534 let first = manager.contract_lock("demo::engine".to_string());
535 let second = manager.contract_lock("demo::engine".to_string());
536 assert!(
537 Arc::ptr_eq(&first, &second),
538 "the same (component, contract) key must coalesce onto one lock"
539 );
540
541 let unrelated = manager.contract_lock("other::engine".to_string());
542 assert!(
543 !Arc::ptr_eq(&first, &unrelated),
544 "an unrelated key must get its own lock so it is never blocked by a different key"
545 );
546 }
547
548 #[test]
549 fn concurrent_calls_for_the_same_key_serialize_on_the_coalescing_lock() {
550 use std::sync::Barrier;
551 use std::thread;
552
553 let dir = tempfile::tempdir().unwrap();
554 let manager = Arc::new(ComponentManager::from_lock(lock("target-a"), dir.path(), "target-a", host()).unwrap());
555 let order = Arc::new(Mutex::new(Vec::new()));
556 let ready = Arc::new(Barrier::new(2));
557
558 let held = manager.contract_lock("demo::engine".to_string());
563 let guard = held.lock().unwrap();
564
565 let waiter = {
566 let manager = Arc::clone(&manager);
567 let order = Arc::clone(&order);
568 let ready = Arc::clone(&ready);
569 thread::spawn(move || {
570 ready.wait();
571 let _ = manager.ensure_contract("demo", "engine", b"alef_component_entry_v1_engine\0");
572 order.lock().unwrap().push("waiter");
573 })
574 };
575
576 ready.wait();
577 thread::sleep(std::time::Duration::from_millis(50));
578 assert!(
579 order.lock().unwrap().is_empty(),
580 "a concurrent call for the same key must not proceed while the lock is held"
581 );
582 order.lock().unwrap().push("holder");
583 drop(guard);
584
585 waiter.join().unwrap();
586 assert_eq!(*order.lock().unwrap(), vec!["holder", "waiter"]);
587 }
588}