airsl 0.1.2

Embeddable Lua 5.4 runtime with a capability-gated sandbox and a host standard library
Documentation
# How-to

Recipes for specific jobs. Each one assumes you already know what a grant is; if not, the
[tutorial](tutorial.md) gets you there in ten minutes.

Every snippet here was run before it was written down. Where output is shown, it is the output.

## From Lua

### Read and write files

```lua
local text = airsstack.fs.read(path)
airsstack.fs.write(path, text)
```

```bash
airsl run --allow-read . --allow-write ./out script.lua
```

A write root is **not** implicitly readable. Grant both if the script reads back what it wrote.

### Replace a file without a torn read

```lua
airsstack.fs.atomic_write(
  airsstack.path.join(root, "index.json"),
  airsstack.json.encode_pretty(index)
)
```

The staging file is created in the target's own directory and renamed over it, so a concurrent
reader sees either the old contents or the new ones and never half of each. `/tmp` is not used,
because a rename across filesystems is not atomic.

JSON object keys always sort, so the same table produces the same bytes on every run — which is what
makes an index file diffable.

### Claim something exactly once

```lua
if airsstack.fs.create_exclusive(path_to_claim) then
  -- you won; do the work
end
```

```
true
false
```

`create_exclusive` returns `false` rather than raising when the file already exists, because losing
that race is the expected *other outcome*, not a failure. This is `O_CREAT|O_EXCL`: a read-then-write
would let several concurrent callers all believe they won.

### Find files by pattern

```lua
for _, relative in ipairs(airsstack.glob.walk(root, "**/*.lua")) do
  print(relative)
end
```

```
a.lua
sub/b.lua
```

Paths come back relative to `root` and in sorted order, so output does not depend on the filesystem's
enumeration order. `**/` matches zero or more segments, so `**/Cargo.toml` finds a root-level one.

`glob.walk` reads directories, so it needs the same read grant `fs` would. `glob.match` is pure
pattern arithmetic and needs nothing.

### Run a program

```lua
local r = airsstack.proc.run({"git", "rev-parse", "--abbrev-ref", "HEAD"})
if r.status ~= 0 then
  airsstack.stdio.error("git failed: " .. r.stderr)
else
  print(airsstack.regex.replace([[\s+$]], r.stdout, ""))
end
```

```bash
airsl run --allow-exec git script.lua
```

`run` takes an argv array and there is no string form, so there is no shell, no word splitting and no
quoting bug available to you. A non-zero status is a *result*, not an error — the script asked what
happened, so it gets told.

The grant matches the program name as written: `--allow-exec git` does not permit `/usr/bin/git`.

### Read environment variables

```lua
local home = airsstack.env.get("HOME")
```

```bash
airsl run --allow-env HOME script.lua
```

An ungranted name **raises**; a granted-but-unset name returns `nil`. The distinction is deliberate —
otherwise a script reports a missing configuration when it was actually denied:

```
ok=false
denied: `SECRET_TOKEN` is not granted — the allowed names are HOME, MY_VAR
```

`env.all()` returns only the granted names, never everything the host process inherited.

### Pass a variable to a child process

```lua
airsstack.env.set("MY_VAR", "from-lua")
local r = airsstack.proc.run({"sh", "-c", "echo $MY_VAR"})
```

```
child sees: from-lua
```

`env.set` writes a per-process overlay that `env.get`, `env.all` and `proc.run` all consult. It does
**not** change the host process's own environment — partly because `std::env::set_var` is `unsafe` in
Edition 2024 and this crate forbids `unsafe`, and partly because a sandboxed script silently changing
the host's environment is not a capability anyone meant to grant.

### Derive a stable project key from a path

```lua
local key = airsstack.hash.sha1(repo_path):sub(1, 8)
```

```
d50c1217
```

SHA-1 is here for compatibility only: the enforcement dispatcher takes `sha1(path)[:8]` and
`ensure-layout.sh` uses `shasum | cut -c1-8`. A port that used SHA-256 would silently re-key every
project and orphan its stored specs and plans. Use `hash.sha256` for anything new.

`hash.hash_file` reads a file, so it needs the read grant that reading it would.

### Format a timestamp reproducibly

```lua
airsstack.time.format(1700000000)              --> 2023-11-14T22:13:20Z
airsstack.time.format(1700000000, "%Y-%m-%d")  --> 2023-11-14
```

`format` takes an explicit instant rather than defaulting to now, and renders UTC rather than local
time, so a script that writes a timestamp into a file produces the same bytes on every machine.

There is no `sleep`: an arbitrary pause would defeat the instruction ceiling, which is the only
defence against a script that never finishes.

### Handle text as characters rather than bytes

```lua
#"café"            --> 5   (bytes)
utf8.len("café")   --> 4   (characters)
```

`utf8` is on every preset. Truncating on the byte count can cut through the middle of a character.

### Debug a refusal

Read the whole message — it names the roots that *were* granted:

```
airsl: fs.read denied: `/etc/hostname` is outside the granted read roots: /home/me/journal
```

