/
Search
EN

Filesystem: capability-gated files, linear handles, deterministic crashes

When a program opens a file, the operating system gives it a file descriptor: a small integer that names the open file inside that process, until the program closes it. Descriptors are a limited resource, and a long-running server that forgets to close them eventually cannot open anything. So every file you open is a promise to close it, and every language has its own way of not quite keeping that promise. C leaves it to a comment. C++ and Rust run a destructor you never see. kola applies the rule from Resources and linearity to the filesystem: a file handle is an ordinary resource, it must be consumed exactly once on every path, and a program that forgets to close a file does not compile.

No global filesystem

There is no open(path) you can call from anywhere. kola has no ambient authority, and the filesystem is a capability like the clock or the random source. In a running program it is sys.fs, rooted at the working directory. Under kola test it is sim.fs, a deterministic in-memory filesystem described at the end of this page. A function that never receives an Fs (or a Dir derived from one) cannot touch a file.

The basic operations are deliberately unremarkable. open_read, create, open_append, open_rw and create_rw return a File, and read, write, sync and close work on it:

fn main(sys: Sys) -> u8 !{io, block, alloc(heap)} {
    let mut f = sys.fs.create("greeting.txt").unwrap();
    let _w = f.write(&b"hello, kola");
    let _s = f.sync().unwrap();
    let _c = f.close();
    0
}

open_rw opens an existing file for reading and writing and answers NotFound if it is missing; create_rw creates it if needed. Neither truncates. create does.

A successful write does not mean the bytes are on the disk. The operating system first copies them into its page cache, an area of memory where it keeps recently used file data, and writes them out later. A power cut before that loses them. sync (the fsync system call underneath) waits until the file's data has actually reached the storage device. It answers Result[(), (FsErr, OsErr)] and leaves the handle with you, whatever happened. Its consuming twin, sync_or_close(self) -> Result[File, (FsErr, OsErr)], hands the file back only on success and closes it on failure. That matters because retrying a failed fsync is not safe: on Linux the kernel may already have discarded the unwritten data and reported the error once, so a second fsync can succeed without the data ever reaching the disk. sync_or_close makes that retry impossible to write.

A File also has positional I/O (read_at, write_at, with an explicit u64 offset and no shared cursor), size, truncate, dup, advisory locks, extended attributes and direct I/O. The path side has mkdir, remove, remove_dir, rename, read_dir, stat and is_private. The complete list with signatures is in the Filesystem and File sections of STD-CARD (kola help std-card). This page teaches the shapes.

Byte counts through your buffer are usize, because they slice that buffer (buf[0..n]). Offsets, lengths and sizes of a file are u64.

File is a linear resource

Delete the sync and close lines from the program above and it no longer compiles:

fn main(sys: Sys) -> u8 !{io, block, alloc(heap)} {
    let mut f = sys.fs.create("greeting.txt").unwrap();
    let _w = f.write(&b"hello, kola");
    0
}
error[card §2]: resource `f` is not consumed on this path
  --> greeting.kl:2:5
   |
 2 |     let mut f = sys.fs.create("greeting.txt").unwrap();
   |     ^^^
   = help: no implicit destructors: move it on, return it, destructure it, or pass it to a consuming fn

This is the same diagnostic you get for any user-defined resource. A leaked file descriptor is not a special case the checker knows about. Every File, Tmp, Sealed and FsNode must be closed, sealed, installed or abandoned on every path, including error paths and early returns. Here is write_sync from std::fs:

pub fn write_sync(fs: &Fs, path: &str, data: &[u8]) -> Result[(), (FsErr, OsErr)] !{io, block} {
    let mut f = fs.create(path)?;
    match f.write(data) {
        Ok(_) => {}
        Err(e) => { let _c = f.close(); return Err(e); }
    }
    let f = f.sync_or_close()?;
    f.close()
}

