Lua runner with rich builtin stdlib
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
a/docs/fs.md

157 lines
5.8 KiB

# `fs`
Whole-file filesystem access, guarded by a per-script folder permission
system. The sandbox loads no stock `io`; this module is the supported way for
a script to touch files, and every operation first passes a permission check
(see [Permissions](#permissions) below).
The v1 surface is deliberately small — whole files in and out, directory
listing, two predicates, and an explicit permission probe:
```lua
local text = fs.readAll("/home/me/notes.txt")
fs.writeAll("out/report.md", text .. "\n-- processed")
for _, name in ipairs(fs.list(".")) do print(name) end
if fs.isFile("config.json") then ... end
if fs.access("/var/data", "w") then ... end
```
## Functions
### `fs.readAll(path) -> string`
Reads the whole file into a string. Lua strings are byte strings, so binary
content round-trips exactly. Errors if `path` is not a regular file, the OS
refuses, or the permission system denies **read** on the file's directory.
### `fs.writeAll(path, content)`
Writes the whole file: created if missing, truncated if it exists. The
containing directory must already exist (there is no mkdir-p), and a dangling
symlink as the target is refused. Errors if the file can't be created or the
permission system denies **write** on the containing directory.
### `fs.list(path) -> string[]`
The names (not full paths) of a directory's entries, sorted. `.` and `..` are
not included. Errors if `path` is not a directory or **read** on it is
denied.
### `fs.isDir(path) -> boolean`, `fs.isFile(path) -> boolean`
`true` iff the path exists, has the right type, *and* is readable under the
permission system. These never error: a nonexistent path is `false` without
any permission prompt; an unknown permission prompts like any other read (a
denial makes the result `false`).
### `fs.getWorkdir() -> string`
The process's current working directory, as an absolute path. Relative paths
given to the other fs functions resolve against it, so it is the natural base
for building paths to probe with `fs.access`:
```lua
local cwd = fs.getWorkdir()
if fs.access(cwd, "w") then
fs.writeAll("report.txt", text) -- relative = cwd .. "/report.txt"
end
```
Not permission-gated — it reports process state and touches no file content.
### `fs.access(dir, mode) -> boolean`
Runs the permission cycle for a directory explicitly — `mode` is `"r"` or
`"w"`:
1. a recorded grant/denial answers immediately;
2. otherwise the interactive prompt asks;
3. with no terminal to ask on, returns `false` **without** recording a
denial (a persisted "no" is always an explicit user decision).
Use it to ask for permission up front at a natural moment instead of
mid-operation. Errors if `dir` is not an existing directory.
## Permissions
Permissions attach to **directories**, not files, and are recorded per
script:
- The permission unit for `readAll`/`writeAll`/`isFile` is the file's
directory; for `list`/`isDir`/`access` it is the directory itself. Paths
are canonicalized first (symlinks resolved, relative paths against the
CWD), so two spellings of the same place share one grant.
- Grants are **recursive**: an allow or deny on a directory covers
everything below it, and the *deepest* recorded ancestor wins — deny
`~/secrets` and it stays denied even though `~` is allowed.
- Read and write are tracked separately; each is approved, denied, or not
decided yet.
When an operation needs a decision that isn't recorded, `a` asks on the
terminal (stderr, so it shows even with stdout piped) — one keypress, no
Enter:
```
a: permission request
script: /home/me/bin/deploy.lua
wants: read access to directory /home/me/project
[y] allow once [n] deny once [A] allow always [N] never allow:
```
- **`y` / `n`** apply for the rest of this run only.
- **`A` / `N`** are recorded in `access.json` and answer silently from then
on. (A lowercase `a` deliberately does nothing — a slip of the finger must
not persist anything.)
- **Esc / Ctrl-C** count as `n`.
- No terminal (piped stdin, cron): the operation is denied for this run and
*nothing* is recorded.
### `access.json`
Recorded answers live in `$XDG_CONFIG_HOME/a/access.json` (usually
`~/.config/a/access.json`), keyed by the script's canonical path — moving or
renaming a script starts it with a clean slate. The file is plain JSON and
safe to hand-edit; each flag is `true` (approved), `false` (denied), or
`null` (not decided yet):
```json
{
"version": 1,
"scripts": {
"/home/me/bin/deploy.lua": {
"/home/me/project": { "read": true, "write": true },
"/etc": { "read": true, "write": false }
}
}
}
```
Concurrent `a` processes update it safely (the write cycle holds a file
lock and replaces the file atomically).
The **REPL** prompts the same way, but its answers — including `A`/`N` —
last only for the session; an interactive session has no script identity to
record them under.
### `sqlite` and `http` respect the same system
- `sqlite.connect(path)` asks one combined **read/write** question for the
database's directory (SQLite keeps WAL/journal files next to it).
Memory-class databases — `":memory:"`, `""` and their `file:` URI
spellings — touch no file and are always allowed, even under `--no-fs` /
`--sandbox`.
- A session's cookie-jar files (`http.session(path)`, `session:load`,
`session:save`) are read/write checked like any other file.
## CLI flags
| Flag | Effect |
|------|--------|
| `--allow-fs` | Skip the permission system: everything granted, nothing asked or recorded. |
| `--no-fs` | Deny all filesystem access (fs module, on-disk sqlite databases, cookie-jar files). |
| `--sandbox` | `--no-fs`, plus networking is cut (`http` errors); overrides `--allow-fs`. |
Denials from a flag read `fs.readAll: filesystem access disabled by --no-fs`,
interactive/recorded ones `fs.readAll: access to /some/dir denied`.