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

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 below).

The v1 surface is deliberately small — whole files in and out, directory listing, two predicates, and an explicit permission probe:

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:

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:

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):

{
  "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.