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