1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum PythonSurfaceState {
18 Present,
20 Absent,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct PythonSurface {
27 pub name: &'static str,
29 pub state: PythonSurfaceState,
31 pub evidence: Vec<String>,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct PythonLibraryManifest {
38 pub builtins: Vec<PythonSurface>,
40 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
77pub 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
126type MatchGuard<'a> = dyn FnMut(&mut Cx, &ShapeBindings) -> Result<bool> + 'a;
128
129pub struct MatchCase<'a> {
131 pub pattern: Arc<dyn Shape>,
133 pub guard: Option<&'a mut MatchGuard<'a>>,
135}
136
137pub enum MatchOutcome {
139 Matched {
141 index: usize,
143 bindings: ShapeBindings,
145 },
146 NoMatch,
148}
149
150pub 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
177pub 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 pub fn with_codec(codec: Symbol) -> Self {
192 Self {
193 loader: ModuleLoader::new(),
194 codec,
195 }
196 }
197
198 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 pub fn receipts(&self) -> Result<Vec<sim_lib_namespace::ModuleResolutionReceipt>> {
222 self.loader.receipts()
223 }
224}
225
226pub struct PythonModuleAdmission {
228 pub root_id: Symbol,
230 pub root: Arc<dyn Dir>,
232 pub read_policy: ReadPolicy,
234 pub requires: Vec<CapabilityName>,
236 pub allow: CapabilitySet,
238}
239
240pub 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 pub fn with_codec(codec: Symbol) -> Self {
255 Self {
256 broker: ReadEvalBroker::new(),
257 codec,
258 }
259 }
260
261 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 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
303pub struct DynamicAdmission {
305 pub read_policy: ReadPolicy,
307 pub requires: Vec<CapabilityName>,
309 pub allow: CapabilitySet,
311 pub expected_shape: Arc<dyn Shape>,
313}
314
315impl DynamicAdmission {
316 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}