1use std::collections::BTreeMap;
11use std::error::Error;
12use std::fmt;
13use std::time::Duration;
14
15use monty::{MontyRun, RunProgress};
16use monty_types::{
17 CompileOptions, DictPairs, ExcType, ExtFunctionResult, FileMode, MontyException,
18 MontyFileHandle, NameLookupResult, PrintWriter, ResourceLimits, ResourceTracker, StringRepr,
19 UnicodeErrorData, UnicodeErrorObject, dir_stat, file_stat, symlink_stat,
20 unicode_decode_error_msg, utf8_error_reason,
21};
22pub use monty_types::{MontyObject, MontyType, OsFunctionCall};
23use vsh_policy::{AccessKind, CallPolicy, DeniedAccess};
24use vsh_types::{ContentVersion, NodeKind, NodeState, RuntimeConfigDigest, VPath, VPathError};
25use vsh_vfs::{EffectOrigin, VfsError, VirtualFs};
26
27mod tools;
28mod worker;
29
30pub use tools::MONTY_VSH_TOOL_NAMES;
31pub use worker::{SubprocessConfig, SubprocessMonty};
32
33pub const DEFAULT_VIRTUAL_ROOT: &str = "/workspace";
35const MAX_PYTHON_RESULT_DEPTH: usize = 200;
36
37#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
39pub enum ResultCompatibility {
40 #[default]
42 Native,
43 Python,
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
49#[non_exhaustive]
50pub enum ResultCompatibilityError {
51 Depth {
53 limit: usize,
55 attempted: usize,
57 },
58 TypeObject {
60 name: String,
62 },
63}
64
65impl fmt::Display for ResultCompatibilityError {
66 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67 match self {
68 Self::Depth { limit, attempted } => write!(
69 formatter,
70 "Python result depth exceeds converter limit: {attempted} > {limit}"
71 ),
72 Self::TypeObject { name } => write!(
73 formatter,
74 "Monty type object {name:?} has no faithful Python projection"
75 ),
76 }
77 }
78}
79
80impl Error for ResultCompatibilityError {}
81
82pub fn validate_result_compatibility(
91 value: &MontyObject,
92 compatibility: ResultCompatibility,
93) -> Result<(), ResultCompatibilityError> {
94 if compatibility == ResultCompatibility::Native {
95 return Ok(());
96 }
97
98 let mut pending = vec![(value, 1_usize)];
99 while let Some((value, depth)) = pending.pop() {
100 if depth > MAX_PYTHON_RESULT_DEPTH {
101 return Err(ResultCompatibilityError::Depth {
102 limit: MAX_PYTHON_RESULT_DEPTH,
103 attempted: depth,
104 });
105 }
106 if let MontyObject::Type(kind) = value
107 && !python_type_object_is_supported(kind)
108 {
109 return Err(ResultCompatibilityError::TypeObject {
110 name: kind.to_string(),
111 });
112 }
113
114 let child_depth = depth.saturating_add(1);
115 match value {
116 MontyObject::List(values)
117 | MontyObject::Tuple(values)
118 | MontyObject::Set(values)
119 | MontyObject::FrozenSet(values)
120 | MontyObject::NamedTuple { values, .. } => {
121 pending.extend(values.iter().map(|value| (value, child_depth)));
122 }
123 MontyObject::Dict(pairs) => {
124 for (key, value) in pairs {
125 pending.push((key, child_depth));
126 pending.push((value, child_depth));
127 }
128 }
129 MontyObject::ClassInstance(instance) => {
130 for (key, value) in instance.attrs.iter().chain(&instance.class_type.attrs) {
131 pending.push((key, child_depth));
132 pending.push((value, child_depth));
133 }
134 }
135 _ => {}
136 }
137 }
138 Ok(())
139}
140
141fn python_type_object_is_supported(kind: &MontyType) -> bool {
142 match kind {
143 MontyType::Exception(kind) => !matches!(
144 kind,
145 ExcType::FrozenInstanceError
146 | ExcType::JsonDecodeError
147 | ExcType::UnsupportedOperation
148 | ExcType::RePatternError
149 ),
150 MontyType::Ellipsis
151 | MontyType::Type
152 | MontyType::NoneType
153 | MontyType::Bool
154 | MontyType::Int
155 | MontyType::Float
156 | MontyType::Range
157 | MontyType::Slice
158 | MontyType::Date
159 | MontyType::DateTime
160 | MontyType::TimeDelta
161 | MontyType::TimeZone
162 | MontyType::Str
163 | MontyType::Bytes
164 | MontyType::List
165 | MontyType::Deque
166 | MontyType::ListIterator
167 | MontyType::CallableIterator
168 | MontyType::Tuple
169 | MontyType::Dict
170 | MontyType::Set
171 | MontyType::FrozenSet
172 | MontyType::TextIOWrapper
173 | MontyType::BufferedReader
174 | MontyType::BufferedWriter
175 | MontyType::BufferedRandom
176 | MontyType::SpecialForm
177 | MontyType::Path
178 | MontyType::Property
179 | MontyType::RePattern
180 | MontyType::ReMatch
181 | MontyType::ItertoolsCount
182 | MontyType::ItertoolsRepeat
183 | MontyType::Field
184 | MontyType::ItertoolsPairwise
185 | MontyType::ItertoolsCompress
186 | MontyType::ItertoolsIslice
187 | MontyType::ItertoolsChain
188 | MontyType::ItertoolsCycle => true,
189 _ => false,
190 }
191}
192
193#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195pub struct ExecutionLimits {
196 pub max_program_bytes: usize,
198 pub max_duration: Duration,
200 pub max_recursion_depth: usize,
202 pub max_memory_bytes: usize,
205 pub max_os_calls: u64,
207 pub max_read_bytes: u64,
209 pub max_write_bytes: u64,
211 pub max_io_call_bytes: usize,
213 pub max_path_bytes: usize,
215 pub max_directory_entries: u64,
217 pub max_output_bytes: usize,
219 pub max_result_bytes: usize,
221 pub max_exception_bytes: usize,
223}
224
225impl Default for ExecutionLimits {
226 fn default() -> Self {
227 Self {
228 max_program_bytes: 1024 * 1024,
229 max_duration: Duration::from_secs(1),
230 max_recursion_depth: 512,
231 max_memory_bytes: 256 * 1024 * 1024,
232 max_os_calls: 10_000,
233 max_read_bytes: 64 * 1024 * 1024,
234 max_write_bytes: 64 * 1024 * 1024,
235 max_io_call_bytes: 4 * 1024 * 1024,
236 max_path_bytes: 16 * 1024,
237 max_directory_entries: 100_000,
238 max_output_bytes: 1024 * 1024,
239 max_result_bytes: 1024 * 1024,
240 max_exception_bytes: 256 * 1024,
241 }
242 }
243}
244
245#[derive(Clone, Debug, Eq, PartialEq)]
247pub struct VirtualRoot {
248 absolute: String,
249}
250
251impl VirtualRoot {
252 pub fn new(absolute: impl Into<String>) -> Result<Self, VirtualRootError> {
259 let absolute = absolute.into();
260 if absolute.contains('\0') {
261 return Err(VirtualRootError::NulByte);
262 }
263 if !absolute.starts_with('/') {
264 return Err(VirtualRootError::NotAbsolute);
265 }
266 if absolute.contains('\\') {
267 return Err(VirtualRootError::PlatformSeparator);
268 }
269
270 let mut components = Vec::new();
271 for component in absolute.split('/') {
272 match component {
273 "" | "." => {}
274 ".." => return Err(VirtualRootError::ParentComponent),
275 value if is_windows_prefix(value) => {
276 return Err(VirtualRootError::PlatformPrefix);
277 }
278 value => components.push(value),
279 }
280 }
281 let absolute = if components.is_empty() {
282 "/".to_owned()
283 } else {
284 format!("/{}", components.join("/"))
285 };
286 Ok(Self { absolute })
287 }
288
289 #[must_use]
291 pub fn as_str(&self) -> &str {
292 &self.absolute
293 }
294
295 pub fn map_path(&self, input: &str) -> Result<VPath, VirtualPathError> {
301 if input.is_empty() {
302 return Err(VirtualPathError::Empty);
303 }
304 if input.contains('\0') {
305 return Err(VirtualPathError::NulByte);
306 }
307
308 let portable = input.replace('\\', "/");
309 if portable.starts_with('/') {
310 let absolute = normalize_absolute(&portable)?;
311 let relative = if self.absolute == "/" {
312 absolute.strip_prefix('/').unwrap_or(&absolute)
313 } else if absolute == self.absolute {
314 ""
315 } else {
316 absolute
317 .strip_prefix(&self.absolute)
318 .and_then(|suffix| suffix.strip_prefix('/'))
319 .ok_or(VirtualPathError::OutsideRoot)?
320 };
321 if relative.is_empty() {
322 Ok(VPath::root())
323 } else {
324 VPath::parse(relative).map_err(VirtualPathError::InvalidRelative)
325 }
326 } else {
327 VPath::parse(&portable).map_err(VirtualPathError::InvalidRelative)
328 }
329 }
330
331 fn present(&self, path: &VPath) -> String {
332 if path.is_root() {
333 return self.absolute.clone();
334 }
335 if self.absolute == "/" {
336 format!("/{}", path.as_str())
337 } else {
338 format!("{}/{}", self.absolute, path.as_str())
339 }
340 }
341}
342
343impl Default for VirtualRoot {
344 fn default() -> Self {
345 Self {
346 absolute: DEFAULT_VIRTUAL_ROOT.to_owned(),
347 }
348 }
349}
350
351#[derive(Clone, Copy, Debug, Eq, PartialEq)]
353#[non_exhaustive]
354pub enum VirtualRootError {
355 NotAbsolute,
357 ParentComponent,
359 NulByte,
361 PlatformSeparator,
363 PlatformPrefix,
365}
366
367impl fmt::Display for VirtualRootError {
368 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369 formatter.write_str(match self {
370 Self::NotAbsolute => "virtual root must be absolute",
371 Self::ParentComponent => "virtual root contains a parent component",
372 Self::NulByte => "virtual root contains a NUL byte",
373 Self::PlatformSeparator => "virtual root contains a platform separator",
374 Self::PlatformPrefix => "virtual root contains a platform prefix",
375 })
376 }
377}
378
379impl Error for VirtualRootError {}
380
381#[derive(Clone, Debug, Eq, PartialEq)]
383#[non_exhaustive]
384pub enum VirtualPathError {
385 Empty,
387 NulByte,
389 EscapesAbsoluteRoot,
391 OutsideRoot,
393 InvalidRelative(VPathError),
395}
396
397impl fmt::Display for VirtualPathError {
398 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
399 match self {
400 Self::Empty => formatter.write_str("virtual path must not be empty"),
401 Self::NulByte => formatter.write_str("virtual path contains a NUL byte"),
402 Self::EscapesAbsoluteRoot => formatter.write_str("virtual path escapes absolute root"),
403 Self::OutsideRoot => formatter.write_str("virtual path is outside /workspace"),
404 Self::InvalidRelative(source) => {
405 write!(formatter, "invalid relative virtual path: {source}")
406 }
407 }
408 }
409}
410
411impl Error for VirtualPathError {
412 fn source(&self) -> Option<&(dyn Error + 'static)> {
413 match self {
414 Self::InvalidRelative(source) => Some(source),
415 Self::Empty | Self::NulByte | Self::EscapesAbsoluteRoot | Self::OutsideRoot => None,
416 }
417 }
418}
419
420#[derive(Clone, Debug, Eq, PartialEq)]
422pub struct InProcessConfig {
423 virtual_root: VirtualRoot,
424 environment: BTreeMap<String, String>,
425 limits: ExecutionLimits,
426 script_name: String,
427 call_policy: CallPolicy,
428}
429
430impl InProcessConfig {
431 #[must_use]
433 pub fn new(virtual_root: VirtualRoot) -> Self {
434 let mut environment = BTreeMap::new();
435 environment.insert("HOME".to_owned(), "/home/vsh".to_owned());
436 environment.insert("PWD".to_owned(), virtual_root.as_str().to_owned());
437 Self {
438 virtual_root,
439 environment,
440 limits: ExecutionLimits::default(),
441 script_name: "<vsh>".to_owned(),
442 call_policy: CallPolicy::default(),
443 }
444 }
445
446 #[must_use]
448 pub fn with_limits(mut self, limits: ExecutionLimits) -> Self {
449 self.limits = limits;
450 self
451 }
452
453 #[must_use]
455 pub fn with_environment(mut self, environment: BTreeMap<String, String>) -> Self {
456 self.environment = environment;
457 self
458 }
459
460 #[must_use]
462 pub fn with_script_name(mut self, script_name: impl Into<String>) -> Self {
463 self.script_name = script_name.into();
464 self
465 }
466
467 #[must_use]
469 pub fn with_call_policy(mut self, call_policy: CallPolicy) -> Self {
470 self.call_policy = call_policy;
471 self
472 }
473
474 #[must_use]
476 pub const fn virtual_root(&self) -> &VirtualRoot {
477 &self.virtual_root
478 }
479
480 #[must_use]
482 pub const fn limits(&self) -> ExecutionLimits {
483 self.limits
484 }
485
486 #[must_use]
488 pub const fn call_policy(&self) -> &CallPolicy {
489 &self.call_policy
490 }
491
492 #[must_use]
494 pub fn security_digest(&self) -> RuntimeConfigDigest {
495 let mut canonical = Vec::new();
496 encode_string("vsh-monty-config-v3", &mut canonical);
497 encode_string(env!("CARGO_PKG_VERSION"), &mut canonical);
498 encode_string("monty-0.0.22", &mut canonical);
499 encode_string(self.virtual_root.as_str(), &mut canonical);
500 encode_string(&self.script_name, &mut canonical);
501 encode_u64(self.limits.max_program_bytes, &mut canonical);
502 canonical.extend_from_slice(&self.limits.max_duration.as_nanos().to_le_bytes());
503 encode_u64(self.limits.max_recursion_depth, &mut canonical);
504 encode_u64(self.limits.max_memory_bytes, &mut canonical);
505 canonical.extend_from_slice(&self.limits.max_os_calls.to_le_bytes());
506 canonical.extend_from_slice(&self.limits.max_read_bytes.to_le_bytes());
507 canonical.extend_from_slice(&self.limits.max_write_bytes.to_le_bytes());
508 encode_u64(self.limits.max_io_call_bytes, &mut canonical);
509 encode_u64(self.limits.max_path_bytes, &mut canonical);
510 canonical.extend_from_slice(&self.limits.max_directory_entries.to_le_bytes());
511 encode_u64(self.limits.max_output_bytes, &mut canonical);
512 encode_u64(self.limits.max_result_bytes, &mut canonical);
513 encode_u64(self.limits.max_exception_bytes, &mut canonical);
514 encode_u64(self.environment.len(), &mut canonical);
515 for (key, value) in &self.environment {
516 encode_string(key, &mut canonical);
517 encode_string(value, &mut canonical);
518 }
519 RuntimeConfigDigest::digest_canonical(&canonical)
520 }
521}
522
523fn encode_string(value: &str, output: &mut Vec<u8>) {
524 encode_u64(value.len(), output);
525 output.extend_from_slice(value.as_bytes());
526}
527
528fn encode_u64(value: usize, output: &mut Vec<u8>) {
529 output.extend_from_slice(&u64::try_from(value).unwrap_or(u64::MAX).to_le_bytes());
530}
531
532impl Default for InProcessConfig {
533 fn default() -> Self {
534 Self::new(VirtualRoot::default())
535 }
536}
537
538#[derive(Clone, Copy, Debug, Eq, PartialEq)]
540#[non_exhaustive]
541pub enum ExecutionLimitExceeded {
542 ProgramBytes {
544 limit: u64,
546 attempted: u64,
548 },
549 OsCalls {
551 limit: u64,
553 attempted: u64,
555 },
556 ReadBytes {
558 limit: u64,
560 attempted: u64,
562 },
563 WriteBytes {
565 limit: u64,
567 attempted: u64,
569 },
570 ReadCallBytes {
572 limit: u64,
574 attempted: u64,
576 },
577 WriteCallBytes {
579 limit: u64,
581 attempted: u64,
583 },
584 PathBytes {
586 limit: u64,
588 attempted: u64,
590 },
591 DirectoryEntries {
593 limit: u64,
595 attempted: u64,
597 },
598 OutputBytes {
600 limit: u64,
602 attempted: u64,
604 },
605 ResultBytes {
607 limit: u64,
609 attempted: u64,
611 },
612 ExceptionBytes {
614 limit: u64,
616 attempted: u64,
618 },
619}
620
621impl fmt::Display for ExecutionLimitExceeded {
622 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
623 let (name, limit, attempted) = match *self {
624 Self::ProgramBytes { limit, attempted } => ("program bytes", limit, attempted),
625 Self::OsCalls { limit, attempted } => ("OS calls", limit, attempted),
626 Self::ReadBytes { limit, attempted } => ("read bytes", limit, attempted),
627 Self::WriteBytes { limit, attempted } => ("write bytes", limit, attempted),
628 Self::ReadCallBytes { limit, attempted } => ("read call bytes", limit, attempted),
629 Self::WriteCallBytes { limit, attempted } => ("write call bytes", limit, attempted),
630 Self::PathBytes { limit, attempted } => ("path bytes", limit, attempted),
631 Self::DirectoryEntries { limit, attempted } => ("directory entries", limit, attempted),
632 Self::OutputBytes { limit, attempted } => ("output bytes", limit, attempted),
633 Self::ResultBytes { limit, attempted } => ("result bytes", limit, attempted),
634 Self::ExceptionBytes { limit, attempted } => ("exception bytes", limit, attempted),
635 };
636 write!(formatter, "{name} limit exceeded: {attempted} > {limit}")
637 }
638}
639
640impl Error for ExecutionLimitExceeded {}
641
642#[derive(Clone, Copy, Debug, Eq, PartialEq)]
644pub enum MontyFailurePhase {
645 Compile,
647 Runtime,
649}
650
651#[derive(Clone, Copy, Debug, Eq, PartialEq)]
653#[non_exhaustive]
654pub enum WorkerFailureKind {
655 Spawn,
657 Transport,
659 Protocol,
661 Crashed,
663 Timeout,
665}
666
667#[derive(Debug)]
669pub struct WorkerFailure {
670 pub kind: WorkerFailureKind,
672 pub detail: String,
674}
675
676impl fmt::Display for WorkerFailure {
677 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
678 write!(
679 formatter,
680 "Monty worker {:?} failure: {}",
681 self.kind, self.detail
682 )
683 }
684}
685
686impl Error for WorkerFailure {}
687
688#[derive(Debug)]
690#[non_exhaustive]
691pub enum ExecutionError {
692 Monty {
694 phase: MontyFailurePhase,
696 source: Box<MontyException>,
698 },
699 Limit(Box<ExecutionLimitExceeded>),
701 InternalVfs(Box<VfsError>),
703 UnsupportedSuspension {
705 kind: &'static str,
707 name: Option<String>,
709 },
710 Worker(Box<WorkerFailure>),
712}
713
714impl fmt::Display for ExecutionError {
715 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
716 match self {
717 Self::Monty { phase, source } => write!(formatter, "Monty {phase:?} failure: {source}"),
718 Self::Limit(source) => write!(formatter, "execution budget failure: {source}"),
719 Self::InternalVfs(source) => write!(formatter, "internal VFS failure: {source}"),
720 Self::UnsupportedSuspension { kind, name } => match name {
721 Some(name) => write!(formatter, "unsupported Monty {kind}: {name}"),
722 None => write!(formatter, "unsupported Monty {kind}"),
723 },
724 Self::Worker(source) => source.fmt(formatter),
725 }
726 }
727}
728
729impl Error for ExecutionError {
730 fn source(&self) -> Option<&(dyn Error + 'static)> {
731 match self {
732 Self::Monty { source, .. } => Some(source),
733 Self::Limit(source) => Some(source),
734 Self::InternalVfs(source) => Some(source),
735 Self::UnsupportedSuspension { .. } => None,
736 Self::Worker(source) => Some(source),
737 }
738 }
739}
740
741#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
743pub struct ExecutionStats {
744 pub os_calls: u64,
746 pub read_bytes: u64,
748 pub write_bytes: u64,
750 pub directory_entries: u64,
752 pub output_bytes: usize,
754 pub denied_accesses: u64,
756 pub result_bytes: u64,
758}
759
760#[derive(Debug)]
762pub struct ExecutionOutcome {
763 pub value: MontyObject,
765 pub stdout: String,
767 pub stats: ExecutionStats,
769 pub denied_accesses: Vec<DeniedAccess>,
771}
772
773#[derive(Clone, Debug, Default)]
779pub struct InProcessMonty {
780 config: InProcessConfig,
781}
782
783impl InProcessMonty {
784 #[must_use]
786 pub const fn new(config: InProcessConfig) -> Self {
787 Self { config }
788 }
789
790 pub fn execute(
797 &self,
798 code: impl Into<String>,
799 filesystem: &mut VirtualFs,
800 ) -> Result<ExecutionOutcome, ExecutionError> {
801 let code = code.into();
802 let program_bytes = u64::try_from(code.len()).unwrap_or(u64::MAX);
803 let max_program_bytes =
804 u64::try_from(self.config.limits.max_program_bytes).unwrap_or(u64::MAX);
805 if program_bytes > max_program_bytes {
806 return Err(limit_error(ExecutionLimitExceeded::ProgramBytes {
807 limit: max_program_bytes,
808 attempted: program_bytes,
809 }));
810 }
811 let (input_names, input_values) = tools::inputs();
812 let run = MontyRun::new(
813 code,
814 &self.config.script_name,
815 input_names,
816 CompileOptions::default(),
817 )
818 .map_err(|source| self.monty_error(MontyFailurePhase::Compile, source))?;
819
820 let resource_limits = ResourceLimits::default()
821 .max_duration(self.config.limits.max_duration)
822 .max_recursion_depth(self.config.limits.max_recursion_depth)
823 .max_suspensions(
824 usize::try_from(self.config.limits.max_os_calls).unwrap_or(usize::MAX),
825 );
826 let tracker = ResourceTracker::new(resource_limits);
827 let mut stdout = String::new();
828 let mut budget = Budget::new(self.config.limits);
829 let mut denied_accesses = Vec::new();
830 let mut progress = run
831 .start(input_values, tracker, self.print_writer(&mut stdout))
832 .map_err(|source| self.monty_error(MontyFailurePhase::Runtime, source))?;
833
834 loop {
835 progress = match progress {
836 RunProgress::Complete(value) => {
837 let mut stats = budget.stats;
838 stats.output_bytes = stdout.len();
839 stats.result_bytes =
840 measure_result(&value, self.config.limits.max_result_bytes)
841 .map_err(limit_error)?;
842 return Ok(ExecutionOutcome {
843 value,
844 stdout,
845 stats,
846 denied_accesses,
847 });
848 }
849 RunProgress::OsCall(call) => {
850 budget.charge_os_call().map_err(limit_error)?;
851 let result =
852 filesystem.with_effect_origin(EffectOrigin::MontyOsCall, |filesystem| {
853 dispatch_call(
854 &call.function_call,
855 filesystem,
856 &self.config,
857 &mut budget,
858 )
859 });
860 let result = call_result(result, &mut budget, &mut denied_accesses)?;
861 call.resume(result, self.print_writer(&mut stdout))
862 .map_err(|source| self.monty_error(MontyFailurePhase::Runtime, source))?
863 }
864 RunProgress::NameLookup(lookup) => lookup
865 .resume(NameLookupResult::Undefined, self.print_writer(&mut stdout))
866 .map_err(|source| self.monty_error(MontyFailurePhase::Runtime, source))?,
867 RunProgress::FunctionCall(call) => {
868 if call.object_id.is_some() || !tools::is_tool(&call.function_name) {
869 return Err(ExecutionError::UnsupportedSuspension {
870 kind: "external function call",
871 name: Some(call.function_name),
872 });
873 }
874 budget.charge_os_call().map_err(limit_error)?;
875 let result =
876 filesystem.with_effect_origin(EffectOrigin::MontyToolCall, |filesystem| {
877 tools::dispatch(
878 &call.function_name,
879 &call.args,
880 &call.kwargs,
881 filesystem,
882 &self.config,
883 &mut budget,
884 )
885 });
886 let result = call_result(result, &mut budget, &mut denied_accesses)?;
887 call.resume(result, self.print_writer(&mut stdout))
888 .map_err(|source| self.monty_error(MontyFailurePhase::Runtime, source))?
889 }
890 RunProgress::ResolveFutures(_) => {
891 return Err(ExecutionError::UnsupportedSuspension {
892 kind: "future resolution",
893 name: None,
894 });
895 }
896 };
897 }
898 }
899
900 fn print_writer<'a>(&self, stdout: &'a mut String) -> PrintWriter<'a> {
901 PrintWriter::CollectString(stdout, Some(self.config.limits.max_output_bytes))
902 }
903
904 fn monty_error(&self, phase: MontyFailurePhase, source: MontyException) -> ExecutionError {
905 let attempted = exception_bytes(&source);
906 let limit = u64::try_from(self.config.limits.max_exception_bytes).unwrap_or(u64::MAX);
907 if attempted > limit {
908 limit_error(ExecutionLimitExceeded::ExceptionBytes { limit, attempted })
909 } else {
910 ExecutionError::Monty {
911 phase,
912 source: Box::new(source),
913 }
914 }
915 }
916}
917
918fn limit_error(source: ExecutionLimitExceeded) -> ExecutionError {
919 ExecutionError::Limit(Box::new(source))
920}
921
922fn call_result(
923 result: Result<MontyObject, CallFailure>,
924 budget: &mut Budget,
925 denied_accesses: &mut Vec<DeniedAccess>,
926) -> Result<ExtFunctionResult, ExecutionError> {
927 match result {
928 Ok(value) => Ok(ExtFunctionResult::Return(value)),
929 Err(CallFailure::Python(exception)) => Ok(ExtFunctionResult::Error(exception)),
930 Err(CallFailure::Policy(denial)) => {
931 budget.stats.denied_accesses = budget.stats.denied_accesses.saturating_add(1);
932 let exception = permission_denied(denial.path.as_str());
933 denied_accesses.push(denial);
934 Ok(ExtFunctionResult::Error(exception))
935 }
936 Err(CallFailure::Limit(source)) => Err(limit_error(source)),
937 Err(CallFailure::InternalVfs(source)) => Err(ExecutionError::InternalVfs(Box::new(source))),
938 }
939}
940
941struct Budget {
942 limits: ExecutionLimits,
943 stats: ExecutionStats,
944}
945
946impl Budget {
947 const fn new(limits: ExecutionLimits) -> Self {
948 Self {
949 limits,
950 stats: ExecutionStats {
951 os_calls: 0,
952 read_bytes: 0,
953 write_bytes: 0,
954 directory_entries: 0,
955 output_bytes: 0,
956 denied_accesses: 0,
957 result_bytes: 0,
958 },
959 }
960 }
961
962 fn charge_os_call(&mut self) -> Result<(), ExecutionLimitExceeded> {
963 charge(
964 &mut self.stats.os_calls,
965 1,
966 self.limits.max_os_calls,
967 |limit, attempted| ExecutionLimitExceeded::OsCalls { limit, attempted },
968 )
969 }
970
971 fn charge_read(&mut self, bytes: u64) -> Result<(), CallFailure> {
972 let call_limit = u64::try_from(self.limits.max_io_call_bytes).unwrap_or(u64::MAX);
973 if bytes > call_limit {
974 return Err(CallFailure::Limit(ExecutionLimitExceeded::ReadCallBytes {
975 limit: call_limit,
976 attempted: bytes,
977 }));
978 }
979 charge(
980 &mut self.stats.read_bytes,
981 bytes,
982 self.limits.max_read_bytes,
983 |limit, attempted| ExecutionLimitExceeded::ReadBytes { limit, attempted },
984 )
985 .map_err(CallFailure::Limit)
986 }
987
988 fn charge_write(&mut self, bytes: usize) -> Result<(), CallFailure> {
989 let bytes = u64::try_from(bytes).unwrap_or(u64::MAX);
990 let call_limit = u64::try_from(self.limits.max_io_call_bytes).unwrap_or(u64::MAX);
991 if bytes > call_limit {
992 return Err(CallFailure::Limit(ExecutionLimitExceeded::WriteCallBytes {
993 limit: call_limit,
994 attempted: bytes,
995 }));
996 }
997 charge(
998 &mut self.stats.write_bytes,
999 bytes,
1000 self.limits.max_write_bytes,
1001 |limit, attempted| ExecutionLimitExceeded::WriteBytes { limit, attempted },
1002 )
1003 .map_err(CallFailure::Limit)
1004 }
1005
1006 fn charge_directory_entries(&mut self, entries: usize) -> Result<(), CallFailure> {
1007 let entries = u64::try_from(entries).unwrap_or(u64::MAX);
1008 charge(
1009 &mut self.stats.directory_entries,
1010 entries,
1011 self.limits.max_directory_entries,
1012 |limit, attempted| ExecutionLimitExceeded::DirectoryEntries { limit, attempted },
1013 )
1014 .map_err(CallFailure::Limit)
1015 }
1016}
1017
1018fn charge<E>(
1019 used: &mut u64,
1020 amount: u64,
1021 limit: u64,
1022 error: impl FnOnce(u64, u64) -> E,
1023) -> Result<(), E> {
1024 let attempted = used.saturating_add(amount);
1025 if attempted > limit {
1026 Err(error(limit, attempted))
1027 } else {
1028 *used = attempted;
1029 Ok(())
1030 }
1031}
1032
1033fn measure_result(value: &MontyObject, limit: usize) -> Result<u64, ExecutionLimitExceeded> {
1034 let limit = u64::try_from(limit).unwrap_or(u64::MAX);
1035 let mut used = 0_u64;
1036 let mut pending = vec![value];
1037 while let Some(value) = pending.pop() {
1038 let bytes = u64::try_from(value.host_size()).unwrap_or(u64::MAX);
1039 let attempted = used.saturating_add(bytes);
1040 if attempted > limit {
1041 return Err(ExecutionLimitExceeded::ResultBytes { limit, attempted });
1042 }
1043 used = attempted;
1044 match value {
1045 MontyObject::List(values)
1046 | MontyObject::Tuple(values)
1047 | MontyObject::Set(values)
1048 | MontyObject::FrozenSet(values)
1049 | MontyObject::NamedTuple { values, .. } => pending.extend(values),
1050 MontyObject::Dict(pairs) => {
1051 for (key, value) in pairs {
1052 pending.push(key);
1053 pending.push(value);
1054 }
1055 }
1056 MontyObject::ClassInstance(instance) => {
1057 for (key, value) in instance.attrs.iter().chain(&instance.class_type.attrs) {
1058 pending.push(key);
1059 pending.push(value);
1060 }
1061 }
1062 _ => {}
1063 }
1064 }
1065 Ok(used)
1066}
1067
1068fn exception_bytes(source: &MontyException) -> u64 {
1069 let mut bytes = u64::try_from(size_of::<MontyException>()).unwrap_or(u64::MAX);
1070 bytes = bytes.saturating_add(source.message().map_or(0, |message| {
1071 u64::try_from(message.len()).unwrap_or(u64::MAX)
1072 }));
1073 for frame in source.traceback() {
1074 bytes = bytes
1075 .saturating_add(u64::try_from(size_of_val(frame)).unwrap_or(u64::MAX))
1076 .saturating_add(u64::try_from(frame.filename.len()).unwrap_or(u64::MAX))
1077 .saturating_add(
1078 frame
1079 .frame_name
1080 .as_ref()
1081 .map_or(0, |name| u64::try_from(name.len()).unwrap_or(u64::MAX)),
1082 )
1083 .saturating_add(
1084 frame
1085 .preview_line
1086 .as_ref()
1087 .map_or(0, |line| u64::try_from(line.len()).unwrap_or(u64::MAX)),
1088 );
1089 }
1090 if let Some(data) = source.data().unicode() {
1091 bytes = bytes
1092 .saturating_add(u64::try_from(data.encoding.len()).unwrap_or(u64::MAX))
1093 .saturating_add(u64::try_from(data.reason.len()).unwrap_or(u64::MAX))
1094 .saturating_add(match &data.object {
1095 UnicodeErrorObject::Bytes(value) => u64::try_from(value.len()).unwrap_or(u64::MAX),
1096 UnicodeErrorObject::Str(value) => u64::try_from(value.len()).unwrap_or(u64::MAX),
1097 });
1098 }
1099 if let Some(data) = source.data().json() {
1100 bytes = bytes
1101 .saturating_add(u64::try_from(data.msg.len()).unwrap_or(u64::MAX))
1102 .saturating_add(
1103 data.doc
1104 .as_ref()
1105 .map_or(0, |doc| u64::try_from(doc.len()).unwrap_or(u64::MAX)),
1106 );
1107 }
1108 bytes
1109}
1110
1111enum CallFailure {
1112 Python(MontyException),
1113 Policy(DeniedAccess),
1114 Limit(ExecutionLimitExceeded),
1115 InternalVfs(VfsError),
1116}
1117
1118#[expect(
1119 clippy::too_many_lines,
1120 reason = "keeping the exhaustive typed Monty boundary in one match makes new upstream variants fail compilation"
1121)]
1122fn dispatch_call(
1123 call: &OsFunctionCall,
1124 filesystem: &mut VirtualFs,
1125 config: &InProcessConfig,
1126 budget: &mut Budget,
1127) -> Result<MontyObject, CallFailure> {
1128 match call {
1129 OsFunctionCall::Exists(path) => {
1130 bool_query(call, path.as_str(), filesystem, config, |state| {
1131 state.is_some()
1132 })
1133 }
1134 OsFunctionCall::IsFile(path) => {
1135 bool_query(call, path.as_str(), filesystem, config, |state| {
1136 state.is_some_and(|state| state.kind() == NodeKind::File)
1137 })
1138 }
1139 OsFunctionCall::IsDir(path) => {
1140 bool_query(call, path.as_str(), filesystem, config, |state| {
1141 state.is_some_and(|state| state.kind() == NodeKind::Directory)
1142 })
1143 }
1144 OsFunctionCall::IsSymlink(path) => {
1145 bool_query(call, path.as_str(), filesystem, config, |state| {
1146 state.is_some_and(|state| state.kind() == NodeKind::Symlink)
1147 })
1148 }
1149 OsFunctionCall::ReadText(path) => {
1150 read_text(call, path.as_str(), filesystem, config, budget)
1151 }
1152 OsFunctionCall::ReadBytes(path) => {
1153 read_bytes(call, path.as_str(), filesystem, config, budget)
1154 }
1155 OsFunctionCall::Stat(path) => stat(call, path.as_str(), filesystem, config),
1156 OsFunctionCall::Iterdir(path) => {
1157 read_directory(call, path.as_str(), filesystem, config, budget)
1158 }
1159 OsFunctionCall::Resolve(path) | OsFunctionCall::Absolute(path) => {
1160 absolute_path(call, path.as_str(), config)
1161 }
1162 OsFunctionCall::WriteText(args) => {
1163 budget.charge_write(args.data.len())?;
1164 write_bytes(
1165 call,
1166 args.path.as_str(),
1167 args.data.as_bytes(),
1168 filesystem,
1169 config,
1170 )?;
1171 Ok(MontyObject::Int(
1172 i64::try_from(args.data.chars().count()).unwrap_or(i64::MAX),
1173 ))
1174 }
1175 OsFunctionCall::WriteBytes(args) => {
1176 budget.charge_write(args.data.len())?;
1177 write_bytes(call, args.path.as_str(), &args.data, filesystem, config)?;
1178 Ok(MontyObject::Int(
1179 i64::try_from(args.data.len()).unwrap_or(i64::MAX),
1180 ))
1181 }
1182 OsFunctionCall::AppendText(args) => {
1183 budget.charge_write(args.data.len())?;
1184 append_bytes(
1185 call,
1186 args.path.as_str(),
1187 args.data.as_bytes(),
1188 filesystem,
1189 config,
1190 budget,
1191 )?;
1192 Ok(MontyObject::Int(
1193 i64::try_from(args.data.chars().count()).unwrap_or(i64::MAX),
1194 ))
1195 }
1196 OsFunctionCall::AppendBytes(args) => {
1197 budget.charge_write(args.data.len())?;
1198 append_bytes(
1199 call,
1200 args.path.as_str(),
1201 &args.data,
1202 filesystem,
1203 config,
1204 budget,
1205 )?;
1206 Ok(MontyObject::Int(
1207 i64::try_from(args.data.len()).unwrap_or(i64::MAX),
1208 ))
1209 }
1210 OsFunctionCall::Open(args) => {
1211 open_file(call, args.path.as_str(), args.mode, filesystem, config)
1212 }
1213 OsFunctionCall::Mkdir(args) => mkdir(
1214 call,
1215 args.path.as_str(),
1216 args.parents,
1217 args.exist_ok,
1218 filesystem,
1219 config,
1220 ),
1221 OsFunctionCall::Unlink(path) => {
1222 let mapped =
1223 map_authorized_path(call, path.as_str(), false, config, &[AccessKind::Delete])?;
1224 vfs(filesystem.unlink(&mapped), path.as_str())?;
1225 Ok(MontyObject::None)
1226 }
1227 OsFunctionCall::Rmdir(path) => {
1228 let mapped =
1229 map_authorized_path(call, path.as_str(), false, config, &[AccessKind::Delete])?;
1230 vfs(filesystem.rmdir(&mapped), path.as_str())?;
1231 Ok(MontyObject::None)
1232 }
1233 OsFunctionCall::Rename(args) => {
1234 let source = map_authorized_path(
1235 call,
1236 args.src.as_str(),
1237 false,
1238 config,
1239 &[AccessKind::RenameSource],
1240 )?;
1241 let destination = map_authorized_path(
1242 call,
1243 args.dst.as_str(),
1244 true,
1245 config,
1246 &[AccessKind::RenameDestination],
1247 )?;
1248 vfs(filesystem.rename(&source, &destination), args.src.as_str())?;
1249 Ok(MontyObject::None)
1250 }
1251 OsFunctionCall::Getenv(args) => Ok(config.environment.get(&args.key).map_or_else(
1252 || args.default.clone(),
1253 |value| MontyObject::String(value.clone()),
1254 )),
1255 OsFunctionCall::GetEnviron => {
1256 let pairs = config
1257 .environment
1258 .iter()
1259 .map(|(key, value)| {
1260 (
1261 MontyObject::String(key.clone()),
1262 MontyObject::String(value.clone()),
1263 )
1264 })
1265 .collect::<Vec<_>>();
1266 Ok(MontyObject::Dict(DictPairs::from(pairs)))
1267 }
1268 OsFunctionCall::DateToday | OsFunctionCall::DateTimeNow(_) => {
1269 Err(CallFailure::Python(call.on_no_handler()))
1270 }
1271 }
1272}
1273
1274fn bool_query(
1275 call: &OsFunctionCall,
1276 raw: &str,
1277 filesystem: &mut VirtualFs,
1278 config: &InProcessConfig,
1279 predicate: impl FnOnce(Option<NodeState>) -> bool,
1280) -> Result<MontyObject, CallFailure> {
1281 check_path_bytes(raw, config)?;
1282 let Ok(path) = config.virtual_root.map_path(raw) else {
1283 return Ok(MontyObject::Bool(false));
1284 };
1285 config
1286 .call_policy
1287 .authorize(&path, AccessKind::MetadataRead)
1288 .map_err(CallFailure::Policy)?;
1289 let state = match filesystem.metadata(&path) {
1290 Ok(state) => Some(state),
1291 Err(VfsError::NotFound { .. }) => None,
1292 Err(source) => return Err(classify_vfs(source, raw)),
1293 };
1294 let _ = call;
1295 Ok(MontyObject::Bool(predicate(state)))
1296}
1297
1298fn read_text(
1299 call: &OsFunctionCall,
1300 raw: &str,
1301 filesystem: &mut VirtualFs,
1302 config: &InProcessConfig,
1303 budget: &mut Budget,
1304) -> Result<MontyObject, CallFailure> {
1305 let bytes = read_file(call, raw, filesystem, config, budget)?;
1306 match String::from_utf8(bytes) {
1307 Ok(text) => Ok(MontyObject::String(text)),
1308 Err(error) => {
1309 let utf8 = error.utf8_error();
1310 let start = utf8.valid_up_to();
1311 let end = utf8
1312 .error_len()
1313 .map_or(error.as_bytes().len(), |length| start + length);
1314 let first_byte = error.as_bytes()[start];
1315 let reason = utf8_error_reason(first_byte, utf8.error_len());
1316 let data = UnicodeErrorData::decode("utf-8", error.as_bytes(), start, end, reason);
1317 Err(CallFailure::Python(
1318 MontyException::new(
1319 ExcType::UnicodeDecodeError,
1320 Some(unicode_decode_error_msg(
1321 "utf-8", first_byte, start, end, reason,
1322 )),
1323 )
1324 .with_data(data),
1325 ))
1326 }
1327 }
1328}
1329
1330fn read_bytes(
1331 call: &OsFunctionCall,
1332 raw: &str,
1333 filesystem: &mut VirtualFs,
1334 config: &InProcessConfig,
1335 budget: &mut Budget,
1336) -> Result<MontyObject, CallFailure> {
1337 read_file(call, raw, filesystem, config, budget).map(MontyObject::Bytes)
1338}
1339
1340fn read_file(
1341 call: &OsFunctionCall,
1342 raw: &str,
1343 filesystem: &mut VirtualFs,
1344 config: &InProcessConfig,
1345 budget: &mut Budget,
1346) -> Result<Vec<u8>, CallFailure> {
1347 let path = map_authorized_path(call, raw, false, config, &[AccessKind::ContentRead])?;
1348 let state = vfs(filesystem.metadata(&path), raw)?;
1349 if state.kind() != NodeKind::File {
1350 return Err(not_regular(raw, state.kind()));
1351 }
1352 budget.charge_read(state.size())?;
1353 vfs(filesystem.read(&path), raw)
1354}
1355
1356fn write_bytes(
1357 call: &OsFunctionCall,
1358 raw: &str,
1359 bytes: &[u8],
1360 filesystem: &mut VirtualFs,
1361 config: &InProcessConfig,
1362) -> Result<(), CallFailure> {
1363 let path = map_authorized_path(
1364 call,
1365 raw,
1366 false,
1367 config,
1368 &[AccessKind::Create, AccessKind::Modify],
1369 )?;
1370 vfs(filesystem.write(&path, bytes), raw)
1371}
1372
1373fn append_bytes(
1374 call: &OsFunctionCall,
1375 raw: &str,
1376 bytes: &[u8],
1377 filesystem: &mut VirtualFs,
1378 config: &InProcessConfig,
1379 budget: &mut Budget,
1380) -> Result<(), CallFailure> {
1381 let path = map_authorized_path(
1382 call,
1383 raw,
1384 false,
1385 config,
1386 &[AccessKind::Create, AccessKind::Modify],
1387 )?;
1388 match filesystem.metadata(&path) {
1389 Ok(state) if state.kind() == NodeKind::File => {
1390 budget.charge_read(state.size())?;
1391 vfs(filesystem.append(&path, bytes), raw)
1392 }
1393 Ok(state) => Err(not_regular(raw, state.kind())),
1394 Err(VfsError::NotFound { .. }) => vfs(filesystem.write(&path, bytes), raw),
1395 Err(source) => Err(classify_vfs(source, raw)),
1396 }
1397}
1398
1399fn read_directory(
1400 call: &OsFunctionCall,
1401 raw: &str,
1402 filesystem: &mut VirtualFs,
1403 config: &InProcessConfig,
1404 budget: &mut Budget,
1405) -> Result<MontyObject, CallFailure> {
1406 let path = map_authorized_path(call, raw, false, config, &[AccessKind::DirectoryRead])?;
1407 let children = vfs(filesystem.read_dir(&path), raw)?;
1408 budget.charge_directory_entries(children.len())?;
1409 Ok(MontyObject::List(
1410 children
1411 .iter()
1412 .filter(|child| {
1413 config
1414 .call_policy
1415 .authorize(child, AccessKind::MetadataRead)
1416 .is_ok()
1417 })
1418 .map(|child| MontyObject::Path(config.virtual_root.present(child)))
1419 .collect(),
1420 ))
1421}
1422
1423fn stat(
1424 call: &OsFunctionCall,
1425 raw: &str,
1426 filesystem: &mut VirtualFs,
1427 config: &InProcessConfig,
1428) -> Result<MontyObject, CallFailure> {
1429 let path = map_authorized_path(call, raw, false, config, &[AccessKind::MetadataRead])?;
1430 let state = vfs(filesystem.metadata(&path), raw)?;
1431 let mtime = match state.content() {
1432 Some(ContentVersion::Stamp(stamp)) => u64::try_from(stamp.mtime_ns)
1433 .map(Duration::from_nanos)
1434 .map_or(0.0, |duration| duration.as_secs_f64()),
1435 _ => 0.0,
1436 };
1437 let mode = i64::from(state.mode());
1438 let size = i64::try_from(state.size()).unwrap_or(i64::MAX);
1439 match state.kind() {
1440 NodeKind::File => Ok(file_stat(mode, size, mtime)),
1441 NodeKind::Directory => Ok(dir_stat(mode, mtime)),
1442 NodeKind::Symlink => Ok(symlink_stat(mode, mtime)),
1443 }
1444}
1445
1446fn absolute_path(
1447 call: &OsFunctionCall,
1448 raw: &str,
1449 config: &InProcessConfig,
1450) -> Result<MontyObject, CallFailure> {
1451 let path = map_call_path(call, raw, false, config)?;
1452 Ok(MontyObject::Path(config.virtual_root.present(&path)))
1453}
1454
1455fn open_file(
1456 call: &OsFunctionCall,
1457 raw: &str,
1458 mode: FileMode,
1459 filesystem: &mut VirtualFs,
1460 config: &InProcessConfig,
1461) -> Result<MontyObject, CallFailure> {
1462 let accesses: &[AccessKind] = match mode {
1463 FileMode::Read(_) => &[AccessKind::ContentRead],
1464 FileMode::ReadUpdate(_) | FileMode::WriteUpdate(_) | FileMode::AppendUpdate(_) => &[
1465 AccessKind::ContentRead,
1466 AccessKind::Create,
1467 AccessKind::Modify,
1468 ],
1469 FileMode::Write(_) | FileMode::Append(_) => &[AccessKind::Create, AccessKind::Modify],
1470 };
1471 let path = map_authorized_path(call, raw, false, config, accesses)?;
1472 match mode {
1473 FileMode::Read(_) | FileMode::ReadUpdate(_) => {
1474 let state = vfs(filesystem.metadata(&path), raw)?;
1475 if state.kind() != NodeKind::File {
1476 return Err(not_regular(raw, state.kind()));
1477 }
1478 }
1479 FileMode::Write(_) | FileMode::WriteUpdate(_) => {
1480 vfs(filesystem.write(&path, &[]), raw)?;
1481 }
1482 FileMode::Append(_) | FileMode::AppendUpdate(_) => match filesystem.metadata(&path) {
1483 Ok(state) if state.kind() == NodeKind::File => {}
1484 Ok(state) => return Err(not_regular(raw, state.kind())),
1485 Err(VfsError::NotFound { .. }) => vfs(filesystem.write(&path, &[]), raw)?,
1486 Err(source) => return Err(classify_vfs(source, raw)),
1487 },
1488 }
1489 Ok(MontyObject::FileHandle(MontyFileHandle {
1490 path: config.virtual_root.present(&path),
1491 mode,
1492 position: 0,
1493 }))
1494}
1495
1496fn mkdir(
1497 call: &OsFunctionCall,
1498 raw: &str,
1499 parents: bool,
1500 exist_ok: bool,
1501 filesystem: &mut VirtualFs,
1502 config: &InProcessConfig,
1503) -> Result<MontyObject, CallFailure> {
1504 let path = map_call_path(call, raw, false, config)?;
1505 authorize_path(config, &path, &[AccessKind::Create, AccessKind::Modify])?;
1506 if parents {
1507 let mut ancestor = path.parent();
1508 while let Some(candidate) = ancestor {
1509 if candidate.is_root() {
1510 break;
1511 }
1512 authorize_path(
1513 config,
1514 &candidate,
1515 &[AccessKind::Create, AccessKind::Modify],
1516 )?;
1517 ancestor = candidate.parent();
1518 }
1519 }
1520 match filesystem.metadata(&path) {
1521 Ok(state) if exist_ok && state.kind() == NodeKind::Directory => {
1522 return Ok(MontyObject::None);
1523 }
1524 Ok(_) => return Err(already_exists(raw)),
1525 Err(VfsError::NotFound { .. }) => {}
1526 Err(source) => return Err(classify_vfs(source, raw)),
1527 }
1528
1529 if !parents {
1530 vfs(filesystem.mkdir(&path, 0o755), raw)?;
1531 return Ok(MontyObject::None);
1532 }
1533
1534 let mut missing = vec![path.clone()];
1535 let mut cursor = path.parent();
1536 while let Some(parent) = cursor {
1537 match filesystem.metadata(&parent) {
1538 Ok(state) if state.kind() == NodeKind::Directory => break,
1539 Ok(_) => return Err(not_directory(raw)),
1540 Err(VfsError::NotFound { .. }) => {
1541 cursor = parent.parent();
1542 missing.push(parent);
1543 }
1544 Err(source) => return Err(classify_vfs(source, raw)),
1545 }
1546 }
1547 for directory in missing.iter().rev() {
1548 vfs(filesystem.mkdir(directory, 0o755), raw)?;
1549 }
1550 Ok(MontyObject::None)
1551}
1552
1553fn map_call_path(
1554 call: &OsFunctionCall,
1555 raw: &str,
1556 destination: bool,
1557 config: &InProcessConfig,
1558) -> Result<VPath, CallFailure> {
1559 check_path_bytes(raw, config)?;
1560 config.virtual_root.map_path(raw).map_err(|source| {
1561 if source == VirtualPathError::NulByte {
1562 CallFailure::Python(MontyException::new(
1563 ExcType::ValueError,
1564 Some(call.embedded_null_message(destination).to_owned()),
1565 ))
1566 } else {
1567 CallFailure::Python(permission_denied(raw))
1568 }
1569 })
1570}
1571
1572fn check_path_bytes(raw: &str, config: &InProcessConfig) -> Result<(), CallFailure> {
1573 let attempted = u64::try_from(raw.len()).unwrap_or(u64::MAX);
1574 let limit = u64::try_from(config.limits.max_path_bytes).unwrap_or(u64::MAX);
1575 if attempted > limit {
1576 Err(CallFailure::Limit(ExecutionLimitExceeded::PathBytes {
1577 limit,
1578 attempted,
1579 }))
1580 } else {
1581 Ok(())
1582 }
1583}
1584
1585fn map_authorized_path(
1586 call: &OsFunctionCall,
1587 raw: &str,
1588 destination: bool,
1589 config: &InProcessConfig,
1590 accesses: &[AccessKind],
1591) -> Result<VPath, CallFailure> {
1592 let path = map_call_path(call, raw, destination, config)?;
1593 authorize_path(config, &path, accesses)?;
1594 Ok(path)
1595}
1596
1597fn authorize_path(
1598 config: &InProcessConfig,
1599 path: &VPath,
1600 accesses: &[AccessKind],
1601) -> Result<(), CallFailure> {
1602 for access in accesses {
1603 config
1604 .call_policy
1605 .authorize(path, *access)
1606 .map_err(CallFailure::Policy)?;
1607 }
1608 Ok(())
1609}
1610
1611fn vfs<T>(result: Result<T, VfsError>, raw: &str) -> Result<T, CallFailure> {
1612 result.map_err(|source| classify_vfs(source, raw))
1613}
1614
1615fn classify_vfs(source: VfsError, raw: &str) -> CallFailure {
1616 let exception = match source {
1617 VfsError::NotFound { .. } => file_not_found(raw),
1618 VfsError::AlreadyExists { .. } => already_exists_exception(raw),
1619 VfsError::NotDirectory { .. } => not_directory_exception(raw),
1620 VfsError::NotFile {
1621 actual: NodeKind::Directory,
1622 ..
1623 }
1624 | VfsError::IsDirectory { .. } => is_directory_exception(raw),
1625 VfsError::NotFile { .. } | VfsError::NotSymlink { .. } | VfsError::RootMutation => {
1626 permission_denied(raw)
1627 }
1628 VfsError::DirectoryNotEmpty { .. } => MontyException::new(
1629 ExcType::OSError,
1630 Some(format!(
1631 "[Errno 39] Directory not empty: {}",
1632 StringRepr(raw)
1633 )),
1634 ),
1635 VfsError::InvalidRename { .. } | VfsError::RenameTypeMismatch { .. } => {
1636 MontyException::new(
1637 ExcType::OSError,
1638 Some(format!("[Errno 22] Invalid argument: {}", StringRepr(raw))),
1639 )
1640 }
1641 internal @ (VfsError::Snapshot(_) | VfsError::Store(_) | VfsError::Path(_)) => {
1642 return CallFailure::InternalVfs(internal);
1643 }
1644 internal => return CallFailure::InternalVfs(internal),
1645 };
1646 CallFailure::Python(exception)
1647}
1648
1649fn file_not_found(raw: &str) -> MontyException {
1650 MontyException::new(
1651 ExcType::FileNotFoundError,
1652 Some(format!(
1653 "[Errno 2] No such file or directory: {}",
1654 StringRepr(raw)
1655 )),
1656 )
1657}
1658
1659fn already_exists(raw: &str) -> CallFailure {
1660 CallFailure::Python(already_exists_exception(raw))
1661}
1662
1663fn already_exists_exception(raw: &str) -> MontyException {
1664 MontyException::new(
1665 ExcType::FileExistsError,
1666 Some(format!("[Errno 17] File exists: {}", StringRepr(raw))),
1667 )
1668}
1669
1670fn not_regular(raw: &str, kind: NodeKind) -> CallFailure {
1671 if kind == NodeKind::Directory {
1672 CallFailure::Python(is_directory_exception(raw))
1673 } else {
1674 CallFailure::Python(permission_denied(raw))
1675 }
1676}
1677
1678fn is_directory_exception(raw: &str) -> MontyException {
1679 MontyException::new(
1680 ExcType::IsADirectoryError,
1681 Some(format!("[Errno 21] Is a directory: {}", StringRepr(raw))),
1682 )
1683}
1684
1685fn not_directory(raw: &str) -> CallFailure {
1686 CallFailure::Python(not_directory_exception(raw))
1687}
1688
1689fn not_directory_exception(raw: &str) -> MontyException {
1690 MontyException::new(
1691 ExcType::NotADirectoryError,
1692 Some(format!("[Errno 20] Not a directory: {}", StringRepr(raw))),
1693 )
1694}
1695
1696fn permission_denied(raw: &str) -> MontyException {
1697 MontyException::new(
1698 ExcType::PermissionError,
1699 Some(format!("[Errno 13] Permission denied: {}", StringRepr(raw))),
1700 )
1701}
1702
1703fn normalize_absolute(input: &str) -> Result<String, VirtualPathError> {
1704 let mut components = Vec::new();
1705 for component in input.split('/') {
1706 match component {
1707 "" | "." => {}
1708 ".." => {
1709 if components.pop().is_none() {
1710 return Err(VirtualPathError::EscapesAbsoluteRoot);
1711 }
1712 }
1713 value => components.push(value),
1714 }
1715 }
1716 if components.is_empty() {
1717 Ok("/".to_owned())
1718 } else {
1719 Ok(format!("/{}", components.join("/")))
1720 }
1721}
1722
1723fn is_windows_prefix(component: &str) -> bool {
1724 let bytes = component.as_bytes();
1725 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
1726}
1727
1728#[cfg(test)]
1729mod tests {
1730 use std::collections::BTreeMap;
1731 use std::error::Error;
1732 use std::fs;
1733 use std::path::{Path, PathBuf};
1734 use std::sync::atomic::{AtomicU64, Ordering};
1735
1736 use monty_types::ExcType;
1737 use vsh_policy::{CallPolicy, DenyReason, PolicyDecision, PolicyInput, TransactionPolicy};
1738 use vsh_store::BlobStore;
1739 use vsh_types::{DiffKind, VPath};
1740 use vsh_vfs::{EffectOrigin, SnapshotBuilder, VfsError, VirtualFs};
1741
1742 use super::{
1743 ExecutionError, ExecutionLimitExceeded, ExecutionLimits, InProcessConfig, InProcessMonty,
1744 MontyObject, MontyType, ResultCompatibility, ResultCompatibilityError, VirtualPathError,
1745 VirtualRoot, VirtualRootError, WorkerFailure, WorkerFailureKind,
1746 validate_result_compatibility,
1747 };
1748
1749 static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1750
1751 struct TestDirectory(PathBuf);
1752
1753 impl TestDirectory {
1754 fn new() -> Self {
1755 let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1756 let path = std::env::temp_dir()
1757 .join(format!("vsh-monty-test-{}-{sequence}", std::process::id()));
1758 fs::create_dir(&path).expect("test directory should be unique");
1759 Self(path)
1760 }
1761
1762 fn path(&self) -> &Path {
1763 &self.0
1764 }
1765 }
1766
1767 impl Drop for TestDirectory {
1768 fn drop(&mut self) {
1769 let _ = fs::remove_dir_all(&self.0);
1770 }
1771 }
1772
1773 fn filesystem(files: &[(&str, &[u8])]) -> (TestDirectory, VirtualFs) {
1774 let directory = TestDirectory::new();
1775 let store = BlobStore::open(directory.path()).expect("blob store should open");
1776 let mut builder = SnapshotBuilder::new(store);
1777 for (path, bytes) in files {
1778 builder
1779 .add_file(
1780 VPath::parse(path).expect("test path should parse"),
1781 bytes,
1782 0o644,
1783 )
1784 .expect("test file should be added");
1785 }
1786 let snapshot = builder.build().expect("snapshot should build");
1787 (directory, VirtualFs::new(snapshot))
1788 }
1789
1790 #[test]
1791 fn virtual_root_maps_only_its_absolute_namespace() {
1792 let root = VirtualRoot::new("/workspace/").expect("root should normalize");
1793 assert_eq!(root.as_str(), "/workspace");
1794 assert_eq!(
1795 root.map_path("/workspace/src/../README.md")
1796 .expect("path should map"),
1797 VPath::parse("README.md").unwrap()
1798 );
1799 assert_eq!(
1800 root.map_path("/etc/passwd"),
1801 Err(VirtualPathError::OutsideRoot)
1802 );
1803 assert_eq!(
1804 root.map_path("/workspace/../../etc/passwd"),
1805 Err(VirtualPathError::EscapesAbsoluteRoot)
1806 );
1807 }
1808
1809 #[test]
1810 fn monty_program_produces_exact_virtual_diff() {
1811 let (_directory, mut filesystem) = filesystem(&[("input.txt", b"hello\n")]);
1812 let outcome = InProcessMonty::default()
1813 .execute(
1814 r"
1815from pathlib import Path
1816source = Path('/workspace/input.txt').read_text()
1817Path('/workspace/out').mkdir()
1818Path('/workspace/out/result.txt').write_text(source.upper())
1819Path('/workspace/input.txt').rename('/workspace/archive.txt')
1820len(source)
1821",
1822 &mut filesystem,
1823 )
1824 .expect("program should execute");
1825
1826 assert_eq!(outcome.value, MontyObject::Int(6));
1827 assert_eq!(outcome.stats.os_calls, 4);
1828 assert_eq!(outcome.stats.read_bytes, 6);
1829 assert_eq!(outcome.stats.write_bytes, 6);
1830 assert!(
1831 filesystem
1832 .effects()
1833 .iter()
1834 .all(|event| event.origin == EffectOrigin::MontyOsCall)
1835 );
1836
1837 let diff = filesystem
1838 .canonical_diff()
1839 .expect("diff should be canonical");
1840 let changes = diff
1841 .entries()
1842 .iter()
1843 .map(|entry| (entry.path.as_str(), entry.kind))
1844 .collect::<Vec<_>>();
1845 assert_eq!(
1846 changes,
1847 vec![
1848 ("archive.txt", DiffKind::Create),
1849 ("input.txt", DiffKind::Delete),
1850 ("out", DiffKind::Create),
1851 ("out/result.txt", DiffKind::Create),
1852 ]
1853 );
1854 assert_eq!(
1855 filesystem
1856 .read(&VPath::parse("out/result.txt").unwrap())
1857 .unwrap(),
1858 b"HELLO\n"
1859 );
1860 }
1861
1862 #[test]
1863 fn monty_tools_and_pathlib_share_one_active_virtual_filesystem() {
1864 let (_directory, mut filesystem) = filesystem(&[]);
1865 let outcome = InProcessMonty::default()
1866 .execute(
1867 r"
1868from pathlib import Path
1869vsh_mkdir('/workspace/out')
1870vsh_write('/workspace/out/tool.txt', 'hello')
1871seen_by_pathlib = Path('/workspace/out/tool.txt').read_text()
1872Path('/workspace/out/pathlib.txt').write_text(seen_by_pathlib.upper())
1873seen_by_tool = vsh_read('/workspace/out/pathlib.txt')
1874(seen_by_pathlib, seen_by_tool, len(vsh_list('/workspace/out')))
1875",
1876 &mut filesystem,
1877 )
1878 .expect("VSH functions and pathlib should interoperate");
1879
1880 assert_eq!(
1881 outcome.value,
1882 MontyObject::Tuple(vec![
1883 MontyObject::String("hello".to_owned()),
1884 MontyObject::String("HELLO".to_owned()),
1885 MontyObject::Int(2),
1886 ])
1887 );
1888 assert_eq!(
1889 filesystem
1890 .read(&VPath::parse("out/pathlib.txt").unwrap())
1891 .unwrap(),
1892 b"HELLO"
1893 );
1894 assert!(
1895 filesystem
1896 .effects()
1897 .iter()
1898 .any(|event| event.origin == EffectOrigin::MontyToolCall)
1899 );
1900 assert!(
1901 filesystem
1902 .effects()
1903 .iter()
1904 .any(|event| event.origin == EffectOrigin::MontyOsCall)
1905 );
1906 }
1907
1908 #[test]
1909 fn monty_tools_copy_glob_search_patch_move_and_remove_virtual_state() {
1910 let (_directory, mut filesystem) = filesystem(&[]);
1911 let outcome = InProcessMonty::default()
1912 .execute(
1913 r"
1914vsh_mkdir('/workspace/src/nested')
1915vsh_write('/workspace/src/a.txt', 'Needle one\n')
1916vsh_write('/workspace/src/nested/b.txt', 'needle two\n')
1917vsh_copy('/workspace/src', '/workspace/copied', recursive=True)
1918paths = vsh_glob('**/*.txt', path='/workspace/copied')
1919hits = vsh_search('needle', path='/workspace/copied', case_sensitive=False)
1920changed = vsh_patch('/workspace/copied/a.txt', 'Needle', 'Found')
1921vsh_move('/workspace/copied/a.txt', '/workspace/copied/renamed.txt')
1922vsh_remove('/workspace/copied/nested', recursive=True)
1923(len(paths), len(hits), changed, vsh_read('/workspace/copied/renamed.txt'), len(vsh_list('/workspace/copied')))
1924",
1925 &mut filesystem,
1926 )
1927 .expect("high-level VSH functions should compose on one overlay");
1928
1929 assert_eq!(
1930 outcome.value,
1931 MontyObject::Tuple(vec![
1932 MontyObject::Int(2),
1933 MontyObject::Int(2),
1934 MontyObject::Int(1),
1935 MontyObject::String("Found one\n".to_owned()),
1936 MontyObject::Int(1),
1937 ])
1938 );
1939 assert_eq!(
1940 filesystem
1941 .read(&VPath::parse("copied/renamed.txt").unwrap())
1942 .unwrap(),
1943 b"Found one\n"
1944 );
1945 assert!(!filesystem.exists(&VPath::parse("copied/nested").unwrap()));
1946 }
1947
1948 #[test]
1949 fn bounded_glob_and_search_stop_walking_after_enough_results() {
1950 let (_directory, mut filesystem) = filesystem(&[
1951 ("a.txt", b"needle"),
1952 ("b.txt", b"needle"),
1953 ("c.txt", b"needle"),
1954 ]);
1955 let outcome = InProcessMonty::default()
1956 .execute(
1957 r"
1958paths = vsh_glob('*.txt', max_results=1)
1959hits = vsh_search('needle', max_results=1)
1960(paths, hits[0]['path'])
1961",
1962 &mut filesystem,
1963 )
1964 .expect("bounded discovery should complete");
1965
1966 assert_eq!(
1967 outcome.value,
1968 MontyObject::Tuple(vec![
1969 MontyObject::List(vec![MontyObject::Path("/workspace/a.txt".to_owned(),)]),
1970 MontyObject::Path("/workspace/a.txt".to_owned()),
1971 ])
1972 );
1973 assert_eq!(outcome.stats.read_bytes, 6);
1974 }
1975
1976 #[test]
1977 fn zero_result_discovery_validates_the_root_without_walking_it() {
1978 let (_directory, mut filesystem) = filesystem(&[("a.txt", b"needle")]);
1979 let outcome = InProcessMonty::default()
1980 .execute(
1981 r"
1982(vsh_glob('*.txt', max_results=0), vsh_search('needle', max_results=0))
1983",
1984 &mut filesystem,
1985 )
1986 .expect("zero-result discovery should remain valid and bounded");
1987
1988 assert_eq!(
1989 outcome.value,
1990 MontyObject::Tuple(vec![MontyObject::List(vec![]), MontyObject::List(vec![])])
1991 );
1992 assert_eq!(outcome.stats.directory_entries, 0);
1993 assert_eq!(outcome.stats.read_bytes, 0);
1994 }
1995
1996 #[test]
1997 fn absolute_host_path_never_falls_back_to_host() {
1998 let (directory, mut filesystem) = filesystem(&[]);
1999 let host_file = directory.path().join("host-secret.txt");
2000 fs::write(&host_file, b"secret").expect("host sentinel should be written");
2001 let code = format!(
2002 "from pathlib import Path\nPath({:?}).exists()",
2003 host_file.to_string_lossy()
2004 );
2005 let outcome = InProcessMonty::default()
2006 .execute(code, &mut filesystem)
2007 .expect("existence check should safely complete");
2008 assert_eq!(outcome.value, MontyObject::Bool(false));
2009 assert_eq!(outcome.stats.read_bytes, 0);
2010 }
2011
2012 #[test]
2013 fn traversal_read_is_denied_without_resuming_host_access() {
2014 let (_directory, mut filesystem) = filesystem(&[]);
2015 let error = InProcessMonty::default()
2016 .execute(
2017 "from pathlib import Path\nPath('/workspace/../../etc/passwd').read_text()",
2018 &mut filesystem,
2019 )
2020 .expect_err("traversal must fail");
2021 let ExecutionError::Monty { source, .. } = error else {
2022 panic!("expected a Monty exception")
2023 };
2024 assert_eq!(source.exc_type(), ExcType::PermissionError);
2025 }
2026
2027 #[test]
2028 fn independent_os_call_limit_is_hard() {
2029 let (_directory, mut filesystem) = filesystem(&[]);
2030 let limits = ExecutionLimits {
2031 max_os_calls: 2,
2032 ..ExecutionLimits::default()
2033 };
2034 let engine = InProcessMonty::new(InProcessConfig::default().with_limits(limits));
2035 let error = engine
2036 .execute(
2037 r"
2038from pathlib import Path
2039Path('/workspace/a').exists()
2040Path('/workspace/b').exists()
2041Path('/workspace/c').exists()
2042",
2043 &mut filesystem,
2044 )
2045 .expect_err("third call must be rejected");
2046 assert!(matches!(
2047 error,
2048 ExecutionError::Limit(source)
2049 if *source == ExecutionLimitExceeded::OsCalls { limit: 2, attempted: 3 }
2050 ));
2051 }
2052
2053 #[test]
2054 fn high_level_vsh_tools_share_the_hard_os_call_budget() {
2055 let (_directory, mut filesystem) = filesystem(&[]);
2056 let limits = ExecutionLimits {
2057 max_os_calls: 2,
2058 ..ExecutionLimits::default()
2059 };
2060 let engine = InProcessMonty::new(InProcessConfig::default().with_limits(limits));
2061 let error = engine
2062 .execute(
2063 r"
2064vsh_write('/workspace/a.txt', 'a')
2065vsh_read('/workspace/a.txt')
2066vsh_list('/workspace')
2067",
2068 &mut filesystem,
2069 )
2070 .expect_err("the third high-level VSH call must be rejected");
2071 assert!(matches!(
2072 error,
2073 ExecutionError::Limit(source)
2074 if *source == ExecutionLimitExceeded::OsCalls { limit: 2, attempted: 3 }
2075 ));
2076 }
2077
2078 #[test]
2079 fn per_call_payload_and_path_limits_stop_before_vfs_mutation() {
2080 let (_directory, mut filesystem) = filesystem(&[("input.txt", b"four")]);
2081 let limits = ExecutionLimits {
2082 max_io_call_bytes: 3,
2083 ..ExecutionLimits::default()
2084 };
2085 let error = InProcessMonty::new(InProcessConfig::default().with_limits(limits))
2086 .execute(
2087 "from pathlib import Path\nPath('/workspace/input.txt').read_bytes()",
2088 &mut filesystem,
2089 )
2090 .unwrap_err();
2091 assert!(matches!(
2092 error,
2093 ExecutionError::Limit(source)
2094 if *source == ExecutionLimitExceeded::ReadCallBytes {
2095 limit: 3,
2096 attempted: 4,
2097 }
2098 ));
2099 let error = InProcessMonty::new(InProcessConfig::default().with_limits(limits))
2100 .execute(
2101 "from pathlib import Path\nPath('/workspace/output.txt').write_text('four')",
2102 &mut filesystem,
2103 )
2104 .unwrap_err();
2105 assert!(matches!(
2106 error,
2107 ExecutionError::Limit(source)
2108 if *source == ExecutionLimitExceeded::WriteCallBytes {
2109 limit: 3,
2110 attempted: 4,
2111 }
2112 ));
2113
2114 let limits = ExecutionLimits {
2115 max_path_bytes: 8,
2116 ..ExecutionLimits::default()
2117 };
2118 let error = InProcessMonty::new(InProcessConfig::default().with_limits(limits))
2119 .execute(
2120 "from pathlib import Path\nPath('/workspace/too-long').write_text('x')",
2121 &mut filesystem,
2122 )
2123 .unwrap_err();
2124 assert!(matches!(
2125 error,
2126 ExecutionError::Limit(source)
2127 if matches!(*source, ExecutionLimitExceeded::PathBytes { limit: 8, .. })
2128 ));
2129 assert!(filesystem.canonical_diff().unwrap().is_empty());
2130 }
2131
2132 #[test]
2133 fn program_result_and_exception_outputs_have_independent_hard_caps() {
2134 let (_directory, mut filesystem) = filesystem(&[]);
2135 let program_limits = ExecutionLimits {
2136 max_program_bytes: 4,
2137 ..ExecutionLimits::default()
2138 };
2139 let error = InProcessMonty::new(InProcessConfig::default().with_limits(program_limits))
2140 .execute("'too long'", &mut filesystem)
2141 .unwrap_err();
2142 assert!(matches!(
2143 error,
2144 ExecutionError::Limit(source)
2145 if matches!(*source, ExecutionLimitExceeded::ProgramBytes { limit: 4, .. })
2146 ));
2147
2148 let result_limits = ExecutionLimits {
2149 max_result_bytes: 128,
2150 ..ExecutionLimits::default()
2151 };
2152 let error = InProcessMonty::new(InProcessConfig::default().with_limits(result_limits))
2153 .execute("'x' * 1_000", &mut filesystem)
2154 .unwrap_err();
2155 assert!(matches!(
2156 error,
2157 ExecutionError::Limit(source)
2158 if matches!(*source, ExecutionLimitExceeded::ResultBytes { limit: 128, .. })
2159 ));
2160
2161 let exception_limits = ExecutionLimits {
2162 max_exception_bytes: 128,
2163 ..ExecutionLimits::default()
2164 };
2165 let error = InProcessMonty::new(InProcessConfig::default().with_limits(exception_limits))
2166 .execute("raise ValueError('x' * 1_000)", &mut filesystem)
2167 .unwrap_err();
2168 assert!(matches!(
2169 error,
2170 ExecutionError::Limit(source)
2171 if matches!(*source, ExecutionLimitExceeded::ExceptionBytes { limit: 128, .. })
2172 ));
2173 }
2174
2175 #[test]
2176 fn security_digest_changes_with_synthetic_environment_and_limits() {
2177 let base = InProcessConfig::default();
2178 let mut environment = BTreeMap::new();
2179 environment.insert("PWD".to_owned(), "/workspace".to_owned());
2180 let changed_environment = base.clone().with_environment(environment);
2181 let changed_limit = base.clone().with_limits(ExecutionLimits {
2182 max_os_calls: base.limits().max_os_calls - 1,
2183 ..base.limits()
2184 });
2185
2186 assert_ne!(
2187 base.security_digest(),
2188 changed_environment.security_digest()
2189 );
2190 assert_ne!(base.security_digest(), changed_limit.security_digest());
2191 assert_eq!(
2192 base.security_digest(),
2193 InProcessConfig::default().security_digest()
2194 );
2195 }
2196
2197 #[test]
2198 fn environment_is_synthetic_and_secret_free() {
2199 let (_directory, mut filesystem) = filesystem(&[]);
2200 let outcome = InProcessMonty::default()
2201 .execute(
2202 "import os\n(os.getenv('PWD'), os.getenv('UNDECLARED_SECRET', 'missing'))",
2203 &mut filesystem,
2204 )
2205 .expect("synthetic environment should execute");
2206 assert_eq!(
2207 outcome.value,
2208 MontyObject::Tuple(vec![
2209 MontyObject::String("/workspace".to_owned()),
2210 MontyObject::String("missing".to_owned()),
2211 ])
2212 );
2213 }
2214
2215 #[test]
2216 fn caught_secret_read_never_reaches_vfs_and_forces_final_deny() {
2217 let (_directory, mut filesystem) = filesystem(&[(".env", b"TOKEN=host-secret\n")]);
2218 let outcome = InProcessMonty::default()
2219 .execute(
2220 r"
2221from pathlib import Path
2222try:
2223 Path('/workspace/.env').read_text()
2224except PermissionError:
2225 Path('/workspace/safe.txt').write_text('continued')
2226'done'
2227",
2228 &mut filesystem,
2229 )
2230 .expect("sandboxed code may catch the policy exception");
2231
2232 assert_eq!(outcome.value, MontyObject::String("done".to_owned()));
2233 assert_eq!(outcome.stats.read_bytes, 0);
2234 assert_eq!(outcome.stats.denied_accesses, 1);
2235 assert_eq!(outcome.denied_accesses[0].path.as_str(), ".env");
2236 assert!(
2237 !filesystem
2238 .read_set()
2239 .contains_key(&VPath::parse(".env").unwrap())
2240 );
2241
2242 let diff = filesystem.canonical_diff().unwrap();
2243 let decision = TransactionPolicy::default().evaluate(PolicyInput {
2244 diff: &diff,
2245 effects: filesystem.effects(),
2246 denied_accesses: &outcome.denied_accesses,
2247 base_node_count: 2,
2248 });
2249 assert!(matches!(
2250 decision,
2251 PolicyDecision::Deny(manifest)
2252 if matches!(manifest.reason, DenyReason::ProtectedAccessAttempt(_))
2253 ));
2254 }
2255
2256 #[test]
2257 fn protected_directory_contents_are_hidden_from_listing_and_direct_reads() {
2258 let directory = TestDirectory::new();
2259 let store = BlobStore::open(directory.path()).expect("blob store should open");
2260 let mut builder = SnapshotBuilder::new(store);
2261 builder
2262 .add_directory(VPath::parse(".env").unwrap(), 0o755)
2263 .unwrap();
2264 builder
2265 .add_file(VPath::parse(".env/token").unwrap(), b"host-secret", 0o600)
2266 .unwrap();
2267 builder
2268 .add_file(VPath::parse("safe.txt").unwrap(), b"safe", 0o644)
2269 .unwrap();
2270 let mut filesystem = VirtualFs::new(builder.build().unwrap());
2271
2272 let outcome = InProcessMonty::default()
2273 .execute(
2274 r"
2275from pathlib import Path
2276visible = list(Path('/workspace').iterdir())
2277try:
2278 Path('/workspace/.env/token').read_text()
2279except PermissionError:
2280 pass
2281visible
2282",
2283 &mut filesystem,
2284 )
2285 .expect("protected direct access may be caught without revealing the listing");
2286
2287 assert_eq!(
2288 outcome.value,
2289 MontyObject::List(vec![MontyObject::Path("/workspace/safe.txt".to_owned())])
2290 );
2291 assert_eq!(outcome.stats.read_bytes, 0);
2292 assert_eq!(outcome.denied_accesses.len(), 1);
2293 assert_eq!(outcome.denied_accesses[0].path.as_str(), ".env/token");
2294 }
2295
2296 #[test]
2297 fn recursive_mkdir_authorizes_every_parent_before_virtual_mutation() {
2298 let (_directory, mut filesystem) = filesystem(&[]);
2299 let policy = CallPolicy::new(vec![
2300 vsh_policy::ProtectedRule::new("blocked", vsh_policy::AccessSet::ALL).unwrap(),
2301 ]);
2302 let config = InProcessConfig::default().with_call_policy(policy);
2303 let outcome = InProcessMonty::new(config)
2304 .execute(
2305 r"
2306from pathlib import Path
2307try:
2308 Path('/workspace/blocked/child').mkdir(parents=True)
2309except PermissionError:
2310 pass
2311'contained'
2312",
2313 &mut filesystem,
2314 )
2315 .expect("the protected parent denial may be caught by sandboxed code");
2316
2317 assert_eq!(outcome.value, MontyObject::String("contained".to_owned()));
2318 assert_eq!(outcome.denied_accesses[0].path.as_str(), "blocked");
2319 assert!(filesystem.canonical_diff().unwrap().is_empty());
2320 }
2321
2322 #[test]
2323 fn caught_vsh_tool_denial_is_retained_for_the_final_policy() {
2324 let (_directory, mut filesystem) = filesystem(&[]);
2325 let policy = CallPolicy::new(vec![
2326 vsh_policy::ProtectedRule::new("blocked", vsh_policy::AccessSet::ALL).unwrap(),
2327 ]);
2328 let config = InProcessConfig::default().with_call_policy(policy);
2329 let outcome = InProcessMonty::new(config)
2330 .execute(
2331 r"
2332try:
2333 vsh_write('/workspace/blocked', 'secret')
2334except PermissionError:
2335 pass
2336'contained'
2337",
2338 &mut filesystem,
2339 )
2340 .expect("sandboxed code may catch a VSH tool policy exception");
2341
2342 assert_eq!(outcome.value, MontyObject::String("contained".to_owned()));
2343 assert_eq!(outcome.stats.denied_accesses, 1);
2344 assert_eq!(outcome.denied_accesses[0].path.as_str(), "blocked");
2345 assert!(filesystem.canonical_diff().unwrap().is_empty());
2346
2347 let diff = filesystem.canonical_diff().unwrap();
2348 let decision = TransactionPolicy::default().evaluate(PolicyInput {
2349 diff: &diff,
2350 effects: filesystem.effects(),
2351 denied_accesses: &outcome.denied_accesses,
2352 base_node_count: 1,
2353 });
2354 assert!(matches!(
2355 decision,
2356 PolicyDecision::Deny(manifest)
2357 if matches!(manifest.reason, DenyReason::ProtectedAccessAttempt(_))
2358 ));
2359 }
2360
2361 #[test]
2362 fn direct_vsh_search_retains_a_content_only_policy_denial() {
2363 let (_directory, mut filesystem) = filesystem(&[("secret.txt", b"needle")]);
2364 let policy = CallPolicy::new(vec![
2365 vsh_policy::ProtectedRule::new("secret.txt", vsh_policy::AccessSet::CONTENT_READ)
2366 .unwrap(),
2367 ]);
2368 let config = InProcessConfig::default().with_call_policy(policy);
2369 let outcome = InProcessMonty::new(config)
2370 .execute(
2371 r"
2372try:
2373 vsh_search('needle', path='/workspace/secret.txt')
2374except PermissionError:
2375 pass
2376'contained'
2377",
2378 &mut filesystem,
2379 )
2380 .expect("sandboxed code may catch a direct search policy exception");
2381
2382 assert_eq!(outcome.value, MontyObject::String("contained".to_owned()));
2383 assert_eq!(outcome.stats.denied_accesses, 1);
2384 assert_eq!(outcome.stats.read_bytes, 0);
2385 assert_eq!(outcome.denied_accesses[0].path.as_str(), "secret.txt");
2386 }
2387
2388 #[test]
2389 fn recursive_vsh_remove_preflights_the_entire_tree_before_mutation() {
2390 let directory = TestDirectory::new();
2391 let store = BlobStore::open(directory.path()).expect("blob store should open");
2392 let mut builder = SnapshotBuilder::new(store);
2393 builder
2394 .add_directory(VPath::parse("tree").unwrap(), 0o755)
2395 .unwrap();
2396 builder
2397 .add_directory(VPath::parse("tree/blocked").unwrap(), 0o755)
2398 .unwrap();
2399 builder
2400 .add_file(
2401 VPath::parse("tree/blocked/secret.txt").unwrap(),
2402 b"secret",
2403 0o600,
2404 )
2405 .unwrap();
2406 builder
2407 .add_file(VPath::parse("tree/safe.txt").unwrap(), b"safe", 0o644)
2408 .unwrap();
2409 let mut filesystem = VirtualFs::new(builder.build().unwrap());
2410 let policy = CallPolicy::new(vec![
2411 vsh_policy::ProtectedRule::new("tree/blocked", vsh_policy::AccessSet::ALL).unwrap(),
2412 ]);
2413 let config = InProcessConfig::default().with_call_policy(policy);
2414 let outcome = InProcessMonty::new(config)
2415 .execute(
2416 r"
2417try:
2418 vsh_remove('/workspace/tree', recursive=True)
2419except PermissionError:
2420 pass
2421'contained'
2422",
2423 &mut filesystem,
2424 )
2425 .expect("sandboxed code may catch a recursive-delete policy exception");
2426
2427 assert_eq!(outcome.value, MontyObject::String("contained".to_owned()));
2428 assert_eq!(outcome.stats.denied_accesses, 1);
2429 assert_eq!(outcome.denied_accesses[0].path.as_str(), "tree/blocked");
2430 assert!(filesystem.canonical_diff().unwrap().is_empty());
2431 assert!(filesystem.exists(&VPath::parse("tree/safe.txt").unwrap()));
2432 assert!(filesystem.exists(&VPath::parse("tree/blocked/secret.txt").unwrap()));
2433 }
2434
2435 #[test]
2436 fn python_result_validation_rejects_unprojectable_types_and_depth() {
2437 let nested_type = MontyObject::List(vec![MontyObject::Type(MontyType::Function)]);
2438 assert_eq!(
2439 validate_result_compatibility(&nested_type, ResultCompatibility::Python),
2440 Err(ResultCompatibilityError::TypeObject {
2441 name: "function".to_owned(),
2442 })
2443 );
2444 assert!(validate_result_compatibility(&nested_type, ResultCompatibility::Native).is_ok());
2445 assert!(
2446 validate_result_compatibility(
2447 &MontyObject::Type(MontyType::Int),
2448 ResultCompatibility::Python,
2449 )
2450 .is_ok()
2451 );
2452
2453 let mut too_deep = MontyObject::None;
2454 for _ in 0..200 {
2455 too_deep = MontyObject::List(vec![too_deep]);
2456 }
2457 assert_eq!(
2458 validate_result_compatibility(&too_deep, ResultCompatibility::Python),
2459 Err(ResultCompatibilityError::Depth {
2460 limit: 200,
2461 attempted: 201,
2462 })
2463 );
2464 }
2465
2466 #[test]
2467 fn mkdir_parents_and_open_append_use_only_virtual_state() {
2468 let (_directory, mut filesystem) = filesystem(&[]);
2469 let outcome = InProcessMonty::default()
2470 .execute(
2471 r"
2472from pathlib import Path
2473Path('/workspace/a/b').mkdir(parents=True)
2474with open('/workspace/a/b/value.txt', 'a') as handle:
2475 handle.write('one')
2476with open('/workspace/a/b/value.txt', 'a') as handle:
2477 handle.write('two')
2478Path('/workspace/a/b/value.txt').read_text()
2479",
2480 &mut filesystem,
2481 )
2482 .expect("open/append flow should execute");
2483 assert_eq!(outcome.value, MontyObject::String("onetwo".to_owned()));
2484 assert_eq!(outcome.stats.write_bytes, 6);
2485 }
2486
2487 #[test]
2488 fn public_path_and_limit_errors_keep_distinct_bounded_diagnostics() {
2489 let root_errors = [
2490 VirtualRootError::NotAbsolute,
2491 VirtualRootError::ParentComponent,
2492 VirtualRootError::NulByte,
2493 VirtualRootError::PlatformSeparator,
2494 VirtualRootError::PlatformPrefix,
2495 ];
2496 assert_eq!(
2497 root_errors
2498 .map(|error| error.to_string())
2499 .into_iter()
2500 .collect::<std::collections::BTreeSet<_>>()
2501 .len(),
2502 root_errors.len()
2503 );
2504
2505 let path_errors = [
2506 VirtualPathError::Empty,
2507 VirtualPathError::NulByte,
2508 VirtualPathError::EscapesAbsoluteRoot,
2509 VirtualPathError::OutsideRoot,
2510 VirtualPathError::InvalidRelative(VPath::parse("").unwrap_err()),
2511 ];
2512 for error in path_errors {
2513 assert!(!error.to_string().is_empty());
2514 assert_eq!(
2515 Error::source(&error).is_some(),
2516 matches!(error, VirtualPathError::InvalidRelative(_))
2517 );
2518 }
2519
2520 let limits = [
2521 ExecutionLimitExceeded::ProgramBytes {
2522 limit: 1,
2523 attempted: 2,
2524 },
2525 ExecutionLimitExceeded::OsCalls {
2526 limit: 1,
2527 attempted: 2,
2528 },
2529 ExecutionLimitExceeded::ReadBytes {
2530 limit: 1,
2531 attempted: 2,
2532 },
2533 ExecutionLimitExceeded::WriteBytes {
2534 limit: 1,
2535 attempted: 2,
2536 },
2537 ExecutionLimitExceeded::ReadCallBytes {
2538 limit: 1,
2539 attempted: 2,
2540 },
2541 ExecutionLimitExceeded::WriteCallBytes {
2542 limit: 1,
2543 attempted: 2,
2544 },
2545 ExecutionLimitExceeded::PathBytes {
2546 limit: 1,
2547 attempted: 2,
2548 },
2549 ExecutionLimitExceeded::DirectoryEntries {
2550 limit: 1,
2551 attempted: 2,
2552 },
2553 ExecutionLimitExceeded::OutputBytes {
2554 limit: 1,
2555 attempted: 2,
2556 },
2557 ExecutionLimitExceeded::ResultBytes {
2558 limit: 1,
2559 attempted: 2,
2560 },
2561 ExecutionLimitExceeded::ExceptionBytes {
2562 limit: 1,
2563 attempted: 2,
2564 },
2565 ];
2566 assert_eq!(
2567 limits
2568 .map(|error| error.to_string())
2569 .into_iter()
2570 .collect::<std::collections::BTreeSet<_>>()
2571 .len(),
2572 limits.len()
2573 );
2574 }
2575
2576 #[test]
2577 fn public_result_and_execution_errors_keep_distinct_diagnostics() {
2578 let compatibility_errors = [
2579 ResultCompatibilityError::Depth {
2580 limit: 1,
2581 attempted: 2,
2582 },
2583 ResultCompatibilityError::TypeObject {
2584 name: "unsupported".to_owned(),
2585 },
2586 ];
2587 assert_ne!(
2588 compatibility_errors[0].to_string(),
2589 compatibility_errors[1].to_string()
2590 );
2591
2592 let execution_errors = [
2593 ExecutionError::Limit(Box::new(ExecutionLimitExceeded::OsCalls {
2594 limit: 1,
2595 attempted: 2,
2596 })),
2597 ExecutionError::InternalVfs(Box::new(VfsError::RootMutation)),
2598 ExecutionError::UnsupportedSuspension {
2599 kind: "call",
2600 name: Some("name".to_owned()),
2601 },
2602 ExecutionError::UnsupportedSuspension {
2603 kind: "call",
2604 name: None,
2605 },
2606 ExecutionError::Worker(Box::new(WorkerFailure {
2607 kind: WorkerFailureKind::Protocol,
2608 detail: "bounded".to_owned(),
2609 })),
2610 ];
2611 for error in execution_errors {
2612 assert!(!error.to_string().is_empty());
2613 }
2614 }
2615}