std.fs
Files and directories. Gated on fs:read and fs:write for package code; root code has full authority. Both capabilities take an optional path scope (fs:write:./logs), so a package can be confined to one directory.
import std.fs
fs.read("notes.txt") # string
fs.read_bytes("logo.png") # bytes
fs.write("out.txt", text)
fs.append("log.txt", line)
fs.open("huge.csv") # a stream, read or written a piece at a time
fs.exists(p) fs.is_file(p) fs.is_dir(p) fs.size(p)
fs.is_symlink(p) fs.modified(p) fs.canonical(p) fs.symlink(target, link)
fs.list_dir(dir) # names
fs.walk(dir) # recursive entries
fs.glob("src/**/*.ecko")
fs.mkdir(p) fs.copy(a, b) fs.rename(a, b) fs.remove(p)
fs.mode(p) fs.chmod(p, "755")
copy reads its source and writes its target, so for package code it needs fs:read on the first path and fs:write on the second. rename needs fs:write on both.
Making a file executable
fs.write("./deploy.sh", "#!/bin/sh\necho deploying\n")
fs.chmod("./deploy.sh", "755")
Without this the only way to mark a script runnable was os.exec("chmod", ..), which needs the exec capability - so a package allowed to write a hook had to be trusted to run any program on the machine to finish the job. chmod needs fs:write, because setting the executable bit is a write to that file; fs.mode needs only fs:read.
The mode is a string. "755", not 755. Ecko has no octal literal, so the readable spelling is not available as a number, and the number you would reach for means something else entirely: 755 in decimal is 0o1363 - setuid, and readable by nobody. A numeric mode is refused rather than silently applied, and the error shows the string to write instead.
fs.mode gives back the same spelling, so the two round-trip:
fs.mode("./deploy.sh") # "755"
Unix only. Windows decides what is executable from the file extension, so there is no mode to set and both calls say so rather than pretending.
Text or bytes
modified is unix milliseconds, the same clock as time.now(), so the two compare without converting anything.
is_symlink is the one that is easy to miss: is_file and is_dir both follow a link, so without it a symlink - including a dangling one, which exists reports as absent - is invisible from Ecko.
canonical is a realpath: absolute, with symlinks and .. resolved. The path has to exist, because resolving a link means reading it.
size is a stat, not a read: it answers in bytes without opening the file, so it works on something far larger than memory. len(fs.read_bytes(p)) gives the same number by loading the whole file, which is what you are avoiding. A directory is refused rather than answered with the size of its entry.
read returns a string and errors on invalid UTF-8. read_bytes returns bytes and always works. Use read_bytes for anything that is not certainly text - the strict default is what stops a binary file from becoming a string full of replacement characters.
Reading in pieces
fs.open hands back a stream rather than the file's contents, so the memory a program spends is a piece rather than the file:
import std.fs
import std.io
s = fs.open("huge.csv")
for line in io.lines(s) {
process(line)
}
io.close(s)
"r" is the default. "w" truncates an existing file or creates one, and "a" appends, so fs.open(p, "w") and fs.open(p, "a") are the streaming forms of fs.write and fs.append. Reading needs fs:read, the two writing modes need fs:write, and both are checked against the same path scopes as the rest of the module. Anything else, including an unknown mode string, is an error that says which modes exist.
fs.read and fs.read_bytes refuse a file larger than ECKO_MAX_ALLOC (256 MiB by default, see limits) rather than trying to hold it, and the error names fs.open as the way through. A stream has no such ceiling: the cap applies to each piece you ask for, not to the file.
The cap is not this module's alone. csv.read, json.read, db.load, config.load and the ecko.json loader all read a whole file to parse it, and all go through the same reader - so a file too big to hold is the same refusal whichever module asks for it. http.serve's static handler holds a response body in memory too, and answers 500 rather than reading past the cap. The refusal is kind: "limit", carrying limit, env and path.
hash.sha256(fs.open(path)) digests a file without loading it - see std.hash.
Paths
fs.join(a, b), fs.basename(p), fs.dirname(p), fs.extension(p) - and fs.path(...) for building one. Use these rather than concatenating with "/", so the code works on Windows too.
Walking and matching
list_dir is one level; walk recurses and yields entries with path and is_dir. glob takes a pattern with * and **, and fs.match(pattern, path) tests one path against a pattern.
for entry in fs.walk("src") {
if not entry.is_dir and fs.extension(entry.path) == "ecko" { check(entry.path) }
}
Temporary files
dir = fs.temp_dir()
f = fs.temp_file("")
temp_file gives a unique path in the system temporary directory. Nothing cleans up after you - remove what you create.
Both write into the system temp directory, so a package on a scoped fs:write needs that directory covered by its scope.
Errors
Everything raises { kind: "fs", path, message } on failure. The path field is there so a handler can report which file, which is the thing you always want and often lose:
try { data = fs.read(p) } catch (e) {
match get(e, "kind") { "fs" => print("cannot read {e.path}") _ => error(e) }
}
Paths from untrusted input
A path built from user input can escape the directory you meant. ../../etc/passwd is a path. If a request names a file, validate it - or better, do not use fs for it at all: web.static resolves safely and is the right tool for serving files.
Capabilities do confine a package to a directory, if you grant them that way: fs:read:./uploads covers ./uploads and nothing else, and neither .. nor a symlink gets a path out of it. See capabilities.
That protects you from a package. It does not protect a package from its own caller - your top-level code is ungated, so validating a path you built from a request is still your job.