@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
833 lines • 34.2 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 { eacces, enoent, enotsup, erofs } from "../errors/fs.js";
import { fromUnits, units } from "../rs/document.js";
import { visibilityMatcher } from "../rs/policy.js";
import { MountMode, weakerMode } from "../types.js";
import { mergeSignals } from "../utils/abort.js";
import { asyncContextIsolatesTasks, createAsyncContext } from "../utils/async_context.js";
import { leadsHidden, pathVisible } from "../utils/hidden.js";
import { norm, parent } from "../utils/path.js";
import { rstripSlash } from "../utils/slash.js";
/** Whoever binds sessions: one per workspace, compared by identity. */
export class SessionOwner {
}
const UNBOUND = {
session: null,
owner: null,
evaluation: null,
gate: null,
ruleGate: null,
mount: null,
refusals: null,
explaining: null,
walkProbe: null,
redirects: null,
program: null,
call: null,
stop: null,
writeHeld: false,
versionReads: true,
lineAbort: null,
recording: null,
revisions: null,
history: null,
commandSink: null,
cache: null,
readFacts: null,
write: null,
started: null,
command: null,
declared: null,
enclosing: null,
execution: null,
process: null,
invocation: null,
vfsMount: null,
};
const callerStorage = createAsyncContext();
/** The caller bound to the current async context. */
export function currentCaller() {
return callerStorage.getStore() ?? UNBOUND;
}
/**
* Every caller live in this context, oldest first: on an isolating
* runtime the bound one alone, on the fallback storage every
* concurrently bound one.
*/
export function liveCallers() {
return callerStorage.liveStores();
}
/**
* Run `fn` with `caller` bound: a layer's own copy, or the record a
* stream pulled or a callback run after its command returned was made
* with.
*/
export async function runWithCaller(caller, fn) {
return callsAs(caller, fn)();
}
/**
* `fn` run as `caller` wherever it is called from: a runtime's guest asks
* its questions from a callback of its own, which carries no caller.
* Without task isolation, reject conflicting conditional sessions before
* binding the caller, so an in-flight mutation can finish under its owner.
*/
export function callsAs(caller, fn) {
return (...args) => {
if (asyncContextIsolatesTasks)
return callerStorage.run(caller, () => fn(...args));
const conflict = callerConflict(caller);
if (conflict !== null)
throw enotsup('workspace', 'conditional write (overlapping sessions)', conflict);
try {
const result = callerStorage.run(caller, () => fn(...args));
if (result instanceof Promise)
return result.finally(flushCloses);
flushCloses();
return result;
}
catch (error) {
flushCloses();
throw error;
}
};
}
/** The conditional mount whose live caller conflicts with this binding. */
function callerConflict(caller) {
if (asyncContextIsolatesTasks)
return null;
for (const held of liveCallers()) {
const write = caller.write?.context != null ? caller.write : held.write;
if (write?.context != null &&
(held.session !== null || held.write?.context != null) &&
(caller.session?.versions ?? null) !== (held.session?.versions ?? null))
return write.prefix;
}
return null;
}
const pendingCloses = new Map();
/** Drain deferred closes after a fallback caller releases its frame. */
function flushCloses() {
for (const [finish, caller] of pendingCloses) {
if (callerConflict(caller) !== null)
continue;
pendingCloses.delete(finish);
void runWithCaller(caller, finish).catch((error) => {
console.debug('deferred stream closer failed:', error);
});
}
}
/** `fn` run as `caller` whoever awaits it. */
export function awaitsAs(caller, fn) {
return (...args) => runWithCaller(caller, () => fn(...args));
}
/**
* `source` pulled and closed as `caller`. A stream keeps the caller it was
* made with: whoever pulls it and however late (a reader further down a
* pipeline, a host draining a line's output), what each pull runs (a
* backend's lazy read, the record it makes, the pins and cache scope it
* reads) is that caller's, and so is the close a reader that stops early
* sends. Mirrors Python's `with_caller`.
*/
export async function* withCaller(source, caller) {
const iterator = source[Symbol.asyncIterator]();
try {
for (;;) {
const next = await runWithCaller(caller, () => iterator.next());
if (next.done === true)
return;
yield next.value;
}
}
finally {
const finish = iterator.return?.bind(iterator);
if (finish !== undefined) {
// A conflicting operation may itself await this close. Queue cleanup
// without binding over that operation or waiting for it to finish.
if (callerConflict(caller) !== null)
pendingCloses.set(finish, caller);
else
await runWithCaller(caller, () => finish());
}
}
}
/** The execution the current call runs in; never a sibling's on hosts
* without task isolation. */
export function currentExecutionId() {
const id = currentCaller().execution;
if (id === null ||
liveCallers().some((caller) => caller.execution !== null && caller.execution !== id)) {
return null;
}
return id;
}
/**
* Run `run` as part of the execution `executionId`, which its records name.
*
* @param process the managed runner it is, when it is one.
*/
export function runWithExecution(executionId, run, process = null) {
return runWithCaller({ ...currentCaller(), execution: executionId, process }, run);
}
function runChanged(changes, fn) {
return runWithCaller({ ...currentCaller(), ...changes }, fn);
}
/**
* Bind `session` for the duration of `fn`.
*
* `owner` names the manager the session belongs to; omitting it keeps the
* owner already bound, so a nested bind inside a line (a background job's
* fork) stays attributed to the workspace running it. `evaluation` is the
* evaluation running on the session; omitting it keeps the one already
* bound when it runs on this same session, and drops another session's.
*/
export function runWithSession(session, fn, options = {}) {
const bound = currentCaller();
return runChanged({
session,
owner: options.owner ?? bound.owner,
evaluation: options.evaluation ?? (bound.session === session ? bound.evaluation : null),
}, fn);
}
export function getCurrentSession() {
return currentCaller().session;
}
/**
* Every line frame live in this context, oldest first, each once: on the
* fallback storage the layers of one line each copy its frame.
*/
export function lineFrames() {
return [
...new Set(liveCallers().flatMap((caller) => (caller.lineAbort === null ? [] : [caller.lineAbort]))),
];
}
/**
* The active line's cancellation channel, when it can be attributed to this
* session: the one line live on it, or the one signal every live line on it
* answers to, as an `eval` or `source` line carries the line that runs it.
*/
export function lineSignal(session) {
const signals = new Set(lineFrames()
.filter((frame) => frame.sessions.includes(session))
.map((frame) => frame.stop ?? frame.signal));
return signals.size === 1 ? [...signals][0] : undefined;
}
/**
* The signal the current call answers to: its evaluation frame's and its
* line's, which withdraw a pipe read or copy that waits; Python cancels the
* awaiting task instead. None in a landing (`runLanding`).
*/
export function callerSignal() {
if (inLanding())
return undefined;
const session = getCurrentSession();
return mergeSignals(getCurrentEvaluation()?.frame.abortSignal, session === null ? undefined : lineSignal(session));
}
// Bound while what a stopped run wrote lands, apart from the caller, which
// a layer below may pin to the run's own.
const landing = createAsyncContext();
/**
* `fn` run as a landing of what a stopped run wrote, which no abort stops,
* as a killed process's writes stay in its files: its calls answer to no
* signal, whatever caller they run as. Mirrors Python's `run_landing`.
*/
export function runLanding(fn) {
return Promise.resolve(landing.run(true, fn));
}
/**
* Whether the current call is part of a landing. Never without task
* isolation (a browser): there the one slot shows a landing to every call
* beside it, and a stopped guest's call must not pass as one, so the
* guest's stop wins over its landing. Mirrors Python's `in_landing`.
*/
export function inLanding() {
return asyncContextIsolatesTasks && landing.getStore() === true;
}
/** Bind an evaluation with its session, as one binding. */
export function runWithEvaluation(context, fn, owner) {
return runWithSession(context.session, fn, {
...(owner === undefined ? {} : { owner }),
evaluation: context,
});
}
/**
* The evaluation bound with the current session, null outside one.
*
* On an isolating runtime that is the task's own binding. On the fallback
* storage it is the newest live binding that carries one: a bind for
* another session without an evaluation (a held dispatcher's) may be the
* newest frame, and must not answer a running line with none, which would
* skip that line's abort checks. Mirrors Python's get_current_evaluation.
*/
export function getCurrentEvaluation() {
const callers = liveCallers();
for (let at = callers.length - 1; at >= 0; at--) {
const evaluation = callers[at]?.evaluation;
if (evaluation !== undefined && evaluation !== null)
return evaluation;
}
return null;
}
/**
* Every session whose binding is live in this context, oldest first.
*
* On an isolating runtime this is the bound session alone, so a fold
* over it computes exactly what `getCurrentSession` implies. On the
* fallback storage it is every concurrently bound session, which is
* what lets a security predicate merge toward the most restrictive
* answer instead of trusting the newest binding: every predicate here
* fails open on "no session", so a frame another task shadows or
* settles past must keep counting while it is live.
*/
export function liveSessions() {
return liveCallers().flatMap((caller) => (caller.session === null ? [] : [caller.session]));
}
/**
* The bound session, but only when `owner` published it.
*
* A session carries one workspace's cwd, env and mount grants, so a
* second workspace re-entered mid-line must resolve its own session
* rather than adopt this one. The owner is the disambiguator, so every
* live binding is searched, newest first: on the fallback storage a
* concurrent workspace's bind shadowing the newest frame must not hide
* this owner's own session.
*/
export function getCurrentSessionFor(owner) {
const callers = liveCallers();
for (let at = callers.length - 1; at >= 0; at--) {
const caller = callers[at];
if (caller?.owner === owner)
return caller.session;
}
return null;
}
/**
* The bound session, unless another owner published it.
*
* An entry point keeps the session it is reached under, so it never widens
* a caller's view: a command's runtime, a kernel mount and a guest
* runtime all bind before they call. A binding that names an owner
* other than `owner` is another workspace's, and its session describes
* that workspace's hides and grants, so the entry point must not adopt it. A
* binding that names no owner is a deliberate placement (a kernel
* mount, a guest runtime, an embedder binding by hand) and is kept.
*/
export function getCurrentSessionUnlessForeign(owner) {
const caller = currentCaller();
if (caller.owner !== null && caller.owner !== owner)
return null;
return caller.session;
}
/**
* The current session's mode for this mount.
*
* `MountMode.EXEC` (no narrowing) when no session is bound, when the
* profile names no mount, or when it names none for this one: a profile's
* mount sections narrow what the mount already offers and never decide
* whether it exists. A profile that must not reach a mount hides it, which
* answers ENOENT rather than a permission error naming something the
* profile cannot see. Folded to the weakest across every live session,
* which on an isolating runtime is the bound one alone.
*/
function sessionModeOf(sess, mountPrefix) {
if (sess.mountModes == null)
return MountMode.EXEC;
return sess.mountModes.get(norm(mountPrefix)) ?? MountMode.EXEC;
}
function sessionMode(mountPrefix) {
let mode = MountMode.EXEC;
for (const sess of liveSessions()) {
mode = weakerMode(mode, sessionModeOf(sess, mountPrefix));
}
return mode;
}
export const DEFAULT_UMASK = 0o022;
/** The owner's creation mask, or the ambient mask when no owner is supplied.
* Unbound browser calls OR every live session's mask so an overlapping
* wider session cannot relax permissions. No live session means bash's default. */
export function sessionUmask(access) {
if (access !== undefined)
return access.umask;
const live = liveSessions();
if (live.length === 0)
return DEFAULT_UMASK;
let mask = 0;
for (const sess of live)
mask |= sess.umask;
return mask;
}
/** Whether the owner's pathname expansion includes leading dots.
* Unbound browser calls require every live session to opt in; no live
* session means bash's default, off. */
export function dotglobActive(access) {
if (access !== undefined)
return access.shopts.dotglob === true;
const live = liveSessions();
return live.length > 0 && live.every((sess) => sess.shopts.dotglob === true);
}
/** Whether the owner's patterns use extglob grammar. Without an explicit
* owner, read the evaluating session; no session means bash's default, off. */
export function extglobActive(access) {
if (access !== undefined)
return access.shopts.extglob === true;
return getCurrentSession()?.shopts.extglob === true;
}
/** The owner's visibility, or the ambient view when no owner is supplied.
* Unbound browser calls combine live sessions' hides and keep only shows
* they all grant. An explicit unrestricted owner stays unrestricted. */
export function sessionVisibility(access) {
if (access !== undefined)
return access.visibility;
const live = liveSessions().map((sess) => sess.visibility);
const first = live[0];
if (first === undefined)
return null;
if (live.every((vis) => vis === first))
return first;
const hiding = live.filter((vis) => vis.paths != null);
const shown = (hiding[0]?.shown?.entries ?? []).filter((entry) => hiding.every((vis) => (vis.shown?.entries ?? []).some((e) => e.path === entry.path)));
const paths = hiding.flatMap((vis) => vis.paths?.paths ?? []);
const patterns = hiding.flatMap((vis) => vis.paths?.patterns ?? []);
const names = live.flatMap((vis) => vis.vars?.names ?? []);
const varPatterns = live.flatMap((vis) => vis.vars?.patterns ?? []);
return {
paths: hiding.length === 0 ? null : { paths, patterns },
shown: shown.length === 0 ? null : { entries: shown },
vars: names.length + varPatterns.length === 0 ? null : { names, patterns: varPatterns },
processes: live.every((vis) => vis.processes === 'workspace') ? 'workspace' : 'session',
};
}
/**
* The error a hidden path answers, in POSIX's own terms.
*
* ENOENT names a component that does not exist and EACCES an entry the
* caller may not write, so a create is EACCES only when the directory
* it lands in is visible: a hidden name under a visible parent reads
* as an existing file the session cannot write, which is the one
* answer that neither reveals the content nor invites a create that
* would clobber it. A create under a hidden directory is ENOENT, the
* same answer every read gives for that directory, so a write cannot
* detect a hide a read could not. Everything that is not a create is
* ENOENT. `create` is whether the op creates the path it names; a
* rename or copy destination is one. `links` are the namespace's links, so
* a directory reached through a link into hidden space counts as hidden.
*/
export function hiddenRefusal(vis, virtual, create, links = new Map()) {
const above = parent(rstripSlash(virtual) || '/');
if (create && pathVisible(vis, above) && !leadsHidden(vis, above, links))
return eacces(virtual);
return enoent(virtual);
}
/**
* Bind the admitted command's entry gate for the duration of `fn`: the
* run of that one command.
*
* Bound by the dispatcher once the gate let the command through, so a
* nested line (`xargs`, `find -exec`, `eval`) binds its own and the outer
* command gets its gate back when it returns, and a pipeline stage in
* its own async context never sees a sibling's.
*/
export function runWithAdmission(gate, fn) {
return runChanged({ gate }, fn);
}
/**
* The entry gate of the command running in this context, null when no
* admitted command is bound (a command constructed outside the
* dispatcher, or a line no gate judged).
*
* On an isolating runtime one gate is live and answers as bound. On
* the fallback storage several commands' gates can be live at once
* with nothing to say whose op is asking, so they merge toward
* refusal: an entry must pass every live gate's `check`, its `refusal` is
* the first live gate's that refuses it, and a rule counts
* as granted only when every live gate carries it (a once-grant nodded
* for one line must not authorize another's dispatcher), and a path as
* judged only when every live gate names it. `scopes` is true when any
* live gate scopes, keeping walks off the unfiltered native fast paths.
*/
export function getAdmission() {
const gates = liveCallers().flatMap((caller) => (caller.gate === null ? [] : [caller.gate]));
const first = gates[0];
if (first === undefined)
return null;
if (gates.every((gate) => gate === first))
return first;
return {
granted: first.granted.filter((rule) => gates.every((gate) => gate.granted.includes(rule))),
judged: new Set([...first.judged].filter((p) => gates.every((gate) => gate.judged.has(p)))),
check(virtual) {
for (const gate of gates)
gate.check(virtual);
},
scopes(virtual) {
return gates.some((gate) => gate.scopes(virtual));
},
refusal(virtual) {
for (const gate of gates) {
const reason = gate.refusal(virtual);
if (reason !== null)
return reason;
}
return null;
},
};
}
/**
* Bind the executing mount's prefix and configured mode for the
* duration of `fn`: the run of one command.
*
* Bound by `Mount.runCommand` around the handler: it is how a command
* tells its run on one mount from a run across mounts, which binds none
* (`crossesMounts`), and the prefix a path it makes up is keyed below
* (`mountRoot`).
*/
export function runWithMountGate(prefix, mode, fn) {
return runChanged({ mount: [prefix, mode] }, fn);
}
/**
* Bind where the entry points note a policy's refusal, for the duration of `fn`:
* one line's run.
*
* Bound by the workspace around a typed line: a command renders an op
* refusal in its own GNU words, which say nothing of the policy, so the
* entry point notes the record here and the line carries it on its result.
* Every task and nested line the line starts inherits the binding, so a
* stream drained after its command returned still reaches it. Mirrors
* Python's set_refusal_sink.
*/
export function runWithRefusalSink(sink, fn) {
return runChanged({ refusals: sink }, fn);
}
/**
* Whether a typed line is running in this context: the entry point it reaches is
* inside a command, not a file tool's or the host's own call. Mirrors
* Python's `line_running`.
*/
export function lineRunning() {
return currentCaller().refusals !== null;
}
/**
* Run `fn` as a dry run: the op gate notes on `trace` what it would
* answer and stops the op before any backend or cache is touched.
* `DryRun.DECIDING` is what a policy does while it decides the op
* explained: its reads run, nothing changes. A null trace runs `fn` for
* real. Mirrors Python's `set_explaining`.
*/
export function runExplaining(trace, fn) {
return runChanged({ explaining: trace }, fn);
}
/**
* The dry run's trace when the calls in this context only explain,
* DECIDING while its policies decide, null when they run.
*/
export function explaining() {
return currentCaller().explaining;
}
/**
* Hand an entry point's refusal to the line running in this context; an entry point
* reached outside any line (a programmatic op) has no line to tell, and
* the record rides the thrown error alone.
*/
export function noteRefusal(refusal) {
currentCaller().refusals?.(refusal);
}
/**
* The walk probe bound to the command serving `virtual`, null outside a
* mount's command (a generic invoked directly in a test).
*
* Selected among every live binding by the path itself, the longest prefix
* covering it winning as the mount table routes, so on the fallback storage
* a concurrent command on another mount cannot lend its probe to this one.
* Mirrors Python's get_walk_probe.
*/
export function walkProbeFor(virtual) {
const v = norm(virtual);
let bestLen = -1;
let best = null;
for (const [rawPrefix, probe] of liveCallers().flatMap((caller) => caller.walkProbe === null ? [] : [caller.walkProbe])) {
const prefix = norm(rawPrefix);
if (prefix !== '/' && v !== prefix && !v.startsWith(prefix + '/'))
continue;
if (prefix.length > bestLen) {
bestLen = prefix.length;
best = probe;
}
}
return best;
}
/**
* Bind a statement's expanded redirect targets to the command node they
* belong to, for the duration of `fn` (that node's run).
*
* The redirect layer expands the targets before the command executes (a
* `$()` in one runs exactly once there), so the admission gate deep in
* command dispatch cannot re-derive them; it reads them here instead.
* Keyed by the node object itself so a nested line expanded on the way
* to the command (a `$()` operand, an `eval`) never inherits the outer
* statement's targets.
*
* The runner applies redirects after command words expand, admitting each
* resolved target before opening it. Nested expansions keep their own identity.
*/
export function runWithRedirectPaths(node, paths, fn, runner = null, syntax = []) {
return runChanged({ redirects: [node, paths, runner, syntax] }, fn);
}
/** The caller a deferred workspace callback runs as: the bound one, as
* `session` when one is named, whose evaluation comes along only when it
* is the session bound now. */
export function sessionCaller(session, owner) {
const bound = currentCaller();
return {
...bound,
session,
owner: owner ?? null,
evaluation: bound.session === session ? bound.evaluation : null,
};
}
/**
* Run a line as a program run in a session: `find -exec` hands its words
* to `execvp`, so the head it runs is the coreutils program, not the
* shell's builtin of the same name (`printf -v` is a format string there,
* not an assignment). Keyed by the session object, which the child shells
* it starts inherit, and cleared again by a nested shell the line starts
* (`-exec sh -c ...`), so that shell's builtins are its own.
*/
export function runAsProgram(session, fn) {
return runChanged({ program: session }, fn);
}
/** Run a nested shell's script as the shell's own again: a nested shell
* is a program, and the builtins it runs are its builtins, `printf -v`
* included. */
export function runAsShell(fn) {
return runChanged({ program: null }, fn);
}
/** Whether the line running in this session is a program run. */
export function isProgramInvocation(session) {
const caller = currentCaller();
const marked = caller.program;
if (marked === null)
return false;
if (session === marked)
return true;
if (caller.session !== session)
return false;
for (let parent = caller.evaluation?.parent ?? null; parent !== null; parent = parent.parent)
if (parent.session === marked)
return true;
return false;
}
/**
* The redirect targets bound to this command node, empty for any other
* node or when none are bound.
*
* The node is the disambiguator, so every live binding is searched,
* newest first: on the fallback storage a concurrent statement's bind
* shadowing the newest frame must not hide this node's own targets.
*/
export function redirectPathsFor(node) {
const bindings = liveCallers().flatMap((caller) => caller.redirects === null ? [] : [caller.redirects]);
for (let at = bindings.length - 1; at >= 0; at--) {
const bound = bindings[at];
if (bound?.[0] === node)
return bound[1];
}
return [];
}
/** Pending redirects, before a rewritten command reaches admission. */
export function redirectSyntaxFor(node) {
const bindings = liveCallers().flatMap((caller) => caller.redirects === null ? [] : [caller.redirects]);
for (let at = bindings.length - 1; at >= 0; at--) {
const bound = bindings[at];
if (bound?.[0] === node)
return bound[3];
}
return [];
}
/**
* The runner bound with this command node's redirect targets, null for any
* other node or when none is bound.
*/
export function redirectRunnerFor(node) {
const bindings = liveCallers().flatMap((caller) => caller.redirects === null ? [] : [caller.redirects]);
for (let at = bindings.length - 1; at >= 0; at--) {
const bound = bindings[at];
if (bound?.[0] === node)
return bound[2];
}
return null;
}
/**
* Whether a path is a redirect target command admission already judged
* for the statement writing it now.
*
* The dispatcher asks this, and unlike `redirectPathsFor` it takes no node,
* because by the time the shell writes the file the node has returned
* and the dispatcher sees only a path. The binding is what keeps that honest: it
* exists only while one statement's targets are being written, and a
* statement whose targets a rule refused never reaches the write at all.
* So a bound target is one the line was admitted with, and re-deriving a
* verdict for it from the dispatcher, which knows neither the line nor the nod it
* holds, can only get it wrong. Every live binding counts, because a
* target is only ever bound after its own line's admission judged it: on the
* fallback storage a concurrent statement's frame must not shadow this
* one's targets into a re-derived refusal.
*/
export function redirectTargetJudged(virtual) {
return liveCallers().some((caller) => caller.redirects?.[1].some((p) => p.virtual === virtual) === true);
}
/**
* The mount mode after narrowing by the owner's profile, or the live
* sessions' when no owner is supplied. The mount's own mode is the
* strongest one available; a profile's mode can only weaken it (a READ
* mount stays read-only whatever the profile says). A mount the profile
* does not name keeps its own mode.
*/
export function effectiveMountMode(mountPrefix, mountMode, access) {
return weakerMode(mountMode, access === undefined ? sessionMode(mountPrefix) : sessionModeOf(access, mountPrefix));
}
/**
* The mode in force at one path: the whole VFS axis on the one
* anchor-depth rule.
*
* The mount's configured mode is narrowed by the deepest session
* statement covering the path, where a statement is the profile's
* per-mount mode (scored at the mount prefix's own depth) or a
* mode-carrying show entry (scored at its anchor depth). Deeper wins,
* so `mounts: {/repo: r}` with `show: {"/repo/build": rw}` reads the
* repo and writes only the build tree; an equal-depth pair takes the
* weaker, failing toward refusal. The configured mode stays the
* strongest answer possible: the document never grants past it.
* Folded to the weakest across every live session, which on an
* isolating runtime is the bound one alone.
*/
function pathModeUnder(sess, virtual, mountPrefix, mountMode) {
const prefix = norm(mountPrefix);
const cap = sess.mountModes?.get(prefix) ?? null;
if (cap === null && sess.visibility?.shown == null)
return mountMode;
return visibilityMatcher(sess.visibility ?? null).ask('effective_mode', {
path: units(virtual),
prefix: units(prefix),
configured: mountMode,
cap,
});
}
export function effectivePathMode(virtual, mountPrefix, mountMode, access) {
if (access !== undefined)
return pathModeUnder(access, virtual, mountPrefix, mountMode);
let mode = mountMode;
for (const sess of liveSessions()) {
mode = weakerMode(mode, pathModeUnder(sess, virtual, mountPrefix, mountMode));
}
return mode;
}
/**
* The strongest mode one session reaches anywhere under a mount: its
* mount-wide mode, or a deeper show grant, still capped by the mount's
* configured mode. Mirrors the Python `strongest_under_session`.
*/
export function strongestUnderSession(sess, mountPrefix, mountMode) {
const prefix = norm(mountPrefix);
const cap = sess.mountModes?.get(prefix) ?? null;
if (cap === null && sess.visibility?.shown == null)
return mountMode;
return visibilityMatcher(sess.visibility ?? null).ask('strongest_mode', {
prefix: units(prefix),
configured: mountMode,
cap,
});
}
/**
* The strongest mode the live sessions reach anywhere under a mount
* (`strongestUnderSession`), folded to the weakest across them: a command
* runs only when every live session would let it.
*
* What the whole-mount gates read: a write command stays runnable on a
* mount whose only writable region is a show entry (the dispatcher then
* refuses per path), and the interpreters' any-`x` rule counts a show
* grant the way it counts a whole mount.
*/
export function strongestModeUnder(mountPrefix, mountMode, access) {
if (access !== undefined)
return strongestUnderSession(access, mountPrefix, mountMode);
let best = mountMode;
for (const sess of liveSessions()) {
best = weakerMode(best, strongestUnderSession(sess, mountPrefix, mountMode));
}
return best;
}
/**
* The path to blame when a subtree mutation reaches into a read-only
* region below its operand, null when nothing below is weaker.
*
* The dual of the per-path check, for the ops that mutate a whole
* subtree in one backend call (`rm -r`, a directory rename, a native
* `cp -r`): the operand's own region may grant writes while a
* mode-carrying show entry holds a deeper subtree to `r`, and the
* backend cannot honor that boundary mid-call, so the caller refuses
* the operand up front. An exact entry is blamed by its anchor, the
* row GNU would report the refusal on; a pattern names no single
* anchor, so the operand itself is blamed whenever the pattern's
* match space could reach below it, failing toward refusal. The first
* blame any live session raises answers.
*/
function readonlyBelowUnder(sess, virtual, mountPrefix, mountMode) {
if (sess.visibility?.shown == null)
return null;
const prefix = norm(mountPrefix);
const found = visibilityMatcher(sess.visibility).ask('readonly_below', {
path: units(virtual),
prefix: units(prefix),
configured: mountMode,
cap: sess.mountModes?.get(prefix) ?? null,
});
return found === null ? null : fromUnits(found);
}
export function readonlyBelow(virtual, mountPrefix, mountMode, access) {
if (access !== undefined)
return readonlyBelowUnder(access, virtual, mountPrefix, mountMode);
for (const sess of liveSessions()) {
const blame = readonlyBelowUnder(sess, virtual, mountPrefix, mountMode);
if (blame !== null)
return blame;
}
return null;
}
/** Apply the same mode ceiling to command, dispatcher and namespace writes. */
export function requirePathsWritable(paths, mountPrefix, mountMode, subtree = false, access) {
for (const path of paths) {
if (effectivePathMode(path.virtual, mountPrefix, mountMode, access) === MountMode.READ) {
throw erofs(path, `mount ${mountPrefix} is read-only`);
}
}
if (subtree) {
for (const path of paths) {
const blame = readonlyBelow(path.virtual, mountPrefix, mountMode, access);
if (blame !== null)
throw erofs(blame, `mount ${mountPrefix} is read-only`);
}
}
}
/** Bind access to its owner before an asynchronous operation can suspend. */
export function sessionAccess(session) {
return (session ?? {
sessionId: '',
visibility: null,
umask: DEFAULT_UMASK,
shopts: {},
mountModes: null,
commandLimits: {},
});
}
//# sourceMappingURL=session_context.js.map