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.
Prerequisites
- [ ]
wasmtime20+ (wasmtime --version) — the::mapping syntax is recent - [ ] A WASI module that opens files — a
wasm32-wasip1build usingstd::fsorfopen - [ ] 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.
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.
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.
Related
- WASI target builds & runtimes — the capability model this flag implements.
- Running Wasm modules with the wasmtime CLI — the other flags you will pair it with.
- Compiling Rust to wasm32-wasip1 — producing a module that opens files at all.
- Security implications of Wasm in enterprise apps — reviewing a grant like this one.
← Back to WASI Target Builds & Runtimes