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 it with `--allow-fs` on the CLI, or the host's capability API when embedding

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.

#Reading, writing, listing

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(reason), where content is a string or bytes. 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.

  • fs.rename(from, to)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. Both paths obey the same rooting rules as everything else, and the root itself is refused at either end.

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

  • 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 — 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 longer than 65536 bytes, or one that is not valid UTF-8, is an Error telling you to read that file with fs.read_chunk instead.

    ⚠️ It folds \r\n to \n, like fs.read_text and unlike the byte verbs. 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 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.
  • fs.write_chunk(f, data)Ok(count) or Error(reason). data is a string or bytes. 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: the file is now exactly its previous length plus those bytes, so you know what state it is in rather than having to assume the worst and throw it away.

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.

#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}`)
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.

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.

#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

#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 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}`)
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")

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.