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)orError(reason), reading a file as UTF-8 text. A missing file is anErrorvalue you handle, not a crash — same asjson.decode. Invalid UTF-8 is anErrorthat names the offending byte offset;\r\nis normalized to\nso line handling is uniform across platforms (usefs.readif 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 anErrornaming 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.grepsearches it without loading it.fs.read(path, opts?)→Ok(contents)orError(reason), reading the rawbytes, exactly as stored — use this for binary files (images, archives), andfs.read_textfor text. Takes the same{ max_bytes = N }option.fs.write(path, content)→Ok(nil)orError(reason), wherecontentis a string orbytes. It creates or truncates the file; the parent directory must already exist — callfs.mkdirfirst if it might not. Replacing a file empties it first — see Replacing a file without losing it.fs.rename(from, to)→Ok(nil)orError(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)orError(reason). Create a directory, including any missing parents. A directory that already exists isOk, 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)orError(reason). Delete a file, or an empty directory. A missing path is anErrorvalue to handle, not a crash. A non-empty directory is refused and tells you to reach forfs.remove_all— the destructive verb is one you have to type on purpose.fs.remove_all(path)→Ok(nil)orError(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-fsat the narrowest directory that works, and prefer deleting only paths your own script created.fs.list(path)→Ok(names)orError(reason). The bare entry names directly under a directory (one level, names only). Use"."for the granted root itself. For kinds, recursion, or filtering, usefs.find. Handing it a file is anErrorthat says so.fs.find(path, opts?)→Ok(entries)orError(reason). The richer search by name. Each entry is a record{ path, name, is_dir }—pathis relative to the root, so it feeds straight back intofs.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.gitignorefiles in the tree (skipping ignored entries and pruning ignored directories). Shallow and unfiltered by default.fs.grep(pattern, path?, opts?)→Ok(hits)orError(reason). Search file contents for the fixed stringpattern. By default each hit is{ path, line, col, text }—pathroot-relative,line/col1-based (matching your editor),textthe 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.pathmay name a single file as well as a directory; a named file is searched whatever thefilter/exclude/gitignoreoptions 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).patternis 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
Errorsaying so. To search across lines, read the file withfs.read_textand usestr.findorregex.findon the whole text. maxis honest in both directions. Without one, the search stops at 10 000 results — and if there were more, that is anErrornaming 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 globalmax). - Surrounding context:
{ context = N }attaches the N lines on each side of a match asbeforeandafterstring 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
regexvalue (fromregex.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 astopfield) instead of the whole line.col/stopstay infs.grep's 1-based editor coordinates — they are positions in a file, not the 0-based string offsetsregex.findreports.
- 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
fs.grep_text(pattern, path?, opts?)→string. The same search asfs.grep, rendered as one compactpath:line: textstring (one line per hit) — the form to paste straight into a prompt or a log without post-processing each record. Withfiles_with_matchesit lists one path per line; withcount_only,path:count. Returns""when nothing matches. (It faults on a bad path rather than returning anErrorvalue — it's a convenience for when you just want the text.)fs.exists(path)→bool— whether something exists atpathwithin the root. A path that would escape the root reads asfalse("not within the root"), never an error.fs.stat(path)→Ok({ size, is_dir, is_file, mtime_ms })orError(reason)— file metadata: bytesize, whether it's a directory or a regular file, andmtime_ms(last-modified time, milliseconds since the epoch)."."stats the root.fs.lstat(path)→Ok({ size, is_dir, is_file, is_symlink, mtime_ms })orError(reason)— the same record for the path itself, without following a final symlink.fs.statfollows links, so it describes what a link points at and reportsis_file = truefor the link;fs.lstatis 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, thenfs.renameit 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}`) endA 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)orError(reason). The path is checked once, here, by the same rules as every otherfsverb. Opening a directory is anError.modeis"read"for now.fs.read_line(f)→Ok(line)orError(reason), where the line has no terminator andnilmeans the file ended. A line longer than 65536 bytes, or one that is not valid UTF-8, is anErrortelling you to read that file withfs.read_chunkinstead.⚠️ It folds
\r\nto\n, likefs.read_textand 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 — usefs.read_chunk, which changes nothing. The rule across the module is the same onefs.readandfs.read_textfollow: text verbs give you LF; byte verbs give you exact bytes.fs.read_chunk(f, max)→Ok(bytes)orError(reason), up tomaxbytes, 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 youbytes— buffer across boundaries withbytes.concatand split withbytes.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)orError(reason).datais a string orbytes. 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, asbeforeandafterstring lists — the same thinggrep -Cgives 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 astopcolumn 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-fsat the narrowest directory that works.--allow-fs=./buildand--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.