Capabilities in Depth
Every chapter so far has touched on capabilities. This one puts the pieces together: where capabilities come from, how a program grants them, how code narrows them before passing them on, how they stay visible in every signature, what the compiler can tell you from them, and what the operating system adds underneath.
The idea, once more
Most languages let any line of code do anything the process can: open any file, connect anywhere, run any program. Whether a function does those things is something you find out by reading it, and everything it calls.
Tessera turns that around. Code can only affect the outside world through capabilities, values that carry authority. There are no global functions for I/O; using a name like print or open is an error that tells you which capability to use instead. And the rules for capabilities are few:
- Programs can't create capabilities. They all come from
root, which the runtime gives tomain. - Capabilities are passed as arguments. A function can use a capability only if it was handed one.
- Narrowing only narrows. You can make a capability that allows less than the one you hold, never more.
- Capabilities stay visible. Every type that holds one says so, and the compiler follows them through every struct, enum and list. They can't be hidden in
dynvalues or in global variables (there are none).
Together, these make a function's signature an honest description of what it can do, all the way down its call tree.
What root holds
root has one field per kind of capability:
| Field | Type | Lets code |
|---|---|---|
root.stdout | Stdout | |
root.stderr | Stderr | print to standard error |
root.stdin | Stdin | read input |
root.clock | Clock | read the time, sleep, time out |
root.rand | Rand | make random numbers |
root.env | Env | read environment variables |
root.fs | Fs | read and write files under one directory |
root.net | Net | connect to hosts, make HTTP requests |
root.listen | Listen | accept connections |
root.exec | Exec | run programs |
root.foreign | Foreign | call C functions (chapter 15) |
The first five are always granted. The rest are granted when the program starts, with --grant flags or the [grants] table of tessera.toml:
$ tessera run app.tsr --grant fs=data:ro --grant net=api.example.com --grant env=HOME
[grants]
fs = "data:ro"
net = ["api.example.com"]
env = ["HOME"]
fs takes one directory, :ro for read-only. net and listen take hosts, with an optional port. env and exec take names. * grants everything of a kind. A binary from tessera build takes the same flags.
An ungranted capability still exists, so code that receives it type-checks and runs. It just refuses: every operation returns Err(Denied(...)), and Env shows no variables.
fn main(root: Root) {
let out = root.stdout
out.print("fs read-only: {root.fs.is_read_only()}")
out.print("HOME: {root.env.get("HOME") ?? "(not granted)"}")
out.print("LANG: {root.env.get("LANG") ?? "(not granted)"}")
match root.fs.read("index.html") {
Ok(text) => out.print("index.html: {text.len()} bytes"),
Err(e) => out.print("index.html: {e}"),
}
match root.fs.write("log.txt", "visited") {
Ok(_) => out.print("wrote log.txt"),
Err(e) => out.print("log.txt: {e}"),
}
}$ LANG=en_US.UTF-8 tessera run main.tsr
fs read-only: true
HOME: (not granted)
LANG: (not granted)
index.html: Denied("fs: not granted (run with --grant fs=DIR)")
log.txt: Denied("fs: not granted (run with --grant fs=DIR)")
$ LANG=en_US.UTF-8 tessera run main.tsr --grant fs=site:ro --grant env=LANG
fs read-only: true
HOME: (not granted)
LANG: en_US.UTF-8
index.html: 12 bytes
log.txt: Denied("fs: this capability is read-only")
$ LANG=en_US.UTF-8 tessera run main.tsr --grant fs=site --grant env=LANG
fs read-only: false
HOME: (not granted)
LANG: en_US.UTF-8
index.html: 12 bytes
wrote log.txt
Notice that HOME is never visible, because it was never granted, even though the program's process has it in its environment.
Narrowing
A program usually holds more authority than any one part of it needs. Before passing a capability on, narrow it:
| Capability | Narrower version |
|---|---|
Fs | fs.under("dir"): only that subdirectory; fs.read_only(): no writing |
Net | net.only("host") or net.only("host:port"): only that host |
Exec | exec.only("program"): only that program |
Foreign | foreign.only("lib"): only that C library |
A narrowed capability is a new value; the original is unchanged. Narrowing can only take away. Asking for a host you don't hold gives a Net that reaches nothing.
fn main(root: Root) {
let out = root.stdout
// the whole site directory is granted; the renderer gets only public/,
// and only for reading
let public = root.fs.under("public").read_only()
out.print(render(public, "about.html"))
out.print(render(public, "../secrets.txt"))
out.print(render(public, "/etc/passwd"))
match public.write("about.html", "defaced") {
Ok(_) => out.print("wrote about.html"),
Err(e) => out.print("write: {e}"),
}
// the full grant can still read it
out.print("main reads secrets.txt: {root.fs.read("secrets.txt") ?? "?"}")
}
fn render(fs: Fs, page: Str) -> Str {
match fs.read(page) {
Ok(text) => "{page}: {text.trim()}",
Err(e) => "{page}: {e}",
}
}$ tessera run main.tsr --grant fs=site
about.html: <p>About us</p>
../secrets.txt: Denied("fs: path escapes the granted directory")
/etc/passwd: Denied("fs: absolute paths are not allowed; paths are relative to the granted directory")
write: Denied("fs: this capability is read-only")
main reads secrets.txt: the-admin-password
render receives public, which is limited to site/public and read-only. Whatever page name reaches it, from a URL or anywhere else, it can't climb out with .., can't use an absolute path, and can't write. main, which holds the whole grant, still can. Symlinks pointing outside the directory are refused the same way.
The network works the same way:
fn main(root: Root) {
let out = root.stdout
// a client for one API: it gets a Net that reaches one host
let api = root.net.only("api.example.com")
for url in ["http://tracker.example.net/collect", "file:///etc/passwd"] {
match api.get(url) {
Ok(r) => out.print("{url}: {r.status}"),
Err(e) => out.print("{url}: {e}"),
}
}
// narrowing to a host the grant does not hold gives an empty Net
match root.net.only("other.example.org").get("http://other.example.org/") {
Ok(r) => out.print("other: {r.status}"),
Err(e) => out.print("other: {e}"),
}
}$ tessera run main.tsr --grant net=api.example.com
http://tracker.example.net/collect: Denied("net: tracker.example.net:80 is not granted")
file:///etc/passwd: Failed("scheme `file` is not allowed, only http and https")
other: Denied("net: other.example.org:80 is not granted")
api can reach api.example.com and nothing else, whatever URL it's given. Only http and https are allowed, and redirects are not followed, so a server can't bounce a request to a host the capability doesn't cover.
Exec is narrowed by program name. Arguments go as a list, never through a shell, and the child sees only the environment variables that were granted:
fn main(root: Root) -> Result[(), IoError] {
let echo = root.exec.only("echo")
let r = echo.run("echo", ["hello", "from", "a child process"])?
root.stdout.write(r.stdout)
match echo.run("ls", ["/"]) {
Ok(r) => root.stdout.print("ls exited with {r.code}"),
Err(e) => root.stdout.print("ls: {e}"),
}
Ok(())
}$ tessera run main.tsr --grant exec=echo --grant exec=ls
hello from a child process
ls: Denied("exec: `ls` is not granted")
Be careful what you grant here: granting a program grants everything that program can do. --grant exec=sh is as good as every other grant together.
Capabilities in structs
Capabilities can be passed, returned, captured by closures, and stored in struct and enum fields. A logger that holds the Stdout it writes to is one value instead of two:
// A logger holds the Stdout it writes to, so code that logs takes a
// Logger instead of a Stdout and a prefix.
struct Logger {
prefix: Str,
out: Stdout,
}
fn log(self: Logger, msg: Str) {
self.out.print("{self.prefix} {msg}")
}
fn total(prices: List[Int]) -> Int {
prices.sum()
}
fn checkout(log: Logger, prices: List[Int]) {
log.log("total: {total(prices)}")
}
fn main(root: Root) {
let log = Logger { prefix: "[shop]", out: root.stdout }
checkout(log, [3, 4, 5])
}$ tessera run main.tsr
[shop] total: 12
$ tessera caps main.tsr
Logger::log Stdout (via Logger.out)
total pure
checkout Stdout (via Logger.out)
main Root
A struct that holds a capability holds authority, and so does anything that holds that struct: an enum with it in a payload, a list of them, a struct with one in a field. The compiler works this out for every type, so authority never disappears into a struct. tessera caps shows where it comes from: checkout takes a Logger, and the Stdout comes through its out field. A dependency can't get around tessera.toml this way either: its functions are checked against their capabilities wherever those sit in their parameters.
A few things don't work with values that hold authority, because each would let authority out of sight:
- They can't become
dyn. Adynvalue says which interface it has, not which type is underneath, so it could carry a capability that no signature shows (chapter 10). Use a type parameter, as in[T: Named], where the type stays visible. - They can't be map keys, and
==and!=don't compare them: two capabilities with the same grants aren't the same value, so the answer would mean nothing. - Functions that take them are never pure. That matters for
tessera capsand for the compiler, below.
interface Named {
fn name(self) -> Str
}
struct Logger {
prefix: Str,
out: Stdout,
}
fn name(self: Logger) -> Str {
self.prefix
}
fn main(root: Root) {
let things: List[dyn Named] = [Logger { prefix: "[shop]", out: root.stdout }]
root.stdout.print(things.len().to_str())
}$ tessera check main.tsr
error[E0112]: `Logger` holds capability `Stdout` through `Logger.out`, so it cannot become `dyn Named`
--> main.tsr:15:36
|
15 | let things: List[dyn Named] = [Logger { prefix: "[shop]", out: root.stdout }]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= note: authority must stay visible in types; pass it with a type parameter, as in `[T: Named]`
1 error
Function fields and channels still can't be stored in structs. And there are no global variables, so every capability a function can reach arrived through its parameters.
Asking the compiler: tessera caps
Since every capability arrives as a parameter, the compiler knows what every function can do. tessera caps prints it:
fn main(root: Root) -> Result[(), IoError] {
let config = load_config(root.fs.read_only())?
let names = fetch_names(root.net.only("api.example.com"), config)?
report(root.stdout, names)
Ok(())
}
fn load_config(fs: Fs) -> Result[Str, IoError] {
fs.read("org.txt")
}
fn fetch_names(net: Net, org: Str) -> Result[List[Str], IoError] {
let resp = net.get("https://api.example.com/orgs/{org.trim()}/repos")?
Ok(parse_names(resp.body))
}
// One name per line of the body.
fn parse_names(body: Str) -> List[Str] {
body.lines().map(|l| l.trim()).filter(|l| l != "")
}
fn report(out: Stdout, names: List[Str]) {
for n in names.sort() {
out.print(n)
}
}
fn retry[T](times: Int, f: fn() -> Result[T, IoError]) -> Result[T, IoError] {
let mut last = f()
for _ in 1..<times {
if last.is_ok() {
return last
}
last = f()
}
last
}$ tessera caps main.tsr
main Root
load_config Fs
fetch_names Net
parse_names pure
report Stdout
retry pure itself, runs callbacks
load_config can read files, fetch_names can use the network, report can print. parse_names is pure: no parameter of it holds a capability, so it can only compute, and that holds for everything it calls, since it has nothing to pass them. retry is pure itself but calls a function it was given, so what it can do depends on that function; tessera caps says so. tessera caps --json gives the same in a form tools can read, for example to fail a build when a module that should be pure isn't.
This is also what lets tessera.toml limit dependencies (chapter 7): the compiler applies the same reasoning to every function a dependency exports.
Purity is useful to the compiler too. A pure call with the same arguments always gives the same result, so the native backend can reuse a result instead of calling again, for instance a pure call inside a loop whose arguments don't change. Nothing your program can observe changes: same results, same output, same panics at the same place.
Files that are part of the program
A program often ships with files of its own: templates, pages, a logo. Reading them at run time would need an Fs. Instead, embed, embed_bytes and embed_dir read them when the program is compiled, and make them part of it:
// The files under public/ are part of the program: read when it is
// compiled, so it needs no Fs to serve them.
fn main(root: Root) {
let site = embed_dir("public")
for (path, data) in site.entries() {
root.stdout.print("{path}: {data.len()} bytes")
}
let notice = embed("notice.txt")
root.stdout.print(notice.trim())
}$ tessera run main.tsr
css/site.css: 20 bytes
index.html: 17 bytes
Built with Tessera.
$ tessera caps main.tsr
main Root
embeds public/css/site.css (20 bytes)
embeds public/index.html (17 bytes)
embeds notice.txt (20 bytes)
The path is relative to the source file and must stay inside its package, so a dependency can't embed your private files into its code. tessera caps lists every embedded file.
Sometimes the files a program needs live next to it rather than inside it, such as a shared directory of templates in a larger repository. The program's own tessera.toml can name extra directories it may embed from:
[embed]
roots = ["../shared/templates"]
The paths are relative to tessera.toml and must be existing directories. Only the program's package can embed from them, and a dependency's tessera.toml can't declare roots at all, so the rule above still holds for every dependency. tessera caps lists the roots with the embedded files. The Tessera compiler written in Tessera works this way: it embeds the standard library and its runtime headers from the compiler's source tree, so the compiled program needs no access to those sources when it runs. The server for this book works this way: every page is embedded, so it runs with just two grants, one to listen and one to read PORT.
What the operating-system sandbox adds
Tessera's checks cover Tessera code. Two things are outside them: C code called through foreign (chapter 15), which runs with the full authority of the process, and any bug in the runtime itself. To cover those too, every program also runs in an operating-system sandbox built from its grants. It is on by default, in both backends.
// C code is outside Tessera's checks: with the foreign grant it could write
// any file the process can. The operating-system sandbox still stops it.
foreign "c" {
fn fopen(path: Str, mode: Str) -> Handle
fn fclose(f: Handle) -> Int32
}
fn main(root: Root) {
let f = fopen(root.foreign, "escape.txt", "w")
if f.addr == 0 {
root.stdout.print("C could not open escape.txt for writing")
} else {
let _ = fclose(root.foreign, f)
root.stdout.print("C wrote escape.txt")
}
}$ tessera run main.tsr --grant foreign=c
tessera: warning: --grant foreign=c is full access: C code can do anything this process can, past every other grant
C could not open escape.txt for writing
$ tessera run main.tsr --grant foreign=c --no-sandbox
tessera: warning: --grant foreign=c is full access: C code can do anything this process can, past every other grant
C wrote escape.txt
With the foreign grant, the C function fopen can try to open any path, and Tessera can't see what it does. But the program was given no Fs, so the kernel refuses the write. Only with --no-sandbox, which exists for debugging, does the file get written.
What the kernel enforces, from the grants:
| Allowed | |
|---|---|
| Writing files | under the Fs directory (unless read-only), and the terminal |
| Reading files | under the Fs directory, and system locations such as libraries, time zones and certificates; not home directories |
| Network | nothing, unless Net or Listen is granted |
| Starting programs | nothing, unless Exec is granted |
The sandbox is coarser than the capabilities: once Net is granted, the kernel allows every host, and Tessera's own checks decide which ones. And it can't protect the program from itself: C code with the foreign grant can still read and corrupt the program's own memory. The two layers together mean that Tessera code is limited precisely, and everything else in the process is limited to roughly what was granted.
On macOS the sandbox is a sandbox profile; on Linux it is Landlock and a seccomp filter. A system without either runs the program unsandboxed.
Designing with capabilities
A few habits follow naturally from all this:
- Decide authority in main.
mainis where grants become capabilities. Narrow them there, and pass each part of the program the least it needs. - Keep the core pure. Parsing, formatting, validation and business rules rarely need a capability. Pure functions are the easiest to test and to trust, and
tessera capsproves they are pure. - Take interfaces in code you want to test. A function that takes
[C: Ticker]instead ofClockcan be tested with a fake; one that takes a narrowedFscan be tested with a temporary directory. - Read signatures in review. A change that adds a
Netparameter to a function deep in the program is visible in the diff, and intessera caps.
Summary
All authority comes from root, granted with --grant or tessera.toml. Capabilities are passed as arguments or held in structs, narrowed with under, read_only, only, and never hidden in dyn values; the compiler follows them through every type, so signatures say what code can do and tessera caps reports it. Files can be embedded instead of read, from the package or from roots the program declares. Underneath, the operating-system sandbox limits everything Tessera can't see to the same grants.
Next: doing several things at once.