Granting Filesystem Access with WASI Preopens

This guide answers one question: how does a WASI module read and write files when it has no ambient access to the filesystem, and what exactly do you type to grant it the access it needs?

The short version is that the runtime opens directories on the module’s behalf before the program starts, and hands it the resulting handles. The module can then open paths within those directories and nowhere else. There is no way for the program to widen its own access at run time, which is what makes the model useful: the grant is entirely in the command that launched it.

Host path, guest path, and the boundary between them The --dir flag names a directory on the host and the path the module will see it as. Inside the module only the guest path exists; the host layout is invisible, and a path outside the mapping cannot be opened because no handle for it was ever created. host /home/dev/project/fixtures real directory, real permissions everything else on this machine remains invisible to the module guest /data the only path the program can name open("/data/input.bin") works open("/etc/passwd") does not --dir ./fixtures::/data The mapping is established before the program starts and cannot be changed by it — the grant lives entirely in the launch command.

Prerequisites

  • [ ] wasmtime 20+ (wasmtime --version) — the :: mapping syntax is recent
  • [ ] A WASI module that opens files — a wasm32-wasip1 build using std::fs or fopen
  • [ ] A directory of test data on the host you are willing to expose
  • [ ] Enough patience to check the guest path in your error messages, not the host one

Step-by-step procedure

1. Grant a directory

wasmtime run --dir ./fixtures::/data tool.wasm -- --input /data/sample.bin

The value before :: is the host directory; the value after is the path the module sees. The module’s argument uses the guest path, because that is the only namespace it has.

2. Use the same name on both sides when it is simpler

wasmtime run --dir . tool.wasm -- --input ./sample.bin

Passing a single path with no :: maps it to itself. This is convenient for a quick run, and slightly worse for reproducibility, because the module’s behaviour now depends on where you launched it.

3. Grant more than one directory

wasmtime run \
  --dir ./fixtures::/in \
  --dir ./out::/out \
  tool.wasm -- --input /in/sample.bin --output /out/result.bin

Separate input and output mappings are worth the extra flag: the program cannot overwrite its own inputs even if a path-handling bug tries to.

4. Confirm what the module can see

Most programs will tell you if you ask them to list their working directory. Failing that, a two-line probe module is the definitive answer:

fn main() {
    for entry in std::fs::read_dir("/data").expect("preopen missing") {
        println!("{}", entry.unwrap().path().display());
    }
}

5. Read the failure when a mapping is missing

wasmtime run tool.wasm -- --input /data/sample.bin
# Error: failed to find a pre-opened file descriptor through which "/data/sample.bin" could be opened

That message names the guest path it could not resolve, which is the fastest way to spot a mismatch between the mapping and the argument.

Expected output

With the mapping in place and the program reading the file, a run is unremarkable — which is the point:

$ wasmtime run --dir ./fixtures::/data tool.wasm -- --input /data/sample.bin
opened /data/sample.bin (4096 bytes)
checksum 0x8f31a2b4

$ wasmtime run --dir ./fixtures::/data tool.wasm -- --input /data/missing.bin
Error: No such file or directory (os error 44)

The second case is worth noticing: once a preopen exists, a genuinely missing file produces an ordinary not-found error rather than a capability error. The two failures look similar in casual reading and mean completely different things — one is a launch-command problem, the other is a data problem.

Two errors that look alike and are not A capability error means no preopen covers the path, and the fix is in the launch command. A not-found error means the preopen exists and the file inside it does not, and the fix is in the data or the path the program constructed. "could not be opened" / capabilities no preopen covers this path the module never got a handle fix the --dir flag the file may well exist "No such file or directory" the preopen exists and worked the file inside it is genuinely absent fix the data or the constructed path the sandbox is working correctly Reading which of the two you have saves the ten minutes usually spent changing the wrong thing.

Gotchas

The program builds the path from the host layout. Code that joins a configuration value like /home/dev/project/fixtures will fail even with a correct mapping, because that path does not exist in the guest namespace. Pass the guest path as an argument rather than deriving it.

A traversal escapes the preopen. ../ above the mapped directory is refused by the runtime, and correctly so. If a program legitimately needs a sibling directory, map that directory too — do not try to defeat the check.