The usual cause is a grant one directory too deep. If the roots list looks right but nothing matches,
check `airsl doctor --policy <preset>` to see the resolved policy.

Relative and symlinked grant roots are resolved when the grant is built, so `--allow-read .` and a
root reached through a symlink both work.

## From Rust

### Embed the runtime

```rust
let engine = Engine::builder().policy(Policy::confined()).build()?;
let script = Script::from_source("return airsstack.json.encode({ok = true})", "demo")?;
println!("{}", engine.eval_to::<String>(&script)?);   // {"ok":true}
```

There is no `build()` until `policy()` has been called, so "did I remember to sandbox it" is not a
question any call site has to ask.

### Grant authority from Rust

```rust
let policy = Policy::confined()
    .with_grants(GrantSet::declared().with_fs(|fs| fs.read(&dir)));
let engine = Engine::builder().policy(policy).build()?;

let script = Script::from_source("return airsstack.fs.read(arg[1])", "read")?
    .with_args([path.to_string_lossy().into_owned()]);
engine.eval_to::<String>(&script)?;                   // body
```

Each axis has a wither: `with_fs`, `with_env`, `with_proc`. Nothing is granted until you say so.

### Reuse one engine

```rust
let add = Script::from_source("return tonumber(arg[1]) * 2", "add")?;
for n in 1..=3 {
    total += engine.eval_to::<i64>(&add.clone().with_args([n.to_string()]))?;
}
```

Reuse is worth about thirty-fold — 4.6 µs against 136 µs to build a fresh state — so for anything
that evaluates repeatedly this is the shape to use, not an optimisation to add later. Each
evaluation gets its own `arg` table, its own `require` root and the whole instruction budget.

What a reused engine does **not** reset is the Lua globals a script wrote. Give one engine scripts
that trust each other.

### Share an engine between threads

```rust
let shared = Arc::new(Engine::builder().policy(Policy::confined()).build()?);
// ... spawn threads, each calling shared.eval_to(...)
```

```
0,1,2,3
```

`Engine` is `Send + Sync` and evaluations are serialised, so each thread gets its own arguments.
This is a way to avoid rebuilding a state, **not** a way to get parallelism — one Lua state cannot
execute in parallel however you hold it.

### Add your own host module

```rust
struct Metrics(ModuleName);

impl HostModule for Metrics {
    fn name(&self) -> &ModuleName { &self.0 }

    fn install(
        &self,
        lua: &airsl::mlua::Lua,
        table: &airsl::mlua::Table,
        cx: &InstallContext<'_>,
    ) -> Result<()> {
        let root = cx.root_table().as_str().to_owned();
        let f = lua
            .create_function(move |_, name: String| Ok(format!("{root}.metrics recorded {name}")))
            .map_err(|e| airsl::Error::lua("metrics", e))?;
        table.set("record", f).map_err(|e| airsl::Error::lua("metrics", e))
    }
}

let mut set = airsl::modules::stdlib()?;
set.insert(Box::new(Metrics(ModuleName::new("metrics")?)))?;

let engine = Engine::builder()
    .policy(Policy::confined())
    .root_table(RootTable::new("myapp")?)
    .stdlib(set)
    .build()?;
```

```
myapp.metrics recorded x
```

Three things to know:

- **Depend on `airsl::mlua`,** not on `mlua` directly. A separately declared version produces type
  errors that never mention the real cause.
- **`install` receives the policy** through `InstallContext`. A module that guards an operation reads
  its authority from there, so what it enforces and what `airsl doctor` reports cannot disagree.
- **Name your own root table.** A module contributed by a third party should not land in a namespace
  called `airsstack`.

### React to a failure without propagating it

```rust
if let Err(error) = engine.eval(&script) {
    let breached = error.exhausted_limit().is_some();
    if !FailurePolicy::FailOpen.swallows_errors() || breached {
        eprintln!("{error}");
    }
}
```

`exhausted_limit` separates "this script is broken" from "this script ate the host's memory or never
terminated". A caller that discards ordinary failures usually still wants to hear the second, because
it is a fact about the machine rather than a diagnostic the script chose to emit.

## Testing

### Run a suite

```bash
airsl test .
airsl test --allow-read . scripts/
```

Test files are named `*_test.lua` or `test_*.lua` and return a table of named functions. A test
passes by returning and fails by raising.

Each file gets a fresh engine, so one file cannot leave globals behind for the next. Finding no test
files at all exits non-zero — "no tests" and "all tests passed" must not read the same to CI.

### Keep tests grant-free

Push logic out of the I/O and into pure functions, then test those. A suite that needs no grants is
one nobody has to think about:

```lua
return {
  finds_every_level = function()
    assert(#headings.find("# One\ntext\n## Two\n") == 2)
  end,
}
```

## See also

- **[Tutorial]tutorial.md** — the guided version, from nothing to a working hook.
- **[Host standard library]stdlib.md** — the full roster and the rules every module follows.
- **[Sandbox]sandbox.md** — what a grant is and where it is enforced.
- **[`airsl-cli` README]../../airsl-cli/README.md** — every flag the binary takes.