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}