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