Skip to main content

sim_lib_cookbook/
catalog.rs

1//! Requires-driven library loading and the cookbook capability profile.
2//!
3//! COOKBOOK_7 turns the cookbook into a capability-scoped `realize` site with
4//! requires-driven loading. Two contracts live here:
5//!
6//! - [`LibCatalog`]: a resolver from a recipe `requires` name to a loadable
7//!   library. The catalog is assembled at the HOST/facade level (batteries `sim`,
8//!   `sim-web-shell`) from the standard distribution, so NO dependency cycle
9//!   touches the thin cookbook engine (R6/R7): the runner (`run.rs`) depends only
10//!   on this trait, never on a concrete domain lib.
11//! - [`CookbookCapabilityProfile`]: the deterministic capability set the cookbook
12//!   seats its eval `Cx` with. It GRANTS pure/offline/deterministic effects and
13//!   DENIES live-net, live-hardware, process-spawn, wall-clock, fs/write, and
14//!   unseeded entropy. An op that requests a denied capability fails closed (the
15//!   capability is simply never granted), which is what makes a Category D recipe
16//!   a descriptor BY CONSTRUCTION.
17
18use sim_cookbook::RecipeCard;
19use sim_kernel::{CapabilityName, Cx, GrantSeat, Lib, Result, Symbol};
20
21macro_rules! grant_into_result {
22    ($grant:expr) => {{
23        #[allow(clippy::let_unit_value)]
24        let grant_result = $grant;
25        #[allow(clippy::unit_arg)]
26        grant_result.into_result()
27    }};
28}
29
30/// A resolver from a recipe `requires` name to a loadable library.
31///
32/// A recipe's `requires = [...]` list names the libs its setup needs
33/// (`numbers/complex`, `codec/lisp`, `organ/binding`, ...). The runner asks the
34/// catalog to resolve each name and loads the returned lib into the eval `Cx`
35/// before decode+eval. A name the catalog does not carry stays unresolved, and
36/// the recipe becomes a descriptor whose reason is the unresolved require.
37pub trait LibCatalog {
38    /// Resolve a cookbook require name to a loadable library, or `None` when this
39    /// catalog does not carry it.
40    ///
41    /// A require matches either the lib's fully qualified cookbook name
42    /// (`numbers/cas`) or its unqualified tail (`cas`), mirroring
43    /// [`crate::missing_requires`].
44    fn resolve(&self, name: &str) -> Option<&dyn Lib>;
45}
46
47/// The empty catalog: resolves nothing.
48///
49/// Callers on the compatibility path pre-load every required lib into the `Cx`
50/// themselves; the runner then finds each require already present and loads
51/// nothing. [`crate::run_recipe`] uses this so its behavior is unchanged.
52pub struct EmptyCatalog;
53
54impl LibCatalog for EmptyCatalog {
55    fn resolve(&self, _name: &str) -> Option<&dyn Lib> {
56        None
57    }
58}
59
60/// Load every `requires` entry of `card` into `cx` via `catalog`, idempotently,
61/// returning the names that stayed unresolved (neither already loaded nor
62/// carried by the catalog).
63///
64/// A require already satisfied by a loaded lib is skipped. A require the catalog
65/// resolves is loaded only if its id is not already registered (idempotent, the
66/// `install_once` contract). An unresolved require is returned so the caller can
67/// report the recipe as a descriptor.
68pub fn load_requires(cx: &mut Cx, catalog: &dyn LibCatalog, card: &RecipeCard) -> Vec<String> {
69    let mut unresolved = Vec::new();
70    for req in &card.requires {
71        if lib_present(cx, req) {
72            continue;
73        }
74        match catalog.resolve(req) {
75            Some(lib) => {
76                let mut visiting = Vec::new();
77                if let Err(err) = load_lib_with_deps(cx, catalog, lib, &mut visiting) {
78                    unresolved.push(format!("{req} (load failed: {err})"));
79                }
80            }
81            None => unresolved.push(req.clone()),
82        }
83    }
84    unresolved
85}
86
87/// Whether a lib matching `name` (by qualified id or unqualified tail) is loaded.
88fn lib_present(cx: &Cx, name: &str) -> bool {
89    cx.registry().libs().iter().any(|lib| {
90        lib.manifest.id.as_qualified_str() == name || lib.manifest.id.name.as_ref() == name
91    })
92}
93
94/// Load `lib` into `cx` after its catalog-resolvable manifest dependencies, so a
95/// lib whose `load` observes already-registered libs (e.g. a promotion rule over
96/// existing number domains) sees them first regardless of the recipe's
97/// `requires` order (COOK8.07 dependency-order loading). Idempotent (a
98/// registered lib is skipped) and cycle-safe (an in-progress id is not
99/// re-entered; R7 keeps the lib graph acyclic anyway).
100fn load_lib_with_deps(
101    cx: &mut Cx,
102    catalog: &dyn LibCatalog,
103    lib: &dyn Lib,
104    visiting: &mut Vec<Symbol>,
105) -> Result<()> {
106    let manifest = lib.manifest();
107    if cx.registry().lib(&manifest.id).is_some() || visiting.contains(&manifest.id) {
108        return Ok(());
109    }
110    visiting.push(manifest.id.clone());
111    for dep in &manifest.requires {
112        let resolved = catalog
113            .resolve(&dep.id.as_qualified_str())
114            .or_else(|| catalog.resolve(dep.id.name.as_ref()));
115        // A dependency the catalog does not carry is assumed already loaded (the
116        // boot prelude) or genuinely absent; either way the lib's own load
117        // surfaces the error. Only catalog-resolvable deps are ordered here.
118        if let Some(dep_lib) = resolved {
119            load_lib_with_deps(cx, catalog, dep_lib, visiting)?;
120        }
121    }
122    cx.load_lib(lib)?;
123    visiting.pop();
124    Ok(())
125}
126
127/// The deterministic capability profile the cookbook seats its eval `Cx` with.
128///
129/// GRANTS the pure/offline/deterministic effects a recipe may legitimately need,
130/// including eval-time macro expansion for recipes that load macro-capable
131/// libraries. It DENIES (by omission) every live/effectful capability. A recipe
132/// requesting a denied capability fails closed: it is a Category D descriptor
133/// whose purpose is the denial. The profile is data, not a closed enum -- the
134/// granted and denied vocabularies are named capability strings the kernel
135/// interns.
136#[derive(Clone, Debug, Default)]
137pub struct CookbookCapabilityProfile;
138
139impl CookbookCapabilityProfile {
140    /// The capabilities this profile GRANTS: read-construct, read-eval,
141    /// eval-time macro expansion, and the pure/offline/deterministic effect
142    /// vocabulary (compute, codec round-trip, offline render with no device,
143    /// deterministic cassette replay, and a deterministic model fixture).
144    pub fn granted() -> Vec<CapabilityName> {
145        [
146            "read-construct",
147            "read-eval",
148            "macro.expand",
149            "macro.expand.eval",
150            "compute",
151            "codec-encode",
152            "codec-decode",
153            "offline-render",
154            "cassette-replay",
155            "model-fixture",
156        ]
157        .into_iter()
158        .map(CapabilityName::new)
159        .collect()
160    }
161
162    /// The capabilities this profile explicitly DENIES: live network, live
163    /// hardware, process spawn, wall-clock time, filesystem writes, and unseeded
164    /// entropy. These are the Category D boundary; a recipe requesting one fails
165    /// closed and its purpose becomes the denial.
166    pub fn denied() -> Vec<CapabilityName> {
167        vec![
168            CapabilityName::new("net/http"),
169            CapabilityName::new("net-connect"),
170            CapabilityName::new("device-open"),
171            CapabilityName::new("exec"),
172            CapabilityName::new("process-spawn"),
173            CapabilityName::new("clock-now"),
174            CapabilityName::new("fs/write"),
175            CapabilityName::new("fs-write"),
176            CapabilityName::new("rng-unseeded"),
177        ]
178    }
179
180    /// Whether this profile grants `capability`.
181    pub fn grants(capability: &CapabilityName) -> bool {
182        Self::granted().contains(capability)
183    }
184
185    /// Whether this profile explicitly denies `capability`.
186    pub fn denies(capability: &CapabilityName) -> bool {
187        Self::denied().contains(capability)
188    }
189
190    /// Seat `cx` with the profile: grant every granted capability through the
191    /// host `seat` (minted with the `Cx` by [`sim_kernel::Cx::new_seated`]).
192    ///
193    /// Denied capabilities are never granted, so an op that demands one fails
194    /// closed. Granting is idempotent, so re-seating a `Cx` is a no-op beyond the
195    /// first call.
196    pub fn seat(seat: &GrantSeat, cx: &mut Cx) -> Result<()> {
197        for capability in Self::granted() {
198            grant_into_result!(seat.grant(cx, capability))?;
199        }
200        Ok(())
201    }
202}
203
204trait GrantOutcome {
205    fn into_result(self) -> Result<()>;
206}
207
208impl GrantOutcome for () {
209    fn into_result(self) -> Result<()> {
210        Ok(())
211    }
212}
213
214impl GrantOutcome for Result<()> {
215    fn into_result(self) -> Result<()> {
216        self
217    }
218}