Skip to content

Files

Read, write, inspect, and stream filesystem entries.

Updated View as Markdown

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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close