@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
807 lines • 34 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { ConcurrencyLimiter } from "../concurrency/limiter.js";
import { classify } from "../errors/index.js";
import { ebadf, einval, eloop, exdev, isMissingOp, isMissingPath } from "../errors/fs.js";
import { atimeMs, contentSize, DIR_MODE, deviceRdev, isCharDevice, isDir, isLink, mtimeMs, posixMode, } from "../utils/stat_view.js";
import { ABSENT_PATH, LISTING_ENTRY_CONCURRENCY } from "./constants.js";
import { normDir, rstripSlash } from "../utils/slash.js";
import { CycleError, resolvePath } from "../utils/path.js";
import { READ_CHUNK } from "./handles/constants.js";
import { MountCore } from "../mount/core.js";
import { PrefixResolver } from "./resolver.js";
import { CallerResolver, workspaceBridge } from "./binding.js";
import { linkView } from "../commands/builtin/utils/links.js";
import { awaitsAs, callsAs, currentCaller, inLanding, runLanding, } from "../context/session_context.js";
import { PathSpec } from "../types.js";
import { concat } from "../utils/bytes.js";
import { entryName } from "../utils/remnants.js";
/**
* One listing row before any stat: its slash mark and its link mark.
*
* The link mark is compared by final segment: backends disagree on entry
* shape (bare names, trailing-slash names, full paths) and the name is the
* part they agree on, the normalization mergeReaddir uses.
*/
function listed(raw, links) {
// Backends that mark directories with a trailing slash skip the
// stat; unmarked entries (e.g. RAM) need one to learn dir-ness.
const mark = links.has(entryName(raw)) ? { isLink: true } : {};
return { path: raw, size: 0, isDir: raw.endsWith('/'), ...mark };
}
/**
* Whether a listing row is one the file adapter did not classify.
*
* Its size-0 non-directory shape is a placeholder, not an answer: a
* guest that needs the entry's kind, size or stamp must ask the mount
* with a stat of its own, which also reports whatever failed. A
* slash-marked directory and a link carry no mode either, but their
* kind is known.
*/
export function isUnclassified(entry) {
return entry.mode === undefined && !entry.isDir && entry.isLink !== true;
}
/**
* Translate one mirage stat row into the guest-facing struct.
*
* The projection lives in the file adapter rather than in each surface so both
* languages build one struct in one tier: preview1 reads the type bits
* out of `mode` and drops the rest, monty fills a `StatResult`,
* Emscripten fills an `FSAttr`. Mirrors python's `stat_row`.
*/
function statRow(st) {
const mtime = mtimeMs(st);
const atime = atimeMs(st);
return {
size: contentSize(st),
isDir: isDir(st),
mode: posixMode(st),
...(mtime !== null ? { mtimeMs: mtime } : {}),
...(isLink(st) ? { isLink: true } : {}),
...(isCharDevice(st) ? { rdev: deviceRdev(st) } : {}),
...(atime !== null ? { atimeMs: atime } : {}),
...(typeof st.uid === 'number' ? { uid: st.uid } : {}),
...(typeof st.gid === 'number' ? { gid: st.gid } : {}),
source: st,
};
}
/**
* One file a guest holds open: the run's core handle on it, the position
* its reads and writes move, and what its open asked for. Nothing the guest
* reads or writes stays here: the core holds it (`RuntimeFiles.open`), so
* every entry point judges, lands and keeps an open file one way. A stream
* and a descriptor over one open share this, and with it the position, as
* POSIX shares an open file description. Mirrors Python's `Descriptor`.
*/
export class Descriptor {
files;
fd;
mode;
opened;
directory;
inode;
pos = 0;
/**
* @param files the adapter whose core the handle is on.
* @param fd the core's handle.
* @param mode what the open asked for.
* @param opened the path it was opened at, the name a file object reports.
* @param directory it names a directory, which it may not read.
* @param inode where the links on `opened` led as it opened, which names
* its inode however the file is renamed or the link changed since.
*/
constructor(files, fd, mode, opened, directory = false, inode = opened) {
this.files = files;
this.fd = fd;
this.mode = mode;
this.opened = opened;
this.directory = directory;
this.inode = inode;
}
/** The path its file has now; a rename moves it. */
get path() {
return this.files.pathOf(this.fd);
}
/** The file's length as the handle holds it, its writes counted. */
async size() {
return (await this.files.fstat(this.fd)).size;
}
/** Read at an explicit offset without moving the position. */
async pread(offset, size) {
if (size <= 0)
return new Uint8Array();
return this.files.pread(this.fd, offset, size);
}
/**
* Read from the position, advancing it by what was read; a negative or
* null size reads to the end, a chunk at a time.
*/
async read(size = null) {
if (size !== null && size >= 0) {
const chunk = await this.pread(this.pos, size);
this.pos += chunk.byteLength;
return chunk;
}
const parts = [];
for (;;) {
const chunk = await this.pread(this.pos, READ_CHUNK);
if (chunk.byteLength === 0)
return concat(parts);
parts.push(chunk);
this.pos += chunk.byteLength;
}
}
/** Write at an explicit offset without moving the position; an appending handle writes at its end. */
async pwrite(offset, data) {
if (data.byteLength > 0)
await this.files.pwrite(this.fd, offset, data);
}
/**
* Write at the position, advancing it past what was written; an appending
* handle first moves the position to its end, as O_APPEND does, so a
* write that fails to find the end leaves nothing written.
*/
async write(data) {
if (this.mode.append && data.byteLength > 0)
this.pos = await this.size();
const at = this.pos;
this.pos += data.byteLength;
await this.pwrite(at, data);
}
/**
* Move the position, POSIX whence numbering; null when the whence is
* unknown or the target would be negative (the position is then
* untouched).
*/
async seek(offset, whence) {
const base = whence === 0 ? 0 : whence === 1 ? this.pos : whence === 2 ? await this.size() : null;
if (base === null || base + offset < 0)
return null;
this.pos = base + offset;
return this.pos;
}
/** Set the file's length: a shrink drops bytes, growth reads zeros. */
async truncate(size) {
await this.files.ftruncate(this.fd, size);
}
/** Land what it wrote, as fsync(2) does. */
async land() {
await this.files.fsync(this.fd);
}
/** Land what it wrote and let the core's handle go. */
async close() {
await this.files.close(this.fd);
}
}
/**
* The mount-facing op vocabulary a sandboxed runtime encodes into.
*
* One instruction set (read/write/append/stat/readdir/create/truncate/
* unlink/mkdir/rmdir/rename/symlink/readlink/setattr), one routing
* table, one place that knows an append may have to become a
* whole-file write. The last three reach the name plane rather than a
* backend, which is what lets a guest create a link or stamp a time on
* a mount whose store has neither. Encoders hold one of these; they
* never inherit it, because a monty encoder is the binding's own `os`
* callback and a quickjs encoder is a table of host functions.
*
* The surface is async, unlike Python's: a JS guest either suspends at
* the call (quickjs asyncify) or records the mutation and replays it
* after the run (pyodide), so nothing here has to block a worker
* thread the way the Python runtimes do.
*
* A file the guest opens is a handle on the run's `MountCore` (`open`,
* `pread`, `pwrite`, `fstat`, `ftruncate`, `fsync`, `close`), the
* open-file layer every entry point shares: it holds what the guest read,
* keeps what it wrote until a close or a sync, lands it in the order the
* writes were made, and keeps a file whose name went for the handles open
* on it. The guest's own table keeps only the descriptor number, its
* position and its flags. A removal, a rename, a truncate and a change of
* times by path go through the core too, so the files open on that path
* follow them. Mirrors Python's `RuntimeFiles`.
*/
export class RuntimeFiles {
bridge;
resolve;
core;
signal;
live;
dispatch;
resolver;
noAppend = new Set();
/**
* @param bridge the bridge every op rides, over the run's files; it also
* answers the raw calls a guest makes past the typed methods (the
* Pyodide worker's xattr and alias checks).
* @param resolver the workspace mount routing table; the default answers
* no mounts, so routing questions answer null.
* @param resolve where the namespace's links lead a path; null while the
* workspace keeps no links.
* @param core the open-file core the run's files open on; null opens
* nothing.
* @param signal the run's, which stops a core op once it aborts.
* @param live the signals of the guest calls on the core now, which its
* every dispatch checks; a call with none (a run's end landing what it
* wrote) passes, however the run ended.
*/
constructor(bridge, resolver = new PrefixResolver(() => []), resolve = null, core = null, signal = null, live = []) {
this.bridge = bridge;
this.resolve = resolve;
this.core = core;
this.signal = signal;
this.live = live;
// One cap on every request the file adapter sends, held for that request
// alone, so the stats of listings that run together (a guest
// walking a tree) share it with the walk's own reads.
const limiter = new ConcurrencyLimiter(LISTING_ENTRY_CONCURRENCY);
this.dispatch = async (...args) => {
const release = await limiter.acquire();
try {
return await bridge(...args);
}
finally {
release();
}
};
this.resolver = resolver;
}
/**
* The file adapter every engine builds for one run.
*
* Over the run's files (`ctx.vfs`, whose calls are the program's own),
* the binding's routing table and the namespace's links, each replaying
* the run's context, which `Runtime.execute` set up, for a guest that
* calls back from elsewhere. A run with no files rides the binding's
* guest bridge (`WorkspaceBinding.overBridge`, the Pyodide worker's).
*
* @param ctx the run's context.
* @param binding the runtime's binding; null outside a workspace, which
* has no adapter.
*/
static of(ctx, binding) {
if (binding === null)
return null;
const vfs = ctx.vfs;
const bridge = vfs === undefined
? binding.bridge
: workspaceBridge(async (name, path, args, kwargs) => (await vfs.dispatch(name, PathSpec.fromStrPath(path), args, kwargs))[0]);
if (bridge === null)
return null;
const caller = currentCaller();
const links = linkView(vfs);
const live = [];
return new RuntimeFiles(awaitsAs(caller, bridge), new CallerResolver(binding.resolver, caller), links === undefined ? null : callsAs(caller, links.resolve.bind(links)), vfs === undefined
? null
: new MountCore(vfs.over(
// A cancelled guest call sends nothing more, whichever op of
// the core's was under way; a landing goes however it ended.
awaitsAs(caller, async (name, path, args, kwargs, report) => {
const guests = inLanding() ? [] : live;
for (const signal of guests)
signal.throwIfAborted();
const out = await vfs.dispatch(name, path, args, kwargs, report);
for (const signal of guests)
signal.throwIfAborted();
return out;
}))), null, live);
}
/** A live guest view that stops issuing operations once its run is cancelled. */
withSignal(signal) {
return new RuntimeFiles(async (...args) => {
signal.throwIfAborted();
const out = await this.bridge(...args);
signal.throwIfAborted();
return out;
}, this.resolver, this.resolve, this.core, signal, this.live);
}
/**
* Run one op on the run's open-file core; EBADF when this adapter opens
* nothing. An op that only lands what the guest already wrote (`lands`)
* runs however the run ended, as the run's end would land it anyway
* (`runLanding`), so a cancelled guest call under way beside it stops only
* itself.
*/
async onCore(op, lands = false) {
const core = this.core;
if (core === null)
throw ebadf('/');
if (lands)
return runLanding(() => op(core));
const signal = this.signal;
if (signal === null)
return op(core);
signal.throwIfAborted();
this.live.push(signal);
try {
const out = await op(core);
signal.throwIfAborted();
return out;
}
finally {
this.live.splice(this.live.indexOf(signal), 1);
}
}
/** The path the core's handle `fd` is on now; EBADF for any other number. */
pathOf(fd) {
const held = this.core?.handles.get(fd);
if (held === undefined)
throw ebadf(String(fd));
return held.path;
}
/**
* Open a file, the open's effect landing before any byte moves
* (`MountCore.open`), and answer the core's handle for it. `perm` is the
* mode bits a create gives the file; null takes the mount's default.
*/
async open(path, mode, perm = null) {
return this.onCore((core) => core.open(path, mode, perm));
}
/**
* Read through an open handle: what it holds, judged as a read of its
* path now, with what it wrote and has not landed laid over.
*/
async pread(fd, offset, size) {
const path = this.pathOf(fd);
return this.onCore((core) => core.read(path, fd, offset, size));
}
/**
* Write through an open handle, which keeps the bytes until a close or a
* sync lands them; one that appends writes at its end.
*/
async pwrite(fd, offset, data) {
const path = this.pathOf(fd);
await this.onCore((core) => core.write(path, fd, data, offset));
}
/** The row an open handle stats by, the size it holds counted. */
async fstat(fd) {
return statRow(await this.onCore((core) => core.fstat(fd)));
}
/** Whether the name an open handle's file had went, so the file has no link left. */
unlinked(fd) {
return this.core?.handles.get(fd)?.detached !== undefined;
}
/** Set the length of the file an open handle is on. */
async ftruncate(fd, length) {
await this.onCore((core) => core.ftruncate(fd, length));
}
/** Land what an open handle wrote, as fsync(2) does. */
async fsync(fd) {
const path = this.pathOf(fd);
await this.onCore((core) => core.flush(path, fd), true);
}
/**
* Land what an open handle wrote and let it go; a landing that fails
* still lets it go, and is thrown.
*/
async close(fd) {
await this.onCore((core) => core.release(fd), true);
}
/**
* The path an op on `path` lands on once the namespace's links are
* followed: the final name too when `follow`, else only the directories
* above it, since a rename or a removal acts on the name itself. One file
* reads as one path whichever spelling reached it, which is how a removal
* finds the files open on it. Mirrors Python's RuntimeFiles.walked.
*/
walked(path, follow = true) {
const resolve = this.resolve;
if (resolve === null)
return path;
try {
if (follow)
return resolvePath(resolve(path), '/');
const cut = path.lastIndexOf('/');
const above = resolvePath(resolve(path.slice(0, cut) || '/'), '/');
return `${rstripSlash(above)}/${path.slice(cut + 1)}`;
}
catch (err) {
if (err instanceof CycleError)
throw eloop(path);
throw err;
}
}
/**
* The workspace mount prefixes, longest first, trailing-slash
* normalized. Longest first is what makes mountOf's first match the
* right one when one mount nests inside another.
*/
prefixes() {
const out = this.resolver.prefixes().map((p) => normDir(p));
return out.sort((a, b) => b.length - a.length);
}
/**
* The mount prefix serving `path`, longest match first, or null. The
* resolver answers in the mount table's own spelling; this surface
* re-spells to its trailing-slash convention, the form `prefixes`
* reports.
*/
mountOf(path) {
const owner = this.resolver.ownerOf(path);
return owner === null ? null : normDir(owner);
}
/**
* Whether the workspace answers for `path`.
*
* A guest's content calls gate on this: monty and quickjs refuse a
* path the workspace does not serve, so they read and write only the
* view. A mount serves what is under it, and a namespace link serves
* what is reached through it wherever it lives, because the
* dispatcher follows a link outside every mount the same way. With no
* mounts wired there is no scoping, and every path routes here.
*/
serves(path) {
if (this.prefixes().length === 0 || this.mountOf(path) !== null)
return true;
let directory = '/';
for (const name of path.replace(/^\/+|\/+$/g, '').split('/')) {
if (this.resolver.linkChildren(directory).has(name))
return true;
directory = directory.replace(/\/$/, '') + '/' + name;
}
return false;
}
/**
* A file's bytes, or the range of them a handle asked for. `raw` reads
* the stored bytes rather than a rendering, which is what an edit that is
* written back must start from; `direct` reads what the backend holds
* now, past the file cache. Mirrors Python's `RuntimeFiles.read`.
*/
async read(path, options = {}) {
const out = await this.dispatch('read', path, undefined, undefined, options);
if (!(out instanceof Uint8Array)) {
throw new TypeError(`runtime files: read ${path} expected Uint8Array, got ${typeof out}`);
}
return out;
}
async write(path, bytes) {
const out = await this.dispatch('write', path, bytes);
if (out !== undefined) {
throw new TypeError(`runtime files: write ${path} expected void, got ${typeof out}`);
}
this.core?.restat(path);
}
/**
* One path's metadata, projected for a guest encoder.
*
* @param path guest-absolute virtual path.
* @param nofollow report a trailing symlink itself rather than its
* target (a guest's lstat). The row is then the node table's own,
* so it carries the target string's length as the size, the link's
* mtime, and whatever a `chown -h` wrote; the dispatcher consumes
* the flag and gates that read exactly as it gates `readlink`.
*/
async stat(path, nofollow = false) {
// One file is one inode whichever spelling reached it, so a copy
// through a link onto the file itself is told apart (samefile).
return { ...(await this.mountRow(path, nofollow)), name: this.walked(path, !nofollow) };
}
async mountRow(path, nofollow) {
// Through the core, so what the files open there wrote and have not
// landed counts, as write(2) is seen at once.
if (this.core !== null)
return statRow(await this.onCore((core) => core.stat(path, !nofollow)));
const out = await this.dispatch('stat', path, undefined, undefined, nofollow ? { nofollow: true } : undefined);
if (out === null || typeof out !== 'object' || typeof out.name !== 'string') {
throw new TypeError(`runtime files: stat ${path} bad shape`);
}
return statRow(out);
}
/**
* The path's row, or null when the mount says it is not there. Only an
* absence (`ABSENT_PATH`) answers null; anything else throws, since a
* refusal is not an answer about the path.
*/
async statOrNull(path, nofollow = false) {
try {
return await this.stat(path, nofollow);
}
catch (err) {
if (classify(err) === 'ENOENT')
return null;
throw err;
}
}
/**
* `path`'s row as a runtime may see it, or null.
*
* Structure is open and content is not. A path in the view (`serves`)
* answers with its mount's own row; any path the workspace lists
* answers as a directory, so the root above nested mounts and the
* directories above a link are directories here as they are in a
* shell, while a withheld surface's files (history, the program view)
* stay unseen. A file's own row decides that, not its listing: the
* history mount lists its one file as empty so a traversal never
* descends into it. 0 is the file adapter's spelling of an unknown mtime.
*/
async viewStat(path) {
const row = await this.statOrNull(path);
if (this.serves(path)) {
if (row !== null)
return row;
}
else if (row !== null && !row.isDir) {
return null;
}
if ((await this.listingOrNull(path)) === null)
return null;
return { size: 0, isDir: true, mode: DIR_MODE };
}
/**
* The directory's unclassified rows, or null when it is not one: the
* question a guest asks of a path with no row of its own, a directory
* a mount only implies (the root above a nested mount), so nothing
* per entry is stat'd. Asked in the directory form, so a prefix store
* never answers `/s3/f` with the keys of `/s3/foo.txt`.
*/
async listingOrNull(path) {
try {
return await this.readdir(normDir(path), false);
}
catch (err) {
const condition = classify(err);
if (condition !== null && ABSENT_PATH.has(condition))
return null;
throw err;
}
}
/**
* List a directory as resolved entries (Python's `readdir` shape).
*
* A backend that slash-marks directories skips the stat; every other
* entry is classified by its own stat, which is RAM when the readdir
* filled the index and a backend request when the mount keeps none.
* At most `LISTING_ENTRY_CONCURRENCY` requests run at once across
* everything this adapter serves, so a large directory on an unindexed
* mount does not put every entry's request on the wire together.
*
* An entry whose stat fails, for any reason, rides unclassified: a
* size-0 non-directory with no mode and no stamp, the row that says
* "not known". One entry never fails the listing, the way a kernel
* readdir never stats at all. What went wrong is not lost: the
* guest's own stat or open of that entry asks the mount again and
* reports it. Only the listing itself failing fails the call. Any
* failure but a missing path (a dangling link, an entry gone since the
* listing) also warns on the host, since the row the guest sees is
* degraded.
*
* A row that did stat carries its mode and stamp too, since the
* struct is already in hand: a guest that places a directory's nodes
* from one listing (Emscripten does) then needs no second stat per
* file.
* The slash-marked and unclassified rows report neither, which is
* the honest answer for a listing that never learned them.
*
* The link mark comes from the name plane, since no backend listing
* reports a link. One table read per listing, and it only ever marks
* a name the listing itself returned, so a link the session hides
* stays hidden: the dispatcher filtered it out of the entries above
* and an unmatched mark marks nothing. A marked row is the link's
* own, as a guest's lstat reads it, since the node table answers that
* stat and no backend is asked.
*
* @param path guest-absolute virtual path of the directory.
* @param classify stat each entry to learn its kind. A guest that
* only needs names (monty's listdir, quickjs) passes false and
* every row comes back unclassified, one request for the listing
* and none per entry, as a POSIX readdir costs.
*/
async readdir(path, classify = true) {
const out = await this.dispatch('readdir', path);
if (!Array.isArray(out)) {
throw new TypeError(`runtime files: readdir ${path} expected array`);
}
// After the listing, not before: a directory that will not list
// (ENOENT, or a link cycle the namespace refuses to resolve) must
// fail as readdir, not as the mark read.
const links = this.resolver.linkChildren(path);
const rows = out.map((raw) => {
if (typeof raw !== 'string') {
throw new TypeError(`runtime files: readdir ${path} bad entry shape`);
}
return listed(raw, links);
});
if (!classify)
return rows;
// A fixed set of workers, not a promise per entry, so a wide directory
// costs the cap's worth of pending work, never its width.
const pending = rows.flatMap((row, index) => (row.isDir ? [] : [[index, row]]));
const queue = pending.values();
const work = async () => {
for (const [index, row] of queue)
rows[index] = await this.classified(path, row);
};
const workers = Math.min(LISTING_ENTRY_CONCURRENCY, pending.length);
await Promise.all(Array.from({ length: workers }, work));
return rows;
}
async classified(directory, row) {
const mark = row.isLink === true ? { isLink: true } : {};
try {
const st = await this.stat(row.path, row.isLink === true);
return {
path: row.path,
size: st.size,
isDir: st.isDir,
mode: st.mode,
mtimeMs: st.mtimeMs ?? 0,
...(st.atimeMs !== undefined ? { atimeMs: st.atimeMs } : {}),
...(st.uid !== undefined ? { uid: st.uid } : {}),
...(st.gid !== undefined ? { gid: st.gid } : {}),
...(st.isLink === true ? { isLink: true } : {}),
...(st.rdev !== undefined ? { rdev: st.rdev } : {}),
...mark,
};
}
catch (err) {
if (!isMissingPath(err)) {
console.warn(`runtime files: readdir ${directory}: stat ${row.path}: ${String(err)}`);
}
return row;
}
}
/** Set `path`'s length: a shrink drops bytes, growth reads zeros. */
async truncate(path, length = 0) {
if (length < 0)
throw einval(path);
if (this.core === null)
await this.dispatch('truncate', path, undefined, undefined, { length });
else
await this.onCore((core) => core.truncate(path, length));
}
/**
* Remove the entry at `path`; a file open on it keeps what it held, as
* POSIX keeps an open file on its inode.
*/
async unlink(path) {
if (this.core === null)
await this.dispatch('unlink', path);
else
await this.onCore((core) => core.unlink(path));
}
/**
* Create a directory; `parents` asks the mount to create missing
* ancestors too (pathlib's mkdir(parents=True), which the backend op
* takes as a flag on both hosts).
*/
async mkdir(path, parents = false) {
await this.dispatch('mkdir', path, undefined, undefined, parents ? { parents: true } : undefined);
}
async rmdir(path) {
await this.dispatch('rmdir', path);
}
/**
* Rename within one mount.
*
* Args:
* src: guest-absolute source path.
* dst: guest-absolute destination path.
*
* Throws:
* EXDEV: the two ends resolve to different mounts.
*/
async rename(src, dst) {
if (this.mountOf(src) !== this.mountOf(dst))
throw exdev(src);
if (this.core === null)
await this.dispatch('rename', src, undefined, dst);
else
await this.onCore((core) => core.rename(src, dst));
}
/**
* Create a namespace symlink at `path` pointing at `target`.
*
* A link is namespace state, so no backend stores one and the target
* is kept verbatim as the guest typed it. The dispatcher answers this
* op from the node table itself, which is why a runtime can serve
* `os.symlink` at all: the dispatcher a surface already holds reaches the
* name plane, not just a mount.
*
* Args:
* path: guest-absolute path of the link to create.
* target: link target, stored as typed.
*/
async symlink(path, target) {
await this.dispatch('symlink', path, undefined, target);
}
/**
* The target of the symlink at `path`.
*
* Throws EINVAL when `path` is not a link, which is what the node
* table answers and what POSIX readlink says.
*/
async readlink(path) {
const out = await this.dispatch('readlink', path);
if (typeof out !== 'string') {
throw new TypeError(`runtime files: readlink ${path} expected string, got ${typeof out}`);
}
return out;
}
/**
* Write metadata fields, natively where the backend can hold them.
*
* The dispatcher reads the whole set and stores in the namespace overlay
* whatever the backend cannot keep, so a mount with no setattr op
* still answers: a utime on an s3 or dropbox mount lands in the name
* plane and stat reports it back. Stored, not enforced; the mount
* mode is the access control.
*
* Args:
* path: guest-absolute virtual path.
* attrs: the fields to write, unset ones omitted.
*/
async setattr(path, attrs) {
// A file still open there lands its writes first, or they would stamp
// over the times.
if (attrs.atime !== undefined || attrs.mtime !== undefined) {
if (this.core !== null)
await this.onCore((core) => core.land(path, attrs.nofollow !== true));
}
await this.dispatch('setattr', path, undefined, undefined, attrs);
// The files open there stat it afresh: the change went past the core.
this.core?.restat(path, attrs.nofollow !== true);
}
/**
* Extend `path` by `tail`, falling back to a whole-file write.
*
* `append` is optional per backend (S3 registers `write` and
* `rename` without it), so a mount that declines is remembered: the
* fallback then costs one failed dispatch per mount rather than one
* per call.
*
* The fallback reads the base fresh every time, never a copy from an
* earlier append: an append lands after whatever the file holds now,
* so a write another action made between two appends is kept, as
* O_APPEND keeps it. It reads the stored bytes, not a rendering, since
* it writes them back. Only a confirmed absence starts from an empty
* base, since an append may create the file; every other read failure
* propagates, because writing the tail alone over a file that exists
* but is momentarily unreadable would replace content this run never
* saw.
*
* Args:
* path: guest-absolute virtual path.
* tail: only the newly appended bytes.
*/
async append(path, tail) {
if (await this.appendDelta(path, tail)) {
this.core?.restat(path);
return;
}
let base = new Uint8Array();
try {
base = await this.read(path, { raw: true, direct: true });
}
catch (err) {
if (!isMissingPath(err))
throw err;
}
await this.write(path, concat([base, tail]));
}
async appendDelta(path, tail) {
const mount = this.mountOf(path) ?? path;
if (this.noAppend.has(mount))
return false;
try {
await this.dispatch('append', path, tail);
}
catch (err) {
if (!isMissingOp(err, 'append'))
throw err;
this.noAppend.add(mount);
return false;
}
return true;
}
}
//# sourceMappingURL=files.js.map