Write access fails on a directory you mapped. Some runtimes support read-only mappings and default to them for certain flag forms; check the runtime’s documentation for its exact syntax, and map the output directory separately so the intent is explicit.

Symlinks behave differently from native. A symlink pointing outside the preopen resolves to something the module cannot reach, so following it fails. This is the sandbox working as designed; copy the target inside the mapping instead.

The current working directory is not what you expect. WASI has no chdir, and relative paths resolve against a preopen rather than a process-wide directory. Prefer absolute guest paths in arguments; they are unambiguous.

Two mappings instead of one Mapping a single directory for both reading and writing lets a path-handling bug overwrite the program's own inputs. Mapping the input read-only and the output separately makes that failure impossible rather than merely unlikely. one mapping for everything --dir .::/work inputs and outputs share a namespace a bad output path can clobber the input separate mappings --dir ./in::/in --dir ./out::/out the two namespaces cannot collide the failure mode is removed, not mitigated The extra flag is the cheapest safety property in the whole pipeline, and it documents the program's data flow in the command line.

Performance note

A preopen costs nothing at run time — it is a directory handle established once before the program starts, and subsequent opens resolve against it with the same cost as any other path resolution. The performance question with WASI file access is elsewhere: each read and write crosses into the runtime, so a program doing many small reads pays a per-call cost it would not pay natively.

The fix is the same as it would be natively, and more important here: buffer. Reading a file in 64 KiB chunks rather than 512-byte ones reduces the number of crossings by two orders of magnitude, and on a large input that difference is easily visible. Rust’s BufReader and C’s setvbuf both do the right thing; the mistake is only in code that reads byte by byte on the assumption that the operating system will absorb it.

Designing a program around preopens

Code written for a conventional filesystem tends to assume it can discover where things are: read a configuration file from a well-known location, walk upward looking for a project root, expand a home-directory path. None of that works under WASI, and the adaptation is small but worth making deliberately rather than by trial and error.

The pattern that ports cleanly is to accept every path as an argument and never derive one. Instead of searching for config.toml, take --config /work/config.toml; instead of writing next to the input, take --output /out/result.bin. The caller then decides both the paths and the grants, and the two stay consistent because they are set in the same command.

wasmtime run \
  --dir ./fixtures::/in \
  --dir ./build::/out \
  tool.wasm -- --config /in/config.toml --input /in/data.bin --output /out/result.bin

Where a program genuinely needs to enumerate rather than be told, list the preopened directory directly and treat its contents as the entire world:

let entries: Vec<_> = std::fs::read_dir("/in")?
    .filter_map(Result::ok)
    .filter(|e| e.path().extension().is_some_and(|x| x == "bin"))
    .collect();

That reads almost identically to the native version; the difference is that /in is a grant rather than a location, so the program cannot wander outside it even by accident.

What this buys you in review

The security argument for this model is easier to make than most, because the grant is visible in one place. A reviewer looking at a deployment does not have to reason about what the program might do with paths — they read the command line and know exactly which directories are reachable. A program with one --dir mapping to a scratch directory has a blast radius of that directory, whatever bugs it contains.

That property composes well with containers rather than duplicating them. A container limits what the process can see; preopens limit what the module can see inside that. The two together mean a compromised dependency inside the module cannot reach the rest of the container, which is a meaningfully stronger position than either alone — and it costs a flag rather than a security review.

Frequently Asked Questions

Can a module list the directories it was given? Yes — WASI exposes the preopen table, and the standard library uses it to resolve paths. A program can therefore report what it can reach, which makes a --list-preopens flag a useful diagnostic to build into any tool that takes file paths.

Is there a way to grant a single file rather than a directory? Not in preview 1; the unit of capability is a directory. Map a directory containing only the file you want to expose. Preview 2’s resource-based interfaces are more granular — see preview 1 vs preview 2.

Does this apply in the browser? There is no filesystem to preopen in a browser, but the model is the same one the browser sandbox uses: the module gets exactly the capabilities the host passes it, and no path to widen them.

Do preopens work the same way in every runtime? The model does — capabilities are handed over before the program starts and cannot be widened — but the command-line syntax differs between wasmtime, wasmer and embeddings. Check the runtime’s own documentation for the exact flag rather than assuming; the concept transfers, the spelling does not.

← Back to WASI Target Builds & Runtimes