1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum PythonSurfaceState {
14 Present,
16 Absent,
18}
19
20#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct PythonSurface {
23 pub name: &'static str,
25 pub state: PythonSurfaceState,
27 pub evidence: Vec<String>,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct PythonLibraryManifest {
34 pub builtins: Vec<PythonSurface>,
36 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
73pub 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
122type MatchGuard<'a> = dyn FnMut(&mut Cx, &ShapeBindings) -> Result<bool> + 'a;
124
125pub struct MatchCase<'a> {
127 pub pattern: Arc<dyn Shape>,
129 pub guard: Option<&'a mut MatchGuard<'a>>,
131}
132
133pub enum MatchOutcome {
135 Matched {
137 index: usize,
139 bindings: ShapeBindings,
141 },
142 NoMatch,
144}
145
146pub 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
173pub fn python_module_policy() -> SourceModulePolicy {
175 python_module_policy_with_codec(Symbol::qualified("codec", "python"))
176}
177
178pub fn python_module_policy_with_codec(codec: Symbol) -> SourceModulePolicy {
180 SourceModulePolicy::new(codec, Arc::new(IdentitySpecifierPolicy))
181}
182
183pub fn dynamic_python_policy(operation: &str) -> DynamicSourcePolicy {
185 dynamic_python_policy_with_codec(operation, Symbol::qualified("codec", "python"))
186}
187
188pub 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}