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