Capabilities and authority

Oska has no ambient authority. Reading a file, opening a connection, reading the clock, and generating a random number are all effects, and code can perform one only if it holds the capability for it. A capability is an unforgeable value. Holding it is the only way to perform its effect, and a function's capability parameters are its effects. Oska has no separate effect annotations (ADR 0002).

The built-in capabilities

CapabilityGrantsRuns in this release
Stdin, Stdout, StderrThe standard streamsYes
FsThe filesystem, but only to attenuate: it reads and writes nothing itself. Attenuates to FsRead, FsWrite, a directory capability (Dir, DirRead, DirWrite), or a file capability (FileRead, FileWrite).Yes
EnvEnvironment variables, which can hold secretsYes
ClockThe time: now_millis()Yes
RandomPseudo-random numbers: next_int()Yes
NetThe network. Attenuates to NetConnect or NetListen.No: E0370
SchedChannels and selectNo: E0370
SpawnStarting OS processesNo: E0370

Args, the program's command-line arguments, is not a capability. It is a program input: plain data that grants no authority.

main is the root of authority

main is the root of authority. The runtime grants main exactly the capabilities its signature declares, and every other capability in the program comes from those. To learn what a program can do, read one signature.

fn main(io: Stdout, clock: Clock) -> Result<(), IoError> {
    let now = clock.now_millis()
    io.println("milliseconds since the epoch: {now}")?
    Ok(())
}
fn main: Stdout (io), Clock (clock)

main may take only built-in capabilities and program inputs (E0211 otherwise), and it must return () or Result<(), E> where E implements Display (E0212).

Passing authority down

A function can reach only the capabilities its parameters supply. That is its authority, and oskac check prints it for every function and method. A function with no capability-bearing parameter is a pure function: it cannot perform effects and it is deterministic.

fn word_count(docs: DirRead, path: Path) -> Result<Int, IoError> {
    let text = docs.read_to_string(path)?
    Ok(text.split(" ").len())
}

fn double(n: Int) -> Int {
    n * 2
}

fn main(io: Stdout, fs: Fs) -> Result<(), IoError> {
    let docs = fs.restrict_to("notes").read_only()
    let words = word_count(docs, Path.new("today.txt"))?
    io.println("{words}")?
    Ok(())
}
fn word_count: DirRead (docs)
fn double: pure
fn main: Stdout (io), Fs (fs)
  Fs -> Dir: restrict_to "notes" (bytes 250..273)
  Dir -> DirRead: read_only (bytes 250..285)

Reading the report: word_count can only read beneath one directory, double can do nothing, and main can write to standard output and attenuate the filesystem. The indented lines under main are attenuations: they narrow Fs to a Dir for notes, then to a DirRead. Every attenuation is listed under the function it is in. Reviewing a change means reading the report, not the bodies.

For a larger example, this site is built by an Oska program, site/build.osk. Its authority report is printed by oskac check on every build and published as it was printed.

Attenuation

You cannot forge a capability. You get one by being passed it, or by attenuation: producing a narrower capability from one you hold. Attenuation never widens authority, and there is no way back from a narrow capability to a wider one.

Fs, FsRead, and FsWrite only attenuate. They have no read_to_string, list_dir, write, or create_dir, so every read and write goes through a directory or file capability whose root the code names. Calling one of those methods on them is E0273, which shows the shape of the fix:

fn load(fs: FsRead, path: Path) -> Result<Untrusted<String>, IoError> {
    fs.read_to_string(path)
}

Four patterns

A directory. Narrow to the directory the code needs, and pass the directory capability down:

fn save(fs: DirWrite, text: String) -> Result<(), IoError> {
    fs.write(Path.new("note.txt"), text)
}

fn main(io: Stdout, fs: Fs) -> Result<(), IoError> {
    let notes = fs.restrict_to("notes")
    save(notes.write_only(), "remember the milk")?
    io.println("saved")?
    Ok(())
}
fn save: DirWrite (fs)
fn main: Stdout (io), Fs (fs)
  Fs -> Dir: restrict_to "notes" (bytes 174..197)
  Dir -> DirWrite: write_only (bytes 207..225)

The report shows main restricting the filesystem to notes, so a reviewer sees where the program can reach without reading its code. A root that is a string literal or a module constant is printed; any other root, such as one built from a variable, is printed as computed at runtime. read_only and write_only take no root.

A subdirectory from untrusted input. Narrowing an existing directory capability takes a Path, which may come from untrusted text through Path.from, because the result stays beneath the same root. Per-user confinement needs no trust assertion:

fn avatar(uploads: DirRead, user: Untrusted<String>) -> Result<Untrusted<String>, IoError> {
    uploads.restrict_to(Path.from(user)).read_to_string(Path.new("avatar.txt"))
}

A single file. When a function needs one file, give it a file capability. Its read_to_string() and write(text) take no path, so the signature says exactly which file it can touch:

