Files and handles
Provium gives you two layers for guest-side file I/O. Most tests use the high-level vm:read_file / vm:write_file / vm:stat calls; tests that need cursor control, partial reads, or POSIX semantics drop to vm:open_file and the File userdata.
High-level: read_file / write_file / stat #
These are one-shot operations. Each is a single round-trip to the agent.
vm:
local body = vm:
local meta = vm:
vm:read_file(path) #
Returns the entire file as a Lua string. Errors on agent-side read failure (ENOENT, EACCES, etc.).
local content = vm:
vm:write_file(path, data) #
Replaces the file's contents. Creates if absent. The mode bits follow agent defaults — use vm:open_file if you need explicit perm.
vm:
vm:push_file(host_path, guest_path, opts?) #
Read a file on the host and write its bytes to guest_path in the guest. Equivalent to reading the host file in Lua and passing the bytes to vm:write_file, but with one important extra: inside a fixture, the host file is folded into the fixture's cache key automatically, so rebuilding the host artifact (e.g. a binary you're testing) invalidates the snapshot.
vm:
Relative host_path is resolved against the fixture (or helper) file's directory, not the cwd at invocation time. Pass {auto_dep = false} to skip the auto-fold for a single call (e.g. a large test corpus you don't want included in the key):
vm:
The auto-fold relies on static scanning of the call site, so non-literal host paths (variables, concatenation) are NOT tracked. Use lab:depends_on_file with a literal string to declare them explicitly. See Fixtures and dependencies — External host-file deps for the full model.
Mode bits follow write_file's agent defaults — vm:run("chmod +x …") after the push if you need executable bits.
vm:stat(path) #
Returns a table:
local m = vm:
print -- 10
print -- POSIX mode bits
print -- "file", "directory", "symlink", …
The full field table (including the complete entry_type value list) is in the VM reference. Two fields carry the modification time: use mtime_ns for exact comparisons; use mtime (float seconds) when "around what o'clock" is enough.
perm is in the POSIX range. Lua 5.4 doesn't accept 0o… literals — use decimal or hex (0x180 for 0o600). 4095 is 0o7777, the upper bound.
vm:listdir(path) #
Returns an array of {name, entry_type} tables:
for _, e in ipairs
vm:mkdir(path, opts?) #
Create a directory:
vm:
vm:
vm: -- 0o700
vm:unlink(path) #
Remove a file or empty directory. Errors on non-empty directory (use vm:run("rm -rf …") for that).
vm:rename(from, to) #
Atomic rename within the guest filesystem.
Low-level: vm:open_file #
Returns a File userdata that you can read, write, seek, and close.
local h = vm:
h:
h:
local s = h:
h:
Mode table #
At least one of read, write, append must be true; an empty mode table errors at open time. The flags you'll combine most often are read / write, create (create if absent), truncate, and perm (POSIX mode for newly-created files):
-- Write-only, create, truncate, mode 0o600.
local h = vm:
The full mode-table reference (including append and exclusive / O_EXCL) is on vm:open_file.
Reading #
local h = vm:
local first = h: -- up to 64 bytes
local rest = h: -- drain to EOF
h:
h:read(n) returns up to n bytes; at EOF it returns the empty string "", never an error — so a read loop terminates on chunk == "". h:read_all() drains from the cursor to EOF in one call. See EOF semantics in the File reference.
Writing #
local h = vm:
local n = h:
-- n is 11 (bytes actually written, may be less than #data on partial write)
h:
Seek and tell #
local h = vm:
h: -- absolute offset 10
h: -- relative +5 from current
h: -- 1 byte before EOF
h: -- current offset
tell() reports the authoritative agent-side position, not a host-side cache — the File reference explains how.
Closing #
h:
h: -- second close is fine; idempotent
h: -- raises "file is closed"
The harness's resource walker auto-closes files at scope end via the _provium_close_test_scope hook, so you can usually omit explicit closes. Closing manually is good practice when the file's lifetime is bounded by a clear point in the test.
Raw fd #
local fd = h: -- u64 handle id
vm:
fd() returns 0 on a closed file, otherwise the handle's u64 value. Useful for vm:ioctl(fd, …) and vm:syscall(…) invocations that take a file descriptor.
Tailing files #
Two ways to tail:
vm:tail_file(path, opts?) #
Subscribe to bytes appended to a file. Returns a Tail.
local stream = vm:
vm::
local line = stream:
t:
opts.start controls the starting position — "end" (the default: only bytes appended after the call), "beginning" (replay from byte 0, then continue tailing), or a byte offset. Negative-offset and float handling are in the VM reference.
file:tail_stream() #
Open a tail rooted at the file handle's current cursor:
local h = vm:
h:
local stream = h:
-- stream now subscribes from current EOF onwards
Useful when you've already seek-d to a known position.
vm:fd_stream(fd_or_file) #
Open a tail against an existing file handle (by fd integer or by the File userdata directly):
local h = vm:
local stream = vm:
Common patterns #
Writing then reading back #
test
Asserting permission bits #
Lua 5.4 has no 0o… literal. Use decimal or hex:
test
Tailing a log while the test acts #
test
Listing then filtering #
test
Files inside a worker #
worker:open_file(path, mode) allocates the file under the worker's namespace. The returned File auto-registers with the test scope:
local w = vm:
local h = w:
h:
h:
Otherwise the API is identical.
Batch I/O for low latency #
For many small ops back-to-back, batch them in one round trip:
local results = vm:
Each entry in results is {ok=value} or {err=msg}. A failure on one op doesn't short-circuit the rest. See VM batch for the per-op return shape.
See also #
- VM reference —
read_file,write_file,stat,mkdir,listdir,unlink,rename,open_file,tail_file,fd_stream. - File handle reference — every method on the File userdata.
- Streams and tails — patterns for tail streams.