airsl 0.1.3

Embeddable Lua 5.4 runtime with a capability-gated sandbox and a host standard library
Documentation
# multi-file-project

A Lua project split across files, resolving against its own directory and nowhere else.

This gets its own example because `require` is the one global whose *identity* changes with the
language surface rather than merely its availability. `trusted` keeps Lua's own loader, which
searches `package.path` — the process's working directory and its installation prefixes — and can
open anything on the machine. `confined` replaces it with a Rust function that resolves under the
directory the script was read from, has no `package` table to configure, and refuses a name that
could describe a parent. To a script that only checks whether the global is there, both answer
"yes", which is where [policy-presets](../policy-presets/) has to stop. This is where the two are
pulled apart.

The project is a real one rather than a fixture. `app.lua` requires `lib` and `lib.text`,
`lib/init.lua` requires `lib.text` as well, and `ring` requires itself the long way round.

## Run

```bash
cargo run -p airsl --example multi-file-project
```

Every `.lua` file here also runs on its own. The host's two arguments are optional, and without
them the refusals name the real directory instead of `<project>`:

```bash
airsl run crates/airsl/examples/multi-file-project/app.lua
airsl run --policy trusted crates/airsl/examples/multi-file-project/which_require.lua
```

## Output

```
-- a fresh engine
evaluation 1 on this engine
lib.version: 1.0.0
lib.text.slug("Multi File Project"): multi-file-project
lib.text is the table lib itself required: true
module bodies run so far: lib, lib.text
require("../secrets"): invalid require target `../secrets`: must contain only ASCII letters, digits, underscores, hyphens and dots
require("missing"): module `missing` not found under `<project>`
require("ring"): lua error in ring: lua error in ring.link: module `ring` requires itself, directly or indirectly, under `<project>`

-- the same engine again
evaluation 2 on this engine
lib.version: 1.0.0
lib.text.slug("Multi File Project"): multi-file-project
lib.text is the table lib itself required: true
module bodies run so far: lib, lib.text
require("../secrets"): invalid require target `../secrets`: must contain only ASCII letters, digits, underscores, hyphens and dots
require("missing"): module `missing` not found under `<project>`
require("ring"): lua error in ring: lua error in ring.link: module `ring` requires itself, directly or indirectly, under `<project>`

-- a second engine
evaluation 1 on this engine
lib.version: 1.0.0
lib.text.slug("Multi File Project"): multi-file-project
lib.text is the table lib itself required: true
module bodies run so far: lib, lib.text
require("../secrets"): invalid require target `../secrets`: must contain only ASCII letters, digits, underscores, hyphens and dots
require("missing"): module `missing` not found under `<project>`
require("ring"): lua error in ring: lua error in ring.link: module `ring` requires itself, directly or indirectly, under `<project>`

-- one name, three functions
trusted  require=native   package=yes searchers=3  require("../secrets"): searched for it as a path
confined require=confined package=no  searchers=0  require("../secrets"): refused, the name is unrepresentable
pure     require=absent   package=no  searchers=0  require("../secrets"): there is no require to call
memory   require=absent   and no directory that could give it one
```

`<project>` is substituted by `app.lua` itself, from the directory the host hands it. Every line
above is printed from Lua and none of it from Rust — not because the two would reorder (they flush
per line and interleave correctly, as [`custom-module`](../custom-module/) demonstrates by doing
it), but because the substitution above has to happen on the Lua side, and a heading printed from
Rust would be the one line in the block that had not been through it.

## What it demonstrates

- **Two different functions behind one name.** `RequireDisposition::decide`
  (`src/require_loader.rs:67`) answers with three values rather than two: `Full` keeps the loader
  `package` installed (`src/require_loader.rs:69`), `Restricted` *with a root* gets the confined one
  (`src/require_loader.rs:70`), and everything else has the global cleared
  (`src/require_loader.rs:71`). The `trusted` row is the native loader — `package` is present, its
  searcher list is there to append to, and `require("../secrets")` is a path it goes off and looks
  for. The `confined` row has no `package` at all, so there is nothing to configure and no way to
  widen where it looks.

