SCUA

How-to

Read and write files

A SCUA script has no ambient access to the filesystem. There is no global open, no way to read a file by spelling the right name — the fs module that does file I/O doesn't exist unless the host grants it. That's deliberate: it's what lets you embed SCUA, hand a script the safe parts of the standard library, and know that file (or, later, network) access cannot leak in unless you turn it on. This is the capability model.

#Granting the capability

From the CLI, the grant is a flag:

scua --allow-fs script.scua            # fs rooted at the current directory
scua --allow-fs=/some/dir script.scua  # fs rooted at /some/dir

Every path the script uses is then relative to that root — not to the script's own location. fs.write("scratch.txt", …) writes <root>/scratch.txt; with bare --allow-fs (root = the current directory) that's scratch.txt in whatever directory you ran the command from. Root at an explicit directory when you want the files to land somewhere specific.

Without --allow-fs, importing fs is a compile error, before a line runs:

$ scua script.scua
script.scua:1: module `fs` needs the `fs` capability, which this run was not granted — grant the folder it works in with `--allow-fs=DIR` (or a per-user folder with `--allow-user=ID`) on the CLI, or the host's capability API when embedding
scua: this program needs --allow-fs=<dir>; run it as: scua --allow-fs=<dir> script.scua
scua: <dir> is the folder the program works in; a bare --allow-fs grants the working directory, which is the whole disk for a host that launches from /

The second line names every grant the program is missing, in the narrowest form that runs it. A program that uses user: paths, in any of its files, is told --allow-user=ID instead, with the id filled in from [app] id in its scua.toml.

That's the default-deny guarantee: a script can't reach the filesystem by accident, and an embedder ships SCUA without file I/O in the build at all unless they choose to add it.

A program's own saves and settings go in a second folder, its per-user folder, through user: paths (user:saves/slot1.json), granted with --allow-user=ID or --user-dir=PATH. Every function on this page takes them. See Save the player's progress.

#Reading, writing, listing

fs.same_file(a, b) returns Ok(true) when two existing file paths refer to the same underlying file, including symbolic links and hard links. Separate files with identical contents return Ok(false). Missing or inaccessible files return Error(reason), as do paths that escape the filesystem grant.

Use it to compare files that already exist. Both paths follow the ordinary rooted-read rules, including rejection of absolute paths and . or .. segments. The files must be accessible for reading. A destination that does not exist yet is an error, not Ok(false). This is a point-in-time check; it does not prevent another process from changing either path before a later write.

Use fs.paths_alias(a, b) when a destination might not exist yet. It compares existing file identities or the locations of missing files, including aliases through parent directories. Both parents must exist inside the granted root. It creates no files and follows the same path restrictions as fs.same_file.

import fs

-- The output need not exist, but its parent directory must exist.
match fs.paths_alias("generated.json", "generated.json")
  Ok(true) -> print("Choose different output paths")
  Ok(false) -> print("The paths are distinct")
  Error(why) -> print(why)
end

Run this as fs_paths_alias.scua with an existing directory as the grant:

$ scua --allow-fs=. fs_paths_alias.scua
Choose different output paths

For missing names in the same directory, the comparison uses supported filesystem case and Unicode rules. It supports APFS on macOS, Windows directories that expose their case-sensitivity setting, and ordinary case-sensitive Linux local filesystems such as ext4 and tmpfs. If it cannot determine the naming rules, including Linux casefold directories or unrecognized mounts, it returns Error instead of guessing that the paths differ. Do not ignore that error when protecting an input or another output from overwrite.

Missing parents, inaccessible paths and paths outside the grant also return Error. Neither comparison reserves a filename or protects a later write from concurrent filesystem changes.

With the grant in place, import fs works, and every path is relative to the granted root:

import fs

match fs.read_text("config.txt")
  Ok(content) -> print(content)
  Error(why) -> print(`could not read: {why}`)
end