The ? after fs.create is safe because nothing is live yet. After the write, f is live, so a bare ? there would leak it and the checker would refuse it. The error arm closes the file before returning. The ? after sync_or_close is safe again, because that call has already closed the file when it fails.

Reading a whole file

f.read(&mut buf) fills one buffer and answers how many bytes it got. For the common case of reading an entire file, std::fs::read_all loops that call and appends to a Vec[u8] you supply:

use std::fs;

test read_all_whole_file (mut sim: Sim) {
    region r {
        let data = alloc in r [107u8, 111u8, 108u8, 97u8];  // "kola"
        let _w = fs::write_sync(&sim.fs, "greeting.txt", data);
        let out: &r mut Vec[u8] = alloc in r mut Vec::new();
        match fs::read_all(&sim.fs, "greeting.txt", out) {
            Ok(n) => { assert n == 4usize; }
            Err(_) => { assert false; }
        }
    }
}

read_all reads in 512-byte chunks, but it keeps every chunk, so its peak memory is the whole file and there is no size cap. It suits configuration files and small payloads. For anything you would not want resident in one region at once, call f.read(&mut chunk) yourself and process each chunk as it arrives, or ask stat for the size first and refuse files that are too large. The helpers have twins for granted roots: write_sync_in, install_bytes_in, read_all_in and read_all_in_ro.

stat: size, time and kind without opening

fs.stat(path) answers Result[FileInfo, (FsErr, OsErr)] with FileInfo { size: u64, mtime_ns: u64, kind: FileKind }, without creating a handle you would have to close. It exists on Fs, Dir and DirRo.

FileKind is named for the question a caller has rather than for the POSIX file types. Extent is fixed-size and randomly addressable (a regular file or a block device). Stream may block and has no meaningful size (a character device, a FIFO, a socket). Dir is a directory. Anything not provably one of these is Other. A directory answers Ok with kind == Dir and size == 0. For a block device, size is the device's length, not the zero that st_size reports. On Darwin that length is only available when the device can be opened; otherwise it is 0, the one case where size == 0 does not mean empty.

Extent describes addressing, not latency: a regular file on a network mount can still block forever. And stat does not replace change_token(path). Many filesystems store mtime with one-second granularity, so a watcher built on mtime can miss a change. Use change_token to detect a change and stat when you need the number.

use std::fs;

test stat_reports_size_and_kind (mut sim: Sim) {
    match fs::write_sync(&sim.fs, "a.bin", &b"abc") { Ok(_) => {} Err(_) => { assert false; } }
    match sim.fs.stat("a.bin") {
        Ok(fi) => { assert fi.size == 3u64; assert fi.kind == FileKind::Extent; }
        Err(_) => { assert false; }
    }
    match sim.fs.mkdir("d") { Ok(_) => {} Err(_) => { assert false; } }
    match sim.fs.stat("d") {
        Ok(fi) => { assert fi.kind == FileKind::Dir; assert fi.size == 0u64; }
        Err(_) => { assert false; }
    }
    match sim.fs.stat("never-written") {
        Ok(_) => { assert false; }
        Err((e, _)) => { assert e == FsErr::NotFound; }
    }
}

// Take the size first, then the bytes. A writer that lands in between is then
// visible: the counts disagree and the caller retries. In the other order the
// recorded size silently stops describing what was read.
pub fn sized_read[r](f: &Fs, path: &str, out: &r mut Vec[u8]) -> Result[u64, (FsErr, OsErr)] !{io, block, alloc(r)} {
    let want = f.stat(path)?;
    let got = fs::read_all(f, path, out)?;
    if widen[u64](got) != want.size {
        return Err((FsErr::Invalid, OsErr::Other));
    }
    Ok(widen[u64](got))
}

test the_sized_read_helper (mut sim: Sim) {
    match fs::write_sync(&sim.fs, "r.bin", &b"payload!") { Ok(_) => {} Err(_) => { assert false; } }
    region r {
        let mut buf = alloc in r mut Vec::new();
        match sized_read(&sim.fs, "r.bin", buf) {
            Ok(n) => { assert n == 8u64; assert buf.len == 8usize; }
            Err(_) => { assert false; }
        }
    }
}

