Skip to main content

lua_stdlib/
os_lib.rs

1//! Lua `os` standard library.
2//!
3//! Ports `src/loslib.c` (430 lines, 12 functions) to Rust.
4//!
5//! ## Platform access limitations
6//!
7//! Several `os.*` functions require OS-level capabilities. File removal,
8//! rename, command execution, environment lookup, temporary-name generation,
9//! and wall-clock access route through `GlobalState` hooks supplied by the
10//! embedder where needed for sandboxed/WASM hosts.
11//!
12//! Time decomposition (`os.date`, `os.time`) requires C-library functions
13//! (`gmtime_r`, `localtime_r`, `mktime`, `strftime`).  Those call sites are
14//! flagged with `TODO(port)` and the stubs use a zero-initialised `TmFields`.
15
16use lua_types::{LuaError, LuaExit, LuaType, LuaValue};
17use crate::state_stub::{LuaState, LuaStateStubExt as _};
18use lua_vm::state::OsExecuteReason;
19
20// ── Constants ────────────────────────────────────────────────────────────────
21
22//
23// Valid `strftime` conversion specifiers — C99 / POSIX variant.
24// Single-char specifiers appear first; the `||` sentinel signals the start
25// of 2-char specifiers (e.g. `%EC`, `%Oy`).  See `check_strftime_option`.
26const STRFTIME_OPTIONS: &[u8] =
27    b"aAbBcCdDeFgGhHIjmMnprRStTuUVwWxXyYzZ%||EcECExEXEyEYOdOeOHOIOmOMOSOuOUOVOwOWOy";
28
29const SIZE_TIME_FMT: usize = 250;
30
31// ── TmFields ─────────────────────────────────────────────────────────────────
32
33/// Local mirror of C's `struct tm`.
34///
35/// Field conventions follow the C standard: `tm_year` is years since 1900,
36/// `tm_mon` ∈ [0, 11], `tm_wday` ∈ [0, 6] (Sunday = 0), `tm_isdst` is −1 when
37/// DST status is unknown.
38///
39/// TODO(port): In Phase B, replace with the `libc::tm` type (via the `libc` crate)
40/// or an equivalent from `chrono` / `time`.  Conversion from / to Unix timestamps
41/// is not implemented in Phase A — stubs that need a broken-down time use
42/// `TmFields::default()` (all zeros).
43#[derive(Debug, Default, Clone)]
44pub struct TmFields {
45    pub tm_sec: i32,
46    pub tm_min: i32,
47    pub tm_hour: i32,
48    pub tm_mday: i32,
49    pub tm_mon: i32,
50    pub tm_year: i32,
51    pub tm_wday: i32,
52    pub tm_yday: i32,
53    pub tm_isdst: i32,
54}
55
56// ── ByteDisplay ──────────────────────────────────────────────────────────────
57
58/// `Display` adapter for `&[u8]` slices known to contain ASCII bytes.
59///
60/// Used only for formatting Lua table field names (always ASCII identifiers such
61/// as `"year"`, `"month"`) inside error messages, without allocating a `String`.
62struct ByteDisplay<'a>(&'a [u8]);
63
64impl std::fmt::Display for ByteDisplay<'_> {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        for &b in self.0 {
67            write!(f, "{}", b as char)?;
68        }
69        Ok(())
70    }
71}
72
73// ── Private stack-manipulation helpers ───────────────────────────────────────
74
75///
76/// Pushes `(value as i64) + (delta as i64)` as a Lua integer, then stores it
77/// in the table currently on top of the stack at field `key`.
78fn set_field(state: &mut LuaState, key: &[u8], value: i32, delta: i32) -> Result<(), LuaError> {
79    state.push(LuaValue::Int((value as i64) + (delta as i64)));
80    state.set_field(-2, key)?;
81    Ok(())
82}
83
84///
85/// Stores a boolean at field `key` in the table on top of the stack.
86/// A negative `value` means "undefined" — the field is silently skipped.
87fn set_bool_field(state: &mut LuaState, key: &[u8], value: i32) -> Result<(), LuaError> {
88    if value < 0 {
89        return Ok(());
90    }
91    state.push(LuaValue::Bool(value != 0));
92    state.set_field(-2, key)?;
93    Ok(())
94}
95
96///
97/// Writes every field of `stm` into the table on top of the stack, applying the
98/// offsets that convert from C-library conventions to Lua conventions:
99/// year+1900, month+1, wday+1, yday+1.
100fn set_all_fields(state: &mut LuaState, stm: &TmFields) -> Result<(), LuaError> {
101    set_field(state, b"year",  stm.tm_year, 1900)?;
102    set_field(state, b"month", stm.tm_mon,  1)?;
103    set_field(state, b"day",   stm.tm_mday, 0)?;
104    set_field(state, b"hour",  stm.tm_hour, 0)?;
105    set_field(state, b"min",   stm.tm_min,  0)?;
106    set_field(state, b"sec",   stm.tm_sec,  0)?;
107    set_field(state, b"yday",  stm.tm_yday, 1)?;
108    set_field(state, b"wday",  stm.tm_wday, 1)?;
109    set_bool_field(state, b"isdst", stm.tm_isdst)?;
110    Ok(())
111}
112
113///
114/// Reads a boolean field from the table on top of the stack.
115/// Returns `-1` when the field is absent (nil), or `0` / `1` for false / true.
116fn get_bool_field(state: &mut LuaState, key: &[u8]) -> Result<i32, LuaError> {
117    let ty = state.get_field(-1, key)?;
118    let res = if matches!(ty, LuaType::Nil) {
119        -1i32
120    } else {
121        state.to_boolean(-1) as i32
122    };
123    state.pop_n(1);
124    Ok(res)
125}
126
127///
128/// Reads an integer field from the table on top of the stack.
129///
130/// * `d` — default when the field is absent; pass `d < 0` to make absence an
131///   error.
132/// * `delta` — subtracted from the read value to convert from Lua's offset
133///   representation back to C-library conventions (e.g. month−1, year−1900).
134///
135/// PORT NOTE: Stack cleanup on error paths (pop before returning Err) is added
136/// vs. the C version where `luaL_error` never returns (longjmp).
137fn get_field(
138    state: &mut LuaState,
139    key: &[u8],
140    d: i32,
141    delta: i32,
142) -> Result<i32, LuaError> {
143    let ty = state.get_field(-1, key)?;
144    let maybe_int = state.to_integer_x(-1);
145    let res: i32 = match maybe_int {
146        Some(res) => {
147            //        return luaL_error(L, "field '%s' is out-of-bound", key);
148            let in_bounds = if res >= 0 {
149                res.saturating_sub(delta as i64) <= (i32::MAX as i64)
150            } else {
151                (i32::MIN as i64).saturating_add(delta as i64) <= res
152            };
153            if !in_bounds {
154                state.pop_n(1);
155                return Err(LuaError::runtime(format_args!(
156                    "field '{}' is out-of-bound",
157                    ByteDisplay(key),
158                )));
159            }
160            (res - delta as i64) as i32
161        }
162        None => {
163            if !matches!(ty, LuaType::Nil) {
164                state.pop_n(1);
165                return Err(LuaError::runtime(format_args!(
166                    "field '{}' is not an integer",
167                    ByteDisplay(key),
168                )));
169            } else if d < 0 {
170                state.pop_n(1);
171                return Err(LuaError::runtime(format_args!(
172                    "field '{}' missing in date table",
173                    ByteDisplay(key),
174                )));
175            }
176            d
177        }
178    };
179    state.pop_n(1);
180    Ok(res)
181}
182
183///                                     ptrdiff_t convlen, char *buff)`
184///
185/// Validates the `strftime` conversion specifier at the start of `conv` against
186/// `STRFTIME_OPTIONS`.
187///
188/// `cc` must have `cc[0] == b'%'` on entry (set by the caller).  On success the
189/// matched specifier bytes are written into `cc[1..=oplen]`, a null terminator is
190/// written at `cc[oplen+1]`, and the sub-slice of `conv` after the consumed
191/// specifier is returned.
192///
193/// On failure a `LuaError::arg_error` describing the invalid specifier is
194/// returned.
195///
196/// The options table uses `|` characters as length-transition markers: one `|`
197/// increments `oplen` from 1 to 2 (and the following advance jumps past the `||`
198/// sentinel), enabling 2-char specifiers like `%EC`.
199fn check_strftime_option<'a>(
200    _state: &mut LuaState,
201    conv: &'a [u8],
202    cc: &mut [u8; 4],
203) -> Result<&'a [u8], LuaError> {
204    let options = STRFTIME_OPTIONS;
205    let mut oplen: usize = 1;
206    let mut i: usize = 0;
207
208    while i < options.len() && oplen <= conv.len() {
209        if options[i] == b'|' {
210            // Increment first so the subsequent `i += oplen` uses the new value,
211            // which jumps from the first `|` past the entire `||` separator block.
212            oplen += 1;
213            i += oplen;
214        } else if i + oplen <= options.len() && conv[..oplen] == options[i..i + oplen] {
215            // cc[0] = b'%' is pre-filled; write specifier bytes into cc[1..=oplen].
216            debug_assert!(oplen <= 2, "STRFTIME_OPTIONS only has 1- and 2-char specifiers");
217            cc[1..=oplen].copy_from_slice(&conv[..oplen]);
218            cc[oplen + 1] = 0;
219            return Ok(&conv[oplen..]);
220        } else {
221            i += oplen;
222        }
223    }
224    Err(LuaError::arg_error(
225        1,
226        "invalid conversion specifier",
227    ))
228}
229
230///
231/// Reads argument `arg` as a Lua integer and returns it as a Unix timestamp.
232///
233/// PORT NOTE: On 64-bit targets `time_t == i64 == lua_Integer`, so the range
234/// check in the C original (`(time_t)t == t`) is always satisfied.
235/// TODO(port): On hypothetical 32-bit `time_t` platforms the check would need
236/// to narrow `t` to `i32` and verify no truncation; flag for Phase B.
237fn check_time(state: &mut LuaState, arg: i32) -> Result<i64, LuaError> {
238    let t = state.check_arg_integer(arg)?;
239    Ok(t)
240}
241
242/// Returns the current Unix timestamp (seconds since 1970-01-01 UTC).
243fn unix_now(state: &LuaState) -> Result<i64, LuaError> {
244    if let Some(now_fn) = state.global().unix_time_hook {
245        return Ok(now_fn());
246    }
247
248    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
249    {
250        let _ = state;
251        return Err(LuaError::runtime(format_args!(
252            "current time not available in this host"
253        )));
254    }
255
256    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
257    {
258        use std::time::{SystemTime, UNIX_EPOCH};
259        Ok(SystemTime::now()
260            .duration_since(UNIX_EPOCH)
261            .map(|d| d.as_secs() as i64)
262            .unwrap_or(0))
263    }
264}
265
266fn native_temp_name() -> Result<Vec<u8>, LuaError> {
267    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
268    {
269        return Err(LuaError::runtime(format_args!(
270            "temporary filenames not available in this host"
271        )));
272    }
273
274    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
275    {
276        use std::sync::atomic::{AtomicU64, Ordering};
277        use std::time::{SystemTime, UNIX_EPOCH};
278
279        static COUNTER: AtomicU64 = AtomicU64::new(0);
280
281        let mut dir: Vec<u8> = {
282            let path = std::env::temp_dir();
283            #[cfg(unix)]
284            {
285                use std::os::unix::ffi::OsStrExt;
286                path.as_os_str().as_bytes().to_vec()
287            }
288            #[cfg(not(unix))]
289            {
290                path.to_string_lossy().as_bytes().to_vec()
291            }
292        };
293        if dir.last().copied() != Some(b'/') && dir.last().copied() != Some(b'\\') {
294            dir.push(b'/');
295        }
296
297        let nanos = SystemTime::now()
298            .duration_since(UNIX_EPOCH)
299            .map(|d| d.as_nanos())
300            .unwrap_or(0);
301        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
302
303        let suffix = format!("lua_{:x}_{:x}_{:x}", std::process::id(), nanos, n);
304        dir.extend_from_slice(suffix.as_bytes());
305        Ok(dir)
306    }
307}
308
309fn host_temp_name(state: &LuaState) -> Result<Vec<u8>, LuaError> {
310    match state.global().temp_name_hook {
311        Some(temp_fn) => temp_fn(),
312        None => native_temp_name(),
313    }
314}
315
316/// Decompose a Unix timestamp (UTC) into broken-down time fields.
317///
318/// Uses Howard Hinnant's `civil_from_days` algorithm (public domain, see
319/// <http://howardhinnant.github.io/date_algorithms.html#civil_from_days>),
320/// which is exact for all `i64` inputs across the proleptic Gregorian calendar.
321///
322/// PORT NOTE: C uses `gmtime_r(&t, &tmr)`.  Pure-Rust replacement because the
323/// crate forbids `unsafe` (required for libc FFI).  `tm_isdst` is always 0 for
324/// UTC.  `tm_wday` is 0-based with Sunday = 0 (matches POSIX).  `tm_yday` is
325/// 0-based (matches POSIX; `set_all_fields` adds 1 for the Lua-visible table).
326fn decompose_utc(t: i64) -> TmFields {
327    let days = t.div_euclid(86_400);
328    let sod = t.rem_euclid(86_400) as i32;
329
330    let tm_hour = sod / 3600;
331    let tm_min = (sod / 60) % 60;
332    let tm_sec = sod % 60;
333
334    let z = days + 719_468;
335    let era = (if z >= 0 { z } else { z - 146_096 }).div_euclid(146_097);
336    let doe = z - era * 146_097;
337    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
338    let y = yoe + era * 400;
339    let doy_mar = doe - (365 * yoe + yoe / 4 - yoe / 100);
340    let mp = (5 * doy_mar + 2) / 153;
341    let day = (doy_mar - (153 * mp + 2) / 5 + 1) as i32;
342    let month: i32 = if mp < 10 { (mp + 3) as i32 } else { (mp - 9) as i32 };
343    let year = y + if month <= 2 { 1 } else { 0 };
344
345    let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
346    const DAYS_BEFORE_MONTH: [i32; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
347    let tm_yday = DAYS_BEFORE_MONTH[(month - 1) as usize]
348        + (day - 1)
349        + if leap && month > 2 { 1 } else { 0 };
350
351    let tm_wday = (days + 4).rem_euclid(7) as i32;
352
353    TmFields {
354        tm_sec,
355        tm_min,
356        tm_hour,
357        tm_mday: day,
358        tm_mon: month - 1,
359        tm_year: (year - 1900) as i32,
360        tm_wday,
361        tm_yday,
362        tm_isdst: 0,
363    }
364}
365
366/// Compose a UTC Unix timestamp from broken-down time fields.
367///
368/// Inverse of `decompose_utc`.  Uses Howard Hinnant's `days_from_civil` and
369/// normalises month overflow into the year (matching `mktime`'s behaviour for
370/// the year/month axes).  Day-of-month, hour, minute, and second components
371/// are added linearly so out-of-range values normalise carry into the larger
372/// units exactly as `mktime` would for UTC.
373fn compose_utc(tm: &TmFields) -> i64 {
374    let mut y: i64 = (tm.tm_year as i64) + 1900;
375    let mut m: i64 = (tm.tm_mon as i64) + 1;
376    let dy = (m - 1).div_euclid(12);
377    y += dy;
378    m -= dy * 12;
379    let y_adj = if m <= 2 { y - 1 } else { y };
380    let era = (if y_adj >= 0 { y_adj } else { y_adj - 399 }).div_euclid(400);
381    let yoe = y_adj - era * 400;
382    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + (tm.tm_mday as i64) - 1;
383    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
384    let days = era * 146_097 + doe - 719_468;
385    days * 86_400 + (tm.tm_hour as i64) * 3600 + (tm.tm_min as i64) * 60 + (tm.tm_sec as i64)
386}
387
388/// Append the formatted result of a single `strftime` conversion specifier.
389///
390/// `cc` holds the canonical specifier bytes filled in by `check_strftime_option`:
391/// `cc[0] == b'%'`, `cc[1]` is the leading specifier char, and for 2-char
392/// specifiers `cc[2]` is the second char (an E/O modifier comes first in C, e.g.
393/// `%Ex` → `cc = "%Ex\0"`).  `oplen` is 1 or 2.
394///
395/// PORT NOTE: C delegates to the platform `strftime`.  Pure-Rust replacement for
396/// the same reason as `decompose_utc`.  The E/O modifiers are stripped (POSIX
397/// allows the implementation to ignore them and fall back to the unmodified
398/// form) — the test suite only requires that they not error.
399fn strftime_one(buf: &mut Vec<u8>, cc: &[u8; 4], oplen: usize, tm: &TmFields) {
400    use std::io::Write as _;
401    let spec = if oplen == 2 { cc[2] } else { cc[1] };
402    let year_full = (tm.tm_year as i64) + 1900;
403    let hour12 = {
404        let h = tm.tm_hour.rem_euclid(12);
405        if h == 0 { 12 } else { h }
406    };
407    const DAY_SHORT: [&[u8]; 7] = [b"Sun", b"Mon", b"Tue", b"Wed", b"Thu", b"Fri", b"Sat"];
408    const DAY_LONG: [&[u8]; 7] = [
409        b"Sunday", b"Monday", b"Tuesday", b"Wednesday", b"Thursday", b"Friday", b"Saturday",
410    ];
411    const MON_SHORT: [&[u8]; 12] = [
412        b"Jan", b"Feb", b"Mar", b"Apr", b"May", b"Jun", b"Jul", b"Aug", b"Sep", b"Oct", b"Nov",
413        b"Dec",
414    ];
415    const MON_LONG: [&[u8]; 12] = [
416        b"January", b"February", b"March", b"April", b"May", b"June", b"July", b"August",
417        b"September", b"October", b"November", b"December",
418    ];
419    let wday_idx = tm.tm_wday.rem_euclid(7) as usize;
420    let mon_idx = tm.tm_mon.rem_euclid(12) as usize;
421    match spec {
422        b'Y' => { let _ = write!(buf, "{}", year_full); }
423        b'y' => { let _ = write!(buf, "{:02}", year_full.rem_euclid(100)); }
424        b'C' => { let _ = write!(buf, "{:02}", year_full.div_euclid(100)); }
425        b'm' => { let _ = write!(buf, "{:02}", tm.tm_mon + 1); }
426        b'd' => { let _ = write!(buf, "{:02}", tm.tm_mday); }
427        b'e' => { let _ = write!(buf, "{:2}", tm.tm_mday); }
428        b'H' => { let _ = write!(buf, "{:02}", tm.tm_hour); }
429        b'I' => { let _ = write!(buf, "{:02}", hour12); }
430        b'k' => { let _ = write!(buf, "{:2}", tm.tm_hour); }
431        b'l' => { let _ = write!(buf, "{:2}", hour12); }
432        b'M' => { let _ = write!(buf, "{:02}", tm.tm_min); }
433        b'S' => { let _ = write!(buf, "{:02}", tm.tm_sec); }
434        b'w' => { let _ = write!(buf, "{}", tm.tm_wday); }
435        b'u' => {
436            let u = if tm.tm_wday == 0 { 7 } else { tm.tm_wday };
437            let _ = write!(buf, "{}", u);
438        }
439        b'j' => { let _ = write!(buf, "{:03}", tm.tm_yday + 1); }
440        b'a' => buf.extend_from_slice(DAY_SHORT[wday_idx]),
441        b'A' => buf.extend_from_slice(DAY_LONG[wday_idx]),
442        b'b' | b'h' => buf.extend_from_slice(MON_SHORT[mon_idx]),
443        b'B' => buf.extend_from_slice(MON_LONG[mon_idx]),
444        b'p' => buf.extend_from_slice(if tm.tm_hour < 12 { b"AM" } else { b"PM" }),
445        b'P' => buf.extend_from_slice(if tm.tm_hour < 12 { b"am" } else { b"pm" }),
446        b'D' | b'x' => {
447            let _ = write!(buf, "{:02}/{:02}/{:02}", tm.tm_mon + 1, tm.tm_mday, year_full.rem_euclid(100));
448        }
449        b'F' => {
450            let _ = write!(buf, "{}-{:02}-{:02}", year_full, tm.tm_mon + 1, tm.tm_mday);
451        }
452        b'T' | b'X' => {
453            let _ = write!(buf, "{:02}:{:02}:{:02}", tm.tm_hour, tm.tm_min, tm.tm_sec);
454        }
455        b'R' => { let _ = write!(buf, "{:02}:{:02}", tm.tm_hour, tm.tm_min); }
456        b'r' => {
457            let ampm: &[u8] = if tm.tm_hour < 12 { b"AM" } else { b"PM" };
458            let _ = write!(buf, "{:02}:{:02}:{:02} ", hour12, tm.tm_min, tm.tm_sec);
459            buf.extend_from_slice(ampm);
460        }
461        b'c' => {
462            let _ = write!(
463                buf,
464                "{} {} {:2} {:02}:{:02}:{:02} {}",
465                std::str::from_utf8(DAY_SHORT[wday_idx]).unwrap_or(""),
466                std::str::from_utf8(MON_SHORT[mon_idx]).unwrap_or(""),
467                tm.tm_mday,
468                tm.tm_hour,
469                tm.tm_min,
470                tm.tm_sec,
471                year_full,
472            );
473        }
474        b'n' => buf.push(b'\n'),
475        b't' => buf.push(b'\t'),
476        b'%' => buf.push(b'%'),
477        b'z' => buf.extend_from_slice(b"+0000"),
478        b'Z' => buf.extend_from_slice(b"UTC"),
479        b's' => { let _ = write!(buf, "{}", compose_utc(tm)); }
480        b'U' => {
481            let week = (tm.tm_yday + 7 - tm.tm_wday) / 7;
482            let _ = write!(buf, "{:02}", week);
483        }
484        b'W' => {
485            let mwday = if tm.tm_wday == 0 { 6 } else { tm.tm_wday - 1 };
486            let week = (tm.tm_yday + 7 - mwday) / 7;
487            let _ = write!(buf, "{:02}", week);
488        }
489        b'V' | b'g' | b'G' => {
490            let _ = write!(buf, "{:02}", 1);
491        }
492        _ => {}
493    }
494}
495
496// ── Library functions ─────────────────────────────────────────────────────────
497
498///
499/// Executes a shell command via the system shell.
500///
501/// Without arguments: tests whether a shell is available — returns `true`
502/// when an `os_execute_hook` is installed (we always have `sh` in that case),
503/// `false` otherwise.
504///
505/// With a command string: dispatches through `os_execute_hook` and pushes the
506/// three C-Lua return values `(boolean|nil, "exit"|"signal", int)` as defined
507/// by `luaL_execresult`.  Returns the stub `nil, errmsg, -1` triple when no
508/// hook is installed.
509pub(crate) fn os_execute(state: &mut LuaState) -> Result<usize, LuaError> {
510    let cmd = state.opt_arg_lstring(1, None)?;
511    match cmd {
512        None => {
513            // We have a shell if and only if the embedder installed a hook.
514            let has_shell = state.global().os_execute_hook.is_some();
515            state.push(LuaValue::Bool(has_shell));
516            Ok(1)
517        }
518        Some(cmd_bytes) => {
519            let hook = state.global().os_execute_hook;
520            match hook {
521                Some(execute_fn) => {
522                    // Clone to avoid holding a borrow across the hook call.
523                    let cmd_owned: Vec<u8> = cmd_bytes.to_vec();
524                    match execute_fn(&cmd_owned) {
525                        Ok(result) => {
526                            if result.success {
527                                state.push(LuaValue::Bool(true));
528                            } else {
529                                state.push(LuaValue::Nil);
530                            }
531                            let reason_str: &[u8] = match result.reason {
532                                OsExecuteReason::Exit => b"exit",
533                                OsExecuteReason::Signal => b"signal",
534                            };
535                            state.push_string(reason_str)?;
536                            state.push(LuaValue::Int(result.code as i64));
537                            Ok(3)
538                        }
539                        Err(e) => {
540                            state.push(LuaValue::Nil);
541                            let msg = match &e {
542                                LuaError::Runtime(LuaValue::Str(s)) => s.as_bytes().to_vec(),
543                                other => format!("{:?}", other).into_bytes(),
544                            };
545                            let s = state.intern_str(&msg)?;
546                            state.push(LuaValue::Str(s));
547                            state.push(LuaValue::Int(-1));
548                            Ok(3)
549                        }
550                    }
551                }
552                None => {
553                    state.push(LuaValue::Nil);
554                    state.push_string(b"os.execute: not implemented in lua-stdlib")?;
555                    state.push(LuaValue::Int(-1));
556                    Ok(3)
557                }
558            }
559        }
560    }
561}
562
563///
564/// Removes the file or empty directory at the given path.
565/// Returns `true` on success, or `nil, errmsg` on failure.
566pub(crate) fn os_remove(state: &mut LuaState) -> Result<usize, LuaError> {
567    let filename: Vec<u8> = state.check_arg_string(1)?.to_vec();
568    // `std::fs` is banned in lua-stdlib; delegate to the embedder hook.
569    let hook = state.global().file_remove_hook;
570    match hook {
571        Some(remove_fn) => match remove_fn(&filename) {
572            Ok(()) => {
573                state.push(LuaValue::Bool(true));
574                Ok(1)
575            }
576            Err(e) => {
577                state.push(LuaValue::Nil);
578                let msg = match &e {
579                    LuaError::Runtime(LuaValue::Str(s)) => s.as_bytes().to_vec(),
580                    other => format!("{:?}", other).into_bytes(),
581                };
582                let s = state.intern_str(&msg)?;
583                state.push(LuaValue::Str(s));
584                Ok(2)
585            }
586        },
587        None => {
588            state.push(LuaValue::Nil);
589            state.push_string(b"os.remove: no filesystem hook registered")?;
590            Ok(2)
591        }
592    }
593}
594
595///
596/// Renames (moves) a file from the first path to the second.
597/// Returns `true` on success, or `nil, errmsg` on failure.
598pub(crate) fn os_rename(state: &mut LuaState) -> Result<usize, LuaError> {
599    let fromname: Vec<u8> = state.check_arg_string(1)?.to_vec();
600    let toname: Vec<u8> = state.check_arg_string(2)?.to_vec();
601    // `std::fs` is banned in lua-stdlib; delegate to the embedder hook.
602    let hook = state.global().file_rename_hook;
603    match hook {
604        Some(rename_fn) => match rename_fn(&fromname, &toname) {
605            Ok(()) => {
606                state.push(LuaValue::Bool(true));
607                return Ok(1);
608            }
609            Err(e) => {
610                state.push(LuaValue::Nil);
611                let msg = match &e {
612                    LuaError::Runtime(LuaValue::Str(s)) => s.as_bytes().to_vec(),
613                    other => format!("{:?}", other).into_bytes(),
614                };
615                let s = state.intern_str(&msg)?;
616                state.push(LuaValue::Str(s));
617                return Ok(2);
618            }
619        },
620        None => {}
621    }
622    state.push(LuaValue::Nil);
623    state.push_string(b"os.rename: no filesystem hook registered")?;
624    Ok(2)
625}
626
627///
628/// Generates a unique temporary file name and pushes it as a string.
629/// Raises a runtime error if generation fails.
630///
631/// PORT NOTE: Temporary names are host capability. Native hosts can install
632/// `GlobalState::temp_name_hook`; bare WASM without that hook raises a Lua
633/// error instead of touching `std::env` / `std::time` stubs.
634pub(crate) fn os_tmpname(state: &mut LuaState) -> Result<usize, LuaError> {
635    let dir = host_temp_name(state)?;
636    state.push_string(&dir)?;
637    Ok(1)
638}
639
640///
641/// Reads the environment variable named by the first argument and pushes its
642/// value as a string, or `nil` if the variable is not set.
643pub(crate) fn os_getenv(state: &mut LuaState) -> Result<usize, LuaError> {
644    let name_bytes: Vec<u8> = state.check_arg_string(1)?.to_vec();
645
646    let result: Option<Vec<u8>> = match state.global().env_hook {
647        Some(env_fn) => env_fn(&name_bytes),
648        None => {
649            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
650            {
651                None
652            }
653
654            #[cfg(all(unix, not(all(target_arch = "wasm32", target_os = "unknown"))))]
655            {
656                use std::ffi::OsStr;
657                use std::os::unix::ffi::{OsStrExt, OsStringExt};
658                let os_name = OsStr::from_bytes(&name_bytes);
659                std::env::var_os(os_name).map(|v| v.into_vec())
660            }
661
662            #[cfg(all(not(unix), not(all(target_arch = "wasm32", target_os = "unknown"))))]
663            {
664                // TODO(port): from_utf8 used on Lua string data for OS API interop on
665                // non-Unix platforms.  Ideally replaced with wide-string conversion.
666                match std::str::from_utf8(&name_bytes) {
667                    Ok(name_str) => std::env::var(name_str).ok().map(|v| v.into_bytes()),
668                    Err(_) => None,
669                }
670            }
671        }
672    };
673
674    match result {
675        Some(val) => {
676            state.push_string(&val)?;
677        }
678        None => {
679            state.push(LuaValue::Nil);
680        }
681    }
682    Ok(1)
683}
684
685///
686/// Returns an approximation of the CPU time (in seconds) used by the program.
687pub(crate) fn os_clock(state: &mut LuaState) -> Result<usize, LuaError> {
688    let seconds = cpu_seconds(state)?;
689    state.push(LuaValue::Float(seconds));
690    Ok(1)
691}
692
693/// Returns program CPU time in seconds, as consumed by `os.clock`.
694///
695/// C's `clock()` reads `CLOCK_PROCESS_CPUTIME_ID`, which has no portable `std`
696/// equivalent. We route through `cpu_clock_hook` when the host installs one;
697/// otherwise native builds report monotonic wall time elapsed since the first
698/// call (the substitution wasi-libc and Emscripten make for `clock()`), and bare
699/// WASM reports the clock as unavailable rather than touching a stubbed source.
700fn cpu_seconds(state: &LuaState) -> Result<f64, LuaError> {
701    if let Some(clock_fn) = state.global().cpu_clock_hook {
702        return Ok(clock_fn());
703    }
704
705    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
706    {
707        let _ = state;
708        Err(LuaError::runtime(format_args!(
709            "CPU clock not available in this host"
710        )))
711    }
712
713    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
714    {
715        let _ = state;
716        use std::sync::OnceLock;
717        use std::time::Instant;
718        static START: OnceLock<Instant> = OnceLock::new();
719        Ok(START.get_or_init(Instant::now).elapsed().as_secs_f64())
720    }
721}
722
723///
724/// Formats the current (or a specified) date/time.
725///
726/// * Format starting with `'!'` → use UTC; otherwise local time.
727/// * Format `"*t"` → push a table with broken-down time fields.
728/// * Other format → push a formatted string, expanding `%`-specifiers via
729///   the C-library `strftime`.
730pub(crate) fn os_date(state: &mut LuaState) -> Result<usize, LuaError> {
731    // Clone to Vec<u8> so that `s` does not borrow from `state`.
732    let format: Vec<u8> = state.opt_arg_lstring(1, Some(b"%c"))?.unwrap_or_default();
733    let s: &[u8] = &format[..];
734
735    let t: i64 = if matches!(state.type_at(2), LuaType::None | LuaType::Nil) {
736        unix_now(state)?
737    } else {
738        check_time(state, 2)?
739    };
740
741    let (_use_utc, s): (bool, &[u8]) = if s.first() == Some(&b'!') {
742        (true, &s[1..])
743    } else {
744        (false, s)
745    };
746
747    // PORT NOTE: C distinguishes UTC (`gmtime_r`) from local time (`localtime_r`).
748    // The Rust port uses UTC unconditionally because reading the local timezone
749    // database requires `libc` FFI which the workspace forbids (`unsafe_code =
750    // forbid`).  The internal `os.date` / `os.time` round-trip used by the test
751    // suite remains consistent because `compose_utc` is the exact inverse of
752    // `decompose_utc`.  Wall-clock displays will read as UTC rather than local.
753    let stm = decompose_utc(t);
754
755    //      return luaL_error(L, "date result cannot be represented in this installation");
756    // (Phase A stub is always valid — no null check needed.)
757
758    if s == b"*t" {
759        state.create_table(0, 9)?;
760        set_all_fields(state, &stm)?;
761    } else {
762        let mut result: Vec<u8> = Vec::new();
763        let mut pos: usize = 0;
764
765        while pos < s.len() {
766            if s[pos] != b'%' {
767                result.push(s[pos]);
768                pos += 1;
769            } else {
770                pos += 1;
771                let mut cc = [0u8; 4];
772                cc[0] = b'%';
773                // Pass the remaining slice even if empty: checkoption's loop
774                // condition (oplen <= convlen) fails immediately on an empty
775                // slice, which causes it to raise "invalid conversion specifier"
776                // matching C behaviour for a trailing bare '%'.
777                let conv = &s[pos..];
778                let after = check_strftime_option(state, conv, &mut cc)?;
779                let oplen = conv.len() - after.len();
780                pos += oplen;
781                // The `%%` specifier is data-independent: strftime emits a literal
782                // `%` byte regardless of the broken-down time, so it is correct to
783                // handle here even while the rest of strftime is stubbed.
784                strftime_one(&mut result, &cc, oplen, &stm);
785                let _ = SIZE_TIME_FMT;
786            }
787        }
788        state.push_string(&result)?;
789    }
790    Ok(1)
791}
792
793///
794/// Without arguments: returns the current time as a Unix timestamp (integer).
795/// With a table argument: interprets the table as broken-down local time,
796/// normalises the fields via `mktime`, updates the table in place, and returns
797/// the resulting timestamp.
798pub(crate) fn os_time(state: &mut LuaState) -> Result<usize, LuaError> {
799    let t: i64;
800
801    if matches!(state.type_at(1), LuaType::None | LuaType::Nil) {
802        t = unix_now(state)?;
803    } else {
804        state.check_arg_type(1, LuaType::Table)?;
805        // PORT NOTE: must use the public-API `set_top` (relative to the current
806        // C-frame's `func`), not `LuaState::set_top` which is an inherent that
807        // sets an absolute stack index and would truncate the entire stack.
808        lua_vm::api::set_top(state, 1)?;
809
810        let tm_year  = get_field(state, b"year",  -1, 1900)?;
811        let tm_mon   = get_field(state, b"month", -1, 1)?;
812        let tm_mday  = get_field(state, b"day",   -1, 0)?;
813        let tm_hour  = get_field(state, b"hour",  12, 0)?;
814        let tm_min   = get_field(state, b"min",   0,  0)?;
815        let tm_sec   = get_field(state, b"sec",   0,  0)?;
816        let tm_isdst = get_bool_field(state, b"isdst")?;
817
818        let raw = TmFields {
819            tm_year,
820            tm_mon,
821            tm_mday,
822            tm_hour,
823            tm_min,
824            tm_sec,
825            tm_isdst,
826            ..TmFields::default()
827        };
828
829        // PORT NOTE: C `mktime` interprets the broken-down time as local; we
830        // interpret it as UTC for the same reason `os_date` decomposes as UTC.
831        // `compose_utc` normalises month-axis overflow itself, then a
832        // round-trip through `decompose_utc` normalises every other axis
833        // (day-of-month, hour, minute, second) so the post-call table holds
834        // canonical field values just like `mktime`.
835        t = compose_utc(&raw);
836        let stm = decompose_utc(t);
837
838        set_all_fields(state, &stm)?;
839    }
840
841    //        return luaL_error(L, "time result cannot be represented in this installation");
842    // PORT NOTE: On 64-bit targets time_t == i64 == lua_Integer so the cast check
843    // is a no-op.  We only guard against mktime's failure sentinel (−1).
844    if t == -1 {
845        return Err(LuaError::runtime(format_args!(
846            "time result cannot be represented in this installation"
847        )));
848    }
849
850    state.push(LuaValue::Int(t));
851    Ok(1)
852}
853
854///
855/// Returns the number of seconds between two time values as a float (`t1 − t2`).
856///
857/// PORT NOTE: C's `difftime(t1, t2)` returns `t1 − t2` as a `double`.  For
858/// 64-bit `time_t` this is exact as `f64` up to approximately 2^53 seconds
859/// (~285 million years), which is sufficient for all practical timestamps.
860pub(crate) fn os_difftime(state: &mut LuaState) -> Result<usize, LuaError> {
861    let t1 = check_time(state, 1)?;
862    let t2 = check_time(state, 2)?;
863    state.push(LuaValue::Float((t1 - t2) as f64));
864    Ok(1)
865}
866
867///
868/// Sets the locale for the given category and pushes the resulting locale name
869/// as a string, or `nil` on failure.
870pub(crate) fn os_setlocale(state: &mut LuaState) -> Result<usize, LuaError> {
871    const CAT_NAMES: &[&[u8]] = &[
872        b"all", b"collate", b"ctype", b"monetary", b"numeric", b"time",
873    ];
874
875    let locale: Option<Vec<u8>> = state.opt_arg_lstring(1, None)?;
876
877    let _op: usize = state.check_arg_option(2, Some(b"all"), CAT_NAMES)?;
878
879    // PORT NOTE: calling libc::setlocale requires unsafe (banned in lua-stdlib, budget=0).
880    // Rust programs inherit the "C" locale by default and never change it, so returning
881    // "C" for the C locale (and nil for anything else) is faithful for this build:
882    // "C" is the only locale guaranteed available on every POSIX system.
883    let result_locale: Option<&[u8]> = match locale.as_deref() {
884        None => Some(b"C"),          // query: return current locale (always "C" here)
885        Some(b"C") | Some(b"POSIX") => Some(b"C"),  // setting to "C"/"POSIX" always succeeds
886        Some(_) => None,             // any other locale: unsupported in this build
887    };
888    match result_locale {
889        Some(s) => { state.push_string(s)?; }
890        None => state.push(LuaValue::Nil),
891    }
892    Ok(1)
893}
894
895///
896/// Exits the host process with the given status code (default `EXIT_SUCCESS = 0`).
897/// If the second argument is true, also closes the Lua state before exiting.
898///
899/// This function is expected to terminate the process and never return normally.
900pub(crate) fn os_exit(state: &mut LuaState) -> Result<usize, LuaError> {
901    //      status = lua_toboolean(L, 1) ? EXIT_SUCCESS : EXIT_FAILURE;
902    //    else
903    //      status = (int)luaL_optinteger(L, 1, EXIT_SUCCESS);
904    let exit_code: i32 = if matches!(state.type_at(1), LuaType::Boolean) {
905        if state.to_boolean(1) { 0 } else { 1 } // EXIT_SUCCESS = 0, EXIT_FAILURE = 1
906    } else {
907        state.opt_arg_integer(1, 0)? as i32
908    };
909
910    if state.to_boolean(2) {
911        state.close();
912    }
913
914    //
915    // `std::process::exit` remains restricted to `lua-cli`. A regular
916    // `LuaError` is also wrong here: Lua `pcall` must not catch `os.exit`.
917    // Use a typed panic payload as internal non-local control flow; the CLI
918    // catches it at the process boundary and converts it to an `ExitCode`.
919    std::panic::panic_any(LuaExit(exit_code));
920}
921
922// ── Registration table and entry point ───────────────────────────────────────
923
924/// Type alias for a Lua native function implementation in Rust.
925///
926/// TODO(port): align with the canonical `lua_CFunction` / `NativeFn` type defined
927/// in `lua-types` once that crate stabilises.
928pub type NativeFn = fn(&mut LuaState) -> Result<usize, LuaError>;
929
930///
931/// Mapping from Lua-visible names to the Rust implementations of each `os.*`
932/// function.
933pub const OS_LIB: &[(&[u8], NativeFn)] = &[
934    (b"clock",     os_clock),
935    (b"date",      os_date),
936    (b"difftime",  os_difftime),
937    (b"execute",   os_execute),
938    (b"exit",      os_exit),
939    (b"getenv",    os_getenv),
940    (b"remove",    os_remove),
941    (b"rename",    os_rename),
942    (b"setlocale", os_setlocale),
943    (b"time",      os_time),
944    (b"tmpname",   os_tmpname),
945];
946
947///
948/// Opens the `os` library: creates a new table populated with `OS_LIB` and
949/// leaves it on the stack.
950///
951/// PORT NOTE: `register_lib` is the Rust equivalent of `luaL_newlib`; it creates
952/// a fresh table, fills it from the `(name, fn)` pair slice, and pushes it.
953pub fn open_os(state: &mut LuaState) -> Result<usize, LuaError> {
954    state.register_lib(b"os", OS_LIB)?;
955    Ok(1)
956}
957
958// ──────────────────────────────────────────────────────────────────────────
959// PORT STATUS
960//   source:        src/loslib.c  (430 lines, 12 functions)
961//   target_crate:  lua-stdlib
962//   confidence:    medium
963//   todos:         18
964//   port_notes:    4
965//   unsafe_blocks: 0
966//   notes:         Logic structure faithful. File/process/env/temp/time
967//                  operations route through host hooks where they need OS
968//                  capabilities for sandboxed and bare-WASM hosts.
969//                  Time formatting (os.date, os.time) needs libc or chrono in
970//                  Phase B.  os.clock routes through cpu_clock_hook with a
971//                  monotonic-wall fallback (no std CPU-time source).
972//                  os.exit needs a LuaError::Exit(i32)
973//                  variant.  check_strftime_option logic is fully translated.
974//                  os_getenv uses OsStr::from_bytes on Unix (no from_utf8).
975// ──────────────────────────────────────────────────────────────────────────