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