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)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({ kind, message }), wherecontentis a string orbytes. Thekindsays 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), andiofor anything else, which may pass on a retry.messageis the text to show. 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. Options:{ exclusive = true }writes only a file that is not there yet and returns anErrorif 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)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 — 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 anError, and if replacing it is what you mean,fs.removeit first. With{ replace = false }it never replaces: iftoalready 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 withreplace = false. Some filesystems can't rename without replacing (exFAT, for one): there the call is anErrorsaying 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)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), in natural order: runs of digits compare as numbers, soe1m2comes beforee1m10, the same on every OS. 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. Entries come depth first, each directory before what is inside it, and each directory's entries in the same natural order asfs.list.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 — 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)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 ends at\n,\r\nor a lone\r, the same breaksstr.linessplits 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 anErrortelling you to read that file withfs.read_chunkinstead.⚠️ 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
\nloses 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."create"creates the file and returns anErrorif 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, andfs.seektakes you anywhere else. It never creates a file: a missing one is anError.fs.sync(f)→Ok(nil)orError(reason)waits until what you wrote is on the disk, not only handed to the operating system. Call it beforefs.closewhen the file must survive a power cut.fs.write_chunk(f, data, { from, to }?)→Ok(count)orError(reason).datais a string orbytes;{ from, to }writes just that byte range of it (0-based,toexclusive), 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)orError(reason). The position comes back counted in bytes from the start.fromis"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" })thenfs.write_chunkrewrites its last 16 bytes. - Positions count bytes, not characters. After
fs.seekinto the middle of a UTF-8 character,fs.read_linegives anErrorfor that line;fs.read_chunkgives 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 rangedfs.readmakes.
#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 withfs.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 returnsOk, the name is yours and the swap replaces only your own placeholder. Checkingfs.existsand 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.listandfs.findshow 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)orError({ 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
kindof anErrorsays whether to try again.timeoutmeans 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 withfs.mkdir) andbad_path(the path cannot be a lock file, such as a folder).unsupportedmeans this file system has no locks at all.iois 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 withfs.write(…, { exclusive = true })plus a timestamp cannot do: it has to guess whether its holder is still alive. - It stops other
fs.lockcalls only. Reads and writes that do not ask for the lock go ahead, so every program that changes the files takes it. Twofs.lockcalls 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.servehandler, 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 tofs.readand the rest, from the root:src/lib/util.scua. Auser:search givesuser:paths (user:saves/slot1.json), and ares:searchres:paths.name— the last part,util.scua.rel— the path below the directory searched,lib/util.scuaforfs.find("src", …). Join it onto another folder to copy a tree; auser:orres:pathjoined onto a host folder is refused, namingrel.is_dir— whether it is a directory.bad_name—truefor 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. Alwaysfalseelsewhere.
#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, 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.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-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")
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.