fs.write("out/log.txt", "a line\n")
fs.list(".")
  • fs.read_text(path) → Ok(contents) or Error(reason), reading a file as UTF-8 text. A missing file is an Error value you handle, not a crash — same as json.decode. Invalid UTF-8 is an Error that names the offending byte offset; \r\n is normalized to \n so line handling is uniform across platforms (use fs.read if you need the exact bytes). There is no default size cap — a large export reads like any other file. Pass { max_bytes = N } when you want the opposite: an unexpectedly large file refused before it costs you the memory, as an Error naming the file's actual size. The only remaining limit is structural — one value cannot exceed 256 MiB, and nothing raises that; for a file bigger than that, fs.grep searches it without loading it.

  • fs.read(path, opts?) → Ok(contents) or Error(reason), reading the raw bytes, exactly as stored — use this for binary files (images, archives), and fs.read_text for text. Takes the same { max_bytes = N } option.

  • fs.write(path, content) → Ok(nil) or Error({ kind, message }), where content is a string or bytes. The kind says whether trying again can help: exists (an exclusive write found a file already there), denied (the system, this run's grants or the path's rules refuse it), not_found (a folder in the path does not exist), bad_path (the path is a folder, or a folder in it is a file), and io for anything else, which may pass on a retry. message is the text to show. It creates or truncates the file; the parent directory must already exist — call fs.mkdir first if it might not. Replacing a file empties it first — see Replacing a file without losing it. Options: { exclusive = true } writes only a file that is not there yet and returns an Error if anything is, checking and creating in one step so two writers can't both succeed. The file appears with all of its content at once, so another program never reads it half-written or empty. { sync = true } returns only once the data is on the disk, along with the file's name in its folder.

  • fs.rename(from, to, opts?) → Ok(nil) or Error(reason). Move or rename a file or directory inside the root. An existing destination file is replaced — that is what makes it the swap step of a safe rewrite — and the new file takes the old one's permissions, so a rewrite doesn't loosen them. A destination marked read-only is not replaced: that is an Error, and if replacing it is what you mean, fs.remove it first. With { replace = false } it never replaces: if to already exists the rename fails and both are left as they were. The check and the move are one step, so when two writers race to save under the same name, exactly one succeeds. That makes it the way to save a file without overwriting another: write it under a temporary name, then rename it into place with replace = false. Some filesystems can't rename without replacing (exFAT, for one): there the call is an Error saying so and nothing moves, so a program that must save anyway can check for the name and fall back to a plain rename. Both paths obey the same rooting rules as everything else, and the root itself is refused at either end. { sync = true } returns only once the rename is on the disk: the folders it changed are flushed, so a power cut afterwards cannot bring back the old name.

  • fs.mkdir(path) → Ok(nil) or Error(reason). Create a directory, including any missing parents. A directory that already exists is Ok, not an error: the useful question is whether it exists now, so you never have to check first or race yourself.

  • fs.remove(path) → Ok(nil) or Error(reason). Delete a file, or an empty directory. A missing path is an Error value to handle, not a crash. A non-empty directory is refused and tells you to reach for fs.remove_all — the destructive verb is one you have to type on purpose.

  • fs.remove_all(path) → Ok(nil) or Error(reason). Delete a directory and everything inside it (or a single file). Reach for this only when you mean it.

    Deletion is bounded by the grant, not by your care: absolute paths and .. are rejected, the path must resolve inside the granted root, the root itself is refused however you spell it, and a symlink is unlinked rather than followed — so it can never reach the file it points at. Grant --allow-fs at the narrowest directory that works, and prefer deleting only paths your own script created.

  • fs.list(path) → Ok(names) or Error(reason). The bare entry names directly under a directory (one level, names only), in natural order: runs of digits compare as numbers, so e1m2 comes before e1m10, the same on every OS. Use "." for the granted root itself. For kinds, recursion, or filtering, use fs.find. Handing it a file is an Error that says so.

  • fs.find(path, opts?) → Ok(entries) or Error(reason). The richer search by name. Each entry is a record { path, name, is_dir } — path is relative to the root, so it feeds straight back into fs.read/fs.stat. Options: { recursive = true } walks the whole tree (symlinked directories are not followed, so there are no loops and no escape); { filter = ["*.scua", "*.toml"] } keeps only entries whose name matches a */? glob; { kind = "file" } or { kind = "dir" } restricts to files or directories; { gitignore = true } honours .gitignore files in the tree (skipping ignored entries and pruning ignored directories). Shallow and unfiltered by default. Entries come depth first, each directory before what is inside it, and each directory's entries in the same natural order as fs.list.

  • fs.grep(pattern, path?, opts?) → Ok(hits) or Error(reason). Search file contents for the fixed string pattern. By default each hit is { path, line, col, text } — path root-relative, line/col 1-based (matching your editor), text the matching line. It streams each file (so file size doesn't matter — no size limit at all, which makes it the way to search a file too big to hold as one value) and skips .git, binary files, and symlinks. path may name a single file as well as a directory; a named file is searched whatever the filter/exclude/gitignore options say, because you already chose it. Options: { recursive = true }, { ignore_case = true }, { word = true } (whole word), { filter = ["*.scua"] } (which files to search), { exclude = ["node_modules"] } (names to prune), { max = N } (cap results), { context = N } (surrounding lines), { only_matching = true } (just the match), { gitignore = true } (honour .gitignore). pattern is a literal string, not a regex.

    • It searches one line at a time, so a pattern containing a newline could never match — and rather than answer "no results", which is indistinguishable from "not there", it refuses with an Error saying so. To search across lines, read the file with fs.read_text and use str.find or regex.find on the whole text.
    • max is honest in both directions. Without one, the search stops at 10 000 results — and if there were more, that is an Error naming the cap, never a quietly shortened list you would have read as complete. Pass { max = N } and the cap becomes yours: it truncates silently, because you chose the number. { max = 0 } means zero results, not "no cap" — and the same goes for { max_per_file = 0 }.
    • Ask for the cheapest sufficient answer (these narrow the result — a big token saving when you don't need whole lines): { files_with_matches = true } returns just { {path} } and stops at each file's first match; { count_only = true } returns { {path, count} } (matching lines per file, or with { matches = true } the number of individual matches); { invert = true } selects the lines that do not match; { max_per_file = N } caps matches per file (alongside the global max).
    • Surrounding context: { context = N } attaches the N lines on each side of a match as before and after string lists on the hit (or { before = N } / { after = N } for one side). When two matches are close enough that their windows overlap, the windows merge — a line is never shown as context for more than one hit.
    • Regex: pass a compiled regex value (from regex.compile) as the pattern, or a string with { regex = true }, to match with a regular expression instead of a fixed string — see Match text with regular expressions. { only_matching = true } emits each matched substring (with a stop field) instead of the whole line. col/stop stay in fs.grep's 1-based editor coordinates — they are positions in a file, not the 0-based string offsets regex.find reports.
  • fs.grep_text(pattern, path?, opts?) → string. The same search as fs.grep, rendered as one compact path:line: text string (one line per hit) — the form to paste straight into a prompt or a log without post-processing each record. With files_with_matches it lists one path per line; with count_only, path:count. Returns "" when nothing matches. (It faults on a bad path rather than returning an Error value — it's a convenience for when you just want the text.)

  • fs.exists(path) → bool — whether something exists at path within the root. A path that would escape the root reads as false ("not within the root"), never an error.

  • fs.stat(path) → Ok({ size, is_dir, is_file, mtime_ms }) or Error(reason) — file metadata: byte size, whether it's a directory or a regular file, and mtime_ms (last-modified time, milliseconds since the epoch). "." stats the root.

  • fs.lstat(path) → Ok({ size, is_dir, is_file, is_symlink, mtime_ms }) or Error(reason) — the same record for the path itself, without following a final symlink. fs.stat follows links, so it describes what a link points at and reports is_file = true for the link; fs.lstat is the only way to ask whether the path you named is a link. This matters before you replace a file: the crash-safe idiom — write a temp file, then fs.rename it over the target — replaces a symlink with a regular file and leaves the real file untouched, so the write appears to succeed while nothing you meant to change did. Check first:

    match fs.lstat(path)
      Ok(st) -> if st.is_symlink then print(`{path} is a symlink — writing would replace the link`) end
      Error(why) -> print(`cannot check {path}: {why}`)
    end

    A symlink pointing outside the granted root is still refused rather than described — you get an Error, which answers the same question.

Because they return Ok/Error, the ? operator chains them just like any other fallible call:

fn load_config()
  let text = fs.read_text("config.txt")?   -- short-circuits to Error if the read (or UTF-8 decode) fails
  return parse(text)
end

#Reading a file too big to load

fs.read and fs.read_text hand you the whole file, so the memory you need is the size of the file — they read it straight into the value, so a 50 MB file costs about 50 MB, not two copies of it — and no single value can exceed 256 MiB, so past that a file cannot be read at all. fs.open gives you a live handle instead: you pull the file through in pieces and process a file of any size in a fixed amount of memory.

Which reader do I want?

You want Use It ends when
The whole file as text, and it comfortably fits fs.read_text(path) —
The whole file as raw bytes, and it comfortably fits fs.read(path) —
Text, a line at a time, however big the file fs.read_line(f) it returns nil
Raw bytes, a block at a time, however big the file fs.read_chunk(f, max) it returns empty bytes

The two streaming readers end differently, and that is deliberate. fs.read_chunk is finished when it hands back an empty chunk. fs.read_line is finished when it hands back nil — because an empty line is a real line, and if "" meant "finished" then a reader would stop at the first blank line in your file and never tell you.

import fs

match fs.open("huge.log")
  Ok(f) -> do
    let count = 0
    let more = true
    while more do
      match fs.read_line(f)
        Ok(line) ->
          if line == nil then          -- nil, NOT "" — a blank line is a line
            more = false
          else
            count = count + 1
          end
        Error(why) -> do
          print(`read failed: {why}`)
          more = false
        end
      end
    end
    print(`{count} lines`)
    fs.close(f)
  end
  Error(why) -> print(`could not open: {why}`)
end
  • fs.open(path, opts?) → Ok(file) or Error(reason). The path is checked once, here, by the same rules as every other fs verb. Opening a directory is an Error. mode is "read" for now.

  • fs.read_line(f) → Ok(line) or Error(reason), where the line has no terminator and nil means the file ended. A line ends at \n, \r\n or a lone \r, the same breaks str.lines splits on, so Unix, Windows and old Mac text all read the same way. A line longer than 65535 bytes, or one that is not valid UTF-8, is an Error telling you to read that file with fs.read_chunk instead.

    ⚠️ It gives you lines, not their endings, and unlike the byte verbs it does not say which ending each line had. That is convenient when you are reading text and wrong when you are rewriting a file: a Windows-formatted file read line by line and written back out with \n loses every carriage return, silently. If you need the bytes exactly as stored — because you are transforming a file rather than reading it — use fs.read_chunk, which changes nothing. The rule across the module is the same one fs.read and fs.read_text follow: text verbs give you LF; byte verbs give you exact bytes.

  • fs.read_chunk(f, max) → Ok(bytes) or Error(reason), up to max bytes, where empty bytes mean the file ended. A short chunk that is not empty is not the end. Chunks split anywhere, including in the middle of a character, which is why this gives you bytes — buffer across boundaries with bytes.concat and split with bytes.find.

  • fs.close(f) → Ok(nil). Safe to call more than once.

Close what you open. Nothing stops you forgetting. Files are closed when the program ends, but a script that opens files in a loop without closing them will run the process out of file descriptors, and the failure then lands somewhere with no obvious connection to the file you forgot — a network connection that will not open, a log that will not write. If a loop opens a file, put the fs.close in the same loop.

#Writing a file too big to build in memory

fs.write(path, content) takes the whole content at once and truncates on every call, so you cannot use it to build a large file in pieces. Open the file for writing instead:

import fs

match fs.open("report.csv", { mode = "write" })     -- or "append"
  Ok(f) -> do
    fs.write_chunk(f, "id,name,total\n")
    -- ... write as many chunks as you like, of any total size ...
    fs.close(f)
  end
  Error(why) -> print(`could not open: {why}`)
end
  • "write" creates the file, or empties an existing one before you start.
  • "append" creates the file, or continues at the end of an existing one. Appending to a file that does not exist yet is normal, not an error — a log's first run just works.
  • "create" creates the file and returns an Error if anything is already there, so it never empties a file you didn't mean to touch.
  • "update" writes into a file that is already there, keeping what it holds. Writing starts at the beginning and replaces bytes as it goes, and fs.seek takes you anywhere else. It never creates a file: a missing one is an Error.
  • fs.sync(f) → Ok(nil) or Error(reason) waits until what you wrote is on the disk, not only handed to the operating system. Call it before fs.close when the file must survive a power cut.
  • fs.write_chunk(f, data, { from, to }?) → Ok(count) or Error(reason). data is a string or bytes; { from, to } writes just that byte range of it (0-based, to exclusive), straight from the value, so a large buffer can go out in pieces without slicing copies. On success the whole of it is written.

Write in reasonably large pieces. Every fs.write_chunk goes straight to disk — nothing is held back in a buffer, so a failure is reported by the call that caused it rather than turning up later at fs.close. The trade is that a chunk is a trip to the operating system, so writing one short line at a time is slow. Build up a few kilobytes (with builder() or by joining strings) and write that.

If a write fails, the error tells you how much got through — for example wrote 4096 of 65536 bytes: the disk is full (…). That number is worth reading: those bytes landed at the handle's position and the position did not move, so you know what state the file is in rather than having to assume the worst and throw it away. Writing the same chunk again puts it right. When you are writing at the end, as you are unless you used fs.seek, the file is its previous length plus those bytes.

Using the wrong verb for the mode is a mistake, not a condition. Reading from a handle you opened for writing (or the reverse) stops the program rather than returning an Error, because you chose the mode a few lines earlier and it is not something that can vary at runtime.

#Making a rewrite survivable

If the program is killed halfway through writing, you are left with a half-written file — SCUA does not do anything clever behind your back. The way to avoid it is the same one described above: write to a temporary name, then fs.rename it over the real one, which is a single step that either happened or did not.

Two things about that recipe on a large file, which are easy to find out the hard way:

  • It needs room for both copies. Rewriting a 30 GB file means 30 GB of temp plus the 30 GB original until the rename lands. If the disk cannot hold both, the write fails partway — with the message above telling you where it stopped.
  • A crash leaves the temp file behind, and nothing removes it. Give temp files a name your program can recognise later (a shared prefix like .tmp-report-…) and sweep them at start-up, or a program that crashes occasionally will slowly fill its own disk.

And check what you are about to replace. fs.rename over a symlink replaces the link, leaving the file it pointed at untouched — the write reports success and the file you meant to change is unchanged. Use fs.lstat first:

match fs.lstat(target)
  Ok(st) -> if st.is_symlink then print(`{target} is a symlink — refusing to replace it`) end
  Error(why) -> print(`cannot check {target}: {why}`)
end

One more thing to know before you reach for this: a program holding an open file cannot be snapshotted. If you are writing a handler that must survive a snapshot, finish with the file and close it before the handler ends — see handlers that survive snapshots.

#Moving around in a file

A handle reads and writes at a position, which starts at the beginning (or at the end, for "append") and moves on past every byte you read or write. fs.seek moves it, so you can read part of a large file without the rest, or go back and fill in a header once you know what it says:

import fs

-- The first line says how many names follow, which is known only once they are written.
let f = fs.open("names.txt", { mode = "write" })?
fs.write_chunk(f, "count=?\n")?
let names = ["ada", "grace", "edsger"]
for _, name in names do
  fs.write_chunk(f, `{name}\n`)?
end
fs.seek(f, 6)?                        -- back to the "?"
fs.write_chunk(f, `{names.len()}`)?
fs.close(f)

let r = fs.open("names.txt")?
print(fs.read_line(r)?)
fs.seek(r, -7, { from = "end" })?     -- the last 7 bytes: "edsger\n"
print(fs.read_line(r)?)
print(fs.seek(r, 0, { from = "current" })?)
fs.close(r)
$ scua --allow-fs=. fs_seek.scua
count=3
edsger
25
  • fs.seek(f, offset, { from }?) → Ok(position) or Error(reason). The position comes back counted in bytes from the start. from is "start" (the default), "current" or "end", and the offset can be negative with the last two. fs.seek(f, 0, { from = "current" }) tells you where a handle is without moving it.
  • A position past the end is allowed. A read there finds the end, and a write there fills the gap with zero bytes. A position before the start is an Error.
  • An "append" handle cannot seek. Its writes always go to the end, so seeking one stops the program the way a read on a write handle does. To change bytes in a file that is already there, open it with "update": fs.seek(f, -16, { from = "end" }) then fs.write_chunk rewrites its last 16 bytes.
  • Positions count bytes, not characters. After fs.seek into the middle of a UTF-8 character, fs.read_line gives an Error for that line; fs.read_chunk gives you the bytes either way.
  • Packed res: files seek too. A stored one jumps straight to the position. A compressed one decodes up to it, and a seek backwards decodes again from the start, so on a large compressed file each one costs about as much as reading that far. Store an entry uncompressed if you seek around in it a lot. A stored file you have seeked in is checked for its size at the end, but not its checksum, the same check a ranged fs.read makes.

#Replacing a file without losing it

fs.write truncates the target before it writes. For a moment the file is zero bytes: the old contents are gone and the new ones have not landed. Nothing goes wrong in the normal case — but if the process dies in that window (a crash, a Ctrl-C, an operation budget running out), the file is not stale, it is empty. On a 40 MB rewrite the window is long enough to watch.

When losing the old copy would matter, don't rewrite the file — write a new one beside it and swap it in with fs.rename:

import fs

-- write the new copy under a name nothing reads
match fs.write("data.json.new", "{\"count\": 2}\n")
  Ok(_) -> do
    -- one step: either data.json is the old file, or it is the new one. Never nothing.
    match fs.rename("data.json.new", "data.json")
      Ok(_) -> print("replaced")
      Error(why) -> print(`swap failed, the original is untouched: {why}`)
    end
  end
  Error(why) -> print(`could not stage the new copy: {why.message}`)
end

print(fs.read_text("data.json"))
$ scua --allow-fs safe_rewrite.scua
replaced
Ok({"count": 2}
)

The original survives right up to the swap, and if writing the new copy fails you still have it. This is the reason fs.rename exists; the cost is one temp name, and you choose when to pay it.

Two options make the recipe stronger when it matters:

  • Survive a power cut: write the new copy with { sync = true } and rename it with fs.rename(tmp, target, { sync = true }). Without the first, the operating system may still hold the data when the rename lands, and a power cut then leaves the new name pointing at an empty file. Without the second, the rename itself may not have reached the disk, and a power cut brings back the old copy. On macOS a sync goes all the way to the drive's own storage (F_FULLFSYNC), not just to its cache. On Windows there is no folder to flush and the file system records the name itself.
  • Never overwrite: when the target must not already exist, claim its name first with fs.write(target, "", { exclusive = true }). If that returns Ok, the name is yours and the swap replaces only your own placeholder. Checking fs.exists and then renaming leaves a gap in which another program can create the file.
  • Hidden temporary names: an exclusive write puts its content in a temporary file beside the target, named .scua-tmp-…, and renames it into place. While the write is in progress, and after a crash part-way through, fs.list and fs.find show that file. A program that scans a folder should skip names starting with .scua-tmp-.

fs.write was deliberately left as a plain truncating write rather than doing this internally. Writing via a temp sibling needs a writable directory, where a plain write needs only a writable file — so making it the default would start refusing scripts that work today. It also replaces the file's identity: mode, owner, and any hard links would silently become whatever the new file was created with. Those are reasonable trade-offs to accept deliberately, and a poor default to impose.

#Sharing files with another running program

When two programs change the same files (two windows of one tool, a server and a command-line helper), take a lock around the change with fs.lock. Lock a file of its own, beside the data:

import fs

let lock = fs.lock("store.lock", { timeout = 2000 })?
let n = match fs.read_text("count.txt")
  Ok(t) -> tonumber(t) ?? 0
  Error(_) -> 0
end
fs.write("count.txt", tostring(n + 1))?
fs.close(lock)
print("bumped")
$ scua --allow-fs bump.scua
bumped
  • fs.lock(path, opts?) → Ok(file) or Error({ kind, message }). It creates the file if it is not there. { timeout = ms } is how long to wait for someone else's lock (0, the default, tries once); { shared = true } takes a read lock, which any number of holders share and an exclusive lock waits for. The timeout is measured on a steady clock, so changing the computer's time does not shorten or lengthen it.
  • The kind of an Error says whether to try again. timeout means someone else holds the lock, so waiting longer may help. These three will fail the same way every time, so stop and report them: denied (the operating system or this run's grants refuse the path, such as a read-only folder), not_found (a folder in the path does not exist; create it with fs.mkdir) and bad_path (the path cannot be a lock file, such as a folder). unsupported means this file system has no locks at all. io is anything else, such as an I/O error on a network drive, and may pass.
  • Release it with fs.close. The operating system also releases it when the program ends, however it ends, so a program that crashes while holding it never leaves it stuck. That is what a lock file made with fs.write(…, { exclusive = true }) plus a timestamp cannot do: it has to guess whether its holder is still alive.
  • It stops other fs.lock calls only. Reads and writes that do not ask for the lock go ahead, so every program that changes the files takes it. Two fs.lock calls in one program, in two partitions say, exclude each other the same way two programs do.
  • Lock a separate file, not the data file. On Windows a lock also stops other programs writing the file it is on.
  • A wait holds only the code that waits. In an http.serve handler, a message handler, or code under --io=async, a call that has to wait parks, and everything else carries on: a request waiting on the lock holds only that request. Elsewhere the partition waits in place, so keep the timeout to what a person would notice.
  • On a network drive, test that it locks. On Linux, NFS and SMB mounts may emulate these locks, and some do not stop a second lock taken by the same program. A program that keeps its data on one can take the lock twice at startup and check that the second attempt times out.

A user: path works too (fs.lock("user:store.lock")); for two programs to share a per-user folder, both launch with --user-shared.

#Finding files

fs.find walks a directory and hands back records you can act on. Recurse, filter by glob, and the path of each result is ready to read:

import fs

-- every .scua file anywhere under src/, read and measured
match fs.find("src", { recursive = true, filter = ["*.scua"] })
  Ok(files) -> do
    for f in files do
      let text = fs.read_text(f.path)?       -- f.path is relative to the root
      print(`{f.path}: {text.len()} chars`)
    end
  end
  Error(why) -> print(`find failed: {why}`)
end

-- just the immediate subdirectories of the root
match fs.find(".", { kind = "dir" })
  Ok(dirs) -> for d in dirs do print(d.name) end
  Error(_) -> print("no root")
end

Each record has the same fields:

  • path — the path to hand back to fs.read and the rest, from the root: src/lib/util.scua. A user: search gives user: paths (user:saves/slot1.json), and a res: search res: paths.
  • name — the last part, util.scua.
  • rel — the path below the directory searched, lib/util.scua for fs.find("src", …). Join it onto another folder to copy a tree; a user: or res: path joined onto a host folder is refused, naming rel.
  • is_dir — whether it is a directory.
  • bad_name — true for a name in the per-user folder that a program could not create there (a : or a trailing dot, say, from a file the player copied in by hand): it reads, and every function that writes refuses it by name. Always false elsewhere.

#Searching file contents

fs.find searches by name; fs.grep searches inside files for a fixed string. Each hit is { path, line, col, text } with 1-based line/col (the coordinates your editor shows), and the file is streamed, so its size doesn't matter (no 8 MiB limit) — .git, binary files, and symlinks are skipped automatically:

import fs

match fs.grep("parsePartition", ".", { recursive = true, filter = ["*.scua"] })
  Ok(hits) -> for h in hits do
    print(`{h.path}:{h.line}:{h.col}: {h.text}`)   -- prints like grep, clickable in an editor
  end
  Error(why) -> print(`search failed: {why}`)
end

Options: recursive, ignore_case, word (whole word), filter (which files to search), exclude (names to prune, e.g. ["node_modules", "dist"]), gitignore (honour .gitignore files), and max (cap the result count). The on/off options of both fs.find and fs.grep (recursive, gitignore, ignore_case, word, regex, only_matching, files_with_matches, count_only, matches, invert) take true or false; anything else, such as { recursive = "yes" }, is an error that names the option, and leaving one out gives its default. The pattern is a literal string unless you ask for a regular expression — pass { regex = true }, or a value from regex.compile.

Two options worth knowing before you write your second call:

  • { context = N } attaches the N lines on each side of every match to the hit, as before and after string lists — the same thing grep -C gives you. It collapses the usual two-step (grep for the line, then read the file to see what surrounds it) into one call, and overlapping windows merge, so no line is ever shown twice. { before = N } / { after = N } do one side.
  • { only_matching = true } returns just the matched substring instead of the whole line, with a stop column bounding it — what you want when the line is long and the match is the part you need.
import fs

match fs.grep("TODO", ".", { recursive = true, context = 1 })
  Ok(hits) -> for h in hits do
    for b in h.before do print(`  {b}`) end
    print(`> {h.path}:{h.line}: {h.text}`)
    for a in h.after do print(`  {a}`) end
  end
  Error(why) -> print(`search failed: {why}`)
end

fs.grep reads one line at a time, so a pattern containing a newline can never match. Rather than report that as "no results" — a lie you cannot tell apart from "not there" — it refuses with an Error saying so. To search across lines, read the file with fs.read_text and use str.find or regex.find. For the same reason max is honest about stopping: reach the default 10 000-result cap and you get an Error naming it, not a truncated list. Set { max = N } yourself and it truncates quietly — you chose the number. { max = 0 } means zero results, not "no cap".

Both fs.find and fs.grep accept { gitignore = true }, which reads the .gitignore files in the tree (per directory, accumulated, with ! negation) and skips ignored entries — pruning whole ignored directories like node_modules/ or build/. It's off by default (so you can search everything when you want to); turn it on to see a repository the way git and ripgrep do. (.git itself is always skipped.)

This pairs with the capability model to make a safe agent toolkit: grant a script --allow-fs=<repo> and nothing else, and it can find / grep / read the codebase — honouring .gitignore — but cannot reach the network or anything outside the root.

#Deleting

Two verbs, deliberately spelled differently, because they do different things.

import fs

-- Work inside a directory this script creates, and remove only that.
fs.mkdir("work/cache")

match fs.write("work/cache/page.html", "<html>…</html>")
  Ok(_) -> print("cached")
  Error(why) -> print(`could not cache: {why.message}`)
end

-- A file, or an empty directory.
match fs.remove("work/cache/page.html")
  Ok(_) -> print("removed")
  Error(why) -> print(`could not remove: {why}`)
end

-- A whole tree. `fs.remove` would refuse this and point you here.
match fs.remove_all("work")
  Ok(_) -> print("cleaned up")
  Error(why) -> print(`could not clean up: {why}`)
end

A missing path is an Error value, not a crash — so "delete it if it's there" needs no fs.exists check first, and neither does fs.mkdir, which is Ok on a directory that already exists.

What bounds the damage is the grant, not your care. Every deletion goes through the same path checks as every other fs call: an absolute path is rejected, .. is rejected, the path must resolve inside the granted root, the root itself is refused however you spell it, and a symlink is unlinked rather than followed — so a link inside the root can never be used to delete the file it points at. A wrong path variable can still delete the wrong thing inside the root, which is the argument for two habits:

  • Grant --allow-fs at the narrowest directory that works. --allow-fs=./build and --allow-fs=/ are the same code with very different consequences.
  • Prefer removing paths your own script created, as above, over paths it was handed.

#You cannot escape the root

The root is a boundary, not a suggestion. Paths are resolved within it, openat-style — an absolute path, one with .., or one that resolves out of the root through a symlink is rejected outright:

fs.read("../../etc/passwd")   -- Error("the path contains `..` — it cannot climb out of the granted root")
fs.read("/etc/passwd")        -- Error("the path is absolute — `fs` paths are relative to the granted root, …")
fs.read("link-to-etc/passwd") -- Error("the path resolves outside the granted root — a symlink pointing out of it, most likely")

On Windows a : anywhere in a path is refused as well. There, notes:v2.txt would name a hidden stream inside the file notes, and C:x would be relative to drive C's current folder, which can be outside the root. Either way the answer is an Error saying so, and nothing is created.

Each refusal names the rule it broke, so you don't have to work out which of three applied. They all mention the granted root, which is the phrase to look for when you're deciding whether a path was refused by the boundary or was simply missing.

So even a script you grant fs to can only touch what's under the directory you rooted it at. Grant a narrow directory and that's the entire reach — you can audit it from the command line, not the code.

#The same script, sandboxed in WebAssembly

The capability model isn't CLI-specific. Run the WASM build under a WASI host and the host's preopen is the root:

wasmtime run --dir=.::. zig-out/scua.wasm --allow-fs script.scua

Here --dir (wasmtime granting one directory to the module) and --allow-fs (SCUA granting fs to the script) stack: the script can only reach what both layers allow, and the WebAssembly sandbox enforces the outer boundary at the runtime level. The same script.scua runs unchanged.

#How this fits the capability model

fs is one of several capabilities, each granted explicitly and denied by default. Network access is the same shape — granted with --allow-net, served by the http and net modules — see Fetch data over HTTP; environment variables go through env with --allow-env. Every capability is host-pluggable by design (no client is bundled into the core). File I/O here is blocking, which is exactly right for a CLI script, and the architecture leaves room for a non-blocking, multi-tenant backend later without changing how any of this looks to a script. The pattern stays the same throughout: grant the narrow thing the script needs, handle the Error cases, and know nothing you didn't grant is reachable.

One consequence worth knowing early: a file that imports a capability cannot be reached by scua test, which takes no grants. The way round it is to take the capability as a parameter and bind the real one in a small adapter — which also lets a host supply its own. See Testing code that needs a capability.