monty_types/os.rs
1//! OS-level operations that require host system access.
2//!
3//! Defines [`OsFunctionCall`] — a tagged dispatch value whose variants carry
4//! the typed args each OS-call accepts. Sandboxed code suspends with one of
5//! these; the host (a `MountTable`, an `os` callback) decides whether to
6//! permit it. The interpreter itself never performs I/O.
7//!
8//! The fs/ layer matches on the enum directly (no value introspection);
9//! host bindings get a generic `(positional, keyword)` view via
10//! [`OsFunctionCall::to_args`].
11
12use std::{borrow::Cow, fmt, ops::Deref, time::Duration};
13
14use crate::{
15 args::ToArgs,
16 exceptions::{ExcType, MontyException},
17 file_mode::FileMode,
18 format::StringRepr,
19 graph::{MontyGraph, MontyNode, NodeId},
20 object::{CallArgs, MontyObject, MontyTimeZone},
21 scan::ScanArgs,
22 unstable::{self, PushValue},
23 virtual_path::normalize_virtual_path,
24};
25// =============================================================================
26// OsFunctionCall — the central public dispatch value.
27// =============================================================================
28
29/// [`OsFunctionCall::Import`]'s dispatch name. Hosts resolve it from their modules before other OS handlers.
30pub const IMPORT_CALL: &str = "import";
31
32/// [`OsFunctionCall::ImportStubs`]'s dispatch name, handled like [`IMPORT_CALL`].
33pub const IMPORT_STUBS_CALL: &str = "import_stubs";
34
35/// Reserved pre-feed [`OsFunctionCall::ImportStubs`] ID, outside the interpreter's IDs counted from zero.
36pub const IMPORT_STUBS_CALL_ID: u32 = u32::MAX;
37
38/// Tagged dispatch value for OS-level operations.
39///
40/// Each variant carries the strongly-typed args/kwargs the corresponding OS
41/// call needs. The fs/ layer matches on this enum directly (no value
42/// introspection); host bindings get a generic `(positional, keyword)` view
43/// via [`OsFunctionCall::to_args`].
44///
45/// See the module docs for how to add a new variant.
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, strum::IntoStaticStr)]
47pub enum OsFunctionCall {
48 // ---- FS read / check (single path) ------------------------------------
49 /// Check if a path exists.
50 #[strum(serialize = "Path.exists")]
51 Exists(MontyPath),
52 /// Check if path is a regular file.
53 #[strum(serialize = "Path.is_file")]
54 IsFile(MontyPath),
55 /// Check if path is a directory.
56 #[strum(serialize = "Path.is_dir")]
57 IsDir(MontyPath),
58 /// Check if path is a symbolic link.
59 #[strum(serialize = "Path.is_symlink")]
60 IsSymlink(MontyPath),
61 /// Read file contents as text.
62 #[strum(serialize = "Path.read_text")]
63 ReadText(MontyPath),
64 /// Read file contents as bytes.
65 #[strum(serialize = "Path.read_bytes")]
66 ReadBytes(MontyPath),
67 /// `stat()` — return a stat result tuple.
68 #[strum(serialize = "Path.stat")]
69 Stat(MontyPath),
70 /// List directory contents.
71 #[strum(serialize = "Path.iterdir")]
72 Iterdir(MontyPath),
73 /// Read a bounded subtree in one call, for `os.scandir`, `os.walk`,
74 /// `Path.walk`, `Path.glob` and `Path.rglob` (see [`crate::scan`]).
75 #[strum(serialize = "Path.scan")]
76 Scan(ScanArgs),
77 /// Resolve symlinks and return absolute path.
78 #[strum(serialize = "Path.resolve")]
79 Resolve(MontyPath),
80 /// Absolute path without symlink resolution.
81 #[strum(serialize = "Path.absolute")]
82 Absolute(MontyPath),
83
84 // ---- FS write (path + data) -------------------------------------------
85 /// Write text to file (truncating).
86 #[strum(serialize = "Path.write_text")]
87 WriteText(PathStringDataArgs),
88 /// Append text to file.
89 #[strum(serialize = "Path.append_text")]
90 AppendText(PathStringDataArgs),
91 /// Write bytes to file (truncating).
92 #[strum(serialize = "Path.write_bytes")]
93 WriteBytes(PathBytesDataArgs),
94 /// Append bytes to file.
95 #[strum(serialize = "Path.append_bytes")]
96 AppendBytes(PathBytesDataArgs),
97
98 // ---- FS mutate (custom shapes) ----------------------------------------
99 /// Open a file. The host performs the open-time effect (truncate for
100 /// `w`/`w+`, create-if-missing for `a`/`a+`, existence check for `r`/`r+`)
101 /// and returns a [`MontyFileHandle`](crate::MontyFileHandle) — it never holds a live OS
102 /// handle across calls.
103 #[strum(serialize = "open")]
104 Open(OpenCallArgs),
105 /// Create directory (`parents`/`exist_ok` kwargs).
106 #[strum(serialize = "Path.mkdir")]
107 Mkdir(MkdirCallArgs),
108 /// Remove file.
109 #[strum(serialize = "Path.unlink")]
110 Unlink(MontyPath),
111 /// Remove directory.
112 #[strum(serialize = "Path.rmdir")]
113 Rmdir(MontyPath),
114 /// Rename / move (src → dst).
115 #[strum(serialize = "Path.rename")]
116 Rename(RenameCallArgs),
117
118 // ---- Non-FS -----------------------------------------------------------
119 /// Get an environment variable value.
120 #[strum(serialize = "os.getenv")]
121 Getenv(GetenvArgs),
122 /// Get the entire environment as a dictionary.
123 #[strum(serialize = "os.environ")]
124 GetEnviron,
125 /// Get today's date from the host system (for `date.today()`).
126 #[strum(serialize = "date.today")]
127 DateToday,
128 /// Get the current date/time from the host system (for `datetime.now(tz=...)`).
129 /// Carries the timezone argument, `None` for a naive result.
130 #[strum(serialize = "datetime.now")]
131 DateTimeNow(Option<MontyTimeZone>),
132 /// Read `size` bytes of entropy from the host (for `os.urandom(size)`, and
133 /// how the `random` module seeds an unseeded generator).
134 #[strum(serialize = "os.urandom")]
135 Urandom(UrandomArgs),
136 /// Read the host clock as `time.time()` does: seconds since the Unix
137 /// epoch, answered with [`MontyObject::float`]. Every clock-reading `time`
138 /// function arrives here; [`TimeCaller`], the call's only argument, says which.
139 #[strum(serialize = "time.time")]
140 Time(TimeCaller),
141 /// `time.sleep(seconds)` under `SleepMode::CallHost` — the host's `os`
142 /// handler waits, then answers with any value (`time.sleep` discards it
143 /// and evaluates to `None`).
144 #[strum(serialize = "time.sleep")]
145 Sleep(Duration),
146 /// `time.sleep(seconds)` under `SleepMode::System`, capped at its maximum.
147 /// The host charges `max_total_sleep`, waits and returns `None` without its
148 /// `os` handler. The distinct name identifies the policy for the host.
149 #[strum(serialize = "system.sleep")]
150 SystemSleep(Duration),
151 /// `asyncio.sleep(delay)` — like [`Sleep`](Self::Sleep), except the
152 /// sandbox turns the answer into an awaitable, so a host that runs an
153 /// event loop should answer with a future (`ExtFunctionResult::Future`)
154 /// and resolve it when the delay elapses, letting sibling tasks run
155 /// meanwhile. The answer's value is ignored: the sandbox keeps the
156 /// `result` argument itself and produces it from the `await`.
157 #[strum(serialize = "asyncio.sleep")]
158 AsyncSleep(Duration),
159 /// `asyncio.sleep(delay)` under `SleepMode::System`: the awaitable form of
160 /// [`SystemSleep`](Self::SystemSleep), which a host running an event loop
161 /// answers with a future it resolves once the delay elapses.
162 #[strum(serialize = "system.async_sleep")]
163 AsyncSystemSleep(Duration),
164
165 // ---- Host modules ------------------------------------------------------
166 /// Requests a missing module by top-level name. The host returns its value, an exception,
167 /// or `not_found` / `not_handled` to raise `ModuleNotFoundError`.
168 /// `from m import a` and `import m.sub` then load attributes from that value.
169 #[strum(serialize = "import")] // IMPORT_CALL
170 Import(String),
171 /// Requests stubs for previously unrequested top-level imports before type-checking a feed.
172 /// The host returns a dict of dotted module paths to stub source (or `None`),
173 /// or `not_found` / `not_handled` for no stubs.
174 #[strum(serialize = "import_stubs")] // IMPORT_STUBS_CALL
175 ImportStubs(Vec<String>),
176}
177
178impl OsFunctionCall {
179 /// Whether this [`name`](Self::name) accepts `ExtFunctionResult::Future`,
180 /// letting other tasks run until the host resolves it. Only `asyncio.sleep`
181 /// qualifies, in either sleep mode; all other calls require an immediate answer.
182 #[must_use]
183 pub fn accepts_future(name: &str) -> bool {
184 matches!(name, "asyncio.sleep" | "system.async_sleep")
185 }
186
187 /// Stable string name for this OS function — surfaces in
188 /// [`Self::on_no_handler`] errors, host `os` callbacks, and serialised
189 /// snapshots. The strum `serialize` string on each variant.
190 #[must_use]
191 pub fn name(&self) -> &'static str {
192 self.into()
193 }
194
195 /// Projects this call's args into the [`CallArgs`] delivered to a host
196 /// callback, with lexically normalized paths. Empty paths stay empty. The
197 /// interpreter checks NUL bytes before dispatch; hosts constructing calls
198 /// must use [`Self::check_path_null_bytes`] first. Mounts must validate
199 /// length limits on the original typed call.
200 #[must_use]
201 pub fn to_args(mut self) -> CallArgs {
202 for path in self.fs_paths_mut() {
203 if !path.is_empty()
204 && let Cow::Owned(normalized) = normalize_virtual_path(path)
205 {
206 *path = MontyPath::new(normalized);
207 }
208 }
209 match self {
210 // Single-path variants — just the path in positionals.
211 Self::Exists(p)
212 | Self::IsFile(p)
213 | Self::IsDir(p)
214 | Self::IsSymlink(p)
215 | Self::ReadText(p)
216 | Self::ReadBytes(p)
217 | Self::Stat(p)
218 | Self::Iterdir(p)
219 | Self::Resolve(p)
220 | Self::Absolute(p)
221 | Self::Unlink(p)
222 | Self::Rmdir(p) => single_arg(p),
223 // Multi-field variants delegate to their derived `ToArgs`.
224 Self::WriteText(a) | Self::AppendText(a) => a.to_args(),
225 Self::WriteBytes(a) | Self::AppendBytes(a) => a.to_args(),
226 Self::Open(a) => a.to_args(),
227 Self::Mkdir(a) => a.to_args(),
228 Self::Rename(a) => a.to_args(),
229 Self::Scan(a) => a.to_args(),
230 Self::Getenv(a) => a.to_args(),
231 Self::Urandom(a) => a.to_args(),
232 // Unit & single-value non-FS variants.
233 Self::GetEnviron | Self::DateToday => CallArgs::new(),
234 Self::Time(caller) => single_arg(caller),
235 Self::DateTimeNow(tz) => single_arg(tz.map_or(MontyNode::None, MontyNode::TimeZone)),
236 Self::Sleep(delay) | Self::SystemSleep(delay) | Self::AsyncSleep(delay) | Self::AsyncSystemSleep(delay) => {
237 single_arg(MontyNode::Float(delay.as_secs_f64()))
238 }
239 Self::Import(name) => single_arg(MontyObject::string(name)),
240 Self::ImportStubs(names) => single_arg(MontyObject::list(names.into_iter().map(MontyObject::string))),
241 }
242 }
243
244 /// Whether this call mutates filesystem state — the read-only-mount gate.
245 /// `Open`'s write-ness is mode-dependent (`w`/`w+`/`a`/`a+` write; `r`/`r+`
246 /// don't).
247 #[must_use]
248 pub fn is_write(&self) -> bool {
249 match self {
250 Self::WriteText(_)
251 | Self::WriteBytes(_)
252 | Self::AppendText(_)
253 | Self::AppendBytes(_)
254 | Self::Mkdir(_)
255 | Self::Unlink(_)
256 | Self::Rmdir(_)
257 | Self::Rename(_) => true,
258 Self::Open(args) => args.mode.create(),
259 _ => false,
260 }
261 }
262
263 /// Whether this operation checks existence without reading content.
264 /// Existence checks return `false` for nonexistent paths rather than
265 /// raising `FileNotFoundError`, matching CPython's `pathlib.Path`.
266 #[must_use]
267 pub fn is_existence_check(&self) -> bool {
268 matches!(
269 self,
270 Self::Exists(_) | Self::IsFile(_) | Self::IsDir(_) | Self::IsSymlink(_)
271 )
272 }
273
274 /// Checks both raw filesystem paths before normalization can hide a NUL byte.
275 /// Returns the operation-specific `ValueError` message; existence predicates
276 /// should return `False` instead of raising it.
277 pub fn check_path_null_bytes(&self) -> Result<(), &'static str> {
278 if self.fs_primary_path().is_some_and(|path| path.contains('\0')) {
279 Err(self.embedded_null_message(false))
280 } else if self.rename_destination().is_some_and(|path| path.contains('\0')) {
281 Err(self.embedded_null_message(true))
282 } else {
283 Ok(())
284 }
285 }
286
287 /// CPython's `ValueError` message for a path containing a null byte.
288 ///
289 /// The wording is not uniform in CPython: it comes from whichever layer
290 /// first inspects the path, so the content operations go through `open()`
291 /// and say `embedded null byte`, while the metadata ones are named by the
292 /// syscall their `os` wrapper was about to make. `for_destination` picks
293 /// the rename argument that carried the byte.
294 #[must_use]
295 pub fn embedded_null_message(&self, for_destination: bool) -> &'static str {
296 match self {
297 Self::Mkdir(_) => "mkdir: embedded null character in path",
298 Self::Unlink(_) => "unlink: embedded null character in path",
299 Self::Rmdir(_) => "rmdir: embedded null character in path",
300 Self::Stat(_) => "stat: embedded null character in path",
301 // `pathlib.Path.iterdir` reaches `os.scandir`, not `os.listdir`.
302 Self::Iterdir(_) | Self::Scan(_) => "scandir: embedded null character in path",
303 Self::Rename(_) if for_destination => "rename: embedded null character in dst",
304 Self::Rename(_) => "rename: embedded null character in src",
305 // `resolve()` lstats each component before returning.
306 Self::Resolve(_) => "lstat: embedded null character in path",
307 // Reads, writes, appends and `open` all land in `io.open`.
308 // `absolute()` shares that generic wording: it is pure string work
309 // that CPython never raises from, so naming a syscall would be a
310 // fiction (see `limitations/filesystem.md`). The predicates never
311 // reach here — they answer `False`.
312 _ => "embedded null byte",
313 }
314 }
315
316 /// The call's primary path if it's a FS operation, `None` otherwise.
317 ///
318 /// Used for routing and error reporting.
319 #[must_use]
320 pub fn fs_primary_path(&self) -> Option<&str> {
321 match self {
322 Self::Exists(p)
323 | Self::IsFile(p)
324 | Self::IsDir(p)
325 | Self::IsSymlink(p)
326 | Self::ReadText(p)
327 | Self::ReadBytes(p)
328 | Self::Stat(p)
329 | Self::Iterdir(p)
330 | Self::Resolve(p)
331 | Self::Absolute(p)
332 | Self::Unlink(p)
333 | Self::Rmdir(p) => Some(p.as_str()),
334 Self::WriteText(a) | Self::AppendText(a) => Some(a.path.as_str()),
335 Self::WriteBytes(a) | Self::AppendBytes(a) => Some(a.path.as_str()),
336 Self::Open(a) => Some(a.path.as_str()),
337 Self::Mkdir(a) => Some(a.path.as_str()),
338 Self::Scan(a) => Some(a.path.as_str()),
339 Self::Rename(a) => Some(a.src.as_str()),
340 Self::Getenv(_)
341 | Self::GetEnviron
342 | Self::DateToday
343 | Self::DateTimeNow(_)
344 | Self::Urandom(_)
345 | Self::Time(_)
346 | Self::Sleep(_)
347 | Self::SystemSleep(_)
348 | Self::AsyncSleep(_)
349 | Self::AsyncSystemSleep(_)
350 | Self::Import(_)
351 | Self::ImportStubs(_) => None,
352 }
353 }
354
355 /// The rename destination path, or `None` for every other variant — the
356 /// second routing key a mount table needs (both rename endpoints must
357 /// resolve to the same mount).
358 #[must_use]
359 pub fn rename_destination(&self) -> Option<&str> {
360 match self {
361 Self::Rename(a) => Some(a.dst.as_str()),
362 _ => None,
363 }
364 }
365
366 /// Every path this call carries, mutably: the primary path plus the
367 /// rename destination. The interpreter resolves relative paths against
368 /// the sandbox working directory here before the call reaches the host,
369 /// so host backends only ever see absolute virtual paths.
370 pub fn fs_paths_mut(&mut self) -> impl Iterator<Item = &mut MontyPath> {
371 let (primary, dst) = match self {
372 Self::Exists(p)
373 | Self::IsFile(p)
374 | Self::IsDir(p)
375 | Self::IsSymlink(p)
376 | Self::ReadText(p)
377 | Self::ReadBytes(p)
378 | Self::Stat(p)
379 | Self::Iterdir(p)
380 | Self::Resolve(p)
381 | Self::Absolute(p)
382 | Self::Unlink(p)
383 | Self::Rmdir(p) => (Some(p), None),
384 Self::WriteText(a) | Self::AppendText(a) => (Some(&mut a.path), None),
385 Self::WriteBytes(a) | Self::AppendBytes(a) => (Some(&mut a.path), None),
386 Self::Open(a) => (Some(&mut a.path), None),
387 Self::Mkdir(a) => (Some(&mut a.path), None),
388 Self::Scan(a) => (Some(&mut a.path), None),
389 Self::Rename(a) => (Some(&mut a.src), Some(&mut a.dst)),
390 Self::Getenv(_)
391 | Self::GetEnviron
392 | Self::DateToday
393 | Self::DateTimeNow(_)
394 | Self::Urandom(_)
395 | Self::Time(_)
396 | Self::Sleep(_)
397 | Self::SystemSleep(_)
398 | Self::AsyncSleep(_)
399 | Self::AsyncSystemSleep(_)
400 | Self::Import(_)
401 | Self::ImportStubs(_) => (None, None),
402 };
403 primary.into_iter().chain(dst)
404 }
405
406 /// Exception to raise when no handler accepted this call: `PermissionError`
407 /// for FS ops (with the path), `ModuleNotFoundError` for an import,
408 /// `RuntimeError` for the other non-FS ops.
409 #[must_use]
410 pub fn on_no_handler(&self) -> MontyException {
411 if let Self::Import(name) = self {
412 MontyException::new(
413 ExcType::ModuleNotFoundError,
414 Some(format!("No module named {}", StringRepr(name))),
415 )
416 } else if let Some(path) = self.fs_primary_path() {
417 let path = if path.is_empty() {
418 Cow::Borrowed(path)
419 } else {
420 normalize_virtual_path(path)
421 };
422 MontyException::new(
423 ExcType::PermissionError,
424 Some(format!("Permission denied: {}", StringRepr(&path))),
425 )
426 } else {
427 MontyException::new(
428 ExcType::RuntimeError,
429 Some(format!("'{}' is not supported in this environment", self.name())),
430 )
431 }
432 }
433}
434
435impl fmt::Display for OsFunctionCall {
436 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
437 f.write_str(self.name())
438 }
439}
440
441/// Which `time` function is reading the clock in an [`OsFunctionCall::Time`].
442///
443/// All share the `time.time` call name since all want the current instant as epoch
444/// seconds; the caller, passed as the call's single positional argument spelled as
445/// the Python function (`"time.perf_counter"`), lets a host that cares answer them
446/// differently (say a virtual clock advancing only for `time.monotonic`).
447#[derive(
448 Debug,
449 Clone,
450 Copy,
451 PartialEq,
452 Eq,
453 serde::Serialize,
454 serde::Deserialize,
455 strum::EnumIter,
456 strum::EnumString,
457 strum::IntoStaticStr,
458)]
459pub enum TimeCaller {
460 /// `time.time()`.
461 #[strum(serialize = "time.time")]
462 Time,
463 /// `time.time_ns()`.
464 #[strum(serialize = "time.time_ns")]
465 TimeNs,
466 /// `time.monotonic()`.
467 #[strum(serialize = "time.monotonic")]
468 Monotonic,
469 /// `time.monotonic_ns()`.
470 #[strum(serialize = "time.monotonic_ns")]
471 MonotonicNs,
472 /// `time.perf_counter()`.
473 #[strum(serialize = "time.perf_counter")]
474 PerfCounter,
475 /// `time.perf_counter_ns()`.
476 #[strum(serialize = "time.perf_counter_ns")]
477 PerfCounterNs,
478 /// `time.gmtime()` with no argument.
479 #[strum(serialize = "time.gmtime")]
480 Gmtime,
481 /// `time.localtime()` with no argument.
482 #[strum(serialize = "time.localtime")]
483 Localtime,
484 /// `time.asctime()` with no argument.
485 #[strum(serialize = "time.asctime")]
486 Asctime,
487 /// `time.ctime()` with no argument.
488 #[strum(serialize = "time.ctime")]
489 Ctime,
490 /// `time.strftime(format)` with no time argument.
491 #[strum(serialize = "time.strftime")]
492 Strftime,
493}
494
495impl TimeCaller {
496 /// The Python function's name, as the host receives it.
497 #[must_use]
498 pub fn as_str(self) -> &'static str {
499 self.into()
500 }
501}
502
503impl fmt::Display for TimeCaller {
504 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
505 f.write_str(self.as_str())
506 }
507}
508
509/// A call with one positional argument.
510fn single_arg(value: impl PushValue) -> CallArgs {
511 let mut call = CallArgs::new();
512 unstable::push_arg(&mut call, value);
513 call
514}
515
516// =============================================================================
517// Args structs — per-variant payloads carried by `OsFunctionCall`.
518// =============================================================================
519//
520// Each variant carries a struct that derives `ToArgs` for projection to
521// `CallArgs`. Zero-arg variants use empty structs so `to_args()` has no
522// special arms. Producers construct these directly via struct literals (see
523// `types/path.rs`, `builtins/open.rs`, etc.).
524
525/// `path + str data` shape used by `WriteText` and `AppendText`.
526#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
527pub struct PathStringDataArgs {
528 pub path: MontyPath,
529 pub data: String,
530}
531
532/// `path + bytes data` shape used by `WriteBytes` and `AppendBytes`.
533#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
534pub struct PathBytesDataArgs {
535 pub path: MontyPath,
536 #[serde(with = "serde_bytes")]
537 pub data: Vec<u8>,
538}
539
540/// Arguments to `open()`: a virtual path and a parsed file mode.
541#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
542pub struct OpenCallArgs {
543 pub path: MontyPath,
544 pub mode: FileMode,
545}
546
547/// `mkdir(path, parents=False, exist_ok=False)` shape. `parents`/`exist_ok`
548/// are kw-only so [`ToArgs`](crate::args::ToArgs) emits them as kwargs (matching CPython).
549#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
550pub struct MkdirCallArgs {
551 pub path: MontyPath,
552 #[from_args(kw_only)]
553 pub parents: bool,
554 #[from_args(kw_only)]
555 pub exist_ok: bool,
556}
557
558/// `rename(src, dst)` shape.
559#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
560pub struct RenameCallArgs {
561 pub src: MontyPath,
562 pub dst: MontyPath,
563}
564
565/// `os.getenv(key, default=None)` shape. The host decides whether to
566/// substitute `default` when the variable is unset.
567#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
568pub struct GetenvArgs {
569 pub key: String,
570 pub default: MontyObject,
571}
572
573/// `os.urandom(size)` shape. The interpreter rejects a negative `size` before
574/// suspending, so the count is unsigned; the host answers with exactly `size`
575/// bytes. `size` is sandbox-controlled, so a handler should cap it before allocating.
576#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
577pub struct UrandomArgs {
578 pub size: u64,
579}
580
581/// Longest sleep the sleep calls accept, matching the point where CPython's
582/// `PyTime_t` (nanoseconds in an `i64`) overflows.
583pub const MAX_SLEEP_SECONDS: f64 = 9_223_372_036.854_775;
584
585/// Why a requested sleep length cannot be carried by an OS call.
586///
587/// The caller picks the Python-level consequence: `time.sleep` raises
588/// (`ValueError` for the first two, `OverflowError` for the third) while
589/// `asyncio.sleep` raises only for NaN and clamps the rest, as CPython does.
590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
591pub enum SleepError {
592 /// The delay was NaN.
593 NotANumber,
594 /// The delay was negative.
595 Negative,
596 /// The delay was past [`MAX_SLEEP_SECONDS`] (infinity included).
597 TooLarge,
598}
599
600/// Converts a Python sleep argument into the [`Duration`] an OS call carries.
601///
602/// Sleep payloads are `Duration` rather than raw seconds precisely so no host
603/// is ever handed a NaN, negative or unrepresentable span to convert — the
604/// obvious `Duration::from_secs_f64` panics on all three. Both producers, the
605/// interpreter and the wire decoder, go through here.
606pub fn sleep_duration(seconds: f64) -> Result<Duration, SleepError> {
607 // Range before sign, as CPython converts to `PyTime_t` before checking
608 // the sign: `-inf` and huge negatives overflow rather than being negative.
609 if seconds.is_nan() {
610 Err(SleepError::NotANumber)
611 } else if seconds.abs() > MAX_SLEEP_SECONDS {
612 Err(SleepError::TooLarge)
613 } else if seconds < 0.0 {
614 Err(SleepError::Negative)
615 } else {
616 Duration::try_from_secs_f64(seconds).map_err(|_| SleepError::TooLarge)
617 }
618}
619
620/// Like [`sleep_duration`], but for `asyncio.sleep`, which clamps rather than
621/// raising: a negative delay becomes no wait at all (CPython returns
622/// immediately) and an over-long one saturates at [`MAX_SLEEP_SECONDS`]. Only
623/// NaN is refused, the one delay CPython rejects there.
624pub fn sleep_duration_saturating(seconds: f64) -> Result<Duration, SleepError> {
625 match sleep_duration(seconds) {
626 Ok(delay) => Ok(delay),
627 // `delay <= 0` returns at once in CPython, however far below zero.
628 Err(SleepError::Negative) => Ok(Duration::ZERO),
629 Err(SleepError::TooLarge) if seconds < 0.0 => Ok(Duration::ZERO),
630 Err(SleepError::TooLarge) => Ok(Duration::from_secs_f64(MAX_SLEEP_SECONDS)),
631 Err(err @ SleepError::NotANumber) => Err(err),
632 }
633}
634
635// =============================================================================
636// MontyPath — owned virtual-sandbox path used by every path-bearing variant.
637// =============================================================================
638
639/// Owned virtual (sandbox) path carried by OS-call args.
640///
641/// Preserves the supplied string, including invalid components, for host validation.
642/// Derefs to `&str` for routing.
643#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
644pub struct MontyPath(String);
645
646impl MontyPath {
647 /// Stores the path without validation or normalization; hosts validate before I/O.
648 #[must_use]
649 pub fn new(path: String) -> Self {
650 Self(path)
651 }
652
653 /// Borrows the original spelling for host validation and error messages.
654 #[must_use]
655 pub fn as_str(&self) -> &str {
656 &self.0
657 }
658
659 /// Takes the original string without copying it.
660 #[must_use]
661 pub fn into_string(self) -> String {
662 self.0
663 }
664}
665
666impl Deref for MontyPath {
667 type Target = str;
668
669 fn deref(&self) -> &str {
670 &self.0
671 }
672}
673
674impl From<String> for MontyPath {
675 fn from(s: String) -> Self {
676 Self(s)
677 }
678}
679
680impl From<&str> for MontyPath {
681 fn from(s: &str) -> Self {
682 Self(s.to_owned())
683 }
684}
685
686impl PushValue for MontyPath {
687 fn push_into(self, graph: &mut MontyGraph) -> NodeId {
688 graph.push(MontyNode::Path(self.0))
689 }
690}
691// =============================================================================
692// stat_result builders — separate utility API used by host backends.
693// =============================================================================
694//
695// These functions create namedtuple values that match Python's
696// os.stat_result structure. The stat_result has 10 fields:
697// st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size, st_atime, st_mtime, st_ctime.
698
699/// Creates a `stat_result` for a regular file.
700///
701/// The file type bits (`0o100_000`) are automatically added if not present.
702///
703/// # Arguments
704/// * `mode` - File permissions as octal. Common values:
705/// - `0o644` - rw-r--r-- (owner read/write, others read)
706/// - `0o600` - rw------- (owner read/write only)
707/// - `0o755` - rwxr-xr-x (executable, owner full, others read/execute)
708/// - `0o100644` - same as 0o644 with explicit file type bits
709/// * `size` - File size in bytes
710/// * `mtime` - Modification time as Unix timestamp
711#[must_use]
712pub fn file_stat(mode: i64, size: i64, mtime: f64) -> MontyObject {
713 let mode = if mode < 0o1000 { mode | 0o100_000 } else { mode };
714 stat_result(mode, 0, 0, 1, 0, 0, size, mtime, mtime, mtime)
715}
716
717/// Creates a `stat_result` for a directory.
718///
719/// The directory type bits (`0o040_000`) are automatically added if not present.
720///
721/// # Arguments
722/// * `mode` - Directory permissions as octal. Common values:
723/// - `0o755` - rwxr-xr-x (owner full, others read/execute)
724/// - `0o700` - rwx------ (owner only)
725/// - `0o040755` - same as 0o755 with explicit directory type bits
726/// * `mtime` - Modification time as Unix timestamp
727#[must_use]
728pub fn dir_stat(mode: i64, mtime: f64) -> MontyObject {
729 let mode = if mode < 0o1000 { mode | 0o040_000 } else { mode };
730 stat_result(mode, 0, 0, 2, 0, 0, 4096, mtime, mtime, mtime)
731}
732
733/// Creates a `stat_result` for a symbolic link.
734///
735/// The symlink type bits (`0o120_000`) are automatically added if not present.
736///
737/// # Arguments
738/// * `mode` - Symlink permissions as octal. Common values:
739/// - `0o777` - rwxrwxrwx (symlinks typically have full permissions)
740/// - `0o120777` - same as 0o777 with explicit symlink type bits
741/// * `mtime` - Modification time as Unix timestamp
742#[must_use]
743pub fn symlink_stat(mode: i64, mtime: f64) -> MontyObject {
744 let mode = if mode < 0o1000 { mode | 0o120_000 } else { mode };
745 stat_result(mode, 0, 0, 1, 0, 0, 0, mtime, mtime, mtime)
746}
747
748/// Creates a full `stat_result` with all 10 fields specified.
749///
750/// This is the low-level builder; prefer `file_stat()`, `dir_stat()`, or `symlink_stat()`
751/// for common cases.
752#[must_use]
753#[expect(clippy::too_many_arguments)]
754pub fn stat_result(
755 st_mode: i64,
756 st_ino: i64,
757 st_dev: i64,
758 st_nlink: i64,
759 st_uid: i64,
760 st_gid: i64,
761 st_size: i64,
762 st_atime: f64,
763 st_mtime: f64,
764 st_ctime: f64,
765) -> MontyObject {
766 MontyObject::named_tuple(
767 STAT_RESULT_TYPE_NAME,
768 STAT_RESULT_FIELDS.iter().copied(),
769 [
770 MontyObject::int(st_mode),
771 MontyObject::int(st_ino),
772 MontyObject::int(st_dev),
773 MontyObject::int(st_nlink),
774 MontyObject::int(st_uid),
775 MontyObject::int(st_gid),
776 MontyObject::int(st_size),
777 MontyObject::float(st_atime),
778 MontyObject::float(st_mtime),
779 MontyObject::float(st_ctime),
780 ],
781 )
782}
783
784const STAT_RESULT_TYPE_NAME: &str = "StatResult";
785const STAT_RESULT_FIELDS: &[&str] = &[
786 "st_mode", "st_ino", "st_dev", "st_nlink", "st_uid", "st_gid", "st_size", "st_atime", "st_mtime", "st_ctime",
787];