Atomic replace as a typestate

Suppose a program rewrites its configuration file in place and the machine loses power halfway through. The file is now half old and half new, which is usually worse than either. The standard fix relies on rename, which replaces one name with another atomically: anyone looking at the path sees either the old file or the new one, never a mix. The full sequence is: write a temporary file, fsync it, rename it over the target, then fsync the directory so the rename itself is durable. Skip the first fsync and a crash can leave the new name pointing at a file whose data never reached the disk. The sequence is well known, and so is how easy it is to get one step wrong. kola spells the sequence as three types, so the steps cannot be put in the wrong order:

use std::fs;

test typestate_install (mut sim: Sim) {
    region r {
        let data = alloc in r [1u8, 2u8, 3u8];
        let mut t = sim.fs.create_tmp("").unwrap();
        let _w = t.write(data);
        let s = t.seal().unwrap();
        let _i = s.install("cfg.bin");
    }
}

create_tmp(dir) opens an unnamed file in dir. A Tmp can write, write_at, set_mode, seal and abandon, and it has no install. seal fsyncs the file, consumes the Tmp and produces a Sealed, whose only operation is install(path). install performs the rename and the directory fsync as one step, so a caller cannot do half of it. If you skip seal, the classic bug "rename before fsync" is a compile error:

test typestate_skip_seal (mut sim: Sim) {
    region r {
        let data = alloc in r [1u8, 2u8, 3u8];
        let mut t = sim.fs.create_tmp("").unwrap();
        let _w = t.write(data);
        let _i = t.install("cfg.bin");   // Tmp has no `install`
    }
}

The compiler reports two errors. The first (card §5) says there is no method install on Tmp. The second says the resource t is not consumed on this path, because t still has to be consumed somehow.

A file installed this way gets mode 0600 unless you call t.set_mode(mode) before seal. Extended attributes (small named values stored with a file, outside its contents) set on the Tmp before seal are persisted with the bytes and published by the same rename, so after a crash the file is either absent or present with its metadata. std::fs::install_bytes wraps the whole sequence for the common case:

pub fn install_bytes(fs: &Fs, dir: &str, path: &str, data: &[u8]) -> Result[(), (FsErr, OsErr)] !{io, block} {
    let mut t = fs.create_tmp(dir)?;
    match t.write(data) {
        Ok(_) => {}
        Err(e) => { t.abandon(); return Err(e); }
    }
    let s = t.seal()?;
    s.install(path)
}

For moving existing files there is rename(path, to), which renames a file or a directory and then fsyncs both parent directories. Across filesystems it fails with FsErr::Invalid and OsErr::XDev, so you can match Err((_, OsErr::XDev)) and fall back to a copy.

Paths are relative to a root

A capability that grants a directory should not let the program reach files outside it. Two things in a path can escape: .., which climbs to the parent directory, and an absolute path starting with /. A symbolic link (symlink), a file that only contains the path of another file, can escape too. sys.fs is rooted at the working directory, and every path you pass is resolved beneath its root. A literal path with a leading / or a .. component is refused at check time, on every kind of root:

pub fn load(fs: &Fs) -> Result[(), (FsErr, OsErr)] !{io, block} {
    let f = fs.open_read("/etc/passwd")?;
    f.close()
}
error[card §7]: `open_read` is given an absolute path — a capability path is relative to its root
  --> pathlit.kl:2:26
   |
 2 |     let f = fs.open_read("/etc/passwd")?;
   |                          ^^^^^^^^^^^^^

The help text that follows explains the alternative: declare a granted root, take it in the program, let the operator bind it, and open the path beneath it (next section). The check follows a literal through wrapper functions and through a const, so moving the string into a constant does not bring the bug back.

