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 `MontyObject` introspection);
9//! host bindings get a generic `(positional, keyword)` view via
10//! [`OsFunctionCall::to_args`].
11
12use std::{fmt, ops::Deref};
13
14use crate::{
15    args::{ToArgs, ToMontyObject},
16    exceptions::{ExcType, MontyException},
17    file_mode::FileMode,
18    format::StringRepr,
19    object::{MontyObject, MontyTimeZone},
20};
21// =============================================================================
22// OsFunctionCall — the central public dispatch value.
23// =============================================================================
24
25/// Tagged dispatch value for OS-level operations.
26///
27/// Each variant carries the strongly-typed args/kwargs the corresponding OS
28/// call needs. The fs/ layer matches on this enum directly (no `MontyObject`
29/// introspection); host bindings get a generic `(positional, keyword)` view
30/// via [`OsFunctionCall::to_args`].
31///
32/// See the module docs for how to add a new variant.
33#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, strum::IntoStaticStr)]
34pub enum OsFunctionCall {
35    // ---- FS read / check (single path) ------------------------------------
36    /// Check if a path exists.
37    #[strum(serialize = "Path.exists")]
38    Exists(MontyPath),
39    /// Check if path is a regular file.
40    #[strum(serialize = "Path.is_file")]
41    IsFile(MontyPath),
42    /// Check if path is a directory.
43    #[strum(serialize = "Path.is_dir")]
44    IsDir(MontyPath),
45    /// Check if path is a symbolic link.
46    #[strum(serialize = "Path.is_symlink")]
47    IsSymlink(MontyPath),
48    /// Read file contents as text.
49    #[strum(serialize = "Path.read_text")]
50    ReadText(MontyPath),
51    /// Read file contents as bytes.
52    #[strum(serialize = "Path.read_bytes")]
53    ReadBytes(MontyPath),
54    /// `stat()` — return a stat result tuple.
55    #[strum(serialize = "Path.stat")]
56    Stat(MontyPath),
57    /// List directory contents.
58    #[strum(serialize = "Path.iterdir")]
59    Iterdir(MontyPath),
60    /// Resolve symlinks and return absolute path.
61    #[strum(serialize = "Path.resolve")]
62    Resolve(MontyPath),
63    /// Absolute path without symlink resolution.
64    #[strum(serialize = "Path.absolute")]
65    Absolute(MontyPath),
66
67    // ---- FS write (path + data) -------------------------------------------
68    /// Write text to file (truncating).
69    #[strum(serialize = "Path.write_text")]
70    WriteText(PathStringDataArgs),
71    /// Append text to file.
72    #[strum(serialize = "Path.append_text")]
73    AppendText(PathStringDataArgs),
74    /// Write bytes to file (truncating).
75    #[strum(serialize = "Path.write_bytes")]
76    WriteBytes(PathBytesDataArgs),
77    /// Append bytes to file.
78    #[strum(serialize = "Path.append_bytes")]
79    AppendBytes(PathBytesDataArgs),
80
81    // ---- FS mutate (custom shapes) ----------------------------------------
82    /// Open a file. The host performs the open-time effect (truncate for
83    /// `w`/`w+`, create-if-missing for `a`/`a+`, existence check for `r`/`r+`)
84    /// and returns a [`MontyObject::FileHandle`] — it never holds a live OS
85    /// handle across calls.
86    #[strum(serialize = "open")]
87    Open(OpenCallArgs),
88    /// Create directory (`parents`/`exist_ok` kwargs).
89    #[strum(serialize = "Path.mkdir")]
90    Mkdir(MkdirCallArgs),
91    /// Remove file.
92    #[strum(serialize = "Path.unlink")]
93    Unlink(MontyPath),
94    /// Remove directory.
95    #[strum(serialize = "Path.rmdir")]
96    Rmdir(MontyPath),
97    /// Rename / move (src → dst).
98    #[strum(serialize = "Path.rename")]
99    Rename(RenameCallArgs),
100
101    // ---- Non-FS -----------------------------------------------------------
102    /// Get an environment variable value.
103    #[strum(serialize = "os.getenv")]
104    Getenv(GetenvArgs),
105    /// Get the entire environment as a dictionary.
106    #[strum(serialize = "os.environ")]
107    GetEnviron,
108    /// Get today's date from the host system (for `date.today()`).
109    #[strum(serialize = "date.today")]
110    DateToday,
111    /// Get the current date/time from the host system (for `datetime.now(tz=...)`).
112    /// Carries the timezone argument, `None` for a naive result.
113    #[strum(serialize = "datetime.now")]
114    DateTimeNow(Option<MontyTimeZone>),
115}
116
117impl OsFunctionCall {
118    /// Stable string name for this OS function — surfaces in
119    /// [`Self::on_no_handler`] errors, host `os` callbacks, and serialised
120    /// snapshots. The strum `serialize` string on each variant.
121    #[must_use]
122    pub fn name(&self) -> &'static str {
123        self.into()
124    }
125
126    /// Projects this call's args into `(positional, keyword)` `MontyObject`
127    /// vectors for delivery to a host callback.
128    #[must_use]
129    pub fn to_args(self) -> (Vec<MontyObject>, Vec<(MontyObject, MontyObject)>) {
130        match self {
131            // Single-path variants — just the path in positionals.
132            Self::Exists(p)
133            | Self::IsFile(p)
134            | Self::IsDir(p)
135            | Self::IsSymlink(p)
136            | Self::ReadText(p)
137            | Self::ReadBytes(p)
138            | Self::Stat(p)
139            | Self::Iterdir(p)
140            | Self::Resolve(p)
141            | Self::Absolute(p)
142            | Self::Unlink(p)
143            | Self::Rmdir(p) => (vec![p.into_monty_object()], vec![]),
144            // Multi-field variants delegate to their derived `ToArgs`.
145            Self::WriteText(a) | Self::AppendText(a) => a.to_args(),
146            Self::WriteBytes(a) | Self::AppendBytes(a) => a.to_args(),
147            Self::Open(a) => a.to_args(),
148            Self::Mkdir(a) => a.to_args(),
149            Self::Rename(a) => a.to_args(),
150            Self::Getenv(a) => a.to_args(),
151            // Unit & single-value non-FS variants.
152            Self::GetEnviron | Self::DateToday => (vec![], vec![]),
153            Self::DateTimeNow(tz) => (vec![tz.map_or(MontyObject::None, MontyObject::TimeZone)], vec![]),
154        }
155    }
156
157    /// Whether this call mutates filesystem state — the read-only-mount gate.
158    /// `Open`'s write-ness is mode-dependent (`w`/`w+`/`a`/`a+` write; `r`/`r+`
159    /// don't).
160    #[must_use]
161    pub fn is_write(&self) -> bool {
162        match self {
163            Self::WriteText(_)
164            | Self::WriteBytes(_)
165            | Self::AppendText(_)
166            | Self::AppendBytes(_)
167            | Self::Mkdir(_)
168            | Self::Unlink(_)
169            | Self::Rmdir(_)
170            | Self::Rename(_) => true,
171            Self::Open(args) => args.mode.create(),
172            _ => false,
173        }
174    }
175
176    /// Whether this operation checks existence without reading content.
177    /// Existence checks return `false` for nonexistent paths rather than
178    /// raising `FileNotFoundError`, matching CPython's `pathlib.Path`.
179    #[must_use]
180    pub fn is_existence_check(&self) -> bool {
181        matches!(
182            self,
183            Self::Exists(_) | Self::IsFile(_) | Self::IsDir(_) | Self::IsSymlink(_)
184        )
185    }
186
187    /// CPython's `ValueError` message for a path containing a null byte.
188    ///
189    /// The wording is not uniform in CPython: it comes from whichever layer
190    /// first inspects the path, so the content operations go through `open()`
191    /// and say `embedded null byte`, while the metadata ones are named by the
192    /// syscall their `os` wrapper was about to make. `for_destination` picks
193    /// the rename argument that carried the byte.
194    #[must_use]
195    pub fn embedded_null_message(&self, for_destination: bool) -> &'static str {
196        match self {
197            Self::Mkdir(_) => "mkdir: embedded null character in path",
198            Self::Unlink(_) => "unlink: embedded null character in path",
199            Self::Rmdir(_) => "rmdir: embedded null character in path",
200            Self::Stat(_) => "stat: embedded null character in path",
201            // `pathlib.Path.iterdir` reaches `os.scandir`, not `os.listdir`.
202            Self::Iterdir(_) => "scandir: embedded null character in path",
203            Self::Rename(_) if for_destination => "rename: embedded null character in dst",
204            Self::Rename(_) => "rename: embedded null character in src",
205            // `resolve()` lstats each component before returning.
206            Self::Resolve(_) => "lstat: embedded null character in path",
207            // Reads, writes, appends and `open` all land in `io.open`.
208            // `absolute()` shares that generic wording: it is pure string work
209            // that CPython never raises from, so naming a syscall would be a
210            // fiction (see `limitations/filesystem.md`). The predicates never
211            // reach here — they answer `False`.
212            _ => "embedded null byte",
213        }
214    }
215
216    /// The call's primary path if it's a FS operation, `None` otherwise.
217    ///
218    /// Used for routing and error reporting.
219    #[must_use]
220    pub fn fs_primary_path(&self) -> Option<&str> {
221        match self {
222            Self::Exists(p)
223            | Self::IsFile(p)
224            | Self::IsDir(p)
225            | Self::IsSymlink(p)
226            | Self::ReadText(p)
227            | Self::ReadBytes(p)
228            | Self::Stat(p)
229            | Self::Iterdir(p)
230            | Self::Resolve(p)
231            | Self::Absolute(p)
232            | Self::Unlink(p)
233            | Self::Rmdir(p) => Some(p.as_str()),
234            Self::WriteText(a) | Self::AppendText(a) => Some(a.path.as_str()),
235            Self::WriteBytes(a) | Self::AppendBytes(a) => Some(a.path.as_str()),
236            Self::Open(a) => Some(a.path.as_str()),
237            Self::Mkdir(a) => Some(a.path.as_str()),
238            Self::Rename(a) => Some(a.src.as_str()),
239            Self::Getenv(_) | Self::GetEnviron | Self::DateToday | Self::DateTimeNow(_) => None,
240        }
241    }
242
243    /// The rename destination path, or `None` for every other variant — the
244    /// second routing key a mount table needs (both rename endpoints must
245    /// resolve to the same mount).
246    #[must_use]
247    pub fn rename_destination(&self) -> Option<&str> {
248        match self {
249            Self::Rename(a) => Some(a.dst.as_str()),
250            _ => None,
251        }
252    }
253
254    /// Exception to raise when no handler accepted this call: `PermissionError`
255    /// for FS ops (with the path), `RuntimeError` for non-FS ops.
256    #[must_use]
257    pub fn on_no_handler(&self) -> MontyException {
258        if let Some(path) = self.fs_primary_path() {
259            MontyException::new(
260                ExcType::PermissionError,
261                Some(format!("Permission denied: {}", StringRepr(path))),
262            )
263        } else {
264            MontyException::new(
265                ExcType::RuntimeError,
266                Some(format!("'{}' is not supported in this environment", self.name())),
267            )
268        }
269    }
270}
271
272impl fmt::Display for OsFunctionCall {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        f.write_str(self.name())
275    }
276}
277// =============================================================================
278// Args structs — per-variant payloads carried by `OsFunctionCall`.
279// =============================================================================
280//
281// Each variant carries a struct that derives `ToArgs` for projection to
282// `(positional, keyword)` MontyObjects. Zero-arg variants use empty structs so
283// `to_args()` has no special arms. Producers construct these directly via
284// struct literals (see `types/path.rs`, `builtins/open.rs`, etc.).
285
286/// `path + str data` shape used by `WriteText` and `AppendText`.
287#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
288pub struct PathStringDataArgs {
289    pub path: MontyPath,
290    pub data: String,
291}
292
293/// `path + bytes data` shape used by `WriteBytes` and `AppendBytes`.
294#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
295pub struct PathBytesDataArgs {
296    pub path: MontyPath,
297    pub data: Vec<u8>,
298}
299
300/// `open(path, mode)` shape. The mode is parsed into [`FileMode`] before
301/// construction so the fs/ backend doesn't re-parse; `ToArgs` re-serialises
302/// it back to a `MontyObject::String` for the host.
303#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
304pub struct OpenCallArgs {
305    pub path: MontyPath,
306    pub mode: FileMode,
307}
308
309/// `mkdir(path, parents=False, exist_ok=False)` shape. `parents`/`exist_ok`
310/// are kw-only so `ToArgs` emits them as kwargs (matching CPython).
311#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
312pub struct MkdirCallArgs {
313    pub path: MontyPath,
314    #[from_args(kw_only)]
315    pub parents: bool,
316    #[from_args(kw_only)]
317    pub exist_ok: bool,
318}
319
320/// `rename(src, dst)` shape.
321#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
322pub struct RenameCallArgs {
323    pub src: MontyPath,
324    pub dst: MontyPath,
325}
326
327/// `os.getenv(key, default=None)` shape. The host decides whether to
328/// substitute `default` when the variable is unset.
329#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, monty_macros::ToArgs)]
330pub struct GetenvArgs {
331    pub key: String,
332    pub default: MontyObject,
333}
334
335// =============================================================================
336// MontyPath — owned virtual-sandbox path used by every path-bearing variant.
337// =============================================================================
338
339/// Owned virtual (sandbox) path carried by OS-call args.
340///
341/// `String` newtype: derefs to `&str` for fs/ routing, and `ToMontyObject`
342/// projects it back to [`MontyObject::Path`] at the host boundary. Constructed
343/// at the producer site after the source `Value` has been validated as a
344/// path/string — never from raw input.
345#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
346pub struct MontyPath(String);
347
348impl MontyPath {
349    #[must_use]
350    pub fn new(path: String) -> Self {
351        Self(path)
352    }
353
354    #[must_use]
355    pub fn as_str(&self) -> &str {
356        &self.0
357    }
358
359    #[must_use]
360    pub fn into_string(self) -> String {
361        self.0
362    }
363}
364
365impl Deref for MontyPath {
366    type Target = str;
367
368    fn deref(&self) -> &str {
369        &self.0
370    }
371}
372
373impl From<String> for MontyPath {
374    fn from(s: String) -> Self {
375        Self(s)
376    }
377}
378
379impl From<&str> for MontyPath {
380    fn from(s: &str) -> Self {
381        Self(s.to_owned())
382    }
383}
384
385impl ToMontyObject for MontyPath {
386    fn into_monty_object(self) -> MontyObject {
387        MontyObject::Path(self.0)
388    }
389}
390// =============================================================================
391// stat_result builders — separate utility API used by host backends.
392// =============================================================================
393//
394// These functions create MontyObject::NamedTuple values that match Python's
395// os.stat_result structure. The stat_result has 10 fields:
396// st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size, st_atime, st_mtime, st_ctime.
397
398/// Creates a `stat_result` for a regular file.
399///
400/// The file type bits (`0o100_000`) are automatically added if not present.
401///
402/// # Arguments
403/// * `mode` - File permissions as octal. Common values:
404///   - `0o644` - rw-r--r-- (owner read/write, others read)
405///   - `0o600` - rw------- (owner read/write only)
406///   - `0o755` - rwxr-xr-x (executable, owner full, others read/execute)
407///   - `0o100644` - same as 0o644 with explicit file type bits
408/// * `size` - File size in bytes
409/// * `mtime` - Modification time as Unix timestamp
410#[must_use]
411pub fn file_stat(mode: i64, size: i64, mtime: f64) -> MontyObject {
412    let mode = if mode < 0o1000 { mode | 0o100_000 } else { mode };
413    stat_result(mode, 0, 0, 1, 0, 0, size, mtime, mtime, mtime)
414}
415
416/// Creates a `stat_result` for a directory.
417///
418/// The directory type bits (`0o040_000`) are automatically added if not present.
419///
420/// # Arguments
421/// * `mode` - Directory permissions as octal. Common values:
422///   - `0o755` - rwxr-xr-x (owner full, others read/execute)
423///   - `0o700` - rwx------ (owner only)
424///   - `0o040755` - same as 0o755 with explicit directory type bits
425/// * `mtime` - Modification time as Unix timestamp
426#[must_use]
427pub fn dir_stat(mode: i64, mtime: f64) -> MontyObject {
428    let mode = if mode < 0o1000 { mode | 0o040_000 } else { mode };
429    stat_result(mode, 0, 0, 2, 0, 0, 4096, mtime, mtime, mtime)
430}
431
432/// Creates a `stat_result` for a symbolic link.
433///
434/// The symlink type bits (`0o120_000`) are automatically added if not present.
435///
436/// # Arguments
437/// * `mode` - Symlink permissions as octal. Common values:
438///   - `0o777` - rwxrwxrwx (symlinks typically have full permissions)
439///   - `0o120777` - same as 0o777 with explicit symlink type bits
440/// * `mtime` - Modification time as Unix timestamp
441#[must_use]
442pub fn symlink_stat(mode: i64, mtime: f64) -> MontyObject {
443    let mode = if mode < 0o1000 { mode | 0o120_000 } else { mode };
444    stat_result(mode, 0, 0, 1, 0, 0, 0, mtime, mtime, mtime)
445}
446
447/// Creates a full `stat_result` with all 10 fields specified.
448///
449/// This is the low-level builder; prefer `file_stat()`, `dir_stat()`, or `symlink_stat()`
450/// for common cases.
451#[must_use]
452#[expect(clippy::too_many_arguments)]
453pub fn stat_result(
454    st_mode: i64,
455    st_ino: i64,
456    st_dev: i64,
457    st_nlink: i64,
458    st_uid: i64,
459    st_gid: i64,
460    st_size: i64,
461    st_atime: f64,
462    st_mtime: f64,
463    st_ctime: f64,
464) -> MontyObject {
465    MontyObject::NamedTuple {
466        type_name: STAT_RESULT_TYPE_NAME.to_owned(),
467        field_names: STAT_RESULT_FIELDS.iter().map(|s| (*s).to_owned()).collect(),
468        values: vec![
469            MontyObject::Int(st_mode),
470            MontyObject::Int(st_ino),
471            MontyObject::Int(st_dev),
472            MontyObject::Int(st_nlink),
473            MontyObject::Int(st_uid),
474            MontyObject::Int(st_gid),
475            MontyObject::Int(st_size),
476            MontyObject::Float(st_atime),
477            MontyObject::Float(st_mtime),
478            MontyObject::Float(st_ctime),
479        ],
480    }
481}
482
483const STAT_RESULT_TYPE_NAME: &str = "StatResult";
484const STAT_RESULT_FIELDS: &[&str] = &[
485    "st_mode", "st_ino", "st_dev", "st_nlink", "st_uid", "st_gid", "st_size", "st_atime", "st_mtime", "st_ctime",
486];