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.
173 lines
6.4 KiB
173 lines
6.4 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.getScriptDir() -> string|nil`
|
|
|
|
The directory the main script really lives in, as an absolute path with
|
|
symlinks resolved — an installed script invoked through a `~/.local/bin`
|
|
symlink gets the directory of the real file. Unlike the working directory,
|
|
this doesn't depend on where the script was invoked from, so it is the right
|
|
base for assets shipped alongside the script:
|
|
|
|
```lua
|
|
local tmpl = fs.readAll(fs.getScriptDir() .. "/templates/report.html")
|
|
```
|
|
|
|
Returns `nil` in the REPL, where there is no script. Not permission-gated —
|
|
it names a place and reads no file content (accessing files under it prompts
|
|
like anywhere else).
|
|
|
|
### `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`.
|
|
|