A computed path (from a configuration file, a request or an argument) cannot be checked at compile time, so it is decided at run time and answered as a value, never a trap. Two error tags tell you who refused:

  • FsErr::Unreachable is the sandbox's verdict on the string and the grants you hold: any .., or an absolute path outside the root's base. No retry or chmod changes it; the fix is the grant or the spelling.

  • FsErr::Denied is the filesystem's verdict: EACCES, or a symlink that would leave the root.

Every component is walked, and a symlink that escapes the root is refused, even one with an absolute target that points back inside. A symlink target is data that the filesystem supplied, and anyone who can write to a directory can plant one. An absolute path that the program itself passes is treated differently: it is honoured when it lies beneath the root's base, because the operator typed it. For the cwd root, the base is the physical working directory and the comparison is on the string, so a path spelled through a symlinked directory (such as /tmp on macOS) is Unreachable while the same file spelled physically is accepted. Relative paths avoid the question. Under kola test, the simulated filesystem has no working directory, so a computed absolute path is always refused there.

subtree(prefix) attenuates a root to a subdirectory, the same narrowing move as dir.ro(). A subtree has no base of its own, so it refuses absolute paths outright, and a literal prefix with / or .. is refused at check time:

fn escaping_path() -> &heap str !{} { "../secret" }

test escape_is_refused_at_runtime (mut sim: Sim) {
    let sub = sim.fs.subtree("work");
    match sub.open_read(escaping_path()) {
        Ok(f) => { let _c = f.close(); assert false; }
        Err((e, _)) => { assert e == FsErr::Unreachable; }
    }
}

Granted roots

A daemon usually needs a directory such as /var/lib/thing, and its working directory is somewhere else. kola does not weaken the sandbox for this. Instead, named directories are opened at the root, as the WebAssembly system interface (WASI) and FreeBSD's Capsicum sandbox do: the operator binds a name to a path, the program asks for the name, and the name appears in the package manifest where a reviewer can read it.

sys.fs.root(b"db") asks for read-write access and answers Option[Dir]. sys.fs.root_ro(b"cfg") asks for read-only access and answers Option[DirRo]. An unknown name, or a read-write request against a read-only grant, is None, never an error. Dir has the same path operations as Fs, rooted at the grant. DirRo has only the reading ones, so a write through a read-only root is a compile error. d.ro() narrows a Dir to a DirRo.

use std::log;

fn main(sys: Sys) -> u8 !{io, block, alloc(heap), log} {
    let d = match sys.fs.root_ro(b"cfg") {
        Some(d) => d,
        None => { log::error("no cfg grant"); return 2; }
    };
    let mut f = match d.open_read("app.conf") {
        Ok(f) => f,
        Err(_) => { log::error("open failed"); return 3; }
    };
    let mut buf = [0u8; 64];
    let n = match f.read(&mut buf) { Ok(n) => n, Err(_) => 0usize };
    let _c = f.close();
    log::info(f"read {n} bytes through the grant");
    0
}
$ kola run roots.kl --fs-root cfg:ro=/etc/app --log stderr
[info] read 12 bytes through the grant

--fs-root name=/abs grants read-write and name:ro=/abs read-only. The environment twin is KOLA_FS_ROOT_cfg (value ro:/abs for read-only), and a compiled binary reads it too. Calling a write operation through the read-only handle fails at check time:

error[card §7]: `DirRo` is a READ-ONLY granted root — `create` writes, which a read-only root cannot reach

The package manifest declares every name the program may ask for, [caps] fs_roots = ["cfg"], and kola audit reports them. A manifest entry may also name a default location from a closed list (xdg-config, xdg-data, xdg-state, xdg-cache, etc, root), used when the operator has not set the variable; the manifest never spells a path.

Code that takes a granted root can be tested. Under kola test, a name listed in the manifest's fs_roots is a simulated root, so sim.fs.root(b"cfg") answers Some. Outside a pack, pass --sim-fs-root cfg:

use std::fs;