fn show(readme: FileRead, io: Stdout) -> Result<(), IoError> {
    io.println(readme.read_to_string()?)
}

fn main(io: Stdout, fs: FsRead) -> Result<(), IoError> {
    show(fs.restrict_to_file("README.md"), io)
}

A file capability reads or writes, never both, and it cannot narrow further.

The whole filesystem. Code that really needs to reach anywhere, such as a file viewer, writes restrict_to("/"). Paths are then relative to /, and the authority report shows the root, so a reviewer cannot miss it:

fn view(everything: DirRead, path: Path) -> Result<Untrusted<String>, IoError> {
    everything.read_to_string(path)
}

fn main(io: Stdout, fs: FsRead) -> Result<(), IoError> {
    let text = view(fs.restrict_to("/"), Path.new("etc/hostname"))?
    io.println(text)
}
fn view: DirRead (everything)
fn main: Stdout (io), FsRead (fs)
  FsRead -> DirRead: restrict_to "/" (bytes 197..216)

What the runtime refuses

A directory or file capability resolves every path against its root and refuses an absolute or empty path, one that climbs out with .. (a/../b, which stays inside, still works), and any symlink beneath the root, whether it points outside, inside, or nowhere. On Linux and macOS it also refuses to read or write an existing file with more than one hard link, since a hard link planted beneath the root could be a file outside it; a write that creates a new file is unaffected. A refusal is an Err(IoError), not a panic. A root that cannot be opened, because it is missing, a symlink, or the wrong kind, gives a capability whose every operation returns an IoError naming the root, so create a directory before you narrow to it.

On Linux and macOS the capability holds an OS handle for its root, and each path is opened one component at a time beneath it without following a symlink. There is no separate check to race against: a process that swaps a directory for a symlink while the program runs cannot steer an open outside the root. On Windows the runtime checks each component and re-checks what it opened. That narrows the race but does not close it, so on Windows do not point a program at a directory that another process can write to. See the runtime notes.

The in-memory filesystem in a test confines the same way:

test "a directory capability refuses to climb out" {
    let docs = t.fs.restrict_to("safe").read_only()
    let outcome = docs.read_to_string(Path.new("../secret.txt"))
    t.assert(!outcome.is_ok())
}

Bundling capabilities: cap struct

A type that holds a capability in its own fields must be declared cap. Leaving it out is E0210, and the diagnostic carries a fix that inserts cap.

struct Logger { out: Stdout }
cap struct Logger { out: Stdout }

impl Logger {
    fn log(self, message: String) -> Result<(), IoError> {
        self.out.println(message)
    }
}

fn main(io: Stdout) -> Result<(), IoError> {
    let logger = Logger(out: io)
    logger.log("started")?
    Ok(())
}

A method's receiver counts towards its authority, so a method on a cap struct is never pure. A generic container is capability-bearing when it holds a capability, so List<Stdout> needs no cap. A capability-bearing type is any type that is a capability or contains one, directly or through type arguments.

Closures: fn and cap fn

A function type fn(T) -> U accepts only pure closures. A closure that captures a capability has the type cap fn, and passing it where a plain fn is expected is E0270.

fn apply(f: fn(Int) -> Int, x: Int) -> Int {
    f(x)
}

fn noisy(out: Stdout) -> Int {
    apply(|n| {
        let _ = out.println("called")
        n + 1
    }, 1)
}

To accept such a closure, declare the parameter cap fn(Int) -> Int:

fn apply(f: cap fn(Int) -> Int, x: Int) -> Int {
    f(x)
}

fn noisy(out: Stdout) -> Int {
    apply(|n| {
        let _ = out.println("called")
        n + 1
    }, 1)
}
fn apply: cap fn (f)
fn noisy: Stdout (out)

A plain fn may always be used where a cap fn is expected. When a plain fn and a cap fn meet in one list, in the branches of an if or match, or as arguments of a generic for one type parameter, the result is a cap fn: [pure_fn, capturing_closure] is a List<cap fn() -> Int>. Writing List<fn() -> Int> as the type still rejects the capturing closure. A cap fn parameter appears in the authority report as cap fn (name) with no capabilities, because a closure's capabilities are not written in its type, but it still makes the function impure.

You cannot forge one

Constructing a built-in capability yourself is E0271, and reading its private fields is E0272.

fn main() {
    let io = Stdout(handle: 1)
}

What the runtime refuses

A program that declares Net, NetConnect, NetListen, Spawn, or Sched in main passes oskac check but is refused by oskac run with E0370 and exit code 2, before anything runs.

fn main(net: Net) {
}
main.osk: error[E0370]: `oskac run` cannot grant `Net` yet: the capability has no runtime (bytes 18..21)

Two exceptions

Two things are deliberately not capabilities: dbg(expr), which writes to standard error in debug builds and tests and is a compile error under --release, and panics, which stop the program but have no effect on the outside world and cannot be caught. See debugging.