Skip to main content

sim_lib_lang_python/
library_core.rs

1//! Capability-bounded matching, source modules, and dynamic evaluation.
2
3use std::sync::Arc;
4
5use sim_kernel::{Cx, Expr, Result, Shape, ShapeBindings, Symbol};
6use sim_lib_core::{DynamicSourcePolicy, RequestOrigin};
7use sim_lib_namespace::{IdentitySpecifierPolicy, SourceModulePolicy};
8
9use crate::python_core_matrix_row;
10
11/// Whether one public Python surface is implemented by the checked profile.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum PythonSurfaceState {
14    /// The checked matrix exercises this member and the profile exposes it.
15    Present,
16    /// The profile deliberately has no implementation for this member.
17    Absent,
18}
19
20/// One explicit builtin or curated source-library member.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct PythonSurface {
23    /// Python-visible qualified name.
24    pub name: &'static str,
25    /// Checked presence rather than an implicit host fallback.
26    pub state: PythonSurfaceState,
27    /// Matrix cases proving a present member, or the stable exclusion reason.
28    pub evidence: Vec<String>,
29}
30
31/// Generated builtin and source-library coverage for the checked matrix.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct PythonLibraryManifest {
34    /// Matrix-derived builtin coverage followed by explicit absences.
35    pub builtins: Vec<PythonSurface>,
36    /// Matrix-derived curated module coverage followed by explicit absences.
37    pub modules: Vec<PythonSurface>,
38}
39
40const BUILTIN_RULES: &[(&str, &[&str])] = &[
41    ("range", &["scalar-flow"]),
42    ("property", &["objects-c3-descriptors-super"]),
43    ("super", &["objects-c3-descriptors-super"]),
44    ("next", &["generator-send-close"]),
45    ("eval", &["authorized-dynamic-eval"]),
46    ("exec", &["authorized-dynamic-exec"]),
47];
48const ABSENT_BUILTINS: &[(&str, &str)] = &[
49    ("compile", "no compiler or bytecode surface"),
50    ("open", "storage is supplied through Table/Dir roots"),
51    ("__import__", "imports use the shared module lifecycle"),
52    ("breakpoint", "no ambient debugger or host process"),
53    ("input", "no ambient terminal"),
54];
55const MODULE_RULES: &[(&str, &[&str])] = &[
56    ("sim.safe_eval", &["authorized-dynamic-eval"]),
57    ("sim.safe_exec", &["authorized-dynamic-exec"]),
58];
59const ABSENT_MODULES: &[(&str, &str)] = &[
60    ("os", "no ambient host access"),
61    ("sys", "no foreign runtime or process state"),
62    (
63        "subprocess",
64        "host exec is a separate capability-gated library",
65    ),
66    (
67        "socket",
68        "network access is a separate capability-gated library",
69    ),
70    ("pathlib", "paths are supplied Table/Dir identities"),
71];
72
73/// Generate the public library manifest directly from the checked Python row.
74///
75/// A rule whose matrix case is missing becomes absent. This makes matrix drift
76/// reduce claims instead of silently retaining an authored promise.
77pub fn python_library_manifest() -> PythonLibraryManifest {
78    let row = python_core_matrix_row();
79    let case_names = row
80        .cases
81        .iter()
82        .filter(|case| case.symbol.namespace.as_deref() == Some("test/python-core"))
83        .map(|case| case.symbol.name.to_string())
84        .collect::<Vec<_>>();
85    let derive = |rules: &[(&'static str, &'static [&'static str])],
86                  absent: &[(&'static str, &'static str)]| {
87        rules
88            .iter()
89            .map(|(name, needs)| {
90                let evidence = needs
91                    .iter()
92                    .filter(|needed| case_names.iter().any(|case| case == **needed))
93                    .map(|needed| (*needed).to_owned())
94                    .collect::<Vec<_>>();
95                PythonSurface {
96                    name,
97                    state: if evidence.len() == needs.len() {
98                        PythonSurfaceState::Present
99                    } else {
100                        PythonSurfaceState::Absent
101                    },
102                    evidence: if evidence.len() == needs.len() {
103                        evidence
104                    } else {
105                        vec![format!("missing checked matrix case: {}", needs.join(", "))]
106                    },
107                }
108            })
109            .chain(absent.iter().map(|(name, reason)| PythonSurface {
110                name,
111                state: PythonSurfaceState::Absent,
112                evidence: vec![(*reason).to_owned()],
113            }))
114            .collect()
115    };
116    PythonLibraryManifest {
117        builtins: derive(BUILTIN_RULES, ABSENT_BUILTINS),
118        modules: derive(MODULE_RULES, ABSENT_MODULES),
119    }
120}
121
122/// One ordered Python structural-match case composed from a canonical Shape.
123type MatchGuard<'a> = dyn FnMut(&mut Cx, &ShapeBindings) -> Result<bool> + 'a;
124
125/// One ordered Python structural-match case composed from a canonical Shape.
126pub struct MatchCase<'a> {
127    /// Shape/pattern that performs structural checking and captures.
128    pub pattern: Arc<dyn Shape>,
129    /// Optional guard evaluated only after this pattern accepts.
130    pub guard: Option<&'a mut MatchGuard<'a>>,
131}
132
133/// Result of ordered structural matching.
134pub enum MatchOutcome {
135    /// First pattern whose guard accepted, including its isolated bindings.
136    Matched {
137        /// Zero-based declaration-order case index.
138        index: usize,
139        /// Captures produced only by the accepted case.
140        bindings: ShapeBindings,
141    },
142    /// No faithfully supported case accepted.
143    NoMatch,
144}
145
146/// Match an expression in declaration order using Shape captures and guards.
147///
148/// Captures from rejected patterns and false guards never escape into later
149/// cases, matching Python's case-local binding policy.
150pub fn match_expr(
151    cx: &mut Cx,
152    subject: &Expr,
153    cases: &mut [MatchCase<'_>],
154) -> Result<MatchOutcome> {
155    for (index, case) in cases.iter_mut().enumerate() {
156        let matched = case.pattern.check_expr(cx, subject)?;
157        if !matched.accepted {
158            continue;
159        }
160        if let Some(guard) = case.guard.as_mut()
161            && !guard(cx, &matched.captures)?
162        {
163            continue;
164        }
165        return Ok(MatchOutcome::Matched {
166            index,
167            bindings: matched.captures,
168        });
169    }
170    Ok(MatchOutcome::NoMatch)
171}
172
173/// Build Python's module entry over the shared source-module policy.
174pub fn python_module_policy() -> SourceModulePolicy {
175    python_module_policy_with_codec(Symbol::qualified("codec", "python"))
176}
177
178/// Build Python's module entry for an installed compatible source codec.
179pub fn python_module_policy_with_codec(codec: Symbol) -> SourceModulePolicy {
180    SourceModulePolicy::new(codec, Arc::new(IdentitySpecifierPolicy))
181}
182
183/// Build one Python dynamic-source entry over the shared source policy.
184pub fn dynamic_python_policy(operation: &str) -> DynamicSourcePolicy {
185    dynamic_python_policy_with_codec(operation, Symbol::qualified("codec", "python"))
186}
187
188/// Build one Python dynamic-source entry for an installed compatible codec.
189pub fn dynamic_python_policy_with_codec(operation: &str, codec: Symbol) -> DynamicSourcePolicy {
190    DynamicSourcePolicy::new(
191        codec,
192        RequestOrigin::new(Symbol::qualified("python", operation)),
193    )
194}
195
196#[cfg(test)]
197mod tests {
198    use std::{cell::Cell, collections::BTreeMap, sync::RwLock};
199
200    use sim_codec_lisp::LispCodecLib;
201    use sim_kernel::{
202        CapabilityName, CapabilitySet, ClassId, ClassRef, CodecId, DefaultFactory, Dir,
203        EagerPolicy, Error, Object, ObjectCompat, ReadPolicy, Table, TrustLevel, Value,
204        read_eval_capability,
205    };
206    use sim_lib_core::SourceAuthority;
207    use sim_lib_namespace::{ModuleResolutionOutcome, module_load_capability};
208    use sim_shape::{AnyShape, CaptureShape, ExactExprShape, ListShape};
209
210    use super::*;
211
212    fn context() -> (Cx, sim_kernel::GrantSeat) {
213        let (mut cx, seat) = Cx::new_seated(Arc::new(EagerPolicy), Arc::new(DefaultFactory));
214        cx.load_lib(&LispCodecLib::new(CodecId(93)).unwrap())
215            .unwrap();
216        (cx, seat)
217    }
218
219    fn trusted() -> ReadPolicy {
220        ReadPolicy {
221            trust: TrustLevel::TrustedSource,
222            capabilities: CapabilitySet::new().grant(read_eval_capability()),
223        }
224    }
225
226    fn authority(requires: Vec<CapabilityName>) -> SourceAuthority {
227        SourceAuthority::new(trusted(), requires, CapabilitySet::new()).unwrap()
228    }
229
230    #[test]
231    fn structural_match_preserves_order_guard_and_case_local_bindings() {
232        let (mut cx, _seat) = context();
233        let capture = || {
234            Arc::new(CaptureShape::new(Symbol::new("item"), Arc::new(AnyShape))) as Arc<dyn Shape>
235        };
236        let tuple = || {
237            Arc::new(ListShape::new(vec![
238                Arc::new(ExactExprShape::new(Expr::String("left".to_owned()))),
239                capture(),
240            ])) as Arc<dyn Shape>
241        };
242        let rejected_guard_calls = Cell::new(0);
243        let mut first_guard = |_cx: &mut Cx, bindings: &ShapeBindings| {
244            rejected_guard_calls.set(rejected_guard_calls.get() + 1);
245            Ok(bindings.exprs().len() == 99)
246        };
247        let mut second_guard =
248            |_cx: &mut Cx, bindings: &ShapeBindings| Ok(bindings.exprs().len() == 1);
249        let mut cases = [
250            MatchCase {
251                pattern: tuple(),
252                guard: Some(&mut first_guard),
253            },
254            MatchCase {
255                pattern: tuple(),
256                guard: Some(&mut second_guard),
257            },
258        ];
259        let outcome = match_expr(
260            &mut cx,
261            &Expr::List(vec![
262                Expr::String("left".to_owned()),
263                Expr::String("right".to_owned()),
264            ]),
265            &mut cases,
266        )
267        .unwrap();
268        assert_eq!(rejected_guard_calls.get(), 1);
269        let MatchOutcome::Matched { index, bindings } = outcome else {
270            panic!("expected match")
271        };
272        assert_eq!(index, 1);
273        assert_eq!(
274            bindings.exprs(),
275            &[(Symbol::new("item"), Expr::String("right".to_owned()))]
276        );
277        assert!(matches!(
278            match_expr(&mut cx, &Expr::String("gap".to_owned()), &mut cases).unwrap(),
279            MatchOutcome::NoMatch
280        ));
281    }
282
283    #[test]
284    fn generated_manifest_tracks_matrix_and_makes_absence_explicit() {
285        let manifest = python_library_manifest();
286        assert!(
287            manifest
288                .builtins
289                .iter()
290                .find(|item| item.name == "eval")
291                .is_some_and(|item| item.state == PythonSurfaceState::Present
292                    && item.evidence == ["authorized-dynamic-eval"])
293        );
294        for absent in ["compile", "open", "__import__", "breakpoint", "input"] {
295            assert!(
296                manifest
297                    .builtins
298                    .iter()
299                    .find(|item| item.name == absent)
300                    .is_some_and(|item| item.state == PythonSurfaceState::Absent
301                        && !item.evidence.is_empty())
302            );
303        }
304        for absent in ["os", "sys", "subprocess", "socket", "pathlib"] {
305            assert!(
306                manifest
307                    .modules
308                    .iter()
309                    .find(|item| item.name == absent)
310                    .is_some_and(|item| item.state == PythonSurfaceState::Absent
311                        && !item.evidence.is_empty())
312            );
313        }
314    }
315
316    #[test]
317    fn dynamic_eval_and_exec_require_authority_and_diminish_it() {
318        let (mut cx, seat) = context();
319        let eval = dynamic_python_policy_with_codec("eval", Symbol::qualified("codec", "lisp"));
320        let exec = dynamic_python_policy_with_codec("exec", Symbol::qualified("codec", "lisp"));
321        let denied = SourceAuthority::new(
322            ReadPolicy {
323                trust: TrustLevel::Untrusted,
324                capabilities: CapabilitySet::new(),
325            },
326            Vec::new(),
327            CapabilitySet::new(),
328        )
329        .unwrap_err();
330        assert!(matches!(
331            denied,
332            Error::TrustDenied { .. } | Error::CapabilityDenied { .. }
333        ));
334
335        seat.grant(&mut cx, read_eval_capability()).unwrap();
336        let missing = CapabilityName::new("python.dynamic.required");
337        let denied = exec
338            .evaluate_text(
339                &mut cx,
340                "42",
341                authority(vec![missing.clone()]),
342                Arc::new(AnyShape),
343            )
344            .unwrap_err();
345        assert!(matches!(denied, Error::CapabilityDenied { .. }));
346        seat.grant(&mut cx, missing).unwrap();
347        let value = eval
348            .evaluate_text(&mut cx, "42", authority(Vec::new()), Arc::new(AnyShape))
349            .unwrap();
350        assert_eq!(value.object().display(&mut cx).unwrap(), "42");
351        let value = exec
352            .evaluate_text(&mut cx, "42", authority(Vec::new()), Arc::new(AnyShape))
353            .unwrap();
354        assert_eq!(value.object().display(&mut cx).unwrap(), "42");
355
356        let denied = eval
357            .evaluate_text(
358                &mut cx,
359                "42",
360                authority(Vec::new()),
361                Arc::new(ExactExprShape::new(Expr::String("not-42".into()))),
362            )
363            .unwrap_err();
364        assert!(matches!(denied, Error::WrongShape { .. }));
365    }
366
367    #[derive(Default)]
368    struct MemoryDir {
369        files: RwLock<BTreeMap<Symbol, Value>>,
370    }
371
372    impl MemoryDir {
373        fn source(&self, cx: &mut Cx, name: &str, source: &str) {
374            self.files.write().unwrap().insert(
375                Symbol::new(name),
376                cx.factory().string(source.to_owned()).unwrap(),
377            );
378        }
379    }
380    impl Object for MemoryDir {
381        fn display(&self, _cx: &mut Cx) -> Result<String> {
382            Ok("python-memory-root".to_owned())
383        }
384        fn as_any(&self) -> &dyn std::any::Any {
385            self
386        }
387    }
388    impl ObjectCompat for MemoryDir {
389        fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
390            cx.factory()
391                .class_stub(ClassId(0), Symbol::qualified("test", "PythonRoot"))
392        }
393        fn as_table_impl(&self) -> Option<&dyn Table> {
394            Some(self)
395        }
396        fn as_dir(&self) -> Option<&dyn Dir> {
397            Some(self)
398        }
399    }
400    impl Table for MemoryDir {
401        fn backend_symbol(&self) -> Symbol {
402            Symbol::qualified("test", "python-root")
403        }
404        fn get(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
405            self.files
406                .read()
407                .unwrap()
408                .get(&key)
409                .cloned()
410                .map_or_else(|| cx.factory().nil(), Ok)
411        }
412        fn set(&self, _cx: &mut Cx, key: Symbol, value: Value) -> Result<()> {
413            self.files.write().unwrap().insert(key, value);
414            Ok(())
415        }
416        fn has(&self, _cx: &mut Cx, key: Symbol) -> Result<bool> {
417            Ok(self.files.read().unwrap().contains_key(&key))
418        }
419        fn del(&self, cx: &mut Cx, key: Symbol) -> Result<Value> {
420            self.files
421                .write()
422                .unwrap()
423                .remove(&key)
424                .map_or_else(|| cx.factory().nil(), Ok)
425        }
426        fn keys(&self, _cx: &mut Cx) -> Result<Vec<Symbol>> {
427            Ok(self.files.read().unwrap().keys().cloned().collect())
428        }
429        fn entries(&self, _cx: &mut Cx) -> Result<Vec<(Symbol, Value)>> {
430            Ok(self
431                .files
432                .read()
433                .unwrap()
434                .iter()
435                .map(|(key, value)| (key.clone(), value.clone()))
436                .collect())
437        }
438        fn len(&self, _cx: &mut Cx) -> Result<usize> {
439            Ok(self.files.read().unwrap().len())
440        }
441        fn clear(&self, _cx: &mut Cx) -> Result<()> {
442            self.files.write().unwrap().clear();
443            Ok(())
444        }
445    }
446    impl Dir for MemoryDir {
447        fn mkdir(&self, _cx: &mut Cx, _name: Symbol) -> Result<Value> {
448            Err(Error::Eval("nested fixture dirs unsupported".to_owned()))
449        }
450        fn opendir(&self, _cx: &mut Cx, _name: Symbol) -> Result<Option<Value>> {
451            Ok(None)
452        }
453        fn rmdir(&self, cx: &mut Cx, _name: Symbol) -> Result<Value> {
454            cx.factory().nil()
455        }
456        fn is_dir(&self, _cx: &mut Cx, _name: Symbol) -> Result<bool> {
457            Ok(false)
458        }
459    }
460
461    #[test]
462    fn modules_use_supplied_dir_shared_lifecycle_and_cache_failures() {
463        let (mut cx, seat) = context();
464        seat.grant(&mut cx, read_eval_capability()).unwrap();
465        let root = Arc::new(MemoryDir::default());
466        root.source(&mut cx, "answer.py", "42");
467        let modules = python_module_policy_with_codec(Symbol::qualified("codec", "lisp"));
468        let load = |modules: &SourceModulePolicy,
469                    cx: &mut Cx,
470                    root: Arc<MemoryDir>,
471                    specifier: &str,
472                    requires| {
473            modules.load(
474                cx,
475                Symbol::new("supplied"),
476                root,
477                specifier,
478                authority(requires),
479            )
480        };
481        let denied = modules
482            .load(
483                &mut cx,
484                Symbol::new("supplied"),
485                root.clone(),
486                "answer.py",
487                authority(vec![module_load_capability()]),
488            )
489            .unwrap_err();
490        assert!(matches!(denied, Error::CapabilityDenied { .. }));
491        seat.grant(&mut cx, module_load_capability()).unwrap();
492        let loaded = load(
493            &modules,
494            &mut cx,
495            root.clone(),
496            "answer.py",
497            vec![module_load_capability()],
498        )
499        .unwrap();
500        assert_eq!(
501            loaded
502                .default_export()
503                .get()
504                .unwrap()
505                .object()
506                .display(&mut cx)
507                .unwrap(),
508            "42"
509        );
510        root.source(&mut cx, "broken.py", "(");
511        assert!(load(&modules, &mut cx, root.clone(), "broken.py", vec![]).is_err());
512        root.source(&mut cx, "broken.py", "41");
513        assert!(load(&modules, &mut cx, root, "broken.py", vec![]).is_err());
514        assert_eq!(
515            modules
516                .receipts()
517                .unwrap()
518                .iter()
519                .map(|receipt| receipt.outcome)
520                .collect::<Vec<_>>(),
521            vec![
522                ModuleResolutionOutcome::Linked,
523                ModuleResolutionOutcome::DecodeFailed,
524                ModuleResolutionOutcome::DecodeFailed
525            ]
526        );
527    }
528}