fn read_conf(d: &Dir) -> Result[usize, (FsErr, OsErr)] !{io, block} {
    let mut f = d.open_read("app.conf")?;
    let mut buf = [0u8; 64];
    match f.read(&mut buf) {
        Ok(n) => { f.close()?; Ok(n) }
        Err(e) => { let _c = f.close(); Err(e) }
    }
}

test reads_through_a_granted_root (mut sim: Sim) {
    let d = sim.fs.root(b"cfg").unwrap();
    let w = fs::write_sync_in(&d, "app.conf", &b"port = 8080");
    assert w == Ok(());
    assert read_conf(&d) == Ok(11usize);
}
$ kola test --sim-fs-root cfg conf.kl

Private files, dup and FsNode

On Unix every file has permission bits, its mode: who may read and write it. Mode 0600 means only the owner can. create_private(path) is the variant of create that uses mode 0600 and the O_EXCL flag, which makes the open fail if the file already exists. The file is unreadable to other users from the moment it exists, and an existing path is refused rather than truncated, so a key file cannot be replaced through a race. It exists on Fs and Dir.

is_private(path) -> Result[bool, (FsErr, OsErr)] checks that promise later, after somebody's cp -r, backup restore or chmod -R may have broken it. Ok(true) means provably private: the mode grants nobody else, and no access control list (ACL, an extra per-user permission list some filesystems support) grants access that the mode does not show. (On macOS, chmod +a "everyone allow read" leaves the mode at 0600 and lets everyone read the file, so the check looks at the ACL.) Ok(false) means not provably private, and you should refuse to continue. The call does not repair anything. Under the simulator the answer is how the file was created.

When you hold an open handle and need facts about it, f.security() reports owner, effective caller, link count, whether it is a regular file, privacy and size for the held descriptor, with no second path lookup. open_read_nofollow(path) is the open to pair with it: it refuses a final symlink and cannot be parked on a FIFO.

f.dup() returns a second, independent File with its own close obligation (the dup system call: a second descriptor for the same open file). kola threads share no memory, so a worker thread cannot borrow a handle; a linear resource has to move to the thread that uses it (see Threads and channels). dup lets a handle table keep its entry while a copy travels. Reference-counted handles would mean two live references to one resource, which linearity forbids.

An FsNode refers to a filesystem object rather than to an open file (like an O_PATH descriptor on Linux: a handle that pins a file without opening it for reading or writing). It follows the object across a rename and keeps working after the last link is removed. Its only operations are open_read() and close(), so reading through it directly is a compile error. Obtaining one needs different permissions on different hosts, so portable code handles a refusal at both open_node and open_read.

use std::fs;

test an_anchor_opens_readable_files (mut sim: Sim) {
    match fs::write_sync(&sim.fs, "n.bin", &b"78") { Ok(_) => {} Err(_) => { assert false; } }
    match sim.fs.open_node("n.bin") {
        Ok(n) => {
            // Opening does not consume the anchor: open through it twice.
            match n.open_read() {
                Ok(f) => { let _c = f.close(); }
                Err(_) => { assert false; }
            }
            match n.open_read() {
                Ok(f2) => { let _c2 = f2.close(); }
                Err(_) => { assert false; }
            }
            let _cn = n.close();
        }
        Err(_) => { assert false; }
    }
}

test dup_yields_a_second_independent_handle (mut sim: Sim) {
    match fs::write_sync(&sim.fs, "d.bin", &b"AB") { Ok(_) => {} Err(_) => { assert false; } }
    let a = sim.fs.open_read("d.bin").unwrap();
    let b = a.dup().unwrap();
    let _ca = a.close();                          // close the original...
    let mut buf = [0u8; 2];
    let n = b.read_at(0u64, &mut buf).unwrap();   // ...the dup still works
    let _cb = b.close();                          // two handles, two obligations
    assert n == 2usize;
    assert buf[0] == 65u8;
}

