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