diff --git a/docs/README.md b/docs/README.md index d1ed665..d1895b2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ also works and returns the same table). | [`utils`](utils.md) | JSON encode/decode, the `NULL` marker, error-trapping (`try`/`tryn`), value `dump`. | | [`math`](math.md) | Additions to stock `math`: `round`, `clamp`, `isFinite`, `sign`, `scale`. | | [`table`](table.md) | Additions to stock `table`: map/filter/reduce, merge, deep-copy, aggregates, lookups, read-only proxy. | -| [`os`](os.md) | Sandbox additions: `microtime` (sub-second clock) and async `sleep`. | +| [`os`](os.md) | Sandbox additions: `microtime` (sub-second clock), async `sleep`, and `args` (script arguments). | | [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `LUNA_LOG`. | | [`sqlite`](sqlite.md) | Embedded SQLite: connect, query, parameters, transactions. | | [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions, per-origin permissions. | diff --git a/docs/os.md b/docs/os.md index c24568d..590bce6 100644 --- a/docs/os.md +++ b/docs/os.md @@ -2,9 +2,10 @@ Luna runs scripts in a sandbox, so the standard `os` table is trimmed to its safe, time-related functions — `os.time`, `os.clock`, `os.date`, -`os.difftime`, `os.getenv` — while the parts that touch the system -(`os.execute`, `os.remove`, `os.exit`, …) are removed. To that trimmed table Luna -adds two functions: a high-resolution clock and an async sleep. +`os.difftime` — while the parts that touch the system +(`os.execute`, `os.getenv`, `os.remove`, `os.exit`, …) are removed. To that +trimmed table Luna adds a high-resolution clock, an async sleep, and access to +the script's command-line arguments. ```lua local t0 = os.microtime() @@ -29,6 +30,37 @@ It reads the system wall clock, so it tracks real time (and can jump if the cloc is adjusted); for interval timing the difference of two readings is what you want. +## `os.args()` + +Return the command-line arguments passed to the script, as an array (a fresh +table on every call, so it is safe to mutate). On the `luna` command line the +script's arguments are everything after the script path — even a token spelled +like a luna flag goes to the script there, so luna's own flags (`--sandbox` & +co.) must come before the path. A `--` between the path and the arguments is +accepted and skipped: + +```sh +luna script.lua one two --three +luna --sandbox script.lua -- one two --three +``` + +```lua +-- script.lua +for i, arg in ipairs(os.args()) do + print(i, arg) --> 1 one / 2 two / 3 --three +end +``` + +An executable script with a `#!/usr/bin/env luna` shebang line receives its +arguments the same way, with no `--` needed: + +```sh +./script.lua one two --three +``` + +In the REPL `os.args()` returns an empty table. Arguments are passed through +as raw byte strings, without UTF-8 conversion. + ## `os.sleep(seconds)` Pause for `seconds` (a float, so `os.sleep(0.1)` is 100 ms). This is an **async** @@ -64,8 +96,8 @@ explanation — the rule is to run anything that needs to sleep through ## Notes - **The sandbox keeps the time functions.** `os.time`, `os.clock`, `os.date`, - `os.difftime`, and `os.getenv` work as in stock Lua; system-mutating functions - are not present. + and `os.difftime` work as in stock Lua; functions that touch the system are + not present. - **`microtime` for durations, `time` for timestamps.** Use `os.microtime` when you need sub-second precision or are timing an interval; `os.time` when whole seconds suffice. diff --git a/lua/demo/args.lua b/lua/demo/args.lua new file mode 100755 index 0000000..4f949b0 --- /dev/null +++ b/lua/demo/args.lua @@ -0,0 +1,36 @@ +#!/usr/bin/env luna +-- Showcase os.args(): the command-line arguments passed to the script. +-- +-- Try: +-- luna lua/demo/args.lua greet --name=World extra +-- luna --sandbox lua/demo/args.lua -- greet --name=World +-- or make the file executable and call it directly (the shebang works too): +-- chmod +x lua/demo/args.lua +-- ./lua/demo/args.lua greet --name=World + +local args = os.args() + +print("os.args() = " .. utils.dump(args)) + +if #args == 0 then + print("no arguments given — try: luna lua/demo/args.lua greet --name=World extra") + return +end + +-- A tiny argument parser: --key=value pairs become options, the rest positionals. +local opts, positional = {}, {} +for _, arg in ipairs(args) do + local key, value = arg:match("^%-%-([%w-]+)=(.*)$") + if key then + opts[key] = value + else + table.insert(positional, arg) + end +end + +print("options = " .. utils.dump(opts)) +print("positionals = " .. utils.dump(positional)) + +if positional[1] == "greet" then + print(("Hello, %s!"):format(opts.name or "stranger")) +end diff --git a/lua/stubs/os.lua b/lua/stubs/os.lua index 75ffbfa..5097669 100644 --- a/lua/stubs/os.lua +++ b/lua/stubs/os.lua @@ -3,12 +3,20 @@ -- Type stubs for the Luna additions to the sandboxed `os` table. -- -- The sandbox trims `os` to its time-related functions — os.time, os.clock, --- os.date, os.difftime, os.getenv — plus the two additions below. The +-- os.date, os.difftime, os.getenv — plus the additions below. The -- system-access functions (os.execute, os.exit, os.remove, os.rename, -- os.tmpname, os.setlocale) do NOT exist at runtime; lua-language-server -- cannot hide individual fields, so it still suggests them — do not use them. -- Reference: docs/os.md +---Command-line arguments passed to the script: everything after the script +---path on the `luna` command line (or after a literal `--`), including +---arguments given directly to an executable `#!/usr/bin/env luna` script. +---Returns a fresh array each call; empty in the REPL. Arguments are raw byte +---strings (no UTF-8 conversion). +---@return string[] args +function os.args() end + ---Current time as a Unix timestamp in seconds, as a float with sub-second ---precision (wall clock — may jump if the system clock is adjusted). Use the ---difference of two readings for interval timing. diff --git a/src/lua_tests/os_tests.rs b/src/lua_tests/os_tests.rs index 9a45624..09160fe 100644 --- a/src/lua_tests/os_tests.rs +++ b/src/lua_tests/os_tests.rs @@ -155,6 +155,45 @@ fn test_os_missing_dangerous_functions() { } } +#[test] +fn test_os_args() { + let lua = super::lua(); + + // Without ScriptArgs app data (tests, and structurally the REPL): empty. + let n: i64 = lua.load(r#"return #os.args()"#).eval().unwrap(); + assert_eq!(n, 0); + + // main plants the parsed trailing arguments; non-UTF-8 bytes pass through. + use std::os::unix::ffi::OsStringExt; + lua.set_app_data(crate::stdlib::os_ext::ScriptArgs(vec![ + "hello".into(), + "--flag".into(), + std::ffi::OsString::from_vec(vec![0xff, b'x']), + ])); + let ok: bool = lua + .load( + r#" + local a = os.args() + return #a == 3 and a[1] == "hello" and a[2] == "--flag" and a[3] == "\xffx" + "#, + ) + .eval() + .unwrap(); + assert!(ok); + + // Each call returns a fresh table — mutations do not leak into the next. + let ok: bool = lua + .load( + r#" + os.args()[1] = "changed" + return os.args()[1] == "hello" + "#, + ) + .eval() + .unwrap(); + assert!(ok); +} + #[test] fn test_os_microtime() { let lua = super::lua(); diff --git a/src/main.rs b/src/main.rs index 6441074..de09f34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,8 +16,17 @@ mod stdlib; #[derive(Parser, Debug)] #[command(version, about)] struct Args { - /// Path to the Lua script to execute. Omit to start an interactive REPL. - filepath: Option, + /// Path to the Lua script to execute, followed by the script's arguments + /// (readable as os.args()). Omit the path to start an interactive REPL. + /// + /// Everything after the script path belongs to the script — even tokens + /// spelled like the flags above — so luna's own flags must come first. + /// One positional per the interpreter convention (`python x.py -v` passes + /// -v to x.py); it also makes `#!/usr/bin/env luna` scripts receive their + /// command-line arguments. An optional `--` between the path and the + /// arguments is skipped. + #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "FILEPATH [ARGS]")] + script: Vec, /// Grant all filesystem access without prompting or recording anything. #[arg(long, conflicts_with = "no_fs")] @@ -111,9 +120,29 @@ async fn main() { async fn run() -> Result<(), Box> { let args = Args::parse(); + // Split the trailing positionals into the script path and its arguments. + // clap only treats `--` as an escape before the first positional + // (`luna -- x.lua`); after the path it comes through literally, so a + // leading one is dropped here — `luna x.lua -- -v` and `luna x.lua -v` + // pass the same args. Later occurrences are the script's to interpret. + let (filepath, script_args) = match args.script.split_first() { + None => (None, &[] as &[std::ffi::OsString]), + Some((path, rest)) => { + let rest = match rest.split_first() { + Some((sep, rest)) if sep.as_os_str() == "--" => rest, + _ => rest, + }; + (Some(PathBuf::from(path)), rest) + } + }; + let lua = sandbox::create_sandboxed_lua()?; - let Some(filepath) = args.filepath.clone() else { + // Exposed to scripts as os.args(). Empty in the REPL (any trailing + // argument would have been taken as the script path). + lua.set_app_data(stdlib::os_ext::ScriptArgs(script_args.to_vec())); + + let Some(filepath) = filepath else { // In the REPL, `.env` and modules are looked up from the CWD. // Permissions are prompted for but never persisted (no script // identity to record them under). diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 30e33af..6672302 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -5,7 +5,9 @@ mod http; mod logging; mod lua_dump; mod math; -mod os_ext; +// pub(crate) for ScriptArgs — `main` plants the command-line arguments +// destined for the script (os.args()). +pub(crate) mod os_ext; // pub(crate) for Policy & co. — `main` builds the permission policy from // the CLI flags and the script's canonical path. pub(crate) mod perm; diff --git a/src/stdlib/os_ext.rs b/src/stdlib/os_ext.rs index 9d4f769..33b650b 100644 --- a/src/stdlib/os_ext.rs +++ b/src/stdlib/os_ext.rs @@ -1,12 +1,36 @@ +use std::ffi::OsString; +use std::os::unix::ffi::OsStrExt; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use mlua::prelude::LuaResult; use mlua::{Lua, Table as LuaTable, Value as LuaValue}; +/// Command-line arguments destined for the script — everything after the +/// script path (or after `--`) on the `luna` command line. Planted into app +/// data by `main`; absent in tests, where `os.args()` returns an empty table. +pub(crate) struct ScriptArgs(pub(crate) Vec); + /// Extend the already-sandboxed `os` table with non-standard additions. pub(super) fn install(lua: &Lua) -> LuaResult<()> { let os: LuaTable = lua.globals().get("os")?; + // os.args() — the arguments passed to the script, as a fresh array each + // call so the caller can mutate the result freely. + os.raw_set( + "args", + lua.create_function(|lua, ()| { + let table = lua.create_table()?; + if let Some(args) = lua.app_data_ref::() { + for (i, arg) in args.0.iter().enumerate() { + // OS arguments are arbitrary bytes, and so are Lua strings — + // no lossy UTF-8 conversion. + table.raw_set(i + 1, lua.create_string(arg.as_bytes())?)?; + } + } + Ok(table) + })?, + )?; + // os.microtime() — Unix timestamp as f64 with sub-second precision. os.raw_set( "microtime",