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