- **The root is inferred, not configured.** `Script::from_file` takes it from the file's parent
  directory (`src/script.rs:63`), and a bare filename roots at `.` rather than at nothing
  (`src/script.rs:169`) — otherwise `app.lua` and `./app.lua` would name the same file and only one
  of them would get a `require`. `Script::from_source` records no root at all
  (`src/script.rs:37`), which is why the last line of the output reads `require=absent` under the
  very policy the row above it calls `confined`: there was no directory to confine a loader to, and
  falling back to the working directory would have made the same script resolve differently
  depending on where the host was started.

- **`require("../secrets")` is refused at the name, before any path exists.** `RequireTarget::new`
  accepts only ASCII letters, digits, `_`, `-` and `.` (`src/types/require_target.rs:52`) and
  rejects any empty dot-separated component (`src/types/require_target.rs:58`), so `..` has no
  spelling as a target. That is a stronger guarantee than a path check rather than a tidier one: no
  candidate path is built, so there is nothing to canonicalise, nothing to compare, and no window
  between the check and the open. The `trusted` row is the contrast — the same string there is a
  relative path, and the loader goes to the filesystem to ask about it.

- **A dot is a path separator, and a directory answers through `init.lua`.**
  `RequireTarget::candidates` returns `<stem>.lua` and then `<stem>/init.lua`
  (`src/types/require_target.rs:77`), so `require("lib")` finds `lib/init.lua` where there is no
  `lib.lua` — a component keeps its parts in a subdirectory and is still required by the name of
  the directory. Every target resolves against the script root and never against the requiring
  file, so `require("lib.text")` means the same file whether `app.lua` writes it or `lib/init.lua`
  does.

- **A module body runs once, and the cache outlives the evaluation.** The cache lives in the
  registry under a single key (`src/require_loader.rs:29`), is created once and kept rather than
  rebuilt per evaluation (`src/require_loader.rs:99`), and is keyed by the canonical path the
  target resolved to (`src/require_loader.rs:147`). `lib/text.lua` is required twice — by
  `lib/init.lua`, then by `app.lua` — and appends its name to `BODIES_RUN` whenever its body runs.
  The list reads `lib, lib.text` on `evaluation 1` and still reads `lib, lib.text` on
  `evaluation 2` of the same engine. `lib.text is the table lib itself required: true` is the other
  half of it: the second call returns the value the first produced, not an equal copy.

- **A second engine starts over.** The third block is the control for the second. Same script, same
  policy, `evaluation 1` again — so the unchanged list in the middle block is the cache surviving,
  not `app.lua` printing a constant.

- **A cycle is answered rather than recursed into.** The loader writes an in-progress marker before
  running a module (`src/require_loader.rs:164`) and reads finding one as a cycle
  (`src/require_loader.rs:154`), raising `Error::RequireCycle` (`src/require_loader.rs:155`).
  Without it, `ring``ring.link``ring` would end in a stack overflow, which aborts the process
  rather than raising something `pcall` can catch. The marker does not survive a failure
  (`src/require_loader.rs:183`), and that is why the same message appears in all three blocks
  instead of the diagnosis changing after the first: a module that raised once must not afterwards
  be reported as a cycle for as long as the engine lives.

- **A missing module names the one directory that was searched** (`src/require_loader.rs:226`).
  There is no second place to look — no `package.path`, no working directory, no environment
  variable — so the message is the whole search rather than a summary of it.

### The one escape a validated name cannot rule out

Not in the output above, deliberately: showing it would mean committing a symlink into the
repository. A target cannot spell a parent, but a symlink *inside* the root can point out of it,
and no amount of name validation sees that. So `resolve` canonicalises both the root and the
candidate and compares them as paths (`src/require_loader.rs:217`), raising `Error::RequireEscape`
when the candidate is not underneath. `Path::starts_with` matches whole components rather than
characters, so a sibling directory whose name merely begins with the root's — `app-extra` beside
`app` — is not a match. The crate's own tests cover both
(`src/require_loader.rs:328`, `src/require_loader.rs:341`).

## See also

- [policy-presets]../policy-presets/ — where `require` reports present on two rows and the
  difference between them cannot be shown.
- [architecture]../../docs/architecture.md — why the confined `require` is built rather than
  narrowed, and what an engine keeps between evaluations.
- [sandbox]../../docs/sandbox.md — the three questions a policy answers, at length.
- [hello-eval]../hello-eval/`Script::from_source`, the constructor that leaves a script with
  no root and therefore no `require`.