Skip to main content

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];