From f69eb0a8d7077a2b1047b47c890dba8d33a3d25c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 29 Jul 2026 13:05:47 +0000 Subject: [PATCH 1/8] feat(exec): run a command with a driver in an unprivileged user namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mountx/exec` grafts an `FsDriver` onto one command's filesystem view at `$MOUNTX_ROOT`, visible to that process tree and to nothing else on the machine, and hands back the command's exit status. Deliberately outside `mountx/auto` for the reason `mountx/s3` is: `auto`'s contract is a mountpoint and this produces none. The mechanism is FUSE inside `unshare -U -r -m`. `unshare(CLONE_NEWUSER)` refuses a threaded caller and Node is never single-threaded, so the namespace is entered by a child (`userns-relay.ts`) that opens `/dev/fuse` in there and relays raw traffic back over a unix socket to a `FuseSession` in this process, where the driver stays. Inside the namespace the relay is uid 0 with `CAP_SYS_ADMIN`, so it takes the ordinary root mount path — no `fusermount3`, no setuid bit, no native addon, and no root on the host. Four things that were each learned the hard way and are documented in the code: `default_permissions` is not a default mount option (the kernel would check a driver's uid against a namespace that maps exactly one, rendering it `nobody` and failing every write); a `cwd` inside the mountpoint is refused rather than deadlocked on; the `unshare` flags are spelled short because busybox has `-r` and no `--map-root-user`; and the relay is a build entry with a candidate-path resolver, because it is spawned rather than imported and obuild answers a dynamic import with a chunk. `probe.ts` is import-light in `src/nfs/probe.ts`'s sense — `node:fs` and nothing else — and `index.ts` keeps a mechanism discriminant and a per-mechanism probe verdict, so a second mechanism is an added arm rather than an API change. Co-Authored-By: Claude Opus 5 --- build.config.ts | 5 + package.json | 4 + src/exec/demo-driver.ts | 27 +++ src/exec/demo-userns.ts | 33 ++++ src/exec/index.ts | 257 ++++++++++++++++++++++++++++ src/exec/probe.ts | 258 ++++++++++++++++++++++++++++ src/exec/userns-relay.ts | 224 ++++++++++++++++++++++++ src/exec/userns.ts | 360 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 1168 insertions(+) create mode 100644 src/exec/demo-driver.ts create mode 100644 src/exec/demo-userns.ts create mode 100644 src/exec/index.ts create mode 100644 src/exec/probe.ts create mode 100644 src/exec/userns-relay.ts create mode 100644 src/exec/userns.ts diff --git a/build.config.ts b/build.config.ts index c4f55f9..5778ce2 100644 --- a/build.config.ts +++ b/build.config.ts @@ -12,6 +12,11 @@ export default defineBuildConfig({ "./src/nfs/index.ts", "./src/9p/index.ts", "./src/s3/index.ts", + "./src/exec/index.ts", + // Not a subpath export: `execUserns()` spawns it, so it has to survive + // the build as its own file beside `dist/exec/index.mjs` rather than + // being bundled into it. + "./src/exec/userns-relay.ts", "./src/drivers/memory.ts", "./src/drivers/node-fs.ts", "./src/drivers/unstorage.ts", diff --git a/package.json b/package.json index 33b49df..5325ce5 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,10 @@ "types": "./dist/s3/index.d.mts", "default": "./dist/s3/index.mjs" }, + "./exec": { + "types": "./dist/exec/index.d.mts", + "default": "./dist/exec/index.mjs" + }, "./drivers/memory": { "types": "./dist/drivers/memory.d.mts", "default": "./dist/drivers/memory.mjs" diff --git a/src/exec/demo-driver.ts b/src/exec/demo-driver.ts new file mode 100644 index 0000000..1b8d6fa --- /dev/null +++ b/src/exec/demo-driver.ts @@ -0,0 +1,27 @@ +/** The tree the demo runner is pointed at. */ + +import { createMemoryDriver } from "../drivers/memory.ts"; +import { createLoopback } from "../harness.ts"; +import type { FsDriver } from "../types.ts"; + +/** A memory driver holding a handful of files a shell can obviously exercise. */ +export async function createDemoDriver(): Promise { + const driver = createMemoryDriver(); + const fs = createLoopback(driver); + await fs.mkdir("/docs", { recursive: true }); + await fs.writeFile("/hello.txt", "hello from a driver that is not on any disk\n"); + await fs.writeFile("/docs/a.txt", "alpha\n"); + await fs.writeFile("/docs/b.txt", "bravo\n"); + await fs.writeFile( + "/numbers.txt", + `${Array.from({ length: 100 }, (_, i) => i + 1).join("\n")}\n`, + ); + // 3 MiB of deterministic bytes, for a byte-exactness check that is bigger + // than any single message the transport underneath will carry. + const big = Buffer.allocUnsafe(3 * 1024 * 1024); + for (let i = 0; i < big.length; i++) { + big[i] = (i * 31 + 7) & 0xff; + } + await fs.writeFile("/big.bin", big); + return driver; +} diff --git a/src/exec/demo-userns.ts b/src/exec/demo-userns.ts new file mode 100644 index 0000000..2f5649c --- /dev/null +++ b/src/exec/demo-userns.ts @@ -0,0 +1,33 @@ +/** + * The user-namespace mechanism, run against the shared demo tree. + * + * ```sh + * node src/exec/demo-userns.ts [command...] + * ``` + * + * A test bench rather than an entry point — `mountx/exec` is the entry point. + * It calls `execUserns()` by name on purpose, so that what it exercises is one + * *named* mechanism rather than whatever the picker would have chosen; that is + * how the measurements in `.agents/proot-plan.md` were taken. + */ + +import { createDemoDriver } from "./demo-driver.ts"; +import { execUserns } from "./userns.ts"; + +// `$MOUNTX_ROOT` rather than a hardcoded path: the relay sets it, and a `cd` +// that happens after the exec is the only kind that does not deadlock. +const command = + process.argv.length > 2 + ? process.argv.slice(2) + : [ + "sh", + "-c", + 'cd "$MOUNTX_ROOT" && ls -la && cat hello.txt && wc -c big.bin && sha256sum big.bin', + ]; + +const driver = await createDemoDriver(); +const result = await execUserns(driver, command, { debug: process.env.MOUNTX_DEBUG === "1" }); +process.stderr.write( + `\n[userns] mountpoint=${result.mountpoint} code=${result.code} signal=${result.signal}\n`, +); +process.exitCode = result.code ?? 1; diff --git a/src/exec/index.ts b/src/exec/index.ts new file mode 100644 index 0000000..4f4a7f2 --- /dev/null +++ b/src/exec/index.ts @@ -0,0 +1,257 @@ +/** + * `mountx/exec` — run a command with a driver grafted onto its filesystem view. + * + * ```ts + * import { exec } from "mountx/exec"; + * const ran = await exec(createMemoryDriver(), ["sh", "-c", "ls -la $MOUNTX_ROOT"]); + * ran.mechanism; // "userns" + * ran.code; // the command's exit status + * ``` + * + * Where `mountx/auto` attaches a driver to a *directory on the machine*, this + * attaches one to a *process tree*: the command and everything it spawns see + * the driver at `$MOUNTX_ROOT`, and nothing else on the host does. That is the + * `proot`-shaped question — "give this subprocess a filesystem" — and the + * answer here is a FUSE mount inside an unprivileged user namespace, which is + * not a mount anybody outside the process tree can see. + * + * | mechanism | what the child sees | what it needs | + * | --------- | --------------------------- | --------------------------------------- | + * | `userns` | FUSE, behind the kernel VFS | `/dev/fuse`, user namespaces, `unshare` | + * + * **Why `userns` and not something cleverer.** It is the cheapest correct thing + * by a wide margin: what the child sees genuinely *is* FUSE, with the kernel's + * own VFS in front of it, so it inherits the whole conformance column + * `src/fuse/` already passes, and it is blind to what the child is linked + * against — a static binary, a Go binary and a setuid binary all behave. It is + * simply a mount nobody outside the namespace can see: `/proc/self/mounts` on + * the host stays empty and the mount dies with the namespace. + * + * What it costs is that it *is* a kernel mount, so it needs `/dev/fuse` — which + * a locked-down container is exactly the sort of place to withhold, and which a + * user-namespace root cannot conjure (`mknod /dev/fuse c 10 229` answers + * `EPERM`, verified on `alpine:latest`). A second mechanism that needs no + * device node at all — a seccomp user-notification supervisor — was built and + * measured alongside this one and is not on this branch; it lives in the + * history of PR #9 on `pithings/mountx`, and `.agents/proot-plan.md` records + * what it showed. + * + * **This file is shaped for more than one mechanism, deliberately.** There is + * one today, and {@link ExecMechanism}, {@link ExecProbe.preference} and the + * discriminant on {@link ExecResult} are the seam a second arrives through + * without an API change: a caller narrowing on `ran.mechanism` or reading + * `probe.userns.reason` keeps working when the list grows. What is *not* here + * is an invented second entry to make the shape look busier than it is. + * + * **Deliberately outside `mountx/auto`.** `auto`'s whole contract is "hand back + * a mounted directory"; this hands back a child process's exit status and has + * no mountpoint to give anyone. Same reasoning that keeps `mountx/s3` out of + * it, arrived at from the other side: `probeTransports()` never mentions this, + * `mount()` never picks it, and importing `mountx/auto` loads none of it. + * + * **Three things it deliberately does not do**, all three lifted from + * `src/auto.ts` because the arguments are the same ones: + * + * - **No fallback after a failure.** {@link probeExec} decides once, from host + * facts; a mechanism that then fails reports its own error. With one mechanism + * there is nothing to fall back *to*, and the rule is written down anyway + * because it is the rule a second one arrives under: two mechanisms would not + * have the same semantics, and a command that has already run once may have + * had effects outside the driver. + * - **No probing when you name a mechanism.** `mechanism: "userns"` calls + * `execUserns()`, whose own errors are more specific than anything this file + * could say about it. + * - **No loading of what it does not use.** The mechanism arrives through + * `await import()`, so a probe that refuses never loads the FUSE session, and + * the probe itself reaches only `src/exec/probe.ts`, which imports `node:fs` + * and nothing else. + * + * The result is the mechanism's own result object with a `mechanism` + * discriminant defined on it — tagged, not wrapped, the way `mountx/auto` tags + * a mount — so narrowing on it reaches everything that mechanism reports. + */ + +import type { FsDriver } from "../types.ts"; +import { type UsernsExecProbe, usernsExecProbe } from "./probe.ts"; +import type { ExecUsernsOptions, ExecUsernsResult } from "./userns.ts"; + +export type { ExecPlatform, UsernsExecProbe } from "./probe.ts"; +export { execPlatform, usernsExecProbe } from "./probe.ts"; +export type { ExecUsernsOptions, ExecUsernsResult } from "./userns.ts"; + +/** + * The mechanisms {@link exec} can choose between. + * + * One of them today. It is a union rather than a string literal because it is + * the discriminant {@link ExecResult} narrows on, and widening a union is a + * change a caller's `switch` survives where replacing a bare type is not. + */ +export type ExecMechanism = "userns"; + +/** What this host can run a command with, and what {@link exec} would pick. */ +export interface ExecProbe { + /** The platform the probe was answered for. */ + platform: NodeJS.Platform; + /** What {@link exec} would use, or `undefined` if nothing works here. */ + chosen: ExecMechanism | undefined; + /** Preference order — the list `chosen` was picked from. */ + preference: readonly ExecMechanism[]; + /** The user-namespace mechanism's own verdict, reasons and all. */ + userns: UsernsExecProbe; + /** Why nothing can run, naming every mechanism. `undefined` when {@link chosen}. */ + reason: string | undefined; +} + +/** + * Options for the command, plus an escape hatch for the mechanism. + * + * Deliberately *not* the mechanism's option type re-exported under another + * name, for the reason `AutoMountOptions` is not one of the three transports': + * these are the options that mean the same thing however the driver reaches the + * command, and anything mechanism-specific goes in {@link ExecOptions.userns} — + * which is applied *after* the shared ones and therefore wins. + */ +export interface ExecOptions { + /** Which mechanism to use. Default `"auto"` — see {@link probeExec}. */ + mechanism?: ExecMechanism | "auto"; + /** + * Where the driver appears to the command, and the value of `$MOUNTX_ROOT`. + * + * For `userns` it is a **real directory** that gets a namespace-private FUSE + * mount on it, created if missing, and defaulting to a private temporary + * directory. Portable code should read `$MOUNTX_ROOT` from inside the command + * rather than assume the default. + */ + root?: string; + /** Working directory for the command. Never default to inside `root` — see below. */ + cwd?: string; + /** Environment for the command. Defaults to this process's. */ + env?: NodeJS.ProcessEnv; + /** Report the driver's own `ino` values instead of synthesising them. */ + useDriverIno?: boolean; + /** Log protocol traffic to stderr. */ + debug?: boolean; + /** Options for the user-namespace mechanism only. Applied after the shared ones. */ + userns?: ExecUsernsOptions; +} + +/** + * How a command ended, tagged with the mechanism that ran it. + * + * The tag is a discriminant, so narrowing on it gives the mechanism's full + * result — `ran.mechanism === "userns"` reaches `mountpoint`. A union of one + * arm is a union: adding an arm is what a second mechanism does here, and a + * caller that already narrows is a caller that already compiles. + */ +export type ExecResult = ExecUsernsResult & { readonly mechanism: "userns" }; + +/** + * What this host can run a command with, and what {@link exec} would choose. + * + * Cheap enough to call before deciding whether to offer this at all, and + * specific enough to print: when nothing works, `reason` names what each + * mechanism is missing rather than reporting the last failure. + * + * Synchronous, unlike `probeTransports()` — every fact here is a `node:fs` read, + * where FUSE's rootless probe has to load the native addon before it can answer. + * + * `platform` exists to be overridden in tests; leave it alone otherwise. + */ +export function probeExec(platform: NodeJS.Platform = process.platform): ExecProbe { + const userns = usernsExecProbe(platform); + // One order on every host, because off Linux nothing here can work and there + // is nothing for a second order to say. The list is what a second mechanism + // is appended to, and the rule it is appended under is that a real filesystem + // outranks an approximation of one. + const preference: readonly ExecMechanism[] = ["userns"]; + const probes = { userns }; + const chosen = preference.find((mechanism) => probes[mechanism].usable); + return { + platform, + chosen, + preference, + userns, + reason: + chosen === undefined + ? `no mechanism can run a command with a driver on this host — user namespace: ` + + `${userns.reason}` + : undefined, + }; +} + +/** The shared options, in the shape the mechanism wants them. */ +function shared(options: ExecOptions): Pick { + return { + cwd: options.cwd, + env: options.env, + useDriverIno: options.useDriverIno, + debug: options.debug, + }; +} + +/** + * Stamp the mechanism onto the result the mechanism just returned. + * + * The result object itself is tagged rather than wrapped, so every + * mechanism-specific member keeps working on the thing the caller holds. Same + * shape as `src/auto.ts`'s `tag()`, and idempotent for the same reason. + */ +function tag( + result: R, + mechanism: M, +): R & { readonly mechanism: M } { + if (!("mechanism" in result)) { + Object.defineProperty(result, "mechanism", { value: mechanism, enumerable: true }); + } + return result as R & { readonly mechanism: M }; +} + +/** + * Run `argv` with `driver` grafted onto its filesystem view. + * + * Resolves when the command exits, with that command's status — a command that + * fails is not an error here, exactly as it is not for `child_process`. An + * error is thrown only when the *mechanism* could not be set up, and it names + * the missing piece. + * + * The command finds the driver at `$MOUNTX_ROOT`, which the mechanism sets in + * its environment. Read it rather than hardcoding {@link ExecOptions.root}: it + * is the one spelling that is right with the default (a private temporary + * directory) in play. + * + * **Do not point `cwd` inside the root.** That is a deadlock, not a slow path: + * `uv_spawn` blocks the thread that answers FUSE requests until the child + * execs, and a child whose first act is a `chdir` into the mount waits for a + * reply only that thread can send. `sh -c 'cd "$MOUNTX_ROOT" && …'` does the + * same thing safely, because the `cd` happens after the exec. The same rule + * covers a command binary that *lives* on the driver. + */ +export async function exec( + driver: FsDriver, + argv: readonly string[], + options: ExecOptions = {}, +): Promise { + if (argv.length === 0) { + throw new Error("mountx: exec needs a command to run"); + } + const requested = options.mechanism ?? "auto"; + let mechanism: ExecMechanism; + if (requested === "auto") { + const probe = probeExec(); + if (probe.chosen === undefined) { + throw new Error(`mountx: ${probe.reason}`); + } + mechanism = probe.chosen; + } else { + mechanism = requested; + } + const { execUserns } = await import("./userns.ts"); + return tag( + await execUserns(driver, argv, { + ...shared(options), + mountpoint: options.root, + ...options.userns, + }), + mechanism, + ); +} diff --git a/src/exec/probe.ts b/src/exec/probe.ts new file mode 100644 index 0000000..522155f --- /dev/null +++ b/src/exec/probe.ts @@ -0,0 +1,258 @@ +/** + * Can this host graft a driver onto a subprocess's filesystem view, and if not, + * which piece is missing? + * + * Split out of the mechanism for the reason `src/nfs/probe.ts` and + * `src/9p/probe.ts` are split out of their transports: asking should cost + * nothing. `userns.ts` reaches the whole FUSE session — a lot of module graph + * to answer a question that reads four files under `/proc` and one directory of + * `$PATH`. So this file imports `node:fs` and nothing else, and + * `src/exec/index.ts` decides from it before loading the mechanism. + * + * **Linux only.** A user namespace is a Linux object; macOS has none, and + * `DYLD_INSERT_LIBRARIES` — the one interception route it does have — is + * blocked by SIP for exactly the system binaries anyone would want to run. + * macOS stays NFS-mount territory. + * + * **Root is needed nowhere.** That is the whole point: an unprivileged user + * namespace is unprivileged by construction, and inside it the relay is uid 0 + * with `CAP_SYS_ADMIN` without anything on the host having granted it. + */ + +import * as fs from "node:fs"; + +/** + * The verdict for `probe("linux")` asked from somewhere that is not Linux. + * + * Every fact below `platform` is a file this process reads about *itself*, so a + * `platform` override — which exists for the tests — takes them all away at + * once. Answering `usable` on the strength of a check that never ran would be + * the one lie a probe must not tell, so the missing evidence is the refusal. + */ +const NOT_THIS_HOST = + "this probe was asked about Linux from a host that is not Linux, so nothing it reads — " + + "/dev/fuse, /proc, $PATH — describes the machine in question"; + +/** The one host this mechanism runs on. */ +export type ExecPlatform = "linux"; + +/** `process.platform`, narrowed to the host that can run a command, or `undefined`. */ +export function execPlatform(platform: NodeJS.Platform): ExecPlatform | undefined { + return platform === "linux" ? "linux" : undefined; +} + +/** What this host can and cannot do about the user-namespace mechanism. */ +export interface UsernsExecProbe { + /** Can a command be run with a namespace-private FUSE mount here? */ + usable: boolean; + /** `"linux"`, or `undefined` on a host with no user namespaces. */ + platform: ExecPlatform | undefined; + /** Is `fuse` listed in `/proc/filesystems`? */ + kernel: boolean; + /** Does `/dev/fuse` exist *and* open for reading and writing? */ + device: boolean; + /** Can this process create a user namespace? */ + userns: boolean; + /** The `unshare(1)` on `$PATH`, or `undefined`. */ + unshare: string | undefined; + /** Everything that is missing, in a sentence. `undefined` when {@link usable}. */ + reason: string | undefined; +} + +/** The contents of `path`, trimmed, or `undefined` if it could not be read. */ +function readQuietly(path: string): string | undefined { + try { + return fs.readFileSync(path, "utf8").trim(); + } catch { + return undefined; + } +} + +/** Is `name` one of the filesystems the kernel knows, per `/proc/filesystems`? */ +function hasFilesystem(name: string): boolean { + const table = readQuietly("/proc/filesystems"); + if (table === undefined) { + return false; + } + // Each line is `nodev\tfuse` or `\text4`: the name is the last + // whitespace-separated field, and matching it whole is what keeps `fuse` from + // being found inside `fuseblk`. + return table.split("\n").some((line) => line.trim().split(/\s+/).pop() === name); +} + +/** + * Why `/dev/fuse` cannot be used from here, or `undefined` if it can. + * + * Opened rather than stat'd, because the two failures a caller can act on are + * different sentences and only an `open(2)` tells them apart. + */ +function deviceRefusal(): string | undefined { + let device: number; + try { + device = fs.openSync("/dev/fuse", fs.constants.O_RDWR); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENXIO" || code === "ENODEV") { + return ( + "there is no usable /dev/fuse on this host — the fuse module is not loaded, or this " + + "container was never given the device (docker/podman: --device /dev/fuse). A user " + + "namespace cannot conjure one either: `mknod /dev/fuse c 10 229` as namespace-root " + + "answers EPERM (verified on alpine:latest), so the node has to come from outside" + ); + } + if (code === "EACCES" || code === "EPERM") { + return ( + "/dev/fuse exists but this process cannot open it — and entering a user namespace does " + + "not help, because the namespace maps only your own uid: the permission check the relay " + + "makes inside it is the one that just failed out here, and the namespace's " + + "CAP_DAC_OVERRIDE does not reach a device node owned by a uid it does not map" + ); + } + return `/dev/fuse could not be opened (${code ?? "unknown error"})`; + } + fs.closeSync(device); + return undefined; +} + +/** + * Why this process cannot create a user namespace, or `undefined` if it can. + * + * Three sysctls, each of which turns unprivileged namespace creation off in a + * different distribution's idiom, plus the kernel-level "compiled without + * namespaces at all". Root is exempt from the last two, which gate the + * *unprivileged* path only. + */ +function usernsRefusal(root: boolean): string | undefined { + if (!fs.existsSync("/proc/self/ns/user")) { + return ( + "this kernel has no user namespaces at all (no /proc/self/ns/user), so there is no " + + "namespace to mount FUSE inside" + ); + } + // Present on every namespace-capable kernel; zero is an administrator turning + // the whole facility off, root included. + const max = readQuietly("/proc/sys/user/max_user_namespaces"); + if (max === "0") { + return ( + "user namespaces are disabled on this host (/proc/sys/user/max_user_namespaces is 0) — " + + "raise it with `sysctl -w user.max_user_namespaces=`" + ); + } + if (root) { + return undefined; + } + // Debian's long-standing knob, and Ubuntu's newer AppArmor one. Both gate the + // unprivileged path only, which is why they are read after the root check. + if (readQuietly("/proc/sys/kernel/unprivileged_userns_clone") === "0") { + return ( + "unprivileged user namespaces are disabled " + + "(/proc/sys/kernel/unprivileged_userns_clone is 0) — enable them with `sysctl -w " + + "kernel.unprivileged_userns_clone=1`, or run as root" + ); + } + if (readQuietly("/proc/sys/kernel/apparmor_restrict_unprivileged_userns") === "1") { + return ( + "AppArmor restricts unprivileged user namespaces on this host " + + "(/proc/sys/kernel/apparmor_restrict_unprivileged_userns is 1, the Ubuntu 23.10+ " + + "default) — an unconfined program gets EPERM from unshare(CLONE_NEWUSER) with no other " + + "clue. Turn it off with `sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, or " + + "install a profile for this program" + ); + } + return undefined; +} + +/** + * The named executable on `$PATH`, or `undefined`. + * + * `$PATH` is split on `:` and joined with `/` rather than through `node:path`, + * which would be the only other import in this file: the mechanism is Linux + * only, and on Linux those two characters are the whole of the question. + */ +function onPath(name: string): string | undefined { + for (const directory of (process.env.PATH ?? "").split(":")) { + if (directory === "") { + continue; + } + const candidate = `${directory}/${name}`; + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // Not here, or here and not executable — either way, keep looking. + } + } + return undefined; +} + +/** + * Can this host run a command inside an unprivileged user namespace with the + * driver mounted over FUSE? + * + * Synchronous and cheap, so a test can gate itself on it and `exec()` can refuse + * with the missing piece named rather than with the raw `ENOENT` from + * `open("/dev/fuse")` that a relay three processes away would otherwise report. + * + * **What makes it usable:** Linux, a `/dev/fuse` this process can open, `fuse` + * in `/proc/filesystems`, user namespaces this process may create, and + * `unshare(1)` on `$PATH` (busybox's applet counts — see `src/exec/userns.ts` + * on why the flags are spelled short). + * + * The device is opened *before* `/proc/filesystems` is read, deliberately: + * `/dev/fuse` is a misc device, so opening it is what triggers the module + * autoload that puts `fuse` in that table in the first place. Reading the table + * first would refuse a host that was one `open(2)` away from working. + * + * `platform` exists to be overridden in tests; leave it alone otherwise. + */ +export function usernsExecProbe(platform: NodeJS.Platform = process.platform): UsernsExecProbe { + const host = execPlatform(platform); + // Only ask this host about itself: with a `platform` override in play the + // files below describe the machine the test runs on rather than the one it is + // asking about, and reporting those would be a lie in both directions. + const linux = host !== undefined && process.platform === "linux"; + const missing: string[] = []; + if (host === undefined) { + missing.push( + `this is ${platform}; a namespace-private FUSE mount needs Linux — no other kernel ` + + `has user namespaces, and macFUSE speaks a dialect mountx does not implement`, + ); + } + const device = linux ? deviceRefusal() : NOT_THIS_HOST; + const kernel = linux && hasFilesystem("fuse"); + const namespaces = linux ? usernsRefusal((process.getuid?.() ?? -1) === 0) : NOT_THIS_HOST; + const unshare = linux ? onPath("unshare") : undefined; + if (host !== undefined && !linux) { + missing.push(NOT_THIS_HOST); + } + if (linux) { + if (device !== undefined) { + missing.push(device); + } + if (device === undefined && !kernel) { + missing.push( + "the kernel has no `fuse` in /proc/filesystems even after /dev/fuse opened, which is " + + "a kernel built without CONFIG_FUSE_FS", + ); + } + if (namespaces !== undefined) { + missing.push(namespaces); + } + if (unshare === undefined) { + missing.push( + "there is no `unshare` on $PATH — util-linux and busybox both provide one, and the " + + "namespace can only be entered by a child process (Node is never single-threaded, " + + "and unshare(CLONE_NEWUSER) refuses a threaded caller)", + ); + } + } + return { + usable: missing.length === 0, + platform: host, + kernel, + device: device === undefined, + userns: namespaces === undefined, + unshare, + reason: missing.length === 0 ? undefined : missing.join("; "), + }; +} diff --git a/src/exec/userns-relay.ts b/src/exec/userns-relay.ts new file mode 100644 index 0000000..72c0499 --- /dev/null +++ b/src/exec/userns-relay.ts @@ -0,0 +1,224 @@ +/** + * The in-namespace half of `execUserns()`. + * + * This file is the only thing that runs *inside* the user+mount namespace. It + * exists because of one kernel rule: `unshare(CLONE_NEWUSER)` requires a + * single-threaded caller, and Node is never single-threaded (the libuv + * threadpool and V8's platform threads are up before any user code runs), so + * `EINVAL` is the only answer a mountx process could ever get from unsharing + * itself. `setns(2)` into a user namespace has the same rule. The namespace + * can therefore only ever be entered by a *child*, which means the process + * holding `/dev/fuse` is never the process holding the driver. + * + * So this helper holds the device and nothing else. It opens `/dev/fuse`, + * spawns `mount(8)` — inside the namespace `getuid()` is 0, so this is the + * ordinary root mount path with no `fusermount3` and no addon anywhere near it + * — and then pumps raw FUSE traffic over a unix socket to the parent, which + * runs the real {@link FuseSession} against the real driver. + * + * **Framing costs nothing.** Every FUSE message begins with its own total + * length in a little-endian `u32` (`fuse_in_header.len`, `fuse_out_header.len`), + * so a stream socket carries them with no envelope of our own. The one rule + * that does not survive the socket is that a reply must reach the device in a + * single `write(2)`; this side reassembles whole messages before writing, which + * is what the `#pending` buffer is for. + * + * **How a failure in here becomes an error out there.** Everything this process + * can fail at happens after `execUserns()` has already handed control to + * `unshare(1)`, so an exit status is the only channel back — and an exit status + * is exactly what the *command* is going to use too. Reporting `mount(8) never + * came up` as "the command exited 70" would be indistinguishable from a command + * that exited 70. So {@link fail} also writes its message to the file named by + * `$MOUNTX_RELAY_STATUS`, which the parent reads once the child is gone and + * turns into a thrown error; a run that never wrote the file ended for the + * command's own reasons. + * + * Usage: `node userns-relay.ts -- [args...]` + */ + +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as net from "node:net"; + +/** `readBufferSize()`'s answer for the default `maxWrite`, spelled out here so + * the relay has no imports from `src/`. 1 MiB of payload plus a page of + * headers — negotiation can only ever agree to less. */ +const READ_BUFFER = 1024 * 1024 + 4096; + +/** Length prefix width: the `len` field at the head of every FUSE message. */ +const LEN_SIZE = 4; + +/** Where the parent reads a failure from. Absent when nobody is listening. */ +const statusPath = process.env.MOUNTX_RELAY_STATUS; + +function fail(message: string): never { + process.stderr.write(`mountx-relay: ${message}\n`); + if (statusPath !== undefined) { + // Best effort by design: the parent falls back to the exit status, and a + // relay that cannot write a file in a directory the parent just made has + // worse problems than the message it was trying to leave. + try { + fs.writeFileSync(statusPath, `${message}\n`); + } catch {} + } + process.exit(70); +} + +const argv = process.argv.slice(2); +const separator = argv.indexOf("--"); +if (separator < 0 || separator < 2) { + fail("usage: userns-relay -- [args...]"); +} +const socketPath = argv[0]!; +const mountpoint = argv[1]!; +const mountOptions = argv[2] === "--" ? "" : argv[2]!; +const command = argv.slice(separator + 1); +if (command.length === 0) { + fail("no command to run"); +} + +// Inside the namespace this process is uid 0 with a full capability set, so +// this is `mount(2)` by the ordinary route. Nothing here is setuid. +const uid = process.getuid?.() ?? -1; +if (uid !== 0) { + fail(`expected to be uid 0 inside the namespace, am ${uid}`); +} + +let device: number; +try { + device = fs.openSync("/dev/fuse", fs.constants.O_RDWR); +} catch (error) { + fail(`could not open /dev/fuse inside the namespace: ${(error as Error).message}`); +} + +const socket = net.connect(socketPath); +socket.on("error", (error) => fail(`socket: ${error.message}`)); +// The parent going away is not survivable: every request from here on would +// park forever in `fuse_get_req` with nobody to answer it, and this process +// would be left orphaned holding a wedged mount. Witnessed once during the +// spike, which is why it is handled rather than assumed away. Closing the +// device first is what aborts the connection so anything already blocked in +// the kernel gets an error instead of waiting. +socket.on("close", () => { + try { + fs.closeSync(device); + } catch {} + process.exit(75); +}); + +socket.on("connect", () => { + const rootMode = fs.statSync(mountpoint).mode; + const options = [ + // The fd has to land in the child at *its own* number, which is why the + // device is handed over as stdio slot 3 below and named as `fd=3` here. + "fd=3", + `rootmode=${rootMode.toString(8)}`, + `user_id=${uid}`, + `group_id=${process.getgid?.() ?? 0}`, + ...(mountOptions === "" ? [] : [mountOptions]), + ].join(","); + // `-i` so `mount(8)` does not hand off to `/sbin/mount.fuse`, `--` so a + // source beginning with a dash stays a source. Same reasoning as + // `src/fuse/mount.ts`; this is that call with the fd renumbered. + const mounter = spawn("mount", ["-i", "-t", "fuse", "-o", options, "--", "mountx", mountpoint], { + stdio: ["ignore", "inherit", "inherit", device], + }); + mounter.on("error", (error) => fail(`could not run mount(8): ${error.message}`)); + mounter.on("exit", (code) => { + if (code !== 0) { + fail(`mount(8) exited ${code} — the namespace mount never came up`); + } + pump(); + run(); + }); +}); + +/** Device → socket. One `read(2)` is exactly one message, forwarded verbatim. */ +function pump(): void { + const buffer = Buffer.allocUnsafe(READ_BUFFER); + const arm = (): void => { + fs.read(device, buffer, 0, buffer.length, null, (error, bytesRead) => { + if (error !== null) { + // ENODEV is the unmount signal: the connection is gone. + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENODEV" || code === "EBADF") { + socket.end(); + return; + } + if (code === "EINTR" || code === "EAGAIN") { + arm(); + return; + } + fail(`reading /dev/fuse: ${error.message}`); + } + if (bytesRead > 0) { + socket.write(Buffer.from(buffer.subarray(0, bytesRead))); + } + arm(); + }); + }; + arm(); +} + +// Socket → device. Reassembled to whole messages, because the device rejects a +// reply delivered in pieces. +let pending = Buffer.alloc(0); +socket.on("data", (chunk: Buffer) => { + pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]); + while (pending.length >= LEN_SIZE) { + const length = pending.readUInt32LE(0); + if (length < LEN_SIZE || pending.length < length) { + break; + } + const message = pending.subarray(0, length); + try { + fs.writeSync(device, message, 0, length); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + // ENOENT here means the kernel already gave up on that request — an + // interrupted syscall, not our problem. Anything else is. + if (code !== "ENOENT" && code !== "ENODEV") { + process.stderr.write(`mountx-relay: writing /dev/fuse: ${(error as Error).message}\n`); + } + } + pending = pending.subarray(length); + } +}); + +/** Run the command the caller actually wanted, then take the mount down. */ +function run(): void { + // **`cwd` is deliberately not the mountpoint.** Witnessed: setting it wedges + // this process in `D` state at `fuse_get_req` and never comes back. It is the + // spawn hazard `src/fuse/mount.ts` and `src/9p/mount.ts` both document, met + // here from the inside — `uv_spawn` blocks the calling thread until the child + // execs, the child's first act is to `chdir` into the mount, and the reply + // that would unblock it can only come from the thread that is blocked. The + // mountpoint is handed over as an environment variable instead, so the `cd` + // happens *after* the exec, in a process that is not the one pumping. + // + // This is the same rule for the command binary itself: a command that lives + // on the mount deadlocks here and no amount of care on this side fixes it. + // Only a relay whose pump is not on the spawning thread would. + // `MOUNTX_RELAY_STATUS` is this process's private channel back to the parent + // and means nothing to the command, so it does not travel any further. + const env: NodeJS.ProcessEnv = { ...process.env, MOUNTX_ROOT: mountpoint }; + delete env.MOUNTX_RELAY_STATUS; + const child = spawn(command[0]!, command.slice(1), { stdio: "inherit", env }); + child.on("error", (error) => fail(`could not run ${command[0]}: ${error.message}`)); + child.on("exit", (code, signal) => { + // `umount` here is uid 0 in the namespace, so it needs no helper either. + // Failure is not worth reporting: this namespace is about to cease to + // exist, and with it the mount. + spawn("umount", [mountpoint], { stdio: "ignore" }).on("exit", () => { + try { + fs.closeSync(device); + } catch {} + socket.end(); + process.exitCode = signal === null ? (code ?? 0) : 128 + 1; + // The read loop holds a threadpool thread; nothing here can be exited + // out of politely once it is parked, so this is the one place a hard + // exit is right — the namespace and everything in it goes with us. + process.exit(process.exitCode); + }); + }); +} diff --git a/src/exec/userns.ts b/src/exec/userns.ts new file mode 100644 index 0000000..a211548 --- /dev/null +++ b/src/exec/userns.ts @@ -0,0 +1,360 @@ +/** + * `execUserns()` — run a command with a driver mounted over FUSE inside an + * unprivileged user namespace, visible to that command's process tree and to + * nothing else on the machine. + * + * This is the mechanism {@link import("./index.ts").exec} runs a command with, + * and it is not an approximation of a filesystem: what + * the child sees *is* FUSE, with the kernel's own VFS in front of it, so every + * POSIX guarantee `src/fuse/` already passes conformance on holds verbatim. It + * also runs a static binary, a Go binary and a setuid binary the same as any + * other, because nothing here depends on what the child is linked against. + * + * What it gives up is that it *is* a real kernel mount. It is simply a mount + * nobody outside the namespace can see — `/proc/self/mounts` on the host stays + * empty, and the mount dies with the namespace. That is the property that made + * a user-namespace mode not worth shipping back when the goal was "mount a + * directory for the machine" (see the roadmap's rootless-FUSE entry); for an + * `exec()`-shaped API the tradeoff inverts, and invisible is the point. + * + * **Why there is a helper process at all.** `unshare(CLONE_NEWUSER)` demands a + * single-threaded caller and Node is never single-threaded, so a mountx process + * cannot enter the namespace it needs — not with `unshare(2)`, and not with + * `setns(2)`, which has the same rule. The namespace is therefore entered by a + * child, `/dev/fuse` is opened in there, and the traffic comes back out over a + * unix socket to the session here. That split is not a workaround; it is the + * shape any version of this has to take, and it is the "relay mode" the roadmap + * defers, arrived at from the other direction. + * + * **Nothing here is privileged.** Inside the namespace the relay is uid 0 with + * `CAP_SYS_ADMIN`, so it takes the ordinary root mount path: no `fusermount3`, + * no setuid bit, and no native addon. On a host with no `fuse3` package + * installed at all — this project's dev host, see `.agents/environment.md` — + * this is the only FUSE route that works. + * + * ```ts + * const ran = await execUserns(createMemoryDriver(), ["sh", "-c", "ls -la $MOUNTX_ROOT"]); + * ran.code; // the command's exit status + * ``` + */ + +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm, stat } from "node:fs/promises"; +import * as net from "node:net"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { FuseSession, type FuseSessionOptions } from "../fuse/session.ts"; +import type { FsDriver } from "../types.ts"; +import { usernsExecProbe } from "./probe.ts"; + +/** The `len` field every FUSE message begins with. */ +const LEN_SIZE = 4; + +/** + * The `-o` options the mount gets when the caller names none: **none**, and + * `default_permissions` in particular. + * + * It is the option a FUSE mount usually wants, and it is wrong here, for a + * reason that only exists inside a user namespace. `default_permissions` asks + * the *kernel* to check the driver's `uid`/`gid`/`mode` against the caller's + * credentials — and in here those two are not the same identity space. + * `unshare -r` maps exactly one uid, yours, onto 0; a driver reporting your + * real uid (which every driver in this repository does, since that is what + * `process.getuid()` says on the serving side) is therefore reporting an + * identity the namespace does not map, which the kernel renders as `nobody`. + * Witnessed: with `default_permissions` on, a `mode` 0755 root directory owned + * by `nobody` refuses every write from the one process that is meant to have + * it, and namespace-root's `CAP_DAC_OVERRIDE` does not rescue it because that + * capability does not reach a file owned by an unmapped uid. + * + * So permission checking stays with the driver, where it can see who it is + * serving. Nothing is lost by it: the mount carries no `allow_other`, so the + * only process that can reach it is the one this call created it for. + */ +const DEFAULT_MOUNT_OPTIONS: readonly string[] = []; + +/** + * Where the relay is, relative to this module, in each layout it can be in. + * + * The relay is spawned rather than imported, so it has to be a file on disk + * with a path — which makes it the one thing here that has to survive the + * build as itself. It is a build entry for exactly that reason, landing at + * `dist/exec/userns-relay.mjs`; what moves underneath it is *this* file: + * + * - `src/exec/userns.ts` — the source tree, where the relay is a sibling `.ts` + * and Node's own type stripping runs it directly. + * - `dist/_chunks/userns.mjs` — the built package. `src/exec/index.ts` reaches + * this module through `await import()`, which is the whole point of the + * mechanism split, and obuild answers a dynamic import with a *chunk* rather + * than by inlining it. So the sibling relationship does not survive, and + * `dist/exec/` is one directory over. + * + * Both are checked, cheaply, once per call. A third layout would announce + * itself as the thrown error below rather than as a mystery `ENOENT` from + * `spawn`, which is what this is for. + */ +const RELAY_CANDIDATES = [ + "userns-relay.ts", + "userns-relay.mjs", + "../exec/userns-relay.mjs", +] as const; + +/** The relay on disk, in whichever layout this module was loaded from. */ +function relayPath(): string { + for (const name of RELAY_CANDIDATES) { + const candidate = new URL(name, import.meta.url).pathname; + if (existsSync(candidate)) { + return candidate; + } + } + throw new Error( + "mountx: the userns relay is missing — `userns-relay` is spawned rather than imported, so " + + "it has to exist as a file next to this module or under a sibling `exec/` directory, and " + + "an install or bundle that dropped it cannot run a command in a namespace", + ); +} + +export interface ExecUsernsOptions extends FuseSessionOptions { + /** + * Where the driver appears *inside the namespace*, and the value of + * `$MOUNTX_ROOT`. Defaults to a private temporary directory. + * + * The path has to exist on the real filesystem — a mount namespace is a copy + * of the parent's mount table, not an empty one — so a path that does not is + * created here, recursively. The mount made on it is visible only to the + * child tree either way. + */ + mountpoint?: string; + /** + * Working directory for the command. Defaults to this process's. + * + * **Never inside {@link ExecUsernsOptions.mountpoint}** — see + * {@link cwdRefusal}, which refuses that rather than letting it deadlock. + */ + cwd?: string; + /** Environment for the command. Defaults to this process's. */ + env?: NodeJS.ProcessEnv; + /** + * `-o` options passed through to `mount(8)` inside the namespace. Default + * none — see {@link DEFAULT_MOUNT_OPTIONS} for why `default_permissions` is + * not among them. + */ + mountOptions?: string[]; +} + +export interface ExecUsernsResult { + /** The command's exit status, or `null` if a signal ended it. */ + code: number | null; + /** The signal that ended the command, if one did. */ + signal: NodeJS.Signals | null; + /** Where the driver was mounted inside the namespace, and `$MOUNTX_ROOT`. */ + mountpoint: string; +} + +/** + * Why this `cwd` cannot be used with this mountpoint, or `undefined` if it can. + * + * The one refusal in this file that is about the *request* rather than the + * host, and the reason it exists is witnessed rather than theoretical: setting + * the child's `cwd` to the mountpoint wedges the relay in `D` state at + * `fuse_get_req` permanently. It is the spawn hazard `src/fuse/mount.ts` and + * `src/9p/mount.ts` both document, met from the inside — `uv_spawn` blocks the + * calling thread until the child execs, the child's first act is a `chdir` into + * the mount, and the reply that would unblock it can only come from the thread + * that is blocked. Nothing on this side can recover from it, so it is refused + * up front, with the spelling that works named in the message. + * + * Pure and exported for the Tier-0 test: the alternative way to check this + * costs a hung process. + */ +export function cwdRefusal(cwd: string, mountpoint: string): string | undefined { + const inside = resolve(cwd); + const root = resolve(mountpoint); + if (inside !== root && !inside.startsWith(`${root}/`)) { + return undefined; + } + return ( + `cwd ${inside} is inside the mountpoint ${root}, which deadlocks rather than failing: ` + + `uv_spawn blocks the thread serving FUSE until the child execs, and a child whose first ` + + `act is a chdir into the mount waits for a reply only that thread can send. Run the ` + + `command as \`sh -c 'cd "$MOUNTX_ROOT" && …'\` instead — a cd after the exec is safe` + ); +} + +/** + * Run `argv` with `driver` mounted at `options.mountpoint`, visible to that + * command and everything it spawns and to nothing else on the machine. + * + * Resolves when the command exits, with that command's status; a command that + * fails is not an error here. An error is thrown when the *mechanism* could not + * be set up — a host that cannot do this (see `usernsExecProbe()`), a request + * that cannot work ({@link cwdRefusal}), or a relay that failed before the + * command ran, whose own message is what surfaces. + * + * Needs no root, no `fusermount3` and no native addon. + */ +export async function execUserns( + driver: FsDriver, + argv: readonly string[], + options: ExecUsernsOptions = {}, +): Promise { + if (argv.length === 0) { + throw new Error("mountx: execUserns needs a command to run"); + } + // The whole host verdict up front, in one sentence naming every missing + // piece, rather than an ENOENT from an `open("/dev/fuse")` three processes + // away that nothing here would be able to explain by the time it arrived. + const probe = usernsExecProbe(); + if (!probe.usable) { + throw new Error(`mountx: cannot run a command in a user namespace here — ${probe.reason}`); + } + const relay = relayPath(); + + const scratch = await mkdtemp(resolve(tmpdir(), "mountx-exec-")); + const socketPath = resolve(scratch, "relay.sock"); + const statusPath = resolve(scratch, "relay.status"); + const mountpoint = + options.mountpoint === undefined ? resolve(scratch, "mnt") : resolve(options.mountpoint); + + let session: FuseSession | undefined; + let server: net.Server | undefined; + try { + await prepareMountpoint(mountpoint); + const cwd = resolve(options.cwd ?? process.cwd()); + const refusal = cwdRefusal(cwd, mountpoint); + if (refusal !== undefined) { + throw new Error(`mountx: ${refusal}`); + } + + session = new FuseSession(driver, options); + const attachedSession = session; + server = net.createServer(); + /** Resolves once the relay has connected and the session is wired to it. */ + const attached = new Promise((resolveAttached) => { + server!.on("connection", (socket) => { + attach(attachedSession, socket); + resolveAttached(); + }); + }); + await new Promise((resolveListen, rejectListen) => { + server!.once("error", rejectListen); + server!.listen(socketPath, resolveListen); + }); + + const relayArgs = [ + socketPath, + mountpoint, + (options.mountOptions ?? [...DEFAULT_MOUNT_OPTIONS]).join(","), + "--", + ...argv, + ]; + const child = spawn( + probe.unshare!, + // Short flags on purpose. `-U -r -m` mean the same thing to util-linux's + // `unshare(1)` and to busybox's applet, but the long spelling does not: + // busybox 1.37 has `-r` and no `--map-root-user` at all, so the long form + // fails outright on Alpine and anything else that is busybox-only. + // `--propagation` is the one long option both do accept. + ["-U", "-r", "-m", "--propagation", "private", process.execPath, relay, ...relayArgs], + { + stdio: "inherit", + cwd, + // `MOUNTX_RELAY_STATUS` is how a relay failure reaches this process as + // an error instead of masquerading as the command's own exit status. + // The relay strips it back out of what the command sees. + env: { ...(options.env ?? process.env), MOUNTX_RELAY_STATUS: statusPath }, + }, + ); + const exited = new Promise((resolveExit, rejectExit) => { + child.on("error", (error) => + rejectExit( + new Error(`mountx: could not run ${probe.unshare} — ${(error as Error).message}`, { + cause: error, + }), + ), + ); + child.on("exit", (code, signal) => resolveExit({ code, signal, mountpoint })); + }); + // If the relay dies before connecting, `exited` settles first and nothing + // waits forever on a handshake that is not coming. + await Promise.race([attached, exited]); + const result = await exited; + const failure = statusMessage(statusPath); + if (failure !== undefined) { + throw new Error(`mountx: ${failure}`); + } + return result; + } finally { + await session?.destroy().catch(() => {}); + if (server !== undefined) { + await new Promise((resolveClose) => server!.close(() => resolveClose())); + } + await rm(scratch, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * Make sure `mountpoint` is a directory, creating it if it is not there. + * + * A missing one is the ordinary case (it is the default, in a directory this + * call just made), and a non-directory is a mistake worth naming: `mount(8)` + * inside the namespace would answer `not a directory` from three processes + * away, where the caller cannot see which path it meant. + */ +async function prepareMountpoint(mountpoint: string): Promise { + try { + const stats = await stat(mountpoint); + if (!stats.isDirectory()) { + throw new Error(`mountx: mountpoint ${mountpoint} exists and is not a directory`); + } + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + await mkdir(mountpoint, { recursive: true }); +} + +/** What the relay said before giving up, or `undefined` if it never did. */ +function statusMessage(path: string): string | undefined { + try { + return readFileSync(path, "utf8").trim() || undefined; + } catch { + return undefined; + } +} + +/** + * Drive a {@link FuseSession} over a stream instead of over `/dev/fuse`. + * + * The zero-copy contract still applies and is still met the same way: each + * whole message is handed to `handleMessage` without awaiting it, and the + * session copies what it retains before its first `await`. The one difference + * from the device is that `pending` here owns its bytes — a socket chunk is + * already a fresh buffer — so slicing a message out of it is safe. + */ +function attach(session: FuseSession, socket: net.Socket): void { + let pending = Buffer.alloc(0); + socket.on("data", (chunk: Buffer) => { + pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]); + while (pending.length >= LEN_SIZE) { + const length = pending.readUInt32LE(0); + if (length < LEN_SIZE || pending.length < length) { + break; + } + const message = pending.subarray(0, length); + pending = pending.subarray(length); + void session + .handleMessage(message) + .then((reply) => { + if (reply !== null && !socket.destroyed) { + socket.write(reply); + } + }) + .catch(() => {}); + } + }); + socket.on("error", () => {}); +} From f5e64a58bc6e407f3420b055bf5ce1db53ca541b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 29 Jul 2026 13:06:01 +0000 Subject: [PATCH 2/8] test(exec): the choice at Tier 0, a real namespace at Tier 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `strategy.test.ts` is Tier 0 for what `mountx/exec` decides — the preference order, the reason the mechanism is ruled out (darwin and win32 answered from any host through the `platform` override, including the refusal to answer for Linux from a host that cannot read Linux's files), the named-mechanism path that skips the picker's probe, and `cwdRefusal()`, whose only other way of being checked costs a hung process. `userns.test.ts` is Tier 2 and runs under `pnpm test:rootless`, gated on `usernsExecProbe().usable` and the same raised-threadpool rule the FUSE rootless file uses. It does not re-test the filesystem — what the command sees is FUSE, which has its own conformance column — only what is different: the driver at `$MOUNTX_ROOT`, writes landing in the driver, the mount staying out of the host's `/proc/self/mounts`, the command's own status surviving, and a relay failure arriving as an error rather than as a plausible exit status. The write case asserts by reading the driver back rather than on the command's exit status, because an earlier version of this work reported success while discarding every write. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- test/exec/strategy.test.ts | 140 ++++++++++++++++++++++++ test/exec/userns.test.ts | 214 +++++++++++++++++++++++++++++++++++++ 3 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 test/exec/strategy.test.ts create mode 100644 test/exec/userns.test.ts diff --git a/package.json b/package.json index 5325ce5..5d5c776 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "test:nfs:mount": "sh test/root.sh test/nfs/mount.test.ts", "test:9p:mount": "sh test/root.sh test/9p/mount.test.ts", "test:pjdfstest": "sh test/pjdfstest/run.sh", - "test:rootless": "sh test/rootless.sh test/fuse/mount-rootless.test.ts test/auto-mount.test.ts test/nfs/mount.test.ts", + "test:rootless": "sh test/rootless.sh test/fuse/mount-rootless.test.ts test/auto-mount.test.ts test/nfs/mount.test.ts test/exec/userns.test.ts", "test:root": "sh test/root.sh test/fuse/mount.test.ts test/fuse/differential.test.ts test/fuse/conformance-mount.test.ts test/nfs/mount.test.ts test/9p/mount.test.ts", "typecheck": "tsc --noEmit --skipLibCheck" }, diff --git a/test/exec/strategy.test.ts b/test/exec/strategy.test.ts new file mode 100644 index 0000000..4abfd14 --- /dev/null +++ b/test/exec/strategy.test.ts @@ -0,0 +1,140 @@ +/** + * Tier 0: what `mountx/exec` decides, and what it refuses to decide. + * + * Nothing here runs a command — `test/exec/userns.test.ts` does that. This is + * the choice itself: the preference order, the reasons the mechanism is ruled + * out, and the named-mechanism path that must *not* consult the picker's probe + * at all. + * + * The `platform` override is what makes it a Tier-0 suite instead of a + * host-dependent one: darwin and win32 are answered from any host, and only the + * assertions about *this* host's real capabilities are gated. + */ + +import { describe, expect, it } from "vitest"; +import { createMemoryDriver } from "../../src/drivers/memory.ts"; +import { exec, probeExec } from "../../src/exec/index.ts"; +import { usernsExecProbe } from "../../src/exec/probe.ts"; +import { cwdRefusal } from "../../src/exec/userns.ts"; + +const here = probeExec(); + +describe("probeExec", () => { + it("has one preference order, on every host", () => { + // One mechanism today, and the order is the seam a second arrives through + // rather than a decision being made now: off Linux nothing here can work, + // so there is no second order to have either way. + expect(here.preference).toEqual(["userns"]); + expect(probeExec("darwin").preference).toEqual(["userns"]); + }); + + it("rules the user namespace out on macOS, in its own words", () => { + const probe = probeExec("darwin"); + expect(probe.chosen).toBeUndefined(); + expect(probe.userns.usable).toBe(false); + // Not a bare "needs Linux": a reader on macOS is entitled to know that + // macFUSE does not help either. + expect(probe.userns.reason).toContain("macFUSE"); + // The picker's own sentence names the mechanism it asked, so it still reads + // correctly once there is more than one to name. + expect(probe.reason).toContain("user namespace:"); + expect(probe.reason).toContain("darwin"); + }); + + it("rules it out on Windows too", () => { + const probe = probeExec("win32"); + expect(probe.chosen).toBeUndefined(); + expect(probe.reason).toContain("win32"); + }); + + it("always pairs usability with a reason, and a choice with a usable mechanism", () => { + expect(here.userns.usable).toBe(here.userns.reason === undefined); + expect(here.platform).toBe(process.platform); + if (here.chosen === undefined) { + expect(here.reason).toBeTruthy(); + } else { + expect(here.reason).toBeUndefined(); + expect(here[here.chosen].usable).toBe(true); + // The choice is the first usable one in preference order, not any usable one. + expect(here.preference.find((mechanism) => here[mechanism].usable)).toBe(here.chosen); + } + }); + + it("delegates the question to the mechanism's own probe rather than re-deciding", () => { + // The same probe `src/exec/probe.ts` publishes, not a second opinion — which + // is what keeps "what does the user-namespace mechanism need" one fact in + // one file. + expect(here.userns).toEqual(usernsExecProbe()); + }); + + it("names the missing piece rather than one errno", () => { + // Whatever this host is, an unusable verdict has to be a sentence somebody + // can act on: a sysctl to raise, a device to pass in, a package to install. + if (here.userns.usable) { + expect(here.userns.reason).toBeUndefined(); + return; + } + expect(here.userns.reason).toMatch(/dev\/fuse|namespace|unshare|CONFIG_FUSE_FS|Linux/); + }); + + it("refuses to answer for Linux from a host that is not Linux", () => { + // The `platform` override takes away every file the probe reads, so a + // verdict of `usable` would rest on checks that never ran. Only asserted + // off Linux, where it is the case that exists. + if (process.platform === "linux") { + expect(usernsExecProbe("linux").reason ?? "").not.toContain("not Linux"); + return; + } + expect(usernsExecProbe("linux").usable).toBe(false); + expect(usernsExecProbe("linux").reason).toContain("not Linux"); + }); +}); + +describe("cwdRefusal", () => { + it("refuses a cwd that is the mountpoint, or under it", () => { + // The witnessed deadlock, refused up front: `uv_spawn` blocks the thread + // that answers FUSE until the child execs, and the child's first act is the + // `chdir` that needs an answer from it. + expect(cwdRefusal("/mnt/x", "/mnt/x")).toContain("deadlocks"); + expect(cwdRefusal("/mnt/x/deep/er", "/mnt/x")).toContain("deadlocks"); + // And names the spelling that works, since the caller does want to be in there. + expect(cwdRefusal("/mnt/x", "/mnt/x")).toContain("$MOUNTX_ROOT"); + }); + + it("allows everything else, including a path that merely starts the same way", () => { + expect(cwdRefusal("/tmp", "/mnt/x")).toBeUndefined(); + expect(cwdRefusal("/mnt", "/mnt/x")).toBeUndefined(); + // `/mnt/xy` is not inside `/mnt/x`; a prefix match with no separator would + // say it was. + expect(cwdRefusal("/mnt/xy", "/mnt/x")).toBeUndefined(); + }); + + it("compares resolved paths, not the strings it was handed", () => { + expect(cwdRefusal("/mnt/x/../x/sub", "/mnt/x")).toContain("deadlocks"); + expect(cwdRefusal("/mnt/x/..", "/mnt/x")).toBeUndefined(); + }); +}); + +describe("exec", () => { + it("needs a command", async () => { + await expect(exec(createMemoryDriver(), [])).rejects.toThrow(/needs a command to run/); + }); + + it.skipIf(here.chosen !== undefined)( + "refuses with the mechanism's reason when nothing can run", + async () => { + await expect(exec(createMemoryDriver(), ["true"])).rejects.toThrow( + /no mechanism can run a command with a driver on this host/, + ); + }, + ); + + it.skipIf(here.userns.usable)("lets a named mechanism fail in its own words", async () => { + // The assertion is the *absence* of the picker's sentence: naming a + // mechanism skips the picker's probe, so whatever comes back is the + // mechanism's own and is more specific than anything `probeExec` could say. + await expect(exec(createMemoryDriver(), ["true"], { mechanism: "userns" })).rejects.toThrow( + /cannot run a command in a user namespace here/, + ); + }); +}); diff --git a/test/exec/userns.test.ts b/test/exec/userns.test.ts new file mode 100644 index 0000000..c17b3f1 --- /dev/null +++ b/test/exec/userns.test.ts @@ -0,0 +1,214 @@ +/** + * Tier 2, without root — a real namespace, a real FUSE mount, a real command. + * + * ```sh + * pnpm test:rootless + * ``` + * + * Everything here needs `/dev/fuse`, unprivileged user namespaces and + * `unshare(1)`, and skips itself when it does not have them, so `pnpm test` + * stays green on a host that has none of it. It deliberately does **not** + * re-test the filesystem: what the command sees is FUSE, and + * `test/fuse/conformance-mount.test.ts` already covers that whole column. What + * is left to prove is everything that *is* different — that the mount is + * private to the child, that `$MOUNTX_ROOT` is where the driver actually is, + * that a relay failure reaches the caller as an error rather than as the + * command's exit status, and that nothing survives the call. + * + * The threadpool gate is the same one `test/fuse/mount-rootless.test.ts` uses, + * and for a *weaker* reason: the read loop on `/dev/fuse` lives in the relay, + * a separate process, so this suite is not on both sides of its own mount the + * way that one is. What it does share is a `FuseSession` answering from this + * process while vitest runs other files' `fs` work alongside, so it keeps the + * same discipline rather than inventing a second rule. + */ + +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createMemoryDriver } from "../../src/drivers/memory.ts"; +import { exec } from "../../src/exec/index.ts"; +import { usernsExecProbe } from "../../src/exec/probe.ts"; +import { execUserns } from "../../src/exec/userns.ts"; +import { createLoopback } from "../../src/harness.ts"; +import type { FsDriver } from "../../src/types.ts"; + +const probe = usernsExecProbe(); + +/** Has someone raised the threadpool for us? Same gate as the FUSE Tier-2 files. */ +const POOL = Number.parseInt(process.env.UV_THREADPOOL_SIZE ?? "", 10); +const roomToRun = Number.isFinite(POOL) && POOL >= 8; +const live = probe.usable && roomToRun; + +/** Real mounts are slow enough that vitest's 5s default is a coin flip. */ +const SLOW = 60_000; + +/** A driver with one obvious file in it, plus a directory. */ +async function demo(): Promise { + const driver = createMemoryDriver(); + const fs = createLoopback(driver); + await fs.mkdir("/docs", { recursive: true }); + await fs.writeFile("/hello.txt", "hello from a driver that is not on any disk\n"); + await fs.writeFile("/docs/a.txt", "alpha\n"); + return driver; +} + +describe.skipIf(!live)("execUserns", () => { + const directories: string[] = []; + + afterEach(async () => { + for (const directory of directories.splice(0)) { + await rm(directory, { recursive: true, force: true }).catch(() => {}); + } + }); + + async function scratch(): Promise { + const path = await mkdtemp(join(tmpdir(), "mountx-exec-test-")); + directories.push(path); + return path; + } + + it( + "serves the driver to the command at $MOUNTX_ROOT", + async () => { + const out = join(await scratch(), "out"); + // Everything the command touches on the *host* is `out`; everything it + // reads comes from the driver. `sh -c` so the `cd` happens after the + // exec — a `cwd` into the mount is the one thing that deadlocks. + const ran = await execUserns(await demo(), [ + "sh", + "-c", + `cd "$MOUNTX_ROOT" && cat hello.txt docs/a.txt > ${out} && ls >> ${out}`, + ]); + expect(ran.code).toBe(0); + expect(ran.signal).toBeNull(); + const text = await readFile(out, "utf8"); + expect(text).toContain("hello from a driver that is not on any disk"); + expect(text).toContain("alpha"); + expect(text).toContain("docs"); + }, + SLOW, + ); + + it( + "writes back through the driver, not to the host", + async () => { + const driver = await demo(); + const ran = await execUserns(driver, [ + "sh", + "-c", + 'printf again > "$MOUNTX_ROOT/written.txt" && mkdir "$MOUNTX_ROOT/made" && ' + + 'printf deeper > "$MOUNTX_ROOT/made/inner.txt"', + ]); + expect(ran.code).toBe(0); + // Asserted against the *driver*, never against the command's exit status: + // an earlier version of this work reported success while discarding every + // write, because `default_permissions` had the kernel checking a uid the + // namespace does not map. A zero exit says nothing about that. + const fs = createLoopback(driver); + const written = await fs.readFile("/written.txt"); + expect(Buffer.from(written).toString("utf8")).toBe("again"); + expect((await fs.stat("/made")).isDirectory()).toBe(true); + const inner = await fs.readFile("/made/inner.txt"); + expect(Buffer.from(inner).toString("utf8")).toBe("deeper"); + // And it never existed anywhere a host path could reach — the mountpoint + // itself is gone by now. + }, + SLOW, + ); + + it( + "mounts where it was told, and leaves nothing behind", + async () => { + const mountpoint = join(await scratch(), "deep", "mnt"); + const out = join(await scratch(), "root"); + // A mountpoint that does not exist yet: created here, recursively, rather + // than failing three processes away inside `mount(8)`. + const ran = await execUserns(await demo(), ["sh", "-c", `printf %s "$MOUNTX_ROOT" > ${out}`]); + expect(ran.code).toBe(0); + expect(ran.mountpoint).toMatch(/^\//); + + const named = await execUserns( + await demo(), + ["sh", "-c", `printf %s "$MOUNTX_ROOT" > ${out}`], + { + mountpoint, + }, + ); + expect(named.mountpoint).toBe(mountpoint); + expect(await readFile(out, "utf8")).toBe(mountpoint); + // Back to an ordinary empty directory: the mount died with the namespace. + await writeFile(join(mountpoint, "still-a-directory"), ""); + }, + SLOW, + ); + + it( + "keeps the mount out of the host's mount table", + async () => { + // The property the whole mechanism exists for. Read from *this* process, + // which is outside the namespace and is where a leak would show up. + const before = await readFile("/proc/self/mounts", "utf8"); + const ran = await execUserns(await demo(), ["sh", "-c", 'test -f "$MOUNTX_ROOT/hello.txt"']); + expect(ran.code).toBe(0); + const after = await readFile("/proc/self/mounts", "utf8"); + expect(after).toBe(before); + expect(after).not.toContain(ran.mountpoint); + }, + SLOW, + ); + + it( + "hands back the command's own status rather than swallowing it", + async () => { + // A command that fails is not an error here, exactly as it is not for + // `child_process` — which is what makes the *thrown* errors mean + // "the mechanism could not be set up" and nothing else. + const ran = await execUserns(await demo(), ["sh", "-c", "exit 42"]); + expect(ran.code).toBe(42); + expect(ran.signal).toBeNull(); + }, + SLOW, + ); + + it( + "turns a relay failure into an error instead of a plausible exit status", + async () => { + // The relay exits 70 when it gives up, and a command exiting 70 is a + // thing that happens; the status file is what tells the two apart. + await expect( + execUserns(await demo(), ["definitely-not-a-binary-on-this-host"]), + ).rejects.toThrow(/could not run definitely-not-a-binary-on-this-host/); + }, + SLOW, + ); + + it( + "refuses a cwd inside the mount rather than deadlocking on it", + async () => { + const mountpoint = join(await scratch(), "mnt"); + await expect( + execUserns(await demo(), ["true"], { mountpoint, cwd: mountpoint }), + ).rejects.toThrow(/deadlocks rather than failing/); + }, + SLOW, + ); +}); + +describe.skipIf(!live)("exec", () => { + it( + "picks the user namespace on a host with a working /dev/fuse, and tags the result", + async () => { + const ran = await exec(await demo(), ["sh", "-c", 'cat "$MOUNTX_ROOT/hello.txt"']); + expect(ran.mechanism).toBe("userns"); + expect(ran.code).toBe(0); + // The tag is a discriminant over the mechanism's *own* result object, so + // narrowing on it reaches `mountpoint` with no cast. + if (ran.mechanism === "userns") { + expect(ran.mountpoint).toMatch(/^\//); + } + }, + SLOW, + ); +}); From 782b9623ca23fec1223020719d3d0ebc0487971d Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 29 Jul 2026 13:08:36 +0000 Subject: [PATCH 3/8] docs: mountx/exec, in the transports section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page of its own beside the S3 gateway, for the reason S3 has one: a transport is everything between the driver and whatever is going to read it, and "whatever is going to read it" being one command rather than the machine does not change what the page has to explain. Guide-level prose first — what the child sees, what it needs, what the probe says when it cannot — and the full export surface below it. The overview and the reference index count one more entry point each, and the mermaid graph grows the second arm that bypasses `auto`. Co-Authored-By: Claude Opus 5 --- docs/2.transports/0.index.md | 17 ++- docs/2.transports/6.exec.md | 207 +++++++++++++++++++++++++++++++++++ docs/3.reference/0.index.md | 6 +- 3 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 docs/2.transports/6.exec.md diff --git a/docs/2.transports/0.index.md b/docs/2.transports/0.index.md index d6c1861..3044ee7 100644 --- a/docs/2.transports/0.index.md +++ b/docs/2.transports/0.index.md @@ -5,9 +5,9 @@ title: Overview # Transports -**One driver interface, four ways of getting it in front of a client — three of them a kernel, one of them not.** +**One driver interface, five ways of getting it in front of a client — three of them a kernel, two of them not.** -A transport is everything between the driver you wrote and whatever is going to read it: the protocol codec, the session that turns messages into driver calls, and (for FUSE, 9P and NFSv3) the piece that attaches the result to a directory. `mountx/auto` picks among those three mount transports for you; all three can be [pinned](#pinning-a-transport) directly. The fourth, [S3](/transports/s3), is not a mount at all — it serves the driver to an S3 client over HTTP instead, which is exactly why `auto` does not choose it. +A transport is everything between the driver you wrote and whatever is going to read it: the protocol codec, the session that turns messages into driver calls, and (for FUSE, 9P and NFSv3) the piece that attaches the result to a directory. `mountx/auto` picks among those three mount transports for you; all three can be [pinned](#pinning-a-transport) directly. The other two produce no mountpoint at all, which is exactly why `auto` chooses neither: [S3](/transports/s3) serves the driver to an S3 client over HTTP, and [exec](/transports/exec) grafts it onto one command's filesystem view for as long as that command runs. ```mermaid graph TB @@ -21,8 +21,11 @@ graph TB knfs["any NFSv3 or NFSv4.1 client
over TCP"] s3["mountx/s3
S3 REST + SigV4"] ks3["any S3 client
over HTTP"] + ex["mountx/exec
FUSE in a user namespace"] + kex["one command
and its children"] driver --> auto driver --> s3 + driver --> ex auto --> fuse auto --> p9 auto --> nfs @@ -30,6 +33,7 @@ graph TB p9 --> kv9fs nfs --> knfs s3 --> ks3 + ex --> kex ``` ## What it is choosing between @@ -103,9 +107,13 @@ It is optional, lazy, and never on the root path: mounting as root opens `/dev/f It also ships as compressed base64 inside a JavaScript module rather than as a `.node` file — a binary is loaded by path, and a path is the one thing a bundle does not have. The loader extracts it to a private temporary directory, `dlopen`s it, and deletes it again. So it bundles: nothing to configure, nothing to mark external, no sibling file to copy into your output. -## The fourth transport is not a mount +## The two that are not mounts -[`mountx/s3`](/transports/s3) serves the same `FsDriver` to an S3 client instead — `rclone`, the AWS CLI, an SDK, a presigned URL — over plain HTTP. Nothing about it produces a mountpoint, so it sits outside everything above: `probeTransports()` never mentions it, `mountx/auto` never picks it, and pinning it is the only way to reach it. Reach for it when what is in front of the driver is an S3 client rather than `ls` and `cat`. +Neither produces a mountpoint, so both sit outside everything above: `probeTransports()` mentions neither, `mountx/auto` picks neither, and pinning is the only way to reach either. + +[`mountx/s3`](/transports/s3) serves the same `FsDriver` to an S3 client — `rclone`, the AWS CLI, an SDK, a presigned URL — over plain HTTP. Reach for it when what is in front of the driver is an S3 client rather than `ls` and `cat`. + +[`mountx/exec`](/transports/exec) runs one command with the driver grafted onto its filesystem view, at `$MOUNTX_ROOT`, visible to that process tree and to nothing else on the machine — a FUSE mount inside an unprivileged user namespace, which needs no root and which the host's own mount table never sees. Reach for it when the consumer is a program you are about to run rather than the machine. ## Next @@ -114,3 +122,4 @@ It also ships as compressed base64 inside a JavaScript module rather than as a ` - [9P2000.L](/transports/9p) — stateful and root-only, for a Linux host or a VM guest. - [NFS](/transports/nfs) — both versions, including serving without mounting at all. - [S3](/transports/s3) — the gateway transport, and why it stays out of `auto`. +- [Exec](/transports/exec) — a filesystem for one command, with no mount the host can see. diff --git a/docs/2.transports/6.exec.md b/docs/2.transports/6.exec.md new file mode 100644 index 0000000..0f682c8 --- /dev/null +++ b/docs/2.transports/6.exec.md @@ -0,0 +1,207 @@ +--- +icon: lucide:square-terminal +title: Exec +--- + +# Exec + +**Give one command a filesystem, instead of giving the machine a folder.** + +`mountx/exec` runs a command with an `FsDriver` grafted onto its filesystem view. The command and everything it spawns see the driver at `$MOUNTX_ROOT`; nothing else on the host does, and nothing is left behind when it exits. It is the `proot`-shaped question — "give this subprocess a filesystem" — answered on Linux with a FUSE mount inside an unprivileged user namespace, which is a mount nobody outside the process tree can see. + +```ts +import { exec } from "mountx/exec"; +import { createMemoryDriver } from "mountx/drivers/memory"; + +const ran = await exec(createMemoryDriver(), ["sh", "-c", 'ls -la "$MOUNTX_ROOT"']); +ran.mechanism; // "userns" +ran.code; // the command's exit status +``` + +Reach for it when the consumer is _a program you are about to run_ rather than a person at a shell: a build step that should read generated files that were never written to disk, a test fixture, an agent's sandbox. Reach for [`mountx/auto`](/transports/auto) when the consumer is the machine. + +## Not a mount transport + +`mountx/auto`'s whole contract is "hand back a mounted directory". This hands back a child process's exit status and has no mountpoint to give anyone, so it sits outside: `probeTransports()` never mentions it, `mount()` never picks it, and importing `mountx/auto` loads none of it. Same line [S3](/transports/s3) sits on, arrived at from the other side. + +Linux only. A user namespace is a Linux object; macOS has none, and `DYLD_INSERT_LIBRARIES` — the one interception route it does have — is blocked by SIP for exactly the system binaries anyone would want to run. macOS stays [NFS](/transports/nfs) territory. + +**It needs no root.** An unprivileged user namespace is unprivileged by construction, and inside it the process holding `/dev/fuse` is uid 0 with `CAP_SYS_ADMIN` without anything on the host having granted it anything. + +## What the child actually sees + +FUSE, with the kernel's own VFS in front of it. That is the whole argument for this mechanism: it is not an approximation of a filesystem, so it inherits the entire conformance column [the FUSE transport](/transports/fuse) already passes — every syscall, full read/write, every errno — and it is blind to what the child is linked against. A static binary, a Go binary and a setuid binary all behave, because nothing about it depends on their linkage. + +What it gives up is that it _is_ a real kernel mount: + +| | | +| --------------------- | ------------------------------------------------------------------ | +| needs `/dev/fuse` | **yes** — a container has to be given it (`--device /dev/fuse`) | +| needs a kernel module | `fuse`, or a kernel built with `CONFIG_FUSE_FS` | +| needs `unshare(1)` | yes — util-linux or busybox, either one | +| needs root | no, on any path | +| what the child links | anything, including nothing | +| writes | yes | +| architectures | any | +| visible to the host | **no** — `/proc/self/mounts` outside the namespace stays unchanged | + +The mount dies with the namespace, which dies with the command. For an `exec()`-shaped API that invisibility is the point rather than the cost — it is also the property that made a user-namespace mode not worth shipping back when the goal was "mount a directory for the machine". + +::note +`/dev/fuse` is the one thing that cannot be worked around from the inside. A user-namespace root cannot create the device node either: `mknod /dev/fuse c 10 229` in there answers `EPERM`, verified on `alpine:latest`. On a host that withholds the device, this refuses with that sentence rather than failing somewhere obscure. +:: + +## How it decides + +```ts +import { probeExec } from "mountx/exec"; + +const probe = probeExec(); +probe.chosen; // "userns" | undefined +probe.userns.reason; // why not, when it cannot +``` + +The probe reads `/dev/fuse`, `/proc` and `$PATH` and nothing else — it loads no FUSE session, so asking costs nothing. When the mechanism is unusable it says which piece is missing, in terms someone can act on: + +| missing | what it says | +| -------------------------------------------- | --------------------------------------------------------------------------------------- | +| no `/dev/fuse` | the device has to come from outside (`--device /dev/fuse`); a namespace cannot make one | +| `/dev/fuse` exists but will not open | the namespace maps only your uid, so it will not rescue an `EACCES` out here | +| no `fuse` in `/proc/filesystems` | a kernel built without `CONFIG_FUSE_FS` | +| `user.max_user_namespaces` is 0 | the sysctl to raise | +| `kernel.unprivileged_userns_clone` is 0 | Debian's knob, and the sysctl to set | +| `apparmor_restrict_unprivileged_userns` is 1 | Ubuntu 23.10+'s default, and the two ways past it | +| no `unshare` on `$PATH` | util-linux or busybox both provide one | + +::note +The `/dev/fuse` check is an `open(2)`, not a `stat`. A device that exists but refuses to open is a different sentence from one that is not there, and only opening it tells the two apart — which matters because entering the namespace does not change the answer: `unshare -r` maps exactly one uid, so the permission check made inside is the one that already failed outside. Opening it first is also what autoloads the module that puts `fuse` in `/proc/filesystems`, which is why that table is read second. +:: + +### Three things it deliberately does not do + +Lifted from [`mountx/auto`](/transports/auto#three-things-auto-deliberately-does-not-do), because the arguments are the same ones: + +- **No fallback after a failure.** The probe decides once, from host facts; a mechanism that then fails reports its own error. There is one mechanism today and so nothing to fall back _to_ — the rule is written down because it is the rule a second one would arrive under. +- **No probing when you name a mechanism.** `mechanism: "userns"` calls `execUserns()`, whose own errors are more specific than anything the picker could say. +- **No wrapping.** The result is the mechanism's own result object with a `mechanism` discriminant defined on it, so narrowing on it reaches everything that mechanism reports. + +## `$MOUNTX_ROOT`, and the one rule about `cwd` + +The command's environment gets `MOUNTX_ROOT`, and reading it is the portable spelling: the default is a private temporary directory, and [`root`](#execoptions) overrides it. + +**Do not point `cwd` inside it.** That is a deadlock rather than a slow path, and `execUserns()` refuses it up front instead of hanging: + +```ts +// ✗ deadlocks — refused +await exec(driver, ["ls"], { root: "/mnt/x", cwd: "/mnt/x" }); + +// ✓ the cd happens after the exec +await exec(driver, ["sh", "-c", 'cd "$MOUNTX_ROOT" && ls']); +``` + +`uv_spawn` blocks the thread answering FUSE requests until the child execs, and a child whose first act is a `chdir` into the mount waits for a reply only that thread can send. It is the same hazard [`mount9p()`](/transports/9p) and the FUSE transport both document, met from the inside — and the same rule covers a command binary that _lives_ on the driver. + +## `exec(driver, argv, options?)` + +```ts +function exec( + driver: FsDriver, + argv: readonly string[], + options?: ExecOptions, +): Promise; +``` + +Resolves when the command exits, with that command's status. **A command that fails is not an error here**, exactly as it is not for `node:child_process` — which is what makes a thrown error mean "the mechanism could not be set up" and nothing else. `stdio` is inherited throughout, so the command's output is this process's output. + +### `ExecOptions` + +| option | default | | +| -------------- | ----------------------------- | ------------------------------------------------------------------------ | +| `mechanism` | `"auto"` | `"userns"` \| `"auto"`; naming one skips the picker's probe | +| `root` | a private temporary directory | where the driver appears, and the value of `$MOUNTX_ROOT` | +| `cwd` | this one | working directory for the command — never inside `root` | +| `env` | this one | environment for the command; `MOUNTX_ROOT` is added to it | +| `useDriverIno` | `false` | report the driver's own `ino` values instead of synthesising them | +| `debug` | `false` | log protocol traffic to stderr | +| `userns` | none | `ExecUsernsOptions`, applied after the shared ones and therefore winning | + +The shared options are the ones that would mean the same thing however the driver reached the command. Anything mechanism-specific goes in the escape hatch, for the reason `AutoMountOptions` has three of them: same-named options with genuinely different shapes merge into something that either lies or is unusable. + +### `ExecResult` + +```ts +type ExecResult = ExecUsernsResult & { readonly mechanism: "userns" }; +``` + +The mechanism's own result, tagged with a discriminant: `code` (`number | null`), `signal` (`NodeJS.Signals | null`) and `mountpoint`. Narrowing on `mechanism` is worth writing even with one arm — it is the seam a second mechanism would arrive through, and a `switch` that already narrows is one that keeps compiling. + +### `probeExec(platform?)` + +```ts +interface ExecProbe { + platform: NodeJS.Platform; + chosen: ExecMechanism | undefined; + preference: readonly ExecMechanism[]; // ["userns"] + userns: UsernsExecProbe; + reason: string | undefined; // names every mechanism, when none can run +} +``` + +Synchronous, unlike [`probeTransports()`](/transports/auto#probetransportsplatform) — every fact here is a file read, where FUSE's rootless probe has to load the native addon before it can answer. The parameter exists to be overridden in tests; leave it alone otherwise. + +There is one preference order on every host, because off Linux nothing here can work and there is nothing for a second order to say. + +### `usernsExecProbe(platform?)` + +```ts +interface UsernsExecProbe { + usable: boolean; + platform: "linux" | undefined; + kernel: boolean; // `fuse` in /proc/filesystems + device: boolean; // /dev/fuse exists *and* opens + userns: boolean; // this process may create one + unshare: string | undefined; // the resolved binary + reason: string | undefined; +} +``` + +The mechanism's own probe, the one `probeExec()` composes — exported because a caller, or a test suite gating itself, often wants exactly it. Cheap enough to call unconditionally. + +## `execUserns(driver, argv, options?)` + +The mechanism on its own, without the picker: + +```ts +import { execUserns } from "mountx/exec"; + +const ran = await execUserns(driver, ["sh", "-c", 'cat "$MOUNTX_ROOT/hello.txt"'], { + mountpoint: "/mnt/x", // created if missing +}); +ran.mountpoint; +``` + +`ExecUsernsOptions` extends `FuseSessionOptions`, so every caching and attribute knob the [FUSE transport](/transports/fuse#fusesessionoptions) has applies here too, plus `mountpoint`, `cwd`, `env` and `mountOptions`. + +**`default_permissions` is not among the default `-o` options,** which is worth knowing before setting it. It asks the _kernel_ to check the driver's `uid`/`gid`/`mode` against the caller's credentials, and inside a namespace those are not the same identity space: `unshare -r` maps exactly one uid, so a driver reporting the serving process's real uid is reporting an identity the namespace renders as `nobody` — and a `nobody`-owned `0755` root directory refuses every write from the one process meant to have it. Namespace-root's `CAP_DAC_OVERRIDE` does not rescue it either; that capability does not reach a file owned by an unmapped uid. Permission checking stays with the driver instead. Nothing is lost by it: the mount carries no `allow_other`, so the only process that can reach it is the one the call created it for. + +### How it is built + +`unshare(CLONE_NEWUSER)` demands a single-threaded caller and Node is never single-threaded, so a mountx process cannot enter the namespace it needs — not with `unshare(2)`, and not with `setns(2)`, which has the same rule. The namespace is entered by a **child**, `/dev/fuse` is opened in there, and raw FUSE traffic comes back out over a unix socket to a `FuseSession` in the parent, which is where your driver stays. Inside the namespace that child is uid 0 with `CAP_SYS_ADMIN`, so it takes the ordinary root mount path: no `fusermount3`, no setuid bit, and **no native addon**. On a host with no `fuse3` package installed at all, this is the only FUSE route that works. + +The flags are `unshare -U -r -m --propagation private`, spelled short on purpose: busybox 1.37's applet has `-r` and no `--map-root-user` at all, so this spelling works on a bare Alpine and the long one does not. + +::note +A relay failure — `mount(8)` not coming up, a command that is not on `$PATH` — reaches you as a thrown error rather than as an exit status. It has to: the relay's own exit code is indistinguishable from the command's, and "the command exited 70" is a thing that happens. The relay also exits when its socket closes, so a parent that goes away does not leave an orphan holding a wedged mount. +:: + +## Not available + +- **Anything without `/dev/fuse`.** A container that withholds the device withholds it from a namespace root too. A second mechanism that needs no device node — a seccomp user-notification supervisor — was built and measured alongside this one; it is not part of this release, and `.agents/proot-plan.md` in the repository records what it showed. +- **macOS and Windows.** Neither has user namespaces. +- **A conformance-matrix column.** There is none, deliberately: what the child sees _is_ FUSE through the kernel's VFS, so the column would duplicate [FUSE's](/transports/fuse) exactly. + +## Next + +- [FUSE](/transports/fuse) — what the child is actually talking to. +- [`mountx/auto`](/transports/auto) — when the consumer is the machine rather than one command. +- [S3](/transports/s3) — the other entry point that produces no mountpoint. diff --git a/docs/3.reference/0.index.md b/docs/3.reference/0.index.md index 48b86aa..d1d0f9e 100644 --- a/docs/3.reference/0.index.md +++ b/docs/3.reference/0.index.md @@ -5,7 +5,7 @@ title: Entry Points # Reference -**Nine subpaths. Each loads only what it needs.** +**Ten subpaths. Each loads only what it needs.** | import from | what you get | | --------------------------------------------------------------- | -------------------------------------------------------------------- | @@ -18,8 +18,9 @@ title: Entry Points | [`mountx/9p`](/transports/9p) | the 9P2000.L transport, and `createP9Server()` | | [`mountx/nfs`](/transports/nfs) | the NFS transport (v3 default, v4.1 opt-in), and `createNfsServer()` | | [`mountx/s3`](/transports/s3) | the S3 gateway, and `createS3Server()` — not a mount transport | +| [`mountx/exec`](/transports/exec) | `exec()` — a filesystem for one command, not a mount transport | -The three `mountx/drivers/*` subpaths are documented in the guide, beside the interface they implement: [built-in drivers](/guide/drivers/built-in). `mountx/auto` and the four transport subpaths are documented in [Transports](/transports), beside the protocols they speak. +The three `mountx/drivers/*` subpaths are documented in the guide, beside the interface they implement: [built-in drivers](/guide/drivers/built-in). `mountx/auto` and the five transport subpaths are documented in [Transports](/transports), beside the protocols they speak. Plus the [`mountx` CLI](/guide/cli), which the package installs as a binary — a demo and a test bench, documented in the guide. @@ -70,5 +71,6 @@ Two rules the type system holds, and both are checked in CI: | `mount9p` | ✅ root | — | — | | `mountNfs` | ✅ root | ✅ no root | — | | `mount` (auto) | ✅ FUSE, then 9P | ✅ NFS | — | +| `exec` | ✅ no root | — | — | `createP9Server` runs anywhere Node does — only `mount9p()` is Linux- and root-only, the same split `mountx/nfs` has between `createNfsServer` and `mountNfs`. Everything in `mountx/fuse` except `mount.ts`, everything in `mountx/9p` except `server.ts`/`mount.ts`, and everything in `mountx/nfs` except `server.ts`/`mount.ts`, is pure data transformation and runs anywhere. From dfa0397ab8118a31d375822e6ebe67cfaf6ad802 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 29 Jul 2026 13:11:22 +0000 Subject: [PATCH 4/8] docs(agents): the exec code map, the roadmap entry, and the measured record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AGENTS.md` grows an `src/exec/` section in the code map, an `exec/` entry under tests, and the `pnpm test:rootless` line that now covers it — the one Tier-2 mount column that needs neither root nor a `fusermount3`, which is why it is the one that passes on this dev host. `.agents/proot-plan.md` is trimmed to what this branch rests on: the measured three-way comparison's conclusion, the four things mechanism A cost and why each is a comment in the code, and the bare-Alpine portability findings. The seccomp user-notification mechanism was built and measured, works where `/dev/fuse` is withheld, and lives in the history of PR #9 on `pithings/mountx` rather than here; the rejected `LD_PRELOAD` autopsy stays there too. `.agents/environment.md` records the verified host facts that made the mechanism possible to find at all: no `fusermount3` anywhere, unprivileged user namespaces working, and Node never being able to enter one itself. Co-Authored-By: Claude Opus 5 --- .agents/environment.md | 28 ++++++++ .agents/proot-plan.md | 147 +++++++++++++++++++++++++++++++++++++++++ .agents/roadmap.md | 43 ++++++++++++ AGENTS.md | 15 ++++- 4 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 .agents/proot-plan.md diff --git a/.agents/environment.md b/.agents/environment.md index f10b77d..df90704 100644 --- a/.agents/environment.md +++ b/.agents/environment.md @@ -322,3 +322,31 @@ vers=3,proto=tcp,port=…,mountport=…,nolocks,soft,nobrowse 127.0.0.1:/ ./mnt` approval), then `sudo umount -f`, then reboot. A hung `umount` does **not** die on `SIGKILL` — it is parked in the kernel — which is why `run()` in `src/nfs/mount.ts` settles on its own deadline instead of waiting for `close`. + +## Unprivileged interception, no mount (verified 2026-07-29, this Linux host) + +Established while spiking the `proot`-style `exec()` work +(`.agents/proot-plan.md`). Kernel 6.12.96 (Debian 13), glibc 2.43, x86-64. + +- **There is no `fusermount3` on this host at all** — not at `/usr/bin`, not + anywhere on `PATH`. So the rootless FUSE path (`src/fuse/fusermount.ts`) + cannot run here, and `pnpm test:rootless`'s FUSE column skips itself for that + reason rather than for a missing prebuilt. +- **Unprivileged user namespaces work**, and are the way around it: + `unshare -Urm` yields uid 0 with `CapEff: 000001ffffffffff`, `/dev/fuse` is + `crw-rw-rw-`, and `mount -t tmpfs` inside succeeds. A FUSE mount made in + there needs **no helper, no root and no native addon** — it is the ordinary + root path in `src/fuse/mount.ts`, which keys off `getuid() === 0`. The mount + is invisible in the host's `/proc/self/mounts` and dies with the namespace. + This is what `src/exec/userns.ts` is built on, and it is why that suite is + the one Tier-2 mount column that passes on this host. +- **Node can never enter such a namespace itself.** `unshare(CLONE_NEWUSER)` + and `setns(2)` both require a single-threaded caller; Node has the libuv + threadpool up before user code runs. Any design here needs a child process. +- **seccomp user notification works unprivileged here too.** A filter installed + with `SECCOMP_FILTER_FLAG_NEW_LISTENER` after `PR_SET_NO_NEW_PRIVS` succeeds, + and `SECCOMP_IOCTL_NOTIF_ADDFD` can inject a descriptor into the tracee. This + is true even though the shell already runs under a seccomp filter of its own + (`Seccomp: 2`, `Seccomp_filters: 1` in `/proc/self/status`) — filters stack. + Recorded because it is a verified host fact, not because anything on this + branch uses it: the mechanism that did lives in the history of PR #9. diff --git a/.agents/proot-plan.md b/.agents/proot-plan.md new file mode 100644 index 0000000..856c34d --- /dev/null +++ b/.agents/proot-plan.md @@ -0,0 +1,147 @@ +# proot-style exec: what was measured, and what shipped + +Question asked (2026-07-29): can mountx give a subprocess access to an +`FsDriver` **without a real kernel mount** — a `proot`-shaped `exec()` that +injects `LD_PRELOAD`, or any other universal syscall-inspection route? + +Three mechanisms were built and measured. This file is the trimmed record: what +ships on this branch, why it is shaped the way it is, and where the rest went. + +**What ships here: mechanism A**, FUSE inside an unprivileged user namespace, +as `mountx/exec` — see `AGENTS.md`'s code map for the file-by-file account and +`docs/2.transports/6.exec.md` for the user-facing page. It has no +conformance-matrix column and is not wired into `mountx/auto`, both +deliberately: the column would duplicate FUSE's exactly, and `auto`'s contract +is a mountpoint this produces none of. + +**What does not ship here.** Mechanism C — a seccomp user-notification +supervisor over an unchanged `createP9Server()` — was built, measured and +found to work in the one environment A cannot serve: a host or container that +withholds `/dev/fuse`. It is **not on this branch**. It lives in the history of +**PR #9 on `pithings/mountx`**, which was closed rather than merged, together +with its own tests, its Zig sources, the three-way comparison harness and the +full autopsy of mechanism B (`LD_PRELOAD`), which was rejected. Read that PR +for any of it. + +## The measured comparison + +Host: Linux 6.12.96 (Debian 13) x86-64, glibc 2.43, zig 0.16, unprivileged user +namespaces available, **no `fusermount3` installed**, no root used anywhere. + +One workload built three ways, each reading a 44-byte file, stat-ing and reading +a 3 MiB file whole, and listing a directory. "pass" means the FNV checksum over +the 3 MiB matched byte for byte. + +| | dynamic glibc | static musl | raw syscalls (the Go case) | +| -------------------- | ------------- | ----------- | -------------------------- | +| **A** userns + FUSE | pass | pass | pass | +| **B** `LD_PRELOAD` | pass | **fail** | **fail** | +| **C** seccomp notify | pass | pass | pass | + +The conclusion that decided this branch: **A is the cheapest correct thing on +any host that has `/dev/fuse`, and it is correct for reasons that do not have +to be maintained.** It covers every binary because nothing about it depends on +what the child is linked against; B cannot see a static or Go binary at all, +and its symbol surface tracks other projects' releases. C matches A on +coverage — the boundary is the syscall ABI — and buys the no-device-node case, +at the price of a separately built supervisor binary, x86-64 only, and a +narrower feature set. So A first, and C as the thing that covers what A cannot. + +## Why mechanism A is shaped the way it is + +Not a "no kernel mount" answer, and included anyway because it is the honest +baseline: what the child sees _is_ FUSE, with the kernel's VFS in front of it, +so it inherits the entire conformance column the FUSE transport already passes. + +It is a mount, but a mount **nobody outside the process tree can see** — +`/proc/self/mounts` on the host stays empty, and the mount dies with the +namespace. The roadmap ruled a user-namespace mode out when the goal was "mount +a directory for the machine"; for an `exec()`-shaped API the tradeoff inverts, +and invisible is the point. + +Four things it cost, all witnessed — the first three during the spike, the last +one while productionising it. Each is a comment in the code today: + +- **Node can never enter the namespace.** `unshare(CLONE_NEWUSER)` requires a + single-threaded caller and Node is never single-threaded; `setns(2)` has the + same rule. So the namespace is entered by a child, `/dev/fuse` is opened + there, and the traffic comes back out over a unix socket to a `FuseSession` + in the parent. That relay is not a workaround for the spike — it is the shape + any version of this must take, and it is the "relay mode" the roadmap defers, + arrived at from the other direction. +- **The relay deadlocks if it spawns into its own mount.** Setting the child's + `cwd` to the mountpoint wedged the relay in `D` state at `fuse_get_req` + permanently: `uv_spawn` blocks the calling thread until the child execs, and + the child's first act was a `chdir` that only that thread could answer. Same + hazard `src/fuse/mount.ts` and `src/9p/mount.ts` both document, met from the + inside. Fixed by passing the mountpoint as `$MOUNTX_ROOT` so the `cd` happens + after the exec, and refused up front (`cwdRefusal()`) when asked for anyway. +- **A killed parent orphans a wedged mount.** Handled by having the relay exit + when the socket closes. +- **`default_permissions` makes the mount read-only by accident.** Found while + productionising A, not during the spike, because the spike's workload never + wrote. The option asks the _kernel_ to check the driver's `uid`/`gid`/`mode` + against the caller's credentials, and inside the namespace those are not the + same identity space: `unshare -r` maps exactly one uid, so a driver reporting + the serving process's real uid reports an identity the namespace renders as + `nobody`, and a `nobody`-owned `0755` root directory refuses every write from + the one process meant to have it. Namespace-root's `CAP_DAC_OVERRIDE` does + not rescue it either — that capability does not reach a file owned by an + unmapped uid. It is no longer the default; permission checking stays with the + driver, and the mount carries no `allow_other`, so the only process that can + reach it is the one it was created for. + +Also worth recording, because it changes what `mountx/auto` could do on this +class of host: **this host has no `fusermount3` at all**, so today's rootless +FUSE path cannot run here — yet inside `unshare -Urm` the process is uid 0 with +`CAP_SYS_ADMIN`, `/dev/fuse` is 0666, and the ordinary root mount path works +verbatim with no helper and no native addon. + +## Portability: what A needs on a bare system + +Asked directly (2026-07-29): does it work on a bare Alpine system with no kernel +modules and no extra shared libraries? Tested in `podman` against +`alpine:latest` and `node:24-alpine`, busybox-only, no `util-linux`, no `fuse3`, +no `fusermount3`. + +**Userland: nothing extra needed, after one fix.** busybox provides both +`unshare` and `mount` applets, and A needs no native addon at all. The one +incompatibility found: busybox 1.37's `unshare` has `-r` but **no +`--map-root-user`**, so the long spelling fails outright. `src/exec/userns.ts` +spawns `-U -r -m --propagation private`, which util-linux and busybox both +accept. + +**Privileges: none.** Verified with `--cap-drop=ALL --user 1000:1000`, no +`--privileged`: the tree mounts and reads back byte-exact. + +**Kernel: yes, and it is not avoidable.** A _is_ FUSE, so it needs the host +kernel's `CONFIG_FUSE_FS` and a working `/dev/fuse` node. + +- `alpine:latest` has **no `/dev/fuse`**, and A fails there with an accurate + message rather than anything mysterious. +- A user-namespace root **cannot create it**: `mknod /d/fuse c 10 229` inside + `unshare -Urm` answers `EPERM`. Verified. So the node has to come from + outside — `--device /dev/fuse`, or a host that has it. +- With `--device /dev/fuse` supplied it works completely: full listing, 3 MiB + read, sha256 `bfe74807…` matching every other column. + +This is exactly the gap mechanism C was pursued for, and exactly why it is +worth reviving from PR #9 rather than rediscovering: the environments where "no +kernel mount" is _wanted_ — a locked-down container, a CI runner, an +unprivileged sandbox — are the ones that withhold `/dev/fuse`, and there A +cannot be made to work from inside by any means. + +## macOS + +Nothing from any of this transfers: no user namespaces, no seccomp, and SIP +blocks `DYLD_INSERT_LIBRARIES` for system binaries. macOS stays NFS-mount +territory. + +## Reproducing + +```sh +node src/exec/demo-userns.ts # the mechanism, against the demo tree +sh test/rootless.sh test/exec/userns.test.ts +``` + +The command sees the driver at `$MOUNTX_ROOT`. diff --git a/.agents/roadmap.md b/.agents/roadmap.md index c76c03a..ce576cf 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -139,6 +139,33 @@ results. column (no host could mount 9P when the benchmark suite was last run — see "Future / deferred", now that one can). +- **`mountx/exec`** (2026-07-29). A `proot`-shaped `exec()`: run a command + with a driver grafted onto its filesystem view at `$MOUNTX_ROOT`, visible + to that process tree and to nothing else on the machine, resolving with + that command's exit status. One mechanism ships — **the user namespace**: + FUSE inside `unshare -U -r -m`, the driver staying in the calling process, + raw traffic relayed over a unix socket because `unshare(CLONE_NEWUSER)` + refuses a threaded caller and Node is never single-threaded. No root, no + `fusermount3`, no native addon, on any path. `probeExec()` publishes what + it can do here and why not; the mechanism arrives through `await import()`, + and the result is its own object with a `mechanism` discriminant defined + on it. `src/exec/probe.ts` is import-light in `src/nfs/probe.ts`'s sense + and names causes a caller can act on rather than one errno. + Deliberately **outside `mountx/auto`**, whose contract is a mountpoint this + produces none of — the line `mountx/s3` already sits on. + What was deliberately **not** done: **a second mechanism on this branch.** + A seccomp user-notification supervisor was built and measured alongside + this one, works where `/dev/fuse` is withheld (which is the case that + motivated the question — a container withholding the device withholds it + from a namespace root too, since `mknod` there answers `EPERM`), and lives + in the history of **PR #9 on `pithings/mountx`** rather than here; the + picker keeps the seam for it (`ExecMechanism`, `preference`, the result + discriminant) without inventing a second entry to fill. Also not done: a + conformance-matrix column, which for this mechanism would duplicate FUSE's + exactly since what the child sees _is_ FUSE; and `default_permissions` on + the namespace mount, because the kernel checking a driver's uid against a + namespace that maps exactly one turns every write into `EACCES`. + ## Finalized decisions (still binding) - **Scope:** FUSE (Linux) + NFSv3 loopback transports. WebDAV deferred. @@ -171,6 +198,22 @@ rather than by accident. is also where `src/9p/`'s deferred `trans=fd` would finally earn its keep: it wants a descriptor the relay already holds, where `mount9p()`'s own `trans=unix` has nothing to relay to. +- **`mountx/exec` without `/dev/fuse`.** The one mechanism here is a real + kernel mount, so a host or container that withholds the device is a host + it cannot serve, and nothing can be done about that from the inside. The + measured answer is a seccomp user-notification supervisor, which needs no + device node, no kernel module and no shared library; it exists in the + history of PR #9 on `pithings/mountx` and would come back as a second arm + of `ExecMechanism` rather than as a new API. `.agents/proot-plan.md` has + what it showed and what was still open when it was set aside. +- **A conformance-matrix column for `mountx/exec`.** There is none. For the + user-namespace mechanism it would duplicate the FUSE column exactly (what + the child sees _is_ FUSE, through the kernel's VFS), which is an argument + for never writing it rather than for writing it later. +- **`mountx/exec` on macOS.** Nothing here transfers: no user namespaces, and + SIP blocks `DYLD_INSERT_LIBRARIES` for exactly the system binaries anyone + would want to run. macOS stays NFS-mount territory, and the honest answer + is that this feature is Linux's. - **A 9P bench column.** `bench/` has loopback and NFS columns and a sudo-gated FUSE one; 9P has none yet, and unlike when the transport was designed, a host that can mount it now exists (`.agents/environment.md`). diff --git a/AGENTS.md b/AGENTS.md index 17a9570..3b86b72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Keep important information about project in AGENTS.md. For more detailed info, p # mountx -Mount a JavaScript filesystem: one driver interface (a subset of `node:fs/promises`), multiple transports (FUSE first, then 9P, then NFS — v3, then v4.1) — plus an S3 gateway (`mountx/s3`) that serves the same driver to an S3 client instead of mounting it, deliberately outside `mountx/auto`. User-facing docs live in `docs/` (); `README.md` is the landing page for npm and GitHub and links there. +Mount a JavaScript filesystem: one driver interface (a subset of `node:fs/promises`), multiple transports (FUSE first, then 9P, then NFS — v3, then v4.1) — plus two things that are not mounts and are both deliberately outside `mountx/auto`: an S3 gateway (`mountx/s3`) that serves the same driver to an S3 client, and `mountx/exec`, which grafts it onto one command's filesystem view for as long as that command runs. User-facing docs live in `docs/` (); `README.md` is the landing page for npm and GitHub and links there. Conventions: pure JS/TS, zero runtime deps, pure-JS-first. Single package with subpath exports. Small conventional commits to `main`, tests green (`pnpm test`) before each commit. @@ -79,6 +79,14 @@ S3 (`src/s3/`, exported as `mountx/s3`): the transport that is not a mount — i - `server.ts` — `createS3Server(source, options)`, the only file here that imports `node:http`. With no `credentials`, binds loopback-only and refuses any other `host` with a named `S3BindError`; with credentials, verifies every request and allows any bind. `close()` drains in order: stop accepting, let in-flight replies finish, drop the rest, sweep multipart staging last. - `index.ts` — re-exports the above by name, minus `xml.ts`'s generic XML primitives it is built on (the same treatment `mountx/nfs` gives its sub-struct helpers). +Exec (`src/exec/`, exported as `mountx/exec`): the other entry point that is not a mount — it grafts an `FsDriver` onto **one command's** filesystem view, at `$MOUNTX_ROOT`, visible to that process tree and to nothing else on the machine, and hands back the command's exit status. Deliberately outside `mountx/auto` for the same reason `src/s3/` is: `auto`'s contract is a mountpoint, and this produces none. Linux only, and no root on any path. + +- `probe.ts` — `usernsExecProbe()` and the `ExecPlatform` narrowing, import-light in the exact sense `src/nfs/probe.ts` and `src/9p/probe.ts` are — `node:fs` and nothing else, so asking never pulls in a FUSE session. The `/dev/fuse` check is an `open(2)` rather than a `stat`, and it runs **before** `/proc/filesystems` is read, because `/dev/fuse` is a misc device and opening it is what autoloads the module that puts `fuse` in that table; it also tells the two actionable failures apart, and says of the `EACCES` one that a namespace does not rescue it (`unshare -r` maps exactly one uid, so the check made inside is the one that already failed outside, and namespace `CAP_DAC_OVERRIDE` does not reach a node owned by an unmapped uid). Three sysctls cover the three distribution idioms for disabling unprivileged user namespaces (`user.max_user_namespaces`, Debian's `unprivileged_userns_clone`, Ubuntu 23.10+'s `apparmor_restrict_unprivileged_userns`), the last two read only when not root because they gate the unprivileged path alone. `NOT_THIS_HOST` is the one verdict a `platform` override forces: every fact below `platform` is a file about _this_ machine, so answering `usable` for Linux from somewhere that is not Linux would rest on checks that never ran. +- `index.ts` — `exec(driver, argv, options)` and `probeExec()`, in `src/auto.ts`'s shape and with all three of its refusals for its reasons: no fallback after a failure, no probe when a mechanism is named, and no loading of what it does not use (the mechanism arrives via `await import()`). There is **one mechanism**, `userns`, and the file is shaped for more than one on purpose — `ExecMechanism`, `ExecProbe.preference` and the `mechanism` discriminant on the result are the seam a second arrives through without an API change, which is why a union of one arm and a one-element preference list are written out rather than collapsed. A second mechanism (a seccomp user-notification supervisor, needing no device node at all) was built and measured beside this one and is **not on this branch**: it lives in the history of PR #9 on `pithings/mountx`, and `.agents/proot-plan.md` keeps what it showed. The result is the mechanism's own result object with the discriminant defined on it — tagged, not wrapped. Shared options are the ones that would mean the same thing however the driver reached the command (`root`, `cwd`, `env`, `useDriverIno`, `debug`); anything mechanism-specific goes in `userns: {…}`. +- `userns.ts` — `execUserns()`: FUSE inside an unprivileged user namespace, the driver staying in this process. `unshare(CLONE_NEWUSER)` demands a single-threaded caller and Node is never single-threaded, so the namespace is entered by a child (`userns-relay.ts`) and raw FUSE traffic comes back over a unix socket to a `FuseSession` here — the "relay mode" the roadmap defers, arrived at from the other direction. `cwdRefusal()` is pure and exported: a `cwd` inside the mountpoint is the `uv_spawn` deadlock `src/fuse/mount.ts` and `src/9p/mount.ts` document, met from the inside, and it is refused rather than hung on. `DEFAULT_MOUNT_OPTIONS` is **empty on purpose** — `default_permissions` asks the kernel to check a driver's uid against a namespace that maps exactly one, so a driver reporting the serving process's real uid renders as `nobody` and every write fails `EACCES`; permission checking stays with the driver, and nothing is lost because the mount carries no `allow_other`. The `unshare` flags are spelled short (`-U -r -m --propagation private`) because busybox 1.37 has `-r` and no `--map-root-user`, which is what bare-Alpine support rests on. `relayPath()` walks `RELAY_CANDIDATES` — the sibling `.ts`, the sibling `.mjs`, then `../exec/userns-relay.mjs` — because the relay is spawned rather than imported and obuild answers `index.ts`'s dynamic `import("./userns.ts")` with `dist/_chunks/userns.mjs`, where the relay is _not_ a sibling; a fourth layout announces itself as this file's own error rather than as a mystery `ENOENT` from `spawn`. +- `userns-relay.ts` — the only thing that runs _inside_ the namespace, and it has no imports from `src/`. Opens `/dev/fuse`, spawns `mount(8)` (in there it is uid 0 with `CAP_SYS_ADMIN`, so this is the ordinary root path — no `fusermount3`, no setuid bit, no native addon), and pumps whole messages both ways; FUSE's own `len` field is the framing, and the one rule the socket does not carry is that a reply must reach the device in a single `write(2)`, which is what the reassembly buffer is for. The command's `cwd` is deliberately _not_ the mountpoint — the mountpoint travels as `$MOUNTX_ROOT` so a `cd` happens after the exec. A closed socket exits the relay, because a parent that went away leaves every later request parked in `fuse_get_req` forever. `fail()` also writes its message to `$MOUNTX_RELAY_STATUS`: the relay's exit code is indistinguishable from the command's, and "the command exited 70" is a thing that happens. It is a **separate build entry** (`build.config.ts`) rather than a subpath export, for the same spawned-not-imported reason. +- `demo-driver.ts`, `demo-userns.ts` — a test bench, not an entry point: one demo tree and one runner, calling `execUserns()` _by name_ so that what runs is one named mechanism rather than whatever the picker would have chosen. `node src/exec/demo-userns.ts [command...]`. + CLI (`src/cli/`, the `mountx` bin, `pnpm play` from source): - `index.ts` — `node:util`'s `parseArgs`, then a memory driver holding one file — this package's own `README.md`, read through `new URL("../../README.md", import.meta.url)`, which is the package root from `src/cli/` and from `dist/cli/` alike, and npm publishes a README whatever the `files` list says — wrapped in `watch.ts` and mounted through `mountx/auto` at `~/mountx` (`[mountpoint]`/`-m`/`$MOUNTX_MOUNTPOINT`; `-t`, `-q`, `-r`, `--empty`, `--allow-other`). It is a demo and a test bench, not a mount tool — what it serves is always a tree that dies with the process. `process.exit` appears only in paths that run _before_ the mount (with one up it wedges), and the stale-mount cleanup detaches only a `fuse*`/`nfs*`/`9p` mount at that exact path (matched whole for `9p`, so a future `9p2` stays untouched), printing the `sudo` line rather than spawning one when the route needs root. It runs on **both hosts**: Linux reads `/proc/self/mounts` inline, macOS asks `mountEntryAt()` from `src/nfs/mount.ts` (dynamically imported, so a Linux run never loads the NFS codec) and clears with an unprivileged `umount -f`, which works because a BSD lets the mounting user unmount. Linux+NFS and Linux+9P both have no unprivileged route and get the `sudo` line — no worse than it sounds, since both needed root to be mounted in the first place. Bounded by `STALE_TIMEOUT`, because the macOS consent gate turns `umount` into a call that never returns — on expiry it prints `consentAdvice()` and lets `mount()` refuse. @@ -94,6 +102,7 @@ Tests (`test/`): - `fuse/` — protocol/session Tier 0 (`random.ts`, `protocol.test.ts`, `golden.test.ts`, `dirent.test.ts`, `init.test.ts`, `flags.test.ts`, `session.test.ts`, `inodes.test.ts`, `session-fuzz.test.ts`, `synthetic-kernel.ts`, `fuzz.test.ts`), `fusermount.test.ts` (the elevation checks and the device-refusal advice, Tier 0 — pure, so it runs on a host with no `fuse3` at all), `native.test.ts` (the whole addon, Tier 0 — passing a descriptor to yourself needs no helper and no privileges), Tier 2 `mount.test.ts` and `mount-rootless.test.ts` (no sudo), the differential oracle (`differential.ts`+`differential.test.ts`), record/replay (`record-fixtures.ts`+`replay.test.ts`), the FUSE conformance column (`conformance-mount.test.ts`). - `nfs/` — Tier 0 for the shared layer (`xdr.test.ts`, `handles.test.ts`, `mount-options.test.ts` — the platform difference, checked from either host) plus `session.test.ts` for the version router alone: which session a `(prog, vers)` pair reaches, and nothing about what it does once it arrives (that is `v3/session.test.ts`'s and `v4/session.test.ts`'s job). In `v3/` beside the code it covers: Tier 0 for the protocol (`protocol.test.ts`, `golden.test.ts`, `fuzz.test.ts`) plus the Tier-1 JS client (`v3/client.ts`) and its conformance column (`v3/conformance.test.ts`, `v3/session.test.ts`). In `v4/`: `constants.test.ts` (the transcription check — RFC spot-checks at distinct points plus whole-table shape assertions, no gap, no repeat, every value named), `attr.test.ts`, `protocol.test.ts`, `golden.test.ts`, `fuzz.test.ts` for the codec, `state.test.ts` for the state machine alone (synchronous, no socket — the replay cache and the lease clock proved with an injected clock rather than a real `setTimeout`), `session.test.ts` for COMPOUND dispatch driven with encoded bytes, and the Tier-1 JS client (`v4/client.ts`, which does its own path-walking and POSIX-vs-NFSv4 op-collapsing — `unlink` vs `rmdir`, OPEN not being for directories) plus `v4/driver.ts` (the `FsDriver` over it) and `v4/conformance.test.ts`, so NFSv4.1 is a conformance-matrix column of its own alongside loopback, FUSE, 9P, NFSv3 and S3. Tier 2 `mount.test.ts` is v3-only so far (gated on `nfsClientProbe()`; sudo on Linux, `pnpm test:rootless` on macOS, where it needs none) — a real-mount NFSv4.1 column is not written yet; the dev host has no `mount.nfs` to write it against either way. - `s3/` — Tier 0 (`sigv4.test.ts` against the official `aws-sig-v4-test-suite` goldens, `xml.test.ts`, `chunked.test.ts`, `protocol.test.ts`, `constants.test.ts` — the errno↔S3-error table's totality), `server.test.ts` (real sockets, driven with `fetch`), the Tier-1 signing JS client (`client.ts`, the `test/nfs/v3/client.ts` pattern) and its conformance column (`conformance.test.ts`, `session.test.ts`, in-process against the memory driver, no sockets), and `oracle.test.ts` — a real `rclone`/`curl` against the gateway, gated on `command -v rclone`/`curl` (the `nfsClientProbe` pattern) and needing no root, so it runs as part of `pnpm test` and skips clean when either binary is absent. +- `exec/` — `strategy.test.ts` is Tier 0 for `mountx/exec`'s choice, the way `auto.test.ts` is for the transports': the preference order, the reason the mechanism is ruled out, the named-mechanism path that must not consult the picker's probe, and `cwdRefusal()`, whose only other way of being checked costs a hung process. Answered for darwin and win32 from any host through the `platform` override — including the refusal to answer for Linux from a host that cannot read Linux's files. `userns.test.ts` is Tier 2 and the one mount suite in the repo that needs neither root nor a `fusermount3`; it is gated on `usernsExecProbe().usable` plus the same raised-threadpool rule the FUSE rootless file uses, and it runs under `pnpm test:rootless`. It does not re-test the filesystem — what the command sees is FUSE, which has its own conformance column — only what is different: the driver at `$MOUNTX_ROOT`, a write landing in the driver (asserted by reading the driver back, never on the command's exit status — an earlier version of this work reported success while discarding every write), the mount staying out of the _host's_ `/proc/self/mounts`, the command's own status surviving, and a relay failure arriving as an error. - `pjdfstest/` — `run.sh`+`run.ts` drive the pinned pjdfstest clone (gitignored) against a real mount and write the committed analysis. - `matrix.ts` — generates `.agents/conformance-matrix.md`. Its `unmetIn()` counts a capability as met when **at least one** target in the column passed a case naming it, not every one — the drivers sharing a column need not have the same capabilities now that `unstorage` runs beside `memory`. `root.sh` — runs any Tier-2 vitest file under sudo with the environment fixed up (raised `UV_THREADPOOL_SIZE`, redirected `TMPDIR`, forwarded `MOUNTX_*`); every Tier-2 file skips itself when not root. `rootless.sh` is the same idea minus the `sudo` and minus everything `sudo` made necessary — `UV_THREADPOOL_SIZE` is all that is left, and `mount-rootless.test.ts` skips itself unless it has been raised. @@ -108,7 +117,7 @@ Native (`native/`), the only non-JS in the repository: `bench/` — `harness.ts` (warmup, adaptive loop, percentiles), `scenarios.ts` (written once against the driver interface), `index.ts` (loopback + NFS columns), `fuse.ts`+`fuse-client.ts` (the FUSE column, client in a child process); generates `.agents/benchmarks.md`. -Docs (`docs/`) — the [undocs](https://undocs.dev) site at . A **standalone pnpm project**, deliberately outside the root workspace (its own `package.json`, lockfile and `pnpm-workspace.yaml`), so the site's dependency tree never reaches the package's: `pnpm install && pnpm dev` inside `docs/`. Three sections, numbered-prefix routing (`1.guide/` → `/guide`): `1.guide/` (introduction, quick start, the CLI, `3.drivers/` — a directory: the interface, then the built-in three and writing your own — mounting, tuning, troubleshooting), `2.transports/` (the overview of what `auto` is choosing between, then `mountx/auto`, FUSE, 9P, NFS — one page carrying both v3 and v4.1, since they are one server behind one `mountNfs()` — and the S3 gateway, guide-level prose first and the full export surface below it, because a transport's API and the protocol it speaks are one subject and two pages of it drift), `3.reference/` (what is left once each entry point is documented beside what it does: the root `mountx` export, split by subject rather than carried as one long page — the driver interface, capabilities, errors, the loopback harness, paths and locking — plus the index that maps every subpath to its page and lists those five — the `mountx/drivers/*` subpaths live in the guide beside the interface they implement, `mountx/auto`, `mountx/fuse`, `mountx/9p`, `mountx/nfs` and `mountx/s3` in `2.transports/`, and the CLI in the guide, since it is a demo and a test bench rather than an entry point). `.config/docs.yaml` is the landing page and the site config; `.docs/public/` holds the icons. **This is where user-facing prose goes now.** `README.md` was cut back to the intro, its snippet, install and a link list when the site landed — a second full copy is one that goes stale, and the CLI mounts the README, so it stays short on purpose. Anything longer than a paragraph belongs on a page here. +Docs (`docs/`) — the [undocs](https://undocs.dev) site at . A **standalone pnpm project**, deliberately outside the root workspace (its own `package.json`, lockfile and `pnpm-workspace.yaml`), so the site's dependency tree never reaches the package's: `pnpm install && pnpm dev` inside `docs/`. Three sections, numbered-prefix routing (`1.guide/` → `/guide`): `1.guide/` (introduction, quick start, the CLI, `3.drivers/` — a directory: the interface, then the built-in three and writing your own — mounting, tuning, troubleshooting), `2.transports/` (the overview of what `auto` is choosing between, then `mountx/auto`, FUSE, 9P, NFS — one page carrying both v3 and v4.1, since they are one server behind one `mountNfs()` the S3 gateway, and `mountx/exec` — which is here rather than in the guide for the reason S3 is: a transport is everything between the driver and whatever is going to read it, and "whatever is going to read it" being one command rather than the machine does not change what the page has to explain. Guide-level prose first and the full export surface below it, because a transport's API and the protocol it speaks are one subject and two pages of it drift), `3.reference/` (what is left once each entry point is documented beside what it does: the root `mountx` export, split by subject rather than carried as one long page — the driver interface, capabilities, errors, the loopback harness, paths and locking — plus the index that maps every subpath to its page and lists those five — the `mountx/drivers/*` subpaths live in the guide beside the interface they implement, `mountx/auto`, `mountx/fuse`, `mountx/9p`, `mountx/nfs`, `mountx/s3` and `mountx/exec` in `2.transports/`, and the CLI in the guide, since it is a demo and a test bench rather than an entry point). `.config/docs.yaml` is the landing page and the site config; `.docs/public/` holds the icons. **This is where user-facing prose goes now.** `README.md` was cut back to the intro, its snippet, install and a link list when the site landed — a second full copy is one that goes stale, and the CLI mounts the README, so it stays short on purpose. Anything longer than a paragraph belongs on a page here. ## Invariants (do not break) @@ -142,7 +151,7 @@ Docs (`docs/`) — the [undocs](https://undocs.dev) site at Date: Wed, 29 Jul 2026 13:37:17 +0000 Subject: [PATCH 5/8] fix(fuse): cross uid and gid through the mount's own id space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every uid/gid on the FUSE wire is named in the id space of the *mount's* user namespace — `fuse_conn.user_ns`, taken from `sb->s_user_ns` at mount time — not in the server's. For every mount `src/fuse/mount.ts` makes those are the same space, which is why nothing here had ever needed to say so. They are not the same for `mountx/exec`, whose mount is made inside an `unshare -U -r` namespace that maps exactly one uid and one gid, both 0. A driver's host ids go on that wire as INVALID_UID, and the VFS then refuses the inode without ever consulting this session: `may_delete()` and `may_linkat()` answer EOVERFLOW ("Inode writeback is not safe when the uid or gid are invalid"), taking out unlink, rmdir, rename and link, and `inode_permission()` answers EACCES via HAS_UNMAPPED_ID() for any write open. Reads, stat and readdir are untouched, so the mount looks entirely healthy until something tries to change it. `FuseSessionOptions.idmap` is the one crossing, the identity by default, so no existing mount changes behaviour. Three sites use it: `#attrOf` outbound, and `#claim` and SETATTR's chown inbound. `#claim` needs it most subtly — it compares the caller against this process, and both sides of that comparison have to be in one id space or every created file is handed to a uid that means nothing on the serving side. `-1` stays `-1`: it is POSIX's "leave this one alone", not an id. Co-Authored-By: Claude Opus 5 --- src/fuse/session.ts | 79 +++++++++++++++++++++--- test/fuse/session.test.ts | 127 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 7 deletions(-) diff --git a/src/fuse/session.ts b/src/fuse/session.ts index a904ac2..77ffcc9 100644 --- a/src/fuse/session.ts +++ b/src/fuse/session.ts @@ -166,6 +166,51 @@ export const DEFAULT_ENTRY_TIMEOUT = 10; /** `RENAME_NOREPLACE | RENAME_EXCHANGE | RENAME_WHITEOUT` — none supported. */ const RENAME_FLAGS_UNSUPPORTED = 0b111; +/** + * How a uid or gid crosses between the driver's id space and the mount's. + * + * **The two are not always the same space.** Every `uid`/`gid` on the FUSE wire + * — `fuse_attr.uid` outbound, `fuse_in_header.uid` and `fuse_setattr_in.uid` + * inbound — is named in the id space of the *mount's* user namespace, which the + * kernel keeps as `fuse_conn.user_ns` (`fs/fuse/inode.c`, from `sb->s_user_ns`) + * and resolves every attribute through: `inode->i_uid = make_kuid(fc->user_ns, + * attr->uid)`. A driver, meanwhile, deals in whatever `process.getuid()` means + * here. Mount from the same user namespace the server runs in — which is every + * mount `mountx/fuse` makes on its own — and the two spaces are identical, so + * the default map is the identity and this option is one nobody needs. + * + * `mountx/exec` is the case where they are not. Its mount is made inside an + * `unshare -U -r` user namespace, whose entire id space is `{0}`, mapped to the + * invoking user on the host. An id the namespace does not map becomes + * `INVALID_UID`, and the VFS refuses an inode carrying one — **before** the + * request ever reaches this session: + * + * - `may_delete()` answers `-EOVERFLOW` ("Inode writeback is not safe when the + * uid or gid are invalid"), which takes out `unlink`, `rmdir` and `rename` on + * both the source and an existing destination; + * - `may_linkat()` answers `-EOVERFLOW` too, taking out `link`; + * - `inode_permission()` answers `-EACCES` for any `MAY_WRITE` via + * `HAS_UNMAPPED_ID()`, taking out opening a file for writing. + * + * Reads, `stat` and `readdir` are unaffected, which is what makes the failure + * so confusing to meet: the mount looks entirely healthy until something tries + * to change it. See `src/exec/userns.ts`'s `usernsIdMap()` for the map that + * fixes it, and the reasoning about which ids it can possibly answer with. + * + * Both hooks are synchronous and must be total: they sit on the encode path of + * every `LOOKUP` reply, so a throw here is an error reply for a request that + * had nothing wrong with it. + */ +export interface FuseIdMap { + /** A driver-side uid (or gid, when `group`) as the mount's namespace names it. */ + toMount: (id: number, group: boolean) => number; + /** A uid (or gid, when `group`) off the wire, in the driver's own id space. */ + fromMount: (id: number, group: boolean) => number; +} + +/** The map for a mount in the server's own user namespace: no translation at all. */ +const IDENTITY_IDS: FuseIdMap = { toMount: (id) => id, fromMount: (id) => id }; + export interface FuseSessionOptions { /** Passed to `negotiateInit` when the kernel's `FUSE_INIT` arrives. */ init?: InitPreferences; @@ -187,6 +232,12 @@ export interface FuseSessionOptions { negativeTimeout?: number; /** Identify files by the driver's `(dev, ino)`, so hardlinks share a nodeid. Default `true`. */ useDriverIno?: boolean; + /** + * Translate uids and gids between the driver's id space and the mount's. + * Default: the identity, which is right for every mount made from the user + * namespace the server runs in. See {@link FuseIdMap} for the one that is not. + */ + idmap?: FuseIdMap; /** Run the reply-exactly-once assertions. Default on outside production. */ debug?: boolean; /** Called for every request that ends in an error reply. */ @@ -405,6 +456,7 @@ export class FuseSession { readonly #filesByInode = new Map>(); readonly #inflight = new Set(); readonly #lock = new PathLock(); + readonly #ids: FuseIdMap; readonly #debug: boolean; #negotiated: NegotiatedSession | undefined; #destroyed = false; @@ -414,6 +466,7 @@ export class FuseSession { this.driver = createLoopback(driver); this.options = options; this.#inodes = new InodeTable({ useDriverIno: options.useDriverIno }); + this.#ids = options.idmap ?? IDENTITY_IDS; this.#debug = options.debug ?? process.env.NODE_ENV !== "production"; } @@ -785,8 +838,11 @@ export class FuseSession { // the mount disagree with every real filesystem for the whole // `mkstemp`+`unlink` pattern (found by the differential suite). nlink: toUnsigned(stats.nlink), - uid: toUnsigned(stats.uid), - gid: toUnsigned(stats.gid), + // The mount's id space, not the driver's — the identity unless the mount + // lives in another user namespace. See {@link FuseIdMap}: an id this + // mount cannot map is an inode the *kernel* refuses to unlink or write. + uid: toUnsigned(this.#ids.toMount(stats.uid, false)), + gid: toUnsigned(this.#ids.toMount(stats.gid, true)), rdev: toUnsigned(stats.rdev), blksize: toUnsigned(stats.blksize), flags: 0, @@ -873,13 +929,20 @@ export class FuseSession { * set-gid directory rule that gives a new entry its parent's group. */ async #claim(path: string, header: FuseInHeader): Promise { + // Both sides of the comparison in the *driver's* id space: `header.uid` is + // the mount's, and on a mount in another user namespace the caller who is + // in fact this process arrives as some other number (0, under `unshare -r`). + // Comparing the raw wire value there would hand every created file to an id + // the driver has never heard of. + const caller = this.#ids.fromMount(header.uid, false); + const callerGroup = this.#ids.fromMount(header.gid, true); const uid = process.getuid?.() ?? -1; const gid = process.getgid?.() ?? -1; - if (header.uid === uid && header.gid === gid) { + if (caller === uid && callerGroup === gid) { return; } try { - await this.driver.lchown(path, header.uid, header.gid); + await this.driver.lchown(path, caller, callerGroup); } catch (error) { const code = (error as { code?: string }).code; if (code !== "ENOSYS" && code !== "EPERM" && code !== "ENOTSUP") { @@ -977,10 +1040,12 @@ export class FuseSession { } if ((valid & (FATTR_UID | FATTR_GID)) !== 0) { // `-1` is POSIX for "leave this one alone", and every driver's `chown` - // inherits that from `node:fs`. + // inherits that from `node:fs` — so it stays `-1` rather than being run + // through the id map, which has no reason to have an opinion about a + // sentinel. const [uid, gid] = [ - (valid & FATTR_UID) === 0 ? -1 : body.uid, - (valid & FATTR_GID) === 0 ? -1 : body.gid, + (valid & FATTR_UID) === 0 ? -1 : this.#ids.fromMount(body.uid, false), + (valid & FATTR_GID) === 0 ? -1 : this.#ids.fromMount(body.gid, true), ]; await this.#nofollow( () => this.driver.lchown(path(), uid, gid), diff --git a/test/fuse/session.test.ts b/test/fuse/session.test.ts index 87f5362..3c4f024 100644 --- a/test/fuse/session.test.ts +++ b/test/fuse/session.test.ts @@ -16,9 +16,11 @@ import { createNodeFsDriver } from "../../src/drivers/node-fs.ts"; import { ERRNO_CODES } from "../../src/errors.ts"; import { FATTR_ATIME, + FATTR_GID, FATTR_MODE, FATTR_MTIME, FATTR_SIZE, + FATTR_UID, FUSE_GETATTR, FUSE_INIT, FUSE_LOOKUP, @@ -44,6 +46,7 @@ import { type FuseInitOut, } from "../../src/fuse/protocol.ts"; import { createFuseSession, FuseSession, type FuseSessionOptions } from "../../src/fuse/session.ts"; +import { createLoopback } from "../../src/harness.ts"; import { S_IFDIR, S_IFMT, S_IFREG, type FsDriver, type StatsLike } from "../../src/types.ts"; import { KernelError, SyntheticKernel } from "./synthetic-kernel.ts"; @@ -779,6 +782,130 @@ describe("SETATTR", () => { }); }); +describe("id maps", () => { + /** + * A driver whose files are owned by {@link OWNER}, and a kernel whose caller + * is 0 — the shape `mountx/exec` produces, where the mount lives in an + * `unshare -r` user namespace that maps the invoking user onto 0 and nothing + * else. + * + * The stakes are not cosmetic: an id the *mount's* namespace cannot map makes + * `may_delete()` answer `-EOVERFLOW` and `inode_permission()` answer `-EACCES` + * on write, both inside the VFS, so the session never even sees the request + * that failed. There is no way to catch that here — no kernel — so what is + * pinned instead is the byte that decides it. + * + * The uid and gid are deliberately different numbers, and the map answers + * them separately, so a crossing that passed the wrong `group` flag — or + * dropped one of the two — shows up as a wrong number rather than as a value + * that happens to be right for both. + */ + const OWNER = { uid: 1000, gid: 2000 }; + const NS_IDS = { + toMount: () => 0, + fromMount: (id: number, group: boolean) => (id === 0 ? (group ? 2000 : 1000) : id), + }; + + function namespaced(options: FuseSessionOptions = {}): { + driver: ReturnType; + session: FuseSession; + kernel: SyntheticKernel; + } { + const driver = createMemoryDriver(OWNER); + const session = new FuseSession(driver, options); + return { driver, session, kernel: new SyntheticKernel(session, { uid: 0, gid: 0 }) }; + } + + it("puts the mount's ids on the wire, not the driver's", async () => { + const { driver, session, kernel } = namespaced({ idmap: NS_IDS }); + await kernel.init(); + await createLoopback(driver).writeFile("/pre.txt", "x\n"); + + // Both the paths a nodeid is learned through, because the bug this pins was + // discovered as "created files can be deleted and looked-up ones cannot". + const entry = await kernel.lookup(FUSE_ROOT_ID, "pre.txt"); + expect(entry.attr.uid).toBe(0); + expect(entry.attr.gid).toBe(0); + const attr = await kernel.getattr(entry.nodeid); + expect(attr.attr.uid).toBe(0); + expect(attr.attr.gid).toBe(0); + expectHealthy(session); + }); + + it("is the identity with no map configured", async () => { + const { driver, session, kernel } = namespaced(); + await kernel.init(); + await createLoopback(driver).writeFile("/pre.txt", "x\n"); + + const entry = await kernel.lookup(FUSE_ROOT_ID, "pre.txt"); + expect(entry.attr.uid).toBe(1000); + expect(entry.attr.gid).toBe(2000); + expectHealthy(session); + }); + + it("reads the caller's id back into the driver's space before handing a file over", async () => { + // The ownership hand-off compares the caller against this process, and both + // sides of that comparison have to be in one id space. Read the wire value + // raw and a caller who *is* this process wearing another number looks like + // a stranger, so every file the command creates is handed to a uid 0 that + // means nothing out here — which is what used to happen. + const { driver, session, kernel } = namespaced({ idmap: NS_IDS }); + await kernel.init(); + await kernel.mkdir(FUSE_ROOT_ID, "made"); + const stats = await createLoopback(driver).lstat("/made"); + expect([stats.uid, stats.gid]).toEqual([1000, 2000]); + expectHealthy(session); + }); + + it("still hands a file over to a caller who really is someone else", async () => { + // The map must not have quietly disabled the hand-off: an id it does not + // rewrite is a different user, and gets the file exactly as before. Derived + // from this process's own ids so it is a stranger on any host. + const foreign = Math.max(process.getuid?.() ?? 0, process.getgid?.() ?? 0) + 4242; + const { driver, session, kernel } = namespaced({ idmap: NS_IDS }); + await kernel.init(); + const other = new SyntheticKernel(session, { uid: foreign, gid: foreign }); + await other.mkdir(FUSE_ROOT_ID, "theirs"); + const stats = await createLoopback(driver).lstat("/theirs"); + expect([stats.uid, stats.gid]).toEqual([foreign, foreign]); + expectHealthy(session); + }); + + it("maps SETATTR's uid and gid back into the driver's space", async () => { + const { driver, session, kernel } = namespaced({ idmap: NS_IDS }); + await kernel.init(); + const fs = createLoopback(driver); + await fs.writeFile("/pre.txt", "x\n"); + await fs.lchown("/pre.txt", 7, 7); + + const entry = await kernel.lookup(FUSE_ROOT_ID, "pre.txt"); + // `chown 0:0` from inside the namespace means "make it mine", and mine out + // here is 1000:2000 — not a 0:0 the driver's world has never heard of. + await kernel.setattr(entry.nodeid, { valid: FATTR_UID | FATTR_GID, uid: 0, gid: 0 }); + const stats = await fs.lstat("/pre.txt"); + expect([stats.uid, stats.gid]).toEqual([1000, 2000]); + expectHealthy(session); + }); + + it("leaves SETATTR's `leave it alone` sentinel out of the map", async () => { + // `-1` is POSIX, not an id, and a map that saw it would chown the half the + // caller asked to keep. The map here would mangle it visibly. + const { driver, session, kernel } = namespaced({ + idmap: { toMount: (id) => id, fromMount: (id) => id + 100 }, + }); + await kernel.init(); + const fs = createLoopback(driver); + await fs.writeFile("/pre.txt", "x\n"); + await fs.lchown("/pre.txt", 7, 9); + + const entry = await kernel.lookup(FUSE_ROOT_ID, "pre.txt"); + await kernel.setattr(entry.nodeid, { valid: FATTR_UID, uid: 5, gid: 0 }); + const stats = await fs.lstat("/pre.txt"); + expect([stats.uid, stats.gid]).toEqual([105, 9]); + expectHealthy(session); + }); +}); + describe("errno discipline", () => { /** A driver that fails one method, however it likes. */ function failing(method: string, error: unknown): FsDriver { From ae38f5a4e4b4d3e6047b90b65855760ea4ef4e87 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 29 Jul 2026 13:37:31 +0000 Subject: [PATCH 6/8] fix(exec): give the namespace's one identity to every entry the driver holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command could remove a file it had created and could not remove one that was already in the driver: `rm: cannot remove '...': Value too large for defined data type`, with no unlink ever reaching the driver. Same for rmdir, rename and hardlink, and a write open on a pre-existing file answered EACCES. The discriminator was not the operation but how the kernel had learned of the inode — a CREATE reply had already been chowned to the caller's 0 by the session's ownership hand-off, so it carried an id the namespace maps; a LOOKUP reply carried the driver's host uid, which it does not, and the VFS refuses an inode with an unmapped id before the request is ever sent. `usernsIdMap()` is the map `unshare -U -r` actually installs. Outbound is the constant 0, and there is no other choice: the namespace's whole id space has one element, and answering the kernel's own `nobody` for foreign ids reinstates the bug verbatim, since 65534 is unmapped in here too. A driver over a tree with mixed ownership therefore presents as uniformly root-owned inside — the truthful rendering of a place with one identity in it and no `allow_other` to admit a second. Inbound reads 0 back as the invoking user, which also fixes the quieter half of this: the hand-off now recognizes the command as the process it already is, so a file it creates stays owned in the driver by whoever ran mountx instead of by a uid 0 that means nothing outside the namespace. Pure and exported, because checking it otherwise costs a namespace, a mount and a subprocess. Co-Authored-By: Claude Opus 5 --- src/exec/userns.ts | 60 ++++++++++++++++++++++++++++++++++++-- test/exec/strategy.test.ts | 37 ++++++++++++++++++++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/exec/userns.ts b/src/exec/userns.ts index a211548..f7a6bfe 100644 --- a/src/exec/userns.ts +++ b/src/exec/userns.ts @@ -44,7 +44,7 @@ import { mkdir, mkdtemp, rm, stat } from "node:fs/promises"; import * as net from "node:net"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; -import { FuseSession, type FuseSessionOptions } from "../fuse/session.ts"; +import { FuseSession, type FuseIdMap, type FuseSessionOptions } from "../fuse/session.ts"; import type { FsDriver } from "../types.ts"; import { usernsExecProbe } from "./probe.ts"; @@ -74,6 +74,55 @@ const LEN_SIZE = 4; */ const DEFAULT_MOUNT_OPTIONS: readonly string[] = []; +/** + * The id map for a mount made inside `unshare -U -r`, whose whole id space is + * `{0}`. + * + * **Why any translation is needed.** `-r` writes exactly one line into + * `/proc/self/uid_map` and one into `gid_map`: `0 1`. So the + * namespace knows one uid and one gid, both 0, and every other number in + * existence is unmapped in it. The driver, meanwhile, reports host ids — every + * driver in this repository owns its files as `process.getuid()`, because that + * is who created them. + * + * Put a host id on the wire unchanged and `make_kuid(fc->user_ns, 1000)` yields + * `INVALID_UID`. The mount then looks fine and is not: `stat`, `read` and + * `readdir` all work, while the VFS refuses, without ever consulting the + * server, to `unlink`, `rmdir`, `rename` or `link` the inode (`-EOVERFLOW`, + * from `may_delete()`/`may_linkat()`'s "Inode writeback is not safe when the + * uid or gid are invalid") or to open it for writing (`-EACCES`, from + * `inode_permission()`'s `HAS_UNMAPPED_ID()`). Witnessed as `rm: cannot remove + * '…': Value too large for defined data type` on every entry the driver held + * before the mount; entries the *command* created escaped it only because the + * session's ownership hand-off had already chowned them to the caller's `0`. + * + * **Outbound is the constant `0`, and there is no other choice.** `toMount` has + * to land in the set of ids the namespace maps, and that set has one element. + * Answering `65534`/`nobody` for ids that are not the invoking user's — the + * rendering the kernel itself would pick — reintroduces the bug verbatim, since + * `nobody` is unmapped in here too. A driver over a tree with mixed ownership + * therefore presents as uniformly root-owned inside the namespace, which is the + * truthful rendering of a place with exactly one identity in it and no + * `allow_other` to let a second one in. + * + * **Inbound maps `0` back to the invoking user**, so the session's ownership + * hand-off sees the caller as the process it already is and skips the chown + * entirely, leaving a file the command created owned in the driver by whoever + * is running mountx rather than by a uid 0 that means nothing out here. Any + * other id is returned unchanged: nothing can produce one — `chown_common()` + * rejects an unmapped id with `EINVAL` long before FUSE is asked — and a map + * on this path still has to be total. + * + * Pure, and exported for the Tier-0 test: the alternative way to check it costs + * a namespace, a mount and a subprocess. + */ +export function usernsIdMap(uid: number, gid: number): FuseIdMap { + return { + toMount: () => 0, + fromMount: (id, group) => (id === 0 ? (group ? gid : uid) : id), + }; +} + /** * Where the relay is, relative to this module, in each layout it can be in. * @@ -227,7 +276,14 @@ export async function execUserns( throw new Error(`mountx: ${refusal}`); } - session = new FuseSession(driver, options); + // The id map is this mechanism's, not the caller's business — `-r` is not + // negotiable here (see the `unshare` arguments below), so what it maps is a + // fact about the mount rather than a preference. A caller who has arranged + // a wider mapping some other way can still say so. + session = new FuseSession(driver, { + ...options, + idmap: options.idmap ?? usernsIdMap(process.getuid?.() ?? 0, process.getgid?.() ?? 0), + }); const attachedSession = session; server = net.createServer(); /** Resolves once the relay has connected and the session is wired to it. */ diff --git a/test/exec/strategy.test.ts b/test/exec/strategy.test.ts index 4abfd14..6176589 100644 --- a/test/exec/strategy.test.ts +++ b/test/exec/strategy.test.ts @@ -15,7 +15,7 @@ import { describe, expect, it } from "vitest"; import { createMemoryDriver } from "../../src/drivers/memory.ts"; import { exec, probeExec } from "../../src/exec/index.ts"; import { usernsExecProbe } from "../../src/exec/probe.ts"; -import { cwdRefusal } from "../../src/exec/userns.ts"; +import { cwdRefusal, usernsIdMap } from "../../src/exec/userns.ts"; const here = probeExec(); @@ -115,6 +115,41 @@ describe("cwdRefusal", () => { }); }); +describe("usernsIdMap", () => { + const map = usernsIdMap(1000, 2000); + + it("answers 0 for every driver-side id, because that is the whole id space", () => { + // `unshare -r` maps one uid and one gid, both 0. Anything else on the wire + // is `INVALID_UID` to the kernel, and an inode carrying one cannot be + // unlinked, renamed, linked or opened for writing — the VFS refuses in + // `may_delete()`/`may_linkat()` (`EOVERFLOW`) and `inode_permission()` + // (`EACCES`) without ever asking the server. So the invoking user's own id + // is not a special case: there is nowhere else for any id to go. + expect(map.toMount(1000, false)).toBe(0); + expect(map.toMount(2000, true)).toBe(0); + expect(map.toMount(0, false)).toBe(0); + // Including the one the kernel itself would have picked for an unmapped id: + // `nobody` is unmapped in here too, so answering it reinstates the bug. + expect(map.toMount(65_534, false)).toBe(0); + expect(map.toMount(65_534, true)).toBe(0); + }); + + it("reads 0 back as the invoking user, uid and gid told apart", () => { + // Which is what lets the session see the command as the process it already + // is, and leave the files it creates owned by whoever ran mountx. + expect(map.fromMount(0, false)).toBe(1000); + expect(map.fromMount(0, true)).toBe(2000); + }); + + it("is total, and leaves an id it does not know alone", () => { + // Nothing can produce one — `chown_common()` rejects an unmapped id with + // `EINVAL` before FUSE is consulted — but the map still sits on the encode + // path of every reply and may not throw. + expect(map.fromMount(4242, false)).toBe(4242); + expect(map.fromMount(65_534, true)).toBe(65_534); + }); +}); + describe("exec", () => { it("needs a command", async () => { await expect(exec(createMemoryDriver(), [])).rejects.toThrow(/needs a command to run/); From e057cd39c2da104309548997f66915bb001e0f6d Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 29 Jul 2026 13:37:39 +0000 Subject: [PATCH 7/8] test(exec): pre-existing entries survive the round trip through the namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file this belongs in already said it deliberately does not re-test the filesystem, because FUSE has its own conformance column. That reasoning has a hole on this host: there is no `fusermount3` and the root column needs sudo, so this is the only Tier-2 FUSE mount column that actually runs here, and the id-space bug lived in the gap for as long as it did because of it. So one case that *is* filesystem behaviour, chosen to be exactly the one no other column can reach: unlink, rmdir, rename, hardlink, truncate, chmod and readdir over entries — a file, a directory, a symlink and a whole subtree — that the driver held *before* the mount. Every assertion reads the driver back afterwards rather than trusting the command's exit status, which is the rule this file already had and the reason the original bug was not louder. Plus the inbound half: a file the command creates is owned in the driver by whoever ran mountx. Both fail without the fix, the first with the reported EOVERFLOW. Co-Authored-By: Claude Opus 5 --- test/exec/userns.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/test/exec/userns.test.ts b/test/exec/userns.test.ts index c17b3f1..3267eae 100644 --- a/test/exec/userns.test.ts +++ b/test/exec/userns.test.ts @@ -118,6 +118,87 @@ describe.skipIf(!live)("execUserns", () => { SLOW, ); + it( + "removes, renames and links entries that were in the driver before the mount", + async () => { + // **The regression this file exists for most.** Every one of these + // answered `EOVERFLOW` ("Value too large for defined data type") until the + // session learned the mount's id space, and the discriminator was not the + // operation but how the kernel had learned about the inode: a file the + // command had itself created could be removed, a file that arrived via + // `LOOKUP` could not. Both are the same driver, the same session and the + // same syscall — what differed was that the created one had already been + // chowned to the caller, and so carried an id the namespace maps. + // + // The VFS refuses these in `may_delete()` and `may_linkat()` before the + // request reaches the server at all, so nothing on the mountx side sees + // an error; the mount reads perfectly and simply cannot be changed. + const driver = createMemoryDriver(); + const fs = createLoopback(driver); + await fs.writeFile("/gone.txt", "delete me\n"); + await fs.mkdir("/gone-dir"); + await fs.writeFile("/from.txt", "rename me\n"); + await fs.writeFile("/linked.txt", "link me\n"); + await fs.writeFile("/grow.txt", "truncate me\n"); + await fs.writeFile("/mode.txt", "chmod me\n"); + await fs.symlink("mode.txt", "/gone-link"); + // A whole pre-existing subtree, which is the realistic shape of this: + // `rm -rf` walks in and every inode it meets arrived through `LOOKUP`. + await fs.mkdir("/tree/deep", { recursive: true }); + await fs.writeFile("/tree/deep/leaf.txt", "leaf\n"); + + const ran = await execUserns(driver, [ + "sh", + "-c", + 'set -e; cd /; R="$MOUNTX_ROOT"; ' + + 'rm "$R/gone.txt"; rmdir "$R/gone-dir"; rm "$R/gone-link"; rm -rf "$R/tree"; ' + + 'mv "$R/from.txt" "$R/to.txt"; ' + + 'ln "$R/linked.txt" "$R/linked2.txt"; printf short > "$R/grow.txt"; ' + + 'chmod 600 "$R/mode.txt"; ls "$R" > "$R/listing"', + ]); + expect(ran.code).toBe(0); + + // Asserted against the driver, never against the exit status — the whole + // point of the original bug is that plenty of it looked like it worked. + const names = (await fs.readdir("/", { withFileTypes: true })).map((entry) => entry.name); + expect(names).not.toContain("gone.txt"); + expect(names).not.toContain("gone-dir"); + expect(names).not.toContain("gone-link"); + expect(names).not.toContain("tree"); + expect(names).not.toContain("from.txt"); + expect(names).toContain("to.txt"); + expect(names).toContain("linked2.txt"); + expect((await fs.lstat("/linked.txt")).nlink).toBe(2); + expect(Buffer.from(await fs.readFile("/grow.txt")).toString("utf8")).toBe("short"); + expect((await fs.stat("/mode.txt")).mode & 0o777).toBe(0o600); + // And a plain `readdir` of pre-existing entries, which never broke, so a + // fix that traded it away would be caught here. + const listing = Buffer.from(await fs.readFile("/listing")).toString("utf8"); + expect(listing).toContain("to.txt"); + expect(listing).toContain("linked2.txt"); + }, + SLOW, + ); + + it( + "leaves files the command created owned by whoever ran mountx", + async () => { + // The other half of the same crossing. `fuse_in_header.uid` is in the + // mount's id space, so a caller who is in fact this process arrives as 0; + // read raw, the session's ownership hand-off saw a stranger and chowned + // every new file to a uid 0 that means nothing on this side of the + // namespace. Reading the id back through the map makes the caller + // recognizable again. + const driver = await demo(); + const ran = await execUserns(driver, ["sh", "-c", 'printf x > "$MOUNTX_ROOT/mine.txt"']); + expect(ran.code).toBe(0); + const stats = await createLoopback(driver).lstat("/mine.txt"); + expect(stats.uid).toBe(process.getuid?.()); + expect(stats.gid).toBe(process.getgid?.()); + }, + SLOW, + ); + it( "mounts where it was told, and leaves nothing behind", async () => { From c0d75ee3f753ae6329cab3c23b8dfbfb26aade1c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 29 Jul 2026 13:37:46 +0000 Subject: [PATCH 8/8] docs: the id space a namespace mount names its files in An invariant, because it is the kind of thing that gets "fixed" in the wrong place twice: a driver that reports 0 to please one mount is lying to every other consumer of the same driver. The exec page gets a section of its own, since uniformly root-owned is user-visible behaviour with a reason worth stating rather than an implementation detail, and the FUSE page gets `idmap` in its options table with the note that mounting with `mount()` never needs it. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 7 ++++--- docs/2.transports/2.fuse.md | 3 +++ docs/2.transports/6.exec.md | 8 ++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3b86b72..6ec7e0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ FUSE (`src/fuse/`, exported as `mountx/fuse`): - `protocol.ts` — every struct encoded **and** decoded, opcode dispatch table (`OPCODES`), message framing, errno-on-the-wire helpers, dirent packing (`DirentPacker`). - `init.ts` — `negotiateInit(kernelInit, preferences)`, pure. - `flags.ts` — the two `open(2)` flag namespaces, pure: `driverOpenFlags()` turns the kernel's `O_*` into the host's for the hand-off to a driver (the identity on Linux, where the wire _is_ the host, so unnamed bits survive), and `reopenFlags()` drops the one-shot creation flags a `handles: false` re-open must not repeat. The translation exists because Tier-0 tests drive a real session on whatever host runs `pnpm test`, and macOS's `O_TRUNC` is Linux's `O_APPEND`. -- `session.ts` — `FuseSession(driver, options)`: `INIT` handshake, opcode switch, file-handle table, readdir paging, `SETATTR` bitmask → driver calls, notify encoders. +- `session.ts` — `FuseSession(driver, options)`: `INIT` handshake, opcode switch, file-handle table, readdir paging, `SETATTR` bitmask → driver calls, notify encoders. `FuseIdMap` (`options.idmap`, default the identity) is the one thing here that is about the mount rather than the protocol: every `uid`/`gid` on the wire is named in the id space of the mount's user namespace (`fuse_conn.user_ns`, from `sb->s_user_ns`), which is the server's own for every mount `mount.ts` makes and is **not** for `mountx/exec`'s. Three crossings use it — `#attrOf` outbound, `#claim` and `SETATTR`'s chown inbound — and `-1` stays `-1`, being POSIX's "leave it alone" rather than an id. - `inodes.ts` — `InodeTable`: nodeid ↔ path ↔ `(dev, ino)`, lookup refcounting, subtree remap on rename, orphans. Entirely synchronous. - `notify.ts` — `notify_inval_inode`/`notify_inval_entry` codecs. - `mount.ts` — `mount(driver, mountpoint, options)` → `Mount`, plus `unmountAll()`/`liveMounts()`. Picks its path by uid: root opens `/dev/fuse` here and spawns `mount(8)` with the descriptor at its own fd number; everyone else goes through `fusermount.ts`. Past the descriptor the two paths are the same code. @@ -83,7 +83,7 @@ Exec (`src/exec/`, exported as `mountx/exec`): the other entry point that is not - `probe.ts` — `usernsExecProbe()` and the `ExecPlatform` narrowing, import-light in the exact sense `src/nfs/probe.ts` and `src/9p/probe.ts` are — `node:fs` and nothing else, so asking never pulls in a FUSE session. The `/dev/fuse` check is an `open(2)` rather than a `stat`, and it runs **before** `/proc/filesystems` is read, because `/dev/fuse` is a misc device and opening it is what autoloads the module that puts `fuse` in that table; it also tells the two actionable failures apart, and says of the `EACCES` one that a namespace does not rescue it (`unshare -r` maps exactly one uid, so the check made inside is the one that already failed outside, and namespace `CAP_DAC_OVERRIDE` does not reach a node owned by an unmapped uid). Three sysctls cover the three distribution idioms for disabling unprivileged user namespaces (`user.max_user_namespaces`, Debian's `unprivileged_userns_clone`, Ubuntu 23.10+'s `apparmor_restrict_unprivileged_userns`), the last two read only when not root because they gate the unprivileged path alone. `NOT_THIS_HOST` is the one verdict a `platform` override forces: every fact below `platform` is a file about _this_ machine, so answering `usable` for Linux from somewhere that is not Linux would rest on checks that never ran. - `index.ts` — `exec(driver, argv, options)` and `probeExec()`, in `src/auto.ts`'s shape and with all three of its refusals for its reasons: no fallback after a failure, no probe when a mechanism is named, and no loading of what it does not use (the mechanism arrives via `await import()`). There is **one mechanism**, `userns`, and the file is shaped for more than one on purpose — `ExecMechanism`, `ExecProbe.preference` and the `mechanism` discriminant on the result are the seam a second arrives through without an API change, which is why a union of one arm and a one-element preference list are written out rather than collapsed. A second mechanism (a seccomp user-notification supervisor, needing no device node at all) was built and measured beside this one and is **not on this branch**: it lives in the history of PR #9 on `pithings/mountx`, and `.agents/proot-plan.md` keeps what it showed. The result is the mechanism's own result object with the discriminant defined on it — tagged, not wrapped. Shared options are the ones that would mean the same thing however the driver reached the command (`root`, `cwd`, `env`, `useDriverIno`, `debug`); anything mechanism-specific goes in `userns: {…}`. -- `userns.ts` — `execUserns()`: FUSE inside an unprivileged user namespace, the driver staying in this process. `unshare(CLONE_NEWUSER)` demands a single-threaded caller and Node is never single-threaded, so the namespace is entered by a child (`userns-relay.ts`) and raw FUSE traffic comes back over a unix socket to a `FuseSession` here — the "relay mode" the roadmap defers, arrived at from the other direction. `cwdRefusal()` is pure and exported: a `cwd` inside the mountpoint is the `uv_spawn` deadlock `src/fuse/mount.ts` and `src/9p/mount.ts` document, met from the inside, and it is refused rather than hung on. `DEFAULT_MOUNT_OPTIONS` is **empty on purpose** — `default_permissions` asks the kernel to check a driver's uid against a namespace that maps exactly one, so a driver reporting the serving process's real uid renders as `nobody` and every write fails `EACCES`; permission checking stays with the driver, and nothing is lost because the mount carries no `allow_other`. The `unshare` flags are spelled short (`-U -r -m --propagation private`) because busybox 1.37 has `-r` and no `--map-root-user`, which is what bare-Alpine support rests on. `relayPath()` walks `RELAY_CANDIDATES` — the sibling `.ts`, the sibling `.mjs`, then `../exec/userns-relay.mjs` — because the relay is spawned rather than imported and obuild answers `index.ts`'s dynamic `import("./userns.ts")` with `dist/_chunks/userns.mjs`, where the relay is _not_ a sibling; a fourth layout announces itself as this file's own error rather than as a mystery `ENOENT` from `spawn`. +- `userns.ts` — `execUserns()`: FUSE inside an unprivileged user namespace, the driver staying in this process. `unshare(CLONE_NEWUSER)` demands a single-threaded caller and Node is never single-threaded, so the namespace is entered by a child (`userns-relay.ts`) and raw FUSE traffic comes back over a unix socket to a `FuseSession` here — the "relay mode" the roadmap defers, arrived at from the other direction. `cwdRefusal()` is pure and exported: a `cwd` inside the mountpoint is the `uv_spawn` deadlock `src/fuse/mount.ts` and `src/9p/mount.ts` document, met from the inside, and it is refused rather than hung on. `usernsIdMap()` is pure and exported too, and is what makes the mount _writable_: `-r` maps one uid and one gid, both 0, so a driver's host ids go on the wire as `INVALID_UID` and the VFS then refuses to `unlink`, `rmdir`, `rename` or `link` the inode (`EOVERFLOW` out of `may_delete()`/`may_linkat()`) or open it for writing (`EACCES` out of `inode_permission()`'s `HAS_UNMAPPED_ID()`) — all of it _before_ the request reaches the session, which is why the mount reads perfectly while nothing can change it. Outbound is therefore the constant `0`: the namespace's whole id space has one element, and `nobody` is not in it either. Inbound reads `0` back as the invoking user, which is what lets `#claim` recognize the command as this process and leave a file it created owned by whoever ran mountx. `DEFAULT_MOUNT_OPTIONS` is **empty on purpose** — `default_permissions` asks the kernel to check a driver's uid against a namespace that maps exactly one, so a driver reporting the serving process's real uid renders as `nobody` and every write fails `EACCES`; permission checking stays with the driver, and nothing is lost because the mount carries no `allow_other`. The `unshare` flags are spelled short (`-U -r -m --propagation private`) because busybox 1.37 has `-r` and no `--map-root-user`, which is what bare-Alpine support rests on. `relayPath()` walks `RELAY_CANDIDATES` — the sibling `.ts`, the sibling `.mjs`, then `../exec/userns-relay.mjs` — because the relay is spawned rather than imported and obuild answers `index.ts`'s dynamic `import("./userns.ts")` with `dist/_chunks/userns.mjs`, where the relay is _not_ a sibling; a fourth layout announces itself as this file's own error rather than as a mystery `ENOENT` from `spawn`. - `userns-relay.ts` — the only thing that runs _inside_ the namespace, and it has no imports from `src/`. Opens `/dev/fuse`, spawns `mount(8)` (in there it is uid 0 with `CAP_SYS_ADMIN`, so this is the ordinary root path — no `fusermount3`, no setuid bit, no native addon), and pumps whole messages both ways; FUSE's own `len` field is the framing, and the one rule the socket does not carry is that a reply must reach the device in a single `write(2)`, which is what the reassembly buffer is for. The command's `cwd` is deliberately _not_ the mountpoint — the mountpoint travels as `$MOUNTX_ROOT` so a `cd` happens after the exec. A closed socket exits the relay, because a parent that went away leaves every later request parked in `fuse_get_req` forever. `fail()` also writes its message to `$MOUNTX_RELAY_STATUS`: the relay's exit code is indistinguishable from the command's, and "the command exited 70" is a thing that happens. It is a **separate build entry** (`build.config.ts`) rather than a subpath export, for the same spawned-not-imported reason. - `demo-driver.ts`, `demo-userns.ts` — a test bench, not an entry point: one demo tree and one runner, calling `execUserns()` _by name_ so that what runs is one named mechanism rather than whatever the picker would have chosen. `node src/exec/demo-userns.ts [command...]`. @@ -102,7 +102,7 @@ Tests (`test/`): - `fuse/` — protocol/session Tier 0 (`random.ts`, `protocol.test.ts`, `golden.test.ts`, `dirent.test.ts`, `init.test.ts`, `flags.test.ts`, `session.test.ts`, `inodes.test.ts`, `session-fuzz.test.ts`, `synthetic-kernel.ts`, `fuzz.test.ts`), `fusermount.test.ts` (the elevation checks and the device-refusal advice, Tier 0 — pure, so it runs on a host with no `fuse3` at all), `native.test.ts` (the whole addon, Tier 0 — passing a descriptor to yourself needs no helper and no privileges), Tier 2 `mount.test.ts` and `mount-rootless.test.ts` (no sudo), the differential oracle (`differential.ts`+`differential.test.ts`), record/replay (`record-fixtures.ts`+`replay.test.ts`), the FUSE conformance column (`conformance-mount.test.ts`). - `nfs/` — Tier 0 for the shared layer (`xdr.test.ts`, `handles.test.ts`, `mount-options.test.ts` — the platform difference, checked from either host) plus `session.test.ts` for the version router alone: which session a `(prog, vers)` pair reaches, and nothing about what it does once it arrives (that is `v3/session.test.ts`'s and `v4/session.test.ts`'s job). In `v3/` beside the code it covers: Tier 0 for the protocol (`protocol.test.ts`, `golden.test.ts`, `fuzz.test.ts`) plus the Tier-1 JS client (`v3/client.ts`) and its conformance column (`v3/conformance.test.ts`, `v3/session.test.ts`). In `v4/`: `constants.test.ts` (the transcription check — RFC spot-checks at distinct points plus whole-table shape assertions, no gap, no repeat, every value named), `attr.test.ts`, `protocol.test.ts`, `golden.test.ts`, `fuzz.test.ts` for the codec, `state.test.ts` for the state machine alone (synchronous, no socket — the replay cache and the lease clock proved with an injected clock rather than a real `setTimeout`), `session.test.ts` for COMPOUND dispatch driven with encoded bytes, and the Tier-1 JS client (`v4/client.ts`, which does its own path-walking and POSIX-vs-NFSv4 op-collapsing — `unlink` vs `rmdir`, OPEN not being for directories) plus `v4/driver.ts` (the `FsDriver` over it) and `v4/conformance.test.ts`, so NFSv4.1 is a conformance-matrix column of its own alongside loopback, FUSE, 9P, NFSv3 and S3. Tier 2 `mount.test.ts` is v3-only so far (gated on `nfsClientProbe()`; sudo on Linux, `pnpm test:rootless` on macOS, where it needs none) — a real-mount NFSv4.1 column is not written yet; the dev host has no `mount.nfs` to write it against either way. - `s3/` — Tier 0 (`sigv4.test.ts` against the official `aws-sig-v4-test-suite` goldens, `xml.test.ts`, `chunked.test.ts`, `protocol.test.ts`, `constants.test.ts` — the errno↔S3-error table's totality), `server.test.ts` (real sockets, driven with `fetch`), the Tier-1 signing JS client (`client.ts`, the `test/nfs/v3/client.ts` pattern) and its conformance column (`conformance.test.ts`, `session.test.ts`, in-process against the memory driver, no sockets), and `oracle.test.ts` — a real `rclone`/`curl` against the gateway, gated on `command -v rclone`/`curl` (the `nfsClientProbe` pattern) and needing no root, so it runs as part of `pnpm test` and skips clean when either binary is absent. -- `exec/` — `strategy.test.ts` is Tier 0 for `mountx/exec`'s choice, the way `auto.test.ts` is for the transports': the preference order, the reason the mechanism is ruled out, the named-mechanism path that must not consult the picker's probe, and `cwdRefusal()`, whose only other way of being checked costs a hung process. Answered for darwin and win32 from any host through the `platform` override — including the refusal to answer for Linux from a host that cannot read Linux's files. `userns.test.ts` is Tier 2 and the one mount suite in the repo that needs neither root nor a `fusermount3`; it is gated on `usernsExecProbe().usable` plus the same raised-threadpool rule the FUSE rootless file uses, and it runs under `pnpm test:rootless`. It does not re-test the filesystem — what the command sees is FUSE, which has its own conformance column — only what is different: the driver at `$MOUNTX_ROOT`, a write landing in the driver (asserted by reading the driver back, never on the command's exit status — an earlier version of this work reported success while discarding every write), the mount staying out of the _host's_ `/proc/self/mounts`, the command's own status surviving, and a relay failure arriving as an error. +- `exec/` — `strategy.test.ts` is Tier 0 for `mountx/exec`'s choice, the way `auto.test.ts` is for the transports': the preference order, the reason the mechanism is ruled out, the named-mechanism path that must not consult the picker's probe, `cwdRefusal()`, whose only other way of being checked costs a hung process, and `usernsIdMap()`, whose own would cost a namespace and a mount. Answered for darwin and win32 from any host through the `platform` override — including the refusal to answer for Linux from a host that cannot read Linux's files. `userns.test.ts` is Tier 2 and the one mount suite in the repo that needs neither root nor a `fusermount3`; it is gated on `usernsExecProbe().usable` plus the same raised-threadpool rule the FUSE rootless file uses, and it runs under `pnpm test:rootless`. It does not re-test the filesystem — what the command sees is FUSE, which has its own conformance column — only what is different: the driver at `$MOUNTX_ROOT`, a write landing in the driver (asserted by reading the driver back, never on the command's exit status — an earlier version of this work reported success while discarding every write), the mount staying out of the _host's_ `/proc/self/mounts`, the command's own status surviving, and a relay failure arriving as an error. Plus the one case that _is_ filesystem behaviour, because no other column on this host can catch it: removing, renaming, hardlinking, truncating and `chmod`ing entries the driver held **before** the mount, all asserted by reading the driver back. That is the id-space bug, and its discriminator was how the kernel learned of the inode rather than the operation — an entry the command created could be removed, one that arrived through `LOOKUP` could not. - `pjdfstest/` — `run.sh`+`run.ts` drive the pinned pjdfstest clone (gitignored) against a real mount and write the committed analysis. - `matrix.ts` — generates `.agents/conformance-matrix.md`. Its `unmetIn()` counts a capability as met when **at least one** target in the column passed a case naming it, not every one — the drivers sharing a column need not have the same capabilities now that `unstorage` runs beside `memory`. `root.sh` — runs any Tier-2 vitest file under sudo with the environment fixed up (raised `UV_THREADPOOL_SIZE`, redirected `TMPDIR`, forwarded `MOUNTX_*`); every Tier-2 file skips itself when not root. `rootless.sh` is the same idea minus the `sudo` and minus everything `sudo` made necessary — `UV_THREADPOOL_SIZE` is all that is left, and `mount-rootless.test.ts` skips itself unless it has been raised. @@ -133,6 +133,7 @@ Docs (`docs/`) — the [undocs](https://undocs.dev) site at s_user_ns`, fixed at mount time), so an id the mount's namespace cannot map becomes `INVALID_UID` and the **VFS** — not this server — then refuses `unlink`, `rmdir`, `rename` and `link` with `EOVERFLOW` (`may_delete()`, `may_linkat()`: "Inode writeback is not safe when the uid or gid are invalid") and any write open with `EACCES` (`inode_permission()`'s `HAS_UNMAPPED_ID()`). Reads, `stat` and `readdir` are untouched, so the mount looks healthy right up until something tries to change it, and no error ever reaches the driver. `FuseSessionOptions.idmap` is the one crossing, the identity by default; `src/exec/userns.ts`'s `usernsIdMap()` is the only caller, because `unshare -U -r` is the only place in this repo where the two spaces differ. Do not "fix" a symptom of this in a driver — a driver that reports `0` to please one mount is lying to every other consumer of the same driver. - **Wire constants are transcribed, never guessed or borrowed from host `node:fs`.** FUSE constants come from the kernel's `include/uapi/linux/fuse.h`; NFS constants come from RFC 1813/5531/4506 and, for v4.1, RFC 8881 with RFC 5662 for the XDR it does not spell out; 9P constants come from the kernel's `include/net/9p/9p.h` (both header sources pinned at tag v6.12) with diod's `protocol.md` as the prose reference; the `fusermount3` handshake and the Node-API declarations come from libfuse's and Node's own sources, both named where they are used. - **A minor version other than 1 is refused, not guessed at.** `vers=4.1` and `vers=4.0` are the same RPC `vers` field — 4 — so the router in `src/nfs/session.ts` cannot tell them apart; the minor version travels inside COMPOUND instead, and `src/nfs/v4/session.ts` answers anything but 1 with `NFS4ERR_MINOR_VERS_MISMATCH` rather than serving it or claiming v4 is unavailable altogether. - **No grace period on NFSv4.1.** This server keeps no stable storage across a restart, so there is nothing to reclaim and no window in which to reclaim it: every reclaiming `OPEN` (`CLAIM_PREVIOUS`) or `LOCK` answers `NFS4ERR_NO_GRACE` unconditionally. `RECLAIM_COMPLETE` still gates ordinary locking exactly as RFC 8881 §18.51.3 requires, independent of that — do not let "there is no grace period" read as "RECLAIM_COMPLETE is a no-op". diff --git a/docs/2.transports/2.fuse.md b/docs/2.transports/2.fuse.md index 3ff185f..a5ccbf9 100644 --- a/docs/2.transports/2.fuse.md +++ b/docs/2.transports/2.fuse.md @@ -139,6 +139,7 @@ await session.destroy(); // idempotent, safe with requests in flight | `negativeTimeout` | `0` | seconds it may cache a _failed_ lookup — off by default | | `keepCache` | `true` | reply `FOPEN_KEEP_CACHE`, keeping page cache across opens | | `useDriverIno` | `true` | identify files by the driver's `(dev, ino)`, so hardlinks share a nodeid | +| `idmap` | the identity | translate uids and gids between the driver's id space and the mount's | | `init` | — | `InitPreferences`, passed to `negotiateInit` | | `debug` | on outside production | run the reply-exactly-once assertions | | `onError` | none | called for every request that ends in an error reply | @@ -148,6 +149,8 @@ await session.destroy(); // idempotent, safe with requests in flight `negativeTimeout` is a real saving for a build that stats hundreds of missing headers, and a real hazard for any driver whose storage has other writers: a file created out of band stays invisible for the whole timeout. Opt in per mount. :: +`idmap` is the one option here that is about the _mount_ rather than the protocol, and mounting with `mount()` never needs it. Every `uid`/`gid` on the FUSE wire is named in the id space of the mount's user namespace, which for a mount this transport makes is the one the server is already in — so the two spaces are identical and the default identity map is right. They differ only when the mount is made from somewhere else, which in this package means [`mountx/exec`](/transports/exec#everything-inside-is-owned-by-root); the cost of getting it wrong is that the kernel refuses to `unlink`, `rename`, `link` or write an inode whose ids the mount cannot map, without the request ever reaching your driver. + ## Teardown, and what it can't do A `-t fuse` mount never receives `FUSE_DESTROY`, so the transport detects unmount itself — EOF or `ENODEV` on `/dev/fuse` — and destroys the session. That is idempotent and safe with requests in flight. diff --git a/docs/2.transports/6.exec.md b/docs/2.transports/6.exec.md index 0f682c8..51e2501 100644 --- a/docs/2.transports/6.exec.md +++ b/docs/2.transports/6.exec.md @@ -184,6 +184,14 @@ ran.mountpoint; **`default_permissions` is not among the default `-o` options,** which is worth knowing before setting it. It asks the _kernel_ to check the driver's `uid`/`gid`/`mode` against the caller's credentials, and inside a namespace those are not the same identity space: `unshare -r` maps exactly one uid, so a driver reporting the serving process's real uid is reporting an identity the namespace renders as `nobody` — and a `nobody`-owned `0755` root directory refuses every write from the one process meant to have it. Namespace-root's `CAP_DAC_OVERRIDE` does not rescue it either; that capability does not reach a file owned by an unmapped uid. Permission checking stays with the driver instead. Nothing is lost by it: the mount carries no `allow_other`, so the only process that can reach it is the one the call created it for. +### Everything inside is owned by root + +The namespace's whole id space is one uid and one gid, both `0`, mapped to the user who made the call — that is all `unshare -r` does. So every entry the command sees is reported as `0:0`, whatever the driver says out here, and a file the command creates is owned in the driver by whoever ran mountx. `chown` inside the namespace can only name `0` anyway, and it means "mine" on both sides. + +This is not cosmetic tidying. An id the namespace cannot map is `INVALID_UID` to the kernel, and the VFS then refuses to `unlink`, `rmdir`, `rename` or `link` such an inode (`EOVERFLOW`, which surfaces as `Value too large for defined data type`) or to open it for writing (`EACCES`) — all of it decided before the request reaches your driver, and none of it affecting `stat`, reads or `readdir`. A mount that skipped this reads perfectly and cannot be changed. Reporting the kernel's own `nobody` for foreign ids would not help: `nobody` is unmapped in here too. + +The consequence to know about is that a driver over a tree with mixed ownership presents as uniformly root-owned to the command. There is nowhere else for those ids to go, and inside a namespace with exactly one identity in it and no `allow_other` to admit a second, root is what you are. + ### How it is built `unshare(CLONE_NEWUSER)` demands a single-threaded caller and Node is never single-threaded, so a mountx process cannot enter the namespace it needs — not with `unshare(2)`, and not with `setns(2)`, which has the same rule. The namespace is entered by a **child**, `/dev/fuse` is opened in there, and raw FUSE traffic comes back out over a unix socket to a `FuseSession` in the parent, which is where your driver stays. Inside the namespace that child is uid 0 with `CAP_SYS_ADMIN`, so it takes the ordinary root mount path: no `fusermount3`, no setuid bit, and **no native addon**. On a host with no `fuse3` package installed at all, this is the only FUSE route that works.