test create_private_refuses_an_existing_path (mut sim: Sim) {
    let f = sim.fs.create_private("id.key").unwrap();
    let _c = f.close();
    match sim.fs.create_private("id.key") {
        Ok(f2) => { let _c2 = f2.close(); assert false; }
        Err((e, _)) => { assert e == FsErr::Exists; }
    }
}

test is_private_answers_how_the_file_was_created (mut sim: Sim) {
    let f = sim.fs.create_private("vault.key").unwrap();
    let _c = f.close();
    match sim.fs.is_private("vault.key") { Ok(p) => { assert p; } Err(_) => { assert false; } }
    let g = sim.fs.create("notes.txt").unwrap();
    let _d = g.close();
    match sim.fs.is_private("notes.txt") { Ok(p) => { assert !p; } Err(_) => { assert false; } }
}

Locks

A lock lets cooperating processes take turns with a file, for example so that two writers never interleave their updates. File locks on Unix are advisory: they only stop other programs that also ask for the lock, and a program that never asks can still read and write. A lock can be shared (many readers at once) or exclusive (one holder). A File has two families of advisory locks. Whole-file locks are try_lock_shared, try_lock_exclusive and unlock (implemented with flock). Range locks are try_lock_range_shared, try_lock_range_exclusive and unlock_range (implemented with open-file-description fcntl locks); a range length of 0 means "to the end of the file". Each try_ call answers Ok(true) if it got the lock and Ok(false) if not.

Do not mix the two families on one file. Whether they exclude each other depends on the host: a range lock taken while another handle holds the whole file is granted on Linux and refused on macOS. A program that uses a whole-file lock in one place and a range lock in another has mutual exclusion on one system and none on the other, and nothing reports it. The simulator treats them as independent, as Linux does, so a test run on macOS tells you the opposite of what a real run there does. Pick one family per file.

hint_evict(off, len) asks the platform to drop cached pages for a range and returns nothing, because a hint that reported success would be a guarantee. Linux drops the pages; macOS does nothing. If a read must reach the storage for correctness, open the file for direct I/O (open_direct_read, open_direct_rw) instead. Direct I/O bypasses the page cache entirely, at the price of strict alignment rules for buffers and offsets.

Deterministic crash testing

Under kola test, sim.fs is SimFs: the same File, Tmp and Sealed surface, held in memory, plus a few test-only calls. arm_crash() and crash() stop the simulated process at a durability boundary and let the test inspect what survived:

use std::fs;

test install_survives_crash (mut sim: Sim) {
    region r {
        let old = alloc in r [79u8, 76u8, 68u8];              // "OLD"
        let new = alloc in r [78u8, 69u8, 87u8, 33u8];        // "NEW!"
        let _w = fs::write_sync(&sim.fs, "cfg", old);
        sim.fs.arm_crash();
        match fs::install_bytes(&sim.fs, "", "cfg", new) { Ok(_) => {} Err(_) => {} }
        sim.fs.crash();
        let out: &r mut Vec[u8] = alloc in r mut Vec::new();
        match fs::read_all(&sim.fs, "cfg", out) {
            Ok(n) => { assert n == 3usize || n == 4usize; }   // all old or all new, never a mix
            Err(_) => { assert false; }                       // never absent
        }
    }
}

The assertion accepts 3 bytes (the whole old file) or 4 (the whole new file), and nothing else. That is exactly the crash-safety claim of install. The crash point comes from the test's seed, so kola test --sim --seed N repeats one crash exactly, and running many seeds covers the schedule. sim.fs.sim_capacity(bytes) gives the simulated disk a size: a write that would exceed it answers FsErr::NoSpace and changes nothing.

The same source runs through the interpreter (kola run), through the C backend on real files (kola run --native, kola build), and against the simulator (kola test).

The own backend does not lower extended attributes yet; a program that uses them builds through C. Direct I/O exists on Linux, macOS and Windows x64, with host-specific alignment rules described in STD-CARD.