File and Dir are stateless namespaces over host filesystem operations;
OpenFile is a live host resource. Every operation suspends, and every
operation except File.exists / Dir.exists returns Result[T, IoError].
Reading a whole file
The smallest useful program: propagate the Result with ? from a fallible
function.
fn read_config(path: string): string ! IoError {
return File.read(path)?
}
fn main(): void {
match read_config("/etc/hostname") {
Ok(text) => println("${text.len()} bytes")
Err(e) => println("could not read config")
}
}? needs an enclosing fallible function. Dropping the ! IoError from the
signature is the most common first mistake:
fn read_config(path: string): string {
return File.read(path)?
}That is ATOLL2002: ? on result requires an enclosing fallible function.
Returning the call without ? fails too — the type is Result[string, IoError],
not string.
The whole-file surface
| Method | Result payload |
|---|---|
File.read(path) |
UTF-8 string |
File.read_bytes(path) |
Owned []byte |
File.read_bytes_zerocopy(path) |
Owned []byte, filled in place |
File.read_lines(path) |
Owned []string |
File.write(path, text) |
void — create or replace |
File.write_bytes(path, bytes) |
void — create or replace |
File.append(path, text) |
void — create if absent |
File.append_bytes(path, bytes) |
void |
File.copy(src, dst) |
void |
File.move(src, dst) |
void — rename or move |
File.delete(path) |
void — NotFound when missing |
File.delete_if_exists(path) |
void — succeeds when missing |
File.exists(path) |
bool, not a Result |
fn round_trip(path: string): int ! IoError {
File.write(path, "alpha\nbeta\n")?
File.append(path, "gamma\n")?
text := File.read(path)?
lines := File.read_lines(path)?
raw := File.read_bytes(path)?
println("${text.len()} bytes, ${lines.len()} lines, ${raw.len()} raw")
backup := "${path}.bak"
File.copy(path, backup)?
println("backup present = ${File.exists(backup)}")
File.delete(backup)?
File.delete_if_exists(path)?
return lines.len()
}
fn main(): void {
match round_trip("/tmp/atoll-book-demo.txt") {
Ok(n) => println("wrote ${n} lines")
Err(e) => println("io failure")
}
}read_bytes_zerocopy reads metadata first, sizes one guest buffer, then fills
it directly from the kernel — no host staging copy. It costs two extra
round-trips, so it pays off on large binary files and not on small ones.
fn byte_count(path: string): int ! IoError {
raw := File.read_bytes_zerocopy(path)?
return raw.len()
}
fn checksum(path: string): int ! IoError {
raw := File.read_bytes(path)?
mut sum := 0
for b in raw {
sum = sum + int(b)
}
return sum
}
fn main(): void {
match byte_count("/etc/hostname") {
Ok(n) => println("${n} bytes")
Err(e) => println("unreadable")
}
match checksum("/etc/hostname") {
Ok(n) => println("checksum ${n}")
Err(e) => println("unreadable")
}
}Metadata
File.metadata(path) returns kind flags, a byte size, and a
FilePermissions record.
fn describe(path: string): string ! IoError {
meta := File.metadata(path)?
kind := if meta.is_directory {
"dir"
} else if meta.is_symlink {
"link"
} else {
"file"
}
p := meta.permissions
return "${kind} ${meta.size}B r=${p.readable} w=${p.writable} x=${p.executable}"
}
fn main(): void {
match describe("/etc/hostname") {
Ok(line) => println(line)
Err(e) => println("no metadata")
}
}Metadata is a snapshot. Another process can extend, replace, or delete the file
between the metadata call and the read, so a size check is a cheap early
rejection, not a guarantee. Enforce the real limit while consuming the bytes
too, and never treat a prior exists check as a lock — just perform the
fallible operation and handle its failure.
Open handles
File.open_read and File.create return an OpenFile whose read_into /
write_all move bytes directly between the kernel and the guest allocation.
Use them when the size is unknown, can grow, or can be processed incrementally.
fn total_bytes(path: string): int ! IoError {
f := File.open_read(path)?
mut buf: []byte = List.with_capacity[byte](4096)
mut total := 0
for {
n := f.read_into(buf, 4096)?
if n == 0 {
break
}
total = total + n
}
f.close()
return total
}
fn main(): void {
match total_bytes("/etc/hostname") {
Ok(n) => println("${n} bytes")
Err(e) => println("read failed")
}
}read_into appends up to max bytes to the buffer and returns the count;
0 means end of file. It never clears the buffer, so track consumed bytes
yourself — the loop above counts n rather than reading buf.len().
write_all takes a borrowed [..]byte slice, not an owned []byte, and
returns the number of bytes written. string.bytes() already produces that
shape:
fn save(path: string, text: string): int ! IoError {
f := File.create(path)?
return f.write_all(text.bytes())?
}
fn main(): void {
match save("/tmp/atoll-book-save.txt", "hello") {
Ok(n) => println("wrote ${n}")
Err(e) => println("write failed")
}
}Handles close through Drop, including on an early ? return, so a forgotten
close() never leaks a descriptor. Call close() explicitly only when the
resource must go before the end of scope, and never touch the handle
afterwards.
Directories
Dir.list yields the immediate entries; Dir.walk recurses. A DirEntry
carries name, the full path, and the same three kind flags as metadata.
fn count_files(dir: string): int ! IoError {
entries := Dir.list(dir)?
mut files := 0
for e in entries {
if e.is_file {
files = files + 1
}
println("${e.name} (dir=${e.is_directory} link=${e.is_symlink}) ${e.path}")
}
return files
}
fn main(): void {
match count_files("/etc") {
Ok(n) => println("${n} files")
Err(e) => println("cannot list")
}
}Lifecycle mirrors File: create, create_all, delete, delete_all,
copy, move, exists.
fn setup(root: string): int ! IoError {
scratch := "${root}/tmp"
Dir.create_all("${root}/logs/archive")?
Dir.create(scratch)?
println("tmp exists = ${Dir.exists(scratch)}")
Dir.copy("${root}/logs", "${root}/logs-copy")?
Dir.move("${root}/logs-copy", "${root}/logs-backup")?
all := Dir.walk(root)?
Dir.delete(scratch)?
Dir.delete_all("${root}/logs-backup")?
return all.len()
}
fn main(): void {
match setup("/tmp/atoll-book-root") {
Ok(n) => println("${n} entries beneath root")
Err(e) => println("setup failed")
}
}Dir.create requires the parent to exist; create_all makes missing parents.
Dir.delete requires an empty directory; Dir.delete_all is recursive and
destructive — derive its target from validated application data, never from an
unchecked user string.
Errors
IoError names the failures a caller can act on. Most variants carry the
offending path; ReadError / WriteError add a host message, Timeout
adds durationMs, and Other carries only a message.
fn load_or_default(path: string, fallback: string): string {
match File.read(path) {
Ok(text) => text
Err(NotFound { path: p }) => fallback
Err(PermissionDenied { path: p }) => "denied: ${p}"
Err(ReadError { path: p, message: m }) => "read failed on ${p}: ${m}"
Err(e) => "unclassified io failure"
}
}
fn main(): void {
println(load_or_default("/etc/hostname", "localhost"))
}Bind the error as err or e. error is a keyword, so the obvious spelling
does not even parse:
fn load(path: string): string ! IoError {
match File.read(path) {
Ok(text) => Ok(text)
Err(error) => Err(error)
}
}That produces ATOLL1016: Invalid pattern.
When mapping IoError onto your own error type, note that a variant with named
fields is constructed unqualified. The qualified form resolves to a
constructor function and is rejected:
error UploadError { TooLarge { size: int } }
fn guard(path: string): int ! UploadError {
meta := File.metadata(path)?
if meta.size > 4096 {
error UploadError.TooLarge { size: meta.size }
}
return meta.size
}Write error TooLarge { size: meta.size } instead.
The handle-based wrappers (open_read, create, read_into, write_all)
currently collapse every negative host errno into Other { message: "" }. Only
the whole-file operations preserve the full taxonomy — prefer them when the
caller needs to distinguish NotFound from PermissionDenied.
A worked example
Import validation, staged write, and publish-by-rename — the shape most applications actually need.
error ImportError {
TooLarge { limit: int, actual: int }
NotAFile { path: string }
Io { message: string }
}
const MAX_IMPORT: int = 1048576
fn guard_size(path: string): int ! ImportError {
meta := match File.metadata(path) {
Ok(m) => m
Err(NotFound { path: p }) => error Io { message: "no such file: ${p}" }
Err(e) => error Io { message: "metadata failed" }
}
if !meta.is_file {
error NotAFile { path: path }
}
if meta.size > MAX_IMPORT {
error TooLarge { limit: MAX_IMPORT, actual: meta.size }
}
return meta.size
}
fn import_lines(path: string): []string ! ImportError {
guard_size(path)?
return match File.read_lines(path) {
Ok(lines) => lines
Err(e) => error Io { message: "read failed" }
}
}
fn stage_and_publish(dir: string, name: string, body: string): int ! ImportError
{
staged := "${dir}/${name}.staging"
final := "${dir}/${name}"
match Dir.create_all(dir) {
Ok(_) => {}
Err(e) => error Io { message: "mkdir failed" }
}
match File.write(staged, body) {
Ok(_) => {}
Err(e) => error Io { message: "write failed" }
}
match File.move(staged, final) {
Ok(_) => {}
Err(e) => error Io { message: "publish failed" }
}
return body.len()
}
fn main(): void {
written := match stage_and_publish("/tmp/atoll-book", "notes.txt", "alpha\nbeta\n") {
Ok(n) => n
Err(TooLarge { limit, actual }) => {
println("${actual} exceeds ${limit}")
return
}
Err(NotAFile { path }) => {
println("not a file: ${path}")
return
}
Err(Io { message }) => {
println("io: ${message}")
return
}
}
println("published ${written} bytes")
match import_lines("/tmp/atoll-book/notes.txt") {
Ok(lines) => println("read ${lines.len()} lines")
Err(e) => println("import failed")
}
}Writing to a temporary file in the destination directory and then moving it over the target is the standard way to avoid publishing a half-written file. Atoll does not promise it is atomic: durability and overwrite semantics are host-platform concerns, and a successful sequence of ordinary calls is not a filesystem transaction. Database transactions do not cover filesystem changes either.
Capabilities and paths
Host capability policy can deny filesystem access even when the source
type-checks — the call fails at load, not at compile time. List only the
namespaces a project actually needs in its [host] allow-list. Type safety
grants no ambient filesystem authority.
Paths keep host-platform semantics. When joining a trusted root with user text,
containment must be validated after the platform’s normalization and symlink
policy are applied. Rejecting literal .. segments alone does not stop
absolute-path, separator, case-folding, or symlink escapes on every host.