UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

833 lines • 34.2 kB
// ========= 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