diff --git a/.agents/skills/debugging-computerd-fuse/SKILL.md b/.agents/skills/debugging-computerd-fuse/SKILL.md index fca758fe7..d95f9816c 100644 --- a/.agents/skills/debugging-computerd-fuse/SKILL.md +++ b/.agents/skills/debugging-computerd-fuse/SKILL.md @@ -22,7 +22,7 @@ capnweb endpoints from a plain Node script. ## When to reach for this - A user report of "wrote a file via FUSE, RPC reads see 0 bytes." -- A user report of "exec hangs forever" or "computerd `/health` stops +- A report of "exec hangs forever" or "computerd `/health` stops responding after the first shell call." - Adding a new computerd op (FUSE callback, sync RPC, runner feature) and you want a smoke test that doesn't depend on Docker-in-DO or @@ -50,9 +50,12 @@ capnweb endpoints from a plain Node script. There's already a recipe — `packages/computer/test-harness/run-computerd.sh`. It picks a host port, runs the binary with `--privileged ---device /dev/fuse --cap-add SYS_ADMIN --cap-add MKNOD`, installs -fuse3 + libfuse2t64 from apt, waits for `/health`, prints the URL -on stdout and the container id on stderr. +--device /dev/fuse --cap-add SYS_ADMIN --cap-add MKNOD` on Debian +trixie with fuse3 from apt (computerd links the system libfuse 3 and +needs 3.17 or newer), waits for `/health`, prints the URL on stdout +and the container id on stderr. It forwards `MOUNT_IGNORE`, +`MOUNT_IGNORE_PATH`, and any `COMPUTERD_FUSE_*` variable from your +environment into the container. ```bash # Start @@ -63,7 +66,7 @@ COMPUTERD_HARNESS_PORT=18080 bash packages/computer/test-harness/run-computerd.s # Verify curl -sf http://127.0.0.1:18080/health docker logs | tail -# should see: computerd listening on 0.0.0.0:8080 mount=/workspace backend=linux +# should see: computerd listening on 0.0.0.0:8080 mount=/workspace backend=fuse # Tear down docker kill @@ -165,7 +168,7 @@ them. Check that: - Your absolute paths start with `MOUNT_POINT` (default `/workspace`). Writing to `/probe.txt` succeeds at the RPC layer but FUSE only serves the `MOUNT_POINT` subtree. -- computerd actually logs `backend=linux` (not `backend=shim`). +- computerd actually logs `backend=fuse` (not `backend=shim`). - You aren't reading the wrong container — `docker ps` after a zombie `docker rm -f` can show stale entries. @@ -179,19 +182,16 @@ running container: docker exec sh -c 'echo container-write > /workspace/r2/hello.txt' ``` -This is a real FUSE write: kernel → fuse-native → computerd's -`writeBuf` op → computerd's in-memory buffer for that file → on +This is a real FUSE write: kernel → fuse-napi → computerd's +`write` op → computerd's in-memory buffer for that file → on `release`/`flush`/`fsync` the buffer spills into the backing VFS (commit `68407fc`). On the host side you can then `pullOnce` and see the new entry land in `applyChanges`. -**Avoid `client.shell.exec()` while debugging FUSE bugs in this -environment.** It calls `spawn` with `cwd=MOUNT_POINT` by default, -which can deadlock under real FUSE (see "Known deadlock patterns" -below). If you need exec-shaped behaviour from outside the -container, `docker exec` is closer to what wrangler's container -runtime would do anyway — computerd isn't the parent of the spawned -process. +Use `docker exec` when you need to isolate the FUSE mount from +computerd's command runner: computerd is not the parent of that +process. `client.shell.exec()` also works on current binaries; it +exercises the command runner and its removal guard as well. ## Known deadlock patterns @@ -215,11 +215,11 @@ buffer-vs-VFS desync. ### computerd's event loop blocked in spawn -Symptom: `client.shell.exec` against a real-FUSE computerd hangs -forever. `/health` stops responding. `docker exec ls -/workspace` also hangs once the deadlock fires. +Historical failure: an older `Runner.exec` implementation made +`client.shell.exec` hang under real FUSE. `/health` stopped responding; +`docker exec ls /workspace` then hung too. -Mechanism: `Runner.exec` calls +Mechanism: it called `spawn("/bin/sh", { cwd: "/workspace" })`. libuv's `uv_spawn` forks and waits on a status pipe for the child to exec. The child, between fork and exec, does `chdir("/workspace")`. The @@ -230,23 +230,19 @@ read. Deadlock. Diagnostic: ```js -// against a fresh computerd -const t = Date.now(); +// Against a fresh computerd, both calls should return. await client.shell.exec({ command: "echo hi", cwd: "/tmp", timeoutMs: 5000 }); -// returns in <100ms — control case await client.shell.exec({ command: "echo hi", cwd: "/workspace", timeoutMs: 5000 }); -// hangs forever — under real FUSE ``` Same computerd, same client, same network — only `cwd` differs. If -that flips success→hang, you've reproduced the deadlock. +the second hangs, the old deadlock has regressed. -Fix: do the chdir inside the **shell**, not inside `spawn`'s -fork-and-exec dance. Prefix the command with `cd ... && exec ...` -so the chdir runs after the shell is up and computerd's event loop is -responsive again. Pre-flight existence via dofs's `stat(db, path)` -(reads SQLite directly, no FUSE callbacks) to preserve the -existing ENOENT-cwd error contract. +The runner now changes directory inside the **shell**, not in +`spawn`'s fork-and-exec path. It prefixes the command with `cd ... &&` +so FUSE lookups run after the shell starts and computerd's event loop is +responsive. It checks cwd existence through the VFS before spawning to +preserve the ENOENT error contract. ### Other event-loop blockers to suspect @@ -261,9 +257,62 @@ goes through Node's `fs` against the mount point must be async ## Recipe library +### Is passthrough engaged? + +With `MOUNT_IGNORE` set, an open of a local-only file should hand the +file to the kernel and keep computerd out of the data path. Two ways to +check, from inside the container: + +```bash +# What the mount negotiated, and how opens were served. +curl -s localhost:8080/__computerd/info | jq .ignore.fastPaths + +# Whether data still reaches the daemon. Boot with +# COMPUTERD_FUSE_TRACE=summary and COMPUTERD_FUSE_TRACE_FILE=/tmp/trace.json, +# do the I/O, then dump the counters. read and write should not move +# for local-only files. +kill -USR2 1 && jq '.ops[] | select(.op == "read" or .op == "write")' /tmp/trace.json +``` + +Keep the trace file outside the mount: a process serving a FUSE mount +deadlocks if it writes into that mount. `passthroughReason` names the +likely cause when passthrough is off, and `COMPUTERD_FUSE_PASSTHROUGH=0` +turns it off on purpose, to rule it in or out of a bug. + +### Check native roots + +With `MOUNT_IGNORE` configured, eligible local-only directories can be +mounted from the backing directory onto `/workspace`. Check the live +state and the kernel's mount table separately: + +```bash +curl -s localhost:8080/__computerd/info | jq .ignore.fastPaths.nativeRoots +docker exec sh -c "grep ' /workspace/' /proc/self/mountinfo" +``` + +The info response shows `active`, `failures`, and `reason`; stats also +include `lastFailure`. A refused bind remains available through FUSE. +A symlink in either source or target path is refused by the native +helper, not followed by a shell mount command. Disable native roots +with `COMPUTERD_FUSE_NATIVE_ROOTS=0` when isolating a problem. + +To remove a root before `rm -rf`, call the daemon's detach endpoint: + +```bash +curl -X POST localhost:8080/__computerd/native-roots/detach \ + -H 'content-type: application/json' \ + -d '{"path":"node_modules"}' +``` + +If `RPC_CLIENT_SECRET` is set, include `Authorization: Bearer `. +Do not use a pathname-based `umount` on a workspace path that might be a +symlink. On shutdown and before mounting FUSE at startup, computerd +lazily detaches everything it finds below the workspace mount; do not +put unrelated mounts there. + ### Reset between probes -A wedged computerd container can leave fuse-native zombie processes +A wedged computerd container can leave FUSE zombie processes that `docker kill` refuses to reap. The cleanest fix: ```bash @@ -341,11 +390,9 @@ writes to that subtree will propagate back via the next ### Reference: a full M3.5-style probe -A working end-to-end probe that stages the mount, pushes, -container-writes via `docker exec`, pulls, and asserts the -expected applied/skipped split lives at -`script/computerd-mount-probe.mjs` if it's still in the tree. Use it as -the template for new probes. +For the FUSE write/pull path, use `script/computerd-fuse-flush.mjs` as +the starting point for a probe that stages a mount and checks the +applied/skipped split. ## What this skill is NOT @@ -369,7 +416,7 @@ your host) so the inspector port is reachable, or `docker run through computerd's launch env. If even that doesn't surface it, the wedge is probably below the -JS layer — in fuse-native or libuv. At that point reach for +JS layer — in the fuse-napi binding or libuv. At that point reach for `strace -fp ` inside the container; the FUSE callbacks all surface as `read(/dev/fuse, ...)` and the per-call timing will show you exactly which op deadlocks. diff --git a/.changeset/libfuse3-passthrough.md b/.changeset/libfuse3-passthrough.md new file mode 100644 index 000000000..87a967143 --- /dev/null +++ b/.changeset/libfuse3-passthrough.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computerd": minor +--- + +Improve local-only filesystem performance with FUSE passthrough and native roots; see the [performance docs](https://github.com/cloudflare/computer/blob/main/docs/19_performance.md). diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 601d72ac6..1f85c0ae0 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -16,6 +16,14 @@ runs: registry-url: ${{ inputs.registry-url }} cache: npm + # npm ci compiles computerd's vendored FUSE addon against these. + - name: Install the libfuse 3 headers + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libfuse3-dev + - name: Install dependencies shell: bash run: npm ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 714f8162e..c4fb951c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,11 +67,9 @@ jobs: path: packages/computerd needs-siblings: true build-computerd-bin: true - # libfuse2 (libfuse.so.2) is what fuse-native binds to at - # runtime; fuse3 ships /dev/fuse helpers and the - # FUSE_MOUNT=shim tests need it for the userspace mount path. - # xz-utils extracts the target Node binary used by build:bin. - system-deps: "libfuse2t64 fuse3 xz-utils" + # fuse3 provides fusermount3 for the real-FUSE tests; xz-utils + # unpacks the Node binary build:bin injects into. + system-deps: "fuse3 xz-utils" steps: - uses: actions/checkout@v6 with: diff --git a/.github/workflows/next-computerd-image.yml b/.github/workflows/next-computerd-image.yml index 391d44aac..9cdb67910 100644 --- a/.github/workflows/next-computerd-image.yml +++ b/.github/workflows/next-computerd-image.yml @@ -55,11 +55,6 @@ jobs: - uses: ./.github/actions/install - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends libfuse2t64 fuse3 - # build-bin generates the SEA blob with the active Node executable and # injects it into the pinned Node 22.23.4 target binary. - name: Use the target Node version for the binary build @@ -72,6 +67,7 @@ jobs: - name: Build all workspaces run: npm run build --workspaces --if-present + # Compiles the FUSE addon in a container, so no FUSE packages here. - name: Build the computerd binary run: npm run build:bin --workspace @cloudflare/computerd diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aac5c604a..58f5debf8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,14 +56,12 @@ jobs: registry-url: "https://registry.npmjs.org" # @cloudflare/computer's prepublishOnly runs `npm run build && npm - # test`; its tests spawn the computerd binary, which dynamically links - # libfuse2 and uses fuse3 helpers for the FUSE_MOUNT=shim path. Without - # these the suite fails with `libfuse.so.2: cannot open shared object - # file`. Mirrors ci.yml. + # test`. fuse3 provides fusermount3 for the suites that mount. + # Mirrors ci.yml. - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libfuse2t64 fuse3 + sudo apt-get install -y --no-install-recommends fuse3 # Build every workspace's dist/ up front. changesets doesn't run the # build itself (changesets/changesets#860), and package builds/tests diff --git a/.gitignore b/.gitignore index c6413c2bf..e28c22245 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ worker-configuration.d.ts # Local celld example state. examples/celld/.celld-s3*/ examples/celld/.celld-state*/ + +# Vendored native addon: build output and its install tree +packages/computerd/vendor/*/build/ +packages/computerd/vendor/*/node_modules/ +packages/computerd/vendor/*/prebuilds/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 86f36d627..f092b9c69 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -32,6 +32,7 @@ ], "ignorePatterns": [ "**/node_modules", + "**/vendor", "**/node_modules.host-backup.*", "**/dist", "/artifacts", diff --git a/.oxlintrc.json b/.oxlintrc.json index afc55ed36..1208201c9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -26,6 +26,7 @@ }, "ignorePatterns": [ "**/node_modules", + "**/vendor", "**/node_modules.host-backup.*", "**/dist", "/artifacts", diff --git a/AGENTS.md b/AGENTS.md index 6a9a6e224..93abc32a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,32 +34,35 @@ the file directly when the trigger applies: A fresh container does not have everything the tests need. The traps below cost real time if you discover them one failure at a time. -**Native build tools.** `packages/computerd` depends on `fuse-native`, a -native addon. Building it needs a C toolchain and the libfuse2 headers. -On Debian or Ubuntu: +**Native build tools.** `packages/computerd` depends on a vendored copy +of `fuse-napi` at `packages/computerd/vendor/fuse-napi`, a native addon +compiled from source on install. Building it needs a C toolchain and the +libfuse 3 headers. On Debian or Ubuntu: ```bash -apt-get install build-essential libfuse-dev +apt-get install build-essential pkg-config libfuse3-dev fuse3 ``` -If the `fuse-native` build fails, `npm install` aborts the whole -install, not just that one package. When you only need the rest of the -workspace, install with `npm install --ignore-scripts` to skip the -native build. +Any libfuse 3 compiles. Kernel passthrough for local-only paths needs +3.17 or newer (Debian trixie); against an older one the addon builds +without it and those paths are served by `computerd` instead. -**arm64 hosts.** `fuse-native` ships a prebuilt libfuse for x64 only. -On a Linux arm64 host or container (including a Linux container on -Apple Silicon, or arm64 CI) the link fails with `file in wrong -format`. The path below is Debian or Ubuntu arm64; a native macOS host -uses macFUSE instead and does not hit this. Replace the bundled library -with the system one and rebuild: +If the addon build fails, `npm install` aborts the whole install, not +just that one package. When you only need the rest of the workspace, +install with `npm install --ignore-scripts`. That also skips the root +`postinstall`, so run `npm run build:types` before building the +examples, and build the addon yourself when you need it: ```bash -cp /usr/lib/aarch64-linux-gnu/libfuse.so.2 \ - node_modules/fuse-shared-library-linux/libfuse/lib/libfuse.so -cd node_modules/fuse-native && npx node-gyp rebuild +cd packages/computerd/vendor/fuse-napi && npx node-gyp rebuild ``` +**The binary build uses Docker.** `npm run build:bin` compiles the addon +for linux-x64 inside a `node:22-trixie-slim` container, so it works the +same from an arm64 machine (through emulation, which is slow the first +time; the result is cached under `packages/computerd/dist/sea`). Set +`COMPUTERD_FUSE_ADDON` to a prebuilt `fuse.node` to skip that step. + **Build before you test.** The test scripts don't build the sibling packages first. Several suites need build output that is absent in a clean checkout: `packages/computerd` imports the sibling `@cloudflare/dofs` @@ -77,9 +80,10 @@ so a `mknod`'d `/dev/fuse` in an unprivileged container defeats the skip and the mount then fails with `EPERM`, turning a clean skip into a hard failure. Leave the device absent unless the container is privileged (`--privileged`, or `CAP_SYS_ADMIN` with device access). The -`src/exec/runner.fuse.test.ts` suite is separate: it skips unless both -Docker and the prebuilt `computerd` binary are available, and runs `computerd` -inside a privileged container. See the +`src/exec/runner.fuse.test.ts` and `src/fuse/passthrough.fuse.test.ts` +suites are separate: they skip unless both Docker and the prebuilt +`computerd` binary are available, and run `computerd` inside a privileged +container. See the [`debugging-computerd-fuse`](.agents/skills/debugging-computerd-fuse/SKILL.md) skill for the privileged Docker setup. diff --git a/docs/07_injected_service.md b/docs/07_injected_service.md index 3ae6b18a0..fcf5bc05b 100644 --- a/docs/07_injected_service.md +++ b/docs/07_injected_service.md @@ -16,14 +16,14 @@ exec runner, and the capnweb RPC endpoint the DO talks to. The package ships it as a single self-contained Node SEA binary — **`computerd`** — produced by `packages/computerd/` (npm package -`@cloudflare/computerd`, bin name `computerd`). The binary embeds Node, -the `fuse-native` prebuilds, and `libfuse` as SEA assets, so the host -image does **not** need a Node runtime. Build it with: +`@cloudflare/computerd`, bin name `computerd`). The binary embeds Node +and the FUSE addon as SEA assets, so the host image does **not** need a +Node runtime. It links the system libfuse 3, so the image does need +libfuse 3.17 or newer. Build it with: ```bash npm run build:bin --workspace @cloudflare/computerd # → artifacts/computerd/computerd-linux-x64 -# → artifacts/computerd/computerd-macos-x64 ``` `examples/container/Dockerfile` is the canonical recipe for staging the @@ -73,11 +73,11 @@ The capnweb bootstrap interface is **`WorkspaceRPC`** (defined in The canonical recipe is `examples/container/Dockerfile`: ```dockerfile -FROM --platform=linux/amd64 debian:stable-slim +FROM --platform=linux/amd64 debian:trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 libfuse2t64 ca-certificates \ + fuse3 ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY build/computerd-linux-x64 /usr/local/bin/computerd @@ -95,9 +95,10 @@ Notes: - No Node, no `npm install`, no `package.json` — the SEA binary embeds everything. -- `libfuse` itself is bundled into the binary as a SEA asset; the apt - install of `fuse3 libfuse2t64` provides the userland tooling and - `/dev/fuse` plumbing for the host kernel. +- `fuse3` provides libfuse 3 and `fusermount3`. The binary built from + this branch needs libfuse 3.17 or newer, which is Debian trixie or + later; bookworm's 3.14 is too old. Published computerd 0.7.1 and + earlier still link libfuse 2 and also need `libfuse2t64`. - `EXPOSE 8080` matches the Cloudflare backend's pinned port. If you run `computerd` outside Cloudflare Containers, leave `PORT` unset (default `45678`) or pick your own. diff --git a/docs/10_project_layout.md b/docs/10_project_layout.md index 892a8ac6b..0929944b0 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -146,10 +146,14 @@ packages/computerd/ │ ├── cli/ │ │ └── computerd.ts # CLI entry │ ├── fuse/ -│ │ ├── driver.ts +│ │ ├── driver.ts # VFS op table and mountFuse +│ │ ├── binding-ops.ts # adapts the op table to fuse-napi +│ │ ├── passthrough.ts # local-only paths (MOUNT_IGNORE) +│ │ ├── ignore.ts # MOUNT_IGNORE matcher +│ │ ├── ignore-config.ts # startup config and /__computerd/info +│ │ ├── options.ts # mount options and init config │ │ ├── backend.ts │ │ ├── vfs.ts -│ │ ├── fuse-native.d.ts # fuse-native typings │ │ └── index.ts │ └── exec/ │ ├── runner.ts @@ -157,14 +161,15 @@ packages/computerd/ │ ├── types.ts │ ├── log.ts │ └── index.ts +├── vendor/ +│ └── fuse-napi/ # patched libfuse 3 binding; see PATCHES.md ├── scripts/ │ ├── build-bin.mjs # SEA driver │ └── sea/ │ └── bundle.mjs # esbuild → SEA bundle ├── artifacts/ │ └── computerd/ -│ ├── computerd-linux-x64 -│ └── computerd-macos-x64 +│ └── computerd-linux-x64 ├── tsdown.config.ts # → dist/cli/computerd.js ├── tsconfig.json └── package.json @@ -173,7 +178,7 @@ packages/computerd/ Build pipeline: tsdown bundles `src/cli/computerd.ts` into `dist/cli/computerd.js`; `scripts/build-bin.mjs` together with `scripts/sea/bundle.mjs` produces the Node SEA single-file binary at -`artifacts/computerd/computerd-{linux,macos}-x64`. +`artifacts/computerd/computerd-linux-x64`. ## Tools @@ -245,7 +250,7 @@ so each example can declare its own dependencies and scripts. - **oxfmt.** Formats JS, TypeScript and JSON, configured in `.oxfmtrc.json` at the repo root. Markdown, YAML, HTML and CSS are left alone. No ESLint, no Prettier. -- **tsdown.** Builds every package: the Workspace package's ESM entrypoints and declarations, `@cloudflare/dofs` and `@cloudflare/computer-rpc`, and the `computerd` CLI. Sources use extensionless relative imports under `moduleResolution: "bundler"`. +- **tsdown.** Builds every package: the Workspace package's ESM entrypoints and declarations, `@cloudflare/dofs` and `@cloudflare/computer-rpc`, and the `computerd` CLI. Relative imports resolve under `moduleResolution: "bundler"`. - **esbuild.** Used by `packages/computerd/scripts/sea/bundle.mjs` to produce the single-file `computerd` SEA bundle. - **vitest.** Drives unit tests in every package. `computerd` additionally uses `node --experimental-strip-types --test` for some scripts diff --git a/docs/19_performance.md b/docs/19_performance.md index 207e0768f..d584af2a8 100644 --- a/docs/19_performance.md +++ b/docs/19_performance.md @@ -66,18 +66,116 @@ the more realistic baseline for general usage. ## Local-only paths (`MOUNT_IGNORE`) -A path matched by `MOUNT_IGNORE` is served from the container's disk and -never enters the VFS, the store, the change-pack encoding, or the pull. +A path matched by `MOUNT_IGNORE` is served from the container's disk +rather than the VFS. + +There are two savings, and they are independent. + +The first is in what is skipped. An ignored write does not enter the VFS, +the SQLite store, the change-pack encoding, or the pull into the Durable +Object. For a dependency install, that is the difference between +transferring tens of thousands of files and transferring none, and it +holds on every host. + +The second is FUSE passthrough. On Linux 6.9 or newer, opening a +local-only file hands the file to the kernel, which then serves its +reads, writes, and mmap straight from the container's disk. The bytes +never reach computerd. What still reaches it is metadata: lookups, +`stat`, directory listings, and each open and close. + +So passthrough brings data-heavy work close to raw disk, and leaves +metadata-heavy work where it was: + +| `fs-bench` scenario | synced path | local-only, passthrough off | local-only | raw disk | +| --- | ---: | ---: | ---: | ---: | +| write 64 MiB | 50.5 ms | 49.1 ms | 21.4 ms | 16.8 ms | +| overwrite 64 MiB | 66.8 ms | 51.8 ms | 22.2 ms | 18.9 ms | +| read 64 MiB | 89.7 ms | 74.4 ms | 22.3 ms | 19.5 ms | +| pure read 64 MiB | 49.8 ms | 34.0 ms | 6.0 ms | 6.4 ms | +| copy 64 MiB | 202.9 ms | 172.4 ms | 67.5 ms | 32.5 ms | +| create 1000 files | 421.1 ms | 314.3 ms | 245.9 ms | 20.6 ms | +| stat 1000 files | 876.2 ms | 695.0 ms | 712.6 ms | 397.6 ms | + +A dependency install sits between the two, because it creates many small +files. These are medians of `npm install` runs, followed by reading every +installed file back once: + +| Install | synced path | local-only, passthrough off | local-only | raw disk | +| --- | ---: | ---: | ---: | ---: | +| `express` (604 files), install | 599 ms | 473 ms | 508 ms | 385 ms | +| `express`, read back | 193 ms | 114 ms | 118 ms | 6 ms | +| front-end toolchain (5,412 files, 88 MB), install | 3,676 ms | 2,279 ms | 2,104 ms | 1,329 ms | +| front-end toolchain, read back | 2,307 ms | 957 ms | 700 ms | 48 ms | + +The toolchain is TypeScript, Vite, webpack, ESLint, React, and +`@types/node`. On an install as small as `express`, the gap between the +two local-only columns is within run-to-run noise. Expect a local-only +install to land between raw disk and the synced mount, nearer the disk +the larger the files are, and expect the larger saving to be the +transfer that no longer follows it. -What this does **not** change is the FUSE round trip: the bytes still -cross from the kernel into the daemon. Passthrough (`FOPEN_PASSTHROUGH`) -would remove that too, but computerd mounts through `fuse-native`, which -binds libfuse 2.9, and passthrough needs the libfuse 3.17 API. So expect -a local-only `npm install` to track the `computerd FUSE` row above -rather than the `ext4 disk` row. +> [!NOTE] +> These numbers come from an arm64 Linux 7.0 virtual machine on a +> laptop, with Docker's overlayfs as the backing store, not from +> Cloudflare Containers hardware. Treat the ratios as the result and the +> absolute times as local. `script/run-fs-bench.sh` (with `LOCAL_ONLY=1`) +> and `script/run-npm-bench.sh` (with `IGNORE_NODE_MODULES=1`) reproduce +> them, with `COMPUTERD_FUSE_NATIVE_ROOTS=0`: local-only directories are +> eligible for native mounts by default where supported (next section). + +### Native roots + +Passthrough leaves metadata on computerd's event loop. Where supported, +computerd can bind-mount eligible local-only directories from the +container's disk, so nothing under a mounted root reaches computerd. +See +[`computerd`: Native roots](../packages/computerd/README.md#native-roots). + +These runs were all taken on the same machine, one after another. The +raw-disk column is the median of the baselines measured alongside each +run, and the local-only columns have passthrough on. + +| fs-bench scenario (median, ms) | synced | local-only, passthrough | local-only, native root | raw disk | +| --- | ---: | ---: | ---: | ---: | +| create 1000 files | 441.4 | 317.6 | **13.7** | 14.7 | +| stat 1000 files | 851.1 | 674.5 | **347.9** | 363.2 | +| rm 1000 files | 600.9 | 320.2 | **20.9** | 22.7 | +| mkdir tree (10x10x10) | 682.7 | 607.1 | **303.6** | 310.7 | +| find tree | 674.5 | 554.2 | **304.5** | 305.6 | +| write 64 MiB | 50.7 | 18.4 | **15.4** | 16.9 | +| copy 64 MiB | 214.6 | 64.9 | **31.6** | 33.2 | +| read 64 MiB | 96.6 | 19.6 | **24.0** | 22.7 | +| pure read 64 MiB | 53.6 | 6.0 | **8.1** | 6.1 | +| pure copy 64 MiB | 151.9 | 41.9 | **14.9** | 14.1 | +| overwrite 64 MiB | 67.3 | 17.1 | **16.8** | 16.2 | +| git init + commit 100 files | 192.5 | 108.4 | **12.5** | 12.4 | +| git clone (shallow, ~1MB) | 710.8 | 561.7 | **668.2** | 558.3 | +| npm init + tiny install | 184.3 | 178.3 | **180.5** | 181.8 | + +| Install (median, ms) | synced path | local-only, passthrough | local-only, native root | raw disk | +| --- | ---: | ---: | ---: | ---: | +| `express` (589 files), install | 550 | 470 | **392** | 379 | +| `express` (589 files), read back | 176 | 73 | **6** | 6 | +| front-end toolchain (5,428 files, 91 MB), install | 3,643 | 2,165 | **1,365** | 1,336 | +| front-end toolchain (5,428 files, 91 MB), read back | 2,114 | 707 | **52** | 50 | + +Metadata-heavy local work under a native root approaches raw disk, where +passthrough alone leaves directory operations on computerd's event loop. +`git clone` is dominated by the network and varies between runs. + +> [!NOTE] +> Same caveat as above: an arm64 Linux 7.0 virtual machine on a laptop +> (OrbStack), not Cloudflare Containers. Reproduce with +> `script/run-fs-bench.sh` (`LOCAL_ONLY=1`) and `script/run-npm-bench.sh` +> (`IGNORE_NODE_MODULES=1`); add `COMPUTERD_FUSE_NATIVE_ROOTS=0` for the +> passthrough column. These numbers predate the descriptor-confined mount +> helper; they measure steady-state file operations, not mount setup time. + +Writeback caching would cut metadata round trips further on writes. It +stays off: libfuse 3 can negotiate it, but it changes write behavior for +the synced mount as well, which is a separate decision. -The saving is the transfer, and for a dependency tree the transfer is -most of the wall clock. +The Cloudflare Containers figure is still open: | Scenario | Install duration | Bytes pulled into the DO | |---|---:|---:| @@ -85,12 +183,11 @@ most of the wall clock. | `npm install` to a `MOUNT_IGNORE` path | *(not yet measured)* | 0 by construction | > [!NOTE] -> The empty cells are deliberate. Bytes-pulled is the load-bearing -> number for this feature and it has not been measured yet; the zero in -> the last cell is a property of the design — an ignored path produces -> no sync entries — not an observation. Fill the table from the same +> The empty cells are deliberate. They are for the same > `cloudflare/sandbox-sdk` install used above, on the same instance -> type, before quoting any of it. +> type, and that has not been run. The zero in the last cell is a +> property of the design — an ignored path produces no sync entries — +> not an observation. ## In-memory store versus on-disk store diff --git a/docs/README.md b/docs/README.md index da4232908..30382d169 100644 --- a/docs/README.md +++ b/docs/README.md @@ -74,7 +74,7 @@ Debian base: ```dockerfile FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.1 AS computerd -FROM debian:stable-slim +FROM debian:trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -91,6 +91,11 @@ EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/computerd"] ``` +`fuse3` brings libfuse 3, which the unreleased binary on this branch +needs at 3.17 or newer, so the base has to be Debian trixie or later. +The pinned 0.7.1 image still links libfuse 2; the snippet also installs +`libfuse2t64` until a libfuse 3 image is released. + To build the binary from source instead, run `npm run build:bin --workspace @cloudflare/computerd`, which emits `artifacts/computerd/computerd-linux-x64`, then `COPY` that into the diff --git a/examples/egress/Dockerfile b/examples/egress/Dockerfile index 353aa84e0..7ad153afc 100644 --- a/examples/egress/Dockerfile +++ b/examples/egress/Dockerfile @@ -1,10 +1,10 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.1 AS computerd -FROM debian:bookworm-slim +FROM debian:trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 libfuse2 ca-certificates curl \ + fuse3 libfuse2t64 ca-certificates curl \ && rm -rf /var/lib/apt/lists/* COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd diff --git a/examples/mcp/Dockerfile b/examples/mcp/Dockerfile index d3fcad47a..466485867 100644 --- a/examples/mcp/Dockerfile +++ b/examples/mcp/Dockerfile @@ -1,10 +1,10 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.1 AS computerd -FROM node:22-bookworm-slim +FROM node:22-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates curl fuse3 git libfuse2 \ + ca-certificates curl fuse3 libfuse2t64 git \ && rm -rf /var/lib/apt/lists/* COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 703e6f4fc..0d0d42038 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -102,11 +102,11 @@ the image is yours — here, `pandoc` and a PDF engine for it. ```dockerfile FROM ghcr.io/cloudflare/computer-computerd-linux-x64:VERSION AS computerd -FROM debian:stable-slim +FROM debian:trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 libfuse2t64 ca-certificates curl xz-utils \ + fuse3 ca-certificates curl xz-utils \ && ...install pandoc and typst... COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd @@ -119,6 +119,9 @@ EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/computerd"] ``` +`fuse3` brings libfuse 3, which `computerd` needs at 3.17 or newer, so +the base has to be Debian trixie or later. + See [`Dockerfile`](Dockerfile) for the full version, including the pinned `pandoc` and `typst` downloads. `typst` is the PDF engine because it is a single 30 MB binary; a LaTeX install would cost several diff --git a/package-lock.json b/package-lock.json index 70d91a9f1..3597a667f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3870,6 +3870,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3887,6 +3890,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3904,6 +3910,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3921,6 +3930,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3938,6 +3950,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3955,6 +3970,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3972,6 +3990,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3989,6 +4010,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4006,6 +4030,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4029,6 +4056,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4052,6 +4082,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4075,6 +4108,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4098,6 +4134,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4121,6 +4160,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4144,6 +4186,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4167,6 +4212,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4312,6 +4360,19 @@ } } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jitl/quickjs-ffi-types": { "version": "0.32.0", "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", @@ -4831,6 +4892,51 @@ "tslib": "2" } }, + "node_modules/@ljharb/now": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@ljharb/now/-/now-1.0.1.tgz", + "integrity": "sha512-yRQ3B8l2v4vTLQ2l+4kBu6EsXaX7czuLB7fvUz0vkHHziHje75R2t/0Mm/vIo31tlZVCEvCoXHkNKW+ugv3uSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@ljharb/resumer": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@ljharb/resumer/-/resumer-0.1.3.tgz", + "integrity": "sha512-d+tsDgfkj9X5QTriqM4lKesCkMMJC3IrbPKHvayP00ELx2axdXvDfWkqjxrLXIzGcQzmj7VAUT1wopqARTvafw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ljharb/through": "^2.3.13", + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@ljharb/through": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", + "integrity": "sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", @@ -5172,6 +5278,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5189,6 +5298,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5206,6 +5318,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5223,6 +5338,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5240,6 +5358,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5257,6 +5378,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5274,6 +5398,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5291,6 +5418,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5579,6 +5709,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5596,6 +5729,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5613,6 +5749,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5630,6 +5769,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5647,6 +5789,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5664,6 +5809,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5681,6 +5829,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5698,6 +5849,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6016,6 +6170,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6032,6 +6189,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6048,6 +6208,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6064,6 +6227,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6080,6 +6246,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6096,6 +6265,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6535,6 +6707,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6552,6 +6727,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6569,6 +6747,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6586,6 +6767,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -7058,142 +7242,482 @@ "license": "MIT" }, "node_modules/@types/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", - "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", - "license": "ISC" - }, - "node_modules/@vercel/oidc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", - "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">= 20" + "node": ">=16.20.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", - "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } + "node": ">=16.20.0" } }, - "node_modules/@vitest/expect": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", - "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.12", - "@vitest/utils": "4.1.12", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", - "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.12", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", - "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@vitest/runner": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", - "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.12", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", - "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.12", - "@vitest/utils": "4.1.12", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.12", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.12", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { @@ -7291,6 +7815,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -7305,6 +7832,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -7319,6 +7849,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -7333,6 +7866,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -7347,6 +7883,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -7361,6 +7900,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -7402,6 +7944,16 @@ "dev": true, "license": "MIT" }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -7560,6 +8112,23 @@ "dequal": "^2.0.3" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -7570,6 +8139,67 @@ "node": ">=8" } }, + "node_modules/array.prototype.every": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/array.prototype.every/-/array.prototype.every-1.1.7.tgz", + "integrity": "sha512-BIP72rKvrKd08ptbetLb4qvrlGjkv30yOKgKcTtOIbHyQt3shr/jyOzdApiCOh3LPYrpJo5M6i0zmVldOF2pUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "is-string": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -7580,6 +8210,16 @@ "node": ">=12" } }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/async-lock": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", @@ -7695,8 +8335,8 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -7707,6 +8347,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -7722,7 +8363,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -7732,8 +8372,8 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -7882,6 +8522,13 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -8086,8 +8733,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC", - "optional": true + "devOptional": true, + "license": "ISC" }, "node_modules/cjs-module-lexer": { "version": "1.2.3", @@ -8122,6 +8769,44 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -8274,6 +8959,60 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dataloader": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz", @@ -8333,6 +9072,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -8360,6 +9132,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/defu": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", @@ -8464,6 +9264,50 @@ "node": ">=10" } }, + "node_modules/dotignore": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz", + "integrity": "sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.4" + }, + "bin": { + "ignored": "bin/ignored" + } + }, + "node_modules/dotignore/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotignore/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dotignore/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/dts-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-3.0.0.tgz", @@ -8543,8 +9387,8 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -8603,6 +9447,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -8613,6 +9467,94 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -8631,6 +9573,27 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -8650,6 +9613,56 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -8827,6 +9840,13 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -9152,8 +10172,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/fs-extra": { "version": "7.0.1", @@ -9170,6 +10190,13 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -9193,50 +10220,43 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fuse-native": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/fuse-native/-/fuse-native-2.2.6.tgz", - "integrity": "sha512-Y5wXd7vUsWWWIIHbjluv7jKZgPZaSVA5YWaW3I5fXIJfcGWL6IRUgoBUveQAq+D8cG9cCiGNahv9CeToccCXrw==", - "hasInstallScript": true, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, "license": "MIT", "dependencies": { - "fuse-shared-library": "^1.0.2", - "nanoresource": "^1.3.0", - "napi-macros": "^2.0.0", - "node-gyp-build": "^4.2.0" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, - "bin": { - "fuse-native": "bin.js" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fuse-shared-library": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/fuse-shared-library/-/fuse-shared-library-1.1.1.tgz", - "integrity": "sha512-EfgTo/eS1euZFUe7x8KqyA40hV4DwP7kqp1VNZApu2nlPnJv8SanraBE3VXyX7ff41sxw7M0oWY7re3G3wnZVA==", + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, "license": "MIT", - "dependencies": { - "fuse-shared-library-darwin": "^1.0.3", - "fuse-shared-library-linux": "^1.0.1", - "fuse-shared-library-linux-arm": "^1.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fuse-shared-library-darwin": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fuse-shared-library-darwin/-/fuse-shared-library-darwin-1.1.3.tgz", - "integrity": "sha512-4Q8gMxyMl1+gwHGpiYUoKKpi7xq8WcPo0TvJvjZzHMuCiszouu2GgEs6SJAqPB3LjfmEkl6kPV+2Oluczr0Nig==", - "license": "MIT" - }, - "node_modules/fuse-shared-library-linux": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fuse-shared-library-linux/-/fuse-shared-library-linux-1.0.1.tgz", - "integrity": "sha512-07MQRSobrBKwW4D7oKm0gM2TwgvZWb+gC08JdiYDG4KBTncxk9ssqEDiDMKll8hpseZufsY2w1yc/feOu2DPmQ==", - "license": "MIT" - }, - "node_modules/fuse-shared-library-linux-arm": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fuse-shared-library-linux-arm/-/fuse-shared-library-linux-arm-1.0.0.tgz", - "integrity": "sha512-Dj4ssxo1/MKGvOsVWRblSRu+o5F5OJTrVPDkjSyGDU2yKvVnIzQSwy1deiWA0qCcS/Q8iJMlZaCpCcZWSwvoug==", - "license": "MIT" + "node_modules/fuse-napi": { + "resolved": "packages/computerd/vendor/fuse-napi", + "link": true }, "node_modules/gaxios": { "version": "7.3.1", @@ -9306,6 +10326,16 @@ "node": ">=18" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -9340,6 +10370,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -9353,6 +10393,40 @@ "node": ">= 0.4" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.6.tgz", + "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -9360,6 +10434,28 @@ "license": "MIT", "optional": true }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -9390,6 +10486,54 @@ "tslib": "2" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -9456,6 +10600,37 @@ "dev": true, "license": "ISC" }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-dynamic-import": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/has-dynamic-import/-/has-dynamic-import-2.1.1.tgz", + "integrity": "sha512-DuTCn6K/RW8S27npDMumGKsjG6HE7MxzedZka5tJP+9dqfxks+UMqKBmeCijHtIhsBEZPlbMg0qMHi2nKYVtKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -9468,6 +10643,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -9756,6 +10947,18 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -9777,6 +10980,21 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ip-address": { "version": "10.7.4", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", @@ -9819,6 +11037,94 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -9831,6 +11137,57 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -9841,6 +11198,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9851,6 +11224,42 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -9874,6 +11283,32 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -9884,6 +11319,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -9909,6 +11361,71 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-subdir": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", @@ -9922,6 +11439,24 @@ "node": ">=4" } }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -9949,6 +11484,52 @@ ], "license": "MIT" }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -10385,6 +11966,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10406,6 +11990,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10427,6 +12014,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10448,6 +12038,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11730,6 +13323,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -11747,6 +13343,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -11764,6 +13363,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -11781,6 +13383,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -11798,6 +13403,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -11815,6 +13423,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -11832,6 +13443,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -11849,6 +13463,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -11866,6 +13483,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -11889,6 +13509,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -11912,6 +13535,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -11935,6 +13561,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -11958,6 +13587,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -11981,6 +13613,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -12004,6 +13639,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -12027,6 +13665,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -12254,12 +13895,58 @@ "minimist": "^1.2.5" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/mock-property": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/mock-property/-/mock-property-1.1.2.tgz", + "integrity": "sha512-Rw0vY0wBdNtm3sbANVMDUgO5Htew9xotKoqcdK/a4rW8jf5KEl6MAWGMBhjKng6gpPjX8SmO8f+65ssnxNroTw==", + "dev": true, "license": "MIT", - "optional": true + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "isarray": "^2.0.5", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/modern-tar": { "version": "0.7.7", @@ -12339,8 +14026,8 @@ "version": "3.94.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "semver": "^7.3.5" }, @@ -12378,6 +14065,35 @@ "node": ">=10.5.0" } }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -12424,6 +14140,31 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/node-gyp": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-13.1.0.tgz", + "integrity": "sha512-FpNzOLFACKG0oRBYbZ5WOeWD1EqRTIWX90MaRIWTO18lVYPx8fqIjrdS3pihjnCkSmgop6zVGkSqg1vXZLksuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^10.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "tar": "^7.5.7", + "tinyglobby": "^0.2.12", + "undici": "^8.4.1", + "which": "^7.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -12435,6 +14176,42 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-7.0.0.tgz", + "integrity": "sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/node-liblzma": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-liblzma/-/node-liblzma-2.2.0.tgz", @@ -12467,6 +14244,35 @@ "node": ">=18" } }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm-run-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz", + "integrity": "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -12488,6 +14294,70 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -12583,6 +14453,25 @@ "dev": true, "license": "MIT" }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/oxfmt": { "version": "0.72.0", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.72.0.tgz", @@ -12916,6 +14805,16 @@ "node": ">=14.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -12925,6 +14824,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -13105,6 +15011,24 @@ "node": ">=10" } }, + "node_modules/prebuildify": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/prebuildify/-/prebuildify-6.0.1.tgz", + "integrity": "sha512-8Y2oOOateom/s8dNBsGIcnm6AxPmLH4/nanQzL5lQMU+sC0CMhzARZHizwr36pUPLdvBnOkCNQzxg4djuFSgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "mkdirp-classic": "^0.5.3", + "node-abi": "^3.3.0", + "npm-run-path": "^3.1.0", + "pump": "^3.0.0", + "tar-fs": "^2.1.0" + }, + "bin": { + "prebuildify": "bin.js" + } + }, "node_modules/prettier": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", @@ -13136,6 +15060,16 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/proc-log": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -13216,8 +15150,8 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -13487,6 +15421,29 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -13511,6 +15468,27 @@ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "license": "MIT" }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -13592,6 +15570,30 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -13665,6 +15667,62 @@ "@rolldown/binding-win32-x64-msvc": "1.2.14" } }, + "node_modules/rolldown-plugin-dts": { + "version": "0.29.0-beta.2", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.29.0-beta.2.tgz", + "integrity": "sha512-0NT4q7HMRIpQv47+SfZB5IhEWJh0gp2rDEZPu2ry+LeJFb9tlMB08Sy+sl1+cqcCXnHpkd9dvBy3rZDCBF1JIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dts-resolver": "^3.0.0", + "get-tsconfig": "5.0.0-beta.6", + "obug": "^3.0.0", + "yuku-ast": "^0.19.0", + "yuku-codegen": "^0.19.0", + "yuku-parser": "^0.19.0" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@volar/typescript": "~2.4.0", + "@vue/language-core": "~3.2.0 || ~3.3.0", + "rolldown": "^1.2.0", + "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0 || ~7.1.0-0", + "vue-tsc": "~3.2.0 || ~3.3.0" + }, + "peerDependenciesMeta": { + "@volar/typescript": { + "optional": true + }, + "@vue/language-core": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/rolldown-plugin-dts/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -13705,6 +15763,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -13725,6 +15803,41 @@ ], "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -13846,6 +15959,37 @@ "node": ">= 0.4" } }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -13994,6 +16138,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -14008,6 +16155,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -14022,6 +16172,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -14036,6 +16189,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -14324,6 +16480,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -14333,6 +16503,66 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -14429,6 +16659,19 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/swr": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", @@ -14456,26 +16699,83 @@ "dev": true, "license": "MIT" }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tape": { + "version": "5.10.3", + "resolved": "https://registry.npmjs.org/tape/-/tape-5.10.3.tgz", + "integrity": "sha512-p+WLISY4ewSacY6yKWxlT6EY+iutUX2magjOlIx29LhmU08yDjWnZ7n7jBQSS8tsWYFuGW1asj3LUwkK1wW9LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ljharb/now": "^1.0.1", + "@ljharb/resumer": "^0.1.3", + "@ljharb/through": "^2.3.14", + "array.prototype.every": "^1.1.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "deep-equal": "^2.2.3", + "defined": "^1.0.1", + "dotignore": "^0.1.2", + "es-object-atoms": "^1.1.2", + "for-each": "^0.3.5", + "get-package-type": "^0.1.0", + "glob": "^7.2.3", + "has-dynamic-import": "^2.1.1", + "hasown": "^2.0.4", + "inherits": "^2.0.4", + "is-regex": "^1.2.1", + "minimist": "^1.2.8", + "mock-property": "^1.1.2", + "object-inspect": "^1.13.4", + "object-is": "^1.1.6", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "resolve": "^2.0.0-next.7", + "string.prototype.trim": "^1.2.11" + }, + "bin": { + "tape": "bin/tape" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "engines": { + "node": ">=18" } }, "node_modules/tar-fs": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -14487,8 +16787,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -14504,8 +16804,8 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -14515,6 +16815,16 @@ "node": ">= 6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -14765,21 +17075,21 @@ "license": "MIT" }, "node_modules/tsdown": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.23.1.tgz", - "integrity": "sha512-MSC2WSOts3in95J334/VIS7KPfor3eCSvndJ6KdoVj9Aak9Kh1aNfz9ws5owhdukuTNSXRdfkPEzA/ihpDD6SA==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.23.2.tgz", + "integrity": "sha512-om1OoDykFlv07uM98WAX3ZNmu02FBmHqZYLW1hm+IMktb92r7VMNKgQ/G8dknECI1Q2huPrFJ+sggdYk8efsQg==", "dev": true, "license": "MIT", "dependencies": { - "cac": "^7.0.1", + "cac": "^7.1.0", "defu": "^6.1.7", "empathic": "^2.1.0", "hookable": "^6.1.2", "import-without-cache": "^0.4.1", "obug": "^3.0.0", "picomatch": "^4.0.7", - "rolldown": "~1.2.13", - "rolldown-plugin-dts": "^0.28.7", + "rolldown": "~1.2.15", + "rolldown-plugin-dts": "^0.29.0-beta.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", @@ -14790,66 +17100,333 @@ "tsdown": "dist/run.mjs" }, "engines": { - "node": "^22.18.0 || ^24.11.0 || >=26.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - }, - "peerDependencies": { - "@arethetypeswrong/core": "^0.18.1", - "@tsdown/css": "0.23.1", - "@tsdown/exe": "0.23.1", - "@vitejs/devtools": "*", - "publint": "^0.3.8", - "tsx": "*", - "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", - "unplugin-unused": ">=0.5.0", - "unrun": "*" - }, - "peerDependenciesMeta": { - "@arethetypeswrong/core": { - "optional": true - }, - "@tsdown/css": { - "optional": true - }, - "@tsdown/exe": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "publint": { - "optional": true - }, - "tsx": { - "optional": true - }, - "typescript": { - "optional": true - }, - "unplugin-unused": { - "optional": true - }, - "unrun": { - "optional": true - } + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.23.2", + "@tsdown/exe": "0.23.2", + "@vitejs/devtools": "*", + "publint": "^0.3.8", + "tsx": "*", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", + "unplugin-unused": ">=0.5.0", + "unrun": "*" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@tsdown/css": { + "optional": true + }, + "@tsdown/exe": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "tsx": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + }, + "unrun": { + "optional": true + } + } + }, + "node_modules/tsdown/node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/tsdown/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/tsdown/node_modules/get-tsconfig": { - "version": "5.0.0-beta.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.6.tgz", - "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", + "node_modules/tsdown/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=20.20.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/tsdown/node_modules/obug": { @@ -14866,50 +17443,38 @@ "node": ">=12.20.0" } }, - "node_modules/tsdown/node_modules/rolldown-plugin-dts": { - "version": "0.28.8", - "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.28.8.tgz", - "integrity": "sha512-FVhfqxorCWcc20BVT5eVRfRjzLLl6mgzjcMR4gSBf0K4oQMXT0qoHhhzfXy0L7y83fQy7Ug5ZDNk90xM3/LiCQ==", + "node_modules/tsdown/node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "dev": true, "license": "MIT", "dependencies": { - "dts-resolver": "^3.0.0", - "get-tsconfig": "5.0.0-beta.6", - "obug": "^3.0.0", - "yuku-ast": "^0.19.0", - "yuku-codegen": "^0.19.0", - "yuku-parser": "^0.19.0" - }, - "engines": { - "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sxzz" + "bin": { + "rolldown": "bin/cli.mjs" }, - "peerDependencies": { - "@typescript/native-preview": "*", - "@volar/typescript": "~2.4.0", - "@vue/language-core": "~3.2.0 || ~3.3.0", - "rolldown": "^1.2.0", - "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", - "vue-tsc": "~3.2.0 || ~3.3.0" + "engines": { + "node": "^20.19.0 || >=22.12.0" }, - "peerDependenciesMeta": { - "@typescript/native-preview": { - "optional": true - }, - "@volar/typescript": { - "optional": true - }, - "@vue/language-core": { - "optional": true - }, - "typescript": { - "optional": true - }, - "vue-tsc": { - "optional": true - } + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/tslib": { @@ -14995,6 +17560,75 @@ "node": ">= 0.4" } }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -15021,6 +17655,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/unconfig-core": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", @@ -15236,8 +17889,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/util/node_modules/inherits": { "version": "2.0.3", @@ -15510,6 +18163,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15530,6 +18186,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15550,6 +18209,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15570,6 +18232,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15785,6 +18450,73 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-typed-array": { "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", @@ -16418,9 +19150,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -16470,6 +19202,16 @@ "dev": true, "license": "MIT" }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -16696,7 +19438,7 @@ "@cloudflare/computer-rpc": "*", "@cloudflare/dofs": "*", "@platformatic/vfs": "^0.4.0", - "fuse-native": "^2.2.6", + "fuse-napi": "file:./vendor/fuse-napi", "ws": "^8.20.1" }, "bin": { @@ -16711,6 +19453,87 @@ "node": ">=22" } }, + "packages/computerd/node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/computerd/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "packages/computerd/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "packages/computerd/vendor/fuse-napi": { + "version": "2.3.1", + "cpu": [ + "x64", + "arm64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "nanoresource": "^1.3.0", + "napi-macros": "^2.2.2", + "node-gyp-build": "^4.8.4" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "concat-stream": "^2.0.0", + "node-gyp": "^13.0.1", + "prebuildify": "^6.0.1", + "tape": "^5.10.2", + "typescript": "^7.0.2" + }, + "engines": { + "node": "^22.0.0 || ^24.0.0 || ^26.0.0" + } + }, "packages/dofs": { "name": "@cloudflare/dofs", "version": "0.7.1", diff --git a/packages/computer-computerd-linux-x64/README.md b/packages/computer-computerd-linux-x64/README.md index fce054e3c..2d0d49f1a 100644 --- a/packages/computer-computerd-linux-x64/README.md +++ b/packages/computer-computerd-linux-x64/README.md @@ -11,17 +11,20 @@ Docker image context for the prebuilt `computerd` linux-x64 binary. `computerd` is the daemon side of [`@cloudflare/computer`](../computer) — see [`docs/`](../../docs) for the wire protocol and architecture. -The binary is a Node single executable application. Everything needed at -runtime — the Node runtime, `fuse-native`, and libfuse — is baked in. The -host needs `/dev/fuse` and a recent enough kernel for FUSE, nothing else. +The binary is a Node single executable application. The Node runtime and +the FUSE addon are baked in. The unreleased binary on this branch links +system libfuse 3.17 or newer (`fuse3` on Debian trixie) and needs Linux +6.9 or newer for local-only FUSE passthrough. The published 0.7.1 image +still links libfuse 2 and needs `libfuse2t64`. Both need `/dev/fuse`. This package is private and is not published to npm. The release workflow builds the binary, stages it into `bin/computerd`, and publishes the image -instead: +instead. This sample keeps both libfuse runtime packages until the +libfuse 3 binary is published: ```dockerfile FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.1 AS computerd -FROM debian:stable-slim +FROM debian:trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates \ diff --git a/packages/computer/test-harness/run-computerd.sh b/packages/computer/test-harness/run-computerd.sh index 3f6f6614f..61032a22b 100755 --- a/packages/computer/test-harness/run-computerd.sh +++ b/packages/computer/test-harness/run-computerd.sh @@ -20,6 +20,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" BINARY="${COMPUTERD_BINARY:-$ROOT/artifacts/computerd/computerd-linux-x64}" HOST_PORT="${COMPUTERD_HARNESS_PORT:-0}" +# linux/arm64 runs an arm64 binary (COMPUTERD_BINARY) natively on an arm64 +# host instead of under emulation. +PLATFORM="${COMPUTERD_HARNESS_PLATFORM:-linux/amd64}" if ! command -v docker >/dev/null 2>&1; then echo "docker not found in PATH" >&2 @@ -32,27 +35,38 @@ if [[ ! -x "$BINARY" ]]; then exit 1 fi -# Pre-pull the libfuse2 packages once per container by using -# our own intermediate image. Skipped if the image already -# exists. -IMAGE_TAG="computerd-harness:libfuse2" -if ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then +# Pre-pull the FUSE runtime once by using our own intermediate +# image. Skipped if the image already exists. computerd links the +# system libfuse 3 and needs 3.17 or newer, which is Debian trixie. +# COMPUTERD_HARNESS_IMAGE boots a prepared image instead, such as one +# committed from a running container to stand in for a snapshot restore. +IMAGE_TAG="${COMPUTERD_HARNESS_IMAGE:-computerd-harness:libfuse3-${PLATFORM//\//-}}" +if [[ -z "${COMPUTERD_HARNESS_IMAGE:-}" ]] && ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then echo "building $IMAGE_TAG..." >&2 - docker build --platform linux/amd64 -t "$IMAGE_TAG" - <<'DOCKERFILE' >&2 -FROM --platform=linux/amd64 debian:stable-slim + docker build --platform "$PLATFORM" -t "$IMAGE_TAG" - <<'DOCKERFILE' >&2 +FROM debian:trixie-slim RUN apt-get update >/dev/null \ && apt-get install -y --no-install-recommends \ - fuse3 libfuse2t64 attr util-linux coreutils findutils \ + fuse3 attr util-linux coreutils findutils \ >/dev/null \ && rm -rf /var/lib/apt/lists/* DOCKERFILE fi +# Lets a test boot computerd with MOUNT_IGNORE, COMPUTERD_DB, or COMPUTERD_FUSE_* +# set. +FORWARD_ENV=() +while IFS='=' read -r name _; do + case "$name" in + MOUNT_IGNORE | MOUNT_IGNORE_PATH | COMPUTERD_DB | COMPUTERD_FUSE_*) FORWARD_ENV+=(-e "$name") ;; + esac +done < <(env) + # Boot the container. Privileged + /dev/fuse + SYS_ADMIN is -# the same recipe as script/fs-tests.sh; lets fuse-native +# the same recipe as script/fs-tests.sh; lets the FUSE binding # actually mount. CID=$(docker run --rm -d \ - --platform linux/amd64 \ + --platform "$PLATFORM" \ --privileged \ --device /dev/fuse \ --cap-add SYS_ADMIN --cap-add MKNOD \ @@ -62,6 +76,7 @@ CID=$(docker run --rm -d \ -p "$HOST_PORT:8080" \ -e PORT=8080 \ -e MOUNT_POINT=/workspace \ + ${FORWARD_ENV[@]+"${FORWARD_ENV[@]}"} \ "$IMAGE_TAG" \ /usr/local/bin/computerd) diff --git a/packages/computerd/README.md b/packages/computerd/README.md index c887cabaa..11a68e260 100644 --- a/packages/computerd/README.md +++ b/packages/computerd/README.md @@ -14,7 +14,7 @@ Computer daemon CLI and FUSE mount package. ## `computerd` -`computerd` starts a FUSE-backed virtual filesystem and an HTTP server. The filesystem is backed by `@platformatic/vfs`, while the FUSE mount is provided by `fuse-native`. +`computerd` starts a FUSE-backed virtual filesystem and an HTTP server. The filesystem is backed by `@platformatic/vfs`, while the FUSE mount is provided by a vendored, patched copy of [`fuse-napi`](vendor/fuse-napi/PATCHES.md), which binds libfuse 3. The HTTP server listens on the port provided by the `PORT` environment variable, defaulting to `45678`. The FUSE mount point is provided by `MOUNT_POINT`, defaulting to `/workspace`. The backing VFS stores files under the same absolute prefix: VFS `/workspace/repo/a.txt` is visible to container processes as `/workspace/repo/a.txt`, so capnweb reads, shim materialisation, and shell `exec` agree on absolute paths. @@ -25,7 +25,7 @@ PORT=45678 MOUNT_POINT=/tmp/workspace npx -p @cloudflare/computerd computerd Current endpoints: - `GET /health` returns `200 OK` with `ok\n` once the HTTP server is up (it does not currently block on FUSE readiness). -- `GET /__computerd/info` returns JSON with the selected FUSE backend, mount point, and bound port. +- `GET /__computerd/info` returns JSON with the selected FUSE backend, mount point, and bound port, plus an `ignore` block describing local-only paths and whether the kernel is serving them through FUSE passthrough. See [Local-only paths](#local-only-paths-mount_ignore). - `GET /__computerd/stats` returns JSON with DOFS table row counts, total inline and blob byte sizes, the orphan-blob subset, process resident memory, and the store's own size and free-page count. Useful for watching how the store grows under load. - `POST /__computerd/checkpoint` folds the store's write-ahead log back into the database file and returns `{ walFrames, sizeBytes, durationMs }`. For a host about to take a disk snapshot. Any other method returns `405`. - `GET /` returns `200 OK` with an empty JSON object: `{}`. @@ -42,7 +42,8 @@ check, which is what the container harnesses rely on. Current filesystem support: - `@platformatic/vfs` in-memory filesystem provided by `@cloudflare/dofs`'s node provider. -- FUSE operation adapter covering the full `fuse-native` operation surface. +- FUSE operation adapter covering the full operation surface the binding exposes. +- Local-only paths (`MOUNT_IGNORE`) served from the container's disk and kept out of sync. With the required kernel support and mount permissions, eligible directories can be bind-mounted to bypass computerd; see [Native roots](#native-roots). - Unsupported FUSE operations return `ENOSYS` to the kernel; the binding logs a one-shot warning per operation. - capnweb RPC over `/api` exposes the workspace database and an `exec` runner to clients. - Synchronization is driven by whoever holds the other end of the session. The daemon serves `SyncRPC`; it does not run a sync loop of its own. @@ -246,17 +247,36 @@ spelling, with the mount point and trailing slashes removed: "patterns": ["**/node_modules", "!/vendor/node_modules", "!**/node_modules/.bin"], "ineffectiveExclusions": ["!**/node_modules/.bin"], "fastPaths": { - "passthrough": false, - "passthroughReason": "fuse-native binds libfuse 2.9; FOPEN_PASSTHROUGH requires the libfuse 3.17 API", + "passthrough": true, + "passthroughReason": "active", + "passthroughOpens": 1284, + "passthroughFallbacks": 0, "writebackCache": false } } } ``` -`fastPaths.passthrough` is `false` on current builds by design. Ignored -writes skip the VFS and the transfer but still cross FUSE; see -[19. Performance](../../docs/19_performance.md#local-only-paths-mount_ignore). +`fastPaths` is read on every request. `passthrough` is `true` once the +kernel is serving local-only file data itself, through FUSE passthrough, +and the two counters say how opens were served. When it is `false`, +`passthroughReason` names the case, because each has a different fix: + +| `passthroughReason` says | What to do | +| --- | --- | +| no local-only paths are configured | Nothing; set `MOUNT_IGNORE` to use the feature. | +| no kernel FUSE mount is running | Expected under `FUSE_MOUNT=shim` or `none`. | +| turned off with `COMPUTERD_FUSE_PASSTHROUGH` | Unset it. | +| the kernel did not offer FUSE passthrough | The host needs Linux 6.9 or newer. | +| refused every backing registration | Give computerd `CAP_SYS_ADMIN`, and keep `MOUNT_IGNORE_PATH` off overlayfs stacked on another overlayfs. | +| negotiated, but no local-only file has been opened yet | Nothing; it flips on the first open. | + +In every one of these cases the local-only paths still work. They are +served by computerd instead of the kernel, which is slower but otherwise +the same. computerd also logs the first refused registration per mount +with the error code. See +[19. Performance](../../docs/19_performance.md#local-only-paths-mount_ignore) +for what passthrough changes. ### Synced directories that hold local-only paths @@ -316,16 +336,137 @@ one transfer but still occupies the container's store. A wrapper that injects `ignore` into `fetchChanges` to keep a dependency tree out of the Durable Object should be deleted in favor of `MOUNT_IGNORE`. +### Native roots + +A local-only path is served from the container's disk, but with FUSE +passthrough alone every lookup, `stat`, create, and listing under it +still reaches computerd; only file data goes straight to the kernel. +For a dependency tree, metadata is most of the work. + +So computerd bind-mounts each local-only directory onto the mount from +the place it already stores it, under `MOUNT_IGNORE_PATH`. Everything +under it is then served by the kernel from the container's disk and +never reaches computerd at all. A native root is a local-only directory +whose parent is synced: `packages/app/node_modules` is one, the +`node_modules` directories nested inside it are already under it, and a +file pattern such as `**/*.tsbuildinfo` never is. + +The content is the same either way. A mount only changes how fast a path +is served, so a mount that fails, lands late, or is removed falls back to +FUSE. The native addon resolves the source and target beneath pinned +root-directory descriptors, rejects symlinks in every path component, +clones a private mount, and attaches it to the pinned target. It does not +pass user-controlled paths to a mount command. If the helper is unavailable +or rejects a path, it does not attempt a path-based bind. Check +`/__computerd/stats` for `localPaths.nativeRoots.lastFailure`. + +Roots are mounted when they appear, with no watcher: + +- When a command creates one. Its parent is synced, so the `mkdir` + reaches computerd, which mounts the directory once it has replied. + The first few files may be written through FUSE before the mount + lands; they end up in the same directory. +- When something looks one up that exists on disk but is not mounted + yet, such as after a container snapshot restore, once the synced + parent is back from the host. +- At startup, for roots already on disk. + +Native roots need FUSE passthrough to be negotiated (Linux 6.9 or newer), +`CAP_SYS_ADMIN`, a kernel with `openat2`, `open_tree`, and `move_mount`, and +the bundled native helper. Without these, local-only paths are served +through FUSE as described above. Native roots do not need util-linux +`mount`; the addon makes the mount system calls itself. +`COMPUTERD_FUSE_NATIVE_ROOTS=0` turns them off, for ruling them in or out +while chasing a problem. + +#### Removing or renaming a root + +`rmdir(2)` and `rename(2)` of a directory that is itself a mount point +fail with `EBUSY`, and the kernel does not ask FUSE first. For the +commands computerd runs, a removal guard takes care of it: computerd +installs a seccomp filter on the thread it spawns commands from, so each +command and everything it starts pauses on `rmdir`, `unlinkat` with +`AT_REMOVEDIR`, and the `rename` family. A thread in computerd checks +the path; if it names a native root, it detaches the mount, then lets +the call continue, and computerd serves it as it would any local-only +path. Everything else, file deletions included, is never stopped. + +| Command | Through FUSE | Native root, from a computerd command | Native root, from elsewhere | +| --- | --- | --- | --- | +| `rm -rf node_modules` | works | works | empties it, then `EBUSY` | +| `rm -rf packages/app` with a root inside | works | works | `EBUSY` on the root | +| `rmdir`/`fs.rm`/`shutil.rmtree` of a root | works | works | `EBUSY` | +| `mv node_modules elsewhere` | `EXDEV`, so `mv` copies | `EXDEV`, so `mv` copies | `EBUSY` | +| `mv packages/app packages/web` | works | works; the mount moves with it | works | + +"Elsewhere" is any process computerd did not start, such as a `docker +exec` shell. A removed root is mounted again when it is created again. +Each directory removal or rename a command makes costs a round trip to +the guard, about 15 µs: `rm -rf` of 5,000 directories took 124 ms +rather than 50 ms on a laptop VM. File deletions, reads, and writes are +not affected. + +The guard is a convenience, not a security boundary. It needs +`CAP_SYS_ADMIN` and Linux 5.5 or newer, and starts only with native +roots. Without it, removing a root from any process gets `EBUSY`, as +from elsewhere above. To remove one from a process the guard does not +cover, detach it first: + +```sh +curl -X POST http://127.0.0.1:$PORT/__computerd/native-roots/detach \ + -d '{"path":"packages/app/node_modules"}' # or no body for every root +``` + +The answer lists what was detached. A detached root is not mounted +again until it is created again, so the `rm -rf` that follows cannot +race a remount. Detaching a native root leaves its backing directory in +place and computerd serves it through FUSE. + +#### Other differences + +- `st_dev` under a root is the container disk's, not the FUSE mount's, + so `find -xdev` and `du -x` stop at it. +- inotify works under a root. +- Hardlinks across a root's boundary still return `EXDEV`, as across + any local-only boundary. + +#### Checking what was applied + +`/__computerd/info` reports `ignore.fastPaths.nativeRoots`: + +```json +{ "enabled": true, "reason": "active", "active": 3, "mounts": 4, + "failures": 0, "detaches": 1, "transparentRemoval": true, + "transparentRemovalReason": "active", "removalDetaches": 1 } +``` + +`active` is read from `/proc/self/mountinfo`, which is the only mount +record. computerd treats every mount below `MOUNT_POINT` as a native root +and detaches it on startup and shutdown. Do not create unrelated mounts +under the workspace while native roots are enabled. `reason` explains a disabled state +(no `MOUNT_IGNORE`, no kernel FUSE mount, turned off, passthrough off or +not offered) or names the last failure when mounts are being refused. +`transparentRemoval` says whether the removal guard is on, and +`transparentRemovalReason` why not when it is off; `removalDetaches` +counts the roots it detached for a removal or rename. +`/__computerd/stats` carries the same counters under +`localPaths.nativeRoots`, along with `lastFailure`. + +computerd detaches every native root before it unmounts FUSE on +`SIGTERM`, and at startup detaches any a previous run left behind +before mounting again. + ## FUSE prerequisites -Linux hosts/containers need access to `/dev/fuse` and mount permissions. +Linux hosts and containers need access to `/dev/fuse`, mount permissions, and libfuse 3.17 or newer at runtime. On Debian that is the `fuse3` package from trixie onward. The binary links the system libfuse rather than carrying a copy, and exits with a message naming the package if the library is missing. -### macOS: macFUSE +Kernel passthrough for local-only paths also needs Linux 6.9 or newer and `CAP_SYS_ADMIN`. Without either, those paths still work and are served through `computerd` instead; `/__computerd/info` says which case applies. -Install macFUSE. On Apple Silicon, macFUSE may require Reduced -Security / kernel extension approval. FUSE-T is intentionally -unsupported — the libfuse2 surface our `fuse-native` dependency -wraps does not work against the FUSE-T userland. +Building from source needs a C toolchain and the libfuse 3 headers (`build-essential pkg-config libfuse3-dev` on Debian or Ubuntu). Any libfuse 3 compiles; an older one than 3.17 builds without passthrough. + +### macOS + +No macOS binary is published. The binding supports macFUSE 5.3.1 or newer with its libfuse 3 runtime, and `FUSE_MOUNT=macfuse` still selects it when running from source, but that path has not been tested since the move to libfuse 3. FUSE-T is not supported. Pick the backend with `FUSE_MOUNT`: @@ -345,8 +486,13 @@ EXEC_SHELL=/usr/bin/bash # interpreter exec runs commands under (defaul RPC_CLIENT_SECRET= # require Authorization: Bearer on every route but /health COMPUTER_VAR_NODE_ENV=production # forwarded into exec as NODE_ENV COMPUTERD_DB=/var/lib/computerd/state.db # on-disk store; "memory" or unset keeps it in memory +MOUNT_IGNORE=node_modules,dist # local-only paths, kept on the container's disk and out of sync +COMPUTERD_FUSE_PASSTHROUGH=0 # serve local-only paths through computerd instead of the kernel +COMPUTERD_FUSE_NATIVE_ROOTS=0 # serve local-only directories through FUSE instead of bind-mounting them ``` +`COMPUTERD_FUSE_PASSTHROUGH` is on by default and only matters when `MOUNT_IGNORE` is set. Turning it off is for ruling passthrough in or out while chasing a problem. + `EXEC_SHELL` must be an absolute path. It exists because `/bin/sh` is `dash` on a Debian-family image, where bash-only syntax is a parse error that aborts the command rather than a missing feature: `${PIPESTATUS[@]}`, arrays, `[[ ... ]]`, and process substitution all fail that way. `PIPESTATUS` is the usual way to recover the real exit status of a pipeline whose output is filtered — a command redacting a credential through `sed`, for instance — so a caller that needs it can select an interpreter that has it without repointing `/bin/sh` for every other script in the image. `FUSE_MOUNT=auto` is the friendly default: if `/dev/fuse` (or macFUSE) is available `computerd` mounts a real FUSE filesystem, otherwise it transparently falls back to the userspace shim. Pin the value (`fuse` / `macfuse` / `shim` / `none`) when a test needs to assert a specific code path. @@ -383,7 +529,7 @@ The test command does not build first. Some suites need build output that is not This package requires Node.js 22+ because `@platformatic/vfs` does. -The two real-FUSE suites gate themselves differently. `src/cli/computerd.test.ts` runs its real-FUSE case only when `/dev/fuse` is reachable; otherwise auto-detection resolves to the shim and the case skips. The guard is a bare existence check, so a `mknod`'d `/dev/fuse` in an unprivileged container defeats the skip and the mount then fails with `EPERM` — leave the device absent unless the container is privileged (`--privileged`, or `CAP_SYS_ADMIN` with device access). `src/exec/runner.fuse.test.ts` is separate: it skips unless both Docker and the prebuilt `computerd` binary are available, and runs `computerd` inside a privileged container, so the host's `/dev/fuse` does not matter. See the [`debugging-computerd-fuse`](../../.agents/skills/debugging-computerd-fuse/SKILL.md) skill for the privileged Docker setup. +The two real-FUSE suites gate themselves differently. `src/cli/computerd.test.ts` runs its real-FUSE case only when `/dev/fuse` is reachable; otherwise auto-detection resolves to the shim and the case skips. The guard is a bare existence check, so a `mknod`'d `/dev/fuse` in an unprivileged container defeats the skip and the mount then fails with `EPERM` — leave the device absent unless the container is privileged (`--privileged`, or `CAP_SYS_ADMIN` with device access). `src/exec/runner.fuse.test.ts` and `src/fuse/passthrough.fuse.test.ts` are separate: they skip unless both Docker and the prebuilt `computerd` binary are available, and run `computerd` inside a privileged container, so the host's `/dev/fuse` does not matter. The passthrough cases also skip on a kernel older than 6.9. See the [`debugging-computerd-fuse`](../../.agents/skills/debugging-computerd-fuse/SKILL.md) skill for the privileged Docker setup. ## Standalone release artifacts @@ -393,4 +539,6 @@ Standalone binaries are release artifacts, not files published in the npm packag npm run build:bin --workspace=@cloudflare/computerd ``` -The binary is produced with Node's Single Executable Application (SEA) feature: `scripts/build-bin.mjs` bundles the CLI with `esbuild`, generates a SEA blob via `node --experimental-sea-config`, downloads the target's Node binary, and injects the blob with `postject`. macOS targets are stripped and re-signed ad-hoc. `fuse-native` prebuilds and `libfuse` are embedded as SEA assets per target. +The binary is produced with Node's Single Executable Application (SEA) feature: `scripts/build-bin.mjs` compiles the FUSE addon inside a `node:22-trixie-slim` container for the target platform, bundles the CLI with `esbuild`, generates a SEA blob via `node --experimental-sea-config`, downloads the target's Node binary, and injects the blob with `postject`. The addon is embedded as a SEA asset; libfuse is not, and comes from the system at runtime. + +The published target is linux-x64. `COMPUTERD_BIN_TARGETS=linux-arm64` builds an arm64 binary as well, for running natively on an arm64 machine. Building needs Docker; the compiled addon is cached under `dist/sea`, and `COMPUTERD_FUSE_ADDON` points at a prebuilt `fuse.node` to skip the container. diff --git a/packages/computerd/bench-results.md b/packages/computerd/bench-results.md index d38ae9db3..e41368e0d 100644 --- a/packages/computerd/bench-results.md +++ b/packages/computerd/bench-results.md @@ -115,6 +115,14 @@ latency a real host would also save. The full `cloudflare/sandbox-sdk` ## FUSE mount option benchmarks +> [!NOTE] +> This section measured the libfuse 2.9 build. The libfuse 3 mount no +> longer sets `auto_cache` or `kernel_cache`, and the +> `COMPUTERD_FUSE_AUTO_CACHE` and `COMPUTERD_FUSE_KERNEL_CACHE` switches +> below are gone. The kernel refuses either cache flag on a passthrough +> open, so the driver now makes `auto_cache`'s own keep-cache decision per +> open instead, for synced files only. The numbers are kept as a record. + Numbers from running `script/run-fs-bench.sh` against the linux-x64 `computerd` binary in a privileged docker container, with the bench's pure large-file scenarios. Measurements were taken on Apple Silicon under diff --git a/packages/computerd/package.json b/packages/computerd/package.json index 9e0dfb895..2bf9a84c1 100644 --- a/packages/computerd/package.json +++ b/packages/computerd/package.json @@ -27,7 +27,7 @@ "@cloudflare/computer-rpc": "*", "@cloudflare/dofs": "*", "@platformatic/vfs": "^0.4.0", - "fuse-native": "^2.2.6", + "fuse-napi": "file:./vendor/fuse-napi", "ws": "^8.20.1" }, "devDependencies": { diff --git a/packages/computerd/scripts/build-bin.mjs b/packages/computerd/scripts/build-bin.mjs index ad96464d5..62bfe592d 100644 --- a/packages/computerd/scripts/build-bin.mjs +++ b/packages/computerd/scripts/build-bin.mjs @@ -1,19 +1,14 @@ #!/usr/bin/env node -// Cross-compile computerd to a self-contained Node SEA binary for each supported -// target. Steps per target: -// 1. esbuild a single ESM bundle (see scripts/sea/bundle.mjs). -// 2. Write a sea-config.json that names the bundle as main and lists the -// target-specific native assets (fuse-native prebuild + libfuse). -// 3. Generate the SEA blob via `node --experimental-sea-config`. -// 4. Download the target's Node binary (cached under .devbox/node-binaries), -// copy it into artifacts/, and inject the blob with postject. -// 5. (macOS) strip and reapply an ad-hoc signature so the kernel will load -// the modified binary. +// Builds computerd as a self-contained Node SEA binary. linux-x64 is the +// published target; COMPUTERD_BIN_TARGETS=linux-arm64 (or a comma-separated +// list) builds others, for running natively on an arm64 machine. // -// Everything used at runtime is embedded; the resulting artifact has no -// sidecar files. +// The binary embeds the FUSE addon but links the system libfuse 3, so the +// image that runs it needs libfuse 3.17 or newer (Debian trixie's fuse3). +// COMPUTERD_FUSE_ADDON names a prebuilt fuse.node to use instead of +// compiling one; it applies to every target in the run. import { execFile } from "node:child_process"; -import { X509Certificate } from "node:crypto"; +import { createHash, X509Certificate } from "node:crypto"; import { createWriteStream } from "node:fs"; import { chmod, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; @@ -31,6 +26,7 @@ const repoRoot = resolve(computerdRoot, "../.."); const outputDir = resolve(repoRoot, "artifacts/computerd"); const seaWorkDir = resolve(computerdRoot, "dist/sea"); const nodeCacheDir = resolve(repoRoot, ".devbox/node-binaries"); +const addonSourceDir = resolve(computerdRoot, "vendor/fuse-napi"); // v22.23.4 is the first 22.x release whose bundled root store drops the // emSign ECC Root CA - G3 and AAA Certificate Services roots, through which // eight compromised eMudhra intermediates chain. The SEA binary verifies TLS @@ -49,38 +45,45 @@ const minimumBundledRoots = 100; const EMBEDDED_PEM_PATTERN = /-----BEGIN CERTIFICATE-----[A-Za-z0-9+/=\r\n]+?-----END CERTIFICATE-----/g; -const targets = [ +// Same Node major as the SEA runtime, and same Debian release as the +// container images, so the addon links the libfuse those images carry. +const addonBuildImage = "node:22-trixie-slim"; +const minimumLibfuse = [3, 17]; + +const allTargets = [ { name: "linux-x64", outputName: "computerd-linux-x64", nodeArchive: `node-${nodeVersion}-linux-x64.tar.xz`, nodeBinaryInArchive: `node-${nodeVersion}-linux-x64/bin/node`, - addon: "node_modules/fuse-native/prebuilds/linux-x64/node.napi.node", - libfuse: "node_modules/fuse-shared-library-linux/libfuse/lib/libfuse.so", - libfuseName: "libfuse.so.2", - envVar: "LD_LIBRARY_PATH", - isMac: false, + dockerPlatform: "linux/amd64", }, { - name: "macos-x64", - outputName: "computerd-macos-x64", - nodeArchive: `node-${nodeVersion}-darwin-x64.tar.xz`, - nodeBinaryInArchive: `node-${nodeVersion}-darwin-x64/bin/node`, - addon: "node_modules/fuse-native/prebuilds/darwin-x64/node.napi.node", - // fuse-native's darwin prebuild ships its own libosxfuse.dylib next to - // the .node addon. macFUSE installs the same library system-wide, but - // we embed our copy so the binary doesn't require the user to have - // macFUSE pre-installed on the dynamic loader search path. - libfuse: "node_modules/fuse-native/prebuilds/darwin-x64/libosxfuse.dylib", - libfuseName: "libosxfuse.2.dylib", - envVar: "DYLD_LIBRARY_PATH", - isMac: true, + name: "linux-arm64", + outputName: "computerd-linux-arm64", + nodeArchive: `node-${nodeVersion}-linux-arm64.tar.xz`, + nodeBinaryInArchive: `node-${nodeVersion}-linux-arm64/bin/node`, + dockerPlatform: "linux/arm64", }, ]; +const requestedTargets = (process.env.COMPUTERD_BIN_TARGETS ?? "linux-x64") + .split(",") + .map((name) => name.trim()) + .filter((name) => name !== ""); +const targets = requestedTargets.map((name) => { + const target = allTargets.find((candidate) => candidate.name === name); + if (target === undefined) { + throw new Error( + `unknown COMPUTERD_BIN_TARGETS entry ${JSON.stringify(name)}; ` + + `expected one of ${allTargets.map((candidate) => candidate.name).join(", ")}`, + ); + } + return target; +}); + async function main() { await runNpm("build"); - await rm(outputDir, { recursive: true, force: true }); await mkdir(outputDir, { recursive: true }); await mkdir(seaWorkDir, { recursive: true }); await mkdir(nodeCacheDir, { recursive: true }); @@ -99,7 +102,8 @@ async function buildTarget(target) { const configPath = resolve(seaWorkDir, `${target.name}.sea-config.json`); const outBin = resolve(outputDir, target.outputName); - await bundleComputerd({ outfile: bundlePath, target }); + const addonPath = await buildAddon(target); + await bundleComputerd({ outfile: bundlePath }); await writeFile( configPath, @@ -112,8 +116,7 @@ async function buildTarget(target) { useCodeCache: false, assets: { "bundle.mjs": bundlePath, - "fuse-native.node": resolve(repoRoot, target.addon), - [target.libfuseName]: resolve(repoRoot, target.libfuse), + "fuse.node": addonPath, }, }, null, @@ -128,13 +131,6 @@ async function buildTarget(target) { await copyFile(nodeBinary, outBin); await chmod(outBin, 0o755); - if (target.isMac) { - // `codesign --remove-signature` only exists on macOS hosts. Skip with a - // warning on linux build hosts — the binary will still load on macOS - // after the user (or a downstream signer) re-signs it. - await maybe(execFileP("codesign", ["--remove-signature", outBin])); - } - const postjectArgs = [ resolve(repoRoot, "node_modules/postject/dist/cli.js"), outBin, @@ -144,11 +140,109 @@ async function buildTarget(target) { "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2", "--overwrite", ]; - if (target.isMac) postjectArgs.push("--macho-segment-name", "NODE_SEA"); await execFileP(process.execPath, postjectArgs); +} + +// Compiled in a container so the result doesn't depend on the build host: +// CI runners carry libfuse 3.14, too old for passthrough, and a developer +// machine may not be x64. Cached because an emulated compile takes minutes. +async function buildAddon(target) { + const override = process.env.COMPUTERD_FUSE_ADDON; + if (override !== undefined && override !== "") { + console.log(`[computerd-bin] using prebuilt addon ${override}`); + return resolve(override); + } + + const outDir = resolve(seaWorkDir, `${target.name}-addon`); + const addonPath = resolve(outDir, "fuse.node"); + const keyPath = resolve(outDir, "fuse.node.key"); + const key = await addonCacheKey(target); + if ((await exists(addonPath)) && (await readText(keyPath)) === key) return addonPath; + + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + console.log(`[computerd-bin] compiling fuse-napi for ${target.name} in ${addonBuildImage}`); + await runInContainer( + target.dockerPlatform, + [`${addonSourceDir}:/src:ro`, `${outDir}:/out`], + [ + "set -eu", + "apt-get update -qq", + "apt-get install -y -qq --no-install-recommends g++ make python3 pkg-config libfuse3-dev >/dev/null", + "mkdir /build", + "tar -C /src --exclude=./build --exclude=./node_modules -cf - . | tar -C /build -xf -", + "cd /build", + "npm install --omit=dev --ignore-scripts --no-audit --no-fund --loglevel=error", + "npx --yes node-gyp@11 rebuild --nodedir=/usr/local --loglevel=error", + "cp build/Release/fuse.node /out/fuse.node", + "pkg-config --modversion fuse3 > /out/libfuse-version", + ], + ); + + assertLibfuseVersion((await readText(resolve(outDir, "libfuse-version"))).trim()); + await writeFile(keyPath, key); + return addonPath; +} + +// Detached, then waited on: an attached `docker run` against a remote +// daemon can lose its stream and return before the container finishes. +async function runInContainer(platform, volumes, scriptLines) { + const volumeArgs = volumes.flatMap((volume) => ["-v", volume]); + const { stdout } = await execFileP("docker", [ + "run", + "-d", + "--platform", + platform, + ...volumeArgs, + addonBuildImage, + "sh", + "-c", + scriptLines.join("\n"), + ]); + const id = stdout.trim(); + try { + const { stdout: status } = await execFileP("docker", ["wait", id]); + if (status.trim() !== "0") { + const logs = await execFileP("docker", ["logs", id], { maxBuffer: 64 * 1024 * 1024 }); + throw new Error(`fuse-napi build exited ${status.trim()}:\n${logs.stdout}${logs.stderr}`); + } + } finally { + await execFileP("docker", ["rm", "-f", id]).catch(() => {}); + } +} - if (target.isMac) { - await maybe(execFileP("codesign", ["--sign", "-", outBin])); +function assertLibfuseVersion(version) { + const [major = 0, minor = 0] = version.split(".").map(Number); + const [minMajor, minMinor] = minimumLibfuse; + if (major < minMajor || (major === minMajor && minor < minMinor)) { + throw new Error( + `${addonBuildImage} carries libfuse ${version}; passthrough needs ` + + `${minimumLibfuse.join(".")} or newer`, + ); + } +} + +async function addonCacheKey(target) { + const hash = createHash("sha256").update(`${addonBuildImage}\n${target.dockerPlatform}\n`); + for (const file of [ + "fuse-native.c", + "removal-guard.c", + "removal-guard.h", + "confined-bind.c", + "binding.gyp", + "scripts/fuse-config.js", + "package.json", + ]) { + hash.update(await readFile(resolve(addonSourceDir, file))); + } + return hash.digest("hex"); +} + +async function readText(p) { + try { + return await readFile(p, "utf8"); + } catch { + return ""; } } @@ -217,14 +311,6 @@ async function exists(p) { } } -async function maybe(promise) { - try { - return await promise; - } catch (err) { - console.warn(`[computerd-bin] non-fatal: ${err.message}`); - } -} - function runNpm(script) { return new Promise((res, rej) => { const child = execFile("npm", ["run", script], { cwd: computerdRoot }); diff --git a/packages/computerd/scripts/sea/bundle.mjs b/packages/computerd/scripts/sea/bundle.mjs index 56266ff53..699a2a1dd 100644 --- a/packages/computerd/scripts/sea/bundle.mjs +++ b/packages/computerd/scripts/sea/bundle.mjs @@ -3,18 +3,11 @@ // Strategy: // * esbuild produces an ESM bundle of `dist/cli/computerd.js`. ESM is required so // capnweb's top-level await survives without an async-IIFE wrapper. A banner -// re-establishes `require`, `__filename`, and `__dirname` for the CJS -// dependencies (fuse-native, ws) transpiled into the bundle. -// * fuse-native's `require('node-gyp-build')(__dirname)` call is intercepted -// by an esbuild plugin. The shim resolves the .node addon and libfuse -// shared library from SEA assets at runtime (via `node:sea`), writes them -// to a deterministic temp directory keyed by sha256, and dlopens the -// addon. The shared library is found by the dynamic loader via -// LD_LIBRARY_PATH / DYLD_LIBRARY_PATH, prepended in the same shim. -// * `fuse-shared-library` is also intercepted: in the deployed environment -// the host already provides `fusermount`, so configure/isConfigured are -// no-ops. The lib path field is left empty because nothing inside -// fuse-native reads it directly. +// re-establishes `require`, `__filename`, and `__dirname` for any CJS code +// transpiled into the bundle. +// * fuse-napi loads its addon through `require('node-gyp-build')(root)`. +// An esbuild plugin swaps that module for a shim that writes the +// embedded addon to a temp directory keyed by its hash and dlopens it. import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,7 +17,7 @@ const here = dirname(fileURLToPath(import.meta.url)); const computerdRoot = resolve(here, "../.."); const repoRoot = resolve(computerdRoot, "../.."); -export async function bundleComputerd({ outfile, target }) { +export async function bundleComputerd({ outfile }) { const nodeGypBuildShim = ` const { writeFileSync, chmodSync, existsSync, mkdirSync } = require("node:fs"); const { tmpdir } = require("node:os"); @@ -32,57 +25,49 @@ const { join } = require("node:path"); const { createHash } = require("node:crypto"); const sea = require("node:sea"); -const LIBFUSE_NAME = ${JSON.stringify(target.libfuseName)}; -const ENV_VAR = ${JSON.stringify(target.envVar)}; - let cached; function loadNative() { if (cached) return cached; - const addonBuf = Buffer.from(sea.getAsset("fuse-native.node")); - const libBuf = Buffer.from(sea.getAsset(LIBFUSE_NAME)); - const hash = createHash("sha256").update(addonBuf).update(libBuf).digest("hex").slice(0, 16); + const addonBuf = Buffer.from(sea.getAsset("fuse.node")); + const hash = createHash("sha256").update(addonBuf).digest("hex").slice(0, 16); const dir = join(tmpdir(), "computerd-sea-" + hash); try { mkdirSync(dir, { recursive: true }); } catch {} - const libPath = join(dir, LIBFUSE_NAME); - const addonPath = join(dir, "fuse-native.node"); - if (!existsSync(libPath)) writeFileSync(libPath, libBuf); + const addonPath = join(dir, "fuse.node"); if (!existsSync(addonPath)) { writeFileSync(addonPath, addonBuf); chmodSync(addonPath, 0o755); } - process.env[ENV_VAR] = dir + (process.env[ENV_VAR] ? ":" + process.env[ENV_VAR] : ""); const m = { exports: {} }; - process.dlopen(m, addonPath); + try { + process.dlopen(m, addonPath); + } catch (error) { + if (typeof error?.message === "string" && error.message.includes("libfuse3")) { + const wrapped = new Error( + "computerd needs libfuse 3.17 or newer. On Debian trixie or later, " + + "install it with: apt-get install fuse3", + ); + wrapped.code = "EFUSEDEPENDENCY"; + wrapped.cause = error; + throw wrapped; + } + throw error; + } cached = m.exports; return cached; } module.exports = function nodeGypBuild(_dir) { return loadNative(); }; -`; - - const fuseSharedLibraryShim = ` -function noop(cb) { if (typeof cb === "function") process.nextTick(cb); } -function isConfigured(cb) { process.nextTick(() => cb(null, true)); } -module.exports = { beforeMount: noop, beforeUnmount: noop, configure: noop, unconfigure: noop, isConfigured }; `; const nativeShimPlugin = { - name: "fuse-native-shim", + name: "fuse-napi-shim", setup(b) { b.onResolve({ filter: /^node-gyp-build$/ }, () => ({ path: "node-gyp-build", namespace: "computerd-shim", })); - b.onResolve({ filter: /^fuse-shared-library$/ }, () => ({ - path: "fuse-shared-library", - namespace: "computerd-shim", + b.onLoad({ filter: /.*/, namespace: "computerd-shim" }, () => ({ + contents: nodeGypBuildShim, + loader: "js", + resolveDir: repoRoot, })); - b.onLoad({ filter: /.*/, namespace: "computerd-shim" }, (args) => { - if (args.path === "node-gyp-build") { - return { contents: nodeGypBuildShim, loader: "js", resolveDir: repoRoot }; - } - if (args.path === "fuse-shared-library") { - return { contents: fuseSharedLibraryShim, loader: "js" }; - } - return null; - }); }, }; diff --git a/packages/computerd/src/cli/bundle-port.test.ts b/packages/computerd/src/cli/bundle-port.test.ts index 04c95beab..c9e625f5f 100644 --- a/packages/computerd/src/cli/bundle-port.test.ts +++ b/packages/computerd/src/cli/bundle-port.test.ts @@ -15,10 +15,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const distEntry = path.resolve(here, "../../dist/cli/computerd.js"); const bundleScript = path.resolve(here, "../../scripts/sea/bundle.mjs"); -type BundleComputerd = (opts: { - outfile: string; - target: { libfuseName: string; envVar: string }; -}) => Promise; +type BundleComputerd = (opts: { outfile: string }) => Promise; async function loadBundler(ctx: { skip: (reason?: string) => void; @@ -45,10 +42,7 @@ test("COMPUTERD_DEFAULT_PORT stamps into the SEA bundle", async (ctx) => { const outfile = path.join(tmp, "computerd.bundle.mjs"); process.env.COMPUTERD_DEFAULT_PORT = "12345"; - await bundleComputerd({ - outfile, - target: { libfuseName: "libfuse.so.2", envVar: "LD_LIBRARY_PATH" }, - }); + await bundleComputerd({ outfile }); const bundle = await fs.readFile(outfile, "utf8"); // esbuild's `define` produces `const DEFAULT_PORT = 12345 ?? 45678;` @@ -65,10 +59,7 @@ test("missing COMPUTERD_DEFAULT_PORT keeps the in-source 45678 fallback", async const outfile = path.join(tmp, "computerd.bundle.mjs"); delete process.env.COMPUTERD_DEFAULT_PORT; - await bundleComputerd({ - outfile, - target: { libfuseName: "libfuse.so.2", envVar: "LD_LIBRARY_PATH" }, - }); + await bundleComputerd({ outfile }); const bundle = await fs.readFile(outfile, "utf8"); // Fallback literal must survive substitution. @@ -87,9 +78,6 @@ test("COMPUTERD_DEFAULT_PORT rejects non-integer values", async (ctx) => { process.env.COMPUTERD_DEFAULT_PORT = "not-a-port"; await expect( - bundleComputerd({ - outfile: path.join(tmp, "computerd.bundle.mjs"), - target: { libfuseName: "libfuse.so.2", envVar: "LD_LIBRARY_PATH" }, - }), + bundleComputerd({ outfile: path.join(tmp, "computerd.bundle.mjs") }), ).rejects.toThrow(/COMPUTERD_DEFAULT_PORT/); }); diff --git a/packages/computerd/src/cli/computerd.test.ts b/packages/computerd/src/cli/computerd.test.ts index d39b8bdc1..fcd88da77 100644 --- a/packages/computerd/src/cli/computerd.test.ts +++ b/packages/computerd/src/cli/computerd.test.ts @@ -111,8 +111,21 @@ test("computerd exposes file IO through real FUSE when FUSE_MOUNT=fuse", async ( ineffectiveExclusions: [], fastPaths: { passthrough: false, - passthroughReason: expect.stringContaining("libfuse 2.9"), + passthroughReason: expect.stringContaining("MOUNT_IGNORE"), + passthroughOpens: 0, + passthroughFallbacks: 0, writebackCache: false, + nativeRoots: { + enabled: false, + reason: expect.stringContaining("MOUNT_IGNORE"), + active: 0, + mounts: 0, + failures: 0, + detaches: 0, + transparentRemoval: false, + transparentRemovalReason: "native roots are off", + removalDetaches: 0, + }, }, }, }); @@ -138,6 +151,9 @@ test("MOUNT_IGNORE keeps matching paths on local disk and out of the VFS", async env: { FUSE_MOUNT: "fuse", MOUNT_IGNORE: "/node_modules,/dist", + // These cases are about the passthrough layer; native roots have + // their own suite. + COMPUTERD_FUSE_NATIVE_ROOTS: "0", MOUNT_IGNORE_PATH: ignoreRoot, }, }); @@ -165,6 +181,13 @@ test("MOUNT_IGNORE keeps matching paths on local disk and out of the VFS", async "module.exports=1", ); + // Whether the open used passthrough depends on the host's kernel and + // libfuse, so only the sum is pinned. + const fastPaths = JSON.parse((await request(`http://127.0.0.1:${port}/__computerd/info`)).body) + .ignore.fastPaths; + expect(fastPaths.passthroughOpens + fastPaths.passthroughFallbacks).toBeGreaterThan(0); + expect(fastPaths.passthroughReason).toEqual(expect.any(String)); + // A non-ignored sibling still goes to the VFS as before. await fs.mkdir(path.join(mountPoint, "src"), { recursive: true }); await fs.writeFile(path.join(mountPoint, "src", "main.ts"), "export {}"); @@ -211,6 +234,9 @@ test("MOUNT_IGNORE patterns route node_modules at any depth, with exclusions", a env: { FUSE_MOUNT: "fuse", MOUNT_IGNORE: "**/node_modules,!/vendor/node_modules,!**/node_modules/.bin", + // These cases are about the passthrough layer; native roots have + // their own suite. + COMPUTERD_FUSE_NATIVE_ROOTS: "0", MOUNT_IGNORE_PATH: ignoreRoot, }, }); @@ -1165,6 +1191,23 @@ test("/__computerd/checkpoint refuses a GET", async (_ctx) => { expect(response.statusCode).toBe(405); }); +test("POST /__computerd/native-roots/detach answers with what it detached", async (_ctx) => { + // No kernel mount, so there is nothing to detach. The route still + // answers, so a host can call it without knowing the backend. + const port = await getAvailablePort(); + const mountPoint = await fs.mkdtemp(path.join(os.tmpdir(), "computerd-mount-")); + await startComputerd({ port, mountPoint, env: { FUSE_MOUNT: "none" } }); + + const response = await request(`http://127.0.0.1:${port}/__computerd/native-roots/detach`, { + method: "POST", + }); + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual({ detached: [] }); + + const refused = await request(`http://127.0.0.1:${port}/__computerd/native-roots/detach`); + expect(refused.statusCode).toBe(405); +}); + test("/__computerd/checkpoint requires the shared secret when one is set", async (_ctx) => { const secret = "checkpoint-secret"; const port = await getAvailablePort(); diff --git a/packages/computerd/src/cli/computerd.ts b/packages/computerd/src/cli/computerd.ts index 643267f91..1e66f8e24 100644 --- a/packages/computerd/src/cli/computerd.ts +++ b/packages/computerd/src/cli/computerd.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node import { timingSafeEqual } from "node:crypto"; -import { mkdir } from "node:fs/promises"; +import { mkdir, stat } from "node:fs/promises"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { Socket } from "node:net"; +import { constants as osConstants } from "node:os"; import { isAbsolute } from "node:path"; import type { ExecEvent as RpcExecEvent } from "@cloudflare/computer-rpc"; import { isStubTrackingEnabled, stubSnapshot } from "@cloudflare/computer-rpc/debug"; @@ -14,12 +15,16 @@ import { WebSocket, WebSocketServer } from "ws"; import { Runner } from "../exec/index"; import type { ExecEvent as ComputerdExecEvent } from "../exec/types"; import { + createNativeRoots, createNodeVirtualFileSystem, describeMountIgnore, type FUSEBackend, type FuseMount, type MountIgnoreInfo, mountFuse, + type NativeRoots, + nativeRootsRequested, + type RemovalGuardBinding, parseFuseMountMode, parseStoreMode, type ResolvedStore, @@ -149,6 +154,47 @@ function dropHeartbeats(stream: ReadableStream): ReadableStr ); } +const NO_NATIVE_ROOT_STATS = { + active: 0, + mounts: 0, + failures: 0, + detaches: 0, + removalDetaches: 0, + lastFailure: undefined, +} as const; + +// Starts the removal guard on this thread, the one every command is +// spawned from, so each command and everything it starts inherits it. +function startRemovalGuard( + guard: RemovalGuardBinding | undefined, + roots: NativeRoots, +): { active: boolean; reason: string } { + if (guard === undefined) + return { active: false, reason: "the FUSE binding has no removal guard" }; + const result = guard.start((target) => roots.guardDetached(target)); + if (result === 0) { + void roots.attachGuard(guard); + return { active: true, reason: "active" }; + } + const code = errnoName(-result); + const reason = + code === "EACCES" || code === "EPERM" + ? `the kernel refused the seccomp filter (${code}); computerd needs CAP_SYS_ADMIN` + : code === "ENOSYS" + ? "not supported on this platform" + : `could not start (${code})`; + console.warn( + `computerd: removal guard off: ${reason}. Removing or renaming a native root ` + + `will fail with EBUSY; POST /__computerd/native-roots/detach first.`, + ); + return { active: false, reason }; +} + +function errnoName(errno: number): string { + const entry = Object.entries(osConstants.errno).find(([, value]) => value === errno); + return entry?.[0] ?? `errno ${errno}`; +} + interface ComputerdInfo { backend: FUSEBackend; mountPoint: string; @@ -202,6 +248,7 @@ function createHTTPServer( secret: string | undefined, getStats?: () => Record, checkpoint?: () => { walFrames: number; sizeBytes: number; durationMs: number }, + nativeRoots?: Pick, ): HTTPHandle { // Holds the current outbound capnweb session opened via /connect. // Re-POSTing /connect (e.g. after a DO hibernate + new incarnation) @@ -281,6 +328,21 @@ function createHTTPServer( return; } + // /__computerd/native-roots/detach — lazily detach one native root, + // or all of them, so a caller can remove or rename the directory. A + // detach is always safe: the FUSE layer serves the same directory. + if (path === "/__computerd/native-roots/detach") { + if (request.method !== "POST") { + send(response, 405, "method not allowed\n", { + allow: "POST", + "content-type": "text/plain; charset=utf-8", + }); + return; + } + void handleNativeRootsDetach(request, response, nativeRoots); + return; + } + if (request.method !== "GET" && request.method !== "HEAD") { send(response, 405, "method not allowed\n", { allow: "GET, HEAD", @@ -575,6 +637,35 @@ async function handleConnect( }); } +// POST { path?: string } with a mount-relative path, or no body to detach +// every native root. Answers 200 with what was detached; a path that is +// not a native root detaches nothing and is not an error. +async function handleNativeRootsDetach( + request: IncomingMessage, + response: ServerResponse, + nativeRoots: Pick | undefined, +): Promise { + let body: { path?: unknown }; + try { + body = await readJson<{ path?: unknown }>(request); + } catch (error) { + send(response, 400, `invalid JSON body: ${(error as Error).message}\n`, { + "content-type": "text/plain; charset=utf-8", + }); + return; + } + if (body.path !== undefined && typeof body.path !== "string") { + send(response, 400, "'path' must be a string when present\n", { + "content-type": "text/plain; charset=utf-8", + }); + return; + } + const detached = nativeRoots === undefined ? [] : await nativeRoots.detach(body.path); + send(response, 200, JSON.stringify({ detached }), { + "content-type": "application/json; charset=utf-8", + }); +} + async function readJson(request: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of request) { @@ -682,15 +773,45 @@ async function main(): Promise { storeStats, close: closeStore, } = await createNodeVirtualFileSystem({ store }); + let fuse: FuseMount | undefined; const info: ComputerdInfo = { backend, mountPoint, port, store, - ignore: describeMountIgnore(ignoreConfig), + // A getter, so each request reports live passthrough state. + get ignore() { + const passthrough = fuse?.getPassthroughStatus?.(); + return describeMountIgnore( + ignoreConfig, + passthrough, + passthrough === undefined + ? undefined + : { + requested: nativeRootsWanted, + ...(nativeRoots?.stats() ?? NO_NATIVE_ROOT_STATS), + ...(removal === undefined ? {} : { removal }), + }, + ); + }, }; - let fuse: FuseMount | undefined; + // Native roots: eligible local-only directories can be bind-mounted from + // where they are stored. Built before the mount so the passthrough layer can call + // into it, and switched on only once the kernel has negotiated FUSE + // passthrough: `active` is read on every call. + const nativeRootsWanted = nativeRootsRequested(process.env); + let removal: { active: boolean; reason: string } | undefined; + const nativeRoots: NativeRoots | undefined = + ignoreConfig.enabled && nativeRootsWanted && backend.kind !== "none" && backend.kind !== "shim" + ? createNativeRoots({ + root: ignoreConfig.root, + mountPoint, + ignore: ignoreConfig.ignore, + active: () => fuse?.getPassthroughStatus?.().negotiated === true, + }) + : undefined; + // When running on the userspace shim, capture the typed handle // so we can wire `flush()` and `reconcileNow()` into the SyncRPC // afterApply / beforeFetch hooks below. A real FUSE mount serves @@ -715,6 +836,9 @@ async function main(): Promise { if (ignoreConfig.enabled) { await mkdir(ignoreConfig.root, { recursive: true }); } + // Detach binds left by a previous run before remounting FUSE. This + // assumes no other subsystem puts mounts below the mount point. + await nativeRoots?.detachAll(); fuse = await mountFuse({ backend, mountPoint, @@ -725,10 +849,30 @@ async function main(): Promise { root: ignoreConfig.root, ignore: ignoreConfig.ignore, mountPoint, + ...(nativeRoots === undefined + ? {} + : { + onLocalDirectory: (path: string, created: boolean) => + nativeRoots.ensure(path, created), + }), }, } : {}), }); + if (nativeRoots !== undefined) { + // The kernel answers this only after FUSE_INIT, which is where + // passthrough is negotiated. Through the thread pool, never a + // synchronous call: this thread serves the request it waits on. + await stat(mountPoint).catch(() => {}); + if (fuse.getPassthroughStatus?.().negotiated === true) { + // Before the HTTP server accepts commands, so every command is + // forked after the filter is in place. + removal = startRemovalGuard(fuse.removalGuard, nativeRoots); + } + // Roots that survived on disk from before a restart or a snapshot + // restore. Not awaited: a mount only makes a path faster. + nativeRoots.scan(); + } } } // EXEC_LOG_MAX_BYTES lets the harness force size-cap eviction @@ -805,12 +949,20 @@ async function main(): Promise { return { ...collectDbStats(db), ...(fuse?.getBufferStats?.() ?? {}), - ...(fuse?.getLocalPathStats === undefined ? {} : { localPaths: fuse.getLocalPathStats() }), + ...(fuse?.getLocalPathStats === undefined + ? {} + : { + localPaths: { + ...fuse.getLocalPathStats(), + ...(nativeRoots === undefined ? {} : { nativeRoots: nativeRoots.stats() }), + }, + }), store_size_bytes: sizeBytes, store_freelist_count: freelistCount, }; }, checkpointStore, + nativeRoots, ); let shuttingDown = false; @@ -829,6 +981,9 @@ async function main(): Promise { console.error(error); } + // Before the FUSE unmount, which fails while anything is mounted + // beneath it. + await nativeRoots?.detachAll(); if (fuse !== undefined) { await unmount(fuse); } diff --git a/packages/computerd/src/fuse/binding-ops.test.ts b/packages/computerd/src/fuse/binding-ops.test.ts new file mode 100644 index 000000000..8a135b51d --- /dev/null +++ b/packages/computerd/src/fuse/binding-ops.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "vitest"; + +import { toBindingOps, UTIME_NOW, UTIME_OMIT } from "./binding-ops.js"; +import type { FuseOps, FuseStat } from "./driver.js"; + +const MTIME = Date.UTC(2020, 0, 1); +const ATIME = Date.UTC(2021, 0, 1); + +function fakeOps(getattrErrno = 0) { + const utimens: Array<[string, number, number]> = []; + const ops = { + init: (cb?: (code: number) => void) => cb?.(0), + error: () => {}, + getBufferStats: () => ({}), + getattr(_path: string, cb: (code: number, stat: FuseStat | null) => void) { + if (getattrErrno !== 0) { + cb(getattrErrno, null); + return; + } + cb(0, { atime: new Date(ATIME), mtime: new Date(MTIME) } as FuseStat); + }, + utimens(path: string, atime: number, mtime: number, cb: (code: number) => void) { + utimens.push([path, atime, mtime]); + cb(0); + }, + readdir(_path: string, cb: (code: number, names: string[]) => void) { + cb(0, []); + }, + } as unknown as FuseOps; + return { ops, utimens }; +} + +type BindingUtimens = ( + path: string, + atime: { seconds: number | bigint; nanoseconds: number }, + mtime: { seconds: number | bigint; nanoseconds: number }, + cb: (code: number) => void, +) => void; + +function call(ops: Record, atime: object, mtime: object): Promise { + const fn = ops.utimensWithTimespec as BindingUtimens; + return new Promise((resolve) => + fn("/f", atime as never, mtime as never, (code: number) => resolve(code)), + ); +} + +describe("toBindingOps", () => { + test("drops the entries fuse-napi refuses", () => { + const out = toBindingOps(fakeOps().ops); + expect(out).not.toHaveProperty("error"); + expect(out).not.toHaveProperty("init"); + expect(out).not.toHaveProperty("getBufferStats"); + expect(out).toHaveProperty("readdir"); + }); + + test("replaces utimens with the timespec variant", () => { + const out = toBindingOps(fakeOps().ops); + expect(out).not.toHaveProperty("utimens"); + expect(typeof out.utimensWithTimespec).toBe("function"); + }); + + test("converts explicit times to milliseconds", async () => { + const { ops, utimens } = fakeOps(); + const out = toBindingOps(ops); + const code = await call( + out, + { seconds: 10, nanoseconds: 500_000_000 }, + { seconds: 20n, nanoseconds: 0 }, + ); + expect(code).toBe(0); + expect(utimens).toEqual([["/f", 10_500, 20_000]]); + }); + + test("resolves UTIME_NOW to the current time", async () => { + const { ops, utimens } = fakeOps(); + const out = toBindingOps(ops, { now: () => 1234 }); + await call(out, { seconds: 0, nanoseconds: UTIME_NOW }, { seconds: 0, nanoseconds: UTIME_NOW }); + expect(utimens).toEqual([["/f", 1234, 1234]]); + }); + + test("keeps the current value for UTIME_OMIT, so touch -m leaves atime alone", async () => { + const { ops, utimens } = fakeOps(); + const out = toBindingOps(ops, { now: () => 1234 }); + await call( + out, + { seconds: 0, nanoseconds: UTIME_OMIT }, + { seconds: 0, nanoseconds: UTIME_NOW }, + ); + expect(utimens).toEqual([["/f", ATIME, 1234]]); + }); + + test("does nothing when both times are omitted", async () => { + const { ops, utimens } = fakeOps(); + const out = toBindingOps(ops); + const code = await call( + out, + { seconds: 0, nanoseconds: UTIME_OMIT }, + { seconds: 0, nanoseconds: UTIME_OMIT }, + ); + expect(code).toBe(0); + expect(utimens).toEqual([]); + }); + + test("reports the getattr error when an omitted time cannot be read", async () => { + const { ops, utimens } = fakeOps(-2); + const out = toBindingOps(ops); + const code = await call( + out, + { seconds: 0, nanoseconds: UTIME_OMIT }, + { seconds: 5, nanoseconds: 0 }, + ); + expect(code).toBe(-2); + expect(utimens).toEqual([]); + }); +}); diff --git a/packages/computerd/src/fuse/binding-ops.ts b/packages/computerd/src/fuse/binding-ops.ts new file mode 100644 index 000000000..699303d22 --- /dev/null +++ b/packages/computerd/src/fuse/binding-ops.ts @@ -0,0 +1,71 @@ +import type { FuseOps } from "./driver.js"; + +// The kernel's sentinel nanosecond values, as fuse-napi passes them. +export const UTIME_NOW = 0x3fffffff; +export const UTIME_OMIT = 0x3ffffffe; + +export interface Timespec { + readonly seconds: number | bigint; + readonly nanoseconds: number; +} + +type StatusCallback = (code: number) => void; + +export interface BindingOpsOptions { + readonly now?: () => number; +} + +/** + * The op table to hand to `new Fuse()`. + * + * fuse-napi refuses `error` (not an operation it knows), `init` (mutually + * exclusive with the `initWithConfig` the mount adds) and `getBufferStats`. + * Its plain `utimens` answers EOPNOTSUPP whenever the kernel sends + * UTIME_NOW or UTIME_OMIT, which is what `touch` does, so the timespec + * variant is used instead. + */ +export function toBindingOps( + ops: FuseOps, + options: BindingOpsOptions = {}, +): Record { + const { getBufferStats: _stats, error: _error, init: _init, utimens: _utimens, ...rest } = ops; + return { ...rest, utimensWithTimespec: utimensWithTimespec(ops, options.now ?? Date.now) }; +} + +function utimensWithTimespec(ops: FuseOps, now: () => number) { + return (path: string, atime: Timespec, mtime: Timespec, cb: StatusCallback): void => { + const omitAtime = atime.nanoseconds === UTIME_OMIT; + const omitMtime = mtime.nanoseconds === UTIME_OMIT; + if (omitAtime && omitMtime) { + cb(0); + return; + } + + const resolve = (time: Timespec, current: number): number => { + if (time.nanoseconds === UTIME_OMIT) return current; + if (time.nanoseconds === UTIME_NOW) return now(); + return toMilliseconds(time); + }; + const apply = (currentAtime: number, currentMtime: number): void => { + ops.utimens(path, resolve(atime, currentAtime), resolve(mtime, currentMtime), cb); + }; + + if (!omitAtime && !omitMtime) { + apply(0, 0); + return; + } + + // Our utimens always sets both times, so read back the one being kept. + ops.getattr(path, (code, stat) => { + if (code !== 0 || stat === null) { + cb(code !== 0 ? code : -2); + return; + } + apply(stat.atime.getTime(), stat.mtime.getTime()); + }); + }; +} + +function toMilliseconds(time: Timespec): number { + return Number(time.seconds) * 1000 + Math.trunc(time.nanoseconds / 1_000_000); +} diff --git a/packages/computerd/src/fuse/driver.test.ts b/packages/computerd/src/fuse/driver.test.ts index 6898e0ea1..723036021 100644 --- a/packages/computerd/src/fuse/driver.test.ts +++ b/packages/computerd/src/fuse/driver.test.ts @@ -1,3 +1,5 @@ +import { constants as fsConstants } from "node:fs"; + import { expect, test } from "vitest"; import { createNodeVirtualFileSystem, makeFUSEOps } from "./index"; @@ -9,7 +11,11 @@ const callback = (fn: (cb: (errno: number, result: unknown) => void) => void) => const status = (fn: (cb: (value: number) => void) => void) => new Promise((resolve) => fn((value) => resolve(value))); -const fuseNativeOperationNames = [ +function handleOf(result: unknown): number { + return typeof result === "number" ? result : (result as { fd: number }).fd; +} + +const operationNames = [ "init", "error", "access", @@ -59,10 +65,10 @@ function disableDirectWrites(vfs: unknown): void { delete target.truncateFileSync; } -test("FUSE ops expose the complete fuse-native operation surface", async () => { +test("FUSE ops expose the complete operation surface", async () => { const ops = makeFUSEOps((await createNodeVirtualFileSystem()).vfs); - for (const name of fuseNativeOperationNames) { + for (const name of operationNames) { expect(typeof ops[name]).toBe("function", `${name} should be defined`); } }); @@ -98,23 +104,24 @@ test("implemented FUSE ops all have explicit current expectations", async () => const create = await callback((cb) => ops.create("/dir/file.txt", 0o644, cb)); expect(create.errno).toBe(0); - expect(typeof create.result).toBe("number"); + expect(typeof handleOf(create.result)).toBe("number"); + expect((create.result as { keepCache?: boolean }).keepCache).toBe(false); const open = await callback((cb) => ops.open("/dir/file.txt", 0, cb)); expect(open.errno).toBe(0); - expect(typeof open.result).toBe("number"); + expect(typeof handleOf(open.result)).toBe("number"); const bytes = Buffer.from("hello fuse"); expect( await status((cb) => - ops.write("/dir/file.txt", create.result as number, bytes, bytes.length, 0, cb), + ops.write("/dir/file.txt", handleOf(create.result), bytes, bytes.length, 0, cb), ), ).toBe(bytes.length); const readBuffer = Buffer.alloc(bytes.length); expect( await status((cb) => - ops.read("/dir/file.txt", create.result as number, readBuffer, readBuffer.length, 0, cb), + ops.read("/dir/file.txt", handleOf(create.result), readBuffer, readBuffer.length, 0, cb), ), ).toBe(bytes.length); expect(readBuffer.toString()).toBe("hello fuse"); @@ -127,7 +134,7 @@ test("implemented FUSE ops all have explicit current expectations", async () => expect(stat.errno).toBe(0); expect((stat.result as { size: number }).size).toBe(bytes.length); - const fstat = await callback((cb) => ops.fgetattr("/dir/file.txt", create.result as number, cb)); + const fstat = await callback((cb) => ops.fgetattr("/dir/file.txt", handleOf(create.result), cb)); expect(fstat.errno).toBe(0); expect((fstat.result as { size: number }).size).toBe(bytes.length); @@ -138,9 +145,11 @@ test("implemented FUSE ops all have explicit current expectations", async () => expect(await status((cb) => ops.chmod("/dir/file.txt", 0o600, cb))).toBe(0); expect(await status((cb) => ops.chown("/dir/file.txt", 123, 456, cb))).toBe(0); - expect(await status((cb) => ops.flush("/dir/file.txt", create.result as number, cb))).toBe(0); - expect(await status((cb) => ops.fsync("/dir/file.txt", create.result as number, 0, cb))).toBe(0); - expect(await status((cb) => ops.fsyncdir("/dir", rootDir.result as number, 0, cb))).toBe(0); + expect(await status((cb) => ops.flush("/dir/file.txt", handleOf(create.result), cb))).toBe(0); + expect(await status((cb) => ops.fsync("/dir/file.txt", false, handleOf(create.result), cb))).toBe( + 0, + ); + expect(await status((cb) => ops.fsyncdir("/dir", false, rootDir.result as number, cb))).toBe(0); expect( await status((cb) => @@ -150,8 +159,7 @@ test("implemented FUSE ops all have explicit current expectations", async () => expect(await status((cb) => ops.getxattr("/dir/file.txt", "user.test", 0, cb))).toBe(-61); const xattrs = await callback((cb) => ops.listxattr("/dir/file.txt", cb)); expect(xattrs.errno).toBe(0); - expect(Buffer.isBuffer(xattrs.result)).toBe(true); - expect((xattrs.result as Buffer).length).toBe(0); + expect(xattrs.result).toEqual([]); expect(await status((cb) => ops.removexattr("/dir/file.txt", "user.test", cb))).toBe(-61); expect(await status((cb) => ops.utimens("/dir/file.txt", Date.now(), Date.now(), cb))).toBe(0); expect(await status((cb) => ops.utimens("/missing", Date.now(), Date.now(), cb))).toBe(-2); @@ -171,7 +179,7 @@ test("implemented FUSE ops all have explicit current expectations", async () => expect(truncBuf.subarray(0, 5).toString()).toBe("hello"); expect( - await status((cb) => ops.ftruncate("/dir/renamed.txt", create.result as number, 2, cb)), + await status((cb) => ops.ftruncate("/dir/renamed.txt", handleOf(create.result), 2, cb)), ).toBe(0); const ftruncBuf = Buffer.alloc(64); expect( @@ -179,10 +187,10 @@ test("implemented FUSE ops all have explicit current expectations", async () => ).toBe(2); expect(ftruncBuf.subarray(0, 2).toString()).toBe("he"); - expect(await status((cb) => ops.release("/dir/renamed.txt", create.result as number, cb))).toBe( + expect(await status((cb) => ops.release("/dir/renamed.txt", handleOf(create.result), cb))).toBe( 0, ); - expect(await status((cb) => ops.release("/dir/renamed.txt", open.result as number, cb))).toBe(0); + expect(await status((cb) => ops.release("/dir/renamed.txt", handleOf(open.result), cb))).toBe(0); expect(await status((cb) => ops.unlink("/dir/renamed.txt", cb))).toBe(0); expect(vfs.readdirSync("/dir")).toEqual([]); expect(await status((cb) => ops.rmdir("/dir", cb))).toBe(0); @@ -216,7 +224,7 @@ test("FUSE maps read-only provider mutations to EROFS", async () => { const opened = await callback((cb) => ops.open("/readonly.txt", 0, cb)); expect(opened.errno).toBe(0); - const fh = opened.result as number; + const fh = handleOf(opened.result); expect(await status((cb) => ops.write("/readonly.txt", fh, Buffer.from("x"), 1, 0, cb))).toBe( -30, ); @@ -248,7 +256,7 @@ test("write past the per-file cap returns EFBIG instead of growing unbounded", a ops.create("/big", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); // Sized just past the cap. The driver allocates the buffer up-front // and writes into it, so this also catches an off-by-one in the @@ -282,7 +290,7 @@ test("FUSE write is visible through the backing VFS after release", async () => ops.create("/from-fuse.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const payload = Buffer.from("from-fuse\n", "utf8"); const written = await status((cb: (value: number) => void) => @@ -293,7 +301,7 @@ test("FUSE write is visible through the backing VFS after release", async () => // release + flush + fsync — every codepath a well-behaved // client would call before considering the write durable. expect(await status((cb) => ops.flush("/from-fuse.txt", fh, cb))).toBe(0); - expect(await status((cb) => ops.fsync("/from-fuse.txt", fh, 0, cb))).toBe(0); + expect(await status((cb) => ops.fsync("/from-fuse.txt", false, fh, cb))).toBe(0); expect(await status((cb) => ops.release("/from-fuse.txt", fh, cb))).toBe(0); // The VFS is the RPC surface's source of truth. Anything that @@ -305,6 +313,50 @@ test("FUSE write is visible through the backing VFS after release", async () => ); }); +test("FUSE open with O_TRUNC empties the file", async () => { + const { vfs } = await createNodeVirtualFileSystem(); + vfs.writeFileSync("/existing.txt", "old contents\n"); + const ops = makeFUSEOps(vfs); + + const opened = await callback((cb) => + ops.open("/existing.txt", fsConstants.O_WRONLY | fsConstants.O_TRUNC, cb), + ); + expect(opened.errno).toBe(0); + const fh = handleOf(opened.result); + + const stat = await callback((cb) => ops.getattr("/existing.txt", cb)); + expect((stat.result as { size: number }).size).toBe(0); + + expect(await status((cb) => ops.release("/existing.txt", fh, cb))).toBe(0); + expect(vfs.readFileSync("/existing.txt").length).toBe(0); +}); + +test("FUSE open with O_TRUNC empties a file whose create is still buffered", async () => { + const { vfs } = await createNodeVirtualFileSystem(); + const ops = makeFUSEOps(vfs); + const created = await callback((cb) => ops.create("/pending.txt", 0o644, cb)); + const createdFh = handleOf(created.result); + const payload = Buffer.from("buffered"); + await status((cb) => ops.write("/pending.txt", createdFh, payload, payload.length, 0, cb)); + + const opened = await callback((cb) => + ops.open("/pending.txt", fsConstants.O_WRONLY | fsConstants.O_TRUNC, cb), + ); + expect(opened.errno).toBe(0); + const stat = await callback((cb) => ops.getattr("/pending.txt", cb)); + expect((stat.result as { size: number }).size).toBe(0); +}); + +test("FUSE open without O_TRUNC keeps the contents", async () => { + const { vfs } = await createNodeVirtualFileSystem(); + vfs.writeFileSync("/kept.txt", "keep me"); + const ops = makeFUSEOps(vfs); + const opened = await callback((cb) => ops.open("/kept.txt", fsConstants.O_RDWR, cb)); + expect(opened.errno).toBe(0); + const stat = await callback((cb) => ops.getattr("/kept.txt", cb)); + expect((stat.result as { size: number }).size).toBe(7); +}); + test("FUSE truncate is visible through the backing VFS after fsync", async () => { // truncate writes only to the in-memory buffer (entry.size, // zero-fill via entry.buf). Without an explicit spill the VFS @@ -320,17 +372,17 @@ test("FUSE truncate is visible through the backing VFS after fsync", async () => ops.create("/t.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const payload = Buffer.from("original-content", "utf8"); await status((cb: (value: number) => void) => ops.write("/t.txt", fh, payload, payload.byteLength, 0, cb), ); - expect(await status((cb) => ops.fsync("/t.txt", fh, 0, cb))).toBe(0); + expect(await status((cb) => ops.fsync("/t.txt", false, fh, cb))).toBe(0); expect(vfs.statSync("/t.txt").size).toBe(payload.byteLength); // Shrink via truncate and re-sync. expect(await status((cb) => ops.truncate("/t.txt", 5, cb))).toBe(0); - expect(await status((cb) => ops.fsync("/t.txt", fh, 0, cb))).toBe(0); + expect(await status((cb) => ops.fsync("/t.txt", false, fh, cb))).toBe(0); expect(vfs.statSync("/t.txt").size).toBe(5); expect(Buffer.from(vfs.readFileSync("/t.txt")).toString("utf8")).toBe("origi"); @@ -348,7 +400,7 @@ test("FUSE rename carries the buffered bytes to the new path", async () => { ops.create("/old.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const payload = Buffer.from("renamed-content", "utf8"); await status((cb: (value: number) => void) => ops.write("/old.txt", fh, payload, payload.byteLength, 0, cb), @@ -357,7 +409,7 @@ test("FUSE rename carries the buffered bytes to the new path", async () => { // Rename before the buffer ever spills. The buffer entry moves // with the file; fsync on the new path spills correctly. expect(await status((cb) => ops.rename("/old.txt", "/new.txt", cb))).toBe(0); - expect(await status((cb) => ops.fsync("/new.txt", fh, 0, cb))).toBe(0); + expect(await status((cb) => ops.fsync("/new.txt", false, fh, cb))).toBe(0); expect(Buffer.from(vfs.readFileSync("/new.txt")).toString("utf8")).toBe("renamed-content"); // Old path is gone from both layers. @@ -375,7 +427,7 @@ test("FUSE getattr size matches what readFileSync would return", async () => { ops.create("/g.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const payload = Buffer.from("twelve-bytes", "utf8"); await status((cb: (value: number) => void) => ops.write("/g.txt", fh, payload, payload.byteLength, 0, cb), @@ -390,7 +442,7 @@ test("FUSE getattr size matches what readFileSync would return", async () => { // After flush they still agree — anything calling stat through // the VFS (RPC, host-side platformatic/vfs) needs the truth. - expect(await status((cb) => ops.fsync("/g.txt", fh, 0, cb))).toBe(0); + expect(await status((cb) => ops.fsync("/g.txt", false, fh, cb))).toBe(0); const afterFlush = await callback((cb: (errno: number, result: unknown) => void) => ops.getattr("/g.txt", cb), ); @@ -416,7 +468,7 @@ test("FUSE ops translate kernel-relative paths onto the configured mount point", expect(open.errno).toBe(0); const readBuffer = Buffer.alloc(5); expect( - await status((cb) => ops.read("/repo/a.txt", open.result as number, readBuffer, 5, 0, cb)), + await status((cb) => ops.read("/repo/a.txt", handleOf(open.result), readBuffer, 5, 0, cb)), ).toBe(5); expect(readBuffer.toString()).toBe("alpha"); @@ -425,10 +477,10 @@ test("FUSE ops translate kernel-relative paths onto the configured mount point", const payload = Buffer.from("bravo"); expect( await status((cb) => - ops.write("/repo/b.txt", create.result as number, payload, payload.length, 0, cb), + ops.write("/repo/b.txt", handleOf(create.result), payload, payload.length, 0, cb), ), ).toBe(payload.length); - expect(await status((cb) => ops.release("/repo/b.txt", create.result as number, cb))).toBe(0); + expect(await status((cb) => ops.release("/repo/b.txt", handleOf(create.result), cb))).toBe(0); expect(vfs.readFileSync("/workspace/repo/b.txt").toString()).toBe("bravo"); // The unprefixed path doesn't exist in the VFS — it would only @@ -445,7 +497,7 @@ test("FUSE clean buffers are not spilled repeatedly", async () => { ops.create("/clean-after-flush.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); let writesAfterCreate = 0; const writeFileSync = vfs.writeFileSync.bind(vfs); @@ -462,7 +514,7 @@ test("FUSE clean buffers are not spilled repeatedly", async () => { ).toBe(payload.byteLength); expect(await status((cb) => ops.flush("/clean-after-flush.txt", fh, cb))).toBe(0); - expect(await status((cb) => ops.fsync("/clean-after-flush.txt", fh, 0, cb))).toBe(0); + expect(await status((cb) => ops.fsync("/clean-after-flush.txt", false, fh, cb))).toBe(0); expect(await status((cb) => ops.release("/clean-after-flush.txt", fh, cb))).toBe(0); expect(writesAfterCreate).toBe(1); @@ -485,7 +537,7 @@ test("FUSE read-only hydrated buffers are not spilled on close", async () => { const open = await callback((cb) => ops.open("/read-only.txt", 0, cb)); expect(open.errno).toBe(0); - const fh = open.result as number; + const fh = handleOf(open.result); const buf = Buffer.alloc(8); expect(await status((cb) => ops.read("/read-only.txt", fh, buf, buf.byteLength, 0, cb))).toBe(8); expect(buf.toString()).toBe("existing"); @@ -504,7 +556,7 @@ test("FUSE flush uses ranged writes when the backing VFS supports them", async ( ops.create("/ranged.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const rangedCalls: Array<{ path: string; @@ -548,10 +600,10 @@ test("FUSE partial writes preserve existing content", async () => { expect(open.errno).toBe(0); expect( await status((cb) => - ops.write("/partial.txt", open.result as number, Buffer.from("X"), 1, 2, cb), + ops.write("/partial.txt", handleOf(open.result), Buffer.from("X"), 1, 2, cb), ), ).toBe(1); - expect(await status((cb) => ops.flush("/partial.txt", open.result as number, cb))).toBe(0); + expect(await status((cb) => ops.flush("/partial.txt", handleOf(open.result), cb))).toBe(0); expect(Buffer.from(vfs.readFileSync("/partial.txt")).toString("utf8")).toBe("abXde"); }); @@ -568,7 +620,7 @@ test("FUSE direct reads do not force later writes onto the buffered fallback", a ops.create("/direct-read-write.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const first = Buffer.from("first"); expect( await status((cb) => ops.write("/direct-read-write.txt", fh, first, first.byteLength, 0, cb)), @@ -614,7 +666,7 @@ test("FUSE direct reads use readRangeSync instead of materializing the whole fil ops.open("/range.bin", 0, cb), ); expect(open.errno).toBe(0); - const fh = open.result as number; + const fh = handleOf(open.result); const buf = Buffer.alloc(5); expect(await status((cb) => ops.read("/range.bin", fh, buf, buf.byteLength, 6, cb))).toBe(5); @@ -633,7 +685,7 @@ test("FUSE direct writes are visible through the VFS before release", async () = ops.create("/direct.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const payload = Buffer.from("direct"); expect( await status((cb) => ops.write("/direct.txt", fh, payload, payload.byteLength, 0, cb)), @@ -661,7 +713,7 @@ test("FUSE buffer stats report resident write buffers", async () => { ops.create("/stats.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const payload = Buffer.from("tracked"); expect( await status((cb) => ops.write("/stats.txt", fh, payload, payload.byteLength, 0, cb)), @@ -695,7 +747,7 @@ test("FUSE release evicts clean file buffers after flush", async () => { ops.create("/evict.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const first = Buffer.from("first"); expect(await status((cb) => ops.write("/evict.txt", fh, first, first.byteLength, 0, cb))).toBe( first.byteLength, @@ -708,7 +760,7 @@ test("FUSE release evicts clean file buffers after flush", async () => { expect(open.errno).toBe(0); const out = Buffer.alloc("second".length); expect( - await status((cb) => ops.read("/evict.txt", open.result as number, out, out.byteLength, 0, cb)), + await status((cb) => ops.read("/evict.txt", handleOf(open.result), out, out.byteLength, 0, cb)), ).toBe(out.byteLength); expect(out.toString()).toBe("second"); }); @@ -771,7 +823,7 @@ test("FUSE buffered writes surface a fresh size through getattr before flush", a ops.create("/buf.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const payload = Buffer.from("buffered"); await status((cb: (value: number) => void) => ops.write("/buf.txt", fh, payload, payload.byteLength, 0, cb), @@ -798,7 +850,7 @@ test("FUSE mode survives a flush after chmod on a pre-existing file", async () = const open = await callback((cb) => ops.open("/mode.txt", 0, cb)); expect(open.errno).toBe(0); - const fh = open.result as number; + const fh = handleOf(open.result); await status((cb) => ops.write("/mode.txt", fh, Buffer.from("b"), 1, 0, cb)); expect(await status((cb) => ops.flush("/mode.txt", fh, cb))).toBe(0); @@ -815,7 +867,7 @@ test("FUSE create+chmod+flush persists the chmod'd mode in the VFS", async () => ops.create("/new.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); await status((cb) => ops.write("/new.txt", fh, Buffer.from("x"), 1, 0, cb)); expect(await status((cb) => ops.chmod("/new.txt", 0o600, cb))).toBe(0); expect(await status((cb) => ops.flush("/new.txt", fh, cb))).toBe(0); @@ -845,10 +897,10 @@ test("FUSE link creates a second name for the same file inode", async () => { const payload = Buffer.from("bye"); expect( await status((cb) => - ops.write("/b.txt", open.result as number, payload, payload.byteLength, 0, cb), + ops.write("/b.txt", handleOf(open.result), payload, payload.byteLength, 0, cb), ), ).toBe(payload.byteLength); - expect(await status((cb) => ops.flush("/b.txt", open.result as number, cb))).toBe(0); + expect(await status((cb) => ops.flush("/b.txt", handleOf(open.result), cb))).toBe(0); expect(Buffer.from(vfs.readFileSync("/a.txt")).toString("utf8")).toBe("bye"); }); @@ -861,7 +913,7 @@ test("FUSE link flushes a pending-create source before linking", async () => { ops.create("/src.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const payload = Buffer.from("linked"); await status((cb) => ops.write("/src.txt", fh, payload, payload.byteLength, 0, cb)); @@ -894,7 +946,7 @@ test("FUSE rename of a pending-create file overwrites an existing destination", ops.create("/src.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); await status((cb) => ops.write("/src.txt", fh, Buffer.from("new"), 3, 0, cb)); expect(await status((cb) => ops.rename("/src.txt", "/dst.txt", cb))).toBe(0); @@ -947,11 +999,11 @@ test("FUSE getattr reports st_blocks so du sees non-zero usage", async () => { const payload = Buffer.alloc(513, 0x61); expect( await status((cb) => - ops.write("/du.txt", create.result as number, payload, payload.length, 0, cb), + ops.write("/du.txt", handleOf(create.result), payload, payload.length, 0, cb), ), ).toBe(payload.length); - expect(await status((cb) => ops.flush("/du.txt", create.result as number, cb))).toBe(0); - expect(await status((cb) => ops.release("/du.txt", create.result as number, cb))).toBe(0); + expect(await status((cb) => ops.flush("/du.txt", handleOf(create.result), cb))).toBe(0); + expect(await status((cb) => ops.release("/du.txt", handleOf(create.result), cb))).toBe(0); const stat = await callback((cb) => ops.getattr("/du.txt", cb)); expect(stat.errno).toBe(0); @@ -964,8 +1016,8 @@ test("FUSE getattr reports zero blocks for an empty file", async () => { const create = await callback((cb) => ops.create("/empty.txt", 0o644, cb)); expect(create.errno).toBe(0); - expect(await status((cb) => ops.flush("/empty.txt", create.result as number, cb))).toBe(0); - expect(await status((cb) => ops.release("/empty.txt", create.result as number, cb))).toBe(0); + expect(await status((cb) => ops.flush("/empty.txt", handleOf(create.result), cb))).toBe(0); + expect(await status((cb) => ops.release("/empty.txt", handleOf(create.result), cb))).toBe(0); const stat = await callback((cb) => ops.getattr("/empty.txt", cb)); expect(stat.errno).toBe(0); @@ -985,7 +1037,7 @@ test("FUSE getattr on a pending-create file reports block metadata", async () => const payload = Buffer.alloc(1025, 0x62); expect( await status((cb) => - ops.write("/pending.txt", create.result as number, payload, payload.length, 0, cb), + ops.write("/pending.txt", handleOf(create.result), payload, payload.length, 0, cb), ), ).toBe(payload.length); @@ -1007,16 +1059,16 @@ test("FUSE getattr block count tracks buffered size before flush", async () => { const seed = Buffer.from("seed"); expect( await status((cb) => - ops.write("/buf-blocks.txt", create.result as number, seed, seed.length, 0, cb), + ops.write("/buf-blocks.txt", handleOf(create.result), seed, seed.length, 0, cb), ), ).toBe(seed.length); - expect(await status((cb) => ops.flush("/buf-blocks.txt", create.result as number, cb))).toBe(0); + expect(await status((cb) => ops.flush("/buf-blocks.txt", handleOf(create.result), cb))).toBe(0); // Grow the buffer past a block boundary without flushing. const grow = Buffer.alloc(2000, 0x63); expect( await status((cb) => - ops.write("/buf-blocks.txt", create.result as number, grow, grow.length, 0, cb), + ops.write("/buf-blocks.txt", handleOf(create.result), grow, grow.length, 0, cb), ), ).toBe(grow.length); @@ -1032,8 +1084,8 @@ test("xattr: getxattr for a VFS-backed file returns correct codes", async () => // Create and flush a VFS-backed file. const create = await callback((cb) => ops.create("/xattr-backed.txt", 0o644, cb)); expect(create.errno).toBe(0); - await status((cb) => ops.flush("/xattr-backed.txt", create.result as number, cb)); - await status((cb) => ops.release("/xattr-backed.txt", create.result as number, cb)); + await status((cb) => ops.flush("/xattr-backed.txt", handleOf(create.result), cb)); + await status((cb) => ops.release("/xattr-backed.txt", handleOf(create.result), cb)); // getxattr returns ENODATA (the file exists but has no xattrs). expect(await status((cb) => ops.getxattr("/xattr-backed.txt", "user.x", 0, cb))).toBe(-61); @@ -1041,15 +1093,15 @@ test("xattr: getxattr for a VFS-backed file returns correct codes", async () => expect( await status((cb) => ops.setxattr("/xattr-backed.txt", "user.x", Buffer.from("v"), 0, 0, cb)), ).toBe(0); - // listxattr returns an empty buffer. + // listxattr returns an empty list of names. const lx = await callback((cb) => ops.listxattr("/xattr-backed.txt", cb)); expect(lx.errno).toBe(0); - expect((lx.result as Buffer).length).toBe(0); + expect(lx.result).toEqual([]); // removexattr returns ENODATA. expect(await status((cb) => ops.removexattr("/xattr-backed.txt", "user.x", cb))).toBe(-61); }); -test("FUSE ftruncate does not depend on fuse-native binding this", async () => { +test("FUSE ftruncate does not depend on the binding's this", async () => { const { vfs } = await createNodeVirtualFileSystem(); const ops = makeFUSEOps(vfs); @@ -1057,7 +1109,7 @@ test("FUSE ftruncate does not depend on fuse-native binding this", async () => { ops.create("/ftruncate-this.txt", 0o644, cb), ); expect(create.errno).toBe(0); - const fh = create.result as number; + const fh = handleOf(create.result); const payload = Buffer.from("abcdef", "utf8"); expect( diff --git a/packages/computerd/src/fuse/driver.ts b/packages/computerd/src/fuse/driver.ts index bb8832ab5..3aef43876 100644 --- a/packages/computerd/src/fuse/driver.ts +++ b/packages/computerd/src/fuse/driver.ts @@ -1,11 +1,14 @@ -import { writeFileSync as nodeWriteFileSync } from "node:fs"; +import { constants as fsConstants, writeFileSync as nodeWriteFileSync } from "node:fs"; import { posix } from "node:path"; import type { WorkspaceDirentEntry } from "@cloudflare/dofs"; -import type { FUSEBackend } from "./backend"; -import { buildFuseOptionString } from "./options"; +import type { FUSEBackend } from "./backend.js"; +import { toBindingOps } from "./binding-ops.js"; +import type { PassthroughStatus } from "./ignore-config.js"; +import { buildFuseInitConfig, buildFuseMountOptions, passthroughRequested } from "./options.js"; import { type LocalPassthroughOptions, + type PassthroughRegistrar, type PassthroughStats, withLocalPassthrough, } from "./passthrough"; @@ -37,6 +40,7 @@ const MAX_FILE_BYTES = 256 * 1024 * 1024; // (which reads st_blocks, not st_size) compute usage against this // constant. A getattr that omits blocks makes the kernel surface // st_blocks=0 and `du` reports zero usage for the whole mount. +const O_TRUNC = fsConstants.O_TRUNC; const STAT_BLOCK_SIZE = 512; // st_blksize is the preferred I/O block size, not the st_blocks unit. @@ -64,15 +68,23 @@ type ResultCallback = (errno: number, result: T) => void; export type ReaddirCallback = (errno: number, names: string[], stats?: FuseStat[]) => void; type NotImplementedOperation = (...args: unknown[]) => void; +export interface FuseFileInfoResult { + readonly fd: number; + readonly keepCache?: boolean; + readonly backingId?: number; +} + +export type FuseOpenResult = number | FuseFileInfoResult; + export interface FuseOps { init(cb?: StatusCallback): void; error: NotImplementedOperation; readdir(path: string, cb: ReaddirCallback): void; getattr(path: string, cb: ResultCallback): void; fgetattr(path: string, fh: number, cb: ResultCallback): void; - open(path: string, flags: number, cb: ResultCallback): void; + open(path: string, flags: number, cb: ResultCallback): void; opendir(path: string, flags: number, cb: ResultCallback): void; - create(path: string, mode: number, cb: ResultCallback): void; + create(path: string, mode: number, cb: ResultCallback): void; read( path: string, fh: number, @@ -102,8 +114,8 @@ export interface FuseOps { statfs(path: string, cb: ResultCallback>): void; chmod(path: string, mode: number, cb: StatusCallback): void; chown(path: string, uid: number, gid: number, cb: StatusCallback): void; - fsync(path: string, fh: number, datasync: number, cb: StatusCallback): void; - fsyncdir(path: string, fh: number, datasync: number, cb: StatusCallback): void; + fsync(path: string, datasync: boolean, fh: number, cb: StatusCallback): void; + fsyncdir(path: string, datasync: boolean, fh: number, cb: StatusCallback): void; utimens(path: string, atime: number, mtime: number, cb: StatusCallback): void; readlink(path: string, cb: ResultCallback): void; mknod: NotImplementedOperation; @@ -116,7 +128,7 @@ export interface FuseOps { cb: StatusCallback, ): void; getxattr(path: string, name: string, position: number, cb: StatusCallback): void; - listxattr(path: string, cb: ResultCallback): void; + listxattr(path: string, cb: ResultCallback): void; removexattr(path: string, name: string, cb: StatusCallback): void; link(source: string, destination: string, cb: StatusCallback): void; symlink(target: string, path: string, cb: StatusCallback): void; @@ -157,12 +169,28 @@ export interface FuseMount { // Counters for the local-only layer. Present only when MOUNT_IGNORE // configured local-only paths on a real FUSE mount. getLocalPathStats?: () => PassthroughStats; + // Only present on a kernel FUSE mount. + getPassthroughStatus?: () => PassthroughStatus; + // The in-process removal guard from the vendored binding, for native + // roots. Only present on a kernel FUSE mount. + removalGuard?: RemovalGuardBinding; } -interface FuseNativeInstance { +/** Fuse.removalGuard in vendor/fuse-napi; each call returns 0 or -errno. */ +export interface RemovalGuardBinding { + start(onDetached: (path: string) => void): number; + addRoot(path: string, fd: number): number; + removeRoot(path: string): boolean; +} + +interface FuseInstance { mount(cb: (error: Error | null) => void): void; unmount(cb: (error: Error | null) => void): void; - _fuseOptions(): string; +} + +interface BackingRegistrar { + backingOpen(fd: number): number; + backingClose(backingId: number): void; } export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseOps { @@ -195,6 +223,30 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO return openHandle(path); }; + // auto_cache's rule, applied per open: keep the page cache only when + // mtime and size are unchanged since this path was last opened. It can't + // stay a mount option, because libfuse would then set keep_cache on + // passthrough opens too, and the kernel fails those with EIO. + const cacheMarks = new Map(); + + const resolveKeepCache = (path: string): boolean => { + let mark: string; + try { + const stat = vfs.statSync(toVfs(path)); + mark = `${Number(stat.mtimeMs ?? 0)}:${Number(stat.size ?? 0)}`; + } catch { + cacheMarks.delete(path); + return false; + } + const previous = cacheMarks.get(path); + cacheMarks.set(path, mark); + return previous === mark; + }; + + const forgetCacheMark = (path: string): void => { + cacheMarks.delete(path); + }; + const releaseFileHandle = (path: string): void => { const next = (fileOpenCounts.get(path) ?? 1) - 1; if (next <= 0) fileOpenCounts.delete(path); @@ -526,6 +578,29 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO }; } + const openExisting = (path: string, cb: ResultCallback): void => { + try { + const entry = files.get(path); + if (entry?.pendingCreate === true) { + // Nothing durable to compare against yet, so never reuse the cache. + cb(0, { fd: openFileHandle(path), keepCache: false }); + return; + } + const stat = vfs.statSync(toVfs(path)); + if (stat.isDirectory()) { + cb(ERRNO.EISDIR, 0); + return; + } + if (hasBufferedWrites) { + directWriteVfs.openWriteBufferSync?.(toVfs(path)); + } + const keepCache = resolveKeepCache(path); + cb(0, { fd: openFileHandle(path), keepCache }); + } catch (error) { + cb(toErrno(error), 0); + } + }; + return { init(cb) { cb?.(0); @@ -581,25 +656,20 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO this.getattr(path, cb); }, - open(path, _flags, cb) { - try { - const entry = files.get(path); - if (entry?.pendingCreate === true) { - cb(0, openFileHandle(path)); - return; - } - const stat = vfs.statSync(toVfs(path)); - if (stat.isDirectory()) { - cb(ERRNO.EISDIR, 0); - return; - } - if (hasBufferedWrites) { - directWriteVfs.openWriteBufferSync?.(toVfs(path)); - } - cb(0, openFileHandle(path)); - } catch (error) { - cb(toErrno(error), 0); + open(path, flags, cb) { + // libfuse 3 enables atomic O_TRUNC, so `: > f` arrives as this flag + // rather than as a separate truncate. + if ((flags & O_TRUNC) !== 0) { + truncatePath(path, 0, (code) => { + if (code !== 0) { + cb(code, 0); + return; + } + openExisting(path, cb); + }); + return; } + openExisting(path, cb); }, opendir(path, _flags, cb) { @@ -622,11 +692,13 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO cb(ERRNO.EEXIST, 0); return; } + // A recreated path must not inherit the previous file's mark. + forgetCacheMark(path); if (hasDeferredCreate) { // Single transaction at release time: defer the inode INSERT // and the chunk commit into one round trip. directWriteVfs.openWriteBufferForCreateSync?.(toVfs(path), { mode }); - cb(0, openFileHandle(path)); + cb(0, { fd: openFileHandle(path), keepCache: false }); return; } if (hasDirectWrites) { @@ -634,7 +706,7 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO if (hasBufferedWrites) { directWriteVfs.openWriteBufferSync?.(toVfs(path)); } - cb(0, openFileHandle(path)); + cb(0, { fd: openFileHandle(path), keepCache: false }); return; } // Defer the VFS inode write until flush/release/fsync. Most create @@ -654,7 +726,7 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO mode, pendingMtime: new Date(), }); - cb(0, openFileHandle(path)); + cb(0, { fd: openFileHandle(path), keepCache: false }); } catch (error) { cb(toErrno(error), 0); } @@ -941,13 +1013,13 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO cb(0); }, - fsync(path, _fh, _datasync, cb) { + fsync(path, _datasync, _fh, cb) { // fsync(2) is the documented user-facing flush. Spill // whatever's buffered so subsequent VFS reads see it. cb(flushEntry(path)); }, - fsyncdir(_path, _fh, _datasync, cb) { + fsyncdir(_path, _datasync, _fh, cb) { cb(0); }, @@ -956,9 +1028,7 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO cb(ERRNO.ENOENT); return; } - // Note: with libfuse2/fuse-native, touch -a / touch -m alone do not - // reach this op — the kernel/libfuse short-circuits when only one of - // atime/mtime is provided (UTIME_OMIT). touch with both set works. + // toBindingOps has already resolved UTIME_NOW and UTIME_OMIT. updateMeta(path, { atime: new Date(atime), mtime: new Date(mtime) }); cb(0); }, @@ -980,7 +1050,7 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO }, listxattr(path, cb) { - cb(exists(path) ? 0 : ERRNO.ENOENT, Buffer.alloc(0)); + cb(exists(path) ? 0 : ERRNO.ENOENT, []); }, removexattr(path, _name, cb) { @@ -1044,34 +1114,86 @@ export async function mountFuse(options: { */ localPaths?: LocalPassthroughOptions; }): Promise { - // oxlint-disable-next-line typescript/no-explicit-any -- fuse-native ships no types - const fuseModule: any = await import("fuse-native"); + // oxlint-disable-next-line typescript/no-explicit-any -- the vendored binding is CJS + const fuseModule: any = await import("fuse-napi"); const Fuse = fuseModule.default ?? fuseModule; // Optional op tracing. `COMPUTERD_FUSE_TRACE=summary` records per-op call // counts and timings; the summary is emitted on SIGUSR2 and on // unmount, to stderr by default or to `COMPUTERD_FUSE_TRACE_FILE` when set. // Disabled means zero wrapping overhead — the unwrapped ops object - // is handed to fuse-native directly. + // is handed to the binding directly. const traceMode = process.env.COMPUTERD_FUSE_TRACE; const tracer: FuseTracer | undefined = traceMode === "summary" ? createFuseTracer() : undefined; const baseOps = makeFUSEOps(options.vfs, options.mountPoint); + + // Only requested with local-only paths, so a mount without MOUNT_IGNORE + // negotiates what it always did. + const wantPassthrough = + options.localPaths !== undefined && + !options.localPaths.ignore.isEmpty && + passthroughRequested(process.env); + let passthroughNegotiated = false; + + // Late-bound, because the Fuse constructor takes the op table that holds + // this. No open arrives before init, and until init has negotiated + // passthrough the local-only layer gets ENOTSUP and serves files itself. + let mountedFuse: BackingRegistrar | undefined; + const registrar: PassthroughRegistrar = { + backingOpen(fd) { + if (!passthroughNegotiated || mountedFuse === undefined) { + throw Object.assign(new Error("FUSE passthrough was not negotiated"), { + code: "ENOTSUP", + }); + } + return mountedFuse.backingOpen(fd); + }, + backingClose(backingId) { + mountedFuse?.backingClose(backingId); + }, + }; + // Local-only paths are routed before tracing, so the trace counts a // passthrough op once, at the layer that actually served it, rather // than attributing it to the VFS driver that never saw it. const localPaths = options.localPaths === undefined ? undefined - : withLocalPassthrough(baseOps, options.localPaths); + : withLocalPassthrough(baseOps, { + ...options.localPaths, + ...(wantPassthrough ? { passthrough: registrar } : {}), + }); const routedOps = localPaths === undefined ? baseOps : localPaths.ops; - const { getBufferStats: _getBufferStats, ...fuseOps } = routedOps; - const ops = - tracer === undefined - ? fuseOps - : wrapFuseOpsWithTracer(fuseOps as unknown as Record, tracer); - const fuse = new Fuse(options.mountPoint, ops, { + const fuseOps = toBindingOps(routedOps); + const ops = tracer === undefined ? fuseOps : wrapFuseOpsWithTracer(fuseOps, tracer); + const mountOptions = buildFuseMountOptions(process.env); + const initConfig = buildFuseInitConfig(process.env); + const CAP_PASSTHROUGH: number = Fuse.CAP_PASSTHROUGH ?? 1 << 29; + + const opsWithInit = { + ...ops, + initWithConfig( + info: { capable: number; want: number }, + cb: (errno: number, config?: unknown) => void, + ) { + const capable = info.capable >>> 0; + passthroughNegotiated = wantPassthrough && (capable & CAP_PASSTHROUGH) !== 0; + const config: Record = { maxWrite: initConfig.maxWrite }; + if (passthroughNegotiated) { + // Offered is not enough: without it in `want`, backingOpen fails + // with EPERM. + config.want = (info.want >>> 0) | CAP_PASSTHROUGH; + config.maxBackingStackDepth = initConfig.maxBackingStackDepth; + } + cb(0, config); + }, + }; + + const fuse = new Fuse(options.mountPoint, opsWithInit, { + ...mountOptions, autoUnmount: true, debug: false, - }) as FuseNativeInstance; + }) as FuseInstance & BackingRegistrar; + mountedFuse = fuse; const emitTrace = (reason: string): void => { if (tracer === undefined) return; @@ -1094,20 +1216,6 @@ export async function mountFuse(options: { process.on("SIGUSR2", () => emitTrace("SIGUSR2")); } - // fuse-native (libfuse 2.9) doesn't expose big_writes/max_write/max_read - // through opts, so monkey-patch _fuseOptions() to append them. big_writes - // lets the kernel batch up to max_write bytes per FUSE op instead of the - // default 4 KiB, cutting per-op round-trips ~32x on large sequential I/O. - // buildFuseOptionString reads COMPUTERD_FUSE_* env vars; with none set it - // emits the production-safe profile (auto_cache plus one-second - // metadata timeouts) backed by the auto_cache contract tests. - const origFuseOptions = fuse._fuseOptions.bind(fuse); - const extraOpts = buildFuseOptionString(process.env); - fuse._fuseOptions = (): string => { - const base = origFuseOptions(); - return base ? `${base},${extraOpts}` : `-o${extraOpts}`; - }; - await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("FUSE mount timed out after 5s")), 5_000); fuse.mount((error: Error | null) => { @@ -1135,8 +1243,18 @@ export async function mountFuse(options: { }); }); }, - getBufferStats: _getBufferStats, + getBufferStats: routedOps.getBufferStats, ...(localPaths === undefined ? {} : { getLocalPathStats: localPaths.stats }), + ...(Fuse.removalGuard === undefined ? {} : { removalGuard: Fuse.removalGuard }), + getPassthroughStatus: () => { + const stats = localPaths?.stats(); + return { + requested: wantPassthrough, + negotiated: passthroughNegotiated, + opens: stats?.passthroughOpens ?? 0, + fallbacks: stats?.passthroughFallbacks ?? 0, + }; + }, }; } diff --git a/packages/computerd/src/fuse/fuse-native.d.ts b/packages/computerd/src/fuse/fuse-native.d.ts deleted file mode 100644 index 916e80cb6..000000000 --- a/packages/computerd/src/fuse/fuse-native.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module "fuse-native" { - interface FuseOptions { - autoUnmount?: boolean; - debug?: boolean; - } - - export default class Fuse { - constructor(mountPoint: string, operations: object, options?: FuseOptions); - mount(callback: (error: Error | null) => void): void; - unmount(callback: (error: Error | null) => void): void; - } -} diff --git a/packages/computerd/src/fuse/ignore-config.test.ts b/packages/computerd/src/fuse/ignore-config.test.ts index 435b08838..314cf130f 100644 --- a/packages/computerd/src/fuse/ignore-config.test.ts +++ b/packages/computerd/src/fuse/ignore-config.test.ts @@ -123,13 +123,138 @@ describe("describeMountIgnore", () => { expect(info.root).toBe("/tmp/workspace"); }); - test("reports passthrough as unavailable, with the reason", () => { - // Reported rather than omitted so an operator can see why without - // reading the source, and so a future binding upgrade shows up as - // a measurable change rather than an assumed one. - const info = describeMountIgnore(resolveMountIgnoreConfig({}, "/workspace")); + const enabled = resolveMountIgnoreConfig({ MOUNT_IGNORE: "/node_modules" }, "/workspace"); + const live = { requested: true, negotiated: true, opens: 0, fallbacks: 0 }; + + test("reports passthrough active once a local-only file has used it", () => { + const info = describeMountIgnore(enabled, { ...live, opens: 1284, fallbacks: 2 }); + expect(info.fastPaths).toMatchObject({ + passthrough: true, + passthroughOpens: 1284, + passthroughFallbacks: 2, + writebackCache: false, + }); + }); + + test("says so when no kernel FUSE mount is running", () => { + const info = describeMountIgnore(enabled); + expect(info.fastPaths.passthrough).toBe(false); + expect(info.fastPaths.passthroughReason).toMatch(/no kernel FUSE mount/); + }); + + test("says so when no local-only paths are configured", () => { + const info = describeMountIgnore(resolveMountIgnoreConfig({}, "/workspace"), live); + expect(info.fastPaths.passthrough).toBe(false); + expect(info.fastPaths.passthroughReason).toMatch(/MOUNT_IGNORE/); + }); + + test("says so when it was turned off", () => { + const info = describeMountIgnore(enabled, { ...live, requested: false, negotiated: false }); + expect(info.fastPaths.passthroughReason).toMatch(/COMPUTERD_FUSE_PASSTHROUGH/); + }); + + test("blames the kernel when it never offered the capability", () => { + const info = describeMountIgnore(enabled, { ...live, negotiated: false }); + expect(info.fastPaths.passthrough).toBe(false); + expect(info.fastPaths.passthroughReason).toMatch(/kernel did not offer/); + expect(info.fastPaths.passthroughReason).toMatch(/6\.9/); + }); + + test("points at privileges and overlayfs when every registration was refused", () => { + const info = describeMountIgnore(enabled, { ...live, fallbacks: 3 }); + expect(info.fastPaths.passthrough).toBe(false); + expect(info.fastPaths.passthroughReason).toMatch(/refused every/); + expect(info.fastPaths.passthroughReason).toMatch(/CAP_SYS_ADMIN/); + expect(info.fastPaths.passthroughReason).toMatch(/overlayfs/); + }); + + test("distinguishes a negotiated mount that has not opened a local-only file yet", () => { + const info = describeMountIgnore(enabled, live); expect(info.fastPaths.passthrough).toBe(false); - expect(info.fastPaths.passthroughReason).toMatch(/libfuse 2\.9/); - expect(info.fastPaths.writebackCache).toBe(false); + expect(info.fastPaths.passthroughReason).toMatch(/no local-only file has been opened/); + }); +}); + +describe("describeMountIgnore: nativeRoots", () => { + const configured = resolveMountIgnoreConfig({ MOUNT_IGNORE: "**/node_modules" }, "/workspace"); + const live = { requested: true, negotiated: true, opens: 0, fallbacks: 0 }; + const native = { + requested: true, + active: 2, + mounts: 3, + failures: 0, + detaches: 1, + removalDetaches: 1, + lastFailure: undefined, + removal: { active: true, reason: "active" }, + }; + + test("is on with MOUNT_IGNORE alone, reporting the counters", () => { + expect(describeMountIgnore(configured, live, native).fastPaths.nativeRoots).toEqual({ + enabled: true, + reason: "active", + active: 2, + mounts: 3, + failures: 0, + detaches: 1, + transparentRemoval: true, + transparentRemovalReason: "active", + removalDetaches: 1, + }); + }); + + test("reports why removal is not transparent", () => { + const refused = { ...native, removal: { active: false, reason: "seccomp refused (EACCES)" } }; + const info = describeMountIgnore(configured, live, refused).fastPaths.nativeRoots; + expect(info).toMatchObject({ enabled: true, transparentRemoval: false }); + expect(info.transparentRemovalReason).toMatch(/EACCES/); + const never = describeMountIgnore(configured, live, { ...native, removal: undefined }); + expect(never.fastPaths.nativeRoots.transparentRemovalReason).toMatch(/not started/); + const off = describeMountIgnore(configured, live, { ...native, requested: false }); + expect(off.fastPaths.nativeRoots).toMatchObject({ + transparentRemoval: false, + transparentRemovalReason: "native roots are off", + }); + }); + + test("is off without MOUNT_IGNORE", () => { + const info = describeMountIgnore(resolveMountIgnoreConfig({}, "/workspace"), live, native); + expect(info.fastPaths.nativeRoots.enabled).toBe(false); + expect(info.fastPaths.nativeRoots.reason).toMatch(/MOUNT_IGNORE/); + }); + + test("says so when turned off", () => { + const info = describeMountIgnore(configured, live, { ...native, requested: false }); + expect(info.fastPaths.nativeRoots.enabled).toBe(false); + expect(info.fastPaths.nativeRoots.reason).toMatch(/COMPUTERD_FUSE_NATIVE_ROOTS/); + }); + + test("needs a kernel mount and negotiated passthrough", () => { + expect(describeMountIgnore(configured).fastPaths.nativeRoots.reason).toMatch(/no kernel FUSE/); + const off = describeMountIgnore( + configured, + { ...live, requested: false, negotiated: false }, + native, + ); + expect(off.fastPaths.nativeRoots.reason).toMatch(/COMPUTERD_FUSE_PASSTHROUGH/); + const old = describeMountIgnore(configured, { ...live, negotiated: false }, native); + expect(old.fastPaths.nativeRoots).toMatchObject({ enabled: false }); + expect(old.fastPaths.nativeRoots.reason).toMatch(/6\.9/); + }); + + test("names the failure when every mount was refused", () => { + const refused = { + ...native, + active: 0, + mounts: 0, + failures: 4, + lastFailure: "a: confined bind failed (-38)", + }; + const info = describeMountIgnore(configured, live, refused).fastPaths.nativeRoots; + expect(info.enabled).toBe(true); + expect(info.reason).toMatch(/every mount so far was refused/); + expect(info.reason).toMatch(/confined bind failed \(-38\)/); + expect(info.reason).toMatch(/native helper/); + expect(info.reason).not.toMatch(/util-linux/); }); }); diff --git a/packages/computerd/src/fuse/ignore-config.ts b/packages/computerd/src/fuse/ignore-config.ts index 68e0dda42..d6b072d24 100644 --- a/packages/computerd/src/fuse/ignore-config.ts +++ b/packages/computerd/src/fuse/ignore-config.ts @@ -69,6 +69,14 @@ export function resolveMountIgnoreConfig( return { root: normalizedRoot, ignore, enabled: !ignore.isEmpty }; } +export interface PassthroughStatus { + /** Local-only paths exist and COMPUTERD_FUSE_PASSTHROUGH did not turn it off. */ + readonly requested: boolean; + readonly negotiated: boolean; + readonly opens: number; + readonly fallbacks: number; +} + /** The `ignore` block reported on /__computerd/info. */ export interface MountIgnoreInfo { readonly supported: true; @@ -79,22 +87,58 @@ export interface MountIgnoreInfo { readonly patterns: readonly string[]; readonly ineffectiveExclusions: readonly string[]; readonly fastPaths: { - /** - * Always false: fuse-native binds libfuse 2.9, passthrough needs the - * libfuse 3.17 API. Reported rather than omitted so the reason is - * visible without reading the source. - */ - readonly passthrough: false; + readonly passthrough: boolean; readonly passthroughReason: string; - /** Also unavailable: libfuse 2.9 fails the mount on the option. */ + readonly passthroughOpens: number; + readonly passthroughFallbacks: number; + /** Possible under libfuse 3, but it would change writes on the synced mount. */ readonly writebackCache: false; + readonly nativeRoots: NativeRootsInfo; }; } -export const PASSTHROUGH_UNAVAILABLE_REASON = - "fuse-native binds libfuse 2.9; FOPEN_PASSTHROUGH requires the libfuse 3.17 API"; +/** + * Native-root state as `describeMountIgnore` reads it. Undefined when no + * kernel FUSE mount is running. + */ +export interface NativeRootsStatus { + /** COMPUTERD_FUSE_NATIVE_ROOTS did not turn them off. */ + readonly requested: boolean; + readonly active: number; + readonly mounts: number; + readonly failures: number; + readonly detaches: number; + readonly removalDetaches: number; + readonly lastFailure: string | undefined; + /** + * The removal guard, which lets commands computerd starts remove or + * rename a native root. Undefined when it was never started. + */ + readonly removal?: { readonly active: boolean; readonly reason: string }; +} + +export interface NativeRootsInfo { + /** True when local-only directories are being bind-mounted. */ + readonly enabled: boolean; + readonly reason: string; + readonly active: number; + readonly mounts: number; + readonly failures: number; + readonly detaches: number; + /** Removing or renaming a root works from commands computerd starts. */ + readonly transparentRemoval: boolean; + readonly transparentRemovalReason: string; + readonly removalDetaches: number; +} -export function describeMountIgnore(config: MountIgnoreConfig): MountIgnoreInfo { +/** `status` is undefined when no kernel FUSE mount is running. */ +export function describeMountIgnore( + config: MountIgnoreConfig, + status?: PassthroughStatus, + native?: NativeRootsStatus, +): MountIgnoreInfo { + const inactive = inactivePassthroughReason(config, status); + const nativeInactive = inactiveNativeRootsReason(config, status, native); return { supported: true, enabled: config.enabled, @@ -102,9 +146,87 @@ export function describeMountIgnore(config: MountIgnoreConfig): MountIgnoreInfo patterns: config.ignore.patterns, ineffectiveExclusions: config.ignore.ineffectiveExclusions, fastPaths: { - passthrough: false, - passthroughReason: PASSTHROUGH_UNAVAILABLE_REASON, + passthrough: inactive === undefined, + passthroughReason: inactive ?? activeReason(status?.fallbacks ?? 0), + passthroughOpens: status?.opens ?? 0, + passthroughFallbacks: status?.fallbacks ?? 0, writebackCache: false, + nativeRoots: { + enabled: nativeInactive === undefined, + reason: nativeInactive ?? nativeActiveReason(native), + active: native?.active ?? 0, + mounts: native?.mounts ?? 0, + failures: native?.failures ?? 0, + detaches: native?.detaches ?? 0, + transparentRemoval: nativeInactive === undefined && native?.removal?.active === true, + transparentRemovalReason: + nativeInactive === undefined + ? (native?.removal?.reason ?? "the removal guard was not started") + : "native roots are off", + removalDetaches: native?.removalDetaches ?? 0, + }, }, }; } + +// Native roots ride on passthrough: both need CAP_SYS_ADMIN and a 6.9 +// kernel, and without passthrough a file opened through FUSE before its +// root was mounted would have a page cache of its own. +function inactiveNativeRootsReason( + config: MountIgnoreConfig, + status: PassthroughStatus | undefined, + native: NativeRootsStatus | undefined, +): string | undefined { + if (!config.enabled) return "no local-only paths are configured (MOUNT_IGNORE)"; + if (status === undefined || native === undefined) return "no kernel FUSE mount is running"; + if (!native.requested) return "turned off with COMPUTERD_FUSE_NATIVE_ROOTS"; + if (!status.requested) { + return "FUSE passthrough is turned off with COMPUTERD_FUSE_PASSTHROUGH, and native roots need it"; + } + if (!status.negotiated) { + return "the kernel did not offer FUSE passthrough, and native roots need it (Linux 6.9 or newer)"; + } + return undefined; +} + +function nativeActiveReason(native: NativeRootsStatus | undefined): string { + if (native === undefined || native.failures === 0) return "active"; + if (native.mounts === 0) { + return ( + `active, but every mount so far was refused (${native.failures}; last: ` + + `${native.lastFailure ?? "unknown"}); check CAP_SYS_ADMIN, kernel mount support, ` + + `and the native helper` + ); + } + return `active; ${native.failures} mount(s) failed and those paths are served through FUSE`; +} + +// Each case has a different fix, so each gets its own wording. +function inactivePassthroughReason( + config: MountIgnoreConfig, + status: PassthroughStatus | undefined, +): string | undefined { + if (!config.enabled) return "no local-only paths are configured (MOUNT_IGNORE)"; + if (status === undefined) { + return "no kernel FUSE mount is running, so local-only paths are not passed through"; + } + if (!status.requested) return "turned off with COMPUTERD_FUSE_PASSTHROUGH"; + if (!status.negotiated) { + return "the kernel did not offer FUSE passthrough at init; it needs Linux 6.9 or newer"; + } + if (status.opens > 0) return undefined; + if (status.fallbacks > 0) { + return ( + `the kernel offered passthrough but refused every backing registration ` + + `(${status.fallbacks}); check that computerd has CAP_SYS_ADMIN and that ` + + `MOUNT_IGNORE_PATH is not on overlayfs stacked on another overlayfs` + ); + } + return "negotiated, but no local-only file has been opened yet"; +} + +function activeReason(fallbacks: number): string { + return fallbacks === 0 + ? "active" + : `active; ${fallbacks} open(s) fell back to computerd after a refused registration`; +} diff --git a/packages/computerd/src/fuse/index.ts b/packages/computerd/src/fuse/index.ts index f5a511df3..7ed935970 100644 --- a/packages/computerd/src/fuse/index.ts +++ b/packages/computerd/src/fuse/index.ts @@ -1,19 +1,33 @@ -export type { FUSEBackend, FuseMountMode, ResolveFuseBackendOptions } from "./backend"; -export { parseFuseMountMode, resolveFuseBackend } from "./backend"; -export type { FuseMount, FuseOps, FuseStat } from "./driver"; -export { makeFUSEOps, mountFuse } from "./driver"; -export type { MountIgnoreSet } from "./ignore"; -export { MountIgnorePathError, parseMountIgnore, resolveMountIgnore } from "./ignore"; -export type { MountIgnoreConfig, MountIgnoreEnv, MountIgnoreInfo } from "./ignore-config"; +export type { FUSEBackend, FuseMountMode, ResolveFuseBackendOptions } from "./backend.js"; +export { parseFuseMountMode, resolveFuseBackend } from "./backend.js"; +export type { FuseMount, FuseOps, FuseStat, RemovalGuardBinding } from "./driver.js"; +export { makeFUSEOps, mountFuse } from "./driver.js"; +export { nativeRootsRequested, passthroughRequested } from "./options.js"; +export type { MountIgnoreSet } from "./ignore.js"; +export { MountIgnorePathError, parseMountIgnore, resolveMountIgnore } from "./ignore.js"; +export type { + MountIgnoreConfig, + MountIgnoreEnv, + MountIgnoreInfo, + NativeRootsInfo, + NativeRootsStatus, + PassthroughStatus, +} from "./ignore-config.js"; export { defaultIgnoreRoot, describeMountIgnore, - PASSTHROUGH_UNAVAILABLE_REASON, resolveMountIgnoreConfig, -} from "./ignore-config"; -export type { LocalPassthrough, LocalPassthroughOptions, PassthroughStats } from "./passthrough"; -export { withLocalPassthrough } from "./passthrough"; -export type { ResolvedStore, StoreMode } from "./store"; -export { parseStoreMode, resolveStore } from "./store"; -export type { CreateNodeVFSOptions, NodeVFSHandle, NodeVirtualFileSystem } from "./vfs"; -export { createNodeVirtualFileSystem } from "./vfs"; +} from "./ignore-config.js"; +export type { + NativeRoots, + NativeRootsOptions, + NativeRootsStats, + RemovalGuard, +} from "./native-roots.js"; +export { createNativeRoots } from "./native-roots.js"; +export type { LocalPassthrough, LocalPassthroughOptions, PassthroughStats } from "./passthrough.js"; +export { withLocalPassthrough } from "./passthrough.js"; +export type { ResolvedStore, StoreMode } from "./store.js"; +export { parseStoreMode, resolveStore } from "./store.js"; +export type { CreateNodeVFSOptions, NodeVFSHandle, NodeVirtualFileSystem } from "./vfs.js"; +export { createNodeVirtualFileSystem } from "./vfs.js"; diff --git a/packages/computerd/src/fuse/native-roots.fuse.test.ts b/packages/computerd/src/fuse/native-roots.fuse.test.ts new file mode 100644 index 000000000..e4ffbc95a --- /dev/null +++ b/packages/computerd/src/fuse/native-roots.fuse.test.ts @@ -0,0 +1,600 @@ +// Boots the binary in a privileged container, as passthrough.fuse.test.ts +// does, with MOUNT_IGNORE set, and checks the kernel's view directly: +// `docker exec` runs in the container's mount namespace, so mountinfo and +// every path it touches are what a command sees. Skips without Docker or +// the binary, and skips the cases that need native roots on a host +// kernel without FUSE passthrough (Linux before 6.9). +// +// Prefers a binary for the host's architecture +// (COMPUTERD_BIN_TARGETS=linux-arm64 on an arm64 machine), falling back +// to linux-x64 under emulation. + +import { type ChildProcess, execFileSync, spawn, spawnSync } from "node:child_process"; +import { accessSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createWorkspaceClient } from "@cloudflare/computer-rpc/client"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../../../../.."); +const RUN_COMPUTERD_SCRIPT = join(REPO_ROOT, "packages/computer/test-harness/run-computerd.sh"); + +function pickBinary(): { binary: string; platform: string } | undefined { + const candidates = + process.arch === "arm64" + ? [ + { name: "computerd-linux-arm64", platform: "linux/arm64" }, + { name: "computerd-linux-x64", platform: "linux/amd64" }, + ] + : [{ name: "computerd-linux-x64", platform: "linux/amd64" }]; + for (const candidate of candidates) { + const binary = join(REPO_ROOT, "artifacts/computerd", candidate.name); + try { + accessSync(binary); + return { binary, platform: candidate.platform }; + } catch { + // Try the next one. + } + } + return undefined; +} + +const target = pickBinary(); + +function dockerAvailable(): boolean { + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const describeIfReal = target !== undefined && dockerAvailable() ? describe : describe.skip; + +const IGNORE = "**/node_modules,!/vendor/node_modules,/dist,**/*.tsbuildinfo"; + +interface Booted { + cid: string; + url: string; +} + +async function boot(env: Record): Promise { + if (target === undefined) throw new Error("no computerd binary"); + const proc = spawn("bash", [RUN_COMPUTERD_SCRIPT], { + env: { + ...process.env, + COMPUTERD_HARNESS_PORT: "0", + COMPUTERD_BINARY: target.binary, + COMPUTERD_HARNESS_PLATFORM: target.platform, + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + if (!proc.stdout || !proc.stderr) throw new Error("run-computerd.sh exposed no pipes"); + const [stdout, stderr] = await Promise.all([drain(proc.stdout), drain(proc.stderr)]); + await waitForExit(proc); + if (proc.exitCode !== 0) throw new Error(`run-computerd.sh exited ${proc.exitCode}: ${stderr}`); + const cid = stderr.match(/COMPUTERD_HARNESS_CID=([0-9a-f]+)/)?.[1]; + if (cid === undefined) throw new Error(`no container id in: ${stderr}`); + return { cid, url: stdout.trim() }; +} + +function kill(booted: Booted | undefined): void { + if (booted === undefined) return; + spawnSync("docker", ["kill", booted.cid], { stdio: "ignore" }); +} + +/** Runs a shell script in the container, from /workspace. */ +function sh(booted: Booted, script: string): { stdout: string; stderr: string; code: number } { + const result = spawnSync( + "docker", + ["exec", booted.cid, "sh", "-c", `cd /workspace && ${script}`], + { + encoding: "utf8", + }, + ); + return { stdout: result.stdout, stderr: result.stderr, code: result.status ?? -1 }; +} + +/** Mount points under /workspace, excluding the FUSE mount itself. */ +function mounts(booted: Booted): string[] { + const { stdout } = sh(booted, "cat /proc/self/mountinfo"); + return stdout + .split("\n") + .map((line) => line.split(" ")[4] ?? "") + .filter((path) => path.startsWith("/workspace/")); +} + +async function waitForMount(booted: Booted, path: string, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (mounts(booted).includes(path)) return true; + await new Promise((r) => setTimeout(r, 100)); + } + return false; +} + +// fetch pools connections, and the daemon's HTTP server closes an idle +// one after five seconds; a request that picks up a socket just as it +// closes fails with "other side closed". Once more on a fresh socket. +async function request(url: string, init?: RequestInit): Promise { + try { + return await fetch(url, init); + } catch { + return await fetch(url, { ...init, keepalive: false }); + } +} + +interface NativeInfo { + enabled: boolean; + reason: string; + active: number; + mounts: number; + failures: number; + transparentRemoval: boolean; + transparentRemovalReason: string; + removalDetaches: number; +} + +/** + * Runs a command through computerd's exec RPC, as a host does. Unlike + * `sh`, the command is computerd's child, so the removal guard covers it. + */ +async function exec( + client: ReturnType, + source: string, +): Promise<{ stdout: string; stderr: string; code: number | undefined }> { + const handle = await client.shell.exec({ source, cwd: "/workspace", timeoutMs: 30_000 }); + const reader = handle.events.getReader(); + let stdout = ""; + let stderr = ""; + let code: number | undefined; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const event = value as { name: string; value?: unknown; code?: number }; + const text = () => new TextDecoder().decode(event.value as Uint8Array); + if (event.name === "stdout") stdout += text(); + if (event.name === "stderr") stderr += text(); + if (event.name === "exit") code = event.code; + } + } finally { + reader.releaseLock(); + } + return { stdout, stderr, code }; +} + +async function nativeInfo(booted: Booted): Promise { + const response = await request(`${booted.url}/__computerd/info`); + const body = (await response.json()) as { ignore: { fastPaths: { nativeRoots: NativeInfo } } }; + return body.ignore.fastPaths.nativeRoots; +} + +async function stats(booted: Booted): Promise<{ localOps: number; vfsNodes: number }> { + const response = await request(`${booted.url}/__computerd/stats`); + const body = (await response.json()) as { + vfs_nodes_count: number; + localPaths: { localOps: number }; + }; + return { localOps: body.localPaths.localOps, vfsNodes: body.vfs_nodes_count }; +} + +describeIfReal("native roots under a real mount", () => { + let booted: Booted | undefined; + let client: ReturnType | undefined; + let available = false; + + beforeAll(async () => { + booted = await boot({ MOUNT_IGNORE: IGNORE }); + available = (await nativeInfo(booted)).enabled; + client = createWorkspaceClient({ + url: `${booted.url.replace(/^http(s?):\/\//, "ws$1://")}/api`, + WebSocketImpl: WebSocket, + }); + }, 120_000); + + afterAll(async () => { + await client?.close(); + kill(booted); + }); + + const guarded = (ctx: { skip: () => void }) => { + const b = ready(ctx); + if (client === undefined) throw new Error("no exec client"); + return { b, run: (source: string) => exec(client as NonNullable, source) }; + }; + + const ready = (ctx: { skip: () => void }): Booted => { + if (booted === undefined) throw new Error("computerd container did not start"); + if (!available) ctx.skip(); + return booted; + }; + + test("a new root is mounted, and files written under it never reach the daemon", async (ctx) => { + const b = ready(ctx); + // The synced parent is a VFS node; make it before counting. + sh(b, "mkdir -p app"); + const before = await stats(b); + const result = sh( + b, + [ + "mkdir app/node_modules", + // Straight after the mkdir, before the mount can have landed: + // the first files go through FUSE, the rest natively, and all of + // them have to end up in the backing directory either way. + "for i in $(seq 1 500); do echo $i > app/node_modules/f$i; done", + ].join(" && "), + ); + expect(result.code).toBe(0); + expect(await waitForMount(b, "/workspace/app/node_modules")).toBe(true); + + const listed = sh( + b, + "ls app/node_modules | wc -l && ls /tmp/workspace/app/node_modules | wc -l", + ); + expect(listed.stdout).toBe("500\n500\n"); + const identity = sh(b, "stat -c '%d:%i' app/node_modules /tmp/workspace/app/node_modules"); + const [mounted, backing] = identity.stdout.trim().split("\n"); + expect(mounted).toBe(backing); + + const after = await stats(b); + // Local-only content never enters the VFS, mounted or not. + expect(after.vfsNodes).toBe(before.vfsNodes); + // Without the mount every create would be a local op. With it, only + // the ones that beat the mount are. + expect(after.localOps - before.localOps).toBeLessThan(250); + }, 60_000); + + test("files written after the mount bypass the daemon entirely", async (ctx) => { + const b = ready(ctx); + sh(b, "mkdir -p quiet/node_modules"); + expect(await waitForMount(b, "/workspace/quiet/node_modules")).toBe(true); + const before = await stats(b); + const result = sh( + b, + "for i in $(seq 1 300); do echo $i > quiet/node_modules/f$i; done && " + + "cat quiet/node_modules/f* | wc -l && ls -la quiet/node_modules > /dev/null", + ); + expect(result.stdout).toBe("300\n"); + expect((await stats(b)).localOps).toBe(before.localOps); + }, 60_000); + + test("the mount outlives FUSE dentry revalidation", async (ctx) => { + // The entry timeout is a second. A revalidation that reported the + // dentry stale would have the kernel detach whatever sits on it. + const b = ready(ctx); + sh(b, "mkdir -p reval/node_modules && echo x > reval/node_modules/f"); + expect(await waitForMount(b, "/workspace/reval/node_modules")).toBe(true); + sh(b, "sleep 2.5 && ls reval > /dev/null && stat reval/node_modules > /dev/null && sleep 1.5"); + expect(mounts(b)).toContain("/workspace/reval/node_modules"); + expect(sh(b, "cat reval/node_modules/f").stdout).toBe("x\n"); + }, 30_000); + + test("every local-only pattern's directory is mounted, not only node_modules", async (ctx) => { + const b = ready(ctx); + sh(b, "mkdir -p dist/assets && echo x > dist/assets/app.js"); + expect(await waitForMount(b, "/workspace/dist")).toBe(true); + expect(sh(b, "cat dist/assets/app.js").stdout).toBe("x\n"); + }, 30_000); + + test("nothing but a topmost local-only directory is mounted", async (ctx) => { + const b = ready(ctx); + const result = sh( + b, + [ + // A nested root inside a root: inside the native tree already. + "mkdir -p top/node_modules/pkg/node_modules", + // Kept synced by an exclusion. + "mkdir -p vendor/node_modules", + // A file pattern: local-only, but never a mount. + "echo x > tsconfig.tsbuildinfo", + // A symlink where a root would be. + "mkdir -p linked/real && ln -s real linked/node_modules", + "sleep 1", + ].join(" && "), + ); + expect(result.code).toBe(0); + expect(await waitForMount(b, "/workspace/top/node_modules")).toBe(true); + const all = mounts(b); + expect(all).not.toContain("/workspace/top/node_modules/pkg/node_modules"); + expect(all).not.toContain("/workspace/vendor/node_modules"); + expect(all).not.toContain("/workspace/tsconfig.tsbuildinfo"); + expect(all).not.toContain("/workspace/linked/node_modules"); + }, 30_000); + + test("outside computerd's commands, removing or renaming a root gets EBUSY until detached", async (ctx) => { + // `docker exec` processes are not computerd's children, so the removal + // guard does not see their syscalls. + const b = ready(ctx); + sh(b, "mkdir -p busy/node_modules/react && echo 1 > busy/node_modules/react/index.js"); + expect(await waitForMount(b, "/workspace/busy/node_modules")).toBe(true); + + const removed = sh(b, "rm -rf busy/node_modules"); + expect(removed.code).not.toBe(0); + expect(removed.stderr).toMatch(/busy/i); + // rm emptied it on the way to the refused rmdir. + expect(sh(b, "ls -A busy/node_modules | wc -l").stdout).toBe("0\n"); + + expect(sh(b, "mv busy/node_modules busy/nm2").code).not.toBe(0); + // Emptying the contents is the documented workaround. + sh(b, "echo 2 > busy/node_modules/x"); + expect(sh(b, "rm -rf busy/node_modules/*").code).toBe(0); + + const response = await request(`${b.url}/__computerd/native-roots/detach`, { + method: "POST", + body: JSON.stringify({ path: "busy/node_modules" }), + }); + expect(await response.json()).toEqual({ detached: ["/workspace/busy/node_modules"] }); + expect(sh(b, "rm -rf busy/node_modules").code).toBe(0); + expect(sh(b, "test ! -e /tmp/workspace/busy/node_modules").code).toBe(0); + + // The next one comes back mounted. + sh(b, "mkdir busy/node_modules"); + expect(await waitForMount(b, "/workspace/busy/node_modules")).toBe(true); + }, 30_000); + + test("renaming a synced ancestor carries exactly one mount along", async (ctx) => { + const b = ready(ctx); + sh(b, "mkdir -p pkgs/app/node_modules/react && echo hi > pkgs/app/node_modules/react/index.js"); + expect(await waitForMount(b, "/workspace/pkgs/app/node_modules")).toBe(true); + expect(sh(b, "mv pkgs/app pkgs/web").code).toBe(0); + await new Promise((r) => setTimeout(r, 500)); + const atNew = mounts(b).filter((path) => path === "/workspace/pkgs/web/node_modules"); + expect(atNew).toHaveLength(1); + expect(mounts(b)).not.toContain("/workspace/pkgs/app/node_modules"); + expect(sh(b, "cat pkgs/web/node_modules/react/index.js").stdout).toBe("hi\n"); + const identity = sh( + b, + "stat -c '%d:%i' pkgs/web/node_modules /tmp/workspace/pkgs/web/node_modules", + ); + const [mounted, backing] = identity.stdout.trim().split("\n"); + expect(mounted).toBe(backing); + }, 30_000); + + test("the removal guard is on", async (ctx) => { + const b = ready(ctx); + const info = await nativeInfo(b); + expect(info.transparentRemoval).toBe(true); + expect(info.transparentRemovalReason).toBe("active"); + }); + + test("rm -rf of a root works from a command computerd runs", async (ctx) => { + const { b, run } = guarded(ctx); + sh(b, "mkdir -p gone/node_modules/react && echo 1 > gone/node_modules/react/index.js"); + expect(await waitForMount(b, "/workspace/gone/node_modules")).toBe(true); + const before = (await nativeInfo(b)).removalDetaches; + const result = await run("rm -rf gone/node_modules && echo removed"); + expect(result).toMatchObject({ code: 0, stdout: "removed\n" }); + expect(mounts(b)).not.toContain("/workspace/gone/node_modules"); + expect( + sh(b, "test ! -e gone/node_modules && test ! -e /tmp/workspace/gone/node_modules").code, + ).toBe(0); + expect((await nativeInfo(b)).removalDetaches).toBe(before + 1); + // And it comes back mounted when created again. + await run("mkdir gone/node_modules"); + expect(await waitForMount(b, "/workspace/gone/node_modules")).toBe(true); + }, 30_000); + + test("rm -rf of a directory holding roots works", async (ctx) => { + const { b, run } = guarded(ctx); + sh(b, "mkdir -p tree/a/node_modules/x tree/b/node_modules && echo 1 > tree/src.ts"); + expect(await waitForMount(b, "/workspace/tree/a/node_modules")).toBe(true); + expect(await waitForMount(b, "/workspace/tree/b/node_modules")).toBe(true); + const result = await run("rm -rf tree && echo removed"); + expect(result).toMatchObject({ code: 0, stdout: "removed\n" }); + expect(sh(b, "test ! -e tree && test ! -e /tmp/workspace/tree").code).toBe(0); + }, 30_000); + + test("a program calling rmdir(2) itself, not rm, works", async (ctx) => { + const { b, run } = guarded(ctx); + sh(b, "mkdir -p direct/node_modules"); + expect(await waitForMount(b, "/workspace/direct/node_modules")).toBe(true); + const result = await run(`perl -e 'rmdir "direct/node_modules" or die "$!\\n"; print "ok\\n"'`); + expect(result).toMatchObject({ code: 0, stdout: "ok\n" }); + expect(sh(b, "test ! -e direct/node_modules").code).toBe(0); + }, 30_000); + + test("mv of a root crosses the boundary as without a mount: EXDEV, then a copy", async (ctx) => { + const { b, run } = guarded(ctx); + sh(b, "mkdir -p moved/node_modules && echo 1 > moved/node_modules/f"); + expect(await waitForMount(b, "/workspace/moved/node_modules")).toBe(true); + const renamed = await run(`perl -e 'rename "moved/node_modules", "moved/nm" or print "$!\\n"'`); + expect(renamed.stdout).toMatch(/cross-device/i); + const result = await run("mv moved/node_modules moved/elsewhere && cat moved/elsewhere/f"); + expect(result).toMatchObject({ code: 0, stdout: "1\n" }); + expect(sh(b, "test ! -e moved/node_modules").code).toBe(0); + }, 30_000); + + test("a background process outliving its command can still remove a root", async (ctx) => { + const { b, run } = guarded(ctx); + sh(b, "rm -f /tmp/bg-status; mkdir -p bg/node_modules/x"); + expect(await waitForMount(b, "/workspace/bg/node_modules")).toBe(true); + const started = await run( + "(sleep 1; rm -rf bg/node_modules; echo $? > /tmp/bg-status) > /dev/null 2>&1 < /dev/null &", + ); + expect(started.code).toBe(0); + await new Promise((r) => setTimeout(r, 2_500)); + expect(sh(b, "cat /tmp/bg-status").stdout).toBe("0\n"); + expect(sh(b, "test ! -e bg/node_modules").code).toBe(0); + }, 30_000); + + test("ordinary removals and renames are untouched", async (ctx) => { + const { run } = guarded(ctx); + const result = await run( + "mkdir -p plain/a/b && echo x > plain/a/f && mv plain/a plain/c && rmdir plain/c/b && " + + "rm plain/c/f && rmdir plain/c && rmdir plain && echo ok", + ); + expect(result).toMatchObject({ code: 0, stdout: "ok\n" }); + }, 30_000); + + test("info and stats report the mounts", async (ctx) => { + const b = ready(ctx); + const info = await nativeInfo(b); + expect(info).toMatchObject({ enabled: true, reason: "active", failures: 0 }); + expect(info.active).toBe(mounts(b).length); + expect(info.mounts).toBeGreaterThanOrEqual(info.active); + }); +}); + +describeIfReal("native roots across restarts", () => { + const started: Booted[] = []; + const images: string[] = []; + + afterAll(() => { + for (const b of started) kill(b); + for (const image of images) spawnSync("docker", ["rmi", "-f", image], { stdio: "ignore" }); + }); + + // Stands in for a container snapshot: the disk comes back, the mount + // table does not. + async function restore(env: Record): Promise { + const first = await boot({ MOUNT_IGNORE: IGNORE, ...env }); + started.push(first); + sh(first, "mkdir -p a/node_modules/x b/c/node_modules && echo kept > a/node_modules/x/f"); + expect(await waitForMount(first, "/workspace/b/c/node_modules")).toBe(true); + const name = `computerd-native-roots-restore:${first.cid.slice(0, 12)}`; + images.push(name); + execFileSync("docker", ["commit", first.cid, name], { stdio: "ignore" }); + kill(first); + const second = await boot({ + MOUNT_IGNORE: IGNORE, + COMPUTERD_HARNESS_IMAGE: name, + ...env, + }); + started.push(second); + return second; + } + + test("with a file store, a restored container mounts its roots at startup", async (ctx) => { + const second = await restore({ COMPUTERD_DB: "/var/lib/computerd/state.db" }); + if (!(await nativeInfo(second)).enabled) ctx.skip(); + expect(await waitForMount(second, "/workspace/a/node_modules")).toBe(true); + expect(await waitForMount(second, "/workspace/b/c/node_modules")).toBe(true); + expect(mounts(second)).not.toContain("/workspace/a/node_modules/x"); + expect(sh(second, "cat a/node_modules/x/f").stdout).toBe("kept\n"); + expect((await nativeInfo(second)).failures).toBe(0); + }, 120_000); + + test("restore refuses a backing root whose workspace parent is a symlink", async (ctx) => { + const first = await boot({ MOUNT_IGNORE: IGNORE, COMPUTERD_DB: "/var/lib/computerd/state.db" }); + started.push(first); + if (!(await nativeInfo(first)).enabled) ctx.skip(); + // The backing store can outlive the synced tree. A workspace symlink + // must never redirect a privileged bind mount outside /workspace. + const setup = sh( + first, + "mkdir -p /opt/victim/node_modules /tmp/workspace/q/node_modules && " + + "echo original > /opt/victim/node_modules/marker && " + + "echo planted > /tmp/workspace/q/node_modules/planted && " + + "ln -s /opt/victim q", + ); + expect(setup.code).toBe(0); + const name = `computerd-native-roots-symlink:${first.cid.slice(0, 12)}`; + images.push(name); + execFileSync("docker", ["commit", first.cid, name], { stdio: "ignore" }); + kill(first); + const restored = await boot({ + MOUNT_IGNORE: IGNORE, + COMPUTERD_DB: "/var/lib/computerd/state.db", + COMPUTERD_HARNESS_IMAGE: name, + }); + started.push(restored); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(sh(restored, "cat /opt/victim/node_modules/marker").stdout).toBe("original\n"); + expect(sh(restored, "test ! -e /opt/victim/node_modules/planted").code).toBe(0); + expect((await nativeInfo(restored)).active).toBe(0); + }, 120_000); + + test("refuses a symlink in a backing root ancestor", async (ctx) => { + const b = await boot({ MOUNT_IGNORE: IGNORE }); + started.push(b); + if (!(await nativeInfo(b)).enabled) ctx.skip(); + const setup = sh( + b, + "mkdir -p z /opt/victim/node_modules && " + + "echo original > /opt/victim/node_modules/marker && " + + "rm -rf /tmp/workspace/z && ln -s /opt/victim /tmp/workspace/z && " + + "test -f z/node_modules/marker", + ); + expect(setup.code).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(mounts(b)).not.toContain("/workspace/z/node_modules"); + expect(sh(b, "cat /opt/victim/node_modules/marker").stdout).toBe("original\n"); + }, 30_000); + + test("with an in-memory store, a root mounts on first lookup once its parent is back", async (ctx) => { + // The VFS starts empty and the host pushes synced directories back + // later, so at startup there is nothing to mount onto yet. + const second = await restore({}); + if (!(await nativeInfo(second)).enabled) ctx.skip(); + await new Promise((r) => setTimeout(r, 500)); + expect(mounts(second)).toEqual([]); + expect((await nativeInfo(second)).failures).toBe(0); + // Stands in for the host's push of the synced parent. + sh(second, "mkdir a"); + expect(sh(second, "cat a/node_modules/x/f").stdout).toBe("kept\n"); + expect(await waitForMount(second, "/workspace/a/node_modules")).toBe(true); + }, 120_000); + + test("SIGTERM detaches the roots before unmounting FUSE", async (ctx) => { + const b = await boot({ MOUNT_IGNORE: IGNORE }); + started.push(b); + if (!(await nativeInfo(b)).enabled) ctx.skip(); + sh(b, "mkdir -p node_modules && echo x > node_modules/f"); + expect(await waitForMount(b, "/workspace/node_modules")).toBe(true); + // Followed live: the container runs with --rm, so its logs are gone + // once it exits. + const follower = spawn("docker", ["logs", "-f", "--since", "0s", b.cid], { + stdio: ["ignore", "pipe", "pipe"], + }); + const logs = Promise.all([ + drain(follower.stdout as NodeJS.ReadableStream), + drain(follower.stderr as NodeJS.ReadableStream), + ]); + await new Promise((r) => setTimeout(r, 300)); + // computerd is PID 1, so `docker stop` delivers SIGTERM to it. + execFileSync("docker", ["stop", "-t", "10", b.cid], { stdio: "ignore" }); + const [out, err] = await logs; + expect(`${out}${err}`).not.toMatch(/failed to unmount FUSE/); + }, 60_000); + + test("COMPUTERD_FUSE_NATIVE_ROOTS=0 turns them off, leaving passthrough", async () => { + const b = await boot({ MOUNT_IGNORE: IGNORE, COMPUTERD_FUSE_NATIVE_ROOTS: "0" }); + started.push(b); + const info = await nativeInfo(b); + expect(info.enabled).toBe(false); + expect(info.reason).toMatch(/COMPUTERD_FUSE_NATIVE_ROOTS/); + sh(b, "mkdir -p node_modules && echo x > node_modules/f && sleep 1"); + expect(mounts(b)).toEqual([]); + expect(sh(b, "cat node_modules/f").stdout).toBe("x\n"); + }, 60_000); + + test("without passthrough, native roots stay off and nothing is mounted", async () => { + const b = await boot({ MOUNT_IGNORE: IGNORE, COMPUTERD_FUSE_PASSTHROUGH: "0" }); + started.push(b); + const info = await nativeInfo(b); + expect(info.enabled).toBe(false); + expect(info.reason).toMatch(/COMPUTERD_FUSE_PASSTHROUGH/); + sh(b, "mkdir -p node_modules && echo x > node_modules/f && sleep 1"); + expect(mounts(b)).toEqual([]); + expect(sh(b, "cat node_modules/f").stdout).toBe("x\n"); + }, 60_000); +}); + +async function drain(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + return new Promise((resolveStream, rejectStream) => { + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stream.on("end", () => resolveStream(Buffer.concat(chunks).toString("utf8"))); + stream.on("error", rejectStream); + }); +} + +async function waitForExit(proc: ChildProcess): Promise { + if (proc.exitCode !== null) return; + await new Promise((resolveExit) => proc.once("exit", () => resolveExit())); +} diff --git a/packages/computerd/src/fuse/native-roots.test.ts b/packages/computerd/src/fuse/native-roots.test.ts new file mode 100644 index 000000000..b9aaf6beb --- /dev/null +++ b/packages/computerd/src/fuse/native-roots.test.ts @@ -0,0 +1,609 @@ +import { describe, expect, test } from "vitest"; + +import { resolveMountIgnore } from "./ignore.js"; +import { createNativeRoots, type NativeRootsOptions, unescapeMountinfo } from "./native-roots.js"; + +const ROOT = "/tmp/workspace"; +const MOUNT = "/workspace"; + +interface Harness { + roots: ReturnType; + commands: string[][]; + mounted: Set; + warnings: string[]; +} + +/** + * A fake system: `dirs` is the backing tree (paths relative to ROOT), + * A confined bind adds the target to the fake mount table. + */ +function harness( + options: { + ignore?: string[]; + dirs?: string[]; + files?: string[]; + symlinks?: string[]; + mountExit?: number; + mismatch?: boolean; + active?: () => boolean; + /** Mount-relative targets that do not exist under the mount yet. */ + missingTargets?: Set; + beforeTarget?: () => Promise; + beforeBind?: () => Promise; + } = {}, +): Harness { + const ignore = resolveMountIgnore(options.ignore ?? ["**/node_modules", "/dist"], MOUNT); + const dirs = new Set(options.dirs ?? []); + const files = new Set(options.files ?? []); + const symlinks = new Set(options.symlinks ?? []); + const commands: string[][] = []; + const mounted = new Set(); + const warnings: string[] = []; + const rel = (path: string) => path.slice(ROOT.length + 1); + const inode = (path: string) => path.length; + + const seams: Partial = { + unbindRoot: async (target, rel) => { + commands.push(["unbindRoot", target, rel]); + mounted.delete(`${target}/${rel}`); + return 0; + }, + bindRoot: async (source, target, rel) => { + commands.push(["bindRoot", source, target, rel]); + if (options.mismatch) return -5; // Native verification failed before returning. + if (options.mountExit) return -options.mountExit; + await options.beforeBind?.(); + mounted.add(`${target}/${rel}`); + return 0; + }, + mountinfo: () => + [ + `22 1 0:40 / ${MOUNT} rw - fuse computerd rw`, + ...[...mounted].map((target, i) => `${30 + i} 22 254:1 /x ${target} rw - ext4 /dev/vda rw`), + ].join("\n"), + lstat: (path) => { + const key = rel(path); + if (symlinks.has(key)) return { isDirectory: () => false, dev: 1, ino: inode(path) }; + const isDir = [...dirs, ...files].some((entry) => entry.startsWith(`${key}/`)); + if (dirs.has(key) || isDir) return { isDirectory: () => true, dev: 1, ino: inode(path) }; + if (files.has(key)) return { isDirectory: () => false, dev: 1, ino: inode(path) }; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }, + lstatTarget: async (path) => { + await options.beforeTarget?.(); + if (options.missingTargets?.has(path.slice(MOUNT.length + 1))) { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + } + return { isDirectory: () => true }; + }, + readdir: (path) => { + const prefix = path === ROOT ? "" : `${rel(path)}/`; + const names = new Set(); + for (const entry of [...dirs, ...files, ...symlinks]) { + if (!entry.startsWith(prefix)) continue; + const rest = entry.slice(prefix.length); + if (rest === "") continue; + names.add(rest.split("/")[0] as string); + } + return [...names]; + }, + warn: (message) => warnings.push(message), + openDirectory: async () => 100 + commands.length, + }; + + const roots = createNativeRoots({ + root: ROOT, + mountPoint: MOUNT, + ignore, + ...(options.active ? { active: options.active } : {}), + ...seams, + }); + return { roots, commands, mounted, warnings }; +} + +describe("isRoot", () => { + test("is a local-only directory whose parent is synced", () => { + const { roots } = harness(); + expect(roots.isRoot("node_modules")).toBe(true); + expect(roots.isRoot("packages/app/node_modules")).toBe(true); + expect(roots.isRoot("/packages/app/node_modules/")).toBe(true); + }); + + test("is false below a root, for synced paths, and for the mount root", () => { + const { roots } = harness(); + expect(roots.isRoot("node_modules/react")).toBe(false); + expect(roots.isRoot("node_modules/react/node_modules")).toBe(false); + expect(roots.isRoot("src")).toBe(false); + expect(roots.isRoot("")).toBe(false); + }); + + test("is any local-only pattern's directory, not only node_modules", () => { + const { roots } = harness(); + expect(roots.isRoot("dist")).toBe(true); + expect(roots.isRoot("dist/assets")).toBe(false); + }); + + test("respects an exclusion that keeps a path synced", () => { + const { roots } = harness({ ignore: ["**/node_modules", "!/vendor/node_modules"] }); + expect(roots.isRoot("vendor/node_modules")).toBe(false); + expect(roots.isRoot("vendor/node_modules/pkg/node_modules")).toBe(true); + }); +}); + +describe("ensure", () => { + test("bind-mounts the backing directory, makes it private, and verifies it", async () => { + const { roots, commands, mounted } = harness({ dirs: ["app/node_modules"] }); + roots.ensure("app/node_modules"); + await roots.settled(); + expect(commands).toEqual([["bindRoot", ROOT, MOUNT, "app/node_modules"]]); + expect([...mounted]).toEqual([`${MOUNT}/app/node_modules`]); + expect(roots.stats()).toMatchObject({ active: 1, mounts: 1, failures: 0 }); + }); + + test("ignores paths that are not native roots", async () => { + const { roots, commands } = harness({ dirs: ["src", "dist/assets", "node_modules/react"] }); + for (const path of ["src", "dist/assets", "node_modules/react"]) roots.ensure(path); + await roots.settled(); + expect(commands).toEqual([]); + }); + + test("never mounts over a symlink or a file", async () => { + const { roots, commands } = harness({ + symlinks: ["a/node_modules"], + files: ["b/node_modules"], + }); + roots.ensure("a/node_modules"); + roots.ensure("b/node_modules"); + await roots.settled(); + expect(commands).toEqual([]); + }); + + test("does nothing when the backing directory is already gone", async () => { + const { roots, commands, warnings } = harness(); + roots.ensure("node_modules"); + await roots.settled(); + expect(commands).toEqual([]); + expect(warnings).toEqual([]); + }); + + test("concurrent calls for one root mount once", async () => { + const { roots, commands } = harness({ dirs: ["node_modules"] }); + roots.ensure("node_modules"); + roots.ensure("node_modules"); + roots.ensure("/node_modules/"); + await roots.settled(); + expect(commands.filter((argv) => argv[0] === "bindRoot")).toHaveLength(1); + }); + + test("an already mounted root is left alone", async () => { + const { roots, commands } = harness({ dirs: ["node_modules"] }); + roots.ensure("node_modules"); + await roots.settled(); + roots.ensure("node_modules"); + await roots.settled(); + expect(commands.filter((argv) => argv[0] === "bindRoot")).toHaveLength(1); + }); + + test("an unavailable native helper never falls back to a path-based mount", async () => { + const { roots, commands, mounted } = harness({ dirs: ["node_modules"], mountExit: 38 }); + roots.ensure("node_modules"); + await roots.settled(); + expect(commands).toEqual([["bindRoot", ROOT, MOUNT, "node_modules"]]); + expect(mounted.size).toBe(0); + expect(roots.stats()).toMatchObject({ mounts: 0, failures: 1 }); + }); + + test("a refused mount is counted and warned about once", async () => { + const { roots, warnings } = harness({ + dirs: ["a/node_modules", "b/node_modules"], + mountExit: 32, + }); + roots.ensure("a/node_modules"); + roots.ensure("b/node_modules"); + await roots.settled(); + expect(roots.stats()).toMatchObject({ active: 0, mounts: 0, failures: 2 }); + expect(roots.stats().lastFailure).toContain("failed (-32)"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("serving it through FUSE instead"); + }); + + test("a refused root is retried when created again, not when looked up", async () => { + const h = harness({ dirs: ["node_modules"], mountExit: 32 }); + h.roots.ensure("node_modules", true); + await h.roots.settled(); + h.roots.ensure("node_modules"); + h.roots.ensure("node_modules", false); + await h.roots.settled(); + const binds = () => h.commands.filter((argv) => argv[0] === "bindRoot").length; + expect(binds()).toBe(1); + h.roots.ensure("node_modules", true); + await h.roots.settled(); + expect(binds()).toBe(2); + }); + + test("a target that does not exist under the mount yet is not a failure", async () => { + // After a restore with an in-memory store the backing directory is on + // disk, but its synced parent is not back from the host yet. + const missingTargets = new Set(["app/node_modules"]); + const { roots, commands, warnings } = harness({ dirs: ["app/node_modules"], missingTargets }); + roots.ensure("app/node_modules"); + await roots.settled(); + expect(commands).toEqual([]); + expect(roots.stats().failures).toBe(0); + expect(warnings).toEqual([]); + // The first lookup once the parent is back mounts it. + missingTargets.clear(); + roots.ensure("app/node_modules"); + await roots.settled(); + expect(roots.stats()).toMatchObject({ active: 1, mounts: 1 }); + }); + + test("a native verification failure leaves no mount", async () => { + const { roots, commands, mounted } = harness({ dirs: ["node_modules"], mismatch: true }); + roots.ensure("node_modules"); + await roots.settled(); + expect(commands).toEqual([["bindRoot", ROOT, MOUNT, "node_modules"]]); + expect(mounted.size).toBe(0); + expect(roots.stats()).toMatchObject({ mounts: 0, failures: 1 }); + }); + + test("does nothing until switched on", async () => { + let on = false; + const { roots, commands } = harness({ dirs: ["node_modules"], active: () => on }); + roots.ensure("node_modules"); + roots.scan(); + await roots.settled(); + expect(commands).toEqual([]); + on = true; + roots.ensure("node_modules"); + await roots.settled(); + expect(commands).toHaveLength(1); + }); +}); + +describe("scan", () => { + test("scan does not descend through a symlink in the backing tree", async () => { + const { roots, commands } = harness({ + symlinks: ["q"], + dirs: ["q/node_modules"], + }); + roots.scan(); + await roots.settled(); + expect(commands).toEqual([]); + }); + + test("scan mounts every existing root without descending into one", async () => { + const readdirs: string[] = []; + const h = harness({ + dirs: [ + "node_modules/react/node_modules/x", + "packages/a/node_modules/lodash", + "packages/b/src", + "dist/assets", + ], + }); + const roots = createNativeRoots({ + root: ROOT, + mountPoint: MOUNT, + ignore: resolveMountIgnore(["**/node_modules", "/dist"], MOUNT), + bindRoot: async (source, target, rel) => { + h.commands.push(["bindRoot", source, target, rel]); + return 0; + }, + mountinfo: () => "", + lstat: () => ({ isDirectory: () => true, dev: 1, ino: 1 }), + lstatTarget: async () => ({ isDirectory: () => true }), + readdir: (path) => { + readdirs.push(path); + const tree: Record = { + [ROOT]: ["node_modules", "packages", "dist"], + [`${ROOT}/packages`]: ["a", "b"], + [`${ROOT}/packages/a`]: ["node_modules"], + [`${ROOT}/packages/b`]: ["src"], + [`${ROOT}/packages/b/src`]: [], + }; + return tree[path] ?? []; + }, + }); + roots.scan(); + await roots.settled(); + const bound = h.commands + .filter((argv) => argv[0] === "bindRoot") + .map((argv) => `${MOUNT}/${argv[3]}`); + expect(bound.sort()).toEqual([ + `${MOUNT}/dist`, + `${MOUNT}/node_modules`, + `${MOUNT}/packages/a/node_modules`, + ]); + // Never inside a root. + expect(readdirs.some((path) => path.includes("node_modules"))).toBe(false); + expect(readdirs.some((path) => path.includes("dist"))).toBe(false); + }); +}); + +describe("parking", () => { + test("a root a caller detached is not mounted back by a lookup", async () => { + // Otherwise `rm -rf` after a detach stats the directory, the lookup + // mounts it again, and the rmdir gets EBUSY after all. + const { roots, mounted } = harness({ dirs: ["node_modules"] }); + roots.ensure("node_modules", true); + await roots.settled(); + await roots.detach("node_modules"); + roots.ensure("node_modules"); + await roots.settled(); + expect(mounted.size).toBe(0); + roots.ensure("node_modules", true); + await roots.settled(); + expect(mounted.size).toBe(1); + }); + + test("detachAll parks nothing, so a startup scan still mounts", async () => { + const { roots, mounted } = harness({ dirs: ["node_modules"] }); + roots.ensure("node_modules"); + await roots.settled(); + await roots.detachAll(); + roots.scan(); + await roots.settled(); + expect(mounted.size).toBe(1); + }); + + test("a mount whose target vanished on the way is not a failure", async () => { + const missingTargets = new Set(); + const h = harness({ dirs: ["node_modules"], mountExit: 32, missingTargets }); + // The target exists for the pre-check, then is gone when the mount + // fails, as when rm -rf removes it in between. + let checks = 0; + const roots = createNativeRoots({ + root: ROOT, + mountPoint: MOUNT, + ignore: resolveMountIgnore(["**/node_modules"], MOUNT), + bindRoot: async () => -2, + mountinfo: () => "", + lstat: () => ({ isDirectory: () => true, dev: 1, ino: 1 }), + lstatTarget: async () => { + checks += 1; + if (checks > 1) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + return { isDirectory: () => true }; + }, + warn: (message) => h.warnings.push(message), + }); + roots.ensure("node_modules"); + await roots.settled(); + expect(roots.stats().failures).toBe(0); + expect(h.warnings).toEqual([]); + }); +}); + +describe("removal guard", () => { + function fakeGuard() { + const roots = new Map(); + const removed: string[] = []; + return { + roots, + removed, + guard: { + addRoot: (path: string, fd: number) => { + roots.set(path, fd); + return 0; + }, + removeRoot: (path: string) => { + removed.push(path); + return roots.delete(path); + }, + }, + }; + } + + test("attaching hands over roots already mounted, and later mounts follow", async () => { + const { roots } = harness({ dirs: ["a/node_modules", "b/node_modules"] }); + roots.ensure("a/node_modules"); + await roots.settled(); + const g = fakeGuard(); + await roots.attachGuard(g.guard); + expect([...g.roots.keys()]).toEqual([`${MOUNT}/a/node_modules`]); + roots.ensure("b/node_modules"); + await roots.settled(); + expect([...g.roots.keys()].sort()).toEqual([ + `${MOUNT}/a/node_modules`, + `${MOUNT}/b/node_modules`, + ]); + }); + + test("a requested detach takes the root back from the guard", async () => { + const { roots } = harness({ dirs: ["node_modules"] }); + const g = fakeGuard(); + await roots.attachGuard(g.guard); + roots.ensure("node_modules"); + await roots.settled(); + await roots.detach("node_modules"); + expect(g.removed).toEqual([`${MOUNT}/node_modules`]); + expect(g.roots.size).toBe(0); + }); + + test("a guard detach parks the root and is counted", async () => { + const h = harness({ dirs: ["node_modules"] }); + h.roots.ensure("node_modules"); + await h.roots.settled(); + // The guard unmounted it itself, from its own thread. + h.mounted.clear(); + h.roots.guardDetached(`${MOUNT}/node_modules`); + expect(h.roots.stats()).toMatchObject({ detaches: 1, removalDetaches: 1, active: 0 }); + h.roots.ensure("node_modules"); + await h.roots.settled(); + expect(h.mounted.size).toBe(0); + h.roots.ensure("node_modules", true); + await h.roots.settled(); + expect(h.mounted.size).toBe(1); + }); + + test("a root parked while its mount was on the way is not mounted", async () => { + // The guard detached it for an rmdir while this mount waited on the + // thread pool; mounting now would put it back under the rmdir. + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const commands: string[][] = []; + const roots = createNativeRoots({ + root: ROOT, + mountPoint: MOUNT, + ignore: resolveMountIgnore(["**/node_modules"], MOUNT), + bindRoot: async (source, target, rel) => { + commands.push(["bindRoot", source, target, rel]); + return 0; + }, + mountinfo: () => "", + lstat: () => ({ isDirectory: () => true, dev: 1, ino: 1 }), + lstatTarget: async () => { + await gate; + return { isDirectory: () => true }; + }, + }); + roots.ensure("node_modules"); + roots.guardDetached(`${MOUNT}/node_modules`); + release(); + await roots.settled(); + expect(commands).toEqual([]); + }); +}); + +describe("detach", () => { + test("a requested detach parks a root while its target lookup is pending", async () => { + let entered!: () => void; + let release!: () => void; + const lookupStarted = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const { roots, mounted, commands } = harness({ + dirs: ["node_modules"], + beforeTarget: async () => { + entered(); + await gate; + }, + }); + roots.ensure("node_modules"); + await lookupStarted; + const detaching = roots.detach("node_modules"); + release(); + expect(await detaching).toEqual([]); + await roots.settled(); + expect(mounted.size).toBe(0); + expect(commands).toEqual([]); + }); + + test("a requested detach waits for an in-flight bind before removing it", async () => { + let entered!: () => void; + let release!: () => void; + const bindStarted = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const { roots, mounted } = harness({ + dirs: ["node_modules"], + beforeBind: async () => { + entered(); + await gate; + }, + }); + roots.ensure("node_modules"); + await bindStarted; + const detaching = roots.detach("node_modules"); + release(); + expect(await detaching).toEqual([`${MOUNT}/node_modules`]); + expect(mounted.size).toBe(0); + }); + + test("a requested detach of all roots parks pending mounts", async () => { + let entered!: () => void; + let release!: () => void; + const lookupStarted = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const { roots, mounted } = harness({ + dirs: ["node_modules"], + beforeTarget: async () => { + entered(); + await gate; + }, + }); + roots.ensure("node_modules"); + await lookupStarted; + const detaching = roots.detach(); + release(); + expect(await detaching).toEqual([]); + roots.ensure("node_modules"); + await roots.settled(); + expect(mounted.size).toBe(0); + }); + + test("detachAll waits for in-flight binds before cleaning up", async () => { + let entered!: () => void; + let release!: () => void; + const bindStarted = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const { roots, mounted } = harness({ + dirs: ["node_modules"], + beforeBind: async () => { + entered(); + await gate; + }, + }); + roots.ensure("node_modules"); + await bindStarted; + const detaching = roots.detachAll(); + release(); + expect(await detaching).toEqual([`${MOUNT}/node_modules`]); + expect(mounted.size).toBe(0); + }); + + test("detachAll lazily detaches everything under the mount point, deepest first", async () => { + const { roots, commands, mounted } = harness({ dirs: ["a/node_modules", "a/b/node_modules"] }); + roots.ensure("a/node_modules"); + roots.ensure("a/b/node_modules"); + await roots.settled(); + commands.length = 0; + const detached = await roots.detachAll(); + expect(detached).toEqual([`${MOUNT}/a/b/node_modules`, `${MOUNT}/a/node_modules`]); + expect(commands).toEqual([ + ["unbindRoot", MOUNT, "a/b/node_modules"], + ["unbindRoot", MOUNT, "a/node_modules"], + ]); + expect(mounted.size).toBe(0); + expect(roots.stats()).toMatchObject({ active: 0, detaches: 2 }); + }); + + test("detach of one path leaves the others, and an unknown path is a no-op", async () => { + const { roots, mounted } = harness({ dirs: ["a/node_modules", "b/node_modules"] }); + roots.ensure("a/node_modules"); + roots.ensure("b/node_modules"); + await roots.settled(); + expect(await roots.detach("a/node_modules")).toEqual([`${MOUNT}/a/node_modules`]); + expect(await roots.detach("nope/node_modules")).toEqual([]); + expect([...mounted]).toEqual([`${MOUNT}/b/node_modules`]); + }); + + test("never touches the FUSE mount itself", async () => { + const { roots, commands } = harness(); + await roots.detachAll(); + expect(commands).toEqual([]); + }); +}); + +test("unescapeMountinfo decodes octal escapes", () => { + expect(unescapeMountinfo("/workspace/my\\040app/node_modules")).toBe( + "/workspace/my app/node_modules", + ); + expect(unescapeMountinfo("/a\\134b")).toBe("/a\\b"); +}); diff --git a/packages/computerd/src/fuse/native-roots.ts b/packages/computerd/src/fuse/native-roots.ts new file mode 100644 index 000000000..d0cec1964 --- /dev/null +++ b/packages/computerd/src/fuse/native-roots.ts @@ -0,0 +1,407 @@ +// Native roots: bind-mounts local-only directories onto the mount. See +// packages/computerd/README.md#native-roots. +// +// The local-only layer in passthrough.ts already stores each local-only +// path at MOUNT_IGNORE_PATH/ and serves it through FUSE. A native +// root bind-mounts that same directory at MOUNT_POINT/, so lookups, +// stats, creates, and listings under it stop reaching this process. The +// source is the directory the FUSE layer serves anyway, which is what +// keeps this simple: +// +// - A mount that lands late, fails, or is detached changes how a path is +// served, never what it contains. Nothing here is on a correctness +// path, so every failure is "leave it to the FUSE layer". +// - /proc/self/mountinfo is the only mount state. This module treats every +// mount strictly below MOUNT_POINT as a native root, including at restart. +// Other code must not create mounts there: detachAll would detach them. +// - Lazily detaching one of our native roots leaves the same backing +// directory served through FUSE. This does not apply to unrelated mounts. +// - Removing or renaming a root itself fails with EBUSY in the kernel. For +// commands computerd runs, the removal guard (vendor/fuse-napi, +// removal-guard.c) detaches the mount first; this module hands it each +// root's descriptor and parks whatever it detaches. +// - Renaming a synced directory needs nothing: the kernel moves a mount +// along with the dentry it sits on. (Re-mounting from the rename reply +// would race the kernel's own move and stack a second mount.) +// +// One rule is load-bearing: no synchronous filesystem call on the main +// thread may touch a path under MOUNT_POINT. Resolving such a path sends +// FUSE requests that this same thread has to answer, so a synchronous +// call there deadlocks the daemon. Paths under the backing root and +// /proc are fine. The native worker resolves and mounts the target off +// the main thread, using pinned directory descriptors. + +import { lstatSync, readdirSync, readFileSync, promises as fsPromises } from "node:fs"; +import { dirname, join } from "node:path"; + +import type { MountIgnoreSet } from "./ignore.js"; + +export interface NativeRootsOptions { + /** Resolved MOUNT_IGNORE_PATH: where local-only paths are stored. */ + readonly root: string; + /** The FUSE mount point. */ + readonly mountPoint: string; + /** The decided ignore set. */ + readonly ignore: MountIgnoreSet; + /** + * Checked on every `ensure`. Lets the daemon build this before the + * FUSE mount exists and switch it on once passthrough is negotiated. + */ + readonly active?: () => boolean; + /** Native operation; injected in tests. Defaults to the confined addon. */ + readonly bindRoot?: (sourceRoot: string, targetRoot: string, rel: string) => Promise; + readonly unbindRoot?: (targetRoot: string, rel: string) => Promise; + readonly mountinfo?: () => string; + readonly lstat?: (path: string) => { isDirectory(): boolean; dev: number; ino: number }; + /** Asynchronous lstat of a path under the mount point. */ + readonly lstatTarget?: (path: string) => Promise<{ isDirectory(): boolean }>; + readonly readdir?: (path: string) => string[]; + readonly warn?: (message: string) => void; + /** Opens a directory under the mount, off the main thread. Injected for tests. */ + readonly openDirectory?: (path: string) => Promise; +} + +/** + * The in-process removal guard (vendor/fuse-napi/removal-guard.c), as this + * module drives it. It owns the descriptors it is given. + */ +export interface RemovalGuard { + addRoot(path: string, fd: number): number; + removeRoot(path: string): boolean; +} + +export interface NativeRootsStats { + /** Native roots mounted right now, read from mountinfo. */ + readonly active: number; + /** Mounts that succeeded since start. */ + readonly mounts: number; + /** Mounts that were refused or failed verification. */ + readonly failures: number; + /** Lazy detaches issued since start, by request or by the removal guard. */ + readonly detaches: number; + /** Detaches the removal guard made so a removal or rename could go through. */ + readonly removalDetaches: number; + /** The most recent failure, for the info endpoint. */ + readonly lastFailure: string | undefined; +} + +export interface NativeRoots { + /** + * Mount `rel` if it is a native root. Fire-and-forget; never throws. + * `created` says the directory was just made. A lookup (`created` + * false) skips a parked root: one whose mount was refused, or one a + * caller detached so it could remove it. Creating it again unparks it. + */ + ensure(relativePath: string, created?: boolean): void; + /** Ensure every native root that already exists on the backing disk. */ + scan(): void; + /** Resolves once every mount started so far has settled. For tests. */ + settled(): Promise; + /** + * Lazily detach one native root, or all of them, on a caller's request, + * and park them so a lookup does not mount them straight back while the + * caller removes or renames them. Never throws. + */ + detach(relativePath?: string): Promise; + /** Lazily detach everything, at startup and shutdown. Parks nothing. */ + detachAll(): Promise; + /** Whether `rel` is a native root: a local-only directory whose parent is synced. */ + isRoot(relativePath: string): boolean; + /** + * Hands every mounted root, now and from here on, to the removal guard. + * Call once the guard has started. + */ + attachGuard(guard: RemovalGuard): Promise; + /** + * The removal guard detached `target` so a removal or rename could go + * through. Parks it, as a requested detach does. + */ + guardDetached(target: string): void; + stats(): NativeRootsStats; +} + +export function createNativeRoots(options: NativeRootsOptions): NativeRoots { + const root = trimTrailing(options.root); + const mountPoint = trimTrailing(options.mountPoint) || "/"; + const unbindRoot = + options.unbindRoot ?? + (async (targetRoot: string, rel: string) => { + const { default: Fuse } = await import("fuse-napi"); + if (typeof Fuse.unbindRoot !== "function") return -38; + return Fuse.unbindRoot(targetRoot, rel); + }); + const bindRoot = + options.bindRoot ?? + (async (sourceRoot: string, targetRoot: string, rel: string) => { + const { default: Fuse } = await import("fuse-napi"); + if (typeof Fuse.bindRoot !== "function") return -38; // ENOSYS; never fall back to path mount. + return Fuse.bindRoot(sourceRoot, targetRoot, rel); + }); + const readMountinfo = options.mountinfo ?? (() => readFileSync("/proc/self/mountinfo", "utf8")); + const lstat = options.lstat ?? ((path: string) => lstatSync(path)); + const lstatTarget = options.lstatTarget ?? ((path: string) => fsPromises.lstat(path)); + const readdir = options.readdir ?? ((path: string) => readdirSync(path)); + const warn = options.warn ?? ((message: string) => console.warn(message)); + const active = options.active ?? (() => true); + + const inflight = new Map>(); + let suspended = 0; + // Roots a lookup must not mount: refused ones, and ones a caller + // detached on purpose. Creating the directory again unparks it. + const parked = new Set(); + let mounts = 0; + let failures = 0; + let detaches = 0; + let lastFailure: string | undefined; + let removalDetaches = 0; + let guard: RemovalGuard | undefined; + const openDirectory = + options.openDirectory ?? + (async (path: string) => { + const { default: Fuse } = await import("fuse-napi"); + if (typeof Fuse.openRoot !== "function") throw new Error("confined open unavailable"); + const fd = await Fuse.openRoot(mountPoint, stripSlashes(path.slice(mountPoint.length))); + if (fd < 0) throw new Error(`confined open failed (${fd})`); + return fd; + }); + + // Through the thread pool: the target is under the mount. + const registerWithGuard = async (target: string): Promise => { + const attachedGuard = guard; + if (attachedGuard === undefined) return; + let fd: number; + try { + fd = await openDirectory(target); + } catch { + return; // Gone again; nothing to guard. + } + attachedGuard.addRoot(target, fd); // The guard owns the descriptor, even on failure. + }; + + const targetOf = (rel: string): string => join(mountPoint, rel); + const relOf = (target: string): string => stripSlashes(target.slice(mountPoint.length)); + const sourceOf = (rel: string): string => join(root, rel); + + const isRoot = (relativePath: string): boolean => { + const rel = stripSlashes(relativePath); + if (rel === "") return false; + return options.ignore.ignores(rel) && !options.ignore.ignores(parentOf(rel)); + }; + + // Treat every mount strictly below the mount point as owned. The FUSE + // mount itself sits at the mount point and is excluded. + const owned = (): string[] => { + let text: string; + try { + text = readMountinfo(); + } catch { + return []; + } + const prefix = mountPoint === "/" ? "/" : `${mountPoint}/`; + const targets: string[] = []; + for (const line of text.split("\n")) { + const field = line.split(" ")[4]; + if (field === undefined) continue; + const target = unescapeMountinfo(field); + if (target !== mountPoint && target.startsWith(prefix)) targets.push(target); + } + return targets; + }; + + const recordFailure = (rel: string, reason: string): void => { + failures += 1; + parked.add(rel); + lastFailure = `${rel}: ${reason}`; + // Once per process. A refused mount is refused for every root, and a + // line per root would bury the log during an install. + if (failures === 1) { + warn( + `computerd: could not mount native root ${rel} (${reason}); serving it through ` + + `FUSE instead, which works but is slower. Further failures are counted in ` + + `/__computerd/stats under localPaths.nativeRoots.`, + ); + } + }; + + const stillThere = async (source: string, target: string): Promise => { + try { + lstat(source); + return (await lstatTarget(target)).isDirectory(); + } catch { + return false; + } + }; + + async function mountOne(rel: string): Promise { + const source = sourceOf(rel); + const target = targetOf(rel); + let sourceStat: { isDirectory(): boolean }; + try { + // lstat, so a symlink at the root path is never mounted over. + sourceStat = lstat(source); + } catch { + return; // Removed again before we got here. + } + if (!sourceStat.isDirectory()) return; + if (owned().includes(target)) return; + // The synced parent may not exist yet: after a restore with an + // in-memory store, the host pushes it back later. That is "not yet", + // not a failure; the first lookup after it arrives mounts the root. + // Asynchronous, because the target is under the mount. + try { + if (!(await lstatTarget(target)).isDirectory()) return; + } catch { + return; + } + // Parked while we waited: the removal guard or a caller detached it so + // it could be removed. Mounting now would put it back under them. + if (parked.has(rel)) return; + + // The addon resolves both paths beneath pinned directory descriptors and + // attaches a private cloned mount with move_mount. A JavaScript realpath + // precheck or an external mount command cannot close this race. + const bound = await bindRoot(root, mountPoint, rel); + if (bound !== 0) { + if (bound === -2 && !(await stillThere(source, target))) return; // ENOENT race + recordFailure(rel, `confined bind failed (${bound})`); + return; + } + // The helper checked the detached clone's identity before attaching it + // through the pinned target descriptor. A rename can move that mount + // after it returns; mountinfo then lists the new path. + mounts += 1; + await registerWithGuard(target); + } + + const ensure = (relativePath: string, created = false): void => { + if (!active() || suspended > 0) return; + const rel = stripSlashes(relativePath); + if (!isRoot(rel) || inflight.has(rel)) return; + if (parked.has(rel)) { + if (!created) return; + parked.delete(rel); + } + const pending = mountOne(rel) + .catch((error: unknown) => recordFailure(rel, String(error))) + .finally(() => inflight.delete(rel)); + inflight.set(rel, pending); + }; + + // Walks the backing tree: synced ancestors of local paths exist there as + // plain directories. Never descends into a local-only directory, so the + // cost is the scaffolding, not the dependency tree. + const walk = (relativePath: string, visit: (rel: string) => void): void => { + const rel = stripSlashes(relativePath); + let names: string[]; + try { + names = readdir(rel === "" ? root : sourceOf(rel)); + } catch { + return; + } + for (const name of names) { + const child = rel === "" ? name : `${rel}/${name}`; + if (isRoot(child)) { + visit(child); + } else if (!options.ignore.ignores(child)) { + try { + // A synced scaffold can be a symlink after restore. Never scan + // through it into an unrelated directory (or a symlink cycle). + if (lstat(sourceOf(child)).isDirectory()) walk(child, visit); + } catch { + // Removed or changed during the scan. The next lookup can retry. + } + } + } + }; + + const settled = async (): Promise => { + while (inflight.size > 0) await Promise.all([...inflight.values()]); + }; + + const detachTargets = async (targets: string[]): Promise => { + const detached: string[] = []; + // Deepest first, so a parent is never detached out from under a child. + for (const target of [...targets].sort((a, b) => b.length - a.length)) { + // Closes the guard's descriptor, which would otherwise keep the + // detached mount alive. + guard?.removeRoot(target); + if ((await unbindRoot(mountPoint, relOf(target))) === 0) { + detaches += 1; + detached.push(target); + } + } + return detached; + }; + + const detachAllTargets = async (park: boolean): Promise => { + // Prevent new binds while existing workers settle. A requested detach + // parks roots; startup/shutdown cleanup leaves them available to scan. + suspended += 1; + try { + if (park) for (const rel of inflight.keys()) parked.add(rel); + await settled(); + const targets = owned(); + if (park) for (const target of targets) parked.add(relOf(target)); + return await detachTargets(targets); + } finally { + suspended -= 1; + } + }; + + return { + ensure, + scan: () => { + if (active()) walk("", (rel) => ensure(rel)); + }, + settled, + detach: async (relativePath) => { + if (relativePath === undefined) return detachAllTargets(true); + const rel = stripSlashes(relativePath); + if (isRoot(rel)) parked.add(rel); + await inflight.get(rel); + return detachTargets(owned().filter((target) => target === targetOf(rel))); + }, + detachAll: () => detachAllTargets(false), + isRoot, + attachGuard: async (attached) => { + guard = attached; + await Promise.all(owned().map((target) => registerWithGuard(target))); + }, + guardDetached: (target) => { + parked.add(relOf(target)); + detaches += 1; + removalDetaches += 1; + }, + stats: () => ({ + active: owned().length, + mounts, + failures, + detaches, + removalDetaches, + lastFailure, + }), + }; +} + +/** mountinfo escapes space, tab, newline, and backslash as octal. */ +export function unescapeMountinfo(field: string): string { + return field.replace(/\\([0-7]{3})/g, (_, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)), + ); +} + +function parentOf(rel: string): string { + const parent = dirname(rel); + return parent === "." ? "" : parent; +} + +function stripSlashes(value: string): string { + let out = value; + while (out.startsWith("/")) out = out.slice(1); + while (out.endsWith("/")) out = out.slice(0, -1); + return out; +} + +function trimTrailing(value: string): string { + return value.replace(/\/+$/, ""); +} diff --git a/packages/computerd/src/fuse/options.test.ts b/packages/computerd/src/fuse/options.test.ts index 4de7567c4..1af90f3bc 100644 --- a/packages/computerd/src/fuse/options.test.ts +++ b/packages/computerd/src/fuse/options.test.ts @@ -1,182 +1,184 @@ import { describe, expect, test } from "vitest"; -import { buildFuseOptionString, type FuseOptionEnv } from "./options"; +import { + buildFuseInitConfig, + buildFuseMountOptions, + type FuseOptionEnv, + nativeRootsRequested, + passthroughRequested, +} from "./options.js"; const empty: FuseOptionEnv = {}; -describe("buildFuseOptionString", () => { +describe("buildFuseMountOptions", () => { test("emits the production-safe profile when no env vars are set", () => { - // Defaults derived from the handoff benchmark report and the - // mtime-propagation contract tests on the apply path. auto_cache - // is the production-safe page-cache option: the kernel keeps - // cached pages until the file is reopened with a different - // mtime or size, at which point it drops them and re-reads - // through FUSE. attr_timeout, entry_timeout, and - // ac_attr_timeout at one second cut metadata round-trips for - // tools that stat repeatedly (find, ls -l, git status) without - // letting a stale view linger. negative_timeout at zero keeps - // "file not found" answers fresh so a just-written file shows - // up immediately. use_ino lets hardlinks stat as the same inode. - // big_writes plus 512 KiB max_read and max_write match the dofs - // CHUNK_SIZE so each FUSE read maps to one chunk fetch. - expect(buildFuseOptionString(empty)).toBe( - "big_writes,use_ino,max_write=524288,max_read=524288,auto_cache,attr_timeout=1,entry_timeout=1,negative_timeout=0,ac_attr_timeout=1", - ); + expect(buildFuseMountOptions(empty)).toEqual({ + useIno: true, + maxRead: 524288, + attrTimeout: 1, + entryTimeout: 1, + negativeTimeout: 0, + }); + }); + + test("max_write moves to the init config, not the mount options", () => { + expect(buildFuseMountOptions(empty)).not.toHaveProperty("maxWrite"); + expect(buildFuseInitConfig(empty).maxWrite).toBe(524288); + }); + + test("requests a backing stack depth so an overlayfs store can register", () => { + expect(buildFuseInitConfig(empty).maxBackingStackDepth).toBe(1); }); test("overrides max_read and max_write from the environment", () => { - const out = buildFuseOptionString({ + const env = { COMPUTERD_FUSE_MAX_READ: "1048576", COMPUTERD_FUSE_MAX_WRITE: "1048576", - }); - expect(out).toContain("max_write=1048576"); - expect(out).toContain("max_read=1048576"); - expect(out).not.toContain("max_write=524288"); + }; + expect(buildFuseMountOptions(env).maxRead).toBe(1048576); + expect(buildFuseInitConfig(env).maxWrite).toBe(1048576); }); test("ignores non-numeric size overrides and falls back to the default", () => { - const out = buildFuseOptionString({ COMPUTERD_FUSE_MAX_READ: "wat" }); - expect(out).toContain("max_read=524288"); + expect(buildFuseMountOptions({ COMPUTERD_FUSE_MAX_READ: "wat" }).maxRead).toBe(524288); }); test("rejects non-positive sizes", () => { - const a = buildFuseOptionString({ COMPUTERD_FUSE_MAX_READ: "0" }); - expect(a).toContain("max_read=524288"); - const b = buildFuseOptionString({ COMPUTERD_FUSE_MAX_WRITE: "-1" }); - expect(b).toContain("max_write=524288"); - }); - - test("keeps auto_cache when COMPUTERD_FUSE_AUTO_CACHE is truthy or unset", () => { - expect(buildFuseOptionString({ COMPUTERD_FUSE_AUTO_CACHE: "1" })).toContain("auto_cache"); - expect(buildFuseOptionString({ COMPUTERD_FUSE_AUTO_CACHE: "true" })).toContain("auto_cache"); - expect(buildFuseOptionString(empty)).toContain("auto_cache"); - }); - - test("drops auto_cache when COMPUTERD_FUSE_AUTO_CACHE is explicitly disabled", () => { - // Operators who hit a correctness issue under auto_cache need - // a way to turn it off without rebuilding. Explicit "0" / - // "false" / "no" / "off" disables it; an empty string is - // treated the same way because that's what an unset env var - // looks like once it reaches the shell. - expect(buildFuseOptionString({ COMPUTERD_FUSE_AUTO_CACHE: "0" })).not.toContain("auto_cache"); - expect(buildFuseOptionString({ COMPUTERD_FUSE_AUTO_CACHE: "false" })).not.toContain( - "auto_cache", - ); - expect(buildFuseOptionString({ COMPUTERD_FUSE_AUTO_CACHE: "" })).not.toContain("auto_cache"); + expect(buildFuseMountOptions({ COMPUTERD_FUSE_MAX_READ: "0" }).maxRead).toBe(524288); + expect(buildFuseInitConfig({ COMPUTERD_FUSE_MAX_WRITE: "-1" }).maxWrite).toBe(524288); + }); + + test("never sets auto_cache or kernel_cache", () => { + for (const env of [ + empty, + { COMPUTERD_FUSE_EXTRA_OPTS: "auto_cache" }, + { COMPUTERD_FUSE_EXTRA_OPTS: "kernel_cache" }, + ]) { + const out = buildFuseMountOptions(env); + expect(out).not.toHaveProperty("autoCache"); + expect(out).not.toHaveProperty("kernelCache"); + } + }); + + test("strips options that would fail the mount", () => { + const out = buildFuseMountOptions({ + COMPUTERD_FUSE_EXTRA_OPTS: + "big_writes,max_write=4096,writeback_cache,ac_attr_timeout=3,allow_other", + }); + expect(Object.keys(out)).not.toContain("bigWrites"); + expect(Object.keys(out)).not.toContain("maxWrite"); + expect(Object.keys(out)).not.toContain("writebackCache"); + expect(Object.keys(out)).not.toContain("acAttrTimeout"); + expect(out.allowOther).toBe(true); }); - test("drops metadata timeouts when explicitly disabled with zero", () => { - // Same opt-out story as auto_cache. "0" or empty turns the - // default off; anything positive overrides the default value. - const off = buildFuseOptionString({ + test("drops metadata timeouts when explicitly disabled with an empty value", () => { + const off = buildFuseMountOptions({ COMPUTERD_FUSE_ATTR_TIMEOUT: "", COMPUTERD_FUSE_ENTRY_TIMEOUT: "", - COMPUTERD_FUSE_AC_ATTR_TIMEOUT: "", - }); - expect(off).not.toContain("attr_timeout"); - expect(off).not.toContain("entry_timeout"); - expect(off).not.toContain("ac_attr_timeout"); - }); - - test("COMPUTERD_FUSE_KERNEL_CACHE=1 alone loses to the default auto_cache", () => { - // The auto_cache default is sticky. Setting only kernel_cache - // looks like asking for both, and the mutual-exclusion rule - // resolves in favor of auto_cache. Operators who want the - // fast / single-writer profile must also disable auto_cache, - // which the next test pins. - const out = buildFuseOptionString({ COMPUTERD_FUSE_KERNEL_CACHE: "1" }); - expect(out).toContain("auto_cache"); - expect(out).not.toContain("kernel_cache"); - }); - - test("treats auto_cache and kernel_cache as mutually exclusive, with auto_cache winning", () => { - // libfuse 2.9 documents kernel_cache and auto_cache as incompatible: - // auto_cache implies the page cache is valid until invalidated, and - // kernel_cache implies the page cache is never invalidated. Asking for - // both is a configuration mistake. We prefer the safer (auto_cache) - // option and warn-by-fact: the option string will say auto_cache only. - const out = buildFuseOptionString({ - COMPUTERD_FUSE_AUTO_CACHE: "1", - COMPUTERD_FUSE_KERNEL_CACHE: "1", + COMPUTERD_FUSE_NEGATIVE_TIMEOUT: "", }); - expect(out).toContain("auto_cache"); - expect(out).not.toContain("kernel_cache"); - }); - - test("kernel_cache replaces the default auto_cache when only kernel_cache is set", () => { - // Single-writer / fast profile: opt out of auto_cache and opt - // in to kernel_cache in one move. Without the explicit - // auto_cache=0 the operator would get both env vars truthy - // and silently fall back to the safer default, defeating the - // intent. Document the working incantation here. - const out = buildFuseOptionString({ - COMPUTERD_FUSE_AUTO_CACHE: "0", - COMPUTERD_FUSE_KERNEL_CACHE: "1", - }); - expect(out).toContain("kernel_cache"); - expect(out).not.toContain("auto_cache"); + expect(off).not.toHaveProperty("attrTimeout"); + expect(off).not.toHaveProperty("entryTimeout"); + expect(off).not.toHaveProperty("negativeTimeout"); }); test("overrides default metadata timeouts when explicit values are set", () => { - const out = buildFuseOptionString({ + const out = buildFuseMountOptions({ COMPUTERD_FUSE_ATTR_TIMEOUT: "5", COMPUTERD_FUSE_ENTRY_TIMEOUT: "4", COMPUTERD_FUSE_NEGATIVE_TIMEOUT: "2", - COMPUTERD_FUSE_AC_ATTR_TIMEOUT: "3", }); - expect(out).toContain("attr_timeout=5"); - expect(out).toContain("entry_timeout=4"); - expect(out).toContain("negative_timeout=2"); - expect(out).toContain("ac_attr_timeout=3"); + expect(out).toMatchObject({ attrTimeout: 5, entryTimeout: 4, negativeTimeout: 2 }); }); test("accepts fractional timeouts because libfuse documents them that way", () => { - const out = buildFuseOptionString({ COMPUTERD_FUSE_ATTR_TIMEOUT: "0.5" }); - expect(out).toContain("attr_timeout=0.5"); + expect(buildFuseMountOptions({ COMPUTERD_FUSE_ATTR_TIMEOUT: "0.5" }).attrTimeout).toBe(0.5); }); - test("rejects non-numeric timeouts", () => { - // The substring "attr_timeout=" with the equals sign avoids - // matching the ac_attr_timeout default that still emits. - const out = buildFuseOptionString({ COMPUTERD_FUSE_ATTR_TIMEOUT: "nope" }); - expect(out).not.toMatch(/(?:^|,)attr_timeout=/); + test("rejects non-numeric and negative timeouts", () => { + expect(buildFuseMountOptions({ COMPUTERD_FUSE_ATTR_TIMEOUT: "nope" })).not.toHaveProperty( + "attrTimeout", + ); + expect(buildFuseMountOptions({ COMPUTERD_FUSE_ATTR_TIMEOUT: "-1" })).not.toHaveProperty( + "attrTimeout", + ); }); - test("rejects negative timeouts", () => { - const out = buildFuseOptionString({ COMPUTERD_FUSE_ATTR_TIMEOUT: "-1" }); - expect(out).not.toMatch(/(?:^|,)attr_timeout=/); + test("maps COMPUTERD_FUSE_EXTRA_OPTS onto the binding's option names", () => { + const out = buildFuseMountOptions({ + COMPUTERD_FUSE_EXTRA_OPTS: "default_permissions,fsname=computerd,umask=18", + }); + expect(out).toMatchObject({ defaultPermissions: true, fsname: "computerd", umask: 18 }); }); - test("appends COMPUTERD_FUSE_EXTRA_OPTS verbatim for last-resort experimentation", () => { - const out = buildFuseOptionString({ - COMPUTERD_FUSE_EXTRA_OPTS: "use_ino,fsname=computerd", - }); - expect(out).toContain("use_ino"); - expect(out).toContain("fsname=computerd"); - }); - - test("does not emit writeback_cache even with EXTRA_OPTS asking for it", () => { - // libfuse 2.9 (the version fuse-native links against) does not - // recognise writeback_cache as a mount option; experiments showed - // mount failing with "fuse: unknown option `writeback_cache'". - // Strip it defensively so a typo in EXTRA_OPTS doesn't take the - // daemon down. - const out = buildFuseOptionString({ - COMPUTERD_FUSE_EXTRA_OPTS: "writeback_cache,use_ino", - }); - expect(out).not.toContain("writeback_cache"); - expect(out).toContain("use_ino"); + test("lets EXTRA_OPTS override a default rather than conflict with it", () => { + const out = buildFuseMountOptions({ COMPUTERD_FUSE_EXTRA_OPTS: "max_read=131072" }); + expect(out.maxRead).toBe(131072); }); test("returns the same options regardless of env var order", () => { - const a = buildFuseOptionString({ - COMPUTERD_FUSE_AUTO_CACHE: "1", + const a = buildFuseMountOptions({ + COMPUTERD_FUSE_MAX_READ: "4096", COMPUTERD_FUSE_ATTR_TIMEOUT: "1", }); - const b = buildFuseOptionString({ + const b = buildFuseMountOptions({ COMPUTERD_FUSE_ATTR_TIMEOUT: "1", - COMPUTERD_FUSE_AUTO_CACHE: "1", + COMPUTERD_FUSE_MAX_READ: "4096", }); - expect(a).toBe(b); + expect(a).toEqual(b); + }); +}); + +describe("the vendored binding", () => { + // Unit tests never mount, so this is what catches an option the binding + // would refuse. + test("accepts the default profile and every documented override", async () => { + // oxlint-disable-next-line typescript/no-explicit-any -- the vendored binding is CJS + const fuseModule: any = await import("fuse-napi"); + const Fuse = fuseModule.default ?? fuseModule; + for (const env of [ + empty, + { + COMPUTERD_FUSE_MAX_READ: "131072", + COMPUTERD_FUSE_ATTR_TIMEOUT: "0.5", + COMPUTERD_FUSE_ENTRY_TIMEOUT: "2", + COMPUTERD_FUSE_NEGATIVE_TIMEOUT: "1", + COMPUTERD_FUSE_EXTRA_OPTS: "allow_other,fsname=computerd,ac_attr_timeout=1", + }, + ]) { + const options = { autoUnmount: true, ...buildFuseMountOptions(env) }; + expect(() => Fuse.validateOptions(options)).not.toThrow(); + } + }); +}); + +describe("passthroughRequested", () => { + test("is on by default", () => { + expect(passthroughRequested(empty)).toBe(true); + }); + + test("turns off with any of the usual negative spellings", () => { + for (const value of ["0", "false", "no", "off", "FALSE", " off "]) { + expect(passthroughRequested({ COMPUTERD_FUSE_PASSTHROUGH: value })).toBe(false); + } + }); + + test("stays on for anything else, so a typo cannot quietly cost speed", () => { + for (const value of ["1", "true", "yes", "on", ""]) { + expect(passthroughRequested({ COMPUTERD_FUSE_PASSTHROUGH: value })).toBe(true); + } + }); +}); + +describe("nativeRootsRequested", () => { + test("is on by default and turns off with the same spellings as passthrough", () => { + expect(nativeRootsRequested(empty)).toBe(true); + for (const value of ["0", "false", "no", "off", " OFF "]) { + expect(nativeRootsRequested({ COMPUTERD_FUSE_NATIVE_ROOTS: value })).toBe(false); + } + for (const value of ["1", "true", ""]) { + expect(nativeRootsRequested({ COMPUTERD_FUSE_NATIVE_ROOTS: value })).toBe(true); + } }); }); diff --git a/packages/computerd/src/fuse/options.ts b/packages/computerd/src/fuse/options.ts index b86a2b082..0d6a2cdce 100644 --- a/packages/computerd/src/fuse/options.ts +++ b/packages/computerd/src/fuse/options.ts @@ -1,154 +1,129 @@ -// FUSE mount option assembly. -// -// fuse-native (libfuse 2.9) doesn't expose most of the interesting -// mount options through its constructor, so the driver monkey-patches -// _fuseOptions() to append a comma-separated extra option string. -// Centralizing the assembly here gives us a pure function we can unit -// test, and gives operators a documented set of env vars to flip when -// experimenting. -// -// The defaults are the production-safe profile derived from the -// benchmark report and the mtime-propagation contract tests on the -// apply path. auto_cache is on; the kernel keeps the page cache -// across reads until the file is reopened with a different mtime or -// size. attr_timeout, entry_timeout, and ac_attr_timeout sit at one -// second so stat-heavy tools (find, ls -l, git status) skip repeated -// FUSE round-trips. negative_timeout stays at zero so a just-written -// file shows up immediately to a process that probed before it -// existed. use_ino tells the kernel to trust the inode numbers -// returned by getattr, which is required for hardlinks to stat as the -// same inode. big_writes plus 512 KiB max_read and max_write match -// the dofs CHUNK_SIZE so a single FUSE read maps to a single chunk -// fetch instead of four 128 KiB slices of the same blob. -// -// Every default is opt-out via the matching COMPUTERD_FUSE_* env var. -// Setting an option to "0", "false", "no", "off", or "" turns it -// off; a positive value overrides the default. -// -// Disallowed options (writeback_cache) are stripped defensively because -// libfuse 2.9 fails the whole mount with "unknown option" when it sees -// them. A typo in COMPUTERD_FUSE_EXTRA_OPTS shouldn't take the daemon down. - -// 512 KiB matches the dofs CHUNK_SIZE so a single FUSE read maps to a -// single chunk fetch. Earlier defaults at 128 KiB issued four reads -// per chunk and four SQL lookups for the same blob. -const DEFAULT_MAX_READ = 524288; -const DEFAULT_MAX_WRITE = 524288; -const DEFAULT_AUTO_CACHE = true; -const DEFAULT_ATTR_TIMEOUT = "1"; -const DEFAULT_ENTRY_TIMEOUT = "1"; -const DEFAULT_AC_ATTR_TIMEOUT = "1"; -const DEFAULT_NEGATIVE_TIMEOUT = "0"; - -// libfuse 2.9 does not understand these. Reject them at assembly time -// so a typo in EXTRA_OPTS doesn't fail the mount on startup. -const DISALLOWED_OPTS = new Set(["writeback_cache"]); +// The dofs chunk size, so one FUSE request maps to one chunk fetch. +const DEFAULT_REQUEST_SIZE = 512 * 1024; + +// One second of attribute and entry caching saves round trips for tools +// that stat repeatedly (find, ls -l, git status). Failed lookups are never +// cached, so a file another process has just created is visible at once. +const DEFAULT_TIMEOUTS = { attrTimeout: 1, entryTimeout: 1, negativeTimeout: 0 } as const; + +type TimeoutOption = keyof typeof DEFAULT_TIMEOUTS; + +// Dropped from COMPUTERD_FUSE_EXTRA_OPTS rather than allowed to fail the +// mount. libfuse 3 removed big_writes and moved max_write into the init +// config. The kernel refuses auto_cache and kernel_cache on a passthrough +// open, so the driver decides keepCache per open instead, and +// ac_attr_timeout only tunes auto_cache. writeback_cache would change write +// behavior on the synced mount. +const IGNORED_EXTRA_OPTS = new Set([ + "big_writes", + "max_write", + "auto_cache", + "kernel_cache", + "ac_attr_timeout", + "writeback_cache", +]); export interface FuseOptionEnv { COMPUTERD_FUSE_MAX_READ?: string; COMPUTERD_FUSE_MAX_WRITE?: string; - COMPUTERD_FUSE_AUTO_CACHE?: string; - COMPUTERD_FUSE_KERNEL_CACHE?: string; COMPUTERD_FUSE_ATTR_TIMEOUT?: string; COMPUTERD_FUSE_ENTRY_TIMEOUT?: string; COMPUTERD_FUSE_NEGATIVE_TIMEOUT?: string; - COMPUTERD_FUSE_AC_ATTR_TIMEOUT?: string; COMPUTERD_FUSE_EXTRA_OPTS?: string; + COMPUTERD_FUSE_PASSTHROUGH?: string; + COMPUTERD_FUSE_NATIVE_ROOTS?: string; +} + +type OptionValue = boolean | number | string; + +/** Mount options in the binding's spelling: camelCase names, flags as `true`. */ +export type FuseMountOptions = Readonly>; + +export interface FuseInitConfig { + readonly maxWrite: number; + readonly maxBackingStackDepth: number; +} + +export function buildFuseInitConfig(env: FuseOptionEnv): FuseInitConfig { + return { + maxWrite: parsePositiveInt(env.COMPUTERD_FUSE_MAX_WRITE) ?? DEFAULT_REQUEST_SIZE, + // Lets a passthrough backing file sit on overlayfs, the usual + // container root. Without it registration fails with ELOOP. + maxBackingStackDepth: 1, + }; +} + +export function buildFuseMountOptions(env: FuseOptionEnv): FuseMountOptions { + return { + // Trust getattr's inode numbers, so hard links stat as one inode. + useIno: true, + maxRead: parsePositiveInt(env.COMPUTERD_FUSE_MAX_READ) ?? DEFAULT_REQUEST_SIZE, + ...timeoutOption("attrTimeout", env.COMPUTERD_FUSE_ATTR_TIMEOUT), + ...timeoutOption("entryTimeout", env.COMPUTERD_FUSE_ENTRY_TIMEOUT), + ...timeoutOption("negativeTimeout", env.COMPUTERD_FUSE_NEGATIVE_TIMEOUT), + ...parseExtraOptions(env.COMPUTERD_FUSE_EXTRA_OPTS), + }; +} + +export function passthroughRequested(env: FuseOptionEnv): boolean { + return !isOff(env.COMPUTERD_FUSE_PASSTHROUGH); } /** - * Build the comma-separated option string that the driver appends to - * fuse-native's _fuseOptions() output. Pure function over an env-like - * object, so tests can drive it directly. + * Native roots are on by default wherever MOUNT_IGNORE is set; + * COMPUTERD_FUSE_NATIVE_ROOTS=0 turns them off, for ruling them in or out + * while chasing a problem. */ -export function buildFuseOptionString(env: FuseOptionEnv): string { - const opts: string[] = ["big_writes", "use_ino"]; - - const maxWrite = parsePositiveInt(env.COMPUTERD_FUSE_MAX_WRITE) ?? DEFAULT_MAX_WRITE; - const maxRead = parsePositiveInt(env.COMPUTERD_FUSE_MAX_READ) ?? DEFAULT_MAX_READ; - opts.push(`max_write=${maxWrite}`); - opts.push(`max_read=${maxRead}`); - - // libfuse 2.9 treats auto_cache and kernel_cache as alternative - // strategies for the page cache, not stacking options. auto_cache wins - // when both are set because it's the safer choice: it invalidates the - // cache when mtime or size change, where kernel_cache never - // invalidates. The driver could warn here, but the option string - // itself is the durable artifact operators inspect when chasing - // misconfiguration, so just emit the safer one and move on. - const autoCache = parseBoolWithDefault(env.COMPUTERD_FUSE_AUTO_CACHE, DEFAULT_AUTO_CACHE); - const kernelCache = parseBoolWithDefault(env.COMPUTERD_FUSE_KERNEL_CACHE, false); - if (autoCache) { - opts.push("auto_cache"); - } else if (kernelCache) { - opts.push("kernel_cache"); - } +export function nativeRootsRequested(env: FuseOptionEnv): boolean { + return !isOff(env.COMPUTERD_FUSE_NATIVE_ROOTS); +} - pushTimeout(opts, "attr_timeout", env.COMPUTERD_FUSE_ATTR_TIMEOUT, DEFAULT_ATTR_TIMEOUT); - pushTimeout(opts, "entry_timeout", env.COMPUTERD_FUSE_ENTRY_TIMEOUT, DEFAULT_ENTRY_TIMEOUT); - pushTimeout( - opts, - "negative_timeout", - env.COMPUTERD_FUSE_NEGATIVE_TIMEOUT, - DEFAULT_NEGATIVE_TIMEOUT, - ); - pushTimeout(opts, "ac_attr_timeout", env.COMPUTERD_FUSE_AC_ATTR_TIMEOUT, DEFAULT_AC_ATTR_TIMEOUT); - - const extra = env.COMPUTERD_FUSE_EXTRA_OPTS; - if (extra !== undefined && extra !== "") { - for (const part of extra.split(",")) { - const trimmed = part.trim(); - if (trimmed === "") continue; - const head = trimmed.split("=")[0]; - if (DISALLOWED_OPTS.has(head)) continue; - opts.push(trimmed); - } +function isOff(raw: string | undefined): boolean { + const value = raw?.trim().toLowerCase(); + return value === "0" || value === "false" || value === "no" || value === "off"; +} + +// An unset variable keeps the default and an empty one turns the option off. +function timeoutOption(name: TimeoutOption, raw: string | undefined): Record { + if (raw === "") return {}; + const value = raw === undefined ? DEFAULT_TIMEOUTS[name] : parseNonNegativeNumber(raw); + return value === undefined ? {} : { [name]: value }; +} + +// Entries use libfuse's spelling ("allow_other,fsname=x") and are renamed +// to the binding's, so an entry overrides a default instead of conflicting +// with it. An unknown name still fails the mount, in the binding. +function parseExtraOptions(extra: string | undefined): Record { + const options: Record = {}; + for (const entry of (extra ?? "").split(",")) { + const [name = "", value] = splitOnce(entry.trim(), "="); + if (name === "" || IGNORED_EXTRA_OPTS.has(name)) continue; + options[toCamelCase(name)] = value === undefined ? true : parseOptionValue(value); } + return options; +} - return opts.join(","); +function splitOnce(text: string, separator: string): [string, string | undefined] { + const index = text.indexOf(separator); + return index === -1 ? [text, undefined] : [text.slice(0, index), text.slice(index + 1)]; } -function parsePositiveInt(value: string | undefined): number | undefined { - if (value === undefined || value === "") return undefined; - const n = Number(value); - if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) return undefined; - return n; +function toCamelCase(name: string): string { + return name.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()); } -function parseNonNegativeNumber(value: string | undefined): number | undefined { - if (value === undefined || value === "") return undefined; - const n = Number(value); - if (!Number.isFinite(n) || n < 0) return undefined; - return n; +function parseOptionValue(raw: string): number | string { + const n = Number(raw); + return raw.trim() !== "" && Number.isFinite(n) ? n : raw; } -// Resolves an env var to a boolean with a documented default. Unset -// (undefined) returns the default; an explicit "0" / "false" / "no" / -// "off" / "" turns the option off; anything else turns it on. The -// asymmetry between unset and empty matters: an operator who shells -// out `COMPUTERD_FUSE_AUTO_CACHE=` expects to disable the option, and that -// reaches this function as the empty string. -function parseBoolWithDefault(value: string | undefined, fallback: boolean): boolean { - if (value === undefined) return fallback; - const v = value.toLowerCase(); - if (v === "" || v === "0" || v === "false" || v === "no" || v === "off") return false; - return true; +function parsePositiveInt(value: string | undefined): number | undefined { + if (value === undefined || value === "") return undefined; + const n = Number(value); + return Number.isInteger(n) && n > 0 ? n : undefined; } -function pushTimeout( - opts: string[], - name: string, - raw: string | undefined, - fallback?: string, -): void { - // Distinguish unset (use the default) from explicit empty (turn the - // option off). Unset is undefined here; explicit empty is "". - const effective = raw === undefined ? fallback : raw === "" ? undefined : raw; - const n = parseNonNegativeNumber(effective); - if (n === undefined) return; - // libfuse accepts integers and fractional seconds. Preserve the - // operator's literal where it parses cleanly so "0.5" stays as - // "0.5" rather than dropping precision through Number formatting. - const formatted = effective !== undefined && Number(effective) === n ? effective : String(n); - opts.push(`${name}=${formatted}`); +function parseNonNegativeNumber(value: string): number | undefined { + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : undefined; } diff --git a/packages/computerd/src/fuse/passthrough.fuse.test.ts b/packages/computerd/src/fuse/passthrough.fuse.test.ts new file mode 100644 index 000000000..d1cd09cdd --- /dev/null +++ b/packages/computerd/src/fuse/passthrough.fuse.test.ts @@ -0,0 +1,182 @@ +// Boots the linux-x64 binary in a privileged container, as +// runner.fuse.test.ts does, and counts the daemon's read and write +// callbacks with the op tracer. Skips without Docker or the binary. The +// passthrough cases also skip on a host kernel without passthrough +// (Linux before 6.9). + +import { type ChildProcess, execFileSync, spawn } from "node:child_process"; +import { accessSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createWorkspaceClient } from "@cloudflare/computer-rpc/client"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../../../../.."); +const COMPUTERD_BINARY = join(REPO_ROOT, "artifacts/computerd/computerd-linux-x64"); +const RUN_COMPUTERD_SCRIPT = join(REPO_ROOT, "packages/computer/test-harness/run-computerd.sh"); +// Outside the mount on purpose: a process serving a FUSE mount that +// writes into that mount deadlocks on itself. +const TRACE_FILE = "/tmp/computerd-trace.json"; + +function available(): boolean { + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + accessSync(COMPUTERD_BINARY); + return true; + } catch { + return false; + } +} + +const describeIfReal = available() ? describe : describe.skip; + +interface FastPaths { + passthrough: boolean; + passthroughReason: string; + passthroughOpens: number; + passthroughFallbacks: number; +} + +describeIfReal("FUSE passthrough under a real mount", () => { + let cid: string | undefined; + let url = ""; + let client: ReturnType | undefined; + let passthroughAvailable = false; + + beforeAll(async () => { + const proc = spawn("bash", [RUN_COMPUTERD_SCRIPT], { + env: { + ...process.env, + COMPUTERD_HARNESS_PORT: "0", + MOUNT_IGNORE: "/node_modules", + // Passthrough on its own: a native root would serve these files + // without the daemon opening them at all. + COMPUTERD_FUSE_NATIVE_ROOTS: "0", + COMPUTERD_FUSE_TRACE: "summary", + COMPUTERD_FUSE_TRACE_FILE: TRACE_FILE, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + if (!proc.stdout || !proc.stderr) throw new Error("run-computerd.sh exposed no pipes"); + const [stdout, stderr] = await Promise.all([drain(proc.stdout), drain(proc.stderr)]); + await waitForExit(proc); + if (proc.exitCode !== 0) throw new Error(`run-computerd.sh exited ${proc.exitCode}: ${stderr}`); + url = stdout.trim(); + cid = stderr.match(/COMPUTERD_HARNESS_CID=([0-9a-f]+)/)?.[1]; + client = createWorkspaceClient({ + url: `${url.replace(/^http(s?):\/\//, "ws$1://")}/api`, + WebSocketImpl: WebSocket, + }); + passthroughAvailable = !/kernel did not offer|turned off/.test( + (await fastPaths()).passthroughReason, + ); + }, 120_000); + + afterAll(async () => { + await client?.close(); + if (cid) { + try { + execFileSync("docker", ["kill", cid], { stdio: "ignore" }); + } catch { + // Already gone. + } + } + }); + + async function run(source: string): Promise<{ stdout: string; code: number | undefined }> { + if (client === undefined) throw new Error("computerd container did not start"); + const handle = await client.shell.exec({ source, cwd: "/workspace", timeoutMs: 20_000 }); + const reader = handle.events.getReader(); + let stdout = ""; + let code: number | undefined; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const event = value as { name: string; value?: unknown; code?: number }; + if (event.name === "stdout") stdout += new TextDecoder().decode(event.value as Uint8Array); + if (event.name === "exit") code = event.code; + } + } finally { + reader.releaseLock(); + } + return { stdout, code }; + } + + async function fastPaths(): Promise { + const response = await fetch(`${url}/__computerd/info`); + return ((await response.json()) as { ignore: { fastPaths: FastPaths } }).ignore.fastPaths; + } + + /** Read and write callbacks the daemon has served so far. */ + async function dataOps(): Promise<{ read: number; write: number }> { + // computerd is PID 1 in the container; SIGUSR2 writes the trace. + const { stdout } = await run(`kill -USR2 1 && sleep 0.5 && cat ${TRACE_FILE}`); + const trace = JSON.parse(stdout) as { ops: Array<{ op: string; count: number }> }; + const count = (op: string) => trace.ops.find((entry) => entry.op === op)?.count ?? 0; + return { read: count("read"), write: count("write") }; + } + + test("the kernel serves local-only file data without the daemon", async (ctx) => { + if (!passthroughAvailable) ctx.skip(); + const before = await dataOps(); + const result = await run( + [ + "mkdir -p node_modules/pkg", + "dd if=/dev/urandom of=node_modules/pkg/blob bs=1M count=4 2>/dev/null", + "cat node_modules/pkg/blob node_modules/pkg/blob | wc -c", + "cmp node_modules/pkg/blob /tmp/workspace/node_modules/pkg/blob && echo same", + ].join(" && "), + ); + expect(result.code).toBe(0); + expect(result.stdout).toBe(`${8 * 1024 * 1024}\nsame\n`); + + expect(await dataOps()).toEqual(before); + + const reported = await fastPaths(); + expect(reported.passthrough).toBe(true); + expect(reported.passthroughOpens).toBeGreaterThan(0); + expect(reported.passthroughFallbacks).toBe(0); + }, 60_000); + + test("concurrent opens of one file, and of a hard link to it, all work", async (ctx) => { + if (!passthroughAvailable) ctx.skip(); + const result = await run( + [ + "mkdir -p node_modules", + "echo shared > node_modules/f", + "ln -f node_modules/f node_modules/g", + "exec 3>node_modules/f", + "echo more >&6", + "cat <&3 && cat <&4 && cat <&5", + "exec 3<&- 4<&- 5<&- 6>&-", + ].join("\n"), + ); + expect(result.code).toBe(0); + expect(result.stdout).toBe("shared\nmore\n".repeat(3)); + }, 60_000); + + test("a synced file still reuses the page cache across opens", async () => { + await run("echo cached > synced.txt && cat synced.txt > /dev/null"); + const warm = await dataOps(); + const result = await run("cat synced.txt && cat synced.txt"); + expect(result.stdout).toBe("cached\ncached\n"); + expect((await dataOps()).read).toBe(warm.read); + }, 60_000); +}); + +async function drain(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + return new Promise((resolveStream, rejectStream) => { + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stream.on("end", () => resolveStream(Buffer.concat(chunks).toString("utf8"))); + stream.on("error", rejectStream); + }); +} + +async function waitForExit(proc: ChildProcess): Promise { + if (proc.exitCode !== null) return; + await new Promise((resolveExit) => proc.once("exit", () => resolveExit())); +} diff --git a/packages/computerd/src/fuse/passthrough.test.ts b/packages/computerd/src/fuse/passthrough.test.ts index 67f1c011d..2d3702d50 100644 --- a/packages/computerd/src/fuse/passthrough.test.ts +++ b/packages/computerd/src/fuse/passthrough.test.ts @@ -15,9 +15,9 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import type { FuseOps } from "./driver"; -import { resolveMountIgnore } from "./ignore"; -import { type PassthroughFs, withLocalPassthrough } from "./passthrough"; +import type { FuseOpenResult, FuseOps } from "./driver.js"; +import { resolveMountIgnore } from "./ignore.js"; +import { type PassthroughFs, withLocalPassthrough } from "./passthrough.js"; // The real filesystem, as the slice withLocalPassthrough takes. Tests // override single calls on top of it. @@ -126,6 +126,19 @@ describe("withLocalPassthrough: routing", () => { expect(readBack).toBe("module.exports = 1\n"); }); + test("fsync on a local handle stays local, whatever datasync is", () => { + // fuse-napi passes (path, datasync, fd, cb). + const { ops, calls } = build(["/node_modules"]); + let fh = 0; + ops.create("/node_modules/sync.js", 0o644, (_code, handle) => { + fh = handle as number; + }); + for (const datasync of [false, true]) { + ops.fsync("/node_modules/sync.js", datasync, fh, (code) => expect(code).toBe(0)); + } + expect(calls).toEqual([]); + }); + test("creates missing parent directories on first write", () => { const { ops } = build(["/node_modules"]); ops.create("/node_modules/a/b/c/deep.js", 0o644, (code) => expect(code).toBe(0)); @@ -701,8 +714,8 @@ describe("withLocalPassthrough: descriptor and metadata operations", () => { writeFileSync(join(root, "node_modules/a"), "x"); const fh = open(ops, "/node_modules/a"); - expect(status((cb) => ops.fsync("/node_modules/a", fh, 0, cb))).toBe(0); - expect(status((cb) => ops.fsync("/node_modules/a", fh, 1, cb))).toBe(0); + expect(status((cb) => ops.fsync("/node_modules/a", false, fh, cb))).toBe(0); + expect(status((cb) => ops.fsync("/node_modules/a", true, fh, cb))).toBe(0); expect(synced).toEqual(["fsync", "fdatasync"]); }); @@ -948,3 +961,280 @@ describe("withLocalPassthrough: synced directories that hold local-only paths", expect(status((cb) => ops.rename("/app/node_modules", "/app/node_modules2", cb))).toBe(-18); }); }); + +describe("withLocalPassthrough: kernel passthrough", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "computerd-passthrough-backing-")); + mkdirSync(join(root, "node_modules"), { recursive: true }); + writeFileSync(join(root, "node_modules/a.js"), "a"); + writeFileSync(join(root, "node_modules/b.js"), "b"); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const registrar = (failWith?: string) => { + const opened: number[] = []; + const closed: number[] = []; + let next = 100; + return { + opened, + closed, + passthrough: { + backingOpen(fd: number): number { + if (failWith !== undefined) { + throw Object.assign(new Error(`backingOpen failed: ${failWith}`), { code: failWith }); + } + opened.push(fd); + return next++; + }, + backingClose(id: number): void { + closed.push(id); + }, + }, + }; + }; + + const build = (passthrough: ReturnType["passthrough"]) => { + const source = recordingOps(); + const warnings: string[] = []; + const layer = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["/node_modules"], MOUNT), + mountPoint: MOUNT, + passthrough, + warn: (message) => warnings.push(message), + }); + return { ...layer, calls: source.calls, warnings }; + }; + + const open = (ops: FuseOps, path: string): FuseOpenResult => { + let reply: FuseOpenResult = 0; + ops.open(path, 0, (code, result) => { + expect(code).toBe(0); + reply = result; + }); + return reply; + }; + const handleOf = (reply: FuseOpenResult): number => + typeof reply === "number" ? reply : reply.fd; + const release = (ops: FuseOps, path: string, reply: FuseOpenResult) => + ops.release(path, handleOf(reply), (code) => expect(code).toBe(0)); + + test("replies with a backing id so the kernel serves the data", () => { + const backing = registrar(); + const { ops } = build(backing.passthrough); + const reply = open(ops, "/node_modules/a.js"); + expect(reply).toEqual({ fd: expect.any(Number), backingId: 100 }); + expect(backing.opened).toHaveLength(1); + }); + + test("registers a created file too", () => { + const backing = registrar(); + const { ops } = build(backing.passthrough); + let reply: FuseOpenResult = 0; + ops.create("/node_modules/new.js", 0o644, (code, result) => { + expect(code).toBe(0); + reply = result; + }); + expect(reply).toEqual({ fd: expect.any(Number), backingId: 100 }); + }); + + test("never sets keepCache or directIO alongside a backing id", () => { + const { ops } = build(registrar().passthrough); + const reply = open(ops, "/node_modules/a.js") as Record; + expect(reply).not.toHaveProperty("keepCache"); + expect(reply).not.toHaveProperty("directIO"); + }); + + test("shares one backing id between concurrent opens of one file", () => { + const backing = registrar(); + const { ops } = build(backing.passthrough); + const first = open(ops, "/node_modules/a.js"); + const second = open(ops, "/node_modules/a.js"); + expect(first).toMatchObject({ backingId: 100 }); + expect(second).toMatchObject({ backingId: 100 }); + expect(backing.opened).toHaveLength(1); + }); + + test("keys the table on the inode, so a hard link shares the id", () => { + const backing = registrar(); + const { ops } = build(backing.passthrough); + ops.link("/node_modules/a.js", "/node_modules/linked.js", (code) => expect(code).toBe(0)); + const viaName = open(ops, "/node_modules/a.js"); + const viaLink = open(ops, "/node_modules/linked.js"); + expect(viaLink).toMatchObject({ backingId: (viaName as { backingId: number }).backingId }); + expect(backing.opened).toHaveLength(1); + }); + + test("gives different files different ids", () => { + const { ops } = build(registrar().passthrough); + expect(open(ops, "/node_modules/a.js")).toMatchObject({ backingId: 100 }); + expect(open(ops, "/node_modules/b.js")).toMatchObject({ backingId: 101 }); + }); + + test("closes the backing id on the last release only", () => { + const backing = registrar(); + const { ops, stats } = build(backing.passthrough); + const first = open(ops, "/node_modules/a.js"); + const second = open(ops, "/node_modules/a.js"); + + release(ops, "/node_modules/a.js", first); + expect(backing.closed).toEqual([]); + expect(stats().passthroughFiles).toBe(1); + + release(ops, "/node_modules/a.js", second); + expect(backing.closed).toEqual([100]); + expect(stats().passthroughFiles).toBe(0); + }); + + test("registers afresh after the last release", () => { + const backing = registrar(); + const { ops } = build(backing.passthrough); + release(ops, "/node_modules/a.js", open(ops, "/node_modules/a.js")); + expect(open(ops, "/node_modules/a.js")).toMatchObject({ backingId: 101 }); + }); + + test("falls back to serving reads itself when registration fails", () => { + const { ops, stats, warnings } = build(registrar("EPERM").passthrough); + const reply = open(ops, "/node_modules/a.js"); + expect(typeof reply === "number" || !("backingId" in reply)).toBe(true); + + const buffer = Buffer.alloc(8); + let bytes = -1; + ops.read("/node_modules/a.js", handleOf(reply), buffer, 8, 0, (result) => { + bytes = result; + }); + expect(buffer.subarray(0, bytes).toString()).toBe("a"); + expect(stats().passthroughFallbacks).toBe(1); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("EPERM"); + }); + + test("says the kernel did not offer passthrough when that is the reason", () => { + const { ops, warnings } = build(registrar("ENOTSUP").passthrough); + open(ops, "/node_modules/a.js"); + expect(warnings[0]).toContain("did not negotiate"); + expect(warnings[0]).not.toContain("CAP_SYS_ADMIN"); + }); + + test("logs a failed registration once per mount", () => { + const { ops, stats, warnings } = build(registrar("ELOOP").passthrough); + open(ops, "/node_modules/a.js"); + open(ops, "/node_modules/b.js"); + expect(stats().passthroughFallbacks).toBe(2); + expect(warnings).toHaveLength(1); + }); + + test("counts passthrough opens and registered files", () => { + const { ops, stats } = build(registrar().passthrough); + open(ops, "/node_modules/a.js"); + open(ops, "/node_modules/a.js"); + open(ops, "/node_modules/b.js"); + expect(stats()).toMatchObject({ + passthroughOpens: 3, + passthroughFallbacks: 0, + passthroughFiles: 2, + }); + }); + + test("never registers a file served by the VFS", () => { + const backing = registrar(); + const { ops, calls } = build(backing.passthrough); + ops.open("/src/index.js", 0, () => {}); + expect(calls).toEqual(["open"]); + expect(backing.opened).toEqual([]); + }); + + test("replies with a bare handle when no registrar is configured", () => { + const source = recordingOps(); + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["/node_modules"], MOUNT), + mountPoint: MOUNT, + }); + expect(typeof open(ops, "/node_modules/a.js")).toBe("number"); + }); +}); + +describe("withLocalPassthrough: directory hooks", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "computerd-passthrough-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const build = () => { + const source = recordingOps(); + const events: string[] = []; + const { ops } = withLocalPassthrough(source.ops, { + root, + ignore: resolveMountIgnore(["**/node_modules"], MOUNT), + mountPoint: MOUNT, + warn: () => {}, + onLocalDirectory: (path, created) => events.push(`${created ? "created" : "seen"}:${path}`), + }); + // Each callback records its result, so a test can tell whether a hook + // fired before or after the kernel was answered. + const reply = (name: string) => (code: number) => events.push(`${name}=${code}`); + return { ops, events, reply }; + }; + + test("a local-only mkdir reports the directory after replying", () => { + const { ops, events, reply } = build(); + ops.mkdir("/workspace/app/node_modules", 0o755, reply("mkdir")); + expect(events).toEqual(["mkdir=0", "created:app/node_modules"]); + }); + + test("a lookup of an existing local-only directory reports it as seen", () => { + const { ops, events } = build(); + mkdirSync(join(root, "app/node_modules/react"), { recursive: true }); + writeFileSync(join(root, "app/node_modules/react/index.js"), ""); + ops.getattr("/workspace/app/node_modules", (code) => events.push(`getattr=${code}`)); + ops.getattr("/workspace/app/node_modules/react/index.js", (code) => + events.push(`file=${code}`), + ); + ops.getattr("/workspace/app/node_modules/missing", (code) => events.push(`missing=${code}`)); + expect(events).toEqual(["getattr=0", "seen:app/node_modules", "file=0", `missing=${-2}`]); + }); + + test("a failed mkdir reports nothing", () => { + const { ops, events, reply } = build(); + ops.mkdir("/workspace/node_modules", 0o755, reply("mkdir")); + ops.mkdir("/workspace/node_modules", 0o755, reply("again")); + expect(events).toEqual(["mkdir=0", "created:node_modules", `again=${-17}`]); + }); + + test("a synced mkdir is the VFS's and reports nothing", () => { + const { ops, events, reply } = build(); + ops.mkdir("/workspace/src", 0o755, reply("mkdir")); + expect(events).toEqual(["mkdir=0"]); + }); + + test("a rename within the local side reports the destination after replying", () => { + const { ops, events, reply } = build(); + ops.mkdir("/workspace/node_modules/.staging", 0o755, () => {}); + events.length = 0; + ops.rename("/workspace/node_modules/.staging", "/workspace/a/node_modules", reply("rename")); + expect(events).toEqual(["rename=0", "created:a/node_modules"]); + }); + + test("a synced rename reports nothing", () => { + // A bind mount under the directory moves with it in the kernel, so + // there is nothing to re-mount. + const { ops, events, reply } = build(); + ops.rename("/workspace/packages/app", "/workspace/packages/web", reply("rename")); + expect(events).toEqual(["rename=0"]); + }); + + test("a crossing rename reports nothing", () => { + const { ops, events, reply } = build(); + ops.rename("/workspace/tmp", "/workspace/node_modules", reply("rename")); + expect(events).toEqual([`rename=${-18}`]); + }); +}); diff --git a/packages/computerd/src/fuse/passthrough.ts b/packages/computerd/src/fuse/passthrough.ts index 9d9736b47..d107cf008 100644 --- a/packages/computerd/src/fuse/passthrough.ts +++ b/packages/computerd/src/fuse/passthrough.ts @@ -6,17 +6,16 @@ // is provably a no-op: `withLocalPassthrough` returns the source object // unchanged. // -// Despite the name there is no FUSE passthrough (FOPEN_PASSTHROUGH) -// here; fuse-native binds libfuse 2.9, below the API version that can -// negotiate it. Data still crosses the FUSE boundary into this process. -// What it skips is the VFS, the SQLite store, the change-pack encoding, -// and the pull into the Durable Object. +// When the mount negotiated FUSE passthrough, opening a local-only file +// registers it with the kernel, which then serves reads, writes, and mmap +// straight from the backing file. Metadata still comes through here. +// Otherwise this process serves the data itself, which still skips the +// VFS, the store, and the pull into the Durable Object. // -// Writes go straight to the host filesystem with pwrite rather than -// through the buffered FileEntry machinery in driver.ts. That buffering -// exists because the VFS has no ranged-write primitive and a naive -// implementation is O(N^2) over sequential appends; the kernel does not -// have that problem, so the indirection would be pure cost here. +// Writes served here go straight to the host filesystem with pwrite +// rather than through the buffered FileEntry machinery in driver.ts. That +// buffering exists because the VFS has no ranged-write primitive; the +// kernel does not have that problem. import { accessSync, @@ -48,8 +47,8 @@ import { } from "node:fs"; import { dirname, join } from "node:path"; -import type { FuseOps, FuseStat } from "./driver"; -import type { MountIgnoreSet } from "./ignore"; +import type { FuseOpenResult, FuseOps, FuseStat } from "./driver.js"; +import type { MountIgnoreSet } from "./ignore.js"; // Mirrors driver.ts. Duplicated rather than exported across modules // because these are the kernel's numbers, not ours, and a shared @@ -84,6 +83,21 @@ export interface LocalPassthroughOptions { readonly onMaterialize?: (relativePath: string) => void; /** Operator-facing warnings. Defaults to console.warn; injected for tests. */ readonly warn?: (message: string) => void; + /** Omitted means every local-only open is served by this process. */ + readonly passthrough?: PassthroughRegistrar; + /** + * Called with the mount-relative path of a local-only directory after + * the kernel has had its reply. `created` is true from mkdir and from a + * rename within the local-only side, false from a lookup that found an + * existing directory. Native roots mount from here. + */ + readonly onLocalDirectory?: (relativePath: string, created: boolean) => void; +} + +/** `backingOpen` throws an Error whose `code` is the errno name. */ +export interface PassthroughRegistrar { + backingOpen(fd: number): number; + backingClose(backingId: number): void; } /** @@ -157,6 +171,12 @@ export interface PassthroughStats { readonly openHandles: number; /** Renames refused with EXDEV for crossing the boundary. */ readonly crossLayerRenames: number; + /** Opens handed to the kernel with a backing id. */ + readonly passthroughOpens: number; + /** Opens served by this process because registration failed. */ + readonly passthroughFallbacks: number; + /** Files with a live backing id right now. */ + readonly passthroughFiles: number; } export interface LocalPassthrough { @@ -182,6 +202,9 @@ export function withLocalPassthrough( localOps: 0, openHandles: 0, crossLayerRenames: 0, + passthroughOpens: 0, + passthroughFallbacks: 0, + passthroughFiles: 0, }), }; } @@ -192,7 +215,10 @@ export function withLocalPassthrough( let localOps = 0; let crossLayerRenames = 0; + let passthroughOpens = 0; + let passthroughFallbacks = 0; const warn = options.warn ?? ((message: string) => console.warn(message)); + const registrar = options.passthrough; // No cache. The ignore set is a handful of entries and the test is a // prefix comparison against each, which costs about what a cache @@ -211,7 +237,13 @@ export function withLocalPassthrough( // layers would read one file and write another. const LOCAL_HANDLE_BASE = 0x4000_0000; let nextHandle = LOCAL_HANDLE_BASE; - const handles = new Map(); + const handles = new Map(); + + // The kernel allows one backing file per inode. Registering the same + // file twice succeeds with a second id, and the open that uses it then + // fails with EIO, so every open of a file shares one id. Keyed on + // dev:ino because a hard link reaches the same inode by another name. + const backingIds = new Map(); const isLocalHandle = (fh: number): boolean => fh >= LOCAL_HANDLE_BASE; const ensureParent = (target: string): void => { @@ -281,11 +313,18 @@ export function withLocalPassthrough( return; } localOps += 1; + let stat: Stats; try { - cb(0, statToFuse(fs.lstatSync(localPath(path)))); + stat = fs.lstatSync(localPath(path)); } catch (error) { cb(toErrno(error), null); + return; } + cb(0, statToFuse(stat)); + // A directory that already exists on disk but has not been mounted + // yet, such as one restored from a snapshot before its synced parent + // came back from the host. + if (stat.isDirectory()) options.onLocalDirectory?.(toRelative(path, mountRoot), false); }, fgetattr(path, fh, cb) { @@ -318,7 +357,7 @@ export function withLocalPassthrough( // create() for that. But a flag set including O_TRUNC still has // to reach the real file, so the flags are passed through as-is. const fd = fs.openSync(target, flags); - cb(0, allocateHandle(fd, path)); + cb(0, registerOpen(allocateHandle(fd, path))); } catch (error) { cb(toErrno(error), 0); } @@ -361,7 +400,7 @@ export function withLocalPassthrough( fsConstants.O_RDWR | fsConstants.O_CREAT | fsConstants.O_TRUNC, mode === 0 ? DEFAULT_FILE_MODE : mode, ); - cb(0, allocateHandle(fd, path)); + cb(0, registerOpen(allocateHandle(fd, path))); } catch (error) { cb(toErrno(error), 0); } @@ -419,6 +458,8 @@ export function withLocalPassthrough( cb(0); } catch (error) { cb(toErrno(error)); + } finally { + releaseBacking(handle.backingKey); } }, @@ -442,9 +483,9 @@ export function withLocalPassthrough( cb(0); }, - fsync(path, fh, datasync, cb) { + fsync(path, datasync, fh, cb) { if (!isLocalHandle(fh)) { - ops.fsync(path, fh, datasync, cb); + ops.fsync(path, datasync, fh, cb); return; } const handle = handles.get(fh); @@ -454,7 +495,7 @@ export function withLocalPassthrough( } localOps += 1; try { - if (datasync !== 0) fs.fdatasyncSync(handle.fd); + if (datasync) fs.fdatasyncSync(handle.fd); else fs.fsyncSync(handle.fd); cb(0); } catch (error) { @@ -521,10 +562,14 @@ export function withLocalPassthrough( const target = localPath(path); ensureParent(target); fs.mkdirSync(target, { mode: mode === 0 ? DEFAULT_DIR_MODE : mode }); - cb(0); } catch (error) { cb(toErrno(error)); + return; } + cb(0); + // After the reply: whatever this triggers resolves paths under the + // mount, and the kernel holds the parent locked until we answer. + options.onLocalDirectory?.(toRelative(path, mountRoot), true); }, rmdir(path, cb) { @@ -606,10 +651,12 @@ export function withLocalPassthrough( const target = localPath(destination); ensureParent(target); fs.renameSync(localPath(source), target); - cb(0); } catch (error) { cb(toErrno(error)); + return; } + cb(0); + options.onLocalDirectory?.(toRelative(destination, mountRoot), true); }, chmod(path, mode, cb) { @@ -735,6 +782,82 @@ export function withLocalPassthrough( return handle; } + // Never sets keepCache or directIO: the kernel refuses either one + // alongside a backing id. + function registerOpen(fh: number): FuseOpenResult { + const handle = handles.get(fh); + if (registrar === undefined || handle === undefined) return fh; + const key = inodeKey(handle.fd); + if (key === undefined) return fh; + + let entry = backingIds.get(key); + if (entry === undefined) { + const id = registerBackingFile(registrar, handle); + if (id === undefined) return fh; + entry = { id, refs: 0 }; + backingIds.set(key, entry); + } + entry.refs += 1; + handle.backingKey = key; + passthroughOpens += 1; + return { fd: fh, backingId: entry.id }; + } + + function inodeKey(fd: number): string | undefined { + try { + const stat = fs.fstatSync(fd); + return `${stat.dev}:${stat.ino}`; + } catch { + return undefined; + } + } + + // A refusal comes from the file or the mount, so every open of one file + // falls back alike and the kernel never sees both modes on one inode. + function registerBackingFile( + target: PassthroughRegistrar, + handle: { fd: number; path: string }, + ): number | undefined { + try { + return target.backingOpen(handle.fd); + } catch (error) { + reportFallback(handle.path, error); + return undefined; + } + } + + function releaseBacking(key: string | undefined): void { + if (key === undefined || registrar === undefined) return; + const entry = backingIds.get(key); + if (entry === undefined) return; + entry.refs -= 1; + if (entry.refs > 0) return; + backingIds.delete(key); + try { + registrar.backingClose(entry.id); + } catch (error) { + warn(`computerd: closing passthrough backing id ${entry.id} failed: ${String(error)}`); + } + } + + function reportFallback(path: string, error: unknown): void { + passthroughFallbacks += 1; + if (passthroughFallbacks > 1) return; + const code = errnoOf(error) ?? String(error); + const reason = + code === "ENOTSUP" || code === "EOPNOTSUPP" + ? "the mount did not negotiate FUSE passthrough with the kernel (it needs Linux 6.9 or newer)" + : `FUSE passthrough registration for ${path} failed with ${code}. EPERM ` + + `usually means the process lacks CAP_SYS_ADMIN, and ELOOP that ` + + `MOUNT_IGNORE_PATH sits on a filesystem stacked too deep, such as ` + + `overlayfs on overlayfs`; + warn( + `computerd: ${reason}. Local-only files are served through computerd ` + + `instead, which works but is slower. Further fallbacks are counted on ` + + `/__computerd/info and not logged.`, + ); + } + function reportCrossLayerRename( source: string, destination: string, @@ -832,6 +955,9 @@ export function withLocalPassthrough( localOps, openHandles: handles.size, crossLayerRenames, + passthroughOpens, + passthroughFallbacks, + passthroughFiles: backingIds.size, }), }; } diff --git a/packages/computerd/vendor/fuse-napi/CHANGELOG.md b/packages/computerd/vendor/fuse-napi/CHANGELOG.md new file mode 100644 index 000000000..249ad8a4c --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/CHANGELOG.md @@ -0,0 +1,263 @@ +# Changelog + +All notable changes to this project are documented here. Releases follow +[Semantic Versioning](https://semver.org/). + +## Unreleased + +## 2.3.1 - 2026-08-05 + +### Fixed + +- Entry invalidations run on a bounded, dedicated native worker, preventing + reverse-invalidation kernel locks from blocking the JavaScript event loop or + libuv worker pool. Teardown drains the queue without racing native mount + disposal, and operation contexts remain guarded until their native response. +- macOS mounts require and verify macFUSE 5.3.1+, the first release whose + `FUSE_NOTIFY_INVAL_ENTRY` support reliably removes VFS name-cache entries. + +## 2.3.0 - 2026-08-05 + +### Added + +- Portable namespace-cache housekeeping through `fuse.invalidateEntry(path)`, + backed by `FUSE_NOTIFY_INVAL_ENTRY`. Nested paths resolve their parent inode, + and calls made from filesystem operation callbacks are rejected with + `EDEADLK` to prevent Linux reverse-invalidation deadlocks. + +## 2.2.2 - 2026-08-01 + +### Fixed + +- `maxRead` now configures both the libfuse `max_read` mount option and the + matching `fuse_conn_info.max_read` init value, allowing FUSE 3 mounts to + initialize successfully while preserving default negotiation when omitted. + +## 2.2.1 - 2026-07-30 + +### Fixed + +- Readlink now preserves the largest kernel-supported symlink target without + dropping its final byte: `PATH_MAX` bytes on macFUSE and `PATH_MAX - 1` bytes + on Linux. The mounted integration suite covers both platform boundaries + without producing an oversized Linux FUSE reply. + +### Removed + +- Removed the unused `semaphore.h` compatibility shim; the native addon uses + libuv semaphores exclusively and the header was never part of the package. + +## 2.2.0 - 2026-07-30 + +### Added + +- Alpine Linux 3.23 release prebuilds for Node.js 22, 24, and 26 on x64 and + arm64. Musl artifacts use explicit `.musl.node` filenames and the SONAME 4 + loader selects them automatically in Alpine containers. + +## 2.1.1 - 2026-07-30 + +### Fixed + +- Corrected the bundled release history so every feature, compatibility change, + production-hardening fix, and supply-chain guarantee shipped in `2.1.0` is + documented under that release instead of `Unreleased`. +- Release verification now requires a changelog section matching the package + version and rejects tagged or packaged releases that still contain staged + `Unreleased` entries. + +### Changed + +- The current `2.x` release line is maintained on `main`; the `1.x` line is + retained on the `1.0` maintenance branch. Repository contributions are + accepted through pull requests, while CI, prebuild creation, tagging, and + publication remain maintainer-controlled manual actions. + +## 2.1.0 - 2026-07-30 + +### Added + +- Production runtime preflight through `Fuse.checkEnvironment()`, performed + automatically before mounts, with stable diagnostics for missing + `fusermount3`, inaccessible `/dev/fuse`, `fuse.conf` policy, unsupported + libfuse versions, and incompatible macFUSE buffer ownership. +- Handle-aware metadata callbacks, FUSE 3 rename flags, delayed poll + notifications, `copy_file_range`, `lseek`, and native Linux `statx` support + when built with libfuse 3.18 or newer. +- Deterministic option fuzzing, native static analysis, ASan/UBSan mount + integration, repeated-mount soak coverage, and a libfuse 3.18 modern-syscall + CI gate. Crashed-mount recovery is isolated as an explicit privileged suite + on every Linux OS/Node.js matrix combination and under ASan/UBSan, while the + ordinary integration suite remains unprivileged. Its fixture tolerates an + incidental kernel-side auto-detach only by creating a new broken mount, so + it still proves the real disconnected-mount path without becoming flaky. + Sanitizer and soak gates retain an explicit root capability boundary, + preventing hosted-runner `EPERM` failures from masking native safety + results. + +### Changed + +- Mount options now pass an explicit FUSE 3 conformance preflight in both the + constructor and the new `Fuse.validateOptions()` helper. Stable error codes + identify removed/internal options, conflicts, missing dependencies, + platform misuse, and invalid module identifiers before libfuse is invoked. +- `nonEmpty`/`nonempty`, `fd`, and `userId`/`user_id` are rejected with + actionable errors instead of being ignored or reaching libfuse3. +- Contradictory access, page-cache, inode-retention, block-device, and + auto-cache settings are rejected deterministically. +- Linux release packages now ship separate Node.js ABI-specific prebuilds for + libfuse SONAME 3 and SONAME 4 on x64 and arm64. Local source builds retain + priority and missing runtime dependencies produce actionable errors. +- `remember` mounts start and stop libfuse's inode-cache cleanup thread, while + null-path operation combinations are rejected unless a safe handle-aware + callback is configured. +- Release artifacts include a CycloneDX SBOM, checksums, and GitHub build and + SBOM attestations. + +### Fixed + +- The libfuse 3.18/SONAME 4 release gate now exercises the exact installed npm + tarball through a dependency-free syscall runner. Clean consumers no longer + fail because the development-only `tape` harness is absent, and release + contract tests prevent the package smoke from regaining a development + dependency. +- Forced recovery from a disconnected mount now waits, with a finite deadline, + until the lazy FUSE detach is stable before starting a replacement mount. A + racing helper failure is accepted only after the detached postcondition is + proven; otherwise `EFUSEUNMOUNT` retains the helper and observation errors + without continuing into native mount startup. This removes Linux races that + could incorrectly report `Mountpoint in use`; a stalled detach after helper + success reports `EFUSEUNMOUNTWAIT`. System unmount helpers no longer inherit + `LD_*` or `DYLD_*` loader injection, and bounded helper output is retained in + failure diagnostics. +- Native poll registrations now have thread-safe shared ownership across + in-flight callbacks, explicit JavaScript closure, and concurrent teardown. +- FUSE 3 buffer, poll, worker, and inode-cache resources are released + deterministically during normal and failed teardown paths. + +## 2.0.0 - 2026-07-29 + +### Added + +- Native FUSE 3 support on Linux through `libfuse3.so.3` and on macOS through + macFUSE 5's libfuse 3 runtime. +- Explicit macFUSE Darwin adapters for attributes, compound `setattr`, + `statfs`, directory entries, resource-fork xattrs, and platform-specific + `UTIME_NOW`/`UTIME_OMIT` encodings. + +### Changed + +- The native API target is now `FUSE_USE_VERSION=31`. Mount construction, + unmounting, request reception, request processing, and buffer ownership use + the FUSE 3 lifecycle without `fuse_chan`. +- FUSE 3's merged `getattr`/`fgetattr` and `truncate`/`ftruncate` callbacks are + routed to the existing JavaScript operations based on file-handle presence. +- `directIo` is applied through `fuse_config.direct_io`; the removed + `nonempty` option is not forwarded to libfuse3. +- Linux unmount helpers use `fusermount3`. Build discovery and release + artifacts require `fuse3`/`libfuse3-dev` and verify libfuse 3 linkage. +- Supported Node.js versions are 22, 24, and 26; the minimum is Node.js 22. + +### Compatibility + +- This is a native-runtime breaking release: Linux requires libfuse 3 and + macOS requires macFUSE 5 with its libfuse 3 headers and runtime. The 1.x + callback surface and TypeScript contracts remain source-compatible. +- Release prebuilds retain the glibc 2.31 and macOS 12 deployment baselines. + +## 1.1.1 - 2026-07-29 + +### Changed + +- Native libfuse 2 option spellings such as `nonempty`, `direct_io`, and + `allow_other` are accepted as explicit aliases for their JavaScript names. + Aliases are normalized before validation, conflicting values are rejected, + and unrelated unknown options remain errors. +- Standalone CI runs are manual-only; the workflow remains reusable by the + manually triggered prebuild workflow. + +## 1.1.0 - 2026-07-29 + +### Added + +- The remaining non-deprecated portable FUSE 2.9 callbacks: `destroy`, + `lock`, `flock`, `bmap`, bounded `ioctl`, snapshot `poll`, `write_buf`, + `read_buf`, and `fallocate`. +- Lossless timespec input/output, including explicit `UTIME_NOW` and + `UTIME_OMIT`, and request-local `fuse.context()` snapshots. +- A configurable fixed worker pool and real-mount production-hardening tests + for concurrency limits, buffer-vector operations, request context, and + destroy ordering. + +### Changed + +- Mount startup is asynchronous and cancellable, operation and option names + are strictly validated, and unimplemented optional callbacks consistently + return `ENOSYS`. +- Native teardown, cancellation, request ownership, callback deadlines, and + 64-bit/timestamp validation have been hardened for deterministic failure + behavior. +- Worker-local request state and one dedicated libuv dispatcher per bounded + worker are initialized before native threads start. This preserves a + one-request/one-wakeup invariant across Node.js versions, while failed + mounts retain all dispatcher backing storage until every close callback has + completed. +- Worker thread identifiers are retired under the cleanup mutex before their + threads are joined, preventing concurrent teardown from cancelling a stale + `pthread_t`. +- Linux instance teardown now has one native owner for the complete FUSE 2 + unmount-and-destroy lifecycle; macOS retains its required force-detach before + joining macFUSE request threads. Neither path accesses a channel after + `fuse_unmount()` has destroyed it. + +## 1.0.0 - 2026-07-28 + +### Added + +- Stable Node-API bindings for the FUSE 2.9 high-level API on Linux and macOS. +- Node.js 20, 22, and 24 prebuilds for Linux and macOS on x64 and arm64. +- Enhanced, opt-in `initWithConfig`, `readdirPaged`, and `createWithFlags` + callbacks while retaining their legacy callback alternatives. +- Lossless `bigint` transport for 64-bit file handles, offsets, sizes, inode + values, counters, and timestamps outside JavaScript's safe-integer range. +- Per-operation callback timeouts and structured operation-error reporting. +- Exact npm-tarball installation tests and checksum-protected release + artifacts. +- Mandatory automated real-mount release gates for exact npm tarballs on Linux + x64/arm64, with documented manual equivalents on macOS Intel/Apple Silicon. +- Manual artifact creation on the exact release tag and manual npm publication + with two-factor authentication; tag pushes cannot publish automatically. + +### Changed + +- Native teardown is deterministic and joins the FUSE thread before releasing + JavaScript and libuv resources. +- Native request buffers are copied into request-owned JavaScript buffers, so + late asynchronous consumers cannot reference expired kernel memory. +- Operation results, statistics, file-info flags, directory entries, extended + attributes, mount options, and FUSE connection settings are range-checked + before crossing the native boundary. +- Errno constants now include every errno exposed by the current host while + retaining inherited compatibility constants. +- Oversized symbolic-link targets are truncated below the Linux kernel FUSE + response limit while remaining null-terminated. +- Release automation uses current Node.js 24-based official GitHub actions, + pinned by immutable commit SHA. +- The deprecated privileged `fuse-napi configure` command from `0.0.1` has + been removed. Installing the host FUSE runtime remains an explicit + administrator action. + +### Compatibility notes + +- Linux requires glibc 2.31 or newer and the system `libfuse.so.2` runtime. +- macOS requires macOS 12 or newer and the macFUSE libfuse 2 compatibility + runtime. +- Operations time out after 15 seconds by default. Use `timeout` per operation + or set a value to `false` only when an unbounded wait is intentional. +- A 64-bit value is a JavaScript `number` while exactly representable and a + `bigint` otherwise. + +## 0.0.1 - 2026-07-28 + +- Initial package publication used to validate the four-platform prebuild and + npm packaging pipeline. diff --git a/packages/computerd/vendor/fuse-napi/COMPATIBILITY.md b/packages/computerd/vendor/fuse-napi/COMPATIBILITY.md new file mode 100644 index 000000000..eebdc7ff3 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/COMPATIBILITY.md @@ -0,0 +1,197 @@ +# FUSE 3 compatibility matrix + +This document describes the current `fuse-napi` contract inherited from +`@cocalc/fuse-native@2.4.3`, including its FUSE 3 portability and +production-hardening guarantees. + +## Callback matrix + +All listed callbacks use the existing asynchronous JavaScript callback style. +The native layer targets the FUSE 3.1 high-level API +(`FUSE_USE_VERSION=31`) while retaining the callback contract from 1.x. + +| JavaScript callback | Linux libfuse 3 | macFUSE libfuse 3 | Current status and differences | +| --- | --- | --- | --- | +| `init(cb)` / `initWithConfig(connection, cb)` | Yes | Yes | Legacy defaults remain unchanged. The enhanced variant exposes and validates the portable FUSE 3 connection fields, including the active `maxRead`, before applying conservative limits/capabilities. Public mount completion waits until the mounted device is visible. | +| `access(path, mode, cb)` | Yes | Yes | Shared implementation. Suppressed by `default_permissions`. | +| `statfs(path, cb)` | Yes | Yes | Shared `struct statvfs` implementation with range-checked 64-bit fields. macFUSE also offers unsupported `statfs_x`. | +| `getattr(path, cb)` | Yes | Yes | Shared, zero-initialized implementation with range-checked 64-bit fields and platform-specific timestamp members. A null path is routed to `fgetattr`. | +| `fgetattr(path, fd, cb)` | Yes | Yes | Handle-aware implementation; path can be null with `nullPathOk`. | +| `flush(path, fd, cb)` | Yes | Yes | Shared implementation. May be called more than once per open. | +| `fsync(path, datasync, fd, cb)` | Yes | Yes | Shared implementation. | +| `fsyncdir(path, datasync, fd, cb)` | Yes | Yes | Shared implementation. | +| `readdir(path, cb)` / `readdirPaged(path, fd, offset, cb)` | Yes | Yes | Legacy mode returns one array with zero filler offsets. The mutually exclusive paged variant forwards the directory handle and signed 64-bit offset and requires one non-zero resume offset per entry. | +| `truncate(path, size, cb)` | Yes | Yes | Signed 64-bit transport; values outside the safe-number range are delivered as `bigint`. | +| `ftruncate(path, fd, size, cb)` | Yes | Yes | Signed 64-bit transport; values outside the safe-number range are delivered as `bigint`. | +| `utimens(path, atime, mtime, cb)` / `utimensWithTimespec(path, atime, mtime, cb)` / `utimensWithHandle(path, fd, atime, mtime, cb)` | Yes | Yes | The legacy variant transports signed milliseconds. Enhanced variants preserve seconds and nanoseconds exactly and safely enable `UTIME_NOW`/`UTIME_OMIT`; the handle-aware variant supports null paths. | +| `readlink(path, cb)` | Yes | Yes | Shared implementation; oversized targets are safely truncated and NUL-terminated to the caller-provided buffer. | +| `chown(path, uid, gid, cb)` / `chownWithHandle(path, fd, uid, gid, cb)` | Yes | Yes | The enhanced variant receives the FUSE 3 file handle and supports null paths. | +| `chmod(path, mode, cb)` / `chmodWithHandle(path, fd, mode, cb)` | Yes | Yes | The enhanced variant receives the FUSE 3 file handle and supports null paths. | +| `mknod(path, mode, dev, cb)` | Yes | Yes | Shared implementation; useful node types vary by host policy. | +| `setxattr(path, name, value, position, flags, cb)` | Yes | Yes | macOS native signature includes `position`; Linux supplies position `0`. JavaScript receives a request-owned copy rather than borrowed kernel memory. | +| `getxattr(path, name, position, cb)` | Yes | Yes | Callback returns a `Buffer`; validated bytes are copied into the native request only on completion. Round trips, listing, removal, and resource forks are covered. | +| `listxattr(path, cb)` | Yes | Yes | Uses an exact UTF-8/NUL size probe, request-owned output storage, and returns `ERANGE` when the caller buffer is too small. | +| `removexattr(path, name, cb)` | Yes | Yes | Shared implementation. | +| `open(path, flags, cb)` | Yes | Yes | Shared implementation; primitive handles remain compatible and `{ fd, directIO, keepCache, nonseekable }` can set portable result bits. | +| `opendir(path, flags, cb)` | Yes | Yes | Forwards open flags and accepts the same validated file-info result as `open`. | +| `read(path, fd, buffer, length, position, cb)` | Yes | Yes | Signed 64-bit positions and lossless handles; uses owned output memory and copies only a validated byte count to FUSE. | +| `write(path, fd, buffer, length, position, cb)` | Yes | Yes | Signed 64-bit positions and lossless handles; JavaScript receives an owned copy of the native request bytes. | +| `release(path, fd, cb)` | Yes | Yes | Shared implementation; return value is ignored by FUSE. | +| `releasedir(path, fd, cb)` | Yes | Yes | Shared implementation. | +| `create(path, mode, cb)` / `createWithFlags(path, mode, flags, cb)` | Yes | Yes | Legacy mode is unchanged. The mutually exclusive enhanced variant also receives the original open flags; both accept enriched file-info results. | +| `unlink(path, cb)` | Yes | Yes | Shared implementation; deletion while an open handle survives is covered on macOS. | +| `rename(src, dest, cb)` / `renameWithFlags(src, dest, flags, cb)` | Yes | Yes | The legacy callback accepts unflagged renames only. The enhanced variant preserves FUSE 3 rename flags instead of silently discarding them. | +| `link(src, dest, cb)` | Yes | Yes | Shared implementation. | +| `symlink(src, dest, cb)` | Yes | Yes | Shared implementation. | +| `mkdir(path, mode, cb)` | Yes | Yes | Shared implementation. | +| `rmdir(path, cb)` | Yes | Yes | Shared implementation. | +| `destroy(cb)` | Yes | Yes | Called once after an orderly exit of an initialized filesystem. Native cleanup waits for completion before releasing the environment and libfuse resources. | +| `lock(path, fd, command, lock, cb)` | Yes | Yes | Portable POSIX record-lock fields are transported losslessly; `F_GETLK` may return an updated lock. | +| `flock(path, fd, operation, cb)` | Yes | Yes | Shared BSD whole-file locking implementation. | +| `bmap(path, blockSize, index, cb)` | Yes | Yes | Lossless block-index transport for block-device-backed filesystems. | +| `ioctl(path, fd, command, argument, flags, data, cb)` | Yes | Yes | Bounded request-owned payloads up to 1 MiB. Unsafe unrestricted retry/iovec requests return `EOPNOTSUPP`. | +| `poll(path, fd, cb)` / `pollWithHandle(path, fd, handle, cb)` | Yes | Yes | Legacy mode returns snapshot readiness. The enhanced variant safely retains an idempotent notification handle, supports delayed `notify()`, and closes every native handle during explicit close, finalization, or teardown. | +| `writeBuffer(path, fd, buffer, length, position, cb)` | Yes | Yes | Implements `write_buf`; generic vectors are flattened into request-owned bytes before JavaScript runs. Mutually exclusive with `write`. | +| `readBuffer(path, fd, length, position, cb)` | Yes | Yes | Implements `read_buf` with libfuse-compatible native ownership. Mutually exclusive with `read`. | +| `fallocate(path, fd, mode, offset, length, cb)` | Yes | Yes | Shared implementation with signed, lossless 64-bit range transport. | +| `copyFileRange(src, srcFd, srcOffset, dest, destFd, destOffset, length, flags, cb)` | Yes | Yes | FUSE 3 range copy with nullable paths and lossless handles/offsets. Result length is range-checked. | +| `lseek(path, fd, offset, whence, cb)` | Yes | Yes | FUSE 3 seek with lossless offsets, including `SEEK_DATA` and `SEEK_HOLE`. | +| Linux `statx` | Yes | Kernel fallback | Native high-level callback is compiled for libfuse 3.18+/SONAME 4; older Linux runtimes use the kernel's `getattr` fallback. | + +### Deprecated FUSE callbacks + +The portable callback contract from 1.x remains exposed. The two superseded +callbacks are intentionally represented by their modern equivalents: + +| Callback | Status | Decision | +| --- | --- | --- | +| `getdir`, `utime` | Deprecated | Do not add; use `readdir` and `utimens`. | + +### macFUSE-only callbacks not exposed + +macFUSE appends optional Darwin extensions to `struct fuse_operations`. +They are deliberately outside the portable callback scope: + +| Callback | Typical purpose | Initial-release decision | +| --- | --- | --- | +| `monitor` | Finder/file watcher count changes | Defer; macFUSE-specific. | +| `renamex` | Legacy macOS extension flags | Portable FUSE 3 flags use `renameWithFlags`; the separate legacy extension is not registered. | +| Darwin `statfs` ABI | Darwin `struct statfs` | Adapted to the portable JavaScript `statfs` result. | +| `setvolname` | Change volume name | Use the `volname` mount option. | +| `exchange` | Exchange two paths | Defer; unsupported on recent macOS versions. | +| `getxtimes`, `setbkuptime`, `setchgtime`, `setcrtime` | macOS extended timestamps | Defer; test portable timestamps first. | +| `chflags` | BSD file flags | Defer. | +| Compound Darwin `setattr` | Metadata updates | Adapted in a stable chmod/chown/truncate/utimens sequence. | + +## Mount-option matrix + +Every option is validated against the supported FUSE 3 contract before +`_fuseOptions()` serializes it. The constructor and `Fuse.validateOptions()` +reject unsupported, internal, platform-specific, conflicting, or incomplete +configurations before libfuse is invoked. + +| JavaScript option | Serialized option/behavior | Linux | macFUSE VFS | Notes | +| --- | --- | --- | --- | --- | +| `debug` | `debug` | Yes | Yes | Also enabled by the inherited `DEBUG` environment check. | +| `allowOther` | `allow_other` | Yes | Yes | Security-sensitive; Linux may require `user_allow_other`. Mutually exclusive with `allowRoot`. | +| `allowRoot` | `allow_root` | Yes | Yes | Mutually exclusive with `allowOther`. | +| `autoUnmount` | `auto_unmount` | Yes | Yes | Requires crash/interruption tests. | +| `defaultPermissions` | `default_permissions` | Yes | Yes | Boolean in both runtime and TypeScript declarations. | +| `blkdev` | `blkdev` | Yes | Rejected | Linux-only, privileged, and requires `fsname`. | +| `blksize` | `blksize=` | Yes | Rejected | Linux-only and requires `blkdev: true`. | +| `maxRead` | `max_read=` and matching `conn->max_read` | Yes | Yes | Both libfuse inputs receive the exact validated `uint32_t` value, including zero. When omitted, neither is set by fuse-napi. Configure it only as a constructor option; the kernel may enforce a lower request limit. | +| `fd` | Rejected | Internal | Internal | libfuse3 owns the FUSE device descriptor. | +| `userId` | Rejected | Internal | Internal | `fusermount3` owns `user_id`; use `uid` only to override returned ownership. | +| `fsname` | `fsname=` | Yes | Yes | Finder presentation also depends on `volname`. | +| `subtype` | `subtype=` | Yes | Yes | Reflected in platform mount information. | +| `kernelCache` | `kernel_cache` | Yes | Yes | Cannot be combined with `autoCache` or `directIo`. | +| `autoCache` | `auto_cache` | Yes | Yes | Cannot be combined with `kernelCache` or `directIo`. | +| `directIo` | `fuse_config.direct_io` | Yes | Yes | Cannot be combined with page-cache policies. This is separate from returning `directIO` for one `open` or `create` result. | +| `umask` | `umask=` | Yes | Yes | Explicit zero is preserved; chmod behavior is covered. | +| `uid` | `uid=` | Yes | Yes | Explicit zero is preserved; ownership behavior is covered. | +| `gid` | `gid=` | Yes | Yes | Explicit zero is preserved; ownership behavior is covered. | +| `entryTimeout` | `entry_timeout=` | Yes | Yes | Controls lookup caching. | +| `attrTimeout` | `attr_timeout=` | Yes | Yes | Controls attribute caching. | +| `acAttrTimeout` | `ac_attr_timeout=` | Yes | Yes | Requires `autoCache: true`. | +| `noforget` | `noforget` | Yes | Yes | Mutually exclusive with `remember`; high memory-retention risk. | +| `nonEmpty` | Rejected when enabled | Removed | Removed | FUSE 3 permits non-empty mountpoints without this FUSE 2 option. | +| `remember` | `remember=` | Yes | Yes | Mutually exclusive with `noforget`. | +| `modules` | `modules=` | Yes | Yes | Colon-separated module identifiers are validated; modules must still be installed. | +| `displayFolder` | `volname`, optionally `volicon` | Rejected | Yes | Uses `name` or mount basename; macFUSE-only. | +| `force` | Disconnected-mount recovery | Yes | Yes | JavaScript lifecycle option, not a FUSE mount option. Healthy mounts are preserved; lazy detach completion is verified with a finite deadline. | +| `mkdir` | Create missing mount point | Yes | Yes | JavaScript lifecycle option. | +| `timeout` | JavaScript callback timeout | Yes | Yes | Bounds callbacks and mount startup; per-operation `false` and zero values are preserved. | +| `name` | Source for `volname` | Rejected | Yes | Requires `displayFolder: true`. | +| `onError` | JavaScript exception reporter | Yes | Yes | Receives operation exceptions and rejected promises before the request is completed with `EIO`. | +| `maxConcurrency` | Fixed native request-worker count | Yes | Yes | Integer from 1 through 64; defaults to 4 and bounds threads, outstanding callbacks, and request memory. | +| `nullPathOk` | `fuse_config.nullpath_ok` | Yes | Yes | Allows `null` paths only when every configured affected operation has its handle-aware counterpart; invalid combinations are rejected in the constructor. | +| `noPath` | `fuse_config.nullpath_ok` compatibility mapping | Yes | Yes | Retains the 1.x behavior and enforces the same handle-aware callback requirements as `nullPathOk`. | + +`fuse.invalidateEntry(path, cb)` uses the portable FUSE notification protocol +to evict one cached namespace entry without unlinking it. It supports nested +paths on Linux, must run outside filesystem operation callbacks, and reports +`ENOSYS` when the connected kernel lacks entry-invalidation support. + +Historical native libfuse spellings are normalized before validation: +`allow_other`, `allow_root`, `auto_unmount`, `default_permissions`, +`max_read`, `user_id`, `kernel_cache`, `auto_cache`, `direct_io`, +`entry_timeout`, `attr_timeout`, `ac_attr_timeout`, `nonempty`, and `nopath`. +Removed or internal spellings therefore receive the same actionable FUSE 3 +error as their JavaScript equivalent. Supplying both forms with different +values is an error. + +String-valued mount options reject NUL, comma, backslash, and newline +characters instead of allowing one JavaScript value to inject additional +libfuse options. Numeric options must be finite and within their declared +integer constraints. + +Conformance failures are `TypeError` instances with one of these stable codes: +`ERR_FUSE_OPTION_REMOVED`, `ERR_FUSE_OPTION_INTERNAL`, +`ERR_FUSE_OPTION_CONFLICT`, `ERR_FUSE_OPTION_DEPENDENCY`, +`ERR_FUSE_OPTION_PLATFORM`, or `ERR_FUSE_OPTION_VALUE`. Their frozen +`options` array identifies the relevant canonical JavaScript option names. + +### macFUSE backend options + +The supported macFUSE 5.3.1+ runtime defaults to its VFS/kernel-extension +backend. Its public libfuse API also accepts `backend=fskit` on supported macOS +versions. `fuse-napi` does not +currently expose a dedicated backend option. + +The current implementation targets the VFS backend because it preserves the broadest +FUSE behavior and accepts the inherited temporary mount points. Selecting +FSKit later through macFUSE's public libfuse API would still avoid a direct +FSKit implementation, but must be explicitly scoped and tested because +macFUSE documents these current differences: + +- mount points must be under `/Volumes`; +- FUSE context is unavailable; +- most kernel-handled mount options are not implemented; +- notification APIs are unavailable; and +- performance is lower than the VFS backend. + +## Runtime compatibility + +Linux release packages contain ABI-specific Node.js 22/24/26 binaries for +both libfuse SONAME 3 and SONAME 4. The loader prefers a local source build, +then selects the modern SONAME 4 artifact when the runtime supports it, and +falls back to SONAME 3. Missing shared libraries produce `EFUSEDEPENDENCY` +with installation guidance. + +Every mount performs a runtime preflight. Linux checks `fusermount3`, +`/dev/fuse`, the loaded libfuse version, and `fuse.conf` policy when required. +macOS checks the macFUSE installation and the buffer-release capability used +by its custom event loop. libfuse 3.10.3 is the supported minimum. + +## Errno compatibility + +Callbacks return negated host errno values. The exported constants are +initialized with the inherited Linux values and then replaced by matching +values from the host's `os.constants.errno` where available. Linux therefore +retains its existing numbers while Darwin receives macOS values such as +`ENOSYS=-78`, `ENOTSUP=-45`, and `ETIMEDOUT=-60`. + +Linux-only constants that have no host equivalent remain available for API +compatibility. Applications should return the portable constant matching the +operation; `ENOTSUP` is explicitly exported for macOS extended-attribute +behavior. diff --git a/packages/computerd/vendor/fuse-napi/CONTRIBUTING.md b/packages/computerd/vendor/fuse-napi/CONTRIBUTING.md new file mode 100644 index 000000000..893077ae9 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +Thank you for proposing an improvement to `fuse-napi`. + +## Repository model + +The repository is publicly readable and forkable. Direct writes to maintained +branches are restricted to the maintainer: + +- `main` contains the current `2.x` release line. +- `1.0` contains the supported `1.x` maintenance line. +- External changes are proposed through pull requests. +- The maintainer decides whether and when a pull request is merged, tested, or + released. + +Do not include release version changes, release tags, generated prebuilds, or +npm publication steps in a pull request unless the maintainer explicitly asks +for them. + +## Before opening a pull request + +Keep each pull request focused and explain the user-visible behavior, supported +platforms, compatibility impact, and tests performed. Add tests and update +documentation for public API or behavior changes. + +When the required FUSE runtime is available, run: + +```sh +npm ci +npm run check +npm run test:types +npm run test:unit +npm run test:fuzz +npm test +``` + +`npm test` performs real mounts. See `README.md` for the Linux and macOS runtime +requirements. + +## CI and releases + +Pull requests and pushes intentionally do not start GitHub Actions. CI is +started manually by the maintainer after review. Prebuild workflows, tags, +GitHub Releases, and npm publication are also maintainer-only manual actions. + +## Security + +Do not report suspected vulnerabilities in an issue or pull request. Follow +the private reporting process in `SECURITY.md`. diff --git a/packages/computerd/vendor/fuse-napi/LICENSE b/packages/computerd/vendor/fuse-napi/LICENSE new file mode 100644 index 000000000..906f7c7b0 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) fuse-native and fuse-napi contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/computerd/vendor/fuse-napi/PATCHES.md b/packages/computerd/vendor/fuse-napi/PATCHES.md new file mode 100644 index 000000000..a6f20277e --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/PATCHES.md @@ -0,0 +1,113 @@ +# Vendored `fuse-napi` + +Upstream: [`fuse-napi`](https://www.npmjs.com/package/fuse-napi) **2.3.1**, MIT. +See `LICENSE` (retained unmodified) and `UPSTREAM.md` for the upstream's own +provenance notes. + +Vendored rather than depended on because the changes below are not upstream. + +## Why + +`fuse-native` links libfuse 2.9, which cannot negotiate `FOPEN_PASSTHROUGH`. +`fuse-napi` is a maintained fork of the same line built against libfuse 3, so +it is a much shorter path to passthrough than patching libfuse 2. + +## What we changed + +The passthrough changes live in `fuse-native.c`, `index.js`, and `index.d.ts`. +Native roots also add `removal-guard.c`, `removal-guard.h`, and +`confined-bind.c`, linked through `binding.gyp`. + +### `fuse-native.c` + +- Include ``, `` and ``, with + fallback definitions of `FUSE_DEV_IOC_BACKING_OPEN` / + `FUSE_DEV_IOC_BACKING_CLOSE` and `struct fuse_backing_map` for build hosts + whose `` predates them. +- `FUSE_HAS_BACKING_ID` guard: `fuse_file_info.backing_id` only exists from + libfuse 3.17, so an older header still compiles and simply never offers + passthrough. +- `fuse_native_backing_open(thread, fd)` and + `fuse_native_backing_close(thread, id)`: the `ioctl` that libfuse only + exposes through the low-level API. The high-level API reaches the same + session fd via `fuse_session_fd(fuse_get_session(fuse))`. Returns the id + (> 0) or `-errno`. +- `FUSE_APPLY_FILE_INFO_RESULT` reads an optional backing id from `argv[4]` + and writes it to `info->backing_id`. libfuse sets `FOPEN_PASSTHROUGH` + whenever that is > 0. +- The `open` / `opendir` / `create` signal arity went from 2 to 3 so + `argv[4]` is in range. +- Rejects a backing id combined with `keep_cache` or `direct_io` with + `EINVAL`. The kernel refuses that combination (`fs/fuse/iomode.c`) and the + failure would otherwise surface as an opaque `EIO` from `open()`. +- New init config field `max_backing_stack_depth` (mask bit 64, slot 7), + needed when the backing file's own filesystem is stacked. + +### `index.js` + +- `backingOpen(fd)` / `backingClose(id)` on the `Fuse` class, throwing an + `Error` with a `.code` errno name on failure. +- `backingId` accepted on the open/create result object, validated, and + refused alongside `keepCache` / `directIO`. +- `maxBackingStackDepth` init config field; the config array grew 7 -> 8. +- `Fuse.CAP_PASSTHROUGH` (bit 29) and `Fuse.CAP_ASYNC_READ`. +- `useIno` (`use_ino`) and `negativeTimeout` (`negative_timeout`) + options, which upstream does not expose, and there is no raw option + string to pass them through instead. computerd needs `use_ino` so hard + links stat as one inode, and sets `negative_timeout=0` explicitly so a + lookup miss is never cached. `negative_timeout` is still a mount + option, but `use_ino` moved into `struct fuse_config` in FUSE 3 and + `fuse_new()` rejects it on the command line, so it travels as a new + operation flag (`FUSE_OPERATION_FLAG_USE_INO`, 32) and the init hook + sets `config->use_ino`. + +### `removal-guard.c` (new), `removal-guard.h` (new), `binding.gyp` + +A removal guard for computerd's native roots: local-only directories it +bind-mounts onto the FUSE mount. `rmdir(2)` and `rename(2)` of a mount +point fail with `EBUSY` in the VFS before FUSE is consulted. + +- `fuse_native_guard_start(onDetached)` starts a thread, then installs a + seccomp filter with `SECCOMP_FILTER_FLAG_NEW_LISTENER` on the calling + thread, without `PR_SET_NO_NEW_PRIVS`, so it needs `CAP_SYS_ADMIN`. The + filter returns `SECCOMP_RET_USER_NOTIF` for `rmdir`, `rename`, + `renameat`, `renameat2`, and `unlinkat` with `AT_REMOVEDIR` (tested in + BPF), and allows everything else, including other architectures and + x32. Children forked from that thread inherit it. Returns 0 or `-errno`. +- The thread reads each trapped call's path with `process_vm_readv`, + resolves it lexically against `/proc//cwd` or `/proc//fd/N`, + and if it is a registered root, detaches it with + `umount2("/proc/self/fd/", MNT_DETACH)`, then answers + `SECCOMP_USER_NOTIF_FLAG_CONTINUE`. It answers `CONTINUE` on every path. + It never resolves a path under the FUSE mount, because the thread whose + call is trapped may be the one serving FUSE. The thread exists before + the filter, so it is not filtered itself. After a detach it calls + `onDetached(path)` through a threadsafe function, unreferenced so it + does not hold the event loop open. +- `fuse_native_guard_add_root(path, fd)` registers a root and takes + ownership of `fd`, an open directory at the root of the mount. + `fuse_native_guard_remove_root(path)` forgets one and closes its + descriptor. `fuse_native_guard_stats()` returns traps, detaches, roots. +- Linux on x86_64 and aarch64 only; elsewhere every entry point returns + `-ENOSYS`. Exported from `fuse-native.c`'s init and wrapped in + `index.js` as `Fuse.removalGuard`, typed in `index.d.ts`. + +### `confined-bind.c` (new), `binding.gyp` + +Native roots use asynchronous addon calls so resolving a directory under the +FUSE mount cannot block its JavaScript event loop. `bindRoot` resolves both +paths beneath pinned directory descriptors with `openat2` and rejects symlinks. +It clones the source with `open_tree` and attaches it to the pinned target +with `move_mount`, after making the parent and clone private. The source +identity is checked before attachment, so no path-based cleanup follows a +successful mount. `unbindRoot` and `openRoot` likewise resolve beneath the +pinned mount root; the latter hands its descriptor to the removal guard. +If these system calls are unavailable, the bind fails without running a +path-based mount command, leaving the directory served through FUSE. + +## Verified on this host + +Kernel 6.18, libfuse 3.17.2, btrfs backing store. A mount that requests +`CAP_PASSTHROUGH` at init and returns `{ fd, backingId }` from `open` served +reads and writes with the JavaScript `read` / `write` handlers never invoked, +including two concurrent opens of the same file sharing one refcounted id. diff --git a/packages/computerd/vendor/fuse-napi/README.md b/packages/computerd/vendor/fuse-napi/README.md new file mode 100644 index 000000000..77c473569 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/README.md @@ -0,0 +1,728 @@ +# fuse-napi + +Production-grade Node-API bindings for the FUSE 3 high-level API on Linux and +macOS. + +Install the stable package from npm: + +```sh +npm install fuse-napi +``` + +The public API follows semantic versioning. See [CHANGELOG.md](./CHANGELOG.md) +for release notes and migration details. + +This project starts from the exact published source of +[`@cocalc/fuse-native@2.4.3`](https://www.npmjs.com/package/@cocalc/fuse-native/v/2.4.3). +It preserves that package's callback-based JavaScript API while using +`FUSE_USE_VERSION=31`. The native addon uses Node-API, with no direct V8 API +dependency. + +The addon dynamically links an external FUSE 3 library: + +- Linux supports both the long-lived `libfuse3.so.3` ABI and the + `libfuse3.so.4` ABI introduced by libfuse 3.18. +- macOS uses the `libfuse3` runtime installed by + [macFUSE](https://macfuse.github.io/). + +Neither libfuse nor macFUSE is bundled or installed by this package. See +[UPSTREAM.md](./UPSTREAM.md) for reproducible provenance and +[COMPATIBILITY.md](./COMPATIBILITY.md) for callback and mount-option details. + +## Supported targets + +| Platform | Minimum runtime | Architectures | Tested Node.js | +| --- | --- | --- | --- | +| Linux | glibc 2.31 and libfuse 3.10.3+ | x86-64, arm64 | 22, 24, 26 | +| Alpine Linux | Alpine 3.23 and libfuse 3.17.3+ (musl) | x86-64, arm64 | 22, 24, 26 | +| macOS | macOS 12 and macFUSE 5.3.1+ with libfuse 3 | Intel x86-64, Apple Silicon arm64 | 22, 24, 26 | + +Release prebuilds are compiled on a glibc 2.31 Linux baseline, Alpine 3.23 for +musl, and with `MACOSX_DEPLOYMENT_TARGET=12.0`. Musl binaries carry an explicit +`.musl.node` filename tag and are selected automatically in Alpine containers. +The release workflow rejects binaries that raise a supported baseline +accidentally. + +## Requirements + +### Linux + +Building from source requires a C/C++ toolchain, `pkg-config`, and the system +libfuse 3 development package. Running a prebuild still requires the system +libfuse 3 runtime. + +On Debian or Ubuntu: + +```sh +sudo apt-get install build-essential fuse3 libfuse3-dev pkg-config +``` + +On Alpine Linux: + +```sh +apk add build-base fuse3 fuse3-dev linux-headers pkgconf python3 +``` + +### macOS + +[Install macFUSE](https://github.com/macfuse/macfuse/wiki/Getting-Started) +before installing or loading `fuse-napi`: + +```sh +brew install --cask macfuse +``` + +Approve the macFUSE system extension in System Settings when prompted and +restart macOS if requested. Apple Silicon systems can additionally require +enabling kernel extensions in Startup Security Utility from macOS Recovery. +Installation alone is not sufficient until macFUSE is enabled. + +No privileged package configuration command is installed or run. Host FUSE +installation and system-extension approval remain explicit administrator +tasks. + +`fuse-napi` requires macFUSE 5.3.1 or newer and verifies the installed product +version before every mount. It uses macFUSE's libfuse 3 compatibility API and +its default VFS backend. The public custom-loop API returns transport-owned buffers, while +current macFUSE releases expose the matching release function only as a +runtime symbol. `fuse-napi` resolves and verifies that capability before every +mount, so an incompatible macFUSE update fails safely with `EMACFUSEABI`. +It does not implement FSKit directly. If the dylib or runtime is unavailable, +installation/loading fails with an actionable macFUSE error. + +## Development + +```sh +npm ci +npm test +``` + +`npm test` performs real mount operations and therefore needs `/dev/fuse` plus +mount privileges on Linux, or an installed and approved macFUSE extension on +macOS. `npm run test:unit` runs only the non-mounting dependency, errno, +lifecycle, and option tests. `npm run test:fuzz` deterministically fuzzes the +public validation boundary, and `npm run test:soak` repeatedly mounts, +exercises, and unmounts the filesystem. + +Recovery from a deliberately crashed FUSE daemon has a stronger privilege +contract because deterministic cleanup requires `CAP_SYS_ADMIN` on hosted +Linux runners. It is isolated from the ordinary suite and can be run with: + +```sh +sudo env "PATH=$PATH" npm run test:privileged-recovery +``` + +The GitHub `CI` workflow is started manually with `workflow_dispatch`; pushes +and pull requests never trigger it. The manually started release workflow can +reuse the same matrix through `workflow_call`. In addition to the +platform/Node.js matrix, it builds against libfuse 3.18, exercises modern +syscalls, runs static analysis, deterministic fuzzing, ASan/UBSan, and a mount +soak test. The normal Linux matrix exercises the ordinary public API paths as +the unprivileged runner user, then runs the isolated crashed-mount recovery +suite behind an explicit non-interactive root boundary on every supported +OS/Node.js combination. ASan/UBSan covers that privileged recovery path as a +separate command, and the soak gate uses the same deterministic mount +capability boundary. Only the required execution configuration is forwarded +explicitly across each boundary. The libfuse 3.18/SONAME 4 gate exercises its +clean tarball consumer through a dependency-free syscall runner, so +development dependencies cannot mask packaging defects. + +The test suite is green on Linux arm64 with libfuse 3.10.3 and on Apple Silicon +with macFUSE 5.3.3. Hosted CI is configured to build both macOS architectures +but cannot load the macFUSE kernel extension. Real macOS mount integration is +therefore a documented manual release check on physical Intel and Apple +Silicon hosts. The automated release workflow performs exact-tarball mounts +and a readlink smoke test on Linux x64/arm64; maintainers repeat the +exact-tarball smoke test manually on both macOS architectures before +publication. + +## Migrating from `@cocalc/fuse-native` + +Change the package import; existing operation callbacks and mount methods are +retained: + +```js +const Fuse = require('fuse-napi') +``` + +Error constants are negated host errno values. Linux values remain unchanged; +macOS receives the corresponding Darwin values. On macOS, filesystems that +want NFC and NFD names to compare equal must normalize lookup keys themselves. + +## API + +In order to create a FUSE mountpoint, you first need to create a `Fuse` object that wraps a set of implemented FUSE syscall handlers: + +```js +const fuse = new Fuse(mnt, handlers, opts = {}) +``` + +Create a new `Fuse` object. + +`mnt` is the string path of your desired mountpoint. + +`handlers` is an object mapping syscall names to implementations. The complete list of available syscalls is described below. As an example, if you wanted to implement a filesystem that only supports `getattr`, your handle object would look like: + +```js +{ + getattr: function (path, cb) { + if (path === '/') { + cb(0, stat({ mode: 'dir', size: 4096 })); + return; + } + if (path === '/test') { + cb(0, stat({ mode: 'file', size: 11 })); + return; + } + cb(Fuse.ENOENT); + } +} +``` + +`opts` can include: + +```js +{ + debug: false, // Enable detailed tracing of operations. + force: false, // Recover a disconnected mount before remounting. + mkdir: false, // Create the mountpoint before mounting. + directIo: false, // Set fuse_config.direct_io for every opened file. + maxRead: 262144, // Set max_read and the matching init connection limit. + timeout: 15000, // Operation and mount-start timeout in milliseconds. + maxConcurrency: 4, // Fixed native request-worker count (1 through 64). + nullPathOk: false, // Accept null paths for unlinked handle operations. + noPath: false, // Avoid path reconstruction for handle operations. + onError: (error, operation, args) => { + // Report exceptions thrown by an operation implementation. + } +} +``` + +`force` only detaches a disconnected FUSE mount; it does not replace a healthy +mounted filesystem. Every unmount attempt is followed by a bounded stability +check, so the replacement mount does not race a lazy or concurrent detach. A +non-zero helper result is treated as an idempotent success only when repeated +observations prove that the mount is already detached. If the helper fails and +the mount remains attached, mounting fails with `EFUSEUNMOUNT` while retaining +both the helper and observation errors. A successful helper whose detach is +still not stably observable after 15 seconds fails with `EFUSEUNMOUNTWAIT`. +Unmount helpers inherit ordinary process settings such as `PATH`, but dynamic +loader injection variables (`LD_*` and `DYLD_*`) are removed before executing +the system helper. This keeps sanitizer, instrumentation, and application +preloads from changing the privileged unmount boundary. Helper standard output +and error output are retained on `helperError` when unmounting fails. + +On macOS, volume presentation can additionally be configured with: + +```js +{ + displayFolder: true, + name: 'Folder Name' +} +``` + +`timeout` can also be an object such as +`{ default: 15000, read: 30000, init: 5000 }`. Set an individual value or +`default` to `false` to disable that timeout deliberately. + +Each operation callback is accepted only once. A timeout, synchronous +exception, or rejected promise is translated to a FUSE error and cannot leave +the native worker blocked. Return values, buffer lengths, directory entries, +extended attributes, statistics, and mount options are validated before they +cross the native boundary. Unknown option and operation names are rejected so +configuration mistakes cannot silently change filesystem behavior. + +`Fuse.validateOptions(options)` performs the same synchronous validation +without creating a filesystem instance. Invalid combinations throw a +`TypeError` with a stable `code` and an `options` array. The FUSE 3 preflight +rejects removed or internal options (`nonempty`, `fd`, and `user_id`), +platform-specific misuse, unsafe module identifiers, and conflicting cache, +access, block-device, or inode-retention policies. For example: + +```js +Fuse.validateOptions({ + directIo: true, + nonempty: true +}) +// TypeError with code ERR_FUSE_OPTION_REMOVED +``` + +Remove `nonempty`; use `directIo` as shown when direct I/O is required. See +the 2.0.0 section in [CHANGELOG.md](./CHANGELOG.md) when upgrading from the +1.x line. + +For mount options with a native snake-case spelling, both the JavaScript name +and the historical libfuse name are accepted when the underlying FUSE 3 +concept still exists (for example, `directIo`/`direct_io` and +`allowOther`/`allow_other`). Inputs are normalized to the JavaScript name, and +conflicting aliases are rejected. + +`await Fuse.checkEnvironment(options)` performs the production runtime +preflight without mounting. It verifies the loaded libfuse version and +capabilities and, on Linux, `fusermount3`, read/write access to `/dev/fuse`, +and `user_allow_other` when `allowOther` or `allowRoot` is requested. Mounting +performs this check automatically. Failures have stable codes such as +`EFUSEHELPER`, `EFUSEDEVICE`, `EFUSEALLOWOTHER`, `EFUSEVERSION`, and +`EMACFUSEVERSION`, and `EMACFUSEABI`. On macOS the preflight reads the installed +macFUSE product version and rejects mounts below 5.3.1. + +The native request loop uses exactly `maxConcurrency` workers instead of +libfuse's dynamically growing multithreaded loop. This bounds native +threads, outstanding JavaScript callbacks, and memory use under load. Mount +startup runs outside the JavaScript event loop and remains subject to the +configured `init` deadline. + +### Namespace cache housekeeping + +Every distinct path resolved by the kernel creates a reachable high-level +libfuse node until the kernel sends `FORGET`. `release` closes a file handle; +it does not evict that namespace entry. Filesystems that expose a continuous +stream of one-shot names can ask the kernel to drop an inactive entry without +deleting the underlying file: + +```js +fuse.invalidateEntry('/archive/file-123', err => { + if (err) console.error(err) +}) +``` + +Call `invalidateEntry` only after the client-side `close()` has returned, or +from an independent housekeeping timer. Notifications run serially on a +bounded, dedicated native worker, so a kernel reverse-invalidation lock cannot +block the JavaScript event loop or exhaust libuv's shared worker pool. Never +call it from `getattr`, `open`, `release`, or another filesystem operation callback; +the method rejects operation-local calls with `EDEADLK` because Linux reverse +invalidation can otherwise deadlock with the originating request. The method +accepts nested paths, resolves the parent node, and sends portable +`FUSE_NOTIFY_INVAL_ENTRY`. macOS mounts require macFUSE 5.3.1+, whose VFS +backend actually evicts the notified name-cache entry. The method returns +`ENOSYS` if the connected kernel does not support the notification. At most +1,024 notifications wait per mount; a larger concurrent burst returns `EAGAIN` +instead of growing native memory without bound. A later access performs a fresh +lookup, so choose a retention window that balances memory against metadata traffic. + +For a larger usage example, see CoCalc's +[WebSocketFS FUSE integration](https://github.com/sagemathinc/websocketfs/tree/main/lib/fuse). + +### FUSE API + +The portable callback surface from 1.x remains supported on FUSE 3. +Deprecated `getdir` and `utime` are represented by `readdir` and `utimens`. +FUSE 3's merged `getattr`/`fgetattr` and `truncate`/`ftruncate` operations are +routed to the existing handlers based on whether a file handle is present. In +general the callback for each op should be called with +`cb(returnCode, [value])`, where the return code is a number (`0` for OK and +`< 0` for errors). See below for a list of POSIX error codes. + +File handles, file positions, sizes, inode counters, and other 64-bit values +are passed as a `number` while exactly representable and as a `bigint` +otherwise. Implementations must preserve a `bigint` file handle and return it +unchanged to their own storage layer. + +During an operation, `fuse.context()` returns a frozen snapshot containing +`uid`, `gid`, `pid`, `umask`, and the portable `fuse_file_info` fields. The +context is isolated across promises and other asynchronous work with +`AsyncLocalStorage`; outside an operation it returns `null`. When +`nullPathOk` or `noPath` is enabled, the affected handle-based callbacks must +accept `null` as their path. On Linux, `pid` identifies the calling thread and +can differ from `process.pid` when Node.js performs filesystem I/O in its +libuv worker pool. + +TypeScript: see [index.d.ts](./index.d.ts). + +#### `ops.init(cb)` + +Called on filesystem init. + +#### `ops.initWithConfig(connection, cb)` + +Enhanced, mutually exclusive alternative to `init`. `connection` is a frozen +snapshot of the portable FUSE 3 connection fields: +`protoMajor`, `protoMinor`, `asyncRead`, `maxWrite`, `maxReadahead`, +`maxRead`, `capable`, `want`, `maxBackground`, and `congestionThreshold`. + +The callback may return a conservative configuration containing `maxWrite`, +`maxReadahead`, `maxBackground`, `congestionThreshold`, `want`, and/or +`asyncRead`. +Requested limits may not exceed the values supplied by the kernel, `want` +must be a subset of `capable`, and the congestion threshold may not exceed +the background-request limit. Omitting the configuration preserves all +libfuse defaults. + +Set `maxRead` through the constructor options. When present, fuse-napi passes +the same validated unsigned 32-bit value as both `max_read=` and +`connection.maxRead`; this is required by libfuse 3. It is intentionally not +an `initWithConfig` result field because changing it after the mount options +have been processed would make the two libfuse values inconsistent. When the +option is omitted, fuse-napi leaves `conn->max_read` to libfuse and kernel +negotiation. The kernel may enforce a lower effective request size. + +#### `ops.access(path, mode, cb)` + +Called before the filesystem accessed a file + +#### `ops.statfs(path, cb)` + +Called when the filesystem is being stat'ed. Accepts a fs stat object after the return code in the callback. + +``` js +ops.statfs = function (path, cb) { + cb(0, { + bsize: 1000000, + frsize: 1000000, + blocks: 1000000, + bfree: 1000000, + bavail: 1000000, + files: 1000000, + ffree: 1000000, + favail: 1000000, + fsid: 1000000, + flag: 1000000, + namemax: 1000000 + }) +} +``` + +#### `ops.getattr(path, cb)` + +Called when a path is being stat'ed. Accepts a stat object (similar to the one returned in `fs.stat(path, cb)`) after the return code in the callback. + +``` js +ops.getattr = function (path, cb) { + cb(0, { + mtime: new Date(), + atime: new Date(), + ctime: new Date(), + size: 100, + mode: 16877, + uid: process.getuid(), + gid: process.getgid() + }) +} +``` + +#### `ops.fgetattr(path, fd, cb)` + +Same as above but is called when someone stats a file descriptor + +#### `ops.flush(path, fd, cb)` + +Called when a file descriptor is being flushed + +#### `ops.fsync(path, datasync, fd, cb)` + +Called when a file descriptor is being fsync'ed. + +#### `ops.fsyncdir(path, datasync, fd, cb)` + +Same as above but on a directory + +#### `ops.readdir(path, cb)` + +Called when a directory is being listed. Accepts an array of file/directory names after the return code in the callback + +``` js +ops.readdir = function (path, cb) { + cb(0, ['file-1.txt', 'dir']) +} +``` + +#### `ops.readdirPaged(path, fd, offset, cb)` + +Enhanced, mutually exclusive alternative to `readdir` for large or remote +directories. `fd` is the handle returned by `opendir`; `offset` is an opaque +signed 64-bit resume value. Return +`cb(0, names, stats, nextOffsets)`, where every name has a corresponding +non-zero next offset. The kernel can resume at the last accepted offset when +its output buffer is full. + +#### `ops.truncate(path, size, cb)` + +Called when a path is being truncated to a specific size + +#### `ops.ftruncate(path, fd, size, cb)` + +Same as above but on a file descriptor + +#### `ops.readlink(path, cb)` + +Called when a symlink is being resolved. Accepts a pathname (that the link should resolve to) after the return code in the callback + +``` js +ops.readlink = function (path, cb) { + cb(null, 'file.txt') // make link point to file.txt +} +``` + +Targets longer than the kernel-provided buffer are truncated and +NUL-terminated as required by the FUSE high-level callback contract. + +#### `ops.chown(path, uid, gid, cb)` + +Called when ownership of a path is being changed + +#### `ops.chmod(path:string, mode:number, cb)` + +Called when the mode of a path is being changed. Always called +with mode a number (not a string). + +#### `ops.mknod(path, mode, dev, cb)` + +Called when a new device file is being made. + +#### `ops.setxattr(path, name, value, position, flags, cb)` + +Called when extended attributes is being set (see the extended docs for your platform). + +`value` is a request-owned copy and can safely be consumed asynchronously. +Copy it only when your storage layer requires independent mutability or +lifetime management. + +The position argument is mostly a legacy argument only used on MacOS but see the getxattr docs +on Mac for more on that (you probably don't need to use that). + +#### `ops.getxattr(path, name, position, cb)` + +Called when extended attributes is being read. + +Return the extended attribute as the second argument to the callback (needs to be a buffer). +If no attribute is stored return `null` as the second argument. + +The position argument is mostly a legacy argument only used on MacOS but see the getxattr docs +on Mac for more on that (you probably don't need to use that). + +#### `ops.listxattr(path, cb)` + +Called when extended attributes of a path are being listed. + +Return a list of strings of the names of the attributes you have stored as the second argument to the callback. + +#### `ops.removexattr(path, name, cb)` + +Called when an extended attribute is being removed. + +#### `ops.open(path, flags, cb)` + +Called when a path is being opened. `flags` in a number containing the permissions being requested. Accepts a file descriptor after the return code in the callback. + +``` js +var toFlag = function(flags) { + flags = flags & 3 + if (flags === 0) return 'r' + if (flags === 1) return 'w' + return 'r+' +} + +ops.open = function (path, flags, cb) { + var flag = toFlag(flags) // convert flags to a node style string + ... + cb(0, 42) // 42 is a file descriptor +} +``` + +The callback may alternatively return +`{ fd, directIO, keepCache, nonseekable }`. This sets the corresponding +portable `fuse_file_info` result bits while retaining primitive file-handle +results for compatibility. `opendir` and both create variants accept the +same result object. + +#### `ops.opendir(path, flags, cb)` + +Same as above but for directories + +#### `ops.read(path, fd, buffer, length, position, cb)` + +Called when contents of a file is being read. You should write the result of the read to the `buffer` and return the number of bytes written as the first argument in the callback. +If no bytes were written (read is complete) return 0 in the callback. + +The buffer is owned by the JavaScript request. It remains valid if an +operation times out or teardown starts; completed bytes are copied back to +the kernel only after the callback result has been validated. + +``` js +var data = Buffer.from('hello world') + +ops.read = function (path, fd, buffer, length, position, cb) { + if (position >= data.length) return cb(0) // done + var part = data.slice(position, position + length) + part.copy(buffer) // write the result of the read to the result buffer + cb(part.length) // return the number of bytes read +} +``` + +#### `ops.write(path, fd, buffer, length, position, cb)` + +Called when a file is being written to. You can get the data being written in `buffer` and you should return the number of bytes written in the callback as the first argument. + +The write buffer is a request-owned copy, so asynchronous consumers never +reference kernel request memory after a timeout. + +``` js +ops.write = function (path, fd, buffer, length, position, cb) { + console.log('writing', buffer.slice(0, length)) + cb(length) // we handled all the data +} +``` + +#### `ops.release(path, fd, cb)` + +Called when a file descriptor is being released. Happens when a read/write is done etc. + +#### `ops.releasedir(path, fd, cb)` + +Same as above but for directories + +#### `ops.create(path, mode, cb)` + +Called when a new file is being opened. + +#### `ops.createWithFlags(path, mode, flags, cb)` + +Enhanced, mutually exclusive alternative to `create`. It also receives the +original FUSE/POSIX open flags. Its callback accepts either a primitive file +handle or the file-info result object described under `open`. + +#### `ops.utimens(path, atime, mtime, cb)` + +Called when the atime/mtime of a file is being changed. `atime` and `mtime` +are signed integer milliseconds since the Unix epoch and can be `bigint` +outside JavaScript's safe-integer range. + +#### `ops.utimensWithTimespec(path, atime, mtime, cb)` + +Mutually exclusive, lossless alternative to `utimens`. Both times are frozen +`{ seconds, nanoseconds }` values. Nanoseconds are preserved exactly and may +be `Fuse.UTIME_NOW` or `Fuse.UTIME_OMIT`; the native +`flag_utime_omit_ok` bit is enabled only for this variant. Stat timestamps may +also be returned in this timespec form. + +#### `ops.utimensWithHandle(path, fd, atime, mtime, cb)` + +Handle-aware alternative to both `utimens` variants. It receives a nullable +path, the open file handle, and the same lossless timespec values as +`utimensWithTimespec`. Use this variant when `nullPathOk` is enabled. + +#### `ops.chownWithHandle(path, fd, uid, gid, cb)` + +Handle-aware alternative to `chown`. The path can be `null` when +`nullPathOk` is enabled. + +#### `ops.chmodWithHandle(path, fd, mode, cb)` + +Handle-aware alternative to `chmod`. The path can be `null` when +`nullPathOk` is enabled. + +#### `ops.destroy(cb)` + +Called exactly once when an initialized filesystem exits through an orderly +libfuse teardown. Teardown waits for this callback, subject to the configured +operation deadline. JavaScript cannot be called once the Node-API environment +itself is already shutting down. + +#### `ops.lock(path, fd, command, lock, cb)` + +Handles portable POSIX record locks. `lock` is a frozen object with `type`, +`whence`, signed `start`, signed `length`, and `pid`. For `F_GETLK`, return an +updated lock as the second callback argument. + +#### `ops.flock(path, fd, operation, cb)` + +Handles BSD `flock` operations for filesystems that need remote locking. +Without this callback the kernel can still provide local locking. + +#### `ops.bmap(path, blockSize, index, cb)` + +Maps a file block to a device block. Return the mapped 64-bit index as the +second callback argument. This is meaningful for block-device-backed mounts. + +#### `ops.ioctl(path, fd, command, argument, flags, data, cb)` + +Handles bounded, well-formed FUSE ioctls. `argument` and file handles remain +lossless, and `data` is request-owned. Return a same-length output Buffer. +Unrestricted retry/iovec ioctls are rejected with `EOPNOTSUPP` because their +borrowed-pointer protocol cannot be represented safely by this callback API. +Payloads larger than 1 MiB are rejected before entering JavaScript. + +#### `ops.poll(path, fd, cb)` + +Returns a snapshot readiness event mask. + +#### `ops.pollWithHandle(path, fd, handle, cb)` + +Mutually exclusive, notification-capable alternative to `poll`. Return the +initial readiness mask through `cb`. When `handle` is non-null, call +`handle.notify()` after readiness changes so the kernel re-evaluates the +poll, then call `handle.close()` when no further notification is needed. +Handles are idempotent and are closed automatically during teardown. + +#### `ops.writeBuffer(path, fd, buffer, length, position, cb)` + +Mutually exclusive alternative to `write` implementing FUSE `write_buf`. +Generic memory/file-descriptor vectors are flattened into a request-owned +Buffer before JavaScript is called. Return the validated byte count. + +#### `ops.readBuffer(path, fd, length, position, cb)` + +Mutually exclusive alternative to `read` implementing FUSE `read_buf`. +Return `cb(0, buffer)` with at most `length` bytes; native storage is allocated +with the ownership rules required by libfuse. + +#### `ops.fallocate(path, fd, mode, offset, length, cb)` + +Allocates a signed 64-bit byte range for an open file. + +#### `ops.copyFileRange(src, srcFd, srcOffset, dest, destFd, destOffset, length, flags, cb)` + +Implements FUSE 3 `copy_file_range`. Return the copied byte count directly to +`cb`; negative values are treated as errno results. Paths can be `null` for +handle-based requests, and all offsets and handles retain 64-bit precision. + +#### `ops.lseek(path, fd, offset, whence, cb)` + +Implements FUSE 3 `lseek`, including `SEEK_DATA` and `SEEK_HOLE`. Return +`cb(0, resultingOffset)`. + +#### `ops.unlink(path, cb)` + +Called when a file is being unlinked. + +#### `ops.rename(src, dest, cb)` + +Called for an unflagged rename. A flagged request is rejected with +`EOPNOTSUPP` rather than silently losing its semantics. + +#### `ops.renameWithFlags(src, dest, flags, cb)` + +Mutually exclusive alternative to `rename` that receives the native FUSE 3 +rename flags, including Linux `RENAME_NOREPLACE`. + +#### `ops.link(src, dest, cb)` + +Called when a new link is created. + +#### `ops.symlink(src, dest, cb)` + +Called when a new symlink is created + +#### `ops.mkdir(path, mode, cb)` + +Called when a new directory is being created + +#### `ops.rmdir(path, cb)` + +Called when a directory is being removed + +## License + +MIT for these bindings. + +The bindings retain their upstream MIT license and attribution. The external +[libfuse](https://github.com/libfuse/libfuse) and +[macFUSE](https://github.com/macfuse/macfuse) installations retain their own +licenses. + +Security issues should be reported privately according to +[SECURITY.md](./SECURITY.md). Maintainer release procedures are documented in +[RELEASING.md](./RELEASING.md). Contributions are accepted through pull +requests under the maintainer-controlled process in +[CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/packages/computerd/vendor/fuse-napi/RELEASING.md b/packages/computerd/vendor/fuse-napi/RELEASING.md new file mode 100644 index 000000000..c9e901709 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/RELEASING.md @@ -0,0 +1,172 @@ +# Release procedure + +Release artifacts are built only by `.github/workflows/prebuilds.yml`. +Publication is performed manually, with npm two-factor authentication, from +the exact downloaded `npm-package` artifact after the manual macOS checks. +Never rebuild or run `npm pack` from a development checkout for publication. + +## One-time release-host configuration + +1. Require two-factor authentication for npm publication and verify that the + release maintainer has publish access to `fuse-napi`. +2. Protect `main` and immutable release tags so the manually initiated hosted + CI and artifact workflow cannot be bypassed. +3. Prepare both a physical Intel Mac and a physical Apple Silicon Mac with a + supported macOS release, an installed and approved macFUSE libfuse 3 + compatibility runtime, `pkg-config`, and supported Node.js versions. + +No self-hosted GitHub runners, npm trusted publisher, `npm-production` +environment, or `NPM_TOKEN` secret are required. Manual OTP publication does +not produce GitHub OIDC provenance. + +## Preparing a release + +1. Update `package.json` and both version fields in `package-lock.json`. +2. Finalize `CHANGELOG.md`, compatibility documentation, and migration notes. + The changelog must contain a dated section matching `package.json`, and its + `Unreleased` section must be empty before tagging. +3. Run: + + ```sh + npm ci + npm run check + npm run test:types + npm run test:unit + npm run test:fuzz + npm test + npm run test:soak + npm run verify:metadata + ``` + +4. Commit the complete release candidate and push it to `main`. +5. Start the `CI` workflow manually for that exact commit and wait for every + hosted job to pass. Pushes and pull requests intentionally do not start CI. +6. On both a physical Intel Mac and a physical Apple Silicon Mac, use a clean + checkout of the exact release commit and run: + + ```sh + npm ci + npm run check + npm run test:types + npm test + ``` + + Record the commit, architecture, macOS version, macFUSE version, Node.js + version, and result in the release notes. +7. Create an immutable annotated tag matching the package version exactly: + + ```sh + FUSE_NAPI_VERSION="$(node -p "require('./package.json').version")" + git tag -a "v$FUSE_NAPI_VERSION" -m "fuse-napi v$FUSE_NAPI_VERSION" + git push origin "v$FUSE_NAPI_VERSION" + unset FUSE_NAPI_VERSION + ``` + +8. In GitHub Actions, manually run `ABI-specific Node.js prebuilds` on the + exact release tag. Pushing a tag never starts this workflow or publishes + automatically. +9. Wait until the complete workflow is green and download its `npm-package` + artifact. It contains the exact tarball, `SHA256SUMS`, and the CycloneDX + SBOM. Verify the GitHub build-provenance and SBOM attestations for that + workflow run as well. +10. On both physical Macs, unpack separate copies of the artifact and run the + following from the unpacked artifact directory. Replace `CHECKOUT` with + the clean checkout of the same release tag: + + ```sh + CHECKOUT=/absolute/path/to/fuse-napi + shasum -a 256 -c SHA256SUMS + TARBALL="$(find "$PWD" -type f -name 'fuse-napi-*.tgz' -print -quit)" + test -n "$TARBALL" + CONSUMER="$(mktemp -d /tmp/fuse-napi-consumer.XXXXXX)" + npm install --prefix "$CONSUMER" --ignore-scripts "$TARBALL" + test ! -d "$CONSUMER/node_modules/fuse-napi/build" + node "$CHECKOUT/scripts/package-mount-smoke.js" \ + "$CONSUMER/node_modules/fuse-napi" + ``` + + Both machines must load the packaged native prebuild and complete the real + readlink mount smoke test. +11. Publish that same verified tarball manually. For a stable release use + `latest`; for a prerelease use `next`: + + ```sh + read -s -p "npm OTP: " FUSE_NAPI_OTP + echo + npm publish "$TARBALL" \ + --access public \ + --provenance=false \ + --tag latest \ + --otp="$FUSE_NAPI_OTP" + unset FUSE_NAPI_OTP + ``` + +12. Create a GitHub Release from the existing immutable tag. Do not let the + GitHub Release UI create or move the tag. Copy the finalized changelog entry + into the release notes, include the recorded Intel and Apple Silicon manual + test results, link the successful release workflow and npm package, and + attach the exact `fuse-napi-*.tgz`, `SHA256SUMS`, and + `fuse-napi-sbom.cdx.json` files from the verified `npm-package` artifact. + +Use a version such as `-rc.1` first when validating release +changes. +Prerelease tags publish under npm's `next` dist-tag; stable versions publish +under `latest`. + +## Automated release gates + +The manually confirmed workflow on the exact release tag: + +- rejects a tag that differs from `package.json` or `package-lock.json`; +- invokes the complete Linux and hosted macOS CI matrix as a hard dependency; +- builds 24 Node.js ABI-specific prebuilds: three supported Node.js ABIs + across four platform/architecture targets, plus both Linux architectures + for both glibc and musl libfuse SONAME 4; +- verifies architecture, libfuse 3 linkage, glibc 2.31, Alpine 3.23/musl, + and macOS 12 baselines; +- loads every matching prebuild on Node.js 22, 24, and 26; +- assembles one npm tarball containing all 24 binaries; +- verifies and installs that exact tarball on every supported Node.js, + platform, and architecture combination; +- loads the exact tarball in Alpine 3.23 containers on x64 and arm64; +- performs real mounts with that tarball on Linux x64/arm64 and runs the + modern syscall suite from the exact SONAME 4 package; +- records SHA-256 checksums and creates a CycloneDX production-dependency + SBOM; +- creates GitHub build-provenance and SBOM attestations; and +- uploads the verified `npm-package` artifact without publishing it. + +Real macOS mounts are intentionally manual. The release is not approved until +the full source suite and exact-tarball smoke test pass on physical Intel and +Apple Silicon hosts. + +## Post-release verification + +Verify that `main` contains the released commit, then check the registry +metadata and install the published artifact on at least one clean Linux and +one clean macOS host: + +```sh +FUSE_NAPI_VERSION="$(node -p "require('./package.json').version")" +test "$(git rev-parse "v$FUSE_NAPI_VERSION^{}")" = "$(git rev-parse origin/main)" +npm view "fuse-napi@$FUSE_NAPI_VERSION" version dist.integrity +npm install "fuse-napi@$FUSE_NAPI_VERSION" +node -e "require('fuse-napi')" +unset FUSE_NAPI_VERSION +``` + +Never reuse a published version or move an existing release tag. If a release +is defective, deprecate it on npm, document the reason, and publish a new +patch version. + +## Repository governance + +`main` is the current `2.x` release line. The `1.0` branch retains the supported +`1.x` maintenance line. Public users can use and fork the repository and submit +pull requests, but only the maintainer can merge or push to protected release +branches. + +Pushes and pull requests intentionally do not start CI. The maintainer reviews +a proposed change first, then manually starts CI when appropriate. Prebuild +creation, release tags, GitHub Releases, and npm publication are always manual +maintainer actions. diff --git a/packages/computerd/vendor/fuse-napi/SECURITY.md b/packages/computerd/vendor/fuse-napi/SECURITY.md new file mode 100644 index 000000000..ec2e3f25e --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/SECURITY.md @@ -0,0 +1,58 @@ +# Security policy + +## Supported versions + +| Version | Branch | Security updates | +| --- | --- | --- | +| 2.x | `main` | Yes | +| 1.x | `1.0` | Critical fixes during the documented transition window | +| 0.0.x | — | No | + +The latest `2.x` release is the actively supported line. Users should also +keep their operating-system libfuse 3 or supported macFUSE 5.3.1+ runtime +current. + +## Reporting a vulnerability + +Do not open a public issue for a suspected vulnerability. Report it privately +through the repository's +[GitHub Security Advisory form](https://github.com/mmdevries/fuse-napi/security/advisories/new). + +Include, where possible: + +- affected `fuse-napi`, Node.js, operating-system, and FUSE runtime versions; +- architecture and installation method; +- a minimal reproducer or crash trace; +- the expected impact and whether untrusted filesystem input is required; and +- any suggested mitigation. + +Reports should receive an acknowledgement within three business days and an +initial assessment within seven business days. Timelines for a coordinated +fix and disclosure depend on severity and whether upstream FUSE components are +involved. + +## Security boundaries + +`fuse-napi` validates the JavaScript/native boundary but does not sandbox a +filesystem implementation. Applications remain responsible for authorization, +path policy, data validation, secrets, and safe use of options such as +`allowOther`, `allowRoot`, and `defaultPermissions`. + +The package dynamically links the host's libfuse 3 or macFUSE 5.3.1+ runtime. +Vulnerabilities in libfuse, macFUSE, the kernel extension, or the operating +system should also be reported to their respective maintainers. + +Every mount performs a runtime preflight, but this does not replace host +patching or least-privilege configuration. Production deployments should +grant only the service account access to `/dev/fuse`, avoid `allowOther` +unless it is an explicit security requirement, and set bounded operation +timeouts. + +Release artifacts contain SHA-256 checksums and a CycloneDX SBOM and receive +GitHub build-provenance and SBOM attestations. Native CI includes static +analysis, deterministic boundary fuzzing, ASan/UBSan integration, and +repeated mount/teardown testing. LeakSanitizer is disabled for the mounted +Node.js process because the upstream Node runtime is not leak-clean at exit; +bounded RSS is checked separately by the soak test. The normal limit is +128 MiB; sanitizer CI permits 192 MiB to account for its bounded shadow and +quarantine memory. diff --git a/packages/computerd/vendor/fuse-napi/UPSTREAM.md b/packages/computerd/vendor/fuse-napi/UPSTREAM.md new file mode 100644 index 000000000..9856e7537 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/UPSTREAM.md @@ -0,0 +1,36 @@ +# Upstream provenance + +The initial source tree was imported from the npm package +`@cocalc/fuse-native@2.4.3`. + +- Package: https://www.npmjs.com/package/@cocalc/fuse-native/v/2.4.3 +- Source repository: https://github.com/sagemathinc/fuse-native +- Tarball: + https://registry.npmjs.org/@cocalc/fuse-native/-/fuse-native-2.4.3.tgz +- npm SHA-512 integrity: + `oINV1aDDPHTZkIIjDFPMAGFypdoi2Vsy2OB+uPljIibcosRW/oTkDsfRUKFPcHgRx7qwVlQ4rcHH2T7mYsxTTw==` +- Baseline commit: `527b2fa` +- Baseline tag: `cocalc-v2.4.3` + +The package tarball was used because the source repository does not contain a +Git tag for version 2.4.3. The baseline commit contains the tarball contents +without project-specific modifications. + +## FUSE runtime build provenance + +Baseline Linux prebuilds are linked against the distribution libfuse 3.10.3 +ABI (`libfuse3.so.3`) on Debian bullseye/glibc 2.31. Modern Linux prebuilds +are linked against libfuse 3.18.2 (`libfuse3.so.4`) built in the same baseline +container from: + +- Release: + https://github.com/libfuse/libfuse/releases/tag/fuse-3.18.2 +- Source archive SHA-256: + `f01de85717e20adf5f98aff324acd85dd73d61a5ca3834d573dcf0bd6e54a298` +- Meson 1.7.2 wheel SHA-256: + `82c6818dc81743c96de3a458f06175776ebfde4081195ea31ea6971838f25e38` + +Musl prebuilds are linked against Alpine 3.23's distribution +`libfuse3.so.4` runtime. The release workflow verifies both downloaded hashes, +the reported libfuse version, shared-library linkage, target architecture, +musl tagging, and maximum glibc symbol version before packaging. diff --git a/packages/computerd/vendor/fuse-napi/binding.gyp b/packages/computerd/vendor/fuse-napi/binding.gyp new file mode 100644 index 000000000..86050a65b --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/binding.gyp @@ -0,0 +1,67 @@ +{ + "targets": [{ + "target_name": "fuse", + 'variables': { + 'fuse__include_dirs%': ' +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +typedef struct { + char *source_root; + char *target_root; + char *relative; + napi_async_work work; + napi_deferred deferred; + int result; + int action; // 0 bind, 1 detach, 2 open +} bind_job; + +#if defined(__linux__) +static int valid_relative(const char *path) { + if (!path || !*path || *path == '/') return 0; + size_t size = strlen(path); + if (size >= PATH_MAX || path[size - 1] == '/') return 0; + const char *part = path; + while (*part) { + const char *end = strchr(part, '/'); + size_t length = end ? (size_t)(end - part) : strlen(part); + if (!length || (length == 1 && part[0] == '.') || + (length == 2 && part[0] == '.' && part[1] == '.')) return 0; + if (!end) break; + part = end + 1; + } + return 1; +} + +static int resolve_directory(int root, const char *path) { + struct open_how how = { + .flags = O_PATH | O_DIRECTORY | O_CLOEXEC, + .resolve = RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS + }; + return (int)syscall(SYS_openat2, root, path, &how, sizeof(how)); +} + +static int bind_confined(const char *source_root, const char *target_root, const char *relative) { + int result = -EINVAL; + int source_base = -1, target_base = -1, source = -1, target = -1, tree = -1; + struct stat source_stat, mounted_stat; + char tree_path[64], target_root_path[64]; + if (!valid_relative(relative)) return result; + source_base = open(source_root, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (source_base < 0) { result = -errno; goto done; } + target_base = open(target_root, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (target_base < 0) { result = -errno; goto done; } + source = resolve_directory(source_base, relative); + if (source < 0) { result = -errno; goto done; } + target = resolve_directory(target_base, relative); + if (target < 0) { result = -errno; goto done; } + if (fstat(source, &source_stat) != 0) { result = -errno; goto done; } + // The propagation of an attachment is controlled by the parent mount, + // not the cloned child's propagation. Make the FUSE mount private before + // attaching anything, or fail before creating a bind. + snprintf(target_root_path, sizeof(target_root_path), "/proc/self/fd/%d", target_base); + if (mount(NULL, target_root_path, NULL, MS_PRIVATE | MS_REC, NULL) != 0) { + result = -errno; + goto done; + } + tree = (int)syscall(SYS_open_tree, source, "", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | AT_EMPTY_PATH); + if (tree < 0) { result = -errno; goto done; } + // Verify the detached mount before attaching it. Nothing after move_mount + // re-resolves the target path or can fail and strand a new mount. + if (fstat(tree, &mounted_stat) != 0 || + source_stat.st_dev != mounted_stat.st_dev || source_stat.st_ino != mounted_stat.st_ino) { + result = -EIO; + goto done; + } + snprintf(tree_path, sizeof(tree_path), "/proc/self/fd/%d", tree); + if (mount(NULL, tree_path, NULL, MS_PRIVATE | MS_REC, NULL) != 0) { + result = -errno; + goto done; + } + if (syscall(SYS_move_mount, tree, "", target, "", + MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_T_EMPTY_PATH) != 0) { + result = -errno; + goto done; + } + result = 0; +done: + if (tree >= 0) close(tree); + if (target >= 0) close(target); + if (source >= 0) close(source); + if (target_base >= 0) close(target_base); + if (source_base >= 0) close(source_base); + return result; +} +static int open_confined(const char *target_root, const char *relative) { + if (!valid_relative(relative)) return -EINVAL; + int base = open(target_root, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (base < 0) return -errno; + int target = resolve_directory(base, relative); + int result = target < 0 ? -errno : target; + close(base); + return result; +} + +static int unbind_confined(const char *target_root, const char *relative) { + if (!valid_relative(relative)) return -EINVAL; + int base = open(target_root, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (base < 0) return -errno; + int target = resolve_directory(base, relative); + int result; + if (target < 0) { + result = -errno; + } else { + char path[64]; + snprintf(path, sizeof(path), "/proc/self/fd/%d", target); + result = umount2(path, MNT_DETACH) == 0 ? 0 : -errno; + close(target); + } + close(base); + return result; +} +#endif + +static void execute_bind(napi_env env, void *data) { + (void)env; + bind_job *job = data; +#if defined(__linux__) + job->result = job->action == 1 ? unbind_confined(job->target_root, job->relative) + : job->action == 2 ? open_confined(job->target_root, job->relative) + : bind_confined(job->source_root, job->target_root, job->relative); +#else + job->result = -ENOSYS; +#endif +} + +static void complete_bind(napi_env env, napi_status status, void *data) { + bind_job *job = data; + napi_value value; +#if defined(__linux__) + if (status != napi_ok && job->action == 2 && job->result >= 0) close(job->result); +#endif + napi_create_int32(env, status == napi_ok ? job->result : -EIO, &value); + napi_resolve_deferred(env, job->deferred, value); + napi_delete_async_work(env, job->work); + free(job->source_root); + free(job->target_root); + free(job->relative); + free(job); +} + +static char *copy_argument(napi_env env, napi_value value) { + size_t length; + if (napi_get_value_string_utf8(env, value, NULL, 0, &length) != napi_ok || length >= 4096) + return NULL; + char *str = malloc(length + 1); + if (!str) return NULL; + size_t read; + if (napi_get_value_string_utf8(env, value, str, length + 1, &read) != napi_ok || + read != length || strlen(str) != length) { + free(str); + return NULL; + } + return str; +} + +static napi_value queue_bind(napi_env env, napi_callback_info info, int action) { + napi_value args[3], promise, label; + size_t argc = 3; + if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok || + argc != (action ? 2u : 3u)) { + napi_throw_type_error(env, NULL, "bindRoot requires two roots and a relative path"); + return NULL; + } + bind_job *job = calloc(1, sizeof(*job)); + if (!job) { napi_throw_error(env, NULL, "Out of memory"); return NULL; } + job->action = action; + job->result = -EIO; + job->source_root = action ? NULL : copy_argument(env, args[0]); + job->target_root = copy_argument(env, args[action ? 0 : 1]); + job->relative = copy_argument(env, args[action ? 1 : 2]); + if ((!action && !job->source_root) || !job->target_root || !job->relative) { + free(job->source_root); free(job->target_root); free(job->relative); free(job); + napi_throw_type_error(env, NULL, "bindRoot requires short, NUL-free strings"); + return NULL; + } + if (napi_create_promise(env, &job->deferred, &promise) != napi_ok || + napi_create_string_utf8(env, "confined bind root", NAPI_AUTO_LENGTH, &label) != napi_ok || + napi_create_async_work(env, NULL, label, execute_bind, complete_bind, job, &job->work) != napi_ok || + napi_queue_async_work(env, job->work) != napi_ok) { + if (job->work != NULL) napi_delete_async_work(env, job->work); + free(job->source_root); free(job->target_root); free(job->relative); free(job); + napi_throw_error(env, NULL, "Cannot queue confined bind"); + return NULL; + } + return promise; +} + +napi_value fuse_native_bind_root(napi_env env, napi_callback_info info) { + return queue_bind(env, info, 0); +} + +napi_value fuse_native_unbind_root(napi_env env, napi_callback_info info) { + return queue_bind(env, info, 1); +} + +napi_value fuse_native_open_root(napi_env env, napi_callback_info info) { + return queue_bind(env, info, 2); +} diff --git a/packages/computerd/vendor/fuse-napi/fuse-native.c b/packages/computerd/vendor/fuse-napi/fuse-native.c new file mode 100644 index 000000000..9671abf11 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/fuse-native.c @@ -0,0 +1,4204 @@ +#define FUSE_USE_VERSION 31 + +/* + * macFUSE's libfuse 3 ABI has Darwin-specific operation signatures for + * metadata and resource-fork support. Keep those extensions enabled on + * macOS and bridge them to the portable JavaScript contract with explicit + * adapters below. + */ +#ifdef __APPLE__ +#define FUSE_DARWIN_ENABLE_EXTENSIONS 1 +#endif + +#include +#include +#include +#include "removal-guard.h" +napi_value fuse_native_bind_root(napi_env env, napi_callback_info info); +napi_value fuse_native_unbind_root(napi_env env, napi_callback_info info); +napi_value fuse_native_open_root(napi_env env, napi_callback_info info); + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) && defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdollar-in-identifier-extension" +#endif +#if defined(__GNUC__) +#pragma GCC diagnostic push +/* + * Some supported libfuse headers contain file-scope static-assert macros with + * a trailing semicolon. Keep pedantic diagnostics strict for this addon while + * treating that external header implementation as a system boundary. + */ +#pragma GCC diagnostic ignored "-Wpedantic" +#endif +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include + +/* Older build hosts may predate the passthrough ioctls. */ +#ifndef FUSE_DEV_IOC_BACKING_OPEN +#ifndef FUSE_DEV_IOC_MAGIC +#define FUSE_DEV_IOC_MAGIC 229 +#endif +struct fuse_backing_map { + int32_t fd; + uint32_t flags; + uint64_t padding[3]; +}; +#define FUSE_DEV_IOC_BACKING_OPEN \ + _IOW(FUSE_DEV_IOC_MAGIC, 1, struct fuse_backing_map) +#define FUSE_DEV_IOC_BACKING_CLOSE _IOW(FUSE_DEV_IOC_MAGIC, 2, uint32_t) +#endif +#endif +#include +#if defined(__GNUC__) +#pragma GCC diagnostic pop +#endif +#if defined(__APPLE__) && defined(__clang__) +#pragma clang diagnostic pop +#endif + +#include +#include +#include +#include +#include +#ifdef __linux__ +#include +#include +#include +#endif + +#ifdef __APPLE__ +#include +/* + * macFUSE can return transport-owned message buffers from the public custom + * event-loop API, but currently omits the corresponding release function from + * its headers. Resolve the helper at runtime so a changed backend ABI becomes + * a controlled mount failure rather than a load-time linker failure. + */ +typedef void (*fuse_native_buf_free_fn)(struct fuse_buf *buf); +#endif + +typedef struct fuse_thread_s fuse_thread_t; +typedef struct fuse_thread_locals_s fuse_thread_locals_t; +typedef struct fuse_worker_s fuse_worker_t; +typedef struct fuse_poll_registration_s fuse_poll_registration_t; +typedef struct fuse_invalidation_request_s fuse_invalidation_request_t; +#if defined(__APPLE__) && FUSE_DARWIN_ENABLE_EXTENSIONS +typedef fuse_darwin_fill_dir_t fuse_native_fill_dir_t; +#else +typedef fuse_fill_dir_t fuse_native_fill_dir_t; +#endif +typedef void (*fuse_dispatch_fn)(uv_async_t *, fuse_thread_locals_t *, fuse_thread_t *); +static void fuse_native_complete_local(fuse_thread_locals_t *l, int32_t result); +static void fuse_native_release_local_payload(fuse_thread_locals_t *l); +static void fuse_native_capture_context(fuse_thread_locals_t *l); +static int fuse_native_schedule_local(fuse_thread_locals_t *l); +static void fuse_native_close_poll_registration(fuse_poll_registration_t *registration); +static void fuse_native_release_poll_registration(fuse_poll_registration_t *registration); +static void fuse_native_dispose_mount( + struct fuse *fuse, + int mounted +); +static napi_status create_request_context_value( + napi_env env, + const fuse_thread_locals_t *l, + napi_value *result +); +static napi_status initialize_callback_arguments( + napi_env env, + napi_value *argv, + size_t argc +); + +#define FUSE_NATIVE_CALLBACK(fn, blk)\ + napi_env env = ft->env;\ + napi_handle_scope scope;\ + if (napi_open_handle_scope(env, &scope) != napi_ok) {\ + fuse_native_complete_local(l, -EIO);\ + return;\ + }\ + napi_value ctx;\ + napi_value callback;\ + if (napi_get_reference_value(env, ft->ctx, &ctx) != napi_ok ||\ + napi_get_reference_value(env, fn, &callback) != napi_ok) {\ + napi_close_handle_scope(env, scope);\ + fuse_native_complete_local(l, -EIO);\ + return;\ + }\ + blk\ + napi_close_handle_scope(env, scope); + +#define FUSE_NATIVE_HANDLER(name, blk)\ + fuse_thread_locals_t *l = get_thread_locals();\ + if (l == NULL) return -EIO;\ + l->info = NULL;\ + l->info_out = NULL;\ + l->owned_input = NULL;\ + l->pollhandle = NULL;\ + l->poll_registration = NULL;\ + l->signed_result = 0;\ + l->op = op_##name;\ + l->op_fn = fuse_native_dispatch_##name;\ + blk\ + fuse_native_capture_context(l);\ + atomic_store(&(l->waiting), 1);\ + if (fuse_native_schedule_local(l) < 0) {\ + atomic_store(&(l->waiting), 0);\ + fuse_native_release_local_payload(l);\ + return -EIO;\ + }\ + uv_sem_wait(&(l->sem));\ + return l->res; + +#define FUSE_CALL_CALLBACK(argc, argv)\ + napi_status callback_status = napi_make_callback(env, NULL, ctx, callback, argc, argv, NULL);\ + if (callback_status != napi_ok) {\ + napi_value callback_exception = NULL;\ + if (callback_status == napi_pending_exception) {\ + napi_get_and_clear_last_exception(env, &callback_exception);\ + }\ + napi_close_handle_scope(env, scope);\ + fuse_native_complete_local(l, -EIO);\ + if (callback_exception != NULL) napi_fatal_exception(env, callback_exception);\ + return;\ + } + +#define FUSE_METHOD(name, callbackArgs, signalArgs, signature, callBlk, callbackBlk, signalBlk)\ + static void fuse_native_dispatch_##name (uv_async_t* handle, fuse_thread_locals_t* l, fuse_thread_t* ft) {\ + (void) handle;\ + uint32_t op = op_##name;\ + FUSE_NATIVE_CALLBACK(ft->handlers[op], {\ + napi_value argv[callbackArgs + 3] = {0};\ + if (initialize_callback_arguments(env, argv, callbackArgs + 3) != napi_ok ||\ + napi_get_reference_value(env, l->self, &(argv[0])) != napi_ok ||\ + napi_create_uint32(env, l->op, &(argv[1])) != napi_ok) {\ + napi_close_handle_scope(env, scope);\ + fuse_native_complete_local(l, -EIO);\ + return;\ + }\ + callbackBlk\ + if (create_request_context_value(env, l, &(argv[callbackArgs + 2])) != napi_ok) {\ + napi_close_handle_scope(env, scope);\ + fuse_native_complete_local(l, -EIO);\ + return;\ + }\ + FUSE_CALL_CALLBACK(callbackArgs + 3, argv)\ + })\ + }\ + NAPI_METHOD(fuse_native_signal_##name) {\ + NAPI_ARGV(signalArgs + 2)\ + NAPI_ARGV_BUFFER_CAST(fuse_thread_locals_t *, l, 0);\ + NAPI_ARGV_INT32(res, 1);\ + signalBlk\ + fuse_native_complete_local(l, res);\ + return NULL;\ + }\ + static int fuse_native_##name signature {\ + FUSE_NATIVE_HANDLER(name, callBlk)\ + } + +#define FUSE_METHOD_VOID(name, callbackArgs, signalArgs, signature, callBlk, callbackBlk)\ + FUSE_METHOD(name, callbackArgs, signalArgs, signature, callBlk, callbackBlk, {}) + +#define FUSE_UINT64_TO_INTS_ARGV(n, pos)\ + uint64_t bits##pos = (uint64_t) (n);\ + uint32_t low##pos = (uint32_t) (bits##pos & UINT32_MAX);\ + uint32_t high##pos = (uint32_t) (bits##pos >> 32);\ + napi_create_uint32(env, low##pos, &(argv[pos]));\ + napi_create_uint32(env, high##pos, &(argv[pos + 1])); + +#define FUSE_CREATE_UINT64_ARGV(n, pos)\ + if (create_uint64_value(env, (uint64_t) (n), &(argv[pos])) != napi_ok) {\ + fuse_native_complete_local(l, -EIO);\ + napi_close_handle_scope(env, scope);\ + return;\ + } + +#define FUSE_CREATE_OWNED_BUFFER_ARGV(source, length, pos)\ + if (create_owned_buffer(env, source, length, &(argv[pos])) != napi_ok) {\ + fuse_native_complete_local(l, -EIO);\ + napi_close_handle_scope(env, scope);\ + return;\ + } + +#define FUSE_CREATE_PATH_ARGV(path, pos)\ + napi_status path_status##pos = (path) == NULL\ + ? napi_get_null(env, &(argv[pos]))\ + : napi_create_string_utf8(env, (path), NAPI_AUTO_LENGTH, &(argv[pos]));\ + if (path_status##pos != napi_ok) {\ + fuse_native_complete_local(l, -EIO);\ + napi_close_handle_scope(env, scope);\ + return;\ + } + +#define FUSE_CREATE_FILE_HANDLE_ARGV(pos)\ + FUSE_CREATE_UINT64_ARGV(l->info == NULL ? 0 : l->info->fh, pos) + + +// Opcodes + +static const uint32_t op_init = 0; +static const uint32_t op_error = 1; +static const uint32_t op_access = 2; +static const uint32_t op_statfs = 3; +static const uint32_t op_fgetattr = 4; +static const uint32_t op_getattr = 5; +static const uint32_t op_flush = 6; +static const uint32_t op_fsync = 7; +static const uint32_t op_fsyncdir = 8; +static const uint32_t op_readdir = 9; +static const uint32_t op_truncate = 10; +static const uint32_t op_ftruncate = 11; +static const uint32_t op_utimens = 12; +static const uint32_t op_readlink = 13; +static const uint32_t op_chown = 14; +static const uint32_t op_chmod = 15; +static const uint32_t op_mknod = 16; +static const uint32_t op_setxattr = 17; +static const uint32_t op_getxattr = 18; +static const uint32_t op_listxattr = 19; +static const uint32_t op_removexattr = 20; +static const uint32_t op_open = 21; +static const uint32_t op_opendir = 22; +static const uint32_t op_read = 23; +static const uint32_t op_write = 24; +static const uint32_t op_release = 25; +static const uint32_t op_releasedir = 26; +static const uint32_t op_create = 27; +static const uint32_t op_unlink = 28; +static const uint32_t op_rename = 29; +static const uint32_t op_link = 30; +static const uint32_t op_symlink = 31; +static const uint32_t op_mkdir = 32; +static const uint32_t op_rmdir = 33; +static const uint32_t op_destroy = 34; +static const uint32_t op_lock = 35; +static const uint32_t op_bmap = 36; +static const uint32_t op_ioctl = 37; +static const uint32_t op_poll = 38; +static const uint32_t op_write_buf = 39; +static const uint32_t op_read_buf = 40; +static const uint32_t op_flock = 41; +static const uint32_t op_fallocate = 42; +static const uint32_t op_copy_file_range = 43; +static const uint32_t op_lseek = 44; +#define FUSE_OPERATION_COUNT 45 +#define FUSE_MAX_WORKERS 64 +#define FUSE_MAX_PENDING_INVALIDATIONS 1024 +#define FUSE_OPERATION_FLAG_NULL_PATH_OK 1U +#define FUSE_OPERATION_FLAG_NO_PATH 2U +#define FUSE_OPERATION_FLAG_UTIME_OMIT_OK 4U +#define FUSE_OPERATION_FLAG_DIRECT_IO 8U +#define FUSE_OPERATION_FLAG_POLL_HANDLE 16U +#define FUSE_OPERATION_FLAG_USE_INO 32U +#define FUSE_OPERATION_FLAGS_ALLOWED \ + (FUSE_OPERATION_FLAG_NULL_PATH_OK | FUSE_OPERATION_FLAG_NO_PATH | \ + FUSE_OPERATION_FLAG_UTIME_OMIT_OK | FUSE_OPERATION_FLAG_DIRECT_IO | \ + FUSE_OPERATION_FLAG_POLL_HANDLE | FUSE_OPERATION_FLAG_USE_INO) + +// Data structures + +struct fuse_thread_s { + napi_env env; + uv_loop_t *loop; + pthread_t thread; + pthread_attr_t attr; + napi_ref ctx; + napi_ref state_ref; + napi_ref cleanup_cb; + napi_ref loop_exit_cb; + napi_ref mount_cb; + + // Operation handlers + napi_ref handlers[FUSE_OPERATION_COUNT]; + uint8_t implemented[FUSE_OPERATION_COUNT]; + + struct fuse *fuse; + struct fuse_operations ops; + char *mnt; + char *mntopts; + int mounted; + int fuse_mounted; + uint32_t operation_flags; + bool max_read_configured; + uint32_t configured_max_read; + int mount_pending; + int mount_cleanup_pending; + int mount_error; + int thread_started; + int attr_initialized; + int mutex_initialized; + int workers_sem_initialized; + int loop_exit_async_initialized; + int invalidation_mutex_initialized; + int invalidation_cond_initialized; + int invalidation_thread_started; + int invalidation_active; + int invalidation_stopping; + atomic_int cleanup_scheduled; + atomic_int cleanup_requested; + atomic_int mount_cancelled; + int env_cleanup; + int cleanup_error; + int cleanup_thread_joined; + int cleanup_invalidation_thread_joined; + int fuse_cache_cleanup_started; + atomic_int loop_result; + size_t max_workers; + size_t workers_started; + size_t close_pending; + fuse_thread_locals_t *locals; + fuse_worker_t *workers; + fuse_poll_registration_t *polls; + fuse_invalidation_request_t *invalidation_head; + fuse_invalidation_request_t *invalidation_tail; + size_t invalidation_pending; + uint64_t next_poll_id; + napi_async_cleanup_hook_handle cleanup_hook; +#ifdef __APPLE__ + fuse_native_buf_free_fn buf_free; +#endif + + uv_async_t loop_exit_async; + uv_mutex_t mut; + uv_mutex_t invalidation_mut; + uv_cond_t invalidation_cond; + pthread_t invalidation_thread; + uv_sem_t workers_finished; + uv_work_t cleanup_work; + uv_work_t mount_work; +}; + +struct fuse_invalidation_request_s { + napi_threadsafe_function callback; + fuse_invalidation_request_t *next; + char *name; + uint64_t parent; + size_t name_length; + int32_t result; +}; + +struct fuse_worker_s { + fuse_thread_t *fuse; + fuse_thread_locals_t *locals; + pthread_t thread; + struct fuse_buf buffer; +}; + +struct fuse_poll_registration_s { + /* Immutable while either the registry or an in-flight request owns it. */ + fuse_thread_t *fuse; + struct fuse_pollhandle *handle; + uint64_t id; + fuse_poll_registration_t *next; + atomic_uint references; +}; + +struct fuse_thread_locals_s { + napi_ref self; + + // Opcode + uint32_t op; + fuse_dispatch_fn op_fn; + + // Payloads + const char *path; + const char *dest; + char *linkname; + struct fuse_file_info *info; + struct fuse_file_info *info_out; + struct fuse_conn_info *conn; + struct fuse_config *config; + const void *buf; + void *owned_input; + off_t offset; + off_t length; + size_t len; + mode_t mode; + int int_value; + dev_t dev; + uid_t uid; + gid_t gid; + struct timespec atime; + struct timespec mtime; + int32_t res; + int64_t signed_result; + uint32_t request_uid; + uint32_t request_gid; + uint32_t request_pid; + uint32_t request_umask; + + // Extended attributes + const char *name; + const char *value; + char *list; + size_t size; + uint32_t position; + int flags; + int cmd; + struct flock *lock; + uint64_t *bmap_index; + void *ioctl_data; + uintptr_t ioctl_argument; + struct fuse_pollhandle *pollhandle; + fuse_poll_registration_t *poll_registration; + unsigned *poll_revents; + struct fuse_bufvec **bufvec_out; + + // Stat + Statfs + struct stat *stat; + struct statvfs *statvfs; + + // Readdir + fuse_native_fill_dir_t readdir_filler; + + // Internal bookkeeping + fuse_thread_t *fuse; + uv_sem_t sem; + uv_async_t async; + int sem_initialized; + int async_initialized; + atomic_int waiting; + fuse_thread_locals_t *next; + +}; + +static void fuse_native_complete_local (fuse_thread_locals_t *l, int32_t result) { + if (atomic_exchange(&(l->waiting), 0) == 1) { + fuse_native_release_local_payload(l); + l->res = result; + uv_sem_post(&(l->sem)); + } +} + +static void fuse_native_invalidation_finalize ( + napi_env env, + void *finalize_data, + void *finalize_hint +) { + (void) env; + (void) finalize_hint; + fuse_invalidation_request_t *request = (fuse_invalidation_request_t *) finalize_data; + free(request->name); + free(request); +} + +static void fuse_native_invalidation_dispatch ( + napi_env env, + napi_value callback, + void *context, + void *data +) { + (void) context; + if (env == NULL || callback == NULL || data == NULL) return; + + fuse_invalidation_request_t *request = (fuse_invalidation_request_t *) data; + napi_value global; + napi_value result; + if (napi_get_global(env, &global) != napi_ok || + napi_create_int32(env, request->result, &result) != napi_ok) { + return; + } + + napi_status status = napi_call_function(env, global, callback, 1, &result, NULL); + if (status == napi_pending_exception) { + napi_value exception; + if (napi_get_and_clear_last_exception(env, &exception) == napi_ok) { + napi_fatal_exception(env, exception); + } + } +} + +static void fuse_native_complete_invalidation ( + fuse_invalidation_request_t *request, + int32_t result +) { + request->result = result; + napi_status status = napi_call_threadsafe_function( + request->callback, + request, + napi_tsfn_nonblocking + ); + napi_release_threadsafe_function( + request->callback, + status == napi_ok ? napi_tsfn_release : napi_tsfn_abort + ); +} + +static void* fuse_native_invalidation_worker (void *data) { + fuse_thread_t *ft = (fuse_thread_t *) data; + + while (true) { + uv_mutex_lock(&(ft->invalidation_mut)); + while (ft->invalidation_head == NULL && + !atomic_load(&(ft->cleanup_requested))) { + uv_cond_wait(&(ft->invalidation_cond), &(ft->invalidation_mut)); + } + + if (ft->invalidation_head == NULL && + atomic_load(&(ft->cleanup_requested))) { + uv_mutex_unlock(&(ft->invalidation_mut)); + break; + } + + fuse_invalidation_request_t *request = ft->invalidation_head; + ft->invalidation_head = request->next; + if (ft->invalidation_head == NULL) ft->invalidation_tail = NULL; + request->next = NULL; + if (ft->invalidation_pending > 0) ft->invalidation_pending--; + uv_mutex_unlock(&(ft->invalidation_mut)); + + int32_t result = -ENOTCONN; + struct fuse_session *session = NULL; + uv_mutex_lock(&(ft->invalidation_mut)); + if (!atomic_load(&(ft->cleanup_requested)) && + !ft->invalidation_stopping && + ft->mounted && + ft->fuse != NULL) { + session = fuse_get_session(ft->fuse); + ft->invalidation_active = session != NULL; + } + uv_mutex_unlock(&(ft->invalidation_mut)); + + if (session != NULL) { + result = fuse_lowlevel_notify_inval_entry( + session, + (fuse_ino_t) request->parent, + request->name, + request->name_length + ); + uv_mutex_lock(&(ft->invalidation_mut)); + ft->invalidation_active = 0; + uv_cond_broadcast(&(ft->invalidation_cond)); + uv_mutex_unlock(&(ft->invalidation_mut)); + } + fuse_native_complete_invalidation(request, result); + } + + return NULL; +} + +static void fuse_native_release_local_payload (fuse_thread_locals_t *l) { + free(l->owned_input); + l->owned_input = NULL; + if (l->pollhandle != NULL) { + fuse_pollhandle_destroy(l->pollhandle); + l->pollhandle = NULL; + } + if (l->poll_registration != NULL) { + fuse_native_close_poll_registration(l->poll_registration); + l->poll_registration = NULL; + } +} + +static fuse_poll_registration_t *fuse_native_register_poll ( + fuse_thread_t *ft, + struct fuse_pollhandle *handle +) { + if (handle == NULL || !ft->mutex_initialized) return NULL; + fuse_poll_registration_t *registration = calloc(1, sizeof(*registration)); + if (registration == NULL) return NULL; + + uv_mutex_lock(&(ft->mut)); + if (atomic_load(&(ft->cleanup_requested))) { + uv_mutex_unlock(&(ft->mut)); + free(registration); + return NULL; + } + ft->next_poll_id++; + if (ft->next_poll_id == 0) ft->next_poll_id++; + registration->fuse = ft; + registration->handle = handle; + registration->id = ft->next_poll_id; + registration->next = ft->polls; + /* + * The registry and the in-flight request each own one reference. JavaScript + * may close a PollHandle before its original callback completes, while + * teardown may concurrently close the entire registry. + */ + atomic_init(&(registration->references), 2); + ft->polls = registration; + uv_mutex_unlock(&(ft->mut)); + return registration; +} + +static void fuse_native_release_poll_registration ( + fuse_poll_registration_t *registration +) { + if (registration != NULL && + atomic_fetch_sub(&(registration->references), 1) == 1) { + free(registration); + } +} + +static void fuse_native_close_poll_registration ( + fuse_poll_registration_t *registration +) { + if (registration == NULL) return; + fuse_thread_t *ft = registration->fuse; + struct fuse_pollhandle *handle = NULL; + bool removed = false; + if (ft != NULL && ft->mutex_initialized) { + uv_mutex_lock(&(ft->mut)); + fuse_poll_registration_t **cursor = &(ft->polls); + while (*cursor != NULL && *cursor != registration) { + cursor = &((*cursor)->next); + } + if (*cursor == registration) { + *cursor = registration->next; + handle = registration->handle; + registration->handle = NULL; + removed = true; + } + uv_mutex_unlock(&(ft->mut)); + } + if (handle != NULL) fuse_pollhandle_destroy(handle); + if (removed) fuse_native_release_poll_registration(registration); + fuse_native_release_poll_registration(registration); +} + +static void fuse_native_close_all_polls (fuse_thread_t *ft) { + if (!ft->mutex_initialized) return; + uv_mutex_lock(&(ft->mut)); + fuse_poll_registration_t *registration = ft->polls; + ft->polls = NULL; + while (registration != NULL) { + fuse_poll_registration_t *next = registration->next; + if (registration->handle != NULL) { + fuse_pollhandle_destroy(registration->handle); + registration->handle = NULL; + } + fuse_native_release_poll_registration(registration); + registration = next; + } + uv_mutex_unlock(&(ft->mut)); +} + +static pthread_key_t thread_locals_key; +static pthread_once_t thread_locals_once = PTHREAD_ONCE_INIT; +static int thread_locals_status = 0; +static fuse_thread_locals_t* get_thread_locals(void); + +static void create_thread_locals_key (void) { + thread_locals_status = pthread_key_create(&(thread_locals_key), NULL); +} + +static void fuse_native_capture_context (fuse_thread_locals_t *l) { + struct fuse_context *ctx = fuse_get_context(); + if (ctx == NULL) { + l->request_uid = 0; + l->request_gid = 0; + l->request_pid = 0; + l->request_umask = 0; + return; + } + l->request_uid = (uint32_t) ctx->uid; + l->request_gid = (uint32_t) ctx->gid; + l->request_pid = (uint32_t) ctx->pid; + l->request_umask = (uint32_t) ctx->umask; +} + +static napi_status create_request_context_value ( + napi_env env, + const fuse_thread_locals_t *l, + napi_value *result +) { + napi_value arraybuffer; + void *raw_data = NULL; + napi_status status = napi_create_arraybuffer( + env, + 11 * sizeof(uint32_t), + &raw_data, + &arraybuffer + ); + if (status != napi_ok) return status; + uint32_t *data = (uint32_t *) raw_data; + data[0] = l->request_uid; + data[1] = l->request_gid; + data[2] = l->request_pid; + data[3] = l->request_umask; + data[4] = l->info == NULL ? 0U : 1U; + data[5] = l->info == NULL ? 0U : (uint32_t) l->info->flags; + data[6] = 0U; + data[7] = 0U; + data[8] = 0U; + data[9] = 0U; + data[10] = 0U; + if (l->info != NULL) { + if (l->info->writepage) data[6] |= 1U; + if (l->info->direct_io) data[6] |= 2U; + if (l->info->keep_cache) data[6] |= 4U; + if (l->info->flush) data[6] |= 8U; + if (l->info->nonseekable) data[6] |= 16U; + if (l->info->flock_release) data[6] |= 32U; + data[7] = (uint32_t) (l->info->fh & UINT32_MAX); + data[8] = (uint32_t) (l->info->fh >> 32); + data[9] = (uint32_t) (l->info->lock_owner & UINT32_MAX); + data[10] = (uint32_t) (l->info->lock_owner >> 32); + } + return napi_create_typedarray( + env, + napi_uint32_array, + 11, + arraybuffer, + 0, + result + ); +} + +static napi_status initialize_callback_arguments ( + napi_env env, + napi_value *argv, + size_t argc +) { + for (size_t i = 0; i < argc; i++) { + napi_status status = napi_get_undefined(env, &(argv[i])); + if (status != napi_ok) return status; + } + return napi_ok; +} + +// Helpers +// TODO: Extract into a separate file. + +static uint64_t uint32s_to_uint64 (uint32_t **ints) { + uint64_t low = *((*ints)++); + uint64_t high = *((*ints)++); + return (high << 32) | low; +} + +static int64_t uint32s_to_int64 (uint32_t **ints) { + return (int64_t) uint32s_to_uint64(ints); +} + +static int uint32s_to_timespec (struct timespec* ts, uint32_t** ints) { + int64_t seconds = uint32s_to_int64(ints); + uint32_t nanoseconds = *((*ints)++); + ts->tv_sec = (time_t) seconds; + ts->tv_nsec = (long) nanoseconds; + if ((int64_t) ts->tv_sec != seconds || + nanoseconds > 999999999U || + (uint32_t) ts->tv_nsec != nanoseconds) { + return -ERANGE; + } + return 0; +} + +static int populate_stat (uint32_t *ints, struct stat* stat) { + memset(stat, 0, sizeof(*stat)); + uint32_t mode = *ints++; + stat->st_mode = (mode_t) mode; + if ((uint32_t) stat->st_mode != mode) return -ERANGE; + stat->st_uid = *ints++; + stat->st_gid = *ints++; + int64_t size = uint32s_to_int64(&ints); + uint64_t dev = uint32s_to_uint64(&ints); + uint64_t nlink = uint32s_to_uint64(&ints); + uint64_t ino = uint32s_to_uint64(&ints); + uint64_t rdev = uint32s_to_uint64(&ints); + uint64_t blksize = uint32s_to_uint64(&ints); + uint64_t blocks = uint32s_to_uint64(&ints); + stat->st_size = (off_t) size; + stat->st_dev = (dev_t) dev; + stat->st_nlink = (nlink_t) nlink; + stat->st_ino = (ino_t) ino; + stat->st_rdev = (dev_t) rdev; + stat->st_blksize = (blksize_t) blksize; + stat->st_blocks = (blkcnt_t) blocks; + if (size < 0 || (int64_t) stat->st_size != size || + (uint64_t) stat->st_dev != dev || + (uint64_t) stat->st_nlink != nlink || + (uint64_t) stat->st_ino != ino || + (uint64_t) stat->st_rdev != rdev || + stat->st_blksize < 0 || (uint64_t) stat->st_blksize != blksize || + stat->st_blocks < 0 || (uint64_t) stat->st_blocks != blocks) { + return -ERANGE; + } +#ifdef __APPLE__ + if (uint32s_to_timespec(&stat->st_atimespec, &ints) != 0 || + uint32s_to_timespec(&stat->st_mtimespec, &ints) != 0 || + uint32s_to_timespec(&stat->st_ctimespec, &ints) != 0) { + return -ERANGE; + } +#else + if (uint32s_to_timespec(&stat->st_atim, &ints) != 0 || + uint32s_to_timespec(&stat->st_mtim, &ints) != 0 || + uint32s_to_timespec(&stat->st_ctim, &ints) != 0) { + return -ERANGE; + } +#endif + return 0; +} + +static int populate_statvfs (uint32_t *ints, struct statvfs* statvfs) { + memset(statvfs, 0, sizeof(*statvfs)); + uint64_t bsize = uint32s_to_uint64(&ints); + uint64_t frsize = uint32s_to_uint64(&ints); + uint64_t blocks = uint32s_to_uint64(&ints); + uint64_t bfree = uint32s_to_uint64(&ints); + uint64_t bavail = uint32s_to_uint64(&ints); + uint64_t files = uint32s_to_uint64(&ints); + uint64_t ffree = uint32s_to_uint64(&ints); + uint64_t favail = uint32s_to_uint64(&ints); + uint64_t fsid = uint32s_to_uint64(&ints); + uint64_t flag = uint32s_to_uint64(&ints); + uint64_t namemax = uint32s_to_uint64(&ints); + statvfs->f_bsize = (unsigned long) bsize; + statvfs->f_frsize = (unsigned long) frsize; + statvfs->f_blocks = (fsblkcnt_t) blocks; + statvfs->f_bfree = (fsblkcnt_t) bfree; + statvfs->f_bavail = (fsblkcnt_t) bavail; + statvfs->f_files = (fsfilcnt_t) files; + statvfs->f_ffree = (fsfilcnt_t) ffree; + statvfs->f_favail = (fsfilcnt_t) favail; + statvfs->f_fsid = (unsigned long) fsid; + statvfs->f_flag = (unsigned long) flag; + statvfs->f_namemax = (unsigned long) namemax; + if ((uint64_t) statvfs->f_bsize != bsize || + (uint64_t) statvfs->f_frsize != frsize || + (uint64_t) statvfs->f_blocks != blocks || + (uint64_t) statvfs->f_bfree != bfree || + (uint64_t) statvfs->f_bavail != bavail || + (uint64_t) statvfs->f_files != files || + (uint64_t) statvfs->f_ffree != ffree || + (uint64_t) statvfs->f_favail != favail || + (uint64_t) statvfs->f_fsid != fsid || + (uint64_t) statvfs->f_flag != flag || + (uint64_t) statvfs->f_namemax != namemax) { + return -ERANGE; + } + return 0; +} + +#if defined(__APPLE__) && FUSE_DARWIN_ENABLE_EXTENSIONS +static void stat_to_darwin_attr ( + const struct stat *source, + struct fuse_darwin_attr *destination +) { + memset(destination, 0, sizeof(*destination)); + destination->ino = source->st_ino; + destination->mode = source->st_mode; + destination->nlink = source->st_nlink; + destination->uid = source->st_uid; + destination->gid = source->st_gid; + destination->rdev = source->st_rdev; + destination->atimespec = source->st_atimespec; + destination->mtimespec = source->st_mtimespec; + destination->ctimespec = source->st_ctimespec; + destination->btimespec = source->st_birthtimespec; + destination->size = source->st_size; + destination->blocks = source->st_blocks; + destination->blksize = source->st_blksize; + destination->flags = source->st_flags; +} + +static int statvfs_to_darwin_statfs ( + const struct statvfs *source, + struct statfs *destination +) { + unsigned long io_size = + source->f_frsize == 0 ? source->f_bsize : source->f_frsize; + if (source->f_bsize > UINT32_MAX || io_size > INT32_MAX) { + return -ERANGE; + } + + memset(destination, 0, sizeof(*destination)); + destination->f_bsize = (uint32_t) source->f_bsize; + destination->f_iosize = (int32_t) io_size; + destination->f_blocks = (uint64_t) source->f_blocks; + destination->f_bfree = (uint64_t) source->f_bfree; + destination->f_bavail = (uint64_t) source->f_bavail; + destination->f_files = (uint64_t) source->f_files; + destination->f_ffree = (uint64_t) source->f_ffree; + return 0; +} +#endif + +static int fuse_native_fill_directory_entry ( + fuse_native_fill_dir_t filler, + void *buffer, + const char *name, + const struct stat *stat, + off_t next_offset +) { +#if defined(__APPLE__) && FUSE_DARWIN_ENABLE_EXTENSIONS + struct fuse_darwin_attr darwin_attr = {0}; + struct fuse_darwin_attr *darwin_attr_ptr = NULL; + if (stat != NULL) { + stat_to_darwin_attr(stat, &darwin_attr); + darwin_attr_ptr = &darwin_attr; + } + return filler( + buffer, + name, + darwin_attr_ptr, + next_offset, + (enum fuse_fill_dir_flags) 0 + ); +#else + return filler( + buffer, + name, + stat, + next_offset, + (enum fuse_fill_dir_flags) 0 + ); +#endif +} + +static napi_status create_uint64_value (napi_env env, uint64_t value, napi_value *result) { + if (value <= 9007199254740991ULL) { + return napi_create_double(env, (double) value, result); + } + return napi_create_bigint_uint64(env, value, result); +} + +static int value_to_uint64 (napi_env env, napi_value value, uint64_t *result) { + napi_valuetype type; + if (napi_typeof(env, value, &type) != napi_ok) return -1; + + if (type == napi_bigint) { + bool lossless = false; + if (napi_get_value_bigint_uint64(env, value, result, &lossless) != napi_ok || !lossless) return -1; + return 0; + } + + if (type == napi_number) { + double number; + if (napi_get_value_double(env, value, &number) != napi_ok) return -1; + if (!isfinite(number) || number < 0 || number > 9007199254740991.0 || floor(number) != number) return -1; + *result = (uint64_t) number; + return 0; + } + + return -1; +} + +static int get_uint32_array (napi_env env, napi_value value, uint32_t **data, size_t *length) { + bool is_typedarray = false; + napi_typedarray_type type; + size_t elements = 0; + napi_value arraybuffer; + size_t byte_offset = 0; + void *raw_data = NULL; + + if (napi_is_typedarray(env, value, &is_typedarray) != napi_ok || !is_typedarray) return -1; + if (napi_get_typedarray_info( + env, value, &type, &elements, &raw_data, &arraybuffer, &byte_offset + ) != napi_ok || + type != napi_uint32_array || + byte_offset % sizeof(uint32_t) != 0) { + return -1; + } + + *data = (uint32_t *) raw_data; + *length = elements; + return 0; +} + +static int value_to_int64_words (napi_env env, napi_value value, int64_t *result) { + uint32_t *words = NULL; + size_t length = 0; + if (get_uint32_array(env, value, &words, &length) != 0 || length != 2) { + return -1; + } + uint32_t *cursor = words; + *result = uint32s_to_int64(&cursor); + return 0; +} + +static napi_status create_owned_buffer ( + napi_env env, + const void *source, + size_t length, + napi_value *result +) { + void *data = NULL; + napi_status status = napi_create_buffer(env, length, &data, result); + if (status == napi_ok && source != NULL && length > 0) memcpy(data, source, length); + return status; +} + +static int copy_owned_buffer ( + napi_env env, + napi_value value, + void *destination, + size_t capacity, + size_t length +) { + bool is_buffer = false; + void *data = NULL; + size_t buffer_length = 0; + if (length > capacity || (length > 0 && destination == NULL)) return -ERANGE; + if (napi_is_buffer(env, value, &is_buffer) != napi_ok || + !is_buffer || + napi_get_buffer_info(env, value, &data, &buffer_length) != napi_ok || + buffer_length < length) { + return -EIO; + } + if (length > 0) memcpy(destination, data, length); + return 0; +} + +static int int64_to_off_t (int64_t value, off_t *result) { + *result = (off_t) value; + return (int64_t) *result == value ? 0 : -ERANGE; +} + +/* + * Darwin and Linux encode UTIME_NOW/UTIME_OMIT differently. The public + * JavaScript constants remain platform-neutral, so normalize at the native + * boundary. + */ +static uint32_t fuse_native_timespec_nanoseconds (long nanoseconds) { + if (nanoseconds == UTIME_NOW) return UINT32_C(0x3fffffff); + if (nanoseconds == UTIME_OMIT) return UINT32_C(0x3ffffffe); + return (uint32_t) nanoseconds; +} + +// Methods + +FUSE_METHOD(statfs, 1, 1, (const char * path, struct statvfs *statvfs), { + l->path = path; + l->statvfs = statvfs; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); +}, { + uint32_t *ints = NULL; + size_t ints_length = 0; + if (res == 0 && (get_uint32_array(env, argv[2], &ints, &ints_length) != 0 || ints_length != 22)) { + res = -EIO; + } else if (res == 0) { + res = populate_statvfs(ints, l->statvfs); + } +}) + +#if defined(__APPLE__) && FUSE_DARWIN_ENABLE_EXTENSIONS +static int fuse_native_statfs_darwin ( + const char *path, + struct statfs *statfs +) { + struct statvfs portable_stat = {0}; + int result = fuse_native_statfs(path, &portable_stat); + if (result != 0) return result; + return statvfs_to_darwin_statfs(&portable_stat, statfs); +} +#endif + +FUSE_METHOD(getattr, 1, 1, (const char *path, struct stat *stat), { + l->path = path; + l->stat = stat; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) +}, { + uint32_t *ints = NULL; + size_t ints_length = 0; + if (res == 0 && (get_uint32_array(env, argv[2], &ints, &ints_length) != 0 || ints_length != 26)) { + res = -EIO; + } else if (res == 0) { + res = populate_stat(ints, l->stat); + } +}) + +FUSE_METHOD(fgetattr, 2, 1, (const char *path, struct stat *stat, struct fuse_file_info *info), { + l->path = path; + l->stat = stat; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + if (l->info != NULL) { + FUSE_CREATE_UINT64_ARGV(l->info->fh, 3) + } else { + FUSE_CREATE_UINT64_ARGV(0, 3) + } +}, { + uint32_t *ints = NULL; + size_t ints_length = 0; + if (res == 0 && (get_uint32_array(env, argv[2], &ints, &ints_length) != 0 || ints_length != 26)) { + res = -EIO; + } else if (res == 0) { + res = populate_stat(ints, l->stat); + } +}) + +/* + * libfuse 3 merged getattr/fgetattr into one operation. Preserve both + * JavaScript handlers and select the handle-aware variant whenever libfuse + * supplies file information. + */ +#if defined(__APPLE__) && FUSE_DARWIN_ENABLE_EXTENSIONS +static int fuse_native_getattr_v3 ( + const char *path, + struct fuse_darwin_attr *attr, + struct fuse_file_info *info +) { + struct stat portable_stat = {0}; + struct fuse_context *context = fuse_get_context(); + fuse_thread_t *ft = context == NULL + ? NULL + : (fuse_thread_t *) context->private_data; + int result = -ENOSYS; + if (info != NULL && ft != NULL && ft->implemented[op_fgetattr]) { + result = fuse_native_fgetattr(path, &portable_stat, info); + } else if (ft != NULL && ft->implemented[op_getattr]) { + result = fuse_native_getattr(path, &portable_stat); + } + if (result == 0) stat_to_darwin_attr(&portable_stat, attr); + return result; +} +#else +static int fuse_native_getattr_v3 ( + const char *path, + struct stat *stat, + struct fuse_file_info *info +) { + struct fuse_context *context = fuse_get_context(); + fuse_thread_t *ft = context == NULL + ? NULL + : (fuse_thread_t *) context->private_data; + if (info != NULL && ft != NULL && ft->implemented[op_fgetattr]) { + return fuse_native_fgetattr(path, stat, info); + } + if (ft != NULL && ft->implemented[op_getattr]) { + return fuse_native_getattr(path, stat); + } + return -ENOSYS; +} +#endif + +FUSE_METHOD_VOID(access, 2, 0, (const char *path, int mode), { + l->path = path; + l->int_value = mode; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_int32(env, l->int_value, &(argv[3])); +}) + +#define FUSE_FILE_INFO_DIRECT_IO 1U +#define FUSE_FILE_INFO_KEEP_CACHE 2U +/* + * fuse_file_info.backing_id appeared in libfuse 3.17. Compiling against an + * older header is still supported; passthrough is simply never offered. + */ +#if defined(__linux__) && FUSE_VERSION >= 317 +#define FUSE_HAS_BACKING_ID 1 +#define FUSE_SET_BACKING_ID(info, id) (info)->backing_id = (int32_t) (id); +#else +#define FUSE_HAS_BACKING_ID 0 +#define FUSE_SET_BACKING_ID(info, id) (void) (id); +#endif + +#define FUSE_FILE_INFO_NONSEEKABLE 4U +#define FUSE_FILE_INFO_ALLOWED_FLAGS \ + (FUSE_FILE_INFO_DIRECT_IO | FUSE_FILE_INFO_KEEP_CACHE | FUSE_FILE_INFO_NONSEEKABLE) + +/* + * argv[4] carries an optional backing id from backingOpen(). libfuse sets + * FOPEN_PASSTHROUGH whenever backing_id > 0, which the kernel rejects if + * keep_cache is also set (fs/fuse/iomode.c), so that combination is refused + * here rather than surfacing as an EIO from open(). + */ +#define FUSE_APPLY_FILE_INFO_RESULT()\ + uint64_t fd = 0;\ + uint32_t result_flags = 0;\ + uint32_t backing_id = 0;\ + if (res == 0 &&\ + (value_to_uint64(env, argv[2], &fd) != 0 ||\ + napi_get_value_uint32(env, argv[3], &result_flags) != napi_ok ||\ + (result_flags & ~FUSE_FILE_INFO_ALLOWED_FLAGS) != 0)) {\ + res = -EINVAL;\ + }\ + if (res == 0) {\ + napi_valuetype backing_type = napi_undefined;\ + if (napi_typeof(env, argv[4], &backing_type) == napi_ok &&\ + backing_type == napi_number &&\ + napi_get_value_uint32(env, argv[4], &backing_id) != napi_ok) {\ + res = -EINVAL;\ + }\ + }\ + if (res == 0 && backing_id > 0 &&\ + (result_flags & (FUSE_FILE_INFO_KEEP_CACHE | FUSE_FILE_INFO_DIRECT_IO)) != 0) {\ + res = -EINVAL;\ + }\ + if (res == 0 && l->info != NULL) {\ + l->info->fh = fd;\ + l->info->direct_io = (result_flags & FUSE_FILE_INFO_DIRECT_IO) != 0;\ + l->info->keep_cache = (result_flags & FUSE_FILE_INFO_KEEP_CACHE) != 0;\ + l->info->nonseekable = (result_flags & FUSE_FILE_INFO_NONSEEKABLE) != 0;\ + FUSE_SET_BACKING_ID(l->info, backing_id)\ + } + +FUSE_METHOD(open, 2, 3, (const char *path, struct fuse_file_info *info), { + l->path = path; + l->info = info; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + if (l->info != NULL) { + napi_create_int32(env, l->info->flags, &(argv[3])); + } else { + napi_create_uint32(env, 0, &(argv[3])); + } +}, { + FUSE_APPLY_FILE_INFO_RESULT() +}) + +FUSE_METHOD(opendir, 2, 3, (const char *path, struct fuse_file_info *info), { + l->path = path; + l->info = info; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + if (l->info != NULL) { + napi_create_int32(env, l->info->flags, &(argv[3])); + } else { + napi_create_uint32(env, 0, &(argv[3])); + } +}, { + FUSE_APPLY_FILE_INFO_RESULT() +}) + +FUSE_METHOD(create, 3, 3, (const char *path, mode_t mode, struct fuse_file_info *info), { + l->path = path; + l->mode = mode; + l->info = info; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_uint32(env, l->mode, &(argv[3])); + if (l->info != NULL) { + napi_create_int32(env, l->info->flags, &(argv[4])); + } else { + napi_create_uint32(env, 0, &(argv[4])); + } +}, { + FUSE_APPLY_FILE_INFO_RESULT() +}) + +FUSE_METHOD_VOID(utimens, 8, 0, ( + const char *path, + const struct timespec tv[2], + struct fuse_file_info *info +), { + l->path = path; + l->atime = tv[0]; + l->mtime = tv[1]; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_UINT64_TO_INTS_ARGV(l->atime.tv_sec, 3) + napi_create_uint32( + env, + fuse_native_timespec_nanoseconds(l->atime.tv_nsec), + &(argv[5]) + ); + FUSE_UINT64_TO_INTS_ARGV(l->mtime.tv_sec, 6) + napi_create_uint32( + env, + fuse_native_timespec_nanoseconds(l->mtime.tv_nsec), + &(argv[8]) + ); + FUSE_CREATE_FILE_HANDLE_ARGV(9) +}) + +FUSE_METHOD_VOID(release, 2, 0, (const char *path, struct fuse_file_info *info), { + l->path = path; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + if (l->info != NULL) { + FUSE_CREATE_UINT64_ARGV(l->info->fh, 3) + } else { + FUSE_CREATE_UINT64_ARGV(0, 3) + } +}) + +FUSE_METHOD_VOID(releasedir, 2, 0, (const char *path, struct fuse_file_info *info), { + l->path = path; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + if (l->info != NULL) { + FUSE_CREATE_UINT64_ARGV(l->info->fh, 3) + } else { + FUSE_CREATE_UINT64_ARGV(0, 3) + } +}) + +FUSE_METHOD(read, 6, 1, (const char *path, char *buf, size_t len, off_t offset, struct fuse_file_info *info), { + if (len > UINT32_MAX) return -EOVERFLOW; + l->path = path; + l->buf = buf; + l->len = len; + l->offset = offset; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + FUSE_CREATE_OWNED_BUFFER_ARGV(NULL, l->len, 4) + napi_create_uint32(env, (uint32_t) l->len, &(argv[5])); + FUSE_UINT64_TO_INTS_ARGV(l->offset, 6) +}, { + if (res > 0) { + if ((size_t) res > l->len) { + res = -EIO; + } else { + int copy_result = copy_owned_buffer(env, argv[2], (void *) l->buf, l->len, (size_t) res); + if (copy_result != 0) res = copy_result; + } + } +}) + +FUSE_METHOD(write, 6, 0, (const char *path, const char *buf, size_t len, off_t offset, struct fuse_file_info *info), { + if (len > UINT32_MAX) return -EOVERFLOW; + l->path = path; + l->buf = buf; + l->len = len; + l->offset = offset; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + FUSE_CREATE_OWNED_BUFFER_ARGV(l->buf, l->len, 4) + napi_create_uint32(env, (uint32_t) l->len, &(argv[5])); + FUSE_UINT64_TO_INTS_ARGV(l->offset, 6) +}, { + if (res > 0 && (size_t) res > l->len) res = -EIO; +}) + +FUSE_METHOD(readdir, 4, 3, ( + const char *path, + void *buf, + fuse_native_fill_dir_t filler, + off_t offset, + struct fuse_file_info *info, + enum fuse_readdir_flags flags +), { + (void) flags; + l->buf = buf; + l->path = path; + l->offset = offset; + l->info = info; + l->readdir_filler = filler; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + if (l->info != NULL) { + FUSE_CREATE_UINT64_ARGV(l->info->fh, 3) + } else { + FUSE_CREATE_UINT64_ARGV(0, 3) + } + FUSE_UINT64_TO_INTS_ARGV(l->offset, 4) +}, { + bool names_is_array = false; + bool stats_is_array = false; + uint32_t names_length = 0; + uint32_t stats_length = 0; + uint32_t *offsets = NULL; + size_t offsets_length = 0; + + if (res == 0 && + (napi_is_array(env, argv[2], &names_is_array) != napi_ok || + napi_is_array(env, argv[3], &stats_is_array) != napi_ok || + !names_is_array || + !stats_is_array || + napi_get_array_length(env, argv[2], &names_length) != napi_ok || + napi_get_array_length(env, argv[3], &stats_length) != napi_ok || + (stats_length != 0 && stats_length != names_length) || + get_uint32_array(env, argv[4], &offsets, &offsets_length) != 0 || + (offsets_length != 0 && offsets_length != (size_t) names_length * 2))) { + res = -EIO; + } + + for (uint32_t i = 0; res == 0 && i < names_length; i++) { + napi_value raw_name; + size_t name_length = 0; + char name[256]; + if (napi_get_element(env, argv[2], i, &raw_name) != napi_ok || + napi_get_value_string_utf8(env, raw_name, NULL, 0, &name_length) != napi_ok || + name_length > 255 || + napi_get_value_string_utf8(env, raw_name, name, sizeof(name), &name_length) != napi_ok) { + res = -EIO; + break; + } + + struct stat st = {0}; + struct stat *stat_ptr = NULL; + if (stats_length != 0) { + napi_value raw_stat; + uint32_t *stats_array = NULL; + size_t stats_array_length = 0; + if (napi_get_element(env, argv[3], i, &raw_stat) != napi_ok || + get_uint32_array(env, raw_stat, &stats_array, &stats_array_length) != 0 || + stats_array_length != 26) { + res = -EIO; + break; + } + int stat_result = populate_stat(stats_array, &st); + if (stat_result != 0) { + res = stat_result; + break; + } + stat_ptr = &st; + } + + off_t next_offset = 0; + if (offsets_length != 0) { + uint32_t *offset_parts = &(offsets[(size_t) i * 2]); + int64_t encoded_offset = uint32s_to_int64(&offset_parts); + if (encoded_offset == 0 || int64_to_off_t(encoded_offset, &next_offset) != 0) { + res = -EIO; + break; + } + } + + if (fuse_native_fill_directory_entry( + l->readdir_filler, + (char *) l->buf, + name, + stat_ptr, + next_offset + ) != 0) break; + } +}) + +#if defined(__APPLE__) && FUSE_DARWIN_ENABLE_EXTENSIONS + +FUSE_METHOD(setxattr, 5, 0, (const char *path, const char *name, const char *value, size_t size, int flags, uint32_t position), { + l->path = path; + l->name = name; + l->value = value; + l->size = size; + l->flags = flags; + l->position = position; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_string_utf8(env, l->name, NAPI_AUTO_LENGTH, &(argv[3])); + FUSE_CREATE_OWNED_BUFFER_ARGV(l->value, l->size, 4) + napi_create_uint32(env, l->position, &(argv[5])); + napi_create_int32(env, l->flags, &(argv[6])); +}, { + if (res > 0) res = -EIO; +}) + +FUSE_METHOD(getxattr, 4, 1, (const char *path, const char *name, char *value, size_t size, uint32_t position), { + l->path = path; + l->name = name; + l->value = value; + l->size = size; + l->position = position; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_string_utf8(env, l->name, NAPI_AUTO_LENGTH, &(argv[3])); + FUSE_CREATE_OWNED_BUFFER_ARGV(NULL, l->size, 4) + napi_create_uint32(env, l->position, &(argv[5])); +}, { + if (l->size > 0 && res > 0) { + if ((size_t) res > l->size) { + res = -ERANGE; + } else { + int copy_result = copy_owned_buffer(env, argv[2], (void *) l->value, l->size, (size_t) res); + if (copy_result != 0) res = copy_result; + } + } +}) + +#else + +FUSE_METHOD(setxattr, 5, 0, (const char *path, const char *name, const char *value, size_t size, int flags), { + l->path = path; + l->name = name; + l->value = value; + l->size = size; + l->flags = flags; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_string_utf8(env, l->name, NAPI_AUTO_LENGTH, &(argv[3])); + FUSE_CREATE_OWNED_BUFFER_ARGV(l->value, l->size, 4) + napi_create_uint32(env, 0, &(argv[5])); // normalize apis between mac and linux + napi_create_int32(env, l->flags, &(argv[6])); +}, { + if (res > 0) res = -EIO; +}) + +FUSE_METHOD(getxattr, 4, 1, (const char *path, const char *name, char *value, size_t size), { + l->path = path; + l->name = name; + l->value = value; + l->size = size; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_string_utf8(env, l->name, NAPI_AUTO_LENGTH, &(argv[3])); + FUSE_CREATE_OWNED_BUFFER_ARGV(NULL, l->size, 4) + napi_create_uint32(env, 0, &(argv[5])); +}, { + if (l->size > 0 && res > 0) { + if ((size_t) res > l->size) { + res = -ERANGE; + } else { + int copy_result = copy_owned_buffer(env, argv[2], (void *) l->value, l->size, (size_t) res); + if (copy_result != 0) res = copy_result; + } + } +}) + +#endif + +FUSE_METHOD(listxattr, 2, 1, (const char *path, char *list, size_t size), { + l->path = path; + l->list = list; + l->size = size; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + FUSE_CREATE_OWNED_BUFFER_ARGV(NULL, l->size, 3) +}, { + if (l->size > 0 && res > 0) { + if ((size_t) res > l->size) { + res = -ERANGE; + } else { + int copy_result = copy_owned_buffer(env, argv[2], l->list, l->size, (size_t) res); + if (copy_result != 0) res = copy_result; + } + } +}) + +FUSE_METHOD_VOID(removexattr, 2, 0, (const char *path, const char *name), { + l->path = path; + l->name = name; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_string_utf8(env, l->name, NAPI_AUTO_LENGTH, &(argv[3])); +}) + +FUSE_METHOD_VOID(flush, 2, 0, (const char *path, struct fuse_file_info *info), { + l->path = path; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + if (l->info != NULL) { + FUSE_CREATE_UINT64_ARGV(l->info->fh, 3) + } else { + FUSE_CREATE_UINT64_ARGV(0, 3) + } +}) + +FUSE_METHOD_VOID(fsync, 3, 0, (const char *path, int datasync, struct fuse_file_info *info), { + l->path = path; + l->int_value = datasync; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + napi_create_int32(env, l->int_value, &(argv[3])); + if (l->info != NULL) { + FUSE_CREATE_UINT64_ARGV(l->info->fh, 4) + } else { + FUSE_CREATE_UINT64_ARGV(0, 4) + } +}) + +FUSE_METHOD_VOID(fsyncdir, 3, 0, (const char *path, int datasync, struct fuse_file_info *info), { + l->path = path; + l->int_value = datasync; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + napi_create_int32(env, l->int_value, &(argv[3])); + if (l->info != NULL) { + FUSE_CREATE_UINT64_ARGV(l->info->fh, 4) + } else { + FUSE_CREATE_UINT64_ARGV(0, 4) + } +}) + + +FUSE_METHOD_VOID(truncate, 3, 0, (const char *path, off_t size), { + l->path = path; + l->offset = size; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_UINT64_TO_INTS_ARGV(l->offset, 3) +}) + +FUSE_METHOD_VOID(ftruncate, 4, 0, (const char *path, off_t size, struct fuse_file_info *info), { + l->path = path; + l->offset = size; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + if (l->info != NULL) { + FUSE_CREATE_UINT64_ARGV(l->info->fh, 3) + } else { + FUSE_CREATE_UINT64_ARGV(0, 3) + } + FUSE_UINT64_TO_INTS_ARGV(l->offset, 4) +}) + +/* + * libfuse 3 merged truncate/ftruncate in the same way as getattr. Route to + * the existing JavaScript-facing operation to keep the 1.x handler contract. + */ +static int fuse_native_truncate_v3 ( + const char *path, + off_t size, + struct fuse_file_info *info +) { + struct fuse_context *context = fuse_get_context(); + fuse_thread_t *ft = context == NULL + ? NULL + : (fuse_thread_t *) context->private_data; + if (info != NULL && ft != NULL && ft->implemented[op_ftruncate]) { + return fuse_native_ftruncate(path, size, info); + } + if (ft != NULL && ft->implemented[op_truncate]) { + return fuse_native_truncate(path, size); + } + return -ENOSYS; +} + +static size_t fuse_native_readlink_capacity (size_t len) { +#ifdef __linux__ + /* + * libfuse provides PATH_MAX + 1 bytes here, but the Linux kernel rejects + * a FUSE readlink reply with PATH_MAX payload bytes. Reserve one extra + * byte so Node-API emits at most the kernel-supported PATH_MAX - 1 bytes. + */ + if (len > 1) return len - 1; +#endif + /* macFUSE accepts PATH_MAX payload bytes and uses the complete capacity. */ + return len; +} + +FUSE_METHOD(readlink, 1, 1, (const char *path, char *linkname, size_t len), { + l->path = path; + l->linkname = linkname; + l->len = len; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); +}, { + if (res == 0) { + size_t linkname_length = 0; + size_t linkname_capacity = fuse_native_readlink_capacity(l->len); + if (l->len == 0 || + napi_get_value_string_utf8( + env, argv[2], l->linkname, linkname_capacity, &linkname_length + ) != napi_ok) { + res = -EIO; + } + } +}) + +FUSE_METHOD_VOID(chown, 4, 0, ( + const char *path, + uid_t uid, + gid_t gid, + struct fuse_file_info *info +), { + l->path = path; + l->uid = uid; + l->gid = gid; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + napi_create_uint32(env, l->uid, &(argv[3])); + napi_create_uint32(env, l->gid, &(argv[4])); + FUSE_CREATE_FILE_HANDLE_ARGV(5) +}) + +FUSE_METHOD_VOID(chmod, 3, 0, ( + const char *path, + mode_t mode, + struct fuse_file_info *info +), { + l->path = path; + l->mode = mode; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + napi_create_uint32(env, l->mode, &(argv[3])); + FUSE_CREATE_FILE_HANDLE_ARGV(4) +}) + +FUSE_METHOD_VOID(mknod, 3, 0, (const char *path, mode_t mode, dev_t dev), { + l->path = path; + l->mode = mode; + l->dev = dev; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_uint32(env, l->mode, &(argv[3])); + FUSE_CREATE_UINT64_ARGV(l->dev, 4) +}) + +FUSE_METHOD_VOID(unlink, 1, 0, (const char *path), { + l->path = path; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); +}) + +FUSE_METHOD_VOID(rename, 3, 0, ( + const char *path, + const char *dest, + unsigned int flags +), { + l->path = path; + l->dest = dest; + l->flags = (int) flags; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_string_utf8(env, l->dest, NAPI_AUTO_LENGTH, &(argv[3])); + napi_create_uint32(env, (uint32_t) l->flags, &(argv[4])); +}) + +FUSE_METHOD_VOID(link, 2, 0, (const char *path, const char *dest), { + l->path = path; + l->dest = dest; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_string_utf8(env, l->dest, NAPI_AUTO_LENGTH, &(argv[3])); +}) + +FUSE_METHOD_VOID(symlink, 2, 0, (const char *path, const char *dest), { + l->path = path; + l->dest = dest; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_string_utf8(env, l->dest, NAPI_AUTO_LENGTH, &(argv[3])); +}) + +FUSE_METHOD_VOID(mkdir, 2, 0, (const char *path, mode_t mode), { + l->path = path; + l->mode = mode; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + napi_create_uint32(env, l->mode, &(argv[3])); +}) + +FUSE_METHOD_VOID(rmdir, 1, 0, (const char *path), { + l->path = path; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); +}) + +#if defined(__APPLE__) && FUSE_DARWIN_ENABLE_EXTENSIONS +/* + * macFUSE 3 combines Darwin metadata mutations in setattr. Execute the + * existing portable handlers in a stable order so a 1.x filesystem keeps its + * chmod/chown/truncate/utimens behavior without learning a platform-only API. + */ +static int fuse_native_setattr_darwin ( + const char *path, + struct fuse_darwin_attr *attr, + int to_set, + struct fuse_file_info *info +) { + struct fuse_context *context = fuse_get_context(); + fuse_thread_t *ft = context == NULL + ? NULL + : (fuse_thread_t *) context->private_data; + if (ft == NULL || attr == NULL) return -EIO; + + uint32_t requested = (uint32_t) to_set; + uint32_t unsupported = + (uint32_t) FUSE_SET_ATTR_BTIME | + (uint32_t) FUSE_SET_ATTR_BKUPTIME | + (uint32_t) FUSE_SET_ATTR_FLAGS; + if ((requested & unsupported) != 0) return -EOPNOTSUPP; + + int result = 0; + if ((requested & FUSE_SET_ATTR_MODE) != 0) { + if (!ft->implemented[op_chmod]) return -ENOSYS; + result = fuse_native_chmod(path, attr->mode, info); + if (result != 0) return result; + } + + if ((requested & (FUSE_SET_ATTR_UID | FUSE_SET_ATTR_GID)) != 0) { + if (!ft->implemented[op_chown]) return -ENOSYS; + uid_t uid = (requested & FUSE_SET_ATTR_UID) != 0 + ? attr->uid + : (uid_t) -1; + gid_t gid = (requested & FUSE_SET_ATTR_GID) != 0 + ? attr->gid + : (gid_t) -1; + result = fuse_native_chown(path, uid, gid, info); + if (result != 0) return result; + } + + if ((requested & FUSE_SET_ATTR_SIZE) != 0) { + result = fuse_native_truncate_v3(path, attr->size, info); + if (result != 0) return result; + } + + if ((requested & (FUSE_SET_ATTR_ATIME | FUSE_SET_ATTR_MTIME | + FUSE_SET_ATTR_ATIME_NOW | + FUSE_SET_ATTR_MTIME_NOW)) != 0) { + if (!ft->implemented[op_utimens]) return -ENOSYS; + struct timespec times[3] = { + {.tv_sec = 0, .tv_nsec = UTIME_OMIT}, + {.tv_sec = 0, .tv_nsec = UTIME_OMIT}, + {.tv_sec = 0, .tv_nsec = UTIME_OMIT} + }; + if ((requested & FUSE_SET_ATTR_ATIME_NOW) != 0) { + times[0].tv_nsec = UTIME_NOW; + } else if ((requested & FUSE_SET_ATTR_ATIME) != 0) { + times[0] = attr->atimespec; + } + if ((requested & FUSE_SET_ATTR_MTIME_NOW) != 0) { + times[1].tv_nsec = UTIME_NOW; + } else if ((requested & FUSE_SET_ATTR_MTIME) != 0) { + times[1] = attr->mtimespec; + } + result = fuse_native_utimens(path, times, info); + if (result != 0) return result; + } + + return 0; +} +#endif + +static void fuse_native_dispatch_destroy ( + uv_async_t* handle, + fuse_thread_locals_t* l, + fuse_thread_t* ft +) { + (void) handle; + FUSE_NATIVE_CALLBACK(ft->handlers[op_destroy], { + napi_value argv[3] = {0}; + if (initialize_callback_arguments(env, argv, 3) != napi_ok || + napi_get_reference_value(env, l->self, &(argv[0])) != napi_ok || + napi_create_uint32(env, l->op, &(argv[1])) != napi_ok || + create_request_context_value(env, l, &(argv[2])) != napi_ok) { + napi_close_handle_scope(env, scope); + fuse_native_complete_local(l, -EIO); + return; + } + FUSE_CALL_CALLBACK(3, argv) + }) +} + +NAPI_METHOD(fuse_native_signal_destroy) { + NAPI_ARGV(2) + NAPI_ARGV_BUFFER_CAST(fuse_thread_locals_t *, l, 0); + NAPI_ARGV_INT32(res, 1); + fuse_native_complete_local(l, res); + return NULL; +} + +static void fuse_native_destroy (void *data) { + fuse_thread_t *ft = (fuse_thread_t *) data; + if (ft == NULL || !ft->thread_started || ft->env_cleanup) return; + + fuse_thread_locals_t *l = + (fuse_thread_locals_t *) pthread_getspecific(thread_locals_key); + if (l == NULL) l = ft->locals; + if (l == NULL) return; + fuse_native_capture_context(l); + l->info = NULL; + l->owned_input = NULL; + l->pollhandle = NULL; + l->op = op_destroy; + l->op_fn = fuse_native_dispatch_destroy; + atomic_store(&(l->waiting), 1); + if (fuse_native_schedule_local(l) < 0) { + atomic_store(&(l->waiting), 0); + return; + } + uv_sem_wait(&(l->sem)); +} + +FUSE_METHOD(lock, 10, 1, ( + const char *path, + struct fuse_file_info *info, + int cmd, + struct flock *lock +), { + l->path = path; + l->info = info; + l->cmd = cmd; + l->lock = lock; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + napi_create_int32(env, l->cmd, &(argv[4])); + napi_create_int32(env, l->lock->l_type, &(argv[5])); + napi_create_int32(env, l->lock->l_whence, &(argv[6])); + FUSE_UINT64_TO_INTS_ARGV(l->lock->l_start, 7) + FUSE_UINT64_TO_INTS_ARGV(l->lock->l_len, 9) + napi_create_int32(env, l->lock->l_pid, &(argv[11])); +}, { + uint32_t *encoded = NULL; + size_t encoded_length = 0; + if (res == 0 && + (get_uint32_array(env, argv[2], &encoded, &encoded_length) != 0 || + encoded_length != 7)) { + res = -EINVAL; + } + if (res == 0) { + int16_t type = (int16_t) encoded[0]; + int16_t whence = (int16_t) encoded[1]; + uint32_t *cursor = &(encoded[2]); + int64_t start = uint32s_to_int64(&cursor); + int64_t length = uint32s_to_int64(&cursor); + int32_t pid = (int32_t) encoded[6]; + off_t native_start; + off_t native_length; + if ((uint32_t) (uint16_t) type != encoded[0] || + (uint32_t) (uint16_t) whence != encoded[1] || + int64_to_off_t(start, &native_start) != 0 || + int64_to_off_t(length, &native_length) != 0) { + res = -ERANGE; + } else { + l->lock->l_type = type; + l->lock->l_whence = whence; + l->lock->l_start = native_start; + l->lock->l_len = native_length; + l->lock->l_pid = (pid_t) pid; + } + } +}) + +FUSE_METHOD(bmap, 3, 1, ( + const char *path, + size_t blocksize, + uint64_t *index +), { + l->path = path; + l->size = blocksize; + l->bmap_index = index; +}, { + napi_create_string_utf8(env, l->path, NAPI_AUTO_LENGTH, &(argv[2])); + FUSE_CREATE_UINT64_ARGV(l->size, 3) + FUSE_CREATE_UINT64_ARGV(*(l->bmap_index), 4) +}, { + uint64_t index = 0; + if (res == 0 && value_to_uint64(env, argv[2], &index) != 0) res = -EINVAL; + if (res == 0) *(l->bmap_index) = index; +}) + +#define FUSE_IOCTL_MAX_SIZE (1024U * 1024U) + +static size_t fuse_native_ioctl_size (int cmd) { +#ifdef __APPLE__ + return (size_t) IOCPARM_LEN((uint32_t) cmd); +#else + return (size_t) _IOC_SIZE((unsigned int) cmd); +#endif +} + +static int fuse_native_ioctl_has_input (int cmd) { +#ifdef __APPLE__ + return (((uint32_t) cmd) & IOC_IN) != 0; +#else + return (_IOC_DIR((unsigned int) cmd) & _IOC_WRITE) != 0; +#endif +} + +FUSE_METHOD(ioctl, 6, 1, ( + const char *path, + int cmd, + void *arg, + struct fuse_file_info *info, + unsigned int flags, + void *data +), { + if ((flags & FUSE_IOCTL_UNRESTRICTED) != 0) return -EOPNOTSUPP; + l->path = path; + l->cmd = cmd; + l->info = info; + l->ioctl_argument = (uintptr_t) arg; + l->flags = (int) flags; + l->ioctl_data = data; + l->size = fuse_native_ioctl_size(cmd); + if (l->size > FUSE_IOCTL_MAX_SIZE) return -E2BIG; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + napi_create_int32(env, l->cmd, &(argv[4])); + FUSE_CREATE_UINT64_ARGV(l->ioctl_argument, 5) + napi_create_uint32(env, (uint32_t) l->flags, &(argv[6])); + FUSE_CREATE_OWNED_BUFFER_ARGV( + fuse_native_ioctl_has_input(l->cmd) ? l->ioctl_data : NULL, + l->size, + 7 + ) +}, { + if (res == 0 && l->size > 0) { + int copy_result = copy_owned_buffer( + env, + argv[2], + l->ioctl_data, + l->size, + l->size + ); + if (copy_result != 0) res = copy_result; + } +}) + +FUSE_METHOD(poll, 3, 1, ( + const char *path, + struct fuse_file_info *info, + struct fuse_pollhandle *pollhandle, + unsigned *revents +), { + l->path = path; + l->info = info; + l->pollhandle = pollhandle; + l->poll_revents = revents; + if (pollhandle != NULL && + (l->fuse->operation_flags & FUSE_OPERATION_FLAG_POLL_HANDLE) != 0) { + l->poll_registration = fuse_native_register_poll(l->fuse, pollhandle); + if (l->poll_registration == NULL) { + fuse_pollhandle_destroy(pollhandle); + l->pollhandle = NULL; + return -ENOMEM; + } + l->pollhandle = NULL; + } +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + FUSE_CREATE_UINT64_ARGV( + l->poll_registration == NULL ? 0 : l->poll_registration->id, + 4 + ) +}, { + uint32_t events = 0; + if (res == 0 && napi_get_value_uint32(env, argv[2], &events) != napi_ok) { + res = -EINVAL; + } + if (res == 0) { + *(l->poll_revents) = events; + fuse_native_release_poll_registration(l->poll_registration); + l->poll_registration = NULL; + } +}) + +FUSE_METHOD(write_buf, 6, 0, ( + const char *path, + struct fuse_bufvec *source, + off_t offset, + struct fuse_file_info *info +), { + l->size = fuse_buf_size(source); + if (l->size > UINT32_MAX) return -EOVERFLOW; + l->owned_input = l->size == 0 ? NULL : malloc(l->size); + if (l->size > 0 && l->owned_input == NULL) return -ENOMEM; + struct fuse_bufvec destination = FUSE_BUFVEC_INIT(l->size); + destination.buf[0].mem = l->owned_input; + ssize_t copied = fuse_buf_copy(&destination, source, FUSE_BUF_NO_SPLICE); + if (copied < 0 || (size_t) copied != l->size) { + free(l->owned_input); + l->owned_input = NULL; + return copied < 0 ? (int) copied : -EIO; + } + l->path = path; + l->offset = offset; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + FUSE_CREATE_OWNED_BUFFER_ARGV(l->owned_input, l->size, 4) + napi_create_uint32(env, (uint32_t) l->size, &(argv[5])); + FUSE_UINT64_TO_INTS_ARGV(l->offset, 6) +}, {}) + +FUSE_METHOD(read_buf, 5, 1, ( + const char *path, + struct fuse_bufvec **output, + size_t size, + off_t offset, + struct fuse_file_info *info +), { + if (size > UINT32_MAX) return -EOVERFLOW; + l->path = path; + l->size = size; + l->offset = offset; + l->info = info; + l->bufvec_out = output; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + napi_create_uint32(env, (uint32_t) l->size, &(argv[4])); + FUSE_UINT64_TO_INTS_ARGV(l->offset, 5) +}, { + bool is_buffer = false; + void *buffer_data = NULL; + size_t buffer_size = 0; + if (res == 0 && + (napi_is_buffer(env, argv[2], &is_buffer) != napi_ok || + !is_buffer || + napi_get_buffer_info(env, argv[2], &buffer_data, &buffer_size) != napi_ok || + buffer_size > l->size)) { + res = -EINVAL; + } + if (res == 0) { + struct fuse_bufvec *output = malloc(sizeof(*output)); + void *output_data = buffer_size == 0 ? NULL : malloc(buffer_size); + if (output == NULL || (buffer_size > 0 && output_data == NULL)) { + free(output); + free(output_data); + res = -ENOMEM; + } else { + *output = FUSE_BUFVEC_INIT(buffer_size); + output->buf[0].mem = output_data; + if (buffer_size > 0) memcpy(output_data, buffer_data, buffer_size); + *(l->bufvec_out) = output; + } + } +}) + +FUSE_METHOD_VOID(flock, 3, 0, ( + const char *path, + struct fuse_file_info *info, + int operation +), { + l->path = path; + l->info = info; + l->int_value = operation; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + napi_create_int32(env, l->int_value, &(argv[4])); +}) + +FUSE_METHOD_VOID(fallocate, 7, 0, ( + const char *path, + int mode, + off_t offset, + off_t length, + struct fuse_file_info *info +), { + l->path = path; + l->int_value = mode; + l->offset = offset; + l->length = length; + l->info = info; +}, { + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + napi_create_int32(env, l->int_value, &(argv[4])); + FUSE_UINT64_TO_INTS_ARGV(l->offset, 5) + FUSE_UINT64_TO_INTS_ARGV(l->length, 7) +}) + +static void fuse_native_dispatch_copy_file_range ( + uv_async_t *handle, + fuse_thread_locals_t *l, + fuse_thread_t *ft +) { + (void) handle; + FUSE_NATIVE_CALLBACK(ft->handlers[op_copy_file_range], { + napi_value argv[13] = {0}; + if (initialize_callback_arguments(env, argv, 13) != napi_ok || + napi_get_reference_value(env, l->self, &(argv[0])) != napi_ok || + napi_create_uint32(env, l->op, &(argv[1])) != napi_ok) { + napi_close_handle_scope(env, scope); + fuse_native_complete_local(l, -EIO); + return; + } + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_UINT64_ARGV(l->info == NULL ? 0 : l->info->fh, 3) + FUSE_UINT64_TO_INTS_ARGV(l->offset, 4) + FUSE_CREATE_PATH_ARGV(l->dest, 6) + FUSE_CREATE_UINT64_ARGV(l->info_out == NULL ? 0 : l->info_out->fh, 7) + FUSE_UINT64_TO_INTS_ARGV(l->length, 8) + FUSE_CREATE_UINT64_ARGV(l->size, 10) + napi_create_int32(env, l->flags, &(argv[11])); + if (create_request_context_value(env, l, &(argv[12])) != napi_ok) { + napi_close_handle_scope(env, scope); + fuse_native_complete_local(l, -EIO); + return; + } + FUSE_CALL_CALLBACK(13, argv) + }) +} + +NAPI_METHOD(fuse_native_signal_copy_file_range) { + NAPI_ARGV(3) + NAPI_ARGV_BUFFER_CAST(fuse_thread_locals_t *, l, 0); + NAPI_ARGV_INT32(res, 1); + int64_t copied = 0; + if (res == 0 && + (value_to_int64_words(env, argv[2], &copied) != 0 || + copied < 0 || + (uint64_t) copied > (uint64_t) l->size || + copied > (int64_t) SSIZE_MAX)) { + res = -ERANGE; + } + if (res == 0) l->signed_result = copied; + fuse_native_complete_local(l, res); + return NULL; +} + +static ssize_t fuse_native_copy_file_range ( + const char *path_in, + struct fuse_file_info *info_in, + off_t offset_in, + const char *path_out, + struct fuse_file_info *info_out, + off_t offset_out, + size_t size, + int flags +) { + fuse_thread_locals_t *l = get_thread_locals(); + if (l == NULL) return -EIO; + l->info = info_in; + l->info_out = info_out; + l->owned_input = NULL; + l->pollhandle = NULL; + l->signed_result = 0; + l->op = op_copy_file_range; + l->op_fn = fuse_native_dispatch_copy_file_range; + l->path = path_in; + l->dest = path_out; + l->offset = offset_in; + l->length = offset_out; + l->size = size; + l->flags = flags; + fuse_native_capture_context(l); + atomic_store(&(l->waiting), 1); + if (fuse_native_schedule_local(l) < 0) { + atomic_store(&(l->waiting), 0); + return -EIO; + } + uv_sem_wait(&(l->sem)); + return l->res == 0 ? (ssize_t) l->signed_result : (ssize_t) l->res; +} + +static void fuse_native_dispatch_lseek ( + uv_async_t *handle, + fuse_thread_locals_t *l, + fuse_thread_t *ft +) { + (void) handle; + FUSE_NATIVE_CALLBACK(ft->handlers[op_lseek], { + napi_value argv[8] = {0}; + if (initialize_callback_arguments(env, argv, 8) != napi_ok || + napi_get_reference_value(env, l->self, &(argv[0])) != napi_ok || + napi_create_uint32(env, l->op, &(argv[1])) != napi_ok) { + napi_close_handle_scope(env, scope); + fuse_native_complete_local(l, -EIO); + return; + } + FUSE_CREATE_PATH_ARGV(l->path, 2) + FUSE_CREATE_FILE_HANDLE_ARGV(3) + FUSE_UINT64_TO_INTS_ARGV(l->offset, 4) + napi_create_int32(env, l->int_value, &(argv[6])); + if (create_request_context_value(env, l, &(argv[7])) != napi_ok) { + napi_close_handle_scope(env, scope); + fuse_native_complete_local(l, -EIO); + return; + } + FUSE_CALL_CALLBACK(8, argv) + }) +} + +NAPI_METHOD(fuse_native_signal_lseek) { + NAPI_ARGV(3) + NAPI_ARGV_BUFFER_CAST(fuse_thread_locals_t *, l, 0); + NAPI_ARGV_INT32(res, 1); + int64_t offset = 0; + off_t converted = 0; + if (res == 0 && + (value_to_int64_words(env, argv[2], &offset) != 0 || + offset < 0 || + int64_to_off_t(offset, &converted) != 0)) { + res = -ERANGE; + } + if (res == 0) l->signed_result = offset; + fuse_native_complete_local(l, res); + return NULL; +} + +static off_t fuse_native_lseek ( + const char *path, + off_t offset, + int whence, + struct fuse_file_info *info +) { + fuse_thread_locals_t *l = get_thread_locals(); + if (l == NULL) return (off_t) -EIO; + l->info = info; + l->info_out = NULL; + l->owned_input = NULL; + l->pollhandle = NULL; + l->signed_result = 0; + l->op = op_lseek; + l->op_fn = fuse_native_dispatch_lseek; + l->path = path; + l->offset = offset; + l->int_value = whence; + fuse_native_capture_context(l); + atomic_store(&(l->waiting), 1); + if (fuse_native_schedule_local(l) < 0) { + atomic_store(&(l->waiting), 0); + return (off_t) -EIO; + } + uv_sem_wait(&(l->sem)); + return l->res == 0 ? (off_t) l->signed_result : (off_t) l->res; +} + +#if defined(__linux__) && FUSE_VERSION >= FUSE_MAKE_VERSION(3, 18) +static int fuse_native_statx ( + const char *path, + int flags, + int mask, + struct statx *statx, + struct fuse_file_info *info +) { + (void) flags; + (void) mask; + struct stat stat = {0}; + int result = fuse_native_getattr_v3(path, &stat, info); + if (result != 0) return result; + if ((uint64_t) stat.st_blksize > UINT32_MAX || + (uint64_t) stat.st_mode > UINT16_MAX) { + return -EOVERFLOW; + } + + memset(statx, 0, sizeof(*statx)); + statx->stx_mask = STATX_BASIC_STATS; + statx->stx_blksize = (uint32_t) stat.st_blksize; + statx->stx_nlink = (uint32_t) stat.st_nlink; + statx->stx_uid = stat.st_uid; + statx->stx_gid = stat.st_gid; + statx->stx_mode = (uint16_t) stat.st_mode; + statx->stx_ino = stat.st_ino; + statx->stx_size = (uint64_t) stat.st_size; + statx->stx_blocks = (uint64_t) stat.st_blocks; + statx->stx_atime.tv_sec = stat.st_atim.tv_sec; + statx->stx_atime.tv_nsec = (uint32_t) stat.st_atim.tv_nsec; + statx->stx_mtime.tv_sec = stat.st_mtim.tv_sec; + statx->stx_mtime.tv_nsec = (uint32_t) stat.st_mtim.tv_nsec; + statx->stx_ctime.tv_sec = stat.st_ctim.tv_sec; + statx->stx_ctime.tv_nsec = (uint32_t) stat.st_ctim.tv_nsec; + statx->stx_rdev_major = major(stat.st_rdev); + statx->stx_rdev_minor = minor(stat.st_rdev); + statx->stx_dev_major = major(stat.st_dev); + statx->stx_dev_minor = minor(stat.st_dev); + return 0; +} +#endif + +#define FUSE_INIT_CONFIG_MAX_WRITE 1U +#define FUSE_INIT_CONFIG_MAX_READAHEAD 2U +#define FUSE_INIT_CONFIG_MAX_BACKGROUND 4U +#define FUSE_INIT_CONFIG_CONGESTION_THRESHOLD 8U +#define FUSE_INIT_CONFIG_WANT 16U +#define FUSE_INIT_CONFIG_ASYNC_READ 32U +#define FUSE_INIT_CONFIG_MAX_BACKING_STACK_DEPTH 64U +#define FUSE_INIT_CONFIG_ALLOWED_MASK \ + (FUSE_INIT_CONFIG_MAX_WRITE | FUSE_INIT_CONFIG_MAX_READAHEAD | \ + FUSE_INIT_CONFIG_MAX_BACKGROUND | FUSE_INIT_CONFIG_CONGESTION_THRESHOLD | \ + FUSE_INIT_CONFIG_WANT | FUSE_INIT_CONFIG_ASYNC_READ | \ + FUSE_INIT_CONFIG_MAX_BACKING_STACK_DEPTH) + +static void fuse_native_dispatch_init (uv_async_t* handle, fuse_thread_locals_t* l, fuse_thread_t* ft) { + (void) handle; + FUSE_NATIVE_CALLBACK(ft->handlers[op_init], { + napi_value argv[13] = {0}; + + if (l->conn == NULL || + napi_get_reference_value(env, l->self, &(argv[0])) != napi_ok || + napi_create_uint32(env, l->op, &(argv[1])) != napi_ok || + napi_create_uint32(env, l->conn->proto_major, &(argv[2])) != napi_ok || + napi_create_uint32(env, l->conn->proto_minor, &(argv[3])) != napi_ok || + napi_create_uint32( + env, + (l->conn->want & FUSE_CAP_ASYNC_READ) != 0, + &(argv[4]) + ) != napi_ok || + napi_create_uint32(env, l->conn->max_write, &(argv[5])) != napi_ok || + napi_create_uint32(env, l->conn->max_readahead, &(argv[6])) != napi_ok || + napi_create_uint32(env, l->conn->capable, &(argv[7])) != napi_ok || + napi_create_uint32(env, l->conn->want, &(argv[8])) != napi_ok || + napi_create_uint32(env, l->conn->max_background, &(argv[9])) != napi_ok || + napi_create_uint32(env, l->conn->congestion_threshold, &(argv[10])) != napi_ok || + napi_create_uint32(env, l->conn->max_read, &(argv[11])) != napi_ok || + create_request_context_value(env, l, &(argv[12])) != napi_ok) { + napi_close_handle_scope(env, scope); + fuse_native_complete_local(l, -EIO); + return; + } + + FUSE_CALL_CALLBACK(13, argv) + }) +} + +NAPI_METHOD(fuse_native_signal_init) { + NAPI_ARGV(3) + NAPI_ARGV_BUFFER_CAST(fuse_thread_locals_t *, l, 0); + NAPI_ARGV_INT32(res, 1); + uint32_t *config = NULL; + size_t config_length = 0; + + if (res == 0 && + (l->conn == NULL || + get_uint32_array(env, argv[2], &config, &config_length) != 0 || + config_length != 8 || + (config[0] & ~FUSE_INIT_CONFIG_ALLOWED_MASK) != 0)) { + res = -EINVAL; + } + + if (res == 0) { + const uint32_t mask = config[0]; + const uint32_t max_write = (mask & FUSE_INIT_CONFIG_MAX_WRITE) != 0 + ? config[1] + : l->conn->max_write; + const uint32_t max_readahead = (mask & FUSE_INIT_CONFIG_MAX_READAHEAD) != 0 + ? config[2] + : l->conn->max_readahead; + const uint32_t max_background = (mask & FUSE_INIT_CONFIG_MAX_BACKGROUND) != 0 + ? config[3] + : l->conn->max_background; + const uint32_t congestion_threshold = (mask & FUSE_INIT_CONFIG_CONGESTION_THRESHOLD) != 0 + ? config[4] + : l->conn->congestion_threshold; + uint32_t want = (mask & FUSE_INIT_CONFIG_WANT) != 0 + ? config[5] + : l->conn->want; + const uint32_t async_read = (mask & FUSE_INIT_CONFIG_ASYNC_READ) != 0 + ? config[6] + : (l->conn->want & FUSE_CAP_ASYNC_READ) != 0; + + if ((mask & FUSE_INIT_CONFIG_ASYNC_READ) != 0) { + if (async_read != 0) { + want |= FUSE_CAP_ASYNC_READ; + } else { + want &= ~((uint32_t) FUSE_CAP_ASYNC_READ); + } + } + + if (((mask & FUSE_INIT_CONFIG_MAX_WRITE) != 0 && + (max_write == 0 || max_write > l->conn->max_write)) || + ((mask & FUSE_INIT_CONFIG_MAX_READAHEAD) != 0 && + max_readahead > l->conn->max_readahead) || + ((mask & FUSE_INIT_CONFIG_MAX_BACKGROUND) != 0 && + (max_background == 0 || max_background > l->conn->max_background)) || + ((mask & FUSE_INIT_CONFIG_CONGESTION_THRESHOLD) != 0 && + (congestion_threshold == 0 || + congestion_threshold > l->conn->congestion_threshold)) || + ((mask & (FUSE_INIT_CONFIG_MAX_BACKGROUND | + FUSE_INIT_CONFIG_CONGESTION_THRESHOLD)) != 0 && + congestion_threshold > max_background) || + ((mask & (FUSE_INIT_CONFIG_WANT | FUSE_INIT_CONFIG_ASYNC_READ)) != 0 && + (want & ~l->conn->capable) != 0) || + ((mask & FUSE_INIT_CONFIG_ASYNC_READ) != 0 && async_read > 1U)) { + res = -EINVAL; + } else { + if ((mask & FUSE_INIT_CONFIG_MAX_WRITE) != 0) l->conn->max_write = max_write; + if ((mask & FUSE_INIT_CONFIG_MAX_READAHEAD) != 0) l->conn->max_readahead = max_readahead; + if ((mask & FUSE_INIT_CONFIG_MAX_BACKGROUND) != 0) l->conn->max_background = max_background; + if ((mask & FUSE_INIT_CONFIG_CONGESTION_THRESHOLD) != 0) { + l->conn->congestion_threshold = congestion_threshold; + } +#if FUSE_HAS_BACKING_ID + /* Required before the kernel will accept a backing file whose + * underlying fs is itself stacked (overlayfs). Harmless otherwise. */ + if ((mask & FUSE_INIT_CONFIG_MAX_BACKING_STACK_DEPTH) != 0) { + l->conn->max_backing_stack_depth = config[7]; + } +#endif + if ((mask & (FUSE_INIT_CONFIG_WANT | FUSE_INIT_CONFIG_ASYNC_READ)) != 0) { + l->conn->want = want; + } + } + } + + fuse_native_complete_local(l, res); + return NULL; +} + +static void * fuse_native_init ( + struct fuse_conn_info *conn, + struct fuse_config *config +) { + fuse_thread_locals_t *l = get_thread_locals(); + struct fuse_context *context = fuse_get_context(); + fuse_thread_t *ft = context == NULL + ? NULL + : (fuse_thread_t *) context->private_data; + if (ft == NULL) return NULL; + + /* + * libfuse requires max_read from fuse_new() and the init hook to match. + * Preserve its normal negotiation completely when the option was omitted. + */ + if (ft->max_read_configured) conn->max_read = ft->configured_max_read; + + /* + * FUSE 3 moved the old operation-level null-path flags into fuse_config. + * Either legacy flag means the JavaScript implementation accepts a NULL + * path for handle-based operations. + */ + if (config != NULL) { + config->nullpath_ok = + (ft->operation_flags & + (FUSE_OPERATION_FLAG_NULL_PATH_OK | FUSE_OPERATION_FLAG_NO_PATH)) != 0; + config->direct_io = + (ft->operation_flags & FUSE_OPERATION_FLAG_DIRECT_IO) != 0; + /* + * use_ino is a fuse_config field in FUSE 3, not a mount option; + * fuse_new() rejects it on the command line. + */ + config->use_ino = + (ft->operation_flags & FUSE_OPERATION_FLAG_USE_INO) != 0; + } + + if (!ft->implemented[op_init] || l == NULL) return ft; + + l->op = op_init; + l->op_fn = fuse_native_dispatch_init; + l->conn = conn; + l->config = config; + l->info = NULL; + fuse_native_capture_context(l); + + atomic_store(&(l->waiting), 1); + if (fuse_native_schedule_local(l) < 0) { + atomic_store(&(l->waiting), 0); + return ft; + } + uv_sem_wait(&(l->sem)); + l->conn = NULL; + l->config = NULL; + + return ft; +} + +// Top-level dispatcher + +static int fuse_native_schedule_local (fuse_thread_locals_t *l) { + fuse_thread_t *ft = l->fuse; + if (!l->async_initialized || + (atomic_load(&(ft->cleanup_requested)) && l->op != op_destroy)) { + return UV_ECANCELED; + } + return uv_async_send(&(l->async)); +} + +static void fuse_native_dispatch (uv_async_t* handle) { + fuse_thread_locals_t *l = (fuse_thread_locals_t *) handle->data; + fuse_thread_t *ft = l->fuse; + if (atomic_load(&(l->waiting)) == 0) return; + if (atomic_load(&(ft->cleanup_requested)) && l->op != op_destroy) { + fuse_native_complete_local(l, -EIO); + return; + } + l->op_fn(handle, l, ft); +} + +static int fuse_native_create_worker_locals (fuse_thread_t *ft) { + /* + * N-API values and libuv handles must be created on the JavaScript thread. + * Each worker gets a dedicated async handle, so one worker can have at most + * one outstanding notification and libuv coalescing cannot lose ownership + * of a different worker's request. + */ + for (size_t i = 0; i < ft->max_workers; i++) { + fuse_thread_locals_t *l = calloc(1, sizeof(*l)); + if (l == NULL) return UV_ENOMEM; + + napi_handle_scope scope; + if (napi_open_handle_scope(ft->env, &scope) != napi_ok) { + free(l); + return UV_EIO; + } + + l->fuse = ft; + atomic_init(&(l->waiting), 0); + + napi_value buf; + if (napi_create_external_buffer( + ft->env, + sizeof(*l), + (char *) l, + NULL, + NULL, + &buf + ) != napi_ok || + napi_create_reference(ft->env, buf, 1, &(l->self)) != napi_ok) { + free(l); + napi_close_handle_scope(ft->env, scope); + return UV_EIO; + } + + int err = uv_sem_init(&(l->sem), 0); + if (err < 0) { + napi_delete_reference(ft->env, l->self); + free(l); + napi_close_handle_scope(ft->env, scope); + return err; + } + l->sem_initialized = 1; + + err = uv_async_init(ft->loop, &(l->async), (uv_async_cb) fuse_native_dispatch); + if (err < 0) { + uv_sem_destroy(&(l->sem)); + l->sem_initialized = 0; + napi_delete_reference(ft->env, l->self); + l->self = NULL; + free(l); + napi_close_handle_scope(ft->env, scope); + return err; + } + l->async_initialized = 1; + l->async.data = l; + uv_unref((uv_handle_t *) &(l->async)); + + l->next = ft->locals; + ft->locals = l; + napi_close_handle_scope(ft->env, scope); + } + return 0; +} + +static fuse_thread_locals_t* get_thread_locals (void) { + struct fuse_context *ctx = fuse_get_context(); + if (ctx == NULL || ctx->private_data == NULL) return NULL; + fuse_thread_t *ft = (fuse_thread_t *) ctx->private_data; + if (atomic_load(&(ft->cleanup_requested))) return NULL; + return (fuse_thread_locals_t *) pthread_getspecific(thread_locals_key); +} + +static void fuse_native_record_loop_error (fuse_thread_t *ft, int error) { + int expected = 0; + atomic_compare_exchange_strong(&(ft->loop_result), &expected, error); +} + +static void fuse_native_worker_finished (void *data) { + fuse_worker_t *worker = (fuse_worker_t *) data; + uv_sem_post(&(worker->fuse->workers_finished)); +} + +static void fuse_native_release_worker_buffer (fuse_worker_t *worker) { +#ifdef __APPLE__ + if (worker->fuse->buf_free != NULL) { + worker->fuse->buf_free(&(worker->buffer)); + } +#else + free(worker->buffer.mem); +#endif + memset(&(worker->buffer), 0, sizeof(worker->buffer)); +} + +static void* fuse_native_worker (void *data) { + fuse_worker_t *worker = (fuse_worker_t *) data; + fuse_thread_t *ft = worker->fuse; + struct fuse_session *session = fuse_get_session(ft->fuse); + + (void) pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); + pthread_cleanup_push(fuse_native_worker_finished, worker); + int locals_result = pthread_setspecific( + thread_locals_key, + (void *) worker->locals + ); + if (locals_result != 0) { + fuse_native_record_loop_error(ft, -locals_result); + fuse_session_exit(session); + } else { + while (!fuse_session_exited(session)) { + (void) pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL); + int result = fuse_session_receive_buf(session, &(worker->buffer)); + (void) pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); + if (result == -EINTR) continue; + if (result <= 0) { + if (result < 0) { + fuse_native_record_loop_error(ft, result); + fuse_session_exit(session); + } + break; + } + fuse_session_process_buf(session, &(worker->buffer)); + } + } + pthread_cleanup_pop(1); + return NULL; +} + +static void fuse_native_cancel_workers_locked (fuse_thread_t *ft) { + for (size_t i = 0; i < ft->workers_started; i++) { + (void) pthread_cancel(ft->workers[i].thread); + } +} + +static void fuse_native_dispose_mount ( + struct fuse *fuse, + int mounted +) { + if (mounted && fuse != NULL) fuse_unmount(fuse); + if (fuse != NULL) fuse_destroy(fuse); +} + +static void* start_fuse_thread (void *data) { + fuse_thread_t *ft = (fuse_thread_t *) data; + struct fuse_session *session = fuse_get_session(ft->fuse); + fuse_worker_t *workers = calloc(ft->max_workers, sizeof(*workers)); + size_t workers_to_join = 0; + + if (session == NULL) { + fuse_native_record_loop_error(ft, -EIO); + } else if (workers == NULL) { + fuse_native_record_loop_error(ft, -ENOMEM); + } else { + if (fuse_start_cleanup_thread(ft->fuse) != 0) { + fuse_native_record_loop_error(ft, -EIO); + fuse_session_exit(session); + } else { + ft->fuse_cache_cleanup_started = 1; + } + + uv_mutex_lock(&(ft->mut)); + ft->workers = workers; + uv_mutex_unlock(&(ft->mut)); + + fuse_thread_locals_t *locals = ft->locals; + for (size_t i = 0; + atomic_load(&(ft->loop_result)) == 0 && + !atomic_load(&(ft->cleanup_requested)) && + i < ft->max_workers; + i++) { + if (locals == NULL) { + fuse_native_record_loop_error(ft, -EIO); + break; + } + workers[i].fuse = ft; + workers[i].locals = locals; + + int result = pthread_create( + &(workers[i].thread), + &(ft->attr), + fuse_native_worker, + &(workers[i]) + ); + if (result != 0) { + fuse_native_record_loop_error(ft, -result); + break; + } + uv_mutex_lock(&(ft->mut)); + ft->workers_started++; + uv_mutex_unlock(&(ft->mut)); + locals = locals->next; + } + + if (ft->workers_started != ft->max_workers || + atomic_load(&(ft->cleanup_requested))) { + if (atomic_load(&(ft->loop_result)) == 0) { + fuse_native_record_loop_error(ft, -EIO); + } + fuse_session_exit(session); + } else { + uv_sem_wait(&(ft->workers_finished)); + } + + fuse_session_exit(session); + uv_mutex_lock(&(ft->mut)); + workers_to_join = ft->workers_started; + fuse_native_cancel_workers_locked(ft); + /* + * Cleanup uses the same mutex before cancelling workers. Retire the + * published set before joining it so another cleanup path can never call + * pthread_cancel() with a pthread_t that has already been joined. + */ + ft->workers = NULL; + ft->workers_started = 0; + uv_mutex_unlock(&(ft->mut)); + + for (size_t i = 0; i < workers_to_join; i++) { + int result = pthread_join(workers[i].thread, NULL); + if (result != 0) fuse_native_record_loop_error(ft, -result); + fuse_native_release_worker_buffer(&(workers[i])); + } + fuse_session_reset(session); + } + + if (ft->fuse_cache_cleanup_started) { + fuse_stop_cleanup_thread(ft->fuse); + ft->fuse_cache_cleanup_started = 0; + } + + fuse_native_close_all_polls(ft); + if (ft->invalidation_mutex_initialized && ft->invalidation_cond_initialized) { + uv_mutex_lock(&(ft->invalidation_mut)); + ft->invalidation_stopping = 1; + while (ft->invalidation_active) { + uv_cond_wait(&(ft->invalidation_cond), &(ft->invalidation_mut)); + } + } + uv_mutex_lock(&(ft->mut)); + struct fuse *fuse = ft->fuse; + int mounted = ft->fuse_mounted; + ft->fuse = NULL; + ft->fuse_mounted = 0; + uv_mutex_unlock(&(ft->mut)); + if (ft->invalidation_mutex_initialized && ft->invalidation_cond_initialized) { + uv_mutex_unlock(&(ft->invalidation_mut)); + } + free(workers); + + fuse_native_dispose_mount(fuse, mounted); + + if (!atomic_load(&(ft->cleanup_requested))) { + int result = uv_async_send(&(ft->loop_exit_async)); + if (result < 0) fuse_native_record_loop_error(ft, result); + } + + return NULL; +} + +static void fuse_native_delete_references (fuse_thread_t *ft) { + if (ft->ctx != NULL) { + napi_delete_reference(ft->env, ft->ctx); + ft->ctx = NULL; + } + if (ft->loop_exit_cb != NULL) { + napi_delete_reference(ft->env, ft->loop_exit_cb); + ft->loop_exit_cb = NULL; + } + for (size_t i = 0; i < FUSE_OPERATION_COUNT; i++) { + if (ft->handlers[i] != NULL) { + napi_delete_reference(ft->env, ft->handlers[i]); + ft->handlers[i] = NULL; + } + } +} + +static void fuse_native_free_strings (fuse_thread_t *ft) { + free(ft->mnt); + free(ft->mntopts); + ft->mnt = NULL; + ft->mntopts = NULL; +} + +static void fuse_native_mount_cleanup_finished (fuse_thread_t *ft) { + ft->mount_cleanup_pending = 0; + if (ft->cleanup_hook != NULL) { + napi_remove_async_cleanup_hook(ft->cleanup_hook); + ft->cleanup_hook = NULL; + } + if (ft->state_ref != NULL) { + napi_delete_reference(ft->env, ft->state_ref); + ft->state_ref = NULL; + } +} + +static void fuse_native_mount_close_complete (fuse_thread_t *ft) { + if (ft->close_pending == 0) return; + ft->close_pending--; + if (ft->close_pending == 0) fuse_native_mount_cleanup_finished(ft); +} + +static void fuse_native_mount_thread_async_closed (uv_handle_t *handle) { + fuse_thread_t *ft = (fuse_thread_t *) handle->data; + fuse_native_mount_close_complete(ft); +} + +static void fuse_native_mount_local_closed (uv_handle_t *handle) { + fuse_thread_locals_t *l = (fuse_thread_locals_t *) handle->data; + fuse_thread_t *ft = l->fuse; + free(l); + fuse_native_mount_close_complete(ft); +} + +static void fuse_native_loop_exit_dispatch (uv_async_t *handle) { + fuse_thread_t *ft = (fuse_thread_t *) handle->data; + if (atomic_load(&(ft->cleanup_requested)) || ft->loop_exit_cb == NULL) return; + + napi_handle_scope scope; + if (napi_open_handle_scope(ft->env, &scope) != napi_ok) return; + napi_value callback; + napi_value receiver; + napi_value result; + if (napi_get_reference_value(ft->env, ft->loop_exit_cb, &callback) == napi_ok && + napi_get_reference_value(ft->env, ft->ctx, &receiver) == napi_ok && + napi_create_int32(ft->env, atomic_load(&(ft->loop_result)), &result) == napi_ok) { + napi_status status = napi_make_callback( + ft->env, + NULL, + receiver, + callback, + 1, + &result, + NULL + ); + if (status == napi_pending_exception) { + napi_value exception; + if (napi_get_and_clear_last_exception(ft->env, &exception) == napi_ok) { + napi_fatal_exception(ft->env, exception); + } + } + } + napi_close_handle_scope(ft->env, scope); +} + +static void fuse_native_mount_cleanup (fuse_thread_t *ft) { + ft->mount_cleanup_pending = 1; + fuse_native_close_all_polls(ft); + if (ft->fuse != NULL) { + fuse_native_dispose_mount(ft->fuse, ft->fuse_mounted); + ft->fuse = NULL; + ft->fuse_mounted = 0; + } + + /* + * ft is backed by a JavaScript Buffer and contains loop_exit_async inline. + * Worker locals contain their own async handles. Keep state_ref alive until + * every close callback has run so none of that storage can be reclaimed + * while libuv still owns a handle. + */ + ft->close_pending = 0; + for (fuse_thread_locals_t *l = ft->locals; l != NULL; l = l->next) { + if (l->async_initialized) ft->close_pending++; + } + if (ft->loop_exit_async_initialized) ft->close_pending++; + + fuse_thread_locals_t *l = ft->locals; + ft->locals = NULL; + while (l != NULL) { + fuse_thread_locals_t *next = l->next; + if (l->self != NULL) { + napi_delete_reference(ft->env, l->self); + l->self = NULL; + } + if (l->sem_initialized) { + uv_sem_destroy(&(l->sem)); + l->sem_initialized = 0; + } + if (l->async_initialized) { + l->async_initialized = 0; + l->async.data = l; + uv_close((uv_handle_t *) &(l->async), fuse_native_mount_local_closed); + } else { + free(l); + } + l = next; + } + + if (ft->loop_exit_async_initialized) { + ft->loop_exit_async.data = ft; + uv_close( + (uv_handle_t *) &(ft->loop_exit_async), + fuse_native_mount_thread_async_closed + ); + ft->loop_exit_async_initialized = 0; + } + if (ft->workers_sem_initialized) { + uv_sem_destroy(&(ft->workers_finished)); + ft->workers_sem_initialized = 0; + } + if (ft->invalidation_cond_initialized) { + uv_cond_destroy(&(ft->invalidation_cond)); + ft->invalidation_cond_initialized = 0; + } + if (ft->invalidation_mutex_initialized) { + uv_mutex_destroy(&(ft->invalidation_mut)); + ft->invalidation_mutex_initialized = 0; + } + if (ft->mutex_initialized) { + uv_mutex_destroy(&(ft->mut)); + ft->mutex_initialized = 0; + } + if (ft->attr_initialized) { + pthread_attr_destroy(&(ft->attr)); + ft->attr_initialized = 0; + } + if (ft->mount_cb != NULL) { + napi_delete_reference(ft->env, ft->mount_cb); + ft->mount_cb = NULL; + } + fuse_native_delete_references(ft); + fuse_native_free_strings(ft); + if (ft->close_pending == 0) fuse_native_mount_cleanup_finished(ft); +} + +NAPI_METHOD(fuse_native_cancel_mount) { + NAPI_ARGV(1) + NAPI_ARGV_BUFFER_CAST(fuse_thread_t *, ft, 0); + if (ft_len < sizeof(*ft) || !ft->mount_pending) { + napi_throw_error(env, "EINVAL", "FUSE mount is not pending"); + return NULL; + } + atomic_store(&(ft->mount_cancelled), 1); + (void) uv_cancel((uv_req_t *) &(ft->mount_work)); + return NULL; +} + +static char * fuse_native_string (napi_env env, napi_value value) { + size_t size = 0; + if (napi_get_value_string_utf8(env, value, NULL, 0, &size) != napi_ok) return NULL; + char *string = malloc(size + 1); + if (string == NULL) return NULL; + size_t written = 0; + if (napi_get_value_string_utf8(env, value, string, size + 1, &written) != napi_ok) { + free(string); + return NULL; + } + string[written] = '\0'; + return string; +} + +static void fuse_native_finish_cleanup (fuse_thread_t *ft); + +static napi_value fuse_native_cleanup_error_value ( + fuse_thread_t *ft, + bool cleanup_complete +) { + napi_value code; + napi_value message; + napi_value error; + napi_value complete; + char text[128]; + snprintf(text, sizeof(text), "Native FUSE cleanup failed: %s", strerror(ft->cleanup_error)); + napi_create_string_utf8(ft->env, "EFUSECLEANUP", NAPI_AUTO_LENGTH, &code); + napi_create_string_utf8(ft->env, text, NAPI_AUTO_LENGTH, &message); + napi_create_error(ft->env, code, message, &error); + napi_get_boolean(ft->env, cleanup_complete, &complete); + napi_set_named_property(ft->env, error, "cleanupComplete", complete); + return error; +} + +static void fuse_native_report_cleanup_failure (fuse_thread_t *ft) { + if (ft->env_cleanup) { + napi_fatal_error( + "fuse-napi", + NAPI_AUTO_LENGTH, + "A native FUSE thread could not be joined during environment cleanup", + NAPI_AUTO_LENGTH + ); + return; + } + + atomic_store(&(ft->cleanup_scheduled), 0); + if (ft->cleanup_cb == NULL) return; + + napi_handle_scope scope; + if (napi_open_handle_scope(ft->env, &scope) != napi_ok) { + napi_fatal_error( + "fuse-napi", + NAPI_AUTO_LENGTH, + "Failed to report an incomplete native FUSE cleanup", + NAPI_AUTO_LENGTH + ); + return; + } + napi_value callback; + napi_value global; + napi_value error = fuse_native_cleanup_error_value(ft, false); + napi_get_global(ft->env, &global); + napi_get_reference_value(ft->env, ft->cleanup_cb, &callback); + napi_delete_reference(ft->env, ft->cleanup_cb); + ft->cleanup_cb = NULL; + napi_status call_status = napi_call_function( + ft->env, + global, + callback, + 1, + &error, + NULL + ); + if (call_status == napi_pending_exception) { + napi_value exception; + if (napi_get_and_clear_last_exception(ft->env, &exception) == napi_ok) { + napi_fatal_exception(ft->env, exception); + } + } + napi_close_handle_scope(ft->env, scope); +} + +static void fuse_native_thread_async_closed (uv_handle_t *handle) { + fuse_thread_t *ft = (fuse_thread_t *) handle->data; + if (--ft->close_pending == 0) fuse_native_finish_cleanup(ft); +} + +static void fuse_native_local_closed (uv_handle_t *handle) { + fuse_thread_locals_t *l = (fuse_thread_locals_t *) handle->data; + fuse_thread_t *ft = l->fuse; + free(l); + if (--ft->close_pending == 0) fuse_native_finish_cleanup(ft); +} + +static void fuse_native_finish_cleanup (fuse_thread_t *ft) { + ft->mounted = 0; + ft->thread_started = 0; + ft->locals = NULL; + fuse_native_free_strings(ft); + + if (ft->cleanup_hook != NULL) { + napi_remove_async_cleanup_hook(ft->cleanup_hook); + ft->cleanup_hook = NULL; + } + + if (ft->env_cleanup) { + if (ft->cleanup_cb != NULL) { + napi_delete_reference(ft->env, ft->cleanup_cb); + ft->cleanup_cb = NULL; + } + if (ft->state_ref != NULL) { + napi_delete_reference(ft->env, ft->state_ref); + ft->state_ref = NULL; + } + return; + } + + napi_handle_scope scope; + if (napi_open_handle_scope(ft->env, &scope) != napi_ok) { + if (ft->cleanup_cb != NULL) { + napi_delete_reference(ft->env, ft->cleanup_cb); + ft->cleanup_cb = NULL; + } + if (ft->state_ref != NULL) { + napi_delete_reference(ft->env, ft->state_ref); + ft->state_ref = NULL; + } + return; + } + napi_value callback; + napi_value global; + napi_value argv[1]; + napi_get_global(ft->env, &global); + napi_get_reference_value(ft->env, ft->cleanup_cb, &callback); + + if (ft->cleanup_error == 0) { + napi_get_null(ft->env, &(argv[0])); + } else { + argv[0] = fuse_native_cleanup_error_value(ft, true); + } + + napi_delete_reference(ft->env, ft->cleanup_cb); + ft->cleanup_cb = NULL; + napi_status call_status = napi_call_function(ft->env, global, callback, 1, argv, NULL); + if (call_status == napi_pending_exception) { + napi_value exception; + napi_get_and_clear_last_exception(ft->env, &exception); + napi_fatal_exception(ft->env, exception); + } + napi_close_handle_scope(ft->env, scope); + if (ft->state_ref != NULL) { + napi_delete_reference(ft->env, ft->state_ref); + ft->state_ref = NULL; + } +} + +static void fuse_native_cleanup_work (uv_work_t *request) { + fuse_thread_t *ft = (fuse_thread_t *) request->data; + + if (ft->invalidation_mutex_initialized && ft->invalidation_cond_initialized) { + uv_mutex_lock(&(ft->invalidation_mut)); + ft->invalidation_stopping = 1; + while (ft->invalidation_active) { + uv_cond_wait(&(ft->invalidation_cond), &(ft->invalidation_mut)); + } + uv_mutex_unlock(&(ft->invalidation_mut)); + } + + if (ft->mutex_initialized) { + uv_mutex_lock(&(ft->mut)); + if (ft->fuse != NULL) fuse_exit(ft->fuse); + if (ft->workers != NULL) fuse_native_cancel_workers_locked(ft); + uv_mutex_unlock(&(ft->mut)); + } + + ft->cleanup_invalidation_thread_joined = !ft->invalidation_thread_started; + if (ft->invalidation_thread_started) { + int err = pthread_join(ft->invalidation_thread, NULL); + if (err != 0) { + ft->cleanup_error = err; + return; + } + ft->cleanup_invalidation_thread_joined = 1; + ft->invalidation_thread_started = 0; + } + + ft->cleanup_thread_joined = !ft->thread_started; + if (ft->thread_started) { + int err = pthread_join(ft->thread, NULL); + if (err != 0) { + ft->cleanup_error = err; + return; + } + ft->cleanup_thread_joined = 1; + } + + for (fuse_thread_locals_t *l = ft->locals; l != NULL; l = l->next) { + if (l->sem_initialized) { + uv_sem_destroy(&(l->sem)); + l->sem_initialized = 0; + } + } + if (ft->workers_sem_initialized) { + uv_sem_destroy(&(ft->workers_finished)); + ft->workers_sem_initialized = 0; + } + if (ft->attr_initialized) { + int err = pthread_attr_destroy(&(ft->attr)); + if (err != 0 && ft->cleanup_error == 0) ft->cleanup_error = err; + ft->attr_initialized = 0; + } + if (ft->invalidation_cond_initialized) { + uv_cond_destroy(&(ft->invalidation_cond)); + ft->invalidation_cond_initialized = 0; + } + if (ft->invalidation_mutex_initialized) { + uv_mutex_destroy(&(ft->invalidation_mut)); + ft->invalidation_mutex_initialized = 0; + } + if (ft->mutex_initialized) { + uv_mutex_destroy(&(ft->mut)); + ft->mutex_initialized = 0; + } +} + +static void fuse_native_cleanup_after (uv_work_t *request, int status) { + fuse_thread_t *ft = (fuse_thread_t *) request->data; + if (status < 0) { + if (ft->cleanup_error == 0) ft->cleanup_error = EIO; + ft->cleanup_thread_joined = 0; + ft->cleanup_invalidation_thread_joined = 0; + } + if (!ft->cleanup_thread_joined || !ft->cleanup_invalidation_thread_joined) { + fuse_native_report_cleanup_failure(ft); + return; + } + + fuse_native_delete_references(ft); + ft->close_pending = 0; + + for (fuse_thread_locals_t *l = ft->locals; l != NULL; l = l->next) { + if (l->self != NULL) { + napi_delete_reference(ft->env, l->self); + l->self = NULL; + } + if (l->async_initialized) ft->close_pending++; + } + if (ft->loop_exit_async_initialized) ft->close_pending++; + + for (fuse_thread_locals_t *l = ft->locals; l != NULL; l = l->next) { + if (l->async_initialized) { + l->async_initialized = 0; + l->async.data = l; + uv_close((uv_handle_t *) &(l->async), fuse_native_local_closed); + } + } + if (ft->loop_exit_async_initialized) { + ft->loop_exit_async_initialized = 0; + ft->loop_exit_async.data = ft; + uv_close((uv_handle_t *) &(ft->loop_exit_async), fuse_native_thread_async_closed); + } + + if (ft->close_pending == 0) fuse_native_finish_cleanup(ft); +} + +static int fuse_native_begin_cleanup (fuse_thread_t *ft, int env_cleanup) { + int expected = 0; + if (!atomic_compare_exchange_strong(&(ft->cleanup_scheduled), &expected, 1)) return UV_EALREADY; + atomic_store(&(ft->cleanup_requested), 1); + ft->env_cleanup = env_cleanup; + ft->cleanup_error = 0; + ft->cleanup_thread_joined = 0; + ft->cleanup_invalidation_thread_joined = 0; + + for (fuse_thread_locals_t *l = ft->locals; l != NULL; l = l->next) { + if (l->op != op_destroy && l->op != op_poll) { + fuse_native_complete_local(l, -EIO); + } + } + for (fuse_thread_locals_t *l = ft->locals; l != NULL; l = l->next) { + if (l->op == op_poll) fuse_native_complete_local(l, -EIO); + } + + if (ft->invalidation_mutex_initialized && ft->invalidation_cond_initialized) { + uv_mutex_lock(&(ft->invalidation_mut)); + uv_cond_broadcast(&(ft->invalidation_cond)); + uv_mutex_unlock(&(ft->invalidation_mut)); + } + + ft->cleanup_work.data = ft; + int err = uv_queue_work(ft->loop, &(ft->cleanup_work), fuse_native_cleanup_work, fuse_native_cleanup_after); + if (err < 0) { + fuse_native_cleanup_work(&(ft->cleanup_work)); + fuse_native_cleanup_after(&(ft->cleanup_work), 0); + return 0; + } + return 0; +} + +static void fuse_native_env_cleanup (napi_async_cleanup_hook_handle handle, void *data) { + (void) handle; + fuse_thread_t *ft = (fuse_thread_t *) data; + if (ft->mount_pending || ft->mount_cleanup_pending) { + ft->env_cleanup = 1; + atomic_store(&(ft->mount_cancelled), 1); + return; + } + if (atomic_load(&(ft->cleanup_scheduled))) { + ft->env_cleanup = 1; + return; + } + fuse_native_begin_cleanup(ft, 1); +} + +#define FUSE_MOUNT_ERROR_NONE 0 +#define FUSE_MOUNT_ERROR_MOUNT 1 +#define FUSE_MOUNT_ERROR_INIT 2 +#define FUSE_MOUNT_ERROR_CANCELLED 3 +#define FUSE_MOUNT_ERROR_THREAD 4 + +static void fuse_native_mount_work (uv_work_t *request) { + fuse_thread_t *ft = (fuse_thread_t *) request->data; + if (atomic_load(&(ft->mount_cancelled))) { + ft->mount_error = FUSE_MOUNT_ERROR_CANCELLED; + return; + } + + int argc = ft->mntopts[0] == '\0' ? 1 : 2; + char *argv[] = { + (char *) "fuse_bindings_dummy", + ft->mntopts + }; + struct fuse_args args = FUSE_ARGS_INIT(argc, argv); + + ft->fuse = fuse_new(&args, &(ft->ops), sizeof(ft->ops), ft); + if (ft->fuse == NULL) { + ft->mount_error = FUSE_MOUNT_ERROR_INIT; + fuse_opt_free_args(&args); + return; + } + if (atomic_load(&(ft->mount_cancelled))) { + ft->mount_error = FUSE_MOUNT_ERROR_CANCELLED; + fuse_opt_free_args(&args); + return; + } + + fuse_opt_free_args(&args); + if (fuse_mount(ft->fuse, ft->mnt) != 0) { + ft->mount_error = FUSE_MOUNT_ERROR_MOUNT; + return; + } + ft->fuse_mounted = 1; + if (atomic_load(&(ft->mount_cancelled))) { + ft->mount_error = FUSE_MOUNT_ERROR_CANCELLED; + } +} + +static void fuse_native_call_mount_callback (fuse_thread_t *ft) { + if (ft->mount_cb == NULL || ft->env_cleanup) return; + + napi_handle_scope scope; + if (napi_open_handle_scope(ft->env, &scope) != napi_ok) return; + napi_value callback; + napi_value receiver; + napi_value argument; + if (napi_get_reference_value(ft->env, ft->mount_cb, &callback) != napi_ok || + napi_get_reference_value(ft->env, ft->ctx, &receiver) != napi_ok) { + napi_close_handle_scope(ft->env, scope); + return; + } + + if (ft->mount_error == FUSE_MOUNT_ERROR_NONE) { + napi_get_null(ft->env, &argument); + } else { + const char *code = "EFUSEINIT"; + const char *message = "Failed to initialize the native FUSE mount"; + if (ft->mount_error == FUSE_MOUNT_ERROR_MOUNT) { + code = "EFUSEMOUNT"; + message = "libfuse failed to mount the requested mountpoint"; + } else if (ft->mount_error == FUSE_MOUNT_ERROR_CANCELLED) { + code = "EFUSEMOUNTCANCELLED"; + message = "The native FUSE mount was cancelled"; + } else if (ft->mount_error == FUSE_MOUNT_ERROR_THREAD) { + code = "EFUSETHREAD"; + message = "Failed to start the bounded FUSE worker pool"; + } + napi_value code_value; + napi_value message_value; + napi_create_string_utf8(ft->env, code, NAPI_AUTO_LENGTH, &code_value); + napi_create_string_utf8(ft->env, message, NAPI_AUTO_LENGTH, &message_value); + napi_create_error(ft->env, code_value, message_value, &argument); + } + + napi_status status = napi_make_callback( + ft->env, + NULL, + receiver, + callback, + 1, + &argument, + NULL + ); + if (status == napi_pending_exception) { + napi_value exception; + if (napi_get_and_clear_last_exception(ft->env, &exception) == napi_ok) { + napi_fatal_exception(ft->env, exception); + } + } + napi_close_handle_scope(ft->env, scope); +} + +static void fuse_native_mount_after (uv_work_t *request, int status) { + fuse_thread_t *ft = (fuse_thread_t *) request->data; + ft->mount_pending = 0; + if (status < 0 && ft->mount_error == FUSE_MOUNT_ERROR_NONE) { + ft->mount_error = FUSE_MOUNT_ERROR_INIT; + } + if (atomic_load(&(ft->mount_cancelled)) && + ft->mount_error == FUSE_MOUNT_ERROR_NONE) { + ft->mount_error = FUSE_MOUNT_ERROR_CANCELLED; + } + if (ft->mount_error == FUSE_MOUNT_ERROR_NONE) { + int result = pthread_create(&(ft->thread), &(ft->attr), start_fuse_thread, ft); + if (result == 0) { + ft->thread_started = 1; + ft->mounted = 1; + } else { + ft->cleanup_error = result; + ft->mount_error = FUSE_MOUNT_ERROR_THREAD; + } + } + if (ft->mount_error != FUSE_MOUNT_ERROR_NONE) { + ft->mount_cleanup_pending = 1; + } + + fuse_native_call_mount_callback(ft); + if (ft->mount_cb != NULL) { + napi_delete_reference(ft->env, ft->mount_cb); + ft->mount_cb = NULL; + } + if (ft->mount_error != FUSE_MOUNT_ERROR_NONE) { + fuse_native_mount_cleanup(ft); + } +} + +NAPI_METHOD(fuse_native_mount) { + NAPI_ARGV(12) + + NAPI_ARGV_BUFFER_CAST(fuse_thread_t *, ft, 2); + if (ft_len < sizeof(*ft)) { + napi_throw_range_error(env, "EINVAL", "Invalid native thread state buffer"); + return NULL; + } + memset(ft, 0, sizeof(*ft)); + atomic_init(&(ft->cleanup_scheduled), 0); + atomic_init(&(ft->cleanup_requested), 0); + atomic_init(&(ft->loop_result), 0); + atomic_init(&(ft->mount_cancelled), 0); + ft->env = env; + if (napi_create_reference(env, argv[2], 1, &(ft->state_ref)) != napi_ok) { + napi_throw_error(env, "EFUSEINIT", "Failed to retain the native thread state"); + return NULL; + } + + ft->mnt = fuse_native_string(env, argv[0]); + ft->mntopts = fuse_native_string(env, argv[1]); + if (ft->mnt == NULL || ft->mntopts == NULL) { + fuse_native_mount_cleanup(ft); + napi_throw_type_error(env, "EINVAL", "Mountpoint and mount options must be strings"); + return NULL; + } + + uint64_t configured_max_read = 0; + if (napi_get_value_bool(env, argv[10], &(ft->max_read_configured)) != napi_ok) { + fuse_native_mount_cleanup(ft); + napi_throw_type_error(env, "EINVAL", "Configured maxRead presence must be a boolean"); + return NULL; + } + if (value_to_uint64(env, argv[11], &configured_max_read) != 0 || + configured_max_read > UINT32_MAX || + (!ft->max_read_configured && configured_max_read != 0)) { + fuse_native_mount_cleanup(ft); + napi_throw_range_error(env, "EINVAL", "Invalid configured maxRead state"); + return NULL; + } + ft->configured_max_read = (uint32_t) configured_max_read; + + napi_valuetype loop_exit_type; + napi_valuetype mount_callback_type; + if (napi_typeof(env, argv[8], &loop_exit_type) != napi_ok || + napi_typeof(env, argv[9], &mount_callback_type) != napi_ok || + loop_exit_type != napi_function || + mount_callback_type != napi_function || + napi_get_uv_event_loop(env, &(ft->loop)) != napi_ok || + napi_create_reference(env, argv[3], 1, &(ft->ctx)) != napi_ok || + napi_create_reference(env, argv[8], 1, &(ft->loop_exit_cb)) != napi_ok || + napi_create_reference(env, argv[9], 1, &(ft->mount_cb)) != napi_ok) { + fuse_native_mount_cleanup(ft); + napi_throw_error(env, "EFUSEINIT", "Failed to initialize the native Node.js context"); + return NULL; + } + + napi_value handlers = argv[4]; + uint32_t *implemented = NULL; + size_t implemented_len = 0; + if (get_uint32_array(env, argv[5], &implemented, &implemented_len) != 0 || + implemented_len != FUSE_OPERATION_COUNT) { + fuse_native_mount_cleanup(ft); + napi_throw_range_error(env, "EINVAL", "Invalid implemented-operation buffer"); + return NULL; + } + uint32_t max_workers = 0; + if (napi_get_value_uint32(env, argv[6], &max_workers) != napi_ok || + max_workers == 0 || + max_workers > FUSE_MAX_WORKERS) { + fuse_native_mount_cleanup(ft); + napi_throw_range_error(env, "EINVAL", "maxConcurrency must be between 1 and 64"); + return NULL; + } + ft->max_workers = (size_t) max_workers; + uint32_t operation_flags = 0; + if (napi_get_value_uint32(env, argv[7], &operation_flags) != napi_ok || + (operation_flags & ~FUSE_OPERATION_FLAGS_ALLOWED) != 0) { + fuse_native_mount_cleanup(ft); + napi_throw_range_error(env, "EINVAL", "Invalid FUSE operation flags"); + return NULL; + } + + bool handlers_is_array = false; + uint32_t handlers_length = 0; + if (napi_is_array(env, handlers, &handlers_is_array) != napi_ok || + !handlers_is_array || + napi_get_array_length(env, handlers, &handlers_length) != napi_ok || + handlers_length != FUSE_OPERATION_COUNT) { + fuse_native_mount_cleanup(ft); + napi_throw_type_error(env, "EINVAL", "Unexpected FUSE operation-handler count"); + return NULL; + } + + for (uint32_t i = 0; i < handlers_length; i++) { + ft->implemented[i] = implemented[i] != 0; + napi_value handler; + napi_valuetype type; + if (napi_get_element(env, handlers, i, &handler) != napi_ok || + napi_typeof(env, handler, &type) != napi_ok) { + fuse_native_mount_cleanup(ft); + napi_throw_type_error(env, "EINVAL", "Invalid FUSE operation handler"); + return NULL; + } + if (type == napi_function) { + if (napi_create_reference(env, handler, 1, &ft->handlers[i]) == napi_ok) continue; + fuse_native_mount_cleanup(ft); + napi_throw_error(env, "EFUSEINIT", "Failed to retain a FUSE operation handler"); + return NULL; + } + if (implemented[i]) { + fuse_native_mount_cleanup(ft); + napi_throw_type_error(env, "EINVAL", "An implemented FUSE operation has no handler"); + return NULL; + } + } + + memset(&(ft->ops), 0, sizeof(ft->ops)); + if (implemented[op_access]) ft->ops.access = fuse_native_access; + if (implemented[op_truncate] || implemented[op_ftruncate]) { + ft->ops.truncate = fuse_native_truncate_v3; + } + if (implemented[op_getattr] || implemented[op_fgetattr]) { + ft->ops.getattr = fuse_native_getattr_v3; + } + if (implemented[op_flush]) ft->ops.flush = fuse_native_flush; + if (implemented[op_fsync]) ft->ops.fsync = fuse_native_fsync; + if (implemented[op_fsyncdir]) ft->ops.fsyncdir = fuse_native_fsyncdir; + if (implemented[op_readdir]) ft->ops.readdir = fuse_native_readdir; + if (implemented[op_readlink]) ft->ops.readlink = fuse_native_readlink; + if (implemented[op_chown]) ft->ops.chown = fuse_native_chown; + if (implemented[op_chmod]) ft->ops.chmod = fuse_native_chmod; + if (implemented[op_mknod]) ft->ops.mknod = fuse_native_mknod; + if (implemented[op_setxattr]) ft->ops.setxattr = fuse_native_setxattr; + if (implemented[op_getxattr]) ft->ops.getxattr = fuse_native_getxattr; + if (implemented[op_listxattr]) ft->ops.listxattr = fuse_native_listxattr; + if (implemented[op_removexattr]) ft->ops.removexattr = fuse_native_removexattr; +#if defined(__APPLE__) && FUSE_DARWIN_ENABLE_EXTENSIONS + if (implemented[op_statfs]) ft->ops.statfs = fuse_native_statfs_darwin; + if (implemented[op_chmod] || implemented[op_chown] || + implemented[op_truncate] || implemented[op_ftruncate] || + implemented[op_utimens]) { + ft->ops.setattr = fuse_native_setattr_darwin; + } +#else + if (implemented[op_statfs]) ft->ops.statfs = fuse_native_statfs; +#endif + if (implemented[op_open]) ft->ops.open = fuse_native_open; + if (implemented[op_opendir]) ft->ops.opendir = fuse_native_opendir; + if (implemented[op_read]) ft->ops.read = fuse_native_read; + if (implemented[op_write]) ft->ops.write = fuse_native_write; + if (implemented[op_release]) ft->ops.release = fuse_native_release; + if (implemented[op_releasedir]) ft->ops.releasedir = fuse_native_releasedir; + if (implemented[op_create]) ft->ops.create = fuse_native_create; + if (implemented[op_utimens]) ft->ops.utimens = fuse_native_utimens; + if (implemented[op_unlink]) ft->ops.unlink = fuse_native_unlink; + if (implemented[op_rename]) ft->ops.rename = fuse_native_rename; + if (implemented[op_link]) ft->ops.link = fuse_native_link; + if (implemented[op_symlink]) ft->ops.symlink = fuse_native_symlink; + if (implemented[op_mkdir]) ft->ops.mkdir = fuse_native_mkdir; + if (implemented[op_rmdir]) ft->ops.rmdir = fuse_native_rmdir; + /* + * Always install init so the legacy null-path options can be translated to + * fuse_config even when no JavaScript init handler is present. + */ + ft->ops.init = fuse_native_init; + if (implemented[op_destroy]) ft->ops.destroy = fuse_native_destroy; + if (implemented[op_lock]) ft->ops.lock = fuse_native_lock; + if (implemented[op_bmap]) ft->ops.bmap = fuse_native_bmap; + if (implemented[op_ioctl]) ft->ops.ioctl = fuse_native_ioctl; + if (implemented[op_poll]) ft->ops.poll = fuse_native_poll; + if (implemented[op_write_buf]) ft->ops.write_buf = fuse_native_write_buf; + if (implemented[op_read_buf]) ft->ops.read_buf = fuse_native_read_buf; + if (implemented[op_flock]) ft->ops.flock = fuse_native_flock; + if (implemented[op_fallocate]) ft->ops.fallocate = fuse_native_fallocate; + if (implemented[op_copy_file_range]) { + ft->ops.copy_file_range = fuse_native_copy_file_range; + } + if (implemented[op_lseek]) ft->ops.lseek = fuse_native_lseek; +#if defined(__linux__) && FUSE_VERSION >= FUSE_MAKE_VERSION(3, 18) + if (implemented[op_getattr] || implemented[op_fgetattr]) { + ft->ops.statx = fuse_native_statx; + } +#endif + ft->operation_flags = operation_flags; + + int err = 0; +#ifdef __APPLE__ + *(void **) (&(ft->buf_free)) = dlsym(RTLD_DEFAULT, "fuse_buf_free"); + if (ft->buf_free == NULL) { + err = ENOSYS; + goto mount_failed; + } +#endif + + err = uv_mutex_init(&(ft->mut)); + if (err < 0) goto mount_failed; + ft->mutex_initialized = 1; + + err = uv_mutex_init(&(ft->invalidation_mut)); + if (err < 0) goto mount_failed; + ft->invalidation_mutex_initialized = 1; + + err = uv_cond_init(&(ft->invalidation_cond)); + if (err < 0) goto mount_failed; + ft->invalidation_cond_initialized = 1; + + err = uv_sem_init(&(ft->workers_finished), 0); + if (err < 0) goto mount_failed; + ft->workers_sem_initialized = 1; + + err = uv_async_init( + ft->loop, + &(ft->loop_exit_async), + (uv_async_cb) fuse_native_loop_exit_dispatch + ); + if (err < 0) goto mount_failed; + ft->loop_exit_async_initialized = 1; + ft->loop_exit_async.data = ft; + uv_unref((uv_handle_t *) &(ft->loop_exit_async)); + + err = fuse_native_create_worker_locals(ft); + if (err < 0) goto mount_failed; + + err = pthread_attr_init(&(ft->attr)); + if (err != 0) goto mount_failed; + ft->attr_initialized = 1; + + if (napi_add_async_cleanup_hook(env, fuse_native_env_cleanup, ft, &(ft->cleanup_hook)) != napi_ok) { + err = UV_EINVAL; + goto mount_failed; + } + + ft->mount_work.data = ft; + ft->mount_pending = 1; + err = uv_queue_work( + ft->loop, + &(ft->mount_work), + fuse_native_mount_work, + fuse_native_mount_after + ); + if (err < 0) { + ft->mount_pending = 0; + goto mount_failed; + } + + return NULL; + +mount_failed: + fuse_native_mount_cleanup(ft); + napi_throw_error(env, "EFUSEINIT", err < 0 ? uv_strerror(err) : strerror(err)); + return NULL; +} + +NAPI_METHOD(fuse_native_unmount) { + NAPI_ARGV(2) + NAPI_ARGV_BUFFER_CAST(fuse_thread_t *, ft, 0); + if (ft_len < sizeof(*ft) || !ft->mounted) { + napi_throw_error(env, "EINVAL", "FUSE filesystem is not mounted"); + return NULL; + } + + napi_valuetype callback_type; + if (napi_typeof(env, argv[1], &callback_type) != napi_ok || + callback_type != napi_function) { + napi_throw_type_error(env, "EINVAL", "Unmount callback must be a function"); + return NULL; + } + + if (napi_create_reference(env, argv[1], 1, &(ft->cleanup_cb)) != napi_ok) { + napi_throw_error(env, "EFUSECLEANUP", "Failed to retain the unmount callback"); + return NULL; + } + + int err = fuse_native_begin_cleanup(ft, 0); + if (err < 0) { + napi_delete_reference(env, ft->cleanup_cb); + ft->cleanup_cb = NULL; + napi_throw_error(env, "EFUSECLEANUP", uv_strerror(err)); + return NULL; + } + + return NULL; +} + +/* + * Register a local file descriptor with the kernel and return its backing + * id, so a later open can reply with FOPEN_PASSTHROUGH and take the daemon + * out of the data path. This is exactly what libfuse's low-level + * fuse_passthrough_open() does; the high-level API reaches the same session + * fd through fuse_get_session(). + * + * Returns the id (> 0) on success, or -errno. The caller decides whether a + * failure is fatal; computerd falls back to pread/pwrite for that file. + */ +NAPI_METHOD(fuse_native_backing_open) { + NAPI_ARGV(2) + NAPI_ARGV_BUFFER_CAST(fuse_thread_t *, ft, 0); + uint32_t fd = 0; + + if (ft_len < sizeof(*ft) || + napi_get_value_uint32(env, argv[1], &fd) != napi_ok) { + napi_throw_type_error(env, "EINVAL", "Invalid backing open request"); + return NULL; + } + +#if FUSE_HAS_BACKING_ID + int32_t result = -ENOTSUP; + struct fuse_session *session = NULL; + + uv_mutex_lock(&(ft->invalidation_mut)); + if (!atomic_load(&(ft->cleanup_requested)) && ft->mounted && ft->fuse != NULL) { + session = fuse_get_session(ft->fuse); + } + uv_mutex_unlock(&(ft->invalidation_mut)); + + if (session == NULL) { + result = -ENOTCONN; + } else { + int session_fd = fuse_session_fd(session); + if (session_fd < 0) { + result = -ENOTCONN; + } else { + struct fuse_backing_map map; + memset(&map, 0, sizeof(map)); + map.fd = (int32_t) fd; + int id = ioctl(session_fd, FUSE_DEV_IOC_BACKING_OPEN, &map); + result = id > 0 ? (int32_t) id : (int32_t) -errno; + } + } + + NAPI_RETURN_INT32(result) +#else + NAPI_RETURN_INT32(-ENOTSUP) +#endif +} + +/* Release a backing id. Safe to call once, on the last release of a file. */ +NAPI_METHOD(fuse_native_backing_close) { + NAPI_ARGV(2) + NAPI_ARGV_BUFFER_CAST(fuse_thread_t *, ft, 0); + uint32_t backing_id = 0; + + if (ft_len < sizeof(*ft) || + napi_get_value_uint32(env, argv[1], &backing_id) != napi_ok || + backing_id == 0) { + napi_throw_type_error(env, "EINVAL", "Invalid backing close request"); + return NULL; + } + +#if FUSE_HAS_BACKING_ID + int32_t result = -ENOTSUP; + struct fuse_session *session = NULL; + + uv_mutex_lock(&(ft->invalidation_mut)); + if (!atomic_load(&(ft->cleanup_requested)) && ft->mounted && ft->fuse != NULL) { + session = fuse_get_session(ft->fuse); + } + uv_mutex_unlock(&(ft->invalidation_mut)); + + if (session == NULL) { + result = -ENOTCONN; + } else { + int session_fd = fuse_session_fd(session); + if (session_fd < 0) { + result = -ENOTCONN; + } else { + uint32_t id = backing_id; + result = ioctl(session_fd, FUSE_DEV_IOC_BACKING_CLOSE, &id) == 0 + ? 0 + : (int32_t) -errno; + } + } + + NAPI_RETURN_INT32(result) +#else + NAPI_RETURN_INT32(-ENOTSUP) +#endif +} + +NAPI_METHOD(fuse_native_invalidate_entry) { + NAPI_ARGV(4) + NAPI_ARGV_BUFFER_CAST(fuse_thread_t *, ft, 0); + uint64_t parent = 0; + size_t name_size = 0; + napi_valuetype callback_type; + + if (ft_len < sizeof(*ft) || + value_to_uint64(env, argv[1], &parent) != 0 || + parent == 0 || + napi_get_value_string_utf8(env, argv[2], NULL, 0, &name_size) != napi_ok || + napi_typeof(env, argv[3], &callback_type) != napi_ok || + callback_type != napi_function) { + napi_throw_type_error(env, "EINVAL", "Invalid FUSE entry invalidation request"); + return NULL; + } + + fuse_invalidation_request_t *request = calloc(1, sizeof(*request)); + if (request == NULL) { + napi_throw_error(env, "ENOMEM", "Failed to allocate FUSE invalidation request"); + return NULL; + } + request->name = fuse_native_string(env, argv[2]); + if (request->name == NULL) { + free(request); + napi_throw_error(env, "ENOMEM", "Failed to allocate FUSE entry name"); + return NULL; + } + request->name_length = strlen(request->name); + request->parent = parent; + if (request->name_length == 0 || + request->name_length != name_size || + request->name_length > 255 || + strchr(request->name, '/') != NULL) { + free(request->name); + free(request); + napi_throw_range_error(env, "EINVAL", "FUSE entry name must be one NUL-free path component"); + return NULL; + } + + napi_value resource_name; + if (napi_create_string_utf8( + env, + "fuse-napi:invalidate-entry", + NAPI_AUTO_LENGTH, + &resource_name + ) != napi_ok || + napi_create_threadsafe_function( + env, + argv[3], + NULL, + resource_name, + 1, + 1, + request, + fuse_native_invalidation_finalize, + NULL, + fuse_native_invalidation_dispatch, + &(request->callback) + ) != napi_ok) { + free(request->name); + free(request); + napi_throw_error(env, "EFUSEINVALIDATE", "Failed to retain the invalidation callback"); + return NULL; + } + + int32_t result = 0; + bool queued = false; + if (!ft->mounted || + atomic_load(&(ft->cleanup_requested)) || + !ft->invalidation_mutex_initialized || + !ft->invalidation_cond_initialized || + !ft->attr_initialized) { + result = -ENOTCONN; + } else { + uv_mutex_lock(&(ft->invalidation_mut)); + if (!ft->mounted || + atomic_load(&(ft->cleanup_requested)) || + ft->invalidation_stopping) { + result = -ENOTCONN; + } else if (ft->invalidation_pending >= FUSE_MAX_PENDING_INVALIDATIONS) { + result = -EAGAIN; + } else { + if (!ft->invalidation_thread_started) { + int err = pthread_create( + &(ft->invalidation_thread), + &(ft->attr), + fuse_native_invalidation_worker, + ft + ); + if (err != 0) { + result = -err; + } else { + ft->invalidation_thread_started = 1; + } + } + if (result == 0) { + if (ft->invalidation_tail == NULL) { + ft->invalidation_head = request; + } else { + ft->invalidation_tail->next = request; + } + ft->invalidation_tail = request; + ft->invalidation_pending++; + queued = true; + uv_cond_signal(&(ft->invalidation_cond)); + } + } + uv_mutex_unlock(&(ft->invalidation_mut)); + } + + if (!queued) fuse_native_complete_invalidation(request, result); + return NULL; +} + +NAPI_METHOD(fuse_native_notify_poll) { + NAPI_ARGV(2) + NAPI_ARGV_BUFFER_CAST(fuse_thread_t *, ft, 0); + uint64_t id = 0; + if (ft_len < sizeof(*ft) || value_to_uint64(env, argv[1], &id) != 0 || id == 0) { + napi_throw_type_error(env, "EINVAL", "Invalid native poll handle"); + return NULL; + } + + bool notified = false; + if (!atomic_load(&(ft->cleanup_requested)) && ft->mutex_initialized) { + uv_mutex_lock(&(ft->mut)); + for (fuse_poll_registration_t *registration = ft->polls; + registration != NULL; + registration = registration->next) { + if (registration->id == id && registration->handle != NULL) { + notified = fuse_notify_poll(registration->handle) == 0; + break; + } + } + uv_mutex_unlock(&(ft->mut)); + } + + napi_value result; + napi_get_boolean(env, notified, &result); + return result; +} + +NAPI_METHOD(fuse_native_close_poll) { + NAPI_ARGV(2) + NAPI_ARGV_BUFFER_CAST(fuse_thread_t *, ft, 0); + uint64_t id = 0; + if (ft_len < sizeof(*ft) || value_to_uint64(env, argv[1], &id) != 0 || id == 0) { + napi_throw_type_error(env, "EINVAL", "Invalid native poll handle"); + return NULL; + } + + fuse_poll_registration_t *found = NULL; + if (!atomic_load(&(ft->cleanup_requested)) && ft->mutex_initialized) { + uv_mutex_lock(&(ft->mut)); + fuse_poll_registration_t **cursor = &(ft->polls); + while (*cursor != NULL) { + if ((*cursor)->id == id) { + found = *cursor; + *cursor = found->next; + break; + } + cursor = &((*cursor)->next); + } + uv_mutex_unlock(&(ft->mut)); + } + if (found != NULL) { + struct fuse_pollhandle *handle = found->handle; + found->handle = NULL; + if (handle != NULL) fuse_pollhandle_destroy(handle); + fuse_native_release_poll_registration(found); + } + + napi_value result; + napi_get_boolean(env, found != NULL, &result); + return result; +} + +NAPI_METHOD(fuse_native_runtime_info) { + (void) info; + napi_value result; + napi_value version; + napi_value api_version; + napi_value buffer_release; + napi_value statx; + const char *package_version = fuse_pkgversion(); + bool has_buffer_release = true; + bool has_statx = false; +#ifdef __APPLE__ + has_buffer_release = dlsym(RTLD_DEFAULT, "fuse_buf_free") != NULL; +#endif +#if defined(__linux__) && FUSE_VERSION >= FUSE_MAKE_VERSION(3, 18) + has_statx = true; +#endif + + if (napi_create_object(env, &result) != napi_ok || + napi_create_string_utf8( + env, + package_version == NULL ? "" : package_version, + NAPI_AUTO_LENGTH, + &version + ) != napi_ok || + napi_create_uint32(env, (uint32_t) fuse_version(), &api_version) != napi_ok || + napi_get_boolean(env, has_buffer_release, &buffer_release) != napi_ok || + napi_get_boolean(env, has_statx, &statx) != napi_ok || + napi_set_named_property(env, result, "version", version) != napi_ok || + napi_set_named_property(env, result, "apiVersion", api_version) != napi_ok || + napi_set_named_property( + env, + result, + "hasBufferRelease", + buffer_release + ) != napi_ok || + napi_set_named_property( + env, + result, + "hasStatx", + statx + ) != napi_ok) { + napi_throw_error(env, "EFUSEINIT", "Failed to inspect the loaded libfuse runtime"); + return NULL; + } + + return result; +} + +NAPI_INIT() { + if (pthread_once(&(thread_locals_once), create_thread_locals_key) != 0 || + thread_locals_status != 0) { + napi_throw_error(env, "EFUSEINIT", "Failed to create FUSE thread-local storage"); + return; + } + + NAPI_EXPORT_SIZEOF(fuse_thread_t) + + NAPI_EXPORT_FUNCTION(fuse_native_mount) + NAPI_EXPORT_FUNCTION(fuse_native_cancel_mount) + NAPI_EXPORT_FUNCTION(fuse_native_unmount) + NAPI_EXPORT_FUNCTION(fuse_native_invalidate_entry) + NAPI_EXPORT_FUNCTION(fuse_native_notify_poll) + NAPI_EXPORT_FUNCTION(fuse_native_close_poll) + NAPI_EXPORT_FUNCTION(fuse_native_runtime_info) + + NAPI_EXPORT_FUNCTION(fuse_native_signal_init) + NAPI_EXPORT_FUNCTION(fuse_native_signal_access) + NAPI_EXPORT_FUNCTION(fuse_native_signal_statfs) + NAPI_EXPORT_FUNCTION(fuse_native_signal_fgetattr) + NAPI_EXPORT_FUNCTION(fuse_native_signal_getattr) + NAPI_EXPORT_FUNCTION(fuse_native_signal_flush) + NAPI_EXPORT_FUNCTION(fuse_native_signal_fsync) + NAPI_EXPORT_FUNCTION(fuse_native_signal_fsyncdir) + NAPI_EXPORT_FUNCTION(fuse_native_signal_readdir) + NAPI_EXPORT_FUNCTION(fuse_native_signal_truncate) + NAPI_EXPORT_FUNCTION(fuse_native_signal_ftruncate) + NAPI_EXPORT_FUNCTION(fuse_native_signal_utimens) + NAPI_EXPORT_FUNCTION(fuse_native_signal_readlink) + NAPI_EXPORT_FUNCTION(fuse_native_signal_chown) + NAPI_EXPORT_FUNCTION(fuse_native_signal_chmod) + NAPI_EXPORT_FUNCTION(fuse_native_signal_mknod) + NAPI_EXPORT_FUNCTION(fuse_native_signal_setxattr) + NAPI_EXPORT_FUNCTION(fuse_native_signal_getxattr) + NAPI_EXPORT_FUNCTION(fuse_native_signal_listxattr) + NAPI_EXPORT_FUNCTION(fuse_native_signal_removexattr) + NAPI_EXPORT_FUNCTION(fuse_native_backing_open) + NAPI_EXPORT_FUNCTION(fuse_native_backing_close) + NAPI_EXPORT_FUNCTION(fuse_native_guard_start) + NAPI_EXPORT_FUNCTION(fuse_native_guard_add_root) + NAPI_EXPORT_FUNCTION(fuse_native_guard_remove_root) + NAPI_EXPORT_FUNCTION(fuse_native_guard_stats) + NAPI_EXPORT_FUNCTION(fuse_native_bind_root) + NAPI_EXPORT_FUNCTION(fuse_native_unbind_root) + NAPI_EXPORT_FUNCTION(fuse_native_open_root) + NAPI_EXPORT_FUNCTION(fuse_native_signal_open) + NAPI_EXPORT_FUNCTION(fuse_native_signal_opendir) + NAPI_EXPORT_FUNCTION(fuse_native_signal_read) + NAPI_EXPORT_FUNCTION(fuse_native_signal_write) + NAPI_EXPORT_FUNCTION(fuse_native_signal_release) + NAPI_EXPORT_FUNCTION(fuse_native_signal_releasedir) + NAPI_EXPORT_FUNCTION(fuse_native_signal_create) + NAPI_EXPORT_FUNCTION(fuse_native_signal_unlink) + NAPI_EXPORT_FUNCTION(fuse_native_signal_rename) + NAPI_EXPORT_FUNCTION(fuse_native_signal_link) + NAPI_EXPORT_FUNCTION(fuse_native_signal_symlink) + NAPI_EXPORT_FUNCTION(fuse_native_signal_mkdir) + NAPI_EXPORT_FUNCTION(fuse_native_signal_rmdir) + NAPI_EXPORT_FUNCTION(fuse_native_signal_destroy) + NAPI_EXPORT_FUNCTION(fuse_native_signal_lock) + NAPI_EXPORT_FUNCTION(fuse_native_signal_bmap) + NAPI_EXPORT_FUNCTION(fuse_native_signal_ioctl) + NAPI_EXPORT_FUNCTION(fuse_native_signal_poll) + NAPI_EXPORT_FUNCTION(fuse_native_signal_write_buf) + NAPI_EXPORT_FUNCTION(fuse_native_signal_read_buf) + NAPI_EXPORT_FUNCTION(fuse_native_signal_flock) + NAPI_EXPORT_FUNCTION(fuse_native_signal_fallocate) + NAPI_EXPORT_FUNCTION(fuse_native_signal_copy_file_range) + NAPI_EXPORT_FUNCTION(fuse_native_signal_lseek) + + NAPI_EXPORT_UINT32(op_init) + NAPI_EXPORT_UINT32(op_error) + NAPI_EXPORT_UINT32(op_access) + NAPI_EXPORT_UINT32(op_statfs) + NAPI_EXPORT_UINT32(op_fgetattr) + NAPI_EXPORT_UINT32(op_getattr) + NAPI_EXPORT_UINT32(op_flush) + NAPI_EXPORT_UINT32(op_fsync) + NAPI_EXPORT_UINT32(op_fsyncdir) + NAPI_EXPORT_UINT32(op_readdir) + NAPI_EXPORT_UINT32(op_truncate) + NAPI_EXPORT_UINT32(op_ftruncate) + NAPI_EXPORT_UINT32(op_utimens) + NAPI_EXPORT_UINT32(op_readlink) + NAPI_EXPORT_UINT32(op_chown) + NAPI_EXPORT_UINT32(op_chmod) + NAPI_EXPORT_UINT32(op_mknod) + NAPI_EXPORT_UINT32(op_setxattr) + NAPI_EXPORT_UINT32(op_getxattr) + NAPI_EXPORT_UINT32(op_listxattr) + NAPI_EXPORT_UINT32(op_removexattr) + NAPI_EXPORT_UINT32(op_open) + NAPI_EXPORT_UINT32(op_opendir) + NAPI_EXPORT_UINT32(op_read) + NAPI_EXPORT_UINT32(op_write) + NAPI_EXPORT_UINT32(op_release) + NAPI_EXPORT_UINT32(op_releasedir) + NAPI_EXPORT_UINT32(op_create) + NAPI_EXPORT_UINT32(op_unlink) + NAPI_EXPORT_UINT32(op_rename) + NAPI_EXPORT_UINT32(op_link) + NAPI_EXPORT_UINT32(op_symlink) + NAPI_EXPORT_UINT32(op_mkdir) + NAPI_EXPORT_UINT32(op_rmdir) + NAPI_EXPORT_UINT32(op_destroy) + NAPI_EXPORT_UINT32(op_lock) + NAPI_EXPORT_UINT32(op_bmap) + NAPI_EXPORT_UINT32(op_ioctl) + NAPI_EXPORT_UINT32(op_poll) + NAPI_EXPORT_UINT32(op_write_buf) + NAPI_EXPORT_UINT32(op_read_buf) + NAPI_EXPORT_UINT32(op_flock) + NAPI_EXPORT_UINT32(op_fallocate) + NAPI_EXPORT_UINT32(op_copy_file_range) + NAPI_EXPORT_UINT32(op_lseek) +} diff --git a/packages/computerd/vendor/fuse-napi/index.d.ts b/packages/computerd/vendor/fuse-napi/index.d.ts new file mode 100644 index 000000000..a4a1c0799 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/index.d.ts @@ -0,0 +1,754 @@ +declare namespace Fuse { + export type Result = number | null | undefined; + export type Int64 = number | bigint; + export type Uint64 = number | bigint; + export type FileHandle = Uint64; + /** Can be null only when nullPathOk/noPath was enabled for handle-based operations. */ + export type HandlePath = string | null; + + export interface Timespec { + readonly seconds: Int64; + readonly nanoseconds: number; + } + + export interface FileInfo { + readonly flags: number; + readonly writepage: boolean; + readonly directIO: boolean; + readonly keepCache: boolean; + readonly flush: boolean; + readonly nonseekable: boolean; + readonly flockRelease: boolean; + readonly fd: FileHandle; + readonly lockOwner: Uint64; + } + + export interface RequestContext { + readonly uid: number; + readonly gid: number; + readonly pid: number; + readonly umask: number; + readonly fileInfo: Readonly | null; + } + + export interface Lock { + readonly type: number; + readonly whence: number; + readonly start: Int64; + readonly length: Int64; + readonly pid: number; + } + + export interface FileInfoResult { + fd?: FileHandle; + directIO?: boolean; + keepCache?: boolean; + nonseekable?: boolean; + /** + * Backing id from `backingOpen()`. When > 0 the kernel serves this + * handle's reads and writes from the backing file directly, bypassing + * the daemon. Cannot be combined with `keepCache` or `directIO`. + */ + backingId?: number; + } + + export interface PollHandle { + readonly closed: boolean; + notify(): boolean; + close(): boolean; + } + + export interface ConnectionInfo { + readonly protoMajor: number; + readonly protoMinor: number; + readonly asyncRead: boolean; + readonly maxWrite: number; + readonly maxReadahead: number; + /** Active maximum read size; equals the constructor maxRead value when configured. */ + readonly maxRead: number; + readonly capable: number; + readonly want: number; + readonly maxBackground: number; + readonly congestionThreshold: number; + } + + export interface InitConfig { + maxWrite?: number; + maxReadahead?: number; + maxBackground?: number; + congestionThreshold?: number; + want?: number; + asyncRead?: boolean; + /** + * Maximum stacking depth permitted for a passthrough backing file. + * Set to 1 when the backing store may live on overlayfs. + */ + maxBackingStackDepth?: number; + } + + export interface EnvironmentReport { + readonly ok: true; + readonly platform: 'linux' | 'darwin'; + readonly helper?: 'fusermount3'; + readonly helperVersion?: string | null; + readonly device?: '/dev/fuse'; + readonly runtime?: string; + readonly macfuseVersion?: string; + readonly libfuseVersion?: string | null; + readonly capabilities: Readonly<{ + statx: boolean; + }>; + } + + // Stats object produced by fuse-native index.js function getStatArray + export interface Stats { + mode?: number; + uid?: number; + gid?: number; + size?: Uint64; + dev?: Uint64; + nlink?: Uint64; + ino?: Uint64; + rdev?: Uint64; + blksize?: Uint64; + blocks?: Uint64; + atime?: Date | Int64 | Timespec; + mtime?: Date | Int64 | Timespec; + ctime?: Date | Int64 | Timespec; + } + + export interface Statfs { + bsize?: Uint64; + frsize?: Uint64; + blocks?: Uint64; + bfree?: Uint64; + bavail?: Uint64; + files?: Uint64; + ffree?: Uint64; + favail?: Uint64; + fsid?: Uint64; + flag?: Uint64; + namemax?: Uint64; + } + + export interface CommonOperations { + access?: (path: string, mode: number, cb: (err: Result) => void) => void; + statfs?: (path: string, cb: (err: Result, stats?: Statfs) => void) => void; + fgetattr?: ( + path: HandlePath, + fd: FileHandle, + cb: (err: Result, stat?: Stats) => void + ) => void; + getattr?: ( + path: HandlePath, + cb: (err: Result, stat?: Stats) => void + ) => void; + flush?: (path: HandlePath, fd: FileHandle, cb: (err: Result) => void) => void; + fsync?: (path: HandlePath, dataSync: boolean, fd: FileHandle, cb: (err: Result) => void) => void; + fsyncdir?: (path: HandlePath, dataSync: boolean, fd: FileHandle, cb: (err: Result) => void) => void; + truncate?: (path: HandlePath, size: Int64, cb: (err: Result) => void) => void; + ftruncate?: (path: HandlePath, fd: FileHandle, size: Int64, cb: (err: Result) => void) => void; + readlink?: (path: string, cb: (err: Result, linkName?: string) => void) => void; + mknod?: (path: string, mode: number, dev: Uint64, cb: (err: Result) => void) => void; + setxattr?: ( + path: string, + name: string, + value: Buffer, + position: number, + flags: number, + cb: (err: Result) => void + ) => void; + getxattr?: ( + path: string, + name: string, + position: number, + cb: (err: Result, value?: Buffer | null) => void + ) => void; + listxattr?: (path: string, cb: (err: Result, list?: string[]) => void) => void; + removexattr?: (path: string, name: string, cb: (err: Result) => void) => void; + open?: ( + path: string, + flags: number, + cb: (err: Result, result?: FileHandle | FileInfoResult) => void + ) => void; + opendir?: ( + path: string, + flags: number, + cb: (err: Result, result?: FileHandle | FileInfoResult) => void + ) => void; + // For every open() call there will be exactly one release() call with the same flags and + // file handle. It is possible to have a file opened more than once, in which case only the + // last release will mean, that no more reads/writes will happen on the file. The return + // value of release is ignored. + release?: (path: HandlePath, fd: FileHandle, cb: (err: Result) => void) => void; + releasedir?: (path: HandlePath, fd: FileHandle, cb: (err: Result) => void) => void; + unlink?: (path: string, cb: (err: Result) => void) => void; + link?: (src: string, dest: string, cb: (err: Result) => void) => void; + symlink?: (src: string, dest: string, cb: (err: Result) => void) => void; + mkdir?: (path: string, mode: number, cb: (err: Result) => void) => void; + rmdir?: (path: string, cb: (err: Result) => void) => void; + destroy?: (cb: (err: Result) => void) => void; + lock?: ( + path: HandlePath, + fd: FileHandle, + command: number, + lock: Readonly, + cb: (err: Result, lock?: Lock) => void + ) => void; + bmap?: ( + path: string, + blockSize: Uint64, + index: Uint64, + cb: (err: Result, index?: Uint64) => void + ) => void; + ioctl?: ( + path: HandlePath, + fd: FileHandle, + command: number, + argument: Uint64, + flags: number, + data: Buffer, + cb: (err: Result, output?: Buffer) => void + ) => void; + flock?: ( + path: HandlePath, + fd: FileHandle, + operation: number, + cb: (err: Result) => void + ) => void; + fallocate?: ( + path: HandlePath, + fd: FileHandle, + mode: number, + offset: Int64, + length: Int64, + cb: (err: Result) => void + ) => void; + copyFileRange?: ( + sourcePath: HandlePath, + sourceFd: FileHandle, + sourceOffset: Int64, + destinationPath: HandlePath, + destinationFd: FileHandle, + destinationOffset: Int64, + length: Uint64, + flags: number, + cb: (result: number | bigint) => void + ) => void; + lseek?: ( + path: HandlePath, + fd: FileHandle, + offset: Int64, + whence: number, + cb: (err: Result, offset?: Int64) => void + ) => void; + } + + export type OPERATIONS = CommonOperations & ( + | { + init?: (cb: (err: Result) => void) => void; + initWithConfig?: never; + } + | { + init?: never; + initWithConfig?: ( + connection: Readonly, + cb: (err: Result, config?: InitConfig) => void + ) => void; + } + ) & ( + | { + readdir?: (path: HandlePath, cb: (err: Result, names?: string[], stats?: Stats[]) => void) => void; + readdirPaged?: never; + } + | { + readdir?: never; + readdirPaged?: ( + path: HandlePath, + fd: FileHandle, + offset: Int64, + cb: (err: Result, names?: string[], stats?: Stats[], nextOffsets?: Int64[]) => void + ) => void; + } + ) & ( + | { + create?: ( + path: string, + mode: number, + cb: (err: Result, result?: FileHandle | FileInfoResult) => void + ) => void; + createWithFlags?: never; + } + | { + create?: never; + createWithFlags?: ( + path: string, + mode: number, + flags: number, + cb: (err: Result, result?: FileHandle | FileInfoResult) => void + ) => void; + } + ) & ( + | { + utimens?: ( + path: HandlePath, + atime: Int64, + mtime: Int64, + cb: (err: Result) => void + ) => void; + utimensWithTimespec?: never; + utimensWithHandle?: never; + } + | { + utimens?: never; + utimensWithTimespec?: ( + path: HandlePath, + atime: Readonly, + mtime: Readonly, + cb: (err: Result) => void + ) => void; + utimensWithHandle?: never; + } + | { + utimens?: never; + utimensWithTimespec?: never; + utimensWithHandle?: ( + path: HandlePath, + fd: FileHandle, + atime: Readonly, + mtime: Readonly, + cb: (err: Result) => void + ) => void; + } + ) & ( + | { + read?: ( + path: HandlePath, + fd: FileHandle, + buffer: Buffer, + length: number, + position: Int64, + cb: (result: number) => void + ) => void; + readBuffer?: never; + } + | { + read?: never; + readBuffer?: ( + path: HandlePath, + fd: FileHandle, + length: number, + position: Int64, + cb: (err: Result, buffer?: Buffer) => void + ) => void; + } + ) & ( + | { + write?: ( + path: HandlePath, + fd: FileHandle, + buffer: Buffer, + length: number, + position: Int64, + cb: (result: number) => void + ) => void; + writeBuffer?: never; + } + | { + write?: never; + writeBuffer?: ( + path: HandlePath, + fd: FileHandle, + buffer: Buffer, + length: number, + position: Int64, + cb: (result: number) => void + ) => void; + } + ) & ( + | { + chown?: ( + path: HandlePath, + uid: number, + gid: number, + cb: (err: Result) => void + ) => void; + chownWithHandle?: never; + } + | { + chown?: never; + chownWithHandle?: ( + path: HandlePath, + fd: FileHandle, + uid: number, + gid: number, + cb: (err: Result) => void + ) => void; + } + ) & ( + | { + chmod?: ( + path: HandlePath, + mode: number, + cb: (err: Result) => void + ) => void; + chmodWithHandle?: never; + } + | { + chmod?: never; + chmodWithHandle?: ( + path: HandlePath, + fd: FileHandle, + mode: number, + cb: (err: Result) => void + ) => void; + } + ) & ( + | { + rename?: ( + src: string, + dest: string, + cb: (err: Result) => void + ) => void; + renameWithFlags?: never; + } + | { + rename?: never; + renameWithFlags?: ( + src: string, + dest: string, + flags: number, + cb: (err: Result) => void + ) => void; + } + ) & ( + | { + poll?: ( + path: HandlePath, + fd: FileHandle, + cb: (err: Result, events?: number) => void + ) => void; + pollWithHandle?: never; + } + | { + poll?: never; + pollWithHandle?: ( + path: HandlePath, + fd: FileHandle, + handle: PollHandle | null, + cb: (err: Result, events?: number) => void + ) => void; + } + ); + + export type OperationName = + | 'init' | 'access' | 'statfs' | 'fgetattr' | 'getattr' | 'flush' + | 'fsync' | 'fsyncdir' | 'readdir' | 'truncate' | 'ftruncate' + | 'utimens' | 'readlink' | 'chown' | 'chmod' | 'mknod' | 'setxattr' + | 'getxattr' | 'listxattr' | 'removexattr' | 'open' | 'opendir' + | 'read' | 'write' | 'release' | 'releasedir' | 'create' | 'unlink' + | 'rename' | 'link' | 'symlink' | 'mkdir' | 'rmdir' | 'destroy' + | 'lock' | 'bmap' | 'ioctl' | 'poll' | 'writeBuffer' | 'readBuffer' + | 'flock' | 'fallocate' | 'copyFileRange' | 'lseek'; + + export type Timeouts = { + default?: number | false; + } & Partial>; + + // See https://github.com/refinio/fuse-native + export interface OPTIONS { + uid?: number; + gid?: number; + timeout?: number | false | Timeouts; + displayFolder?: boolean; + debug?: boolean; + force?: boolean; + mkdir?: boolean; + allowOther?: boolean; + /** Native libfuse spelling for allowOther. */ + allow_other?: boolean; + allowRoot?: boolean; + /** Native libfuse spelling for allowRoot. */ + allow_root?: boolean; + autoUnmount?: boolean; + /** Native libfuse spelling for autoUnmount. */ + auto_unmount?: boolean; + defaultPermissions?: boolean; + /** Native libfuse spelling for defaultPermissions. */ + default_permissions?: boolean; + blkdev?: boolean; + blksize?: number; + /** Unsigned 32-bit maximum passed to both libfuse max_read inputs. */ + maxRead?: number; + /** Native libfuse spelling for maxRead. */ + max_read?: number; + /** @deprecated Removed by FUSE 3. Only an explicit false value is accepted. */ + nonEmpty?: false; + /** @deprecated Historical spelling of the removed FUSE 2 option. */ + nonempty?: false; + /** @deprecated Managed internally by libfuse3 and rejected at runtime. */ + fd?: never; + /** @deprecated Managed internally by fusermount3; use uid when appropriate. */ + userId?: never; + /** @deprecated Native spelling of the internal userId option. */ + user_id?: never; + fsname?: string; + subtype?: string; + kernelCache?: boolean; + /** Native libfuse spelling for kernelCache. */ + kernel_cache?: boolean; + autoCache?: boolean; + /** Native libfuse spelling for autoCache. */ + auto_cache?: boolean; + /** Enable direct I/O for every opened file through fuse_config. */ + directIo?: boolean; + /** Historical native spelling for directIo. */ + direct_io?: boolean; + umask?: number; + entryTimeout?: number; + /** Native libfuse spelling for entryTimeout. */ + entry_timeout?: number; + attrTimeout?: number; + /** Native libfuse spelling for attrTimeout. */ + attr_timeout?: number; + acAttrTimeout?: number; + /** Native libfuse spelling for acAttrTimeout. */ + ac_attr_timeout?: number; + /** Seconds the kernel caches a failed lookup. 0 turns it off. */ + negativeTimeout?: number; + /** Native libfuse spelling for negativeTimeout. */ + negative_timeout?: number; + /** Trust the inode numbers returned by getattr, so hard links match. */ + useIno?: boolean; + /** Native libfuse spelling for useIno. */ + use_ino?: boolean; + noforget?: boolean; + remember?: number; + modules?: string; + name?: string; + onError?: (error: unknown, operation: string, args: readonly unknown[]) => void; + /** Maximum number of simultaneous native FUSE requests. Range: 1..64. */ + maxConcurrency?: number; + /** Permit null paths for supported handle-based operations after unlink. */ + nullPathOk?: boolean; + /** Avoid path reconstruction for supported handle-based operations. */ + noPath?: boolean; + /** Historical native spelling for noPath. */ + nopath?: boolean; + } +} + +declare class Fuse { + constructor(mnt: string, ops?: Fuse.OPERATIONS, opts?: Fuse.OPTIONS); + + /** Validate and normalize-check an options object without mounting. */ + static validateOptions(opts?: Fuse.OPTIONS): void; + + /** Verify the complete host FUSE runtime before attempting a mount. */ + static checkEnvironment(opts?: Fuse.OPTIONS): Promise; + static checkEnvironment( + opts: Fuse.OPTIONS, + cb: (err: Error | null, report?: Fuse.EnvironmentReport) => void + ): void; + static checkEnvironment( + cb: (err: Error | null, report?: Fuse.EnvironmentReport) => void + ): void; + + static unmount(mnt: string, cb?: (err: null | Error) => void): void; + + // Error codes - numeric value retrieved from Fuse instance with errno(code) + static EPERM: number; + static ENOENT: number; + static ESRCH: number; + static EINTR: number; + static EIO: number; + static ENXIO: number; + static E2BIG: number; + static ENOEXEC: number; + static EBADF: number; + static ECHILD: number; + static EAGAIN: number; + static ENOMEM: number; + static EACCES: number; + static EFAULT: number; + static ENOTBLK: number; + static EBUSY: number; + static EEXIST: number; + static EXDEV: number; + static ENODEV: number; + static ENOTDIR: number; + static EISDIR: number; + static EINVAL: number; + static ENFILE: number; + static EMFILE: number; + static ENOTTY: number; + static ETXTBSY: number; + static EFBIG: number; + static ENOSPC: number; + static ESPIPE: number; + static EROFS: number; + static EMLINK: number; + static EPIPE: number; + static EDOM: number; + static ERANGE: number; + static EDEADLK: number; + static ENAMETOOLONG: number; + static ENOLCK: number; + static ENOSYS: number; + static ENOTEMPTY: number; + static ELOOP: number; + static EWOULDBLOCK: number; + static ENOMSG: number; + static EIDRM: number; + static ECHRNG: number; + static EL2NSYNC: number; + static EL3HLT: number; + static EL3RST: number; + static ELNRNG: number; + static EUNATCH: number; + static ENOCSI: number; + static EL2HLT: number; + static EBADE: number; + static EBADR: number; + static EXFULL: number; + static ENOANO: number; + static EBADRQC: number; + static EBADSLT: number; + static EDEADLOCK: number; + static EBFONT: number; + static ENOSTR: number; + static ENODATA: number; + static ETIME: number; + static ENOSR: number; + static ENONET: number; + static ENOPKG: number; + static EREMOTE: number; + static ENOLINK: number; + static EADV: number; + static ESRMNT: number; + static ECOMM: number; + static EPROTO: number; + static EMULTIHOP: number; + static EDOTDOT: number; + static EBADMSG: number; + static EOVERFLOW: number; + static ENOTUNIQ: number; + static EBADFD: number; + static EREMCHG: number; + static ELIBACC: number; + static ELIBBAD: number; + static ELIBSCN: number; + static ELIBMAX: number; + static ELIBEXEC: number; + static EILSEQ: number; + static ERESTART: number; + static ESTRPIPE: number; + static EUSERS: number; + static ENOTSOCK: number; + static EDESTADDRREQ: number; + static EMSGSIZE: number; + static EPROTOTYPE: number; + static ENOPROTOOPT: number; + static EPROTONOSUPPORT: number; + static ESOCKTNOSUPPORT: number; + static EOPNOTSUPP: number; + static ENOTSUP: number; + static EPFNOSUPPORT: number; + static EAFNOSUPPORT: number; + static EADDRINUSE: number; + static EADDRNOTAVAIL: number; + static ENETDOWN: number; + static ENETUNREACH: number; + static ENETRESET: number; + static ECONNABORTED: number; + static ECONNRESET: number; + static ENOBUFS: number; + static EISCONN: number; + static ENOTCONN: number; + static ESHUTDOWN: number; + static ETOOMANYREFS: number; + static ETIMEDOUT: number; + static ECONNREFUSED: number; + static EHOSTDOWN: number; + static EHOSTUNREACH: number; + static EALREADY: number; + static EINPROGRESS: number; + static ESTALE: number; + static EUCLEAN: number; + static ENOTNAM: number; + static ENAVAIL: number; + static EISNAM: number; + static EREMOTEIO: number; + static EDQUOT: number; + static ENOMEDIUM: number; + static EMEDIUMTYPE: number; + static ECANCELED: number; + static UTIME_NOW: number; + static UTIME_OMIT: number; + + public readonly opts: Readonly; + public readonly mnt: string; + public readonly ops: Readonly; + public readonly timeout: number | false | Readonly; + public readonly maxConcurrency: number; + + public mount: (cb: (err?: null | Error) => any) => void; + public unmount: (cb: (err: null | Error) => any) => void; + /** + * Invalidate one cached namespace entry without deleting the underlying file. + * Must be called outside filesystem operation callbacks. + * Returns EAGAIN through the callback when the bounded native queue is full. + */ + public invalidateEntry: (path: string, cb?: (err?: null | Error) => any) => void; + + /** + * Register an open file descriptor with the kernel for FUSE passthrough, + * returning a backing id to pass back as `{ fd, backingId }` from open or + * create. Throws an Error with a `.code` errno name on failure; callers + * are expected to fall back to ordinary read/write. + */ + public backingOpen: (fd: number) => number; + + /** Release a backing id, on the last release of the file it belongs to. */ + public backingClose: (backingId: number) => void; + + /** FUSE capability bit for passthrough, for use with `want`. */ + public static CAP_PASSTHROUGH: number; + /** Confined native-root bind; zero or a negative Linux errno. Never use path-based fallback. */ + public static bindRoot: (sourceRoot: string, targetRoot: string, relativePath: string) => Promise; + /** Lazily detach a target resolved beneath a pinned mount root. */ + public static unbindRoot: (targetRoot: string, relativePath: string) => Promise; + /** Open a directory beneath a pinned root; nonnegative descriptor or negative errno. */ + public static openRoot: (targetRoot: string, relativePath: string) => Promise; + /** Not upstream; see PATCHES.md. */ + public static removalGuard: { + start(onDetached: (path: string) => void): number; + addRoot(path: string, fd: number): number; + removeRoot(path: string): boolean; + stats(): { traps?: number; detaches?: number; roots?: number }; + }; + public static CAP_ASYNC_READ: number; + public context: () => Readonly | null; + public errno: (code?: string) => number; + + // From "nanoresource" + // See https://github.com/mafintosh/nanoresource/blob/master/index.js + public opening: boolean; + public opened: boolean; + public closing: boolean; + public closed: boolean; + public actives: number; + + public open(cb: (err?: Error) => any): void; + + public active(cb?: (err?: Error) => any): boolean; + + public inactive(): void; + public inactive(cb: (err: Error, val: any) => any, err: null, val: any): void; + public inactive(cb: (err: Error, val: any) => any, err: Error): void; + + public close(cb?: (err?: Error) => any): void; + public close(allowActive: boolean, cb: (err?: Error) => any): void; +} + +export = Fuse; diff --git a/packages/computerd/vendor/fuse-napi/index.js b/packages/computerd/vendor/fuse-napi/index.js new file mode 100644 index 000000000..3e80ffbc4 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/index.js @@ -0,0 +1,2377 @@ +const os = require('os') +const fs = require('fs') +const path = require('path') +const { execFile } = require('child_process') +const { AsyncLocalStorage } = require('async_hooks') + +const Nanoresource = require('nanoresource') +const { checkEnvironment } = require('./lib/environment') +const { wrapMacFuseLoadError } = require('./lib/macfuse') +const { loadNativeBinding } = require('./lib/native-binding') +const { validateFuse3Options } = require('./lib/fuse3-options') + +const IS_OSX = os.platform() === 'darwin' +let binding +try { + binding = loadNativeBinding(__dirname) +} catch (err) { + throw IS_OSX ? wrapMacFuseLoadError(err) : err +} +const NATIVE_RUNTIME = Object.freeze(binding.fuse_native_runtime_info()) + +const OSX_FOLDER_ICON = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericFolderIcon.icns' +const HAS_FOLDER_ICON = IS_OSX && fs.existsSync(OSX_FOLDER_ICON) +const DEFAULT_TIMEOUT = 15 * 1000 +const UNMOUNT_POLL_INTERVAL = 10 +const UNMOUNT_STABILITY_CHECKS = 3 +const UNMOUNT_HELPER_UNSAFE_ENV_PREFIXES = ['LD_', 'DYLD_'] +const DEFAULT_MAX_CONCURRENCY = 4 +const MAX_MAX_CONCURRENCY = 64 +const MAX_INT32 = 0x7fffffff +const MIN_INT32 = -0x80000000 +const UTIME_NOW = 0x3fffffff +const UTIME_OMIT = 0x3ffffffe +const OPERATION_FLAG_NULL_PATH_OK = 1 +const OPERATION_FLAG_NO_PATH = 2 +const OPERATION_FLAG_UTIME_OMIT_OK = 4 +const OPERATION_FLAG_DIRECT_IO = 8 +const OPERATION_FLAG_POLL_HANDLE = 16 +const OPERATION_FLAG_USE_INO = 32 +const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER) +const XATTR_NOT_FOUND = -(os.constants.errno.ENOATTR || os.constants.errno.ENODATA || 61) +const EMPTY_INIT_CONFIG = new Uint32Array(8) +const FILE_INFO_DIRECT_IO = 1 +const FILE_INFO_KEEP_CACHE = 2 +const FILE_INFO_NONSEEKABLE = 4 +const FILE_INFO_RESULT_FIELDS = new Set(['fd', 'directIO', 'keepCache', 'nonseekable', 'backingId']) +const requestContexts = new AsyncLocalStorage() +const activeRequestContexts = new WeakSet() +const INIT_CONFIG_FIELDS = new Map([ + ['maxWrite', { index: 1, mask: 1, minimum: 1 }], + ['maxReadahead', { index: 2, mask: 2, minimum: 0 }], + ['maxBackground', { index: 3, mask: 4, minimum: 1 }], + ['congestionThreshold', { index: 4, mask: 8, minimum: 1 }], + ['want', { index: 5, mask: 16, minimum: 0 }], + ['asyncRead', { index: 6, mask: 32, boolean: true }], + ['maxBackingStackDepth', { index: 7, mask: 64, minimum: 0 }] +]) +const ENHANCED_OPERATIONS = new Map([ + ['initWithConfig', binding.op_init], + ['readdirPaged', binding.op_readdir], + ['createWithFlags', binding.op_create], + ['utimensWithTimespec', binding.op_utimens], + ['utimensWithHandle', binding.op_utimens], + ['chownWithHandle', binding.op_chown], + ['chmodWithHandle', binding.op_chmod], + ['renameWithFlags', binding.op_rename], + ['pollWithHandle', binding.op_poll] +]) + +const OpcodesAndDefaults = new Map([ + ['init', { + op: binding.op_init, + defaults: [EMPTY_INIT_CONFIG] + }], + ['error', { + op: binding.op_error + }], + ['access', { + op: binding.op_access, + defaults: [0] + }], + ['statfs', { + op: binding.op_statfs, + defaults: [getStatfsArray()] + }], + ['fgetattr', { + op: binding.op_fgetattr, + defaults: [getStatArray()] + }], + ['getattr', { + op: binding.op_getattr, + defaults: [getStatArray()] + }], + ['flush', { + op: binding.op_flush + }], + ['fsync', { + op: binding.op_fsync + }], + ['fsyncdir', { + op: binding.op_fsyncdir + }], + ['readdir', { + op: binding.op_readdir, + defaults: [[], [], new Uint32Array(0)] + }], + ['truncate', { + op: binding.op_truncate + }], + ['ftruncate', { + op: binding.op_ftruncate + }], + ['utimens', { + op: binding.op_utimens + }], + ['readlink', { + op: binding.op_readlink, + defaults: [''] + }], + ['chown', { + op: binding.op_chown + }], + ['chmod', { + op: binding.op_chmod + }], + ['mknod', { + op: binding.op_mknod + }], + ['setxattr', { + op: binding.op_setxattr + }], + ['getxattr', { + op: binding.op_getxattr + }], + ['listxattr', { + op: binding.op_listxattr + }], + ['removexattr', { + op: binding.op_removexattr + }], + ['open', { + op: binding.op_open, + defaults: [0, 0] + }], + ['opendir', { + op: binding.op_opendir, + defaults: [0, 0] + }], + ['read', { + op: binding.op_read, + defaults: [0] + }], + ['write', { + op: binding.op_write, + defaults: [0] + }], + ['release', { + op: binding.op_release + }], + ['releasedir', { + op: binding.op_releasedir + }], + ['create', { + op: binding.op_create, + defaults: [0, 0] + }], + ['unlink', { + op: binding.op_unlink + }], + ['rename', { + op: binding.op_rename + }], + ['link', { + op: binding.op_link + }], + ['symlink', { + op: binding.op_symlink + }], + ['mkdir', { + op: binding.op_mkdir + }], + ['rmdir', { + op: binding.op_rmdir + }], + ['destroy', { + op: binding.op_destroy + }], + ['lock', { + op: binding.op_lock + }], + ['bmap', { + op: binding.op_bmap + }], + ['ioctl', { + op: binding.op_ioctl + }], + ['poll', { + op: binding.op_poll, + defaults: [0] + }], + ['writeBuffer', { + op: binding.op_write_buf, + nativeName: 'write_buf' + }], + ['readBuffer', { + op: binding.op_read_buf, + nativeName: 'read_buf', + defaults: [Buffer.alloc(0)] + }], + ['flock', { + op: binding.op_flock + }], + ['fallocate', { + op: binding.op_fallocate + }], + ['copyFileRange', { + op: binding.op_copy_file_range, + nativeName: 'copy_file_range', + defaults: [new Uint32Array(2)] + }], + ['lseek', { + op: binding.op_lseek, + defaults: [new Uint32Array(2)] + }] +]) +const KNOWN_OPERATIONS = new Set([...OpcodesAndDefaults.keys(), ...ENHANCED_OPERATIONS.keys()]) +const OPTION_ALIASES = new Map([ + ['allow_other', 'allowOther'], + ['allow_root', 'allowRoot'], + ['auto_unmount', 'autoUnmount'], + ['default_permissions', 'defaultPermissions'], + ['max_read', 'maxRead'], + ['user_id', 'userId'], + ['kernel_cache', 'kernelCache'], + ['auto_cache', 'autoCache'], + ['entry_timeout', 'entryTimeout'], + ['attr_timeout', 'attrTimeout'], + ['negative_timeout', 'negativeTimeout'], + ['use_ino', 'useIno'], + ['ac_attr_timeout', 'acAttrTimeout'], + ['nonempty', 'nonEmpty'], + ['direct_io', 'directIo'], + ['nopath', 'noPath'] +]) +const KNOWN_OPTIONS = new Set([ + 'uid', 'gid', 'timeout', 'displayFolder', 'debug', 'force', 'mkdir', + 'allowOther', 'allowRoot', 'autoUnmount', 'defaultPermissions', 'blkdev', + 'blksize', 'maxRead', 'nonEmpty', 'fd', 'userId', 'fsname', 'subtype', + 'kernelCache', 'autoCache', 'umask', 'entryTimeout', 'attrTimeout', + 'acAttrTimeout', 'noforget', 'remember', 'modules', 'name', 'onError', + 'maxConcurrency', 'nullPathOk', 'noPath', 'directIo', 'useIno', + 'negativeTimeout' +]) + +const pollHandleFinalizer = new FinalizationRegistry(state => { + if (state.closed) return + state.closed = true + try { + binding.fuse_native_close_poll(state.thread, state.id) + } catch {} +}) + +class PollHandle { + constructor (thread, id) { + this._state = { thread, id, closed: false } + pollHandleFinalizer.register(this, this._state, this) + } + + get closed () { + return this._state.closed + } + + notify () { + if (this._state.closed) return false + const notified = binding.fuse_native_notify_poll(this._state.thread, this._state.id) + if (!notified) this.close() + return notified + } + + close () { + if (this._state.closed) return false + this._state.closed = true + pollHandleFinalizer.unregister(this) + return binding.fuse_native_close_poll(this._state.thread, this._state.id) + } +} + +class Fuse extends Nanoresource { + static validateOptions (opts = {}) { + if (!opts || typeof opts !== 'object' || Array.isArray(opts)) { + throw new TypeError('Options must be an object') + } + normalizeAndValidateOptions(opts) + } + + static checkEnvironment (opts = {}, cb) { + if (typeof opts === 'function') { + cb = opts + opts = {} + } + let promise + try { + if (!opts || typeof opts !== 'object' || Array.isArray(opts)) { + throw new TypeError('Environment options must be an object') + } + const normalized = normalizeAndValidateOptions(opts) + promise = checkEnvironment(normalized, { nativeRuntime: NATIVE_RUNTIME }) + } catch (error) { + promise = Promise.reject(error) + } + + if (typeof cb !== 'function') return promise + promise.then( + report => process.nextTick(cb, null, report), + err => process.nextTick(cb, err) + ) + } + + constructor (mnt, ops = {}, opts = {}) { + super() + + if (typeof mnt !== 'string' || mnt.length === 0 || mnt.includes('\0')) { + throw new TypeError('Mountpoint must be a non-empty, NUL-free string') + } + if (!ops || typeof ops !== 'object' || Array.isArray(ops)) { + throw new TypeError('Operations must be an object') + } + if (!opts || typeof opts !== 'object' || Array.isArray(opts)) { + throw new TypeError('Options must be an object') + } + opts = normalizeAndValidateOptions(opts) + validateOperations(ops) + if (ops.error !== undefined) { + throw new TypeError('Operation "error" is not a supported FUSE operation') + } + + for (const [name] of OpcodesAndDefaults) { + if (ops[name] !== undefined && typeof ops[name] !== 'function') { + throw new TypeError(`Operation ${JSON.stringify(name)} must be a function`) + } + } + for (const [name] of ENHANCED_OPERATIONS) { + if (ops[name] !== undefined && typeof ops[name] !== 'function') { + throw new TypeError(`Operation ${JSON.stringify(name)} must be a function`) + } + } + for (const [legacy, enhanced] of [ + ['init', 'initWithConfig'], + ['readdir', 'readdirPaged'], + ['create', 'createWithFlags'], + ['chown', 'chownWithHandle'], + ['chmod', 'chmodWithHandle'], + ['rename', 'renameWithFlags'], + ['poll', 'pollWithHandle'], + ['read', 'readBuffer'], + ['write', 'writeBuffer'] + ]) { + if (ops[legacy] && ops[enhanced]) { + throw new TypeError(`Operations ${JSON.stringify(legacy)} and ${JSON.stringify(enhanced)} are mutually exclusive`) + } + } + const utimensVariants = ['utimens', 'utimensWithTimespec', 'utimensWithHandle'] + .filter(name => ops[name] !== undefined) + if (utimensVariants.length > 1) { + throw new TypeError(`Operations ${utimensVariants.map(JSON.stringify).join(', ')} are mutually exclusive`) + } + if (opts.nullPathOk || opts.noPath) { + const nullPathOption = opts.nullPathOk ? 'nullPathOk' : 'noPath' + for (const [pathOnly, handleAware] of [ + ['getattr', 'fgetattr'], + ['truncate', 'ftruncate'], + ['chown', 'chownWithHandle'], + ['chmod', 'chmodWithHandle'], + ['utimens', 'utimensWithHandle'], + ['utimensWithTimespec', 'utimensWithHandle'] + ]) { + if (ops[pathOnly] && !ops[handleAware]) { + throw new TypeError( + `Option ${JSON.stringify(nullPathOption)} requires operation ${JSON.stringify(handleAware)} ` + + `when ${JSON.stringify(pathOnly)} is implemented` + ) + } + } + } + + const timeout = normalizeTimeoutOption(opts.timeout) + this.opts = Object.freeze({ + ...opts, + timeout: timeout && typeof timeout === 'object' + ? Object.freeze({ ...timeout }) + : timeout + }) + this.mnt = path.resolve(mnt) + this.ops = Object.freeze({ ...ops }) + this.timeout = this.opts.timeout + this.maxConcurrency = this.opts.maxConcurrency === undefined + ? DEFAULT_MAX_CONCURRENCY + : boundedInteger('maxConcurrency', this.opts.maxConcurrency, 1, MAX_MAX_CONCURRENCY) + this._operationFlags = + (this.opts.nullPathOk ? OPERATION_FLAG_NULL_PATH_OK : 0) | + (this.opts.noPath ? OPERATION_FLAG_NO_PATH : 0) | + ((this.ops.utimensWithTimespec || this.ops.utimensWithHandle) + ? OPERATION_FLAG_UTIME_OMIT_OK + : 0) | + (this.opts.directIo ? OPERATION_FLAG_DIRECT_IO : 0) | + (this.opts.useIno ? OPERATION_FLAG_USE_INO : 0) | + (this.ops.pollWithHandle ? OPERATION_FLAG_POLL_HANDLE : 0) + + this._force = !!this.opts.force + this._mkdir = !!this.opts.mkdir + this._thread = null + this._mountpointDev = null + this._nativeMounted = false + this._nativeMountPending = false + this._startupTimer = null + this._tearingDown = false + this._teardownCallbacks = [] + this._pendingSignals = new Set() + this._handlers = this._makeHandlerArray() + + const implemented = [binding.op_init, binding.op_getattr] + if (this.ops) { + for (const [name, { op }] of OpcodesAndDefaults) { + if (this.ops[name] && this._handlers[op]) implemented.push(op) + } + for (const [name, op] of ENHANCED_OPERATIONS) { + if (this.ops[name] && this._handlers[op]) implemented.push(op) + } + } + this._implemented = new Set(implemented) + + // Used to determine if the user-defined callback needs to be nextTick'd. + this._sync = true + } + + _getImplementedArray () { + const implemented = new Uint32Array(OpcodesAndDefaults.size) + for (const impl of this._implemented) { + implemented[impl] = 1 + } + return implemented + } + + _fuseOptions () { + const options = [] + const hasValue = name => this.opts[name] !== undefined && this.opts[name] !== null + + if ((/\*|(?:^|,)fuse-(?:bindings|napi)(?:,|$)/.test(process.env.DEBUG || '')) || this.opts.debug) options.push('debug') + if (this.opts.allowOther) options.push('allow_other') + if (this.opts.allowRoot) options.push('allow_root') + if (this.opts.autoUnmount) options.push('auto_unmount') + if (this.opts.defaultPermissions) options.push('default_permissions') + if (this.opts.blkdev) options.push('blkdev') + if (hasValue('blksize')) options.push('blksize=' + mountInteger('blksize', this.opts.blksize)) + if (hasValue('maxRead')) options.push('max_read=' + mountInteger('maxRead', this.opts.maxRead)) + if (this.opts.fsname) options.push('fsname=' + mountString('fsname', this.opts.fsname)) + if (this.opts.subtype) options.push('subtype=' + mountString('subtype', this.opts.subtype)) + if (this.opts.kernelCache) options.push('kernel_cache') + if (this.opts.autoCache) options.push('auto_cache') + if (hasValue('umask')) options.push('umask=' + mountInteger('umask', this.opts.umask)) + if (hasValue('uid')) options.push('uid=' + mountInteger('uid', this.opts.uid)) + if (hasValue('gid')) options.push('gid=' + mountInteger('gid', this.opts.gid)) + if (hasValue('entryTimeout')) options.push('entry_timeout=' + mountNumber('entryTimeout', this.opts.entryTimeout)) + if (hasValue('attrTimeout')) options.push('attr_timeout=' + mountNumber('attrTimeout', this.opts.attrTimeout)) + if (hasValue('negativeTimeout')) options.push('negative_timeout=' + mountNumber('negativeTimeout', this.opts.negativeTimeout)) + if (hasValue('acAttrTimeout')) options.push('ac_attr_timeout=' + mountNumber('acAttrTimeout', this.opts.acAttrTimeout)) + if (this.opts.noforget) options.push('noforget') + if (hasValue('remember')) options.push('remember=' + mountInteger('remember', this.opts.remember)) + if (this.opts.modules) options.push('modules=' + mountString('modules', this.opts.modules)) + + if (this.opts.displayFolder && IS_OSX) { // only works on osx + options.push('volname=' + mountString('name', path.basename(this.opts.name || this.mnt))) + if (HAS_FOLDER_ICON) options.push('volicon=' + OSX_FOLDER_ICON) + } + + return options.length ? '-o' + options.join(',') : '' + } + + _makeHandlerArray () { + const self = this + const handlers = new Array(OpcodesAndDefaults.size) + + for (const [name, { op, defaults, nativeName }] of OpcodesAndDefaults) { + const internalName = nativeName || name + const nativeSignal = binding[`fuse_native_signal_${internalName}`] + if (!nativeSignal) continue + + handlers[op] = makeHandler(name, internalName, op, defaults, nativeSignal) + } + + return handlers + + function makeHandler (name, internalName, op, defaults, nativeSignal) { + const to = operationTimeout(self.timeout, name) + + return function (nativeHandler, opCode, ...args) { + const context = extractRequestContext(args) + const sig = signal.bind(null, nativeHandler, context) + const input = [...args] + if (context) activeRequestContexts.add(context) + const boundSignal = onceSignal(sig, input, context) + const funcName = `_op_${internalName}` + if (!self[funcName] || !self._implemented.has(op)) return boundSignal(Fuse.ENOSYS, ...defaults) + try { + const result = requestContexts.run(context, () => { + return self[funcName].apply(self, [boundSignal, ...args]) + }) + if (result && typeof result.then === 'function') { + result.catch(err => failOperation(err, boundSignal, input)) + } + return result + } catch (err) { + return failOperation(err, boundSignal, input) + } + } + + function signal (nativeHandler, context, result, ...args) { + result = normalizeResult(result, name) + var arr = [nativeHandler, result, ...args] + + if (defaults) { + while (arr.length > 2 && arr[arr.length - 1] === undefined) arr.pop() + if (arr.length === 2) arr = arr.concat(defaults) + } + + return process.nextTick(() => { + try { + nativeSignal(...arr) + } finally { + if (context) activeRequestContexts.delete(context) + } + }) + } + + function onceSignal (cb, input, context) { + let called = false + const timeout = to ? setTimeout(signalOnce, to, Fuse.ETIMEDOUT) : null + signalOnce.cancel = () => failSignal(Fuse.EIO) + signalOnce.operation = name + self._pendingSignals.add(signalOnce) + return signalOnce + + function signalOnce (err, ...args) { + if (called) return + called = true + self._pendingSignals.delete(signalOnce) + if (timeout) clearTimeout(timeout) + + cb(err, ...args) + } + + function failSignal (err) { + return signalOnce(err) + } + } + + function failOperation (err, cb, input) { + self._reportOperationError(err, name, input) + if (name === 'init') { + const initError = err instanceof Error ? err : new Error(String(err)) + if (!initError.code) initError.code = 'EFUSEINIT' + self._failOpen(initError) + } + return cb(Fuse.EIO) + } + } + } + + // Static methods + + static unmount (mnt, cb) { + if (typeof cb !== 'function') cb = () => {} + if (typeof mnt !== 'string' || mnt.length === 0 || mnt.includes('\0')) { + return process.nextTick(cb, new TypeError('Mountpoint must be a non-empty, NUL-free string')) + } + + const command = IS_OSX ? 'diskutil' : 'fusermount3' + const args = IS_OSX ? ['unmount', 'force', mnt] : ['-uz', '--', mnt] + execFile(command, args, { + shell: false, + timeout: DEFAULT_TIMEOUT, + killSignal: 'SIGKILL', + env: createUnmountHelperEnvironment() + }, (helperError, stdout, stderr) => { + if (helperError) { + if (typeof stdout === 'string' && stdout.length > 0) helperError.stdout = stdout + if (typeof stderr === 'string' && stderr.length > 0) helperError.stderr = stderr + } + return waitForUnmountedMountpoint(mnt, observationError => { + if (!observationError) return cb(null) + if (!helperError) return cb(observationError) + return cb(createUnmountError(mnt, command, helperError, observationError)) + }) + }) + } + + // Debugging methods + + // Lifecycle methods + + _open (cb) { + const self = this + + if (this._force) { + return fs.stat(path.join(this.mnt, 'test'), (err, st) => { + if (isDisconnectedError(err)) { + return Fuse.unmount(this.mnt, err => { + if (err) return cb(err) + return open() + }) + } + return open() + }) + } + return open() + + function open () { + if (self._nativeMountPending) { + const err = new Error('A previous native FUSE mount cancellation is still pending') + err.code = 'EFUSEMOUNTBUSY' + return process.nextTick(cb, err) + } + self._openCallback = cb + return Fuse.checkEnvironment(self.opts, err => { + if (err) return self._completeOpen(err) + return beginOpen() + }) + + function beginOpen () { + self._thread = Buffer.alloc(binding.sizeof_fuse_thread_t) + + let opts + let implemented + try { + opts = self._fuseOptions() + implemented = self._getImplementedArray() + } catch (err) { + return self._completeOpen(err) + } + + return fs.stat(self.mnt, (err, stat) => { + if (err && err.code !== 'ENOENT') return self._completeOpen(err) + if (err) { + if (!self._mkdir) return self._completeOpen(new Error('Mountpoint does not exist')) + return fs.mkdir(self.mnt, { recursive: true }, err => { + if (err) return self._completeOpen(err) + fs.stat(self.mnt, (err, stat) => { + if (err) return self._completeOpen(err) + return onexists(stat) + }) + }) + } + if (!stat.isDirectory()) return self._completeOpen(new Error('Mountpoint is not a directory')) + return onexists(stat) + }) + + function onexists (stat) { + fs.stat(path.join(self.mnt, '..'), (parentErr, parent) => { + if (parentErr) return self._completeOpen(parentErr) + if (parent.dev !== stat.dev) return self._completeOpen(new Error('Mountpoint in use')) + self._mountpointDev = stat.dev + self._startMountTimer() + self._nativeMountPending = true + try { + binding.fuse_native_mount( + self.mnt, + opts, + self._thread, + self, + self._handlers, + implemented, + self.maxConcurrency, + self._operationFlags, + self._nativeLoopExited.bind(self), + self._nativeMountComplete.bind(self), + self.opts.maxRead !== undefined && self.opts.maxRead !== null, + self.opts.maxRead === undefined || self.opts.maxRead === null + ? 0 + : self.opts.maxRead + ) + } catch (err) { + self._nativeMountPending = false + return self._completeOpen(err) + } + }) + } + } + } + } + + _close (cb) { + this._teardown(null, cb) + } + + _nativeMountComplete (err) { + this._nativeMountPending = false + if (!this._openCallback) { + this._thread = null + return + } + if (err) return this._completeOpen(err) + this._nativeMounted = true + } + + _nativeLoopExited (nativeResult) { + if (this._tearingDown || !this._nativeMounted) return + const err = new Error(nativeResult < 0 + ? `The FUSE request loop terminated unexpectedly with native result ${nativeResult}` + : 'The FUSE request loop terminated unexpectedly') + err.code = 'EFUSELOOPEXIT' + err.nativeResult = nativeResult + + if (this._openCallback) return this._failOpen(err) + this._reportOperationError(err, 'lifecycle', []) + this.close(closeError => { + if (closeError) this._reportOperationError(closeError, 'cleanup', []) + }) + } + + // Handlers + + _op_init ( + signal, + protoMajor, + protoMinor, + asyncRead, + maxWrite, + maxReadahead, + capable, + want, + maxBackground, + congestionThreshold, + maxRead + ) { + const connection = Object.freeze({ + protoMajor, + protoMinor, + asyncRead: asyncRead !== 0, + maxWrite, + maxReadahead, + capable, + want, + maxBackground, + congestionThreshold, + maxRead + }) + const complete = (err, requested) => { + if (err) { + signal(err) + const initError = new Error(`FUSE init failed with result ${err}`) + initError.code = 'EFUSEINIT' + this._failOpen(initError) + return + } + + let config + try { + config = getInitConfigArray(connection, requested) + } catch (err) { + this._reportOperationError(err, 'init', [connection, requested]) + signal(Fuse.EIO) + const initError = err instanceof Error ? err : new Error(String(err)) + initError.code = 'EFUSEINIT' + this._failOpen(initError) + return + } + + signal(0, config) + this._waitForMount() + } + + if (this.ops.initWithConfig) return this.ops.initWithConfig(connection, complete) + if (this.ops.init) return this.ops.init(err => complete(err)) + return complete(0) + } + + _completeOpen (err) { + if (!this._openCallback) return + this._clearMountTimer() + if (err && !this._nativeMounted) this._thread = null + const cb = this._openCallback + this._openCallback = null + process.nextTick(cb, err) + } + + _startMountTimer () { + if (this._startupTimer) return + const timeout = mountTimeout(this.timeout) + if (!timeout) return + this._startupTimer = setTimeout(() => { + const err = new Error(IS_OSX + ? `Timed out waiting for macFUSE to mount ${JSON.stringify(this.mnt)}. ` + + 'Ensure macFUSE is installed and enabled in Privacy & Security, ' + + 'then restart macOS if requested. See https://macfuse.github.io/.' + : `Timed out waiting for FUSE to mount ${JSON.stringify(this.mnt)}`) + err.code = IS_OSX ? 'EMACFUSEMOUNT' : 'EFUSEMOUNTTIMEOUT' + this._failOpen(err) + }, timeout) + } + + _clearMountTimer () { + if (!this._startupTimer) return + clearTimeout(this._startupTimer) + this._startupTimer = null + } + + _failOpen (err) { + if (!this._openCallback) return + this._clearMountTimer() + if (this._nativeMountPending) { + try { + binding.fuse_native_cancel_mount(this._thread) + } catch (cancelError) { + this._reportOperationError(cancelError, 'mount-cancel', [this.mnt]) + } + return this._completeOpen(err) + } + if (!this._nativeMounted) return this._completeOpen(err) + this._teardown(err, cleanupErr => this._completeOpen(cleanupErr || err)) + } + + _teardown (primaryError, cb) { + if (typeof cb !== 'function') cb = () => {} + this._teardownCallbacks.push({ primaryError, cb }) + if (this._tearingDown) return + this._tearingDown = true + + const finish = (nativeError, unmountError) => { + this._clearMountTimer() + this._tearingDown = false + + const cleanupComplete = !nativeError || nativeError.cleanupComplete === true + if (cleanupComplete) { + this._nativeMounted = false + this._thread = null + } + if (unmountError) { + this._reportOperationError(unmountError, 'unmount', [this.mnt]) + } + if (nativeError && unmountError && !nativeError.unmountError) { + nativeError.unmountError = unmountError + } + + const callbacks = this._teardownCallbacks + this._teardownCallbacks = [] + if (nativeError && callbacks.some(entry => entry.primaryError)) { + this._reportOperationError(nativeError, 'cleanup', [this.mnt]) + } + for (const entry of callbacks) { + const err = entry.primaryError || nativeError || null + process.nextTick(entry.cb, err) + } + } + + if (!this._nativeMounted || !this._thread) return finish(null, null) + + const cancelPending = () => { + for (const signal of [...this._pendingSignals]) { + if (signal.operation !== 'destroy') signal.cancel() + } + } + cancelPending() + + /* + * The native teardown owns the complete FUSE 3 lifecycle: stop and join + * the request loop, call fuse_unmount() once, then call fuse_destroy(). + * Linux must not run a second fusermount helper before that lifecycle. + * macFUSE, however, requires a force-detach to wake its blocking receive + * before the native request threads can be joined. + */ + const beginNativeCleanup = (unmountError) => { + cancelPending() + try { + binding.fuse_native_unmount(this._thread, nativeError => { + finish(nativeError || null, unmountError || null) + }) + } catch (nativeError) { + finish(nativeError, unmountError || null) + } + } + + if (!IS_OSX) return beginNativeCleanup(null) + Fuse.unmount(this.mnt, unmountError => { + if (unmountError) unmountError.unmountFailure = true + beginNativeCleanup(unmountError || null) + }) + } + + _reportOperationError (err, operation, args) { + if (this.opts.onError) { + try { + this.opts.onError(err, operation, args) + return + } catch (onErrorErr) { + process.emitWarning(onErrorErr) + } + } + process.emitWarning(err instanceof Error ? err : new Error(String(err)), { + code: 'FUSE_OPERATION_ERROR', + detail: `Operation: ${operation}` + }) + } + + _respond (signal, operation, failureArgs, fn) { + try { + return fn() + } catch (err) { + this._reportOperationError(err, operation, []) + return signal(Fuse.EIO, ...failureArgs) + } + } + + _waitForMount () { + const self = this + if (!this._startupTimer) this._startMountTimer() + + check() + + function check () { + fs.stat(self.mnt, (err, stat) => { + if (!err && stat.dev !== self._mountpointDev) { + self._completeOpen(null) + return + } + + if (self._openCallback) setTimeout(check, 10) + }) + } + } + + _op_error (signal) { + if (!this.ops.error) { + signal(0) + return + } + return this.ops.error(err => { + return signal(err) + }) + } + + _op_statfs (signal, path) { + return this.ops.statfs(path, (err, statfs) => { + if (err) return signal(err) + return this._respond(signal, 'statfs', [], () => signal(0, getStatfsArray(statfs))) + }) + } + + _op_getattr (signal, path) { + if (!this.ops.getattr) { + if (path !== '/') { + signal(Fuse.EPERM) + } else { + signal(0, getStatArray({ mtime: new Date(0), atime: new Date(0), ctime: new Date(0), mode: 16877, size: 4096 })) + } + return + } + + return this.ops.getattr(path, (err, stat) => { + if (err) return signal(err, getStatArray()) + return this._respond(signal, 'getattr', [], () => signal(0, getStatArray(stat))) + }) + } + + _op_fgetattr (signal, path, fd) { + if (!this.ops.fgetattr) { + if (path !== '/') { + signal(Fuse.EPERM) + } else { + signal(0, getStatArray({ mtime: new Date(0), atime: new Date(0), ctime: new Date(0), mode: 16877, size: 4096 })) + } + return + } + return this.ops.fgetattr(path, fd, (err, stat) => { + if (err) return signal(err) + return this._respond(signal, 'fgetattr', [], () => signal(0, getStatArray(stat))) + }) + } + + _op_access (signal, path, mode) { + return this.ops.access(path, mode, err => { + return signal(err) + }) + } + + _op_open (signal, path, flags) { + return this.ops.open(path, flags, (err, result) => { + if (err) return signal(err) + return this._respond(signal, 'open', [], () => signal(0, ...normalizeOpenResult(result))) + }) + } + + _op_opendir (signal, path, flags) { + return this.ops.opendir(path, flags, (err, result) => { + if (err) return signal(err) + return this._respond(signal, 'opendir', [], () => signal(0, ...normalizeOpenResult(result))) + }) + } + + _op_create (signal, path, mode, flags) { + const complete = (err, result) => { + if (err) return signal(err) + return this._respond(signal, 'create', [], () => signal(0, ...normalizeOpenResult(result))) + } + if (this.ops.createWithFlags) return this.ops.createWithFlags(path, mode, flags, complete) + return this.ops.create(path, mode, complete) + } + + _op_utimens ( + signal, + path, + atimeSecondsLow, + atimeSecondsHigh, + atimeNanoseconds, + mtimeSecondsLow, + mtimeSecondsHigh, + mtimeNanoseconds, + fd + ) { + const atime = decodeTimespec(atimeSecondsLow, atimeSecondsHigh, atimeNanoseconds) + const mtime = decodeTimespec(mtimeSecondsLow, mtimeSecondsHigh, mtimeNanoseconds) + const complete = err => { + return signal(err) + } + if (this.ops.utimensWithTimespec) { + return this.ops.utimensWithTimespec(path, atime, mtime, complete) + } + if (this.ops.utimensWithHandle) { + return this.ops.utimensWithHandle(path, fd, atime, mtime, complete) + } + if (atime.nanoseconds === UTIME_NOW || atime.nanoseconds === UTIME_OMIT || + mtime.nanoseconds === UTIME_NOW || mtime.nanoseconds === UTIME_OMIT) { + return signal(Fuse.EOPNOTSUPP) + } + return this.ops.utimens( + path, + timespecToMilliseconds(atime), + timespecToMilliseconds(mtime), + complete + ) + } + + _op_release (signal, path, fd) { + return this.ops.release(path, fd, err => { + return signal(err) + }) + } + + _op_releasedir (signal, path, fd) { + return this.ops.releasedir(path, fd, err => { + return signal(err) + }) + } + + _op_read (signal, path, fd, buf, len, offsetLow, offsetHigh) { + return this.ops.read(path, fd, buf, len, getSignedDoubleArg(offsetLow, offsetHigh), result => { + return this._respond(signal, 'read', [buf], () => { + return signal(normalizeIOResult(result, len), buf) + }) + }) + } + + _op_write (signal, path, fd, buf, len, offsetLow, offsetHigh) { + return this.ops.write(path, fd, buf, len, getSignedDoubleArg(offsetLow, offsetHigh), result => { + return this._respond(signal, 'write', [], () => { + return signal(normalizeIOResult(result, len)) + }) + }) + } + + _op_readdir (signal, path, fd, offsetLow, offsetHigh) { + const paged = !!this.ops.readdirPaged + const complete = (err, names, stats, nextOffsets) => { + if (err) return signal(err) + return this._respond(signal, 'readdir', [], () => { + if (!Array.isArray(names) || !names.every(isValidDirectoryEntry)) { + throw new TypeError('readdir names must be valid NUL-free entry names of at most 255 UTF-8 bytes') + } + if (stats !== undefined && !Array.isArray(stats)) { + throw new TypeError('readdir stats must be an array') + } + if (stats && stats.length !== 0 && stats.length !== names.length) { + throw new RangeError('readdir stats must be empty or align with names') + } + if (stats) stats = stats.map(getStatArray) + const offsets = paged ? getReaddirOffsetsArray(nextOffsets, names.length) : new Uint32Array(0) + return signal(0, names, stats || [], offsets) + }) + } + if (paged) { + const offset = getSignedDoubleArg(offsetLow, offsetHigh) + return this.ops.readdirPaged(path, fd, offset, complete) + } + return this.ops.readdir(path, complete) + } + + _op_setxattr (signal, path, name, value, position, flags) { + return this.ops.setxattr(path, name, value, position, flags, err => { + return signal(err) + }) + } + + _op_getxattr (signal, path, name, valueBuf, position) { + return this.ops.getxattr(path, name, position, (err, value) => { + if (err) return signal(err) + return this._respond(signal, 'getxattr', [valueBuf], () => { + if (value === null || value === undefined) return signal(XATTR_NOT_FOUND) + if (!Buffer.isBuffer(value)) throw new TypeError('getxattr value must be a Buffer or null') + if (valueBuf.length === 0) return signal(value.length, valueBuf) + if (value.length > valueBuf.length) return signal(Fuse.ERANGE) + value.copy(valueBuf) + return signal(value.length, valueBuf) + }) + }) + } + + _op_listxattr (signal, path, listBuf) { + return this.ops.listxattr(path, (err, list) => { + if (err) return signal(err) + return this._respond(signal, 'listxattr', [listBuf], () => { + if (!Array.isArray(list) || !list.every(name => typeof name === 'string' && !name.includes('\0'))) { + throw new TypeError('listxattr result must be an array of NUL-free strings') + } + + const size = list.reduce((total, name) => total + Buffer.byteLength(name) + 1, 0) + if (listBuf.length === 0) return signal(size, listBuf) + if (size > listBuf.length) return signal(Fuse.ERANGE) + + let ptr = 0 + for (const name of list) { + ptr += listBuf.write(name, ptr, Buffer.byteLength(name), 'utf8') + listBuf[ptr++] = 0 + } + + return signal(ptr, listBuf) + }) + }) + } + + _op_removexattr (signal, path, name) { + return this.ops.removexattr(path, name, err => { + return signal(err) + }) + } + + _op_flush (signal, path, fd) { + return this.ops.flush(path, fd, err => { + return signal(err) + }) + } + + _op_fsync (signal, path, datasync, fd) { + return this.ops.fsync(path, datasync !== 0, fd, err => { + return signal(err) + }) + } + + _op_fsyncdir (signal, path, datasync, fd) { + return this.ops.fsyncdir(path, datasync !== 0, fd, err => { + return signal(err) + }) + } + + _op_truncate (signal, path, sizeLow, sizeHigh) { + const size = getSignedDoubleArg(sizeLow, sizeHigh) + return this.ops.truncate(path, size, err => { + return signal(err) + }) + } + + _op_ftruncate (signal, path, fd, sizeLow, sizeHigh) { + const size = getSignedDoubleArg(sizeLow, sizeHigh) + return this.ops.ftruncate(path, fd, size, err => { + return signal(err) + }) + } + + _op_readlink (signal, path) { + return this.ops.readlink(path, (err, linkname) => { + if (err) return signal(err) + return this._respond(signal, 'readlink', [], () => { + if (typeof linkname !== 'string' || linkname.includes('\0')) { + throw new TypeError('readlink result must be a NUL-free string') + } + return signal(0, linkname) + }) + }) + } + + _op_chown (signal, path, uid, gid, fd) { + if (this.ops.chownWithHandle) { + return this.ops.chownWithHandle(path, fd, uid, gid, err => signal(err)) + } + return this.ops.chown(path, uid, gid, err => { + return signal(err) + }) + } + + _op_chmod (signal, path, mode, fd) { + if (this.ops.chmodWithHandle) { + return this.ops.chmodWithHandle(path, fd, mode, err => signal(err)) + } + return this.ops.chmod(path, mode, err => { + return signal(err) + }) + } + + _op_mknod (signal, path, mode, dev) { + return this.ops.mknod(path, mode, dev, err => { + return signal(err) + }) + } + + _op_unlink (signal, path) { + return this.ops.unlink(path, err => { + return signal(err) + }) + } + + _op_rename (signal, src, dest, flags) { + if (this.ops.renameWithFlags) { + return this.ops.renameWithFlags(src, dest, flags, err => signal(err)) + } + if (flags !== 0) return signal(Fuse.EOPNOTSUPP) + return this.ops.rename(src, dest, err => { + return signal(err) + }) + } + + _op_link (signal, src, dest) { + return this.ops.link(src, dest, err => { + return signal(err) + }) + } + + _op_symlink (signal, src, dest) { + return this.ops.symlink(src, dest, err => { + return signal(err) + }) + } + + _op_mkdir (signal, path, mode) { + return this.ops.mkdir(path, mode, err => { + return signal(err) + }) + } + + _op_rmdir (signal, path) { + return this.ops.rmdir(path, err => { + return signal(err) + }) + } + + _op_destroy (signal) { + return this.ops.destroy(err => signal(err)) + } + + _op_lock ( + signal, + path, + fd, + command, + type, + whence, + startLow, + startHigh, + lengthLow, + lengthHigh, + pid + ) { + const lock = Object.freeze({ + type, + whence, + start: getSignedDoubleArg(startLow, startHigh), + length: getSignedDoubleArg(lengthLow, lengthHigh), + pid + }) + return this.ops.lock(path, fd, command, lock, (err, result) => { + if (err) return signal(err) + return this._respond(signal, 'lock', [], () => signal(0, encodeLock(result || lock))) + }) + } + + _op_bmap (signal, path, blockSize, index) { + return this.ops.bmap(path, blockSize, index, (err, result) => { + if (err) return signal(err) + return this._respond(signal, 'bmap', [], () => { + return signal(0, toUint64Value(result ?? index, 'bmap index')) + }) + }) + } + + _op_ioctl (signal, path, fd, command, argument, flags, data) { + return this.ops.ioctl(path, fd, command, argument, flags, data, (err, output) => { + if (err) return signal(err) + return this._respond(signal, 'ioctl', [data], () => { + if (output === undefined || output === null) output = data + if (!Buffer.isBuffer(output) || output.length !== data.length) { + throw new RangeError('ioctl output must be a Buffer with the same length as its input') + } + return signal(0, output) + }) + }) + } + + _op_poll (signal, path, fd, pollId) { + const pollHandle = this.ops.pollWithHandle && pollId + ? new PollHandle(this._thread, pollId) + : null + const complete = (err, events) => { + if (err && pollHandle) pollHandle.close() + if (err) return signal(err) + return this._respond(signal, 'poll', [], () => { + return signal(0, toUint32(events ?? 0, 'poll events')) + }) + } + if (this.ops.pollWithHandle) { + return this.ops.pollWithHandle(path, fd, pollHandle, complete) + } + return this.ops.poll(path, fd, complete) + } + + _op_write_buf (signal, path, fd, buffer, length, offsetLow, offsetHigh) { + return this.ops.writeBuffer( + path, + fd, + buffer, + length, + getSignedDoubleArg(offsetLow, offsetHigh), + result => { + return this._respond(signal, 'writeBuffer', [], () => { + return signal(normalizeIOResult(result, length)) + }) + } + ) + } + + _op_read_buf (signal, path, fd, length, offsetLow, offsetHigh) { + return this.ops.readBuffer( + path, + fd, + length, + getSignedDoubleArg(offsetLow, offsetHigh), + (err, buffer) => { + if (err) return signal(err) + return this._respond(signal, 'readBuffer', [Buffer.alloc(0)], () => { + if (!Buffer.isBuffer(buffer) || buffer.length > length) { + throw new RangeError('readBuffer must return a Buffer no larger than the requested length') + } + return signal(0, buffer) + }) + } + ) + } + + _op_flock (signal, path, fd, operation) { + return this.ops.flock(path, fd, operation, err => signal(err)) + } + + _op_fallocate ( + signal, + path, + fd, + mode, + offsetLow, + offsetHigh, + lengthLow, + lengthHigh + ) { + return this.ops.fallocate( + path, + fd, + mode, + getSignedDoubleArg(offsetLow, offsetHigh), + getSignedDoubleArg(lengthLow, lengthHigh), + err => signal(err) + ) + } + + _op_copy_file_range ( + signal, + pathIn, + fdIn, + offsetInLow, + offsetInHigh, + pathOut, + fdOut, + offsetOutLow, + offsetOutHigh, + length, + flags + ) { + return this.ops.copyFileRange( + pathIn, + fdIn, + getSignedDoubleArg(offsetInLow, offsetInHigh), + pathOut, + fdOut, + getSignedDoubleArg(offsetOutLow, offsetOutHigh), + length, + flags, + result => { + return this._respond(signal, 'copyFileRange', [], () => { + if (typeof result === 'number' && result < 0) { + return signal(normalizeResult(result, 'copyFileRange'), new Uint32Array(2)) + } + const copied = toInt64(result, 'copyFileRange result') + const requested = toUint64(length, 'copyFileRange length') + if (copied < 0n || BigInt.asUintN(64, copied) > requested) { + throw new RangeError('copyFileRange result must be between zero and the requested length') + } + return signal(0, encodeInt64(copied, 'copyFileRange result')) + }) + } + ) + } + + _op_lseek (signal, path, fd, offsetLow, offsetHigh, whence) { + return this.ops.lseek( + path, + fd, + getSignedDoubleArg(offsetLow, offsetHigh), + whence, + (err, offset) => { + if (err) return signal(err, new Uint32Array(2)) + return this._respond(signal, 'lseek', [], () => { + const result = toInt64(offset, 'lseek result') + if (result < 0n) throw new RangeError('lseek result must not be negative') + return signal(0, encodeInt64(result, 'lseek result')) + }) + } + ) + } + + // Public API + + mount (cb) { + return this.open(cb) + } + + unmount (cb) { + return this.close(cb) + } + + /** + * Register an open file descriptor with the kernel for FUSE passthrough. + * Returns a backing id (> 0) to hand back from open/create as + * `{ fd, backingId }`, which takes this process out of the data path for + * that handle. + * + * Throws with a .code of the errno name on failure. Callers are expected + * to fall back to ordinary read/write rather than treat it as fatal. + */ + backingOpen (fd) { + if (!Number.isInteger(fd) || fd < 0) { + throw new TypeError('backingOpen requires a file descriptor') + } + const result = binding.fuse_native_backing_open(this._thread, fd) + if (result > 0) return result + const err = new Error('Failed to register FUSE backing file') + err.errno = result + err.code = errnoName(-result) + throw err + } + + /** Release a backing id, on the last release of the file it belongs to. */ + backingClose (backingId) { + if (!Number.isInteger(backingId) || backingId <= 0) { + throw new TypeError('backingClose requires a backing id') + } + const result = binding.fuse_native_backing_close(this._thread, backingId) + if (result === 0) return + const err = new Error('Failed to release FUSE backing file') + err.errno = result + err.code = errnoName(-result) + throw err + } + + invalidateEntry (entryPath, cb) { + if (typeof cb !== 'function') cb = () => {} + + if (activeRequestContexts.has(requestContexts.getStore())) { + const err = new Error( + 'FUSE entry invalidation must run outside a filesystem operation callback' + ) + err.code = 'EDEADLK' + return process.nextTick(cb, err) + } + + let normalized + try { + normalized = normalizeInvalidationPath(entryPath) + } catch (err) { + return process.nextTick(cb, err) + } + + if (!this._nativeMounted || !this._thread) { + const err = new Error('FUSE filesystem is not mounted') + err.code = 'ENOTCONN' + return process.nextTick(cb, err) + } + + const thread = this._thread + const name = path.posix.basename(normalized) + const parentPath = path.posix.dirname(normalized) + const notify = parent => { + let called = false + let synchronous = true + const complete = result => { + if (called) return + called = true + if (result === 0 || result === Fuse.ENOENT) return cb(null) + const err = new Error( + `Failed to invalidate FUSE entry ${JSON.stringify(normalized)}: native result ${result}` + ) + err.code = invalidationErrorCode(result) + err.errno = result + cb(err) + } + const completed = result => { + if (synchronous) return process.nextTick(complete, result) + complete(result) + } + try { + binding.fuse_native_invalidate_entry(thread, parent, name, completed) + } catch (err) { + called = true + return process.nextTick(cb, err) + } finally { + synchronous = false + } + } + + if (parentPath === '/') return notify(1) + const mountedParent = path.join(this.mnt, parentPath.slice(1)) + fs.stat(mountedParent, { bigint: true }, (err, stat) => { + if (err) return cb(err) + notify(stat.ino) + }) + } + + context () { + return requestContexts.getStore() || null + } + + errno (code) { + return (code && Fuse[code.toUpperCase()]) || -1 + } +} + +Fuse.EPERM = -1 +Fuse.ENOENT = -2 +Fuse.ESRCH = -3 +Fuse.EINTR = -4 +Fuse.EIO = -5 +Fuse.ENXIO = -6 +Fuse.E2BIG = -7 +Fuse.ENOEXEC = -8 +Fuse.EBADF = -9 +Fuse.ECHILD = -10 +Fuse.EAGAIN = -11 +Fuse.ENOMEM = -12 +Fuse.EACCES = -13 +Fuse.EFAULT = -14 +Fuse.ENOTBLK = -15 +Fuse.EBUSY = -16 +Fuse.EEXIST = -17 +Fuse.EXDEV = -18 +Fuse.ENODEV = -19 +Fuse.ENOTDIR = -20 +Fuse.EISDIR = -21 +Fuse.EINVAL = -22 +Fuse.ENFILE = -23 +Fuse.EMFILE = -24 +Fuse.ENOTTY = -25 +Fuse.ETXTBSY = -26 +Fuse.EFBIG = -27 +Fuse.ENOSPC = -28 +Fuse.ESPIPE = -29 +Fuse.EROFS = -30 +Fuse.EMLINK = -31 +Fuse.EPIPE = -32 +Fuse.EDOM = -33 +Fuse.ERANGE = -34 +Fuse.EDEADLK = -35 +Fuse.ENAMETOOLONG = -36 +Fuse.ENOLCK = -37 +Fuse.ENOSYS = -38 +Fuse.ENOTEMPTY = -39 +Fuse.ELOOP = -40 +Fuse.EWOULDBLOCK = -11 +Fuse.ENOMSG = -42 +Fuse.EIDRM = -43 +Fuse.ECHRNG = -44 +Fuse.EL2NSYNC = -45 +Fuse.EL3HLT = -46 +Fuse.EL3RST = -47 +Fuse.ELNRNG = -48 +Fuse.EUNATCH = -49 +Fuse.ENOCSI = -50 +Fuse.EL2HLT = -51 +Fuse.EBADE = -52 +Fuse.EBADR = -53 +Fuse.EXFULL = -54 +Fuse.ENOANO = -55 +Fuse.EBADRQC = -56 +Fuse.EBADSLT = -57 +Fuse.EDEADLOCK = -35 +Fuse.EBFONT = -59 +Fuse.ENOSTR = -60 +Fuse.ENODATA = -61 +Fuse.ETIME = -62 +Fuse.ENOSR = -63 +Fuse.ENONET = -64 +Fuse.ENOPKG = -65 +Fuse.EREMOTE = -66 +Fuse.ENOLINK = -67 +Fuse.EADV = -68 +Fuse.ESRMNT = -69 +Fuse.ECOMM = -70 +Fuse.EPROTO = -71 +Fuse.EMULTIHOP = -72 +Fuse.EDOTDOT = -73 +Fuse.EBADMSG = -74 +Fuse.EOVERFLOW = -75 +Fuse.ENOTUNIQ = -76 +Fuse.EBADFD = -77 +Fuse.EREMCHG = -78 +Fuse.ELIBACC = -79 +Fuse.ELIBBAD = -80 +Fuse.ELIBSCN = -81 +Fuse.ELIBMAX = -82 +Fuse.ELIBEXEC = -83 +Fuse.EILSEQ = -84 +Fuse.ERESTART = -85 +Fuse.ESTRPIPE = -86 +Fuse.EUSERS = -87 +Fuse.ENOTSOCK = -88 +Fuse.EDESTADDRREQ = -89 +Fuse.EMSGSIZE = -90 +Fuse.EPROTOTYPE = -91 +Fuse.ENOPROTOOPT = -92 +/* + * FUSE capability bits, as exposed to init(). Only the ones computerd + * negotiates are listed; the full set lives in fuse_common.h. + */ +Fuse.CAP_PASSTHROUGH = 1 << 29 +Fuse.CAP_ASYNC_READ = 1 << 0 + +Fuse.EPROTONOSUPPORT = -93 +Fuse.ESOCKTNOSUPPORT = -94 +Fuse.EOPNOTSUPP = -95 +Fuse.ENOTSUP = -95 +Fuse.EPFNOSUPPORT = -96 +Fuse.EAFNOSUPPORT = -97 +Fuse.EADDRINUSE = -98 +Fuse.EADDRNOTAVAIL = -99 +Fuse.ENETDOWN = -100 +Fuse.ENETUNREACH = -101 +Fuse.ENETRESET = -102 +Fuse.ECONNABORTED = -103 +Fuse.ECONNRESET = -104 +Fuse.ENOBUFS = -105 +Fuse.EISCONN = -106 +Fuse.ENOTCONN = -107 +Fuse.ESHUTDOWN = -108 +Fuse.ETOOMANYREFS = -109 +Fuse.ETIMEDOUT = -110 +Fuse.ECONNREFUSED = -111 +Fuse.EHOSTDOWN = -112 +Fuse.EHOSTUNREACH = -113 +Fuse.EALREADY = -114 +Fuse.EINPROGRESS = -115 +Fuse.ESTALE = -116 +Fuse.EUCLEAN = -117 +Fuse.ENOTNAM = -118 +Fuse.ENAVAIL = -119 +Fuse.EISNAM = -120 +Fuse.EREMOTEIO = -121 +Fuse.EDQUOT = -122 +Fuse.ENOMEDIUM = -123 +Fuse.EMEDIUMTYPE = -124 +Fuse.UTIME_NOW = UTIME_NOW +Fuse.UTIME_OMIT = UTIME_OMIT + +for (const [name, value] of Object.entries(os.constants.errno)) { + Fuse[name] = -value +} + +// Not upstream; see PATCHES.md. Pauses rmdir/rename of registered paths +// for this process's threads and children long enough to lazily detach the +// mount on them. Each call returns 0 or a negative errno. +// The native worker resolves source and target beneath pinned directories. +// Never fall back to the path-based mount command if this binding is absent. +Fuse.bindRoot = (sourceRoot, targetRoot, relativePath) => + binding.fuse_native_bind_root(sourceRoot, targetRoot, relativePath) +Fuse.unbindRoot = (targetRoot, relativePath) => + binding.fuse_native_unbind_root(targetRoot, relativePath) +Fuse.openRoot = (targetRoot, relativePath) => + binding.fuse_native_open_root(targetRoot, relativePath) + +Fuse.removalGuard = Object.freeze({ + start (onDetached) { + if (typeof onDetached !== 'function') throw new TypeError('removalGuard.start requires a callback') + return binding.fuse_native_guard_start(onDetached) + }, + addRoot (path, fd) { + return binding.fuse_native_guard_add_root(path, fd) + }, + removeRoot (path) { + return binding.fuse_native_guard_remove_root(path) === 1 + }, + stats () { + return binding.fuse_native_guard_stats() + } +}) + +module.exports = Fuse + +function mountTimeout (timeout) { + if (typeof timeout !== 'object' || !timeout) return timeout + return operationTimeout(timeout, 'init') +} + +function operationTimeout (timeout, operation) { + if (typeof timeout !== 'object' || !timeout) return timeout + const hasOperation = Object.prototype.hasOwnProperty.call(timeout, operation) + const hasDefault = Object.prototype.hasOwnProperty.call(timeout, 'default') + const value = hasOperation ? timeout[operation] : (hasDefault ? timeout.default : DEFAULT_TIMEOUT) + return value === false ? 0 : value +} + +function isValidDirectoryEntry (name) { + return typeof name === 'string' && + name.length > 0 && + !name.includes('\0') && + !name.includes('/') && + Buffer.byteLength(name) <= 255 +} + +function getStatfsArray (statfs) { + const ints = new Uint32Array(22) + const values = [ + statfs && statfs.bsize, + statfs && statfs.frsize, + statfs && statfs.blocks, + statfs && statfs.bfree, + statfs && statfs.bavail, + statfs && statfs.files, + statfs && statfs.ffree, + statfs && statfs.favail, + statfs && statfs.fsid, + statfs && statfs.flag, + statfs && statfs.namemax + ] + const names = [ + 'bsize', 'frsize', 'blocks', 'bfree', 'bavail', 'files', + 'ffree', 'favail', 'fsid', 'flag', 'namemax' + ] + for (let i = 0; i < values.length; i++) setUint64(ints, i * 2, values[i] ?? 0, `statfs.${names[i]}`) + + return ints +} + +function setUint64 (arr, idx, value, name) { + const num = toUint64(value, name) + arr[idx] = Number(num & 0xffffffffn) + arr[idx + 1] = Number(num >> 32n) +} + +function setInt64 (arr, idx, value, name) { + const num = toInt64(value, name) + const bits = BigInt.asUintN(64, num) + arr[idx] = Number(bits & 0xffffffffn) + arr[idx + 1] = Number(bits >> 32n) +} + +function getSignedDoubleArg (low, high) { + const value = BigInt.asIntN(64, (BigInt(high) << 32n) | BigInt(low)) + if (value >= -MAX_SAFE_BIGINT && value <= MAX_SAFE_BIGINT) return Number(value) + return value +} + +function extractRequestContext (args) { + const encoded = args[args.length - 1] + if (!(encoded instanceof Uint32Array) || encoded.length !== 11) return null + args.pop() + const fileInfoFlags = encoded[6] + const fileInfo = encoded[4] === 0 + ? null + : Object.freeze({ + flags: encoded[5] | 0, + writepage: (fileInfoFlags & 1) !== 0, + directIO: (fileInfoFlags & 2) !== 0, + keepCache: (fileInfoFlags & 4) !== 0, + flush: (fileInfoFlags & 8) !== 0, + nonseekable: (fileInfoFlags & 16) !== 0, + flockRelease: (fileInfoFlags & 32) !== 0, + fd: getUnsignedDoubleArg(encoded[7], encoded[8]), + lockOwner: getUnsignedDoubleArg(encoded[9], encoded[10]) + }) + return Object.freeze({ + uid: encoded[0], + gid: encoded[1], + pid: encoded[2], + umask: encoded[3], + fileInfo + }) +} + +function getUnsignedDoubleArg (low, high) { + const value = (BigInt(high) << 32n) | BigInt(low) + return value <= MAX_SAFE_BIGINT ? Number(value) : value +} + +function decodeTimespec (secondsLow, secondsHigh, nanoseconds) { + if (!Number.isInteger(nanoseconds) || + (nanoseconds > 999999999 && nanoseconds !== UTIME_NOW && nanoseconds !== UTIME_OMIT) || + nanoseconds < 0) { + throw new RangeError('Native FUSE timespec nanoseconds are outside the valid range') + } + return Object.freeze({ + seconds: getSignedDoubleArg(secondsLow, secondsHigh), + nanoseconds + }) +} + +function timespecToMilliseconds (timespec) { + if (timespec.nanoseconds === UTIME_NOW || timespec.nanoseconds === UTIME_OMIT) { + throw new RangeError( + 'UTIME_NOW and UTIME_OMIT require utimensWithTimespec or utimensWithHandle' + ) + } + const seconds = typeof timespec.seconds === 'bigint' + ? timespec.seconds + : BigInt(timespec.seconds) + const milliseconds = (seconds * 1000n) + BigInt(Math.trunc(timespec.nanoseconds / 1000000)) + return milliseconds >= -MAX_SAFE_BIGINT && milliseconds <= MAX_SAFE_BIGINT + ? Number(milliseconds) + : milliseconds +} + +function normalizeTimespec (value, name) { + if (value === undefined || value === null) { + return { seconds: 0n, nanoseconds: 0 } + } + + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) throw new TypeError(`${name} must be a valid Date`) + value = value.getTime() + } + + if (typeof value === 'number' || typeof value === 'bigint') { + const milliseconds = toInt64(value, `${name} milliseconds`) + let seconds = milliseconds / 1000n + let remainder = milliseconds % 1000n + if (remainder < 0n) { + seconds-- + remainder += 1000n + } + return { + seconds, + nanoseconds: Number(remainder) * 1000000 + } + } + + if (typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${name} must be a Date, integer milliseconds, or a timespec object`) + } + for (const field of Reflect.ownKeys(value)) { + if (field !== 'seconds' && field !== 'nanoseconds') { + throw new TypeError(`Unknown ${name} property ${String(field)}`) + } + } + const seconds = toInt64(value.seconds ?? 0, `${name}.seconds`) + const nanoseconds = value.nanoseconds ?? 0 + if (!Number.isInteger(nanoseconds) || nanoseconds < 0 || nanoseconds > 999999999) { + throw new RangeError(`${name}.nanoseconds must be an integer from 0 through 999999999`) + } + return { seconds, nanoseconds } +} + +function setTimespec (arr, idx, value, name) { + const timespec = normalizeTimespec(value, name) + setInt64(arr, idx, timespec.seconds, `${name}.seconds`) + arr[idx + 2] = timespec.nanoseconds +} + +function getStatArray (stat) { + const ints = new Uint32Array(26) + + ints[0] = toUint32(stat && stat.mode, 'stat.mode') + ints[1] = toUint32(stat && stat.uid, 'stat.uid') + ints[2] = toUint32(stat && stat.gid, 'stat.gid') + setNonNegativeInt64(ints, 3, (stat && stat.size) ?? 0, 'stat.size') + setUint64(ints, 5, (stat && stat.dev) ?? 0, 'stat.dev') + setUint64(ints, 7, (stat && stat.nlink) ?? 1, 'stat.nlink') + setUint64(ints, 9, (stat && stat.ino) ?? 0, 'stat.ino') + setUint64(ints, 11, (stat && stat.rdev) ?? 0, 'stat.rdev') + setUint64(ints, 13, (stat && stat.blksize) ?? 0, 'stat.blksize') + setUint64(ints, 15, (stat && stat.blocks) ?? 0, 'stat.blocks') + setTimespec(ints, 17, stat && stat.atime, 'stat.atime') + setTimespec(ints, 20, stat && stat.mtime, 'stat.mtime') + setTimespec(ints, 23, stat && stat.ctime, 'stat.ctime') + + return ints +} + +function waitForUnmountedMountpoint (mnt, cb) { + const deadline = Date.now() + DEFAULT_TIMEOUT + const mountpoint = path.resolve(mnt) + const parent = path.dirname(mountpoint) + let lastError = null + let stableChecks = 0 + + check() + + function check () { + fs.stat(mountpoint, (mountErr, mountStat) => { + if (mountErr) { + if (mountErr.code === 'ENOENT') return cb(null) + stableChecks = 0 + if (isDisconnectedError(mountErr)) return retry(mountErr) + return cb(mountErr) + } + + fs.stat(parent, (parentErr, parentStat) => { + if (parentErr) return cb(parentErr) + if (mountStat.dev === parentStat.dev) { + stableChecks++ + if (stableChecks >= UNMOUNT_STABILITY_CHECKS) return cb(null) + return retry() + } + stableChecks = 0 + return retry() + }) + }) + } + + function retry (cause) { + if (cause) lastError = cause + if (Date.now() < deadline) { + return setTimeout(check, UNMOUNT_POLL_INTERVAL) + } + + const err = new Error(`Timed out waiting for FUSE mount ${JSON.stringify(mountpoint)} to detach`) + err.code = 'EFUSEUNMOUNTWAIT' + if (lastError) err.cause = lastError + return cb(err) + } +} + +function createUnmountHelperEnvironment () { + const env = {} + for (const [name, value] of Object.entries(process.env)) { + if (UNMOUNT_HELPER_UNSAFE_ENV_PREFIXES.some(prefix => name.startsWith(prefix))) continue + env[name] = value + } + return env +} + +function createUnmountError (mnt, command, helperError, observationError) { + const mountpoint = path.resolve(mnt) + const helperDetail = formatHelperError(helperError) + const err = new Error( + `${command} failed and FUSE mount ${JSON.stringify(mountpoint)} did not detach` + + (helperDetail ? `: ${helperDetail}` : '') + ) + err.code = 'EFUSEUNMOUNT' + err.mountpoint = mountpoint + err.command = command + err.cause = helperError + err.helperError = helperError + err.observationError = observationError + return err +} + +function formatHelperError (err) { + if (!err) return '' + const detail = typeof err.stderr === 'string' && err.stderr.trim() + ? err.stderr.trim() + : typeof err.message === 'string' + ? err.message.trim() + : '' + return detail.replace(/\s+/g, ' ').slice(0, 512) +} + +function isDisconnectedError (err) { + return !!err && ( + err.code === 'ENOTCONN' || + err.code === 'ENXIO' || + err.errno === Fuse.ENOTCONN || + err.errno === Fuse.ENXIO + ) +} + +function normalizeInvalidationPath (entryPath) { + if (typeof entryPath !== 'string' || entryPath.length === 0 || entryPath.includes('\0')) { + throw new TypeError('FUSE entry path must be a non-empty NUL-free string') + } + if (!entryPath.startsWith('/')) { + throw new TypeError('FUSE entry path must be absolute within the mounted filesystem') + } + const normalized = path.posix.normalize(entryPath) + if (normalized === '/') throw new RangeError('The FUSE root entry cannot be invalidated') + if (Buffer.byteLength(path.posix.basename(normalized)) > 255) { + throw new RangeError('FUSE entry name must be at most 255 UTF-8 bytes') + } + return normalized +} + +function invalidationErrorCode (result) { + for (const [code, errno] of Object.entries(os.constants.errno)) { + if (result === -errno) return code + } + return 'EFUSEINVALIDATE' +} + +function normalizeTimeoutOption (timeout) { + if (timeout === false) return 0 + if (timeout === undefined) return DEFAULT_TIMEOUT + if (typeof timeout === 'number') return timeoutNumber('timeout', timeout) + if (!timeout || typeof timeout !== 'object' || Array.isArray(timeout)) { + throw new TypeError('timeout must be a non-negative number, false, or an object') + } + + const normalized = {} + for (const [name, value] of Object.entries(timeout)) { + if (name !== 'default' && !OpcodesAndDefaults.has(name)) { + throw new TypeError(`Unknown timeout operation ${JSON.stringify(name)}`) + } + if (value === false) { + normalized[name] = false + } else { + normalized[name] = timeoutNumber(`timeout.${name}`, value) + } + } + return normalized +} + +function validateOptions (opts) { + const booleanOptions = [ + 'displayFolder', 'debug', 'force', 'mkdir', 'allowOther', 'allowRoot', + 'autoUnmount', 'defaultPermissions', 'blkdev', 'kernelCache', 'autoCache', + 'noforget', 'nonEmpty', 'nullPathOk', 'noPath', 'directIo', 'useIno' + ] + const numberOptions = ['entryTimeout', 'attrTimeout', 'acAttrTimeout', 'negativeTimeout'] + const stringOptions = ['fsname', 'subtype', 'modules', 'name'] + + for (const name of Reflect.ownKeys(opts)) { + if (typeof name !== 'string' || !KNOWN_OPTIONS.has(name)) { + throw new TypeError(`Unknown FUSE option ${String(name)}`) + } + } + + for (const name of booleanOptions) { + if (opts[name] !== undefined && typeof opts[name] !== 'boolean') { + throw new TypeError(`${name} must be a boolean`) + } + } + for (const name of ['uid', 'gid']) { + if (opts[name] !== undefined && opts[name] !== null) boundedInteger(name, opts[name], 0, MAX_INT32) + } + for (const name of ['userId', 'blksize', 'maxRead']) { + if (opts[name] !== undefined && opts[name] !== null) boundedInteger(name, opts[name], 0, 0xffffffff) + } + if (opts.fd !== undefined && opts.fd !== null) boundedInteger('fd', opts.fd, 0, MAX_INT32) + if (opts.umask !== undefined && opts.umask !== null) boundedInteger('umask', opts.umask, 0, 0o7777) + if (opts.remember !== undefined && opts.remember !== null) { + boundedInteger('remember', opts.remember, 0, MAX_INT32) + } + if (opts.maxConcurrency !== undefined && opts.maxConcurrency !== null) { + boundedInteger('maxConcurrency', opts.maxConcurrency, 1, MAX_MAX_CONCURRENCY) + } + for (const name of numberOptions) { + if (opts[name] !== undefined && opts[name] !== null) mountNumber(name, opts[name]) + } + for (const name of stringOptions) { + if (opts[name] !== undefined && opts[name] !== null) mountString(name, opts[name]) + } + if (opts.onError !== undefined && typeof opts.onError !== 'function') { + throw new TypeError('onError must be a function') + } +} + +function normalizeAndValidateOptions (opts) { + const normalized = normalizeOptionAliases(opts) + validateOptions(normalized) + validateFuse3Options(normalized) + return normalized +} + +function normalizeOptionAliases (opts) { + let normalized = null + + for (const [alias, canonical] of OPTION_ALIASES) { + if (!Object.prototype.hasOwnProperty.call(opts, alias)) continue + + const hasCanonical = Object.prototype.hasOwnProperty.call(opts, canonical) + if (hasCanonical && opts[canonical] !== opts[alias]) { + throw new TypeError( + `FUSE options ${JSON.stringify(canonical)} and ${JSON.stringify(alias)} must not conflict` + ) + } + + if (!normalized) normalized = { ...opts } + if (!hasCanonical) normalized[canonical] = opts[alias] + delete normalized[alias] + } + + return normalized || opts +} + +function validateOperations (ops) { + for (const name of Reflect.ownKeys(ops)) { + if (typeof name !== 'string' || !KNOWN_OPERATIONS.has(name)) { + if (name === 'error') continue + throw new TypeError(`Unknown FUSE operation ${String(name)}`) + } + } +} + +function timeoutNumber (name, value) { + if (!Number.isFinite(value) || value < 0 || !Number.isSafeInteger(value)) { + throw new TypeError(`${name} must be a non-negative safe integer`) + } + return value +} + +function mountString (name, value) { + if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${name} must be a non-empty string`) + if (/[\0,\\\r\n]/.test(value)) { + throw new TypeError(`${name} cannot contain NUL, comma, backslash, or a newline`) + } + return value +} + +function mountNumber (name, value) { + if (!Number.isFinite(value) || value < 0) throw new TypeError(`${name} must be a non-negative finite number`) + return value +} + +function mountInteger (name, value) { + if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative safe integer`) + return value +} + +function boundedInteger (name, value, minimum, maximum) { + if (!Number.isSafeInteger(value)) throw new TypeError(`${name} must be a safe integer`) + if (value < minimum || value > maximum) { + throw new RangeError(`${name} must be between ${minimum} and ${maximum}`) + } + return value +} + +function normalizeResult (result, operation) { + if (result === null || result === undefined) return 0 + if (!Number.isInteger(result) || result < MIN_INT32 || result > MAX_INT32) return Fuse.EIO + if (result > 0 && operation !== 'read' && operation !== 'write' && + operation !== 'writeBuffer' && operation !== 'copyFileRange' && + operation !== 'getxattr' && operation !== 'listxattr') { + return Fuse.EIO + } + return result +} + +function normalizeIOResult (result, length) { + result = normalizeResult(result, 'read') + if (result > length) throw new RangeError(`I/O callback returned ${result} bytes for a ${length}-byte buffer`) + return result +} + +function normalizeFileHandle (value) { + if (value === null || value === undefined) return 0 + return toUint64Value(value, 'file handle') +} + +/* Minimal errno -> name map for the backing-file errors we can hit. */ +const BACKING_ERRNO_NAMES = new Map([ + [1, 'EPERM'], + [9, 'EBADF'], + [16, 'EBUSY'], + [22, 'EINVAL'], + [40, 'ELOOP'], + [95, 'ENOTSUP'], + [107, 'ENOTCONN'] +]) + +function errnoName (code) { + return BACKING_ERRNO_NAMES.get(code) || `UNKNOWN(${code})` +} + +function normalizeOpenResult (result) { + if (result === null || result === undefined || + typeof result === 'number' || typeof result === 'bigint') { + return [normalizeFileHandle(result), 0] + } + if (typeof result !== 'object' || Array.isArray(result)) { + throw new TypeError('open result must be a file handle or file-info object') + } + + for (const name of Reflect.ownKeys(result)) { + if (typeof name !== 'string' || !FILE_INFO_RESULT_FIELDS.has(name)) { + throw new TypeError(`Unknown file-info property ${String(name)}`) + } + } + const fd = result.fd + const directIO = result.directIO + const keepCache = result.keepCache + const nonseekable = result.nonseekable + const backingId = result.backingId + for (const [name, value] of [ + ['directIO', directIO], + ['keepCache', keepCache], + ['nonseekable', nonseekable] + ]) { + if (value !== undefined && typeof value !== 'boolean') { + throw new TypeError(`file-info.${name} must be a boolean`) + } + } + + if (backingId !== undefined && + (!Number.isInteger(backingId) || backingId < 0)) { + throw new TypeError('file-info.backingId must be a non-negative integer') + } + // The kernel rejects FOPEN_PASSTHROUGH combined with either of these + // (fs/fuse/iomode.c), and the failure surfaces as an opaque EIO from + // open(), so it is caught here instead. + if (backingId > 0 && (keepCache || directIO)) { + throw new TypeError('file-info.backingId cannot be combined with keepCache or directIO') + } + + let flags = 0 + if (directIO) flags |= FILE_INFO_DIRECT_IO + if (keepCache) flags |= FILE_INFO_KEEP_CACHE + if (nonseekable) flags |= FILE_INFO_NONSEEKABLE + return [normalizeFileHandle(fd), flags, backingId === undefined ? 0 : backingId] +} + +function getReaddirOffsetsArray (offsets, length) { + if (!Array.isArray(offsets) || offsets.length !== length) { + throw new RangeError('readdirPaged offsets must align exactly with names') + } + const encoded = new Uint32Array(length * 2) + for (let i = 0; i < offsets.length; i++) { + const offset = toInt64(offsets[i], `readdirPaged.offsets[${i}]`) + if (offset === 0n) throw new RangeError('readdirPaged offsets must be non-zero') + setInt64(encoded, i * 2, offset, `readdirPaged.offsets[${i}]`) + } + return encoded +} + +function encodeLock (lock) { + if (!lock || typeof lock !== 'object' || Array.isArray(lock)) { + throw new TypeError('lock result must be an object') + } + const fields = new Set(['type', 'whence', 'start', 'length', 'pid']) + for (const name of Reflect.ownKeys(lock)) { + if (typeof name !== 'string' || !fields.has(name)) { + throw new TypeError(`Unknown lock property ${String(name)}`) + } + } + const encoded = new Uint32Array(8) + encoded[0] = boundedInteger('lock.type', lock.type, 0, 0xffff) + encoded[1] = boundedInteger('lock.whence', lock.whence, 0, 0xffff) + setInt64(encoded, 2, lock.start, 'lock.start') + setInt64(encoded, 4, lock.length, 'lock.length') + const pid = lock.pid ?? 0 + if (!Number.isInteger(pid) || pid < MIN_INT32 || pid > MAX_INT32) { + throw new RangeError('lock.pid must be a signed 32-bit integer') + } + encoded[6] = pid >>> 0 + return encoded +} + +function encodeInt64 (value, name) { + const encoded = new Uint32Array(2) + setInt64(encoded, 0, value, name) + return encoded +} + +function getInitConfigArray (connection, requested) { + if (requested === null || requested === undefined) return EMPTY_INIT_CONFIG + if (typeof requested !== 'object' || Array.isArray(requested)) { + throw new TypeError('init configuration must be an object') + } + + const config = new Uint32Array(8) + for (const name of Reflect.ownKeys(requested)) { + if (typeof name !== 'string') throw new TypeError(`Unknown init configuration property ${String(name)}`) + const field = INIT_CONFIG_FIELDS.get(name) + if (!field) throw new TypeError(`Unknown init configuration property ${JSON.stringify(name)}`) + const value = field.boolean + ? booleanToUint32(requested[name], `init configuration.${name}`) + : toUint32(requested[name], `init configuration.${name}`) + if (value < field.minimum) { + throw new RangeError(`init configuration.${name} must be at least ${field.minimum}`) + } + config[0] |= field.mask + config[field.index] = value + } + + for (const name of ['maxWrite', 'maxReadahead', 'maxBackground', 'congestionThreshold']) { + const field = INIT_CONFIG_FIELDS.get(name) + if ((config[0] & field.mask) !== 0 && config[field.index] > connection[name]) { + throw new RangeError(`init configuration.${name} cannot exceed the kernel value ${connection[name]}`) + } + } + const wantField = INIT_CONFIG_FIELDS.get('want') + if ((config[0] & wantField.mask) !== 0 && + ((config[wantField.index] & (~connection.capable >>> 0)) >>> 0) !== 0) { + throw new RangeError('init configuration.want contains capabilities unsupported by the kernel') + } + + const backgroundField = INIT_CONFIG_FIELDS.get('maxBackground') + const congestionField = INIT_CONFIG_FIELDS.get('congestionThreshold') + const hasBackground = (config[0] & backgroundField.mask) !== 0 + const hasCongestion = (config[0] & congestionField.mask) !== 0 + const maxBackground = hasBackground ? config[backgroundField.index] : connection.maxBackground + const congestionThreshold = hasCongestion ? config[congestionField.index] : connection.congestionThreshold + if ((hasBackground || hasCongestion) && + congestionThreshold > maxBackground) { + throw new RangeError('init configuration.congestionThreshold cannot exceed maxBackground') + } + return config +} + +function booleanToUint32 (value, name) { + if (typeof value !== 'boolean') throw new TypeError(`${name} must be a boolean`) + return value ? 1 : 0 +} + +function toUint32 (value, name) { + if (value === undefined || value === null) return 0 + if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) { + throw new RangeError(`${name} must be an unsigned 32-bit integer`) + } + return value +} + +function toUint64Value (value, name) { + const num = toUint64(value, name) + return num <= MAX_SAFE_BIGINT ? Number(num) : num +} + +function toUint64 (value, name) { + let num + if (typeof value === 'bigint') { + num = value + } else if (Number.isSafeInteger(value)) { + num = BigInt(value) + } else { + throw new TypeError(`${name} must be a safe integer or bigint`) + } + if (num < 0n || num > 0xffffffffffffffffn) throw new RangeError(`${name} is outside the uint64 range`) + return num +} + +function toInt64 (value, name) { + let num + if (typeof value === 'bigint') { + num = value + } else if (Number.isSafeInteger(value)) { + num = BigInt(value) + } else { + throw new TypeError(`${name} must be a safe integer or bigint`) + } + if (num < -0x8000000000000000n || num > 0x7fffffffffffffffn) { + throw new RangeError(`${name} is outside the int64 range`) + } + return num +} + +function setNonNegativeInt64 (arr, idx, value, name) { + const num = toInt64(value, name) + if (num < 0n) throw new RangeError(`${name} must be non-negative`) + setInt64(arr, idx, num, name) +} diff --git a/packages/computerd/vendor/fuse-napi/lib/environment.js b/packages/computerd/vendor/fuse-napi/lib/environment.js new file mode 100644 index 000000000..55e1f5fec --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/lib/environment.js @@ -0,0 +1,228 @@ +const fs = require('fs') +const os = require('os') +const { execFile } = require('child_process') + +const { MACFUSE_URL } = require('./macfuse') + +const EXEC_TIMEOUT = 5000 +const MINIMUM_LIBFUSE_VERSION = Object.freeze([3, 10, 3]) +const MINIMUM_MACFUSE_VERSION = Object.freeze([5, 3, 1]) +const MACFUSE_INFO_PLIST = '/Library/Filesystems/macfuse.fs/Contents/Info.plist' + +function checkEnvironment (options = {}, dependencies = {}) { + const platform = dependencies.platform || os.platform() + const access = dependencies.access || fs.promises.access.bind(fs.promises) + const readFile = dependencies.readFile || fs.promises.readFile.bind(fs.promises) + const exists = dependencies.exists || fs.existsSync + const run = dependencies.run || runCommand + const nativeRuntime = dependencies.nativeRuntime || null + + if (platform === 'linux') { + validateNativeRuntime(platform, nativeRuntime) + return checkLinux(options, { access, readFile, run }, nativeRuntime) + } + if (platform === 'darwin') { + validateNativeRuntime(platform, nativeRuntime) + return checkDarwin({ exists, run }, nativeRuntime) + } + + return Promise.reject(environmentError( + 'EFUSEPLATFORM', + `fuse-napi supports Linux and macOS; received unsupported platform ${platform}.` + )) +} + +async function checkLinux (options, dependencies, nativeRuntime) { + let helper + try { + helper = await dependencies.run('fusermount3', ['--version']) + } catch (cause) { + throw environmentError( + 'EFUSEHELPER', + 'The FUSE 3 mount helper "fusermount3" is unavailable or not executable. ' + + 'Install the FUSE 3 runtime package (for Debian/Ubuntu: sudo apt-get install fuse3).', + cause + ) + } + + try { + await dependencies.access('/dev/fuse', fs.constants.R_OK | fs.constants.W_OK) + } catch (cause) { + throw environmentError( + 'EFUSEDEVICE', + 'The FUSE device /dev/fuse is missing or is not readable and writable by this process. ' + + 'Load the fuse kernel module and grant the service account access to /dev/fuse.', + cause + ) + } + + if (options.allowOther || options.allowRoot) { + let fuseConfig = '' + try { + fuseConfig = await dependencies.readFile('/etc/fuse.conf', 'utf8') + } catch (cause) { + throw environmentError( + 'EFUSEALLOWOTHER', + 'allowOther/allowRoot requires a readable /etc/fuse.conf containing user_allow_other.', + cause + ) + } + if (!/^\s*user_allow_other(?:\s*(?:#.*)?)?$/m.test(fuseConfig)) { + throw environmentError( + 'EFUSEALLOWOTHER', + 'allowOther/allowRoot requires "user_allow_other" in /etc/fuse.conf.' + ) + } + } + + return Object.freeze({ + ok: true, + platform: 'linux', + helper: 'fusermount3', + helperVersion: firstLine(helper.stdout), + device: '/dev/fuse', + libfuseVersion: nativeRuntime && nativeRuntime.version, + capabilities: runtimeCapabilities(nativeRuntime) + }) +} + +async function checkDarwin (dependencies, nativeRuntime) { + const roots = ['/Library/Filesystems/macfuse.fs'] + const libraries = [ + '/usr/local/lib/libfuse3.dylib', + '/usr/local/lib/libfuse3.4.dylib' + ] + if (!roots.every(dependencies.exists) || !libraries.some(dependencies.exists)) { + throw environmentError( + 'EMACFUSEDEPENDENCY', + `macFUSE ${MINIMUM_MACFUSE_VERSION.join('.')} or newer with its libfuse3 ` + + 'compatibility runtime is not installed completely. ' + + `Install and enable macFUSE from ${MACFUSE_URL}, then restart macOS if requested.` + ) + } + + let macfuseVersion + try { + const result = await dependencies.run('/usr/bin/plutil', [ + '-extract', + 'CFBundleShortVersionString', + 'raw', + MACFUSE_INFO_PLIST + ]) + macfuseVersion = firstLine(result.stdout) + } catch (cause) { + throw environmentError( + 'EMACFUSEVERSION', + `Unable to verify the installed macFUSE version. fuse-napi requires macFUSE ` + + `${MINIMUM_MACFUSE_VERSION.join('.')} or newer. Reinstall or upgrade macFUSE ` + + `from ${MACFUSE_URL}.`, + cause + ) + } + const parsedMacfuseVersion = parseVersion(macfuseVersion) + if (!parsedMacfuseVersion || + compareVersions(parsedMacfuseVersion, MINIMUM_MACFUSE_VERSION) < 0) { + throw environmentError( + 'EMACFUSEVERSION', + `fuse-napi requires macFUSE ${MINIMUM_MACFUSE_VERSION.join('.')} or newer; ` + + `the installed runtime reports ${JSON.stringify(macfuseVersion || 'an unknown version')}. ` + + `Upgrade macFUSE from ${MACFUSE_URL}.` + ) + } + + let version = null + try { + const result = await dependencies.run('pkg-config', ['--modversion', 'fuse3']) + version = firstLine(result.stdout) + } catch { + // pkg-config is a build-time convenience; the verified runtime files are + // sufficient for a prebuilt production installation. + } + + return Object.freeze({ + ok: true, + platform: 'darwin', + runtime: '/Library/Filesystems/macfuse.fs', + macfuseVersion, + libfuseVersion: nativeRuntime && nativeRuntime.version + ? nativeRuntime.version + : version, + capabilities: runtimeCapabilities(nativeRuntime) + }) +} + +function runtimeCapabilities (runtime) { + return Object.freeze({ + statx: !!(runtime && runtime.hasStatx) + }) +} + +function validateNativeRuntime (platform, runtime) { + if (!runtime) return + + const version = parseVersion(runtime.version) + if (!version || compareVersions(version, MINIMUM_LIBFUSE_VERSION) < 0) { + throw environmentError( + 'EFUSEVERSION', + `fuse-napi requires libfuse ${MINIMUM_LIBFUSE_VERSION.join('.')} or newer; ` + + `the loaded runtime reports ${JSON.stringify(runtime.version || 'an unknown version')}.` + ) + } + + if (platform === 'darwin' && runtime.hasBufferRelease !== true) { + throw environmentError( + 'EMACFUSEABI', + 'The loaded macFUSE compatibility runtime does not expose the buffer-release ' + + `capability required by fuse-napi. Upgrade macFUSE from ${MACFUSE_URL}.` + ) + } +} + +function parseVersion (value) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[.-]|$)/.exec(String(value || '')) + if (!match) return null + return match.slice(1).map(Number) +} + +function compareVersions (left, right) { + for (let index = 0; index < 3; index++) { + if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1 + } + return 0 +} + +function runCommand (command, args) { + return new Promise((resolve, reject) => { + execFile(command, args, { + encoding: 'utf8', + shell: false, + timeout: EXEC_TIMEOUT, + killSignal: 'SIGKILL' + }, (error, stdout, stderr) => { + if (error) { + error.stdout = stdout + error.stderr = stderr + reject(error) + return + } + resolve({ stdout, stderr }) + }) + }) +} + +function environmentError (code, message, cause) { + const error = new Error(message) + error.code = code + if (cause) error.cause = cause + return error +} + +function firstLine (value) { + return String(value || '').trim().split(/\r?\n/, 1)[0] || null +} + +module.exports = { + checkEnvironment, + environmentError, + MINIMUM_LIBFUSE_VERSION +} diff --git a/packages/computerd/vendor/fuse-napi/lib/fuse3-options.js b/packages/computerd/vendor/fuse-napi/lib/fuse3-options.js new file mode 100644 index 000000000..2513bd59e --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/lib/fuse3-options.js @@ -0,0 +1,140 @@ +const FUSE3_OPTION_ERROR_PREFIX = 'Invalid FUSE 3 option configuration' + +function validateFuse3Options (options, platform = process.platform) { + if (options.nonEmpty === true) { + throw optionError( + 'ERR_FUSE_OPTION_REMOVED', + ['nonEmpty'], + '"nonEmpty"/"nonempty" was removed in FUSE 3; remove the option. ' + + 'FUSE 3 permits non-empty mountpoints without it.' + ) + } + + if (hasValue(options, 'fd')) { + throw optionError( + 'ERR_FUSE_OPTION_INTERNAL', + ['fd'], + '"fd" is managed internally by libfuse3 and cannot be supplied by an application.' + ) + } + + if (hasValue(options, 'userId')) { + throw optionError( + 'ERR_FUSE_OPTION_INTERNAL', + ['userId'], + '"userId"/"user_id" is managed internally by fusermount3. ' + + 'Use "uid" only when libfuse should override returned file ownership.' + ) + } + + rejectConflict( + options, + ['allowOther', 'allowRoot'], + '"allowOther" and "allowRoot" are mutually exclusive in FUSE 3.' + ) + rejectConflict( + options, + ['kernelCache', 'autoCache'], + '"kernelCache" and "autoCache" select incompatible cache policies.' + ) + + if (options.directIo === true && (options.kernelCache === true || options.autoCache === true)) { + const cacheOption = options.kernelCache === true ? 'kernelCache' : 'autoCache' + throw optionError( + 'ERR_FUSE_OPTION_CONFLICT', + ['directIo', cacheOption], + `"directIo" cannot be combined with "${cacheOption}" because direct I/O bypasses the kernel page cache.` + ) + } + + if (options.noforget === true && hasValue(options, 'remember')) { + throw optionError( + 'ERR_FUSE_OPTION_CONFLICT', + ['noforget', 'remember'], + '"noforget" and "remember" are mutually exclusive inode-retention policies.' + ) + } + + if (hasValue(options, 'acAttrTimeout') && options.autoCache !== true) { + throw optionError( + 'ERR_FUSE_OPTION_DEPENDENCY', + ['acAttrTimeout', 'autoCache'], + '"acAttrTimeout" is only meaningful when "autoCache" is enabled.' + ) + } + + if (platform !== 'linux' && (options.blkdev === true || hasValue(options, 'blksize'))) { + const names = options.blkdev === true ? ['blkdev'] : ['blksize'] + throw optionError( + 'ERR_FUSE_OPTION_PLATFORM', + names, + `"${names[0]}" is supported only by Linux libfuse3.` + ) + } + + if (hasValue(options, 'blksize') && options.blkdev !== true) { + throw optionError( + 'ERR_FUSE_OPTION_DEPENDENCY', + ['blksize', 'blkdev'], + '"blksize" is only valid for a FUSE block-device mount with "blkdev: true".' + ) + } + + if (options.blkdev === true && !hasValue(options, 'fsname')) { + throw optionError( + 'ERR_FUSE_OPTION_DEPENDENCY', + ['blkdev', 'fsname'], + '"blkdev" requires an explicit "fsname" identifying the backing device.' + ) + } + + if (platform !== 'darwin' && options.displayFolder === true) { + throw optionError( + 'ERR_FUSE_OPTION_PLATFORM', + ['displayFolder'], + '"displayFolder" is a macFUSE-only presentation option.' + ) + } + + if (hasValue(options, 'name') && options.displayFolder !== true) { + throw optionError( + 'ERR_FUSE_OPTION_DEPENDENCY', + ['name', 'displayFolder'], + '"name" is only used as a macFUSE volume name when "displayFolder" is enabled.' + ) + } + + if (hasValue(options, 'modules')) validateModuleList(options.modules) +} + +function validateModuleList (value) { + const modules = value.split(':') + if (modules.some(name => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))) { + throw optionError( + 'ERR_FUSE_OPTION_VALUE', + ['modules'], + '"modules" must be a colon-separated list of non-empty libfuse module identifiers.' + ) + } +} + +function rejectConflict (options, names, message) { + if (names.every(name => options[name] === true)) { + throw optionError('ERR_FUSE_OPTION_CONFLICT', names, message) + } +} + +function hasValue (options, name) { + return options[name] !== undefined && options[name] !== null +} + +function optionError (code, options, message) { + const error = new TypeError(`${FUSE3_OPTION_ERROR_PREFIX}: ${message}`) + error.code = code + error.options = Object.freeze([...options]) + return error +} + +module.exports = { + validateFuse3Options +} diff --git a/packages/computerd/vendor/fuse-napi/lib/macfuse.js b/packages/computerd/vendor/fuse-napi/lib/macfuse.js new file mode 100644 index 000000000..d6c9919c1 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/lib/macfuse.js @@ -0,0 +1,20 @@ +const MACFUSE_URL = 'https://macfuse.github.io/' + +function wrapMacFuseLoadError (err) { + const message = err && err.message ? err.message : String(err) + if (!/(?:macfuse|libfuse3(?:\.\d+)*\.dylib)/i.test(message)) return err + + const wrapped = new Error( + 'fuse-napi requires macFUSE 5.3.1 or newer with its libfuse3 runtime. ' + + `Install and enable macFUSE from ${MACFUSE_URL}, then restart macOS if requested. ` + + `Original error: ${message}` + ) + wrapped.code = 'EMACFUSEDEPENDENCY' + wrapped.cause = err + return wrapped +} + +module.exports = { + MACFUSE_URL, + wrapMacFuseLoadError +} diff --git a/packages/computerd/vendor/fuse-napi/lib/native-binding.js b/packages/computerd/vendor/fuse-napi/lib/native-binding.js new file mode 100644 index 000000000..947588b2e --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/lib/native-binding.js @@ -0,0 +1,79 @@ +const fs = require('fs') +const path = require('path') + +const loadDefaultBinding = require('node-gyp-build') + +function loadNativeBinding (root, dependencies = {}) { + const platform = dependencies.platform || process.platform + const arch = dependencies.arch || process.arch + const abi = dependencies.abi || process.versions.modules + const exists = dependencies.exists || fs.existsSync + const loadDefault = dependencies.loadDefault || loadDefaultBinding + const loadFile = dependencies.loadFile || require + + if (platform !== 'linux') return loadDefault(root) + + /* + * A local source build is linked to the host's installed libfuse ABI and + * must take precedence over bundled release artifacts. + */ + if (exists(path.join(root, 'build', 'Release', 'fuse.node')) || + exists(path.join(root, 'build', 'Debug', 'fuse.node'))) { + return loadDefault(root) + } + + const libc = dependencies.libc || process.env.LIBC || + (exists('/etc/alpine-release') ? 'musl' : 'glibc') + + /* + * libfuse 3.18 moved from SONAME libfuse3.so.3 to libfuse3.so.4 when statx + * was added to the high-level API. Keep the glibc/libfuse 3.10 baseline and + * select an explicitly tagged musl artifact on Alpine, while preferring the + * modern ABI when the matching release artifact and runtime are present. + */ + const modernPrebuild = path.join( + root, + 'prebuilds', + `linux-${arch}-fuse4`, + `fuse-napi.abi${abi}${libc === 'musl' ? '.musl' : ''}.node` + ) + if (exists(modernPrebuild)) { + try { + return loadFile(modernPrebuild) + } catch (error) { + if (!isMissingSharedLibrary(error, 'libfuse3.so.4')) throw error + } + } + + try { + return loadDefault(root) + } catch (error) { + throw wrapLinuxFuseLoadError(error, modernPrebuild, exists(modernPrebuild)) + } +} + +function wrapLinuxFuseLoadError (error, modernPrebuild, hasModernPrebuild) { + if (!isMissingSharedLibrary(error, 'libfuse3.so.3') && + !isMissingSharedLibrary(error, 'libfuse3.so.4')) { + return error + } + + const wrapped = new Error( + 'fuse-napi requires a supported FUSE 3 runtime. Install libfuse 3.10.3 or ' + + 'newer plus fusermount3 (Alpine: apk add fuse3; Debian/Ubuntu: ' + + 'sudo apt-get install fuse3 libfuse3-3).' + ) + wrapped.code = 'EFUSEDEPENDENCY' + wrapped.cause = error + if (!hasModernPrebuild) wrapped.missingPrebuild = modernPrebuild + return wrapped +} + +function isMissingSharedLibrary (error, library) { + return !!error && typeof error.message === 'string' && error.message.includes(library) +} + +module.exports = { + loadNativeBinding, + wrapLinuxFuseLoadError +} diff --git a/packages/computerd/vendor/fuse-napi/package-lock.json b/packages/computerd/vendor/fuse-napi/package-lock.json new file mode 100644 index 000000000..af3f001a1 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/package-lock.json @@ -0,0 +1,3089 @@ +{ + "name": "fuse-napi", + "version": "2.3.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fuse-napi", + "version": "2.3.1", + "cpu": [ + "x64", + "arm64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "nanoresource": "^1.3.0", + "napi-macros": "^2.2.2", + "node-gyp-build": "^4.8.4" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "concat-stream": "^2.0.0", + "node-gyp": "^13.0.1", + "prebuildify": "^6.0.1", + "tape": "^5.10.2", + "typescript": "^7.0.2" + }, + "engines": { + "node": "^22.0.0 || ^24.0.0 || ^26.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@ljharb/now": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@ljharb/now/-/now-1.0.1.tgz", + "integrity": "sha512-yRQ3B8l2v4vTLQ2l+4kBu6EsXaX7czuLB7fvUz0vkHHziHje75R2t/0Mm/vIo31tlZVCEvCoXHkNKW+ugv3uSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@ljharb/resumer": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@ljharb/resumer/-/resumer-0.1.3.tgz", + "integrity": "sha512-d+tsDgfkj9X5QTriqM4lKesCkMMJC3IrbPKHvayP00ELx2axdXvDfWkqjxrLXIzGcQzmj7VAUT1wopqARTvafw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ljharb/through": "^2.3.13", + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@ljharb/through": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", + "integrity": "sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.every": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/array.prototype.every/-/array.prototype.every-1.1.7.tgz", + "integrity": "sha512-BIP72rKvrKd08ptbetLb4qvrlGjkv30yOKgKcTtOIbHyQt3shr/jyOzdApiCOh3LPYrpJo5M6i0zmVldOF2pUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "is-string": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dotignore": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dotignore/-/dotignore-0.1.2.tgz", + "integrity": "sha512-UGGGWfSauusaVJC+8fgV+NVvBXkCTmVv7sk6nojDZZvuOUNGUy0Zk4UpHQD6EDjS0jpBwcACvH4eofvyzBcRDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.4" + }, + "bin": { + "ignored": "bin/ignored" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-dynamic-import": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/has-dynamic-import/-/has-dynamic-import-2.1.1.tgz", + "integrity": "sha512-DuTCn6K/RW8S27npDMumGKsjG6HE7MxzedZka5tJP+9dqfxks+UMqKBmeCijHtIhsBEZPlbMg0qMHi2nKYVtKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/mock-property": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/mock-property/-/mock-property-1.1.2.tgz", + "integrity": "sha512-Rw0vY0wBdNtm3sbANVMDUgO5Htew9xotKoqcdK/a4rW8jf5KEl6MAWGMBhjKng6gpPjX8SmO8f+65ssnxNroTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "isarray": "^2.0.5", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/nanoresource": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nanoresource/-/nanoresource-1.3.0.tgz", + "integrity": "sha512-OI5dswqipmlYfyL3k/YMm7mbERlh4Bd1KuKdMHpeoVD1iVxqxaTMKleB4qaA2mbQZ6/zMNSxCXv9M9P/YbqTuQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4" + } + }, + "node_modules/napi-macros": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", + "integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-gyp": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-13.0.2.tgz", + "integrity": "sha512-SXTvw3PxznpowYhJSOD9mVQBgDaCTWXffX+wQZsQ7PbcTV86TsXCUcSZhHnskFQvrvn/OdSzJPMsptT2pIj9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^10.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^8.4.1", + "which": "^7.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/npm-run-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz", + "integrity": "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prebuildify": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/prebuildify/-/prebuildify-6.0.1.tgz", + "integrity": "sha512-8Y2oOOateom/s8dNBsGIcnm6AxPmLH4/nanQzL5lQMU+sC0CMhzARZHizwr36pUPLdvBnOkCNQzxg4djuFSgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "mkdirp-classic": "^0.5.3", + "node-abi": "^3.3.0", + "npm-run-path": "^3.1.0", + "pump": "^3.0.0", + "tar-fs": "^2.1.0" + }, + "bin": { + "prebuildify": "bin.js" + } + }, + "node_modules/proc-log": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tape": { + "version": "5.10.2", + "resolved": "https://registry.npmjs.org/tape/-/tape-5.10.2.tgz", + "integrity": "sha512-MfhVVAWa834kfYL4KgrqHblWfwkjaQvfaQhAX/bBTNQsK8oJ4ztpUe7jiemR2Vegw4pYwpnQMy51MhEmRzbbsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ljharb/now": "^1.0.1", + "@ljharb/resumer": "^0.1.3", + "@ljharb/through": "^2.3.14", + "array.prototype.every": "^1.1.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "deep-equal": "^2.2.3", + "defined": "^1.0.1", + "dotignore": "^0.1.2", + "es-object-atoms": "^1.1.2", + "for-each": "^0.3.5", + "get-package-type": "^0.1.0", + "glob": "^7.2.3", + "has-dynamic-import": "^2.1.1", + "hasown": "^2.0.4", + "inherits": "^2.0.4", + "is-regex": "^1.2.1", + "minimist": "^1.2.8", + "mock-property": "^1.1.0", + "object-inspect": "^1.13.4", + "object-is": "^1.1.6", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "resolve": "^2.0.0-next.7", + "string.prototype.trim": "^1.2.11" + }, + "bin": { + "tape": "bin/tape" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-7.0.0.tgz", + "integrity": "sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/packages/computerd/vendor/fuse-napi/package.json b/packages/computerd/vendor/fuse-napi/package.json new file mode 100644 index 000000000..2fd89b5ce --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/package.json @@ -0,0 +1,115 @@ +{ + "name": "fuse-napi", + "version": "2.3.1", + "description": "Production-grade Node-API bindings for FUSE 3 on Linux and macOS", + "keywords": [ + "fuse", + "filesystem", + "napi", + "native-addon", + "linux", + "macos", + "macfuse" + ], + "main": "index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "require": "./index.js", + "default": "./index.js" + }, + "./package.json": "./package.json" + }, + "os": [ + "darwin", + "linux" + ], + "cpu": [ + "x64", + "arm64" + ], + "files": [ + "CHANGELOG.md", + "COMPATIBILITY.md", + "CONTRIBUTING.md", + "README.md", + "RELEASING.md", + "SECURITY.md", + "UPSTREAM.md", + "binding.gyp", + "fuse-native.c", + "removal-guard.c", + "removal-guard.h", + "confined-bind.c", + "index.d.ts", + "index.js", + "lib/fuse3-options.js", + "lib/environment.js", + "lib/macfuse.js", + "lib/native-binding.js", + "prebuilds/", + "scripts/fuse-config.js", + "scripts/verify-release.js" + ], + "gypfile": true, + "engines": { + "node": "^22.0.0 || ^24.0.0 || ^26.0.0" + }, + "dependencies": { + "nanoresource": "^1.3.0", + "napi-macros": "^2.2.2", + "node-gyp-build": "^4.8.4" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "concat-stream": "^2.0.0", + "node-gyp": "^13.0.1", + "prebuildify": "^6.0.1", + "tape": "^5.10.2", + "typescript": "^7.0.2" + }, + "overrides": { + "brace-expansion": "5.0.8" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/mmdevries/fuse-napi.git" + }, + "author": "Matthijs de Vries", + "contributors": [ + { + "name": "Mathias Buus", + "url": "https://github.com/mafintosh" + }, + { + "name": "SageMath, Inc." + } + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/mmdevries/fuse-napi/issues" + }, + "homepage": "https://github.com/mmdevries/fuse-napi#readme", + "publishConfig": { + "access": "public" + }, + "scripts": { + "check": "node --check index.js && node --check example.js && node --check lib/environment.js && node --check lib/fuse3-options.js && node --check lib/macfuse.js && node --check lib/native-binding.js && node --check scripts/fuse-config.js && node --check scripts/modern-operations-smoke.js && node --check scripts/mount-soak.js && node --check scripts/open-release-soak.js && node --check scripts/package-mount-smoke.js && node --check scripts/verify-release.js && node --check scripts/write-leak-soak.js && node --check test/fixtures/modern-operations-runner.js && node --check test/invalidation.js && node --check test/modern-operations.js && node --check test/option-fuzz.js && node --check test/privileged/broken-mount.js", + "install": "node-gyp-build", + "test": "tape test/*.js", + "test:package-mount": "node scripts/package-mount-smoke.js", + "test:fuzz": "node test/option-fuzz.js", + "test:privileged-recovery": "tape test/privileged/broken-mount.js", + "test:soak": "node --expose-gc scripts/mount-soak.js", + "test:open-release": "node --expose-gc scripts/open-release-soak.js", + "test:write-leak": "node --expose-gc scripts/write-leak-soak.js", + "test:unit": "tape test/dependencies.js test/environment.js test/errno.js test/hardening.js test/mount-ready.js test/options.js test/release.js", + "test:types": "tsc -p tsconfig.json", + "clean": "rm -rf node_modules build", + "prebuild": "prebuildify --strip --no-napi --node-gyp node_modules/.bin/node-gyp", + "verify:metadata": "node scripts/verify-release.js", + "verify:release": "node scripts/verify-release.js --artifacts", + "prepublishOnly": "node scripts/verify-release.js --artifacts" + } +} diff --git a/packages/computerd/vendor/fuse-napi/removal-guard.c b/packages/computerd/vendor/fuse-napi/removal-guard.c new file mode 100644 index 000000000..b4097a615 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/removal-guard.c @@ -0,0 +1,593 @@ +/* + * Removal guard for computerd's native roots. Not upstream; see PATCHES.md. + * + * A native root is a local-only directory bind-mounted onto the FUSE mount. + * rmdir(2) and rename(2) of a mount point fail with EBUSY inside the VFS, + * before FUSE is asked, so `rm -rf node_modules` empties the directory and + * then fails on it. This installs a seccomp filter on the calling thread -- + * computerd's JavaScript main thread, which every command it spawns is forked + * from -- that pauses exactly those syscalls. A thread here reads the path, + * and if it names a native root, lazily detaches the mount, then lets the + * original syscall continue. computerd then serves it through FUSE as it does + * for any local-only path. + * + * This is a convenience, not an enforcement point: a path re-read by the + * kernel after SECCOMP_USER_NOTIF_FLAG_CONTINUE may differ from the one read + * here. The worst outcomes are a mount detached for nothing (served through + * FUSE, remounted on the next mkdir) or a missed detach (EBUSY, as without + * the guard). A lazy detach is always safe: the mount's source is the same + * directory computerd serves the path from. + * + * The guard thread must never wait on the JavaScript thread, whose own + * rmdir/rename calls are trapped too. So it reads paths through /proc and + * process_vm_readv, checks them against an in-memory table, and detaches + * through a descriptor (/proc/self/fd/N) rather than the path under the FUSE + * mount, whose walk could need a FUSE lookup answered by that thread. + */ +#define _GNU_SOURCE + +#include +#include +#include +#include + +#include + +#include "removal-guard.h" + +#if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__)) +#define GUARD_SUPPORTED 1 +#else +#define GUARD_SUPPORTED 0 +#endif + +static napi_value int32_value(napi_env env, int32_t value) { + napi_value out = NULL; + napi_create_int32(env, value, &out); + return out; +} + +#if GUARD_SUPPORTED + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__x86_64__) +#define GUARD_ARCH AUDIT_ARCH_X86_64 +#else +#define GUARD_ARCH AUDIT_ARCH_AARCH64 +#endif + +#ifndef SECCOMP_USER_NOTIF_FLAG_CONTINUE +#define SECCOMP_USER_NOTIF_FLAG_CONTINUE (1UL << 0) +#endif + +#define GUARD_MAX_INSNS 32 +#define GUARD_NOTIF_BUFFER 512 +#define GUARD_READ_CHUNK 4096 + +typedef struct { + char *path; + int fd; +} guard_root_t; + +static pthread_mutex_t g_roots_lock = PTHREAD_MUTEX_INITIALIZER; +static guard_root_t *g_roots = NULL; +static size_t g_roots_len = 0; +static size_t g_roots_cap = 0; + +static atomic_bool g_started = false; +static atomic_ullong g_traps = 0; +static atomic_ullong g_detaches = 0; +static napi_threadsafe_function g_on_detached = NULL; +static int g_handoff[2] = { -1, -1 }; + +/* Syscalls that name a directory to remove or a path to rename. unlinkat is + * handled separately: only its AT_REMOVEDIR form is trapped. */ +static const int g_notify_nrs[] = { +#ifdef __NR_rmdir + __NR_rmdir, +#endif +#ifdef __NR_rename + __NR_rename, +#endif +#ifdef __NR_renameat + __NR_renameat, +#endif + __NR_renameat2, +}; + +static struct sock_filter insn(uint16_t code, uint8_t jt, uint8_t jf, uint32_t k) { + struct sock_filter out; + out.code = code; + out.jt = jt; + out.jf = jf; + out.k = k; + return out; +} + +/* Allows everything except the listed syscalls and unlinkat(AT_REMOVEDIR), + * which go to the listener. Other architectures and x32 are allowed. */ +static size_t build_filter(struct sock_filter *f, size_t cap) { + const uint16_t ld = (uint16_t) (BPF_LD | BPF_W | BPF_ABS); + const uint16_t jeq = (uint16_t) (BPF_JMP | BPF_JEQ | BPF_K); + const uint16_t jset = (uint16_t) (BPF_JMP | BPF_JSET | BPF_K); + const uint16_t ret = (uint16_t) (BPF_RET | BPF_K); + const size_t listed = sizeof(g_notify_nrs) / sizeof(g_notify_nrs[0]); +#if defined(__x86_64__) + const size_t x32 = 1; +#else + const size_t x32 = 0; +#endif + const size_t total = 3 + x32 + listed + 3 + 2; + if (total > cap) return 0; + const size_t allow = total - 2; + const size_t notify = total - 1; + size_t i = 0; + +#define GUARD_JUMP(target) ((uint8_t) ((target) - i - 1)) + f[i] = insn(ld, 0, 0, (uint32_t) offsetof(struct seccomp_data, arch)); + i++; + f[i] = insn(jeq, 0, GUARD_JUMP(allow), (uint32_t) GUARD_ARCH); + i++; + f[i] = insn(ld, 0, 0, (uint32_t) offsetof(struct seccomp_data, nr)); + i++; +#if defined(__x86_64__) + f[i] = insn((uint16_t) (BPF_JMP | BPF_JGE | BPF_K), GUARD_JUMP(allow), 0, 0x40000000U); + i++; +#endif + for (size_t j = 0; j < listed; j++) { + f[i] = insn(jeq, GUARD_JUMP(notify), 0, (uint32_t) g_notify_nrs[j]); + i++; + } + f[i] = insn(jeq, 0, GUARD_JUMP(allow), (uint32_t) __NR_unlinkat); + i++; + /* The low word of args[2]; both supported architectures are little endian. */ + f[i] = insn(ld, 0, 0, (uint32_t) (offsetof(struct seccomp_data, args) + 2 * sizeof(uint64_t))); + i++; + f[i] = insn(jset, GUARD_JUMP(notify), GUARD_JUMP(allow), (uint32_t) AT_REMOVEDIR); + i++; + f[i] = insn(ret, 0, 0, SECCOMP_RET_ALLOW); + i++; + f[i] = insn(ret, 0, 0, SECCOMP_RET_USER_NOTIF); + i++; +#undef GUARD_JUMP + return i; +} + +/* Without PR_SET_NO_NEW_PRIVS: computerd has CAP_SYS_ADMIN, and setting it + * would stop setuid binaries elevating inside commands. Without the + * capability the kernel refuses, and the guard stays off. */ +static int install_listener(void) { + struct sock_filter f[GUARD_MAX_INSNS]; + size_t n = build_filter(f, GUARD_MAX_INSNS); + if (n == 0) return -EINVAL; + struct sock_fprog prog; + prog.len = (unsigned short) n; + prog.filter = f; + long fd = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, &prog); + return fd < 0 ? -errno : (int) fd; +} + +static int read_target_string(pid_t pid, uint64_t addr, char *out, size_t cap) { + size_t got = 0; + /* Chunks never cross a 4 KiB boundary, so never a page boundary either, + * whatever the page size: a string ending just before an unmapped page + * still reads. */ + while (got + 1 < cap) { + uint64_t at = addr + got; + size_t chunk = (size_t) (GUARD_READ_CHUNK - (at % GUARD_READ_CHUNK)); + if (chunk > cap - 1 - got) chunk = cap - 1 - got; + struct iovec local = { out + got, chunk }; + struct iovec remote = { (void *) (uintptr_t) at, chunk }; + ssize_t n = process_vm_readv(pid, &local, 1, &remote, 1, 0); + if (n <= 0) return -1; + if (memchr(out + got, '\0', (size_t) n) != NULL) return 0; + got += (size_t) n; + } + return -1; +} + +/* Lexical: "." and ".." are folded, symlinks are not followed. The caller's + * cwd or dirfd link is already resolved, and a native root is a mount + * point, so a path through a symlink to one simply does not match. */ +static int normalize(const char *in, char *out, size_t cap) { + size_t len = 0; + const char *p = in; + out[0] = '\0'; + while (*p != '\0') { + while (*p == '/') p++; + if (*p == '\0') break; + const char *segment = p; + while (*p != '\0' && *p != '/') p++; + size_t segment_len = (size_t) (p - segment); + if (segment_len == 1 && segment[0] == '.') continue; + if (segment_len == 2 && segment[0] == '.' && segment[1] == '.') { + while (len > 0 && out[len - 1] != '/') len--; + if (len > 0) len--; + out[len] = '\0'; + continue; + } + if (len + 1 + segment_len + 1 > cap) return -1; + out[len++] = '/'; + memcpy(out + len, segment, segment_len); + len += segment_len; + out[len] = '\0'; + } + if (len == 0) { + if (cap < 2) return -1; + out[0] = '/'; + out[1] = '\0'; + } + return 0; +} + +static int resolve(pid_t pid, int dirfd, const char *path, char *out, size_t cap) { + char joined[PATH_MAX * 2]; + if (path[0] == '/') { + size_t n = strlen(path); + if (n >= sizeof(joined)) return -1; + memcpy(joined, path, n + 1); + } else { + char link[64]; + char base[PATH_MAX]; + if (dirfd == AT_FDCWD) { + snprintf(link, sizeof(link), "/proc/%d/cwd", (int) pid); + } else { + snprintf(link, sizeof(link), "/proc/%d/fd/%d", (int) pid, dirfd); + } + ssize_t n = readlink(link, base, sizeof(base) - 1); + if (n <= 0) return -1; + base[n] = '\0'; + if (base[0] != '/') return -1; + size_t base_len = (size_t) n; + size_t path_len = strlen(path); + if (base_len + 1 + path_len + 1 > sizeof(joined)) return -1; + memcpy(joined, base, base_len); + joined[base_len] = '/'; + memcpy(joined + base_len + 1, path, path_len + 1); + } + return normalize(joined, out, cap); +} + +static void call_js(napi_env env, napi_value js_cb, void *context, void *data) { + (void) context; + char *path = (char *) data; + if (env != NULL && js_cb != NULL && path != NULL) { + napi_value undefined = NULL; + napi_value arg = NULL; + if (napi_get_undefined(env, &undefined) == napi_ok && + napi_create_string_utf8(env, path, NAPI_AUTO_LENGTH, &arg) == napi_ok) { + napi_call_function(env, undefined, js_cb, 1, &arg, NULL); + } + } + free(path); +} + +static void detach_if_root(const char *path) { + int fd = -1; + char *owned = NULL; + pthread_mutex_lock(&g_roots_lock); + for (size_t i = 0; i < g_roots_len; i++) { + if (strcmp(g_roots[i].path, path) == 0) { + fd = g_roots[i].fd; + owned = g_roots[i].path; + g_roots[i] = g_roots[--g_roots_len]; + break; + } + } + pthread_mutex_unlock(&g_roots_lock); + if (fd < 0) return; + + char link[64]; + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + /* EINVAL means it was already detached, by computerd or the kernel. */ + if (umount2(link, MNT_DETACH) == 0) atomic_fetch_add(&g_detaches, 1); + close(fd); + /* Tell computerd so it parks the root: a lookup must not remount it + * under the removal that is about to continue. */ + if (g_on_detached != NULL && + napi_call_threadsafe_function(g_on_detached, owned, napi_tsfn_nonblocking) == napi_ok) { + owned = NULL; + } + free(owned); +} + +static void handle(int listener, const struct seccomp_notif *req) { + const struct seccomp_data *d = &req->data; + int dirs[2] = { AT_FDCWD, AT_FDCWD }; + uint64_t paths[2] = { 0, 0 }; + int count = 0; + + if (d->nr == __NR_unlinkat) { + dirs[0] = (int) (int32_t) (uint32_t) d->args[0]; + paths[0] = d->args[1]; + count = 1; +#ifdef __NR_rmdir + } else if (d->nr == __NR_rmdir) { + paths[0] = d->args[0]; + count = 1; +#endif +#ifdef __NR_rename + } else if (d->nr == __NR_rename) { + paths[0] = d->args[0]; + paths[1] = d->args[1]; + count = 2; +#endif +#ifdef __NR_renameat + } else if (d->nr == __NR_renameat) { + dirs[0] = (int) (int32_t) (uint32_t) d->args[0]; + paths[0] = d->args[1]; + dirs[1] = (int) (int32_t) (uint32_t) d->args[2]; + paths[1] = d->args[3]; + count = 2; +#endif + } else if (d->nr == __NR_renameat2) { + dirs[0] = (int) (int32_t) (uint32_t) d->args[0]; + paths[0] = d->args[1]; + dirs[1] = (int) (int32_t) (uint32_t) d->args[2]; + paths[1] = d->args[3]; + count = 2; + } + + for (int i = 0; i < count; i++) { + char raw[PATH_MAX]; + char absolute[PATH_MAX]; + if (read_target_string((pid_t) req->pid, paths[i], raw, sizeof(raw)) != 0) continue; + if (resolve((pid_t) req->pid, dirs[i], raw, absolute, sizeof(absolute)) != 0) continue; + /* The task may have died, or the call been interrupted, since. */ + if (ioctl(listener, SECCOMP_IOCTL_NOTIF_ID_VALID, &req->id) != 0) return; + detach_if_root(absolute); + } +} + +static void *guard_main(void *unused) { + (void) unused; + int listener = -1; + ssize_t got; + do { + got = read(g_handoff[0], &listener, sizeof(listener)); + } while (got < 0 && errno == EINTR); + close(g_handoff[0]); + if (got != (ssize_t) sizeof(listener) || listener < 0) return NULL; + + static union { + struct seccomp_notif notif; + unsigned char bytes[GUARD_NOTIF_BUFFER]; + } req_buf; + static union { + struct seccomp_notif_resp resp; + unsigned char bytes[GUARD_NOTIF_BUFFER]; + } resp_buf; + size_t req_size = sizeof(req_buf.notif); + size_t resp_size = sizeof(resp_buf.resp); + struct seccomp_notif_sizes sizes; + if (syscall(SYS_seccomp, SECCOMP_GET_NOTIF_SIZES, 0, &sizes) == 0) { + if (sizes.seccomp_notif > req_size) req_size = sizes.seccomp_notif; + if (sizes.seccomp_notif_resp > resp_size) resp_size = sizes.seccomp_notif_resp; + } + if (req_size > GUARD_NOTIF_BUFFER || resp_size > GUARD_NOTIF_BUFFER) { + /* Closing the listener makes trapped calls fail with ENOSYS rather + * than wait forever. Not expected on any current kernel. */ + close(listener); + return NULL; + } + + for (;;) { + memset(&req_buf, 0, req_size); + if (ioctl(listener, SECCOMP_IOCTL_NOTIF_RECV, &req_buf.notif) != 0) { + if (errno == EINTR || errno == ENOENT) continue; + close(listener); + return NULL; + } + atomic_fetch_add(&g_traps, 1); + handle(listener, &req_buf.notif); + memset(&resp_buf, 0, resp_size); + resp_buf.resp.id = req_buf.notif.id; + resp_buf.resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE; + /* ENOENT: the task went away; nothing to answer. */ + ioctl(listener, SECCOMP_IOCTL_NOTIF_SEND, &resp_buf.resp); + } + return NULL; +} + +static int start_guard(void) { + if (pipe2(g_handoff, O_CLOEXEC) != 0) return -errno; + pthread_t thread; + int err = pthread_create(&thread, NULL, guard_main, NULL); + if (err != 0) { + close(g_handoff[0]); + close(g_handoff[1]); + return -err; + } + pthread_detach(thread); + /* The thread exists before the filter, so it does not inherit it and can + * never be the one waiting on itself. */ + int listener = install_listener(); + ssize_t written; + do { + written = write(g_handoff[1], &listener, sizeof(listener)); + } while (written < 0 && errno == EINTR); + close(g_handoff[1]); + return listener < 0 ? listener : 0; +} + +static bool get_string(napi_env env, napi_value value, char *out, size_t cap) { + size_t len = 0; + if (napi_get_value_string_utf8(env, value, out, cap, &len) != napi_ok) return false; + return len > 0 && len + 1 < cap; +} + +/* guardStart(onDetached): installs the filter on the calling thread and starts + * the guard. Returns 0 or -errno; -EALREADY when already started. */ +napi_value fuse_native_guard_start(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1] = { NULL }; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + if (atomic_exchange(&g_started, true)) return int32_value(env, -EALREADY); + + napi_valuetype type = napi_undefined; + if (argc >= 1 && napi_typeof(env, argv[0], &type) == napi_ok && type == napi_function) { + napi_value name = NULL; + napi_create_string_utf8(env, "computerd-removal-guard", NAPI_AUTO_LENGTH, &name); + if (napi_create_threadsafe_function(env, argv[0], NULL, name, 0, 1, NULL, NULL, NULL, call_js, + &g_on_detached) == napi_ok) { + napi_unref_threadsafe_function(env, g_on_detached); + } else { + g_on_detached = NULL; + } + } + + int result = start_guard(); + if (result != 0) { + if (g_on_detached != NULL) { + napi_release_threadsafe_function(g_on_detached, napi_tsfn_abort); + g_on_detached = NULL; + } + atomic_store(&g_started, false); + } + return int32_value(env, result); +} + +/* guardAddRoot(path, fd): the guard takes ownership of fd, an open directory + * at the root of the mount. Replaces any earlier entry for path. */ +napi_value fuse_native_guard_add_root(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2] = { NULL, NULL }; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + char path[PATH_MAX]; + int32_t fd = -1; + if (argc < 2 || napi_get_value_int32(env, argv[1], &fd) != napi_ok || fd < 0) { + return int32_value(env, -EINVAL); + } + /* From here on the descriptor is ours, whatever happens. */ + if (!get_string(env, argv[0], path, sizeof(path))) { + close(fd); + return int32_value(env, -EINVAL); + } + char *copy = strdup(path); + if (copy == NULL) { + close(fd); + return int32_value(env, -ENOMEM); + } + + int old_fd = -1; + char *old_path = NULL; + int result = 0; + pthread_mutex_lock(&g_roots_lock); + size_t i = 0; + for (; i < g_roots_len; i++) { + if (strcmp(g_roots[i].path, path) == 0) break; + } + if (i < g_roots_len) { + old_fd = g_roots[i].fd; + old_path = g_roots[i].path; + g_roots[i].path = copy; + g_roots[i].fd = fd; + } else if (g_roots_len == g_roots_cap) { + size_t cap = g_roots_cap == 0 ? 16 : g_roots_cap * 2; + guard_root_t *grown = realloc(g_roots, cap * sizeof(guard_root_t)); + if (grown == NULL) { + result = -ENOMEM; + } else { + g_roots = grown; + g_roots_cap = cap; + } + } + if (result == 0 && i == g_roots_len) { + g_roots[g_roots_len].path = copy; + g_roots[g_roots_len].fd = fd; + g_roots_len++; + } + pthread_mutex_unlock(&g_roots_lock); + + if (result != 0) { + free(copy); + close(fd); + } + if (old_fd >= 0) close(old_fd); + free(old_path); + return int32_value(env, result); +} + +/* guardRemoveRoot(path): forgets path and closes its descriptor. Returns 1 + * if it was known, else 0. */ +napi_value fuse_native_guard_remove_root(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1] = { NULL }; + napi_get_cb_info(env, info, &argc, argv, NULL, NULL); + char path[PATH_MAX]; + if (argc < 1 || !get_string(env, argv[0], path, sizeof(path))) return int32_value(env, 0); + int fd = -1; + char *owned = NULL; + pthread_mutex_lock(&g_roots_lock); + for (size_t i = 0; i < g_roots_len; i++) { + if (strcmp(g_roots[i].path, path) == 0) { + fd = g_roots[i].fd; + owned = g_roots[i].path; + g_roots[i] = g_roots[--g_roots_len]; + break; + } + } + pthread_mutex_unlock(&g_roots_lock); + if (fd >= 0) close(fd); + free(owned); + return int32_value(env, fd >= 0 ? 1 : 0); +} + +/* guardStats(): { traps, detaches, roots }. */ +napi_value fuse_native_guard_stats(napi_env env, napi_callback_info info) { + (void) info; + napi_value out = NULL; + napi_value value = NULL; + napi_create_object(env, &out); + napi_create_double(env, (double) atomic_load(&g_traps), &value); + napi_set_named_property(env, out, "traps", value); + napi_create_double(env, (double) atomic_load(&g_detaches), &value); + napi_set_named_property(env, out, "detaches", value); + pthread_mutex_lock(&g_roots_lock); + double roots = (double) g_roots_len; + pthread_mutex_unlock(&g_roots_lock); + napi_create_double(env, roots, &value); + napi_set_named_property(env, out, "roots", value); + return out; +} + +#else /* !GUARD_SUPPORTED */ + +napi_value fuse_native_guard_start(napi_env env, napi_callback_info info) { + (void) info; + return int32_value(env, -ENOSYS); +} + +napi_value fuse_native_guard_add_root(napi_env env, napi_callback_info info) { + (void) info; + return int32_value(env, -ENOSYS); +} + +napi_value fuse_native_guard_remove_root(napi_env env, napi_callback_info info) { + (void) info; + return int32_value(env, 0); +} + +napi_value fuse_native_guard_stats(napi_env env, napi_callback_info info) { + (void) info; + napi_value out = NULL; + napi_create_object(env, &out); + return out; +} + +#endif diff --git a/packages/computerd/vendor/fuse-napi/removal-guard.h b/packages/computerd/vendor/fuse-napi/removal-guard.h new file mode 100644 index 000000000..0ba2d6915 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/removal-guard.h @@ -0,0 +1,12 @@ +#ifndef COMPUTERD_REMOVAL_GUARD_H +#define COMPUTERD_REMOVAL_GUARD_H + +#include + +/* Not upstream; see PATCHES.md. */ +napi_value fuse_native_guard_start(napi_env env, napi_callback_info info); +napi_value fuse_native_guard_add_root(napi_env env, napi_callback_info info); +napi_value fuse_native_guard_remove_root(napi_env env, napi_callback_info info); +napi_value fuse_native_guard_stats(napi_env env, napi_callback_info info); + +#endif diff --git a/packages/computerd/vendor/fuse-napi/scripts/fuse-config.js b/packages/computerd/vendor/fuse-napi/scripts/fuse-config.js new file mode 100644 index 000000000..503817b5f --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/scripts/fuse-config.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +const fs = require('fs') +const { spawnSync } = require('child_process') + +const { MACFUSE_URL } = require('../lib/macfuse') + +const MINIMUM_LIBFUSE3_VERSION = [3, 10, 3] + +if (require.main === module) { + try { + const config = discover() + switch (process.argv[2]) { + case 'include-dirs': + console.log(config.includeDirs.join(' ')) + break + case 'libraries': + console.log(config.libraries.join(' ')) + break + default: + throw new Error('Usage: fuse-config.js ') + } + } catch (err) { + console.error(err.message) + process.exitCode = 1 + } +} + +function discover ( + platform = process.platform, + pkgConfig = runPkgConfig, + exists = fs.existsSync +) { + const version = pkgConfig(['--modversion', 'fuse3']) + if (version !== null) { + const normalizedVersion = version.trim() + if (!isSupportedVersion(normalizedVersion)) { + throw new Error( + `Unsupported libfuse3 version ${normalizedVersion || '(empty)'}. ` + + `fuse-napi requires libfuse >=${MINIMUM_LIBFUSE3_VERSION.join('.')}.` + ) + } + + const cflags = pkgConfig(['--cflags-only-I', 'fuse3']) + const libraries = pkgConfig(['--libs', 'fuse3']) + if (cflags !== null && libraries !== null) { + return { + includeDirs: words(cflags).map(flag => flag.replace(/^-I/, '')), + libraries: words(libraries) + } + } + } + + if (platform === 'darwin') { + const include = '/usr/local/include/fuse3' + const dylibs = [ + '/usr/local/lib/libfuse3.dylib', + '/usr/local/lib/libfuse3.4.dylib' + ] + if (exists(`${include}/fuse.h`) && dylibs.some(exists)) { + return { + includeDirs: [include], + libraries: ['-L/usr/local/lib', '-lfuse3', '-pthread'] + } + } + + throw new Error( + 'macFUSE 5.3.1 or newer with its libfuse3 headers is required to build fuse-napi on macOS. ' + + `Install macFUSE from ${MACFUSE_URL}; fuse-napi does not download or install it.` + ) + } + + if (platform === 'linux') { + throw new Error( + 'System libfuse3 development files, the FUSE 3 mount helper, and pkg-config are required to build fuse-napi on Linux. ' + + 'On Alpine, run: apk add build-base fuse3 fuse3-dev linux-headers pkgconf python3. ' + + 'On Debian or Ubuntu, run: sudo apt-get install libfuse3-dev fuse3 pkg-config' + ) + } + + throw new Error(`fuse-napi supports Linux and macOS; received unsupported platform ${platform}.`) +} + +function runPkgConfig (args) { + const result = spawnSync(process.env.PKG_CONFIG || 'pkg-config', args, { + encoding: 'utf8' + }) + if (result.error || result.status !== 0) return null + return result.stdout.trim() +} + +function words (value) { + return value.trim() ? value.trim().split(/\s+/) : [] +} + +function isSupportedVersion (version) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[.-].*)?$/.exec(version) + if (!match) return false + + const actual = match.slice(1).map(Number) + for (let i = 0; i < MINIMUM_LIBFUSE3_VERSION.length; i++) { + if (actual[i] > MINIMUM_LIBFUSE3_VERSION[i]) return true + if (actual[i] < MINIMUM_LIBFUSE3_VERSION[i]) return false + } + return true +} + +module.exports = { + discover +} diff --git a/packages/computerd/vendor/fuse-napi/scripts/verify-release.js b/packages/computerd/vendor/fuse-napi/scripts/verify-release.js new file mode 100644 index 000000000..bcc17cddd --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/scripts/verify-release.js @@ -0,0 +1,118 @@ +const fs = require('fs') +const path = require('path') + +const root = path.resolve(__dirname, '..') +const manifest = readJson('package.json') +const lockPath = path.join(root, 'package-lock.json') +const changelogPath = path.join(root, 'CHANGELOG.md') +const verifyArtifacts = process.argv.includes('--artifacts') +const releaseTag = process.env.GITHUB_REF_TYPE === 'tag' + ? process.env.GITHUB_REF_NAME + : process.env.RELEASE_TAG + +if (manifest.name !== 'fuse-napi') fail(`Unexpected package name ${JSON.stringify(manifest.name)}`) +if (!isValidVersion(manifest.version)) fail(`Invalid package version ${JSON.stringify(manifest.version)}`) + +if (fs.existsSync(lockPath)) { + const lock = readJson('package-lock.json') + if (lock.version !== manifest.version || !lock.packages || !lock.packages[''] || + lock.packages[''].version !== manifest.version) { + fail('package.json and package-lock.json versions must match exactly') + } +} + +if (releaseTag && releaseTag !== `v${manifest.version}`) { + fail(`Release tag ${JSON.stringify(releaseTag)} must equal "v${manifest.version}"`) +} + +if (process.env.npm_lifecycle_event === 'prepublishOnly' && !releaseTag) { + fail('Publishing is only allowed from a verified release tag') +} + +const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8') +if (/not yet published to npm/i.test(readme)) { + fail('README still claims that the package is not published') +} + +let changelog +try { + changelog = fs.readFileSync(changelogPath, 'utf8') +} catch (err) { + fail(`Cannot read CHANGELOG.md: ${err.message}`) +} +if (!changelog.includes(`## ${manifest.version} - `)) { + fail(`CHANGELOG.md must contain a dated section for ${manifest.version}`) +} +if (releaseTag || verifyArtifacts || process.env.npm_lifecycle_event === 'prepublishOnly') { + const unreleased = changelogSection(changelog, 'Unreleased') + if (/(?:^|\n)###\s|(?:^|\n)-\s/.test(unreleased)) { + fail('CHANGELOG.md contains staged Unreleased entries; move them into the release section') + } +} + +if (verifyArtifacts) { + const abiFiles = [ + 'fuse-napi.abi127.node', + 'fuse-napi.abi137.node', + 'fuse-napi.abi147.node' + ] + const targets = [ + { directory: 'linux-x64', files: abiFiles }, + { directory: 'linux-arm64', files: abiFiles }, + { directory: 'darwin-x64', files: abiFiles }, + { directory: 'darwin-arm64', files: abiFiles }, + { + directory: 'linux-x64-fuse4', + files: abiFiles.concat(abiFiles.map(name => name.replace(/\.node$/, '.musl.node'))) + }, + { + directory: 'linux-arm64-fuse4', + files: abiFiles.concat(abiFiles.map(name => name.replace(/\.node$/, '.musl.node'))) + } + ] + for (const target of targets) { + for (const abiFile of target.files) { + const prebuild = path.join(root, 'prebuilds', target.directory, abiFile) + let stat + try { + stat = fs.statSync(prebuild) + } catch { + fail(`Missing release prebuild: ${path.relative(root, prebuild)}`) + } + if (!stat.isFile() || stat.size === 0) { + fail(`Release prebuild is empty or not a file: ${path.relative(root, prebuild)}`) + } + } + } +} + +function readJson (filename) { + const absolute = path.join(root, filename) + try { + return JSON.parse(fs.readFileSync(absolute, 'utf8')) + } catch (err) { + fail(`Cannot read ${filename}: ${err.message}`) + } +} + +function isValidVersion (version) { + if (typeof version !== 'string') return false + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version) + if (!match || !match[4]) return !!match + return match[4].split('.').every(identifier => !/^\d+$/.test(identifier) || !/^0\d+/.test(identifier)) +} + +function changelogSection (source, heading) { + const startMarker = `## ${heading}` + const start = source.indexOf(startMarker) + if (start === -1) fail(`CHANGELOG.md is missing the "${heading}" section`) + const contentStart = source.indexOf('\n', start + startMarker.length) + if (contentStart === -1) return '' + const next = source.indexOf('\n## ', contentStart + 1) + return source.slice(contentStart + 1, next === -1 ? source.length : next) +} + +function fail (message) { + console.error(`Release verification failed: ${message}`) + process.exit(1) +} diff --git a/script/computerd-fuse-flush.mjs b/script/computerd-fuse-flush.mjs index e5d1e7b47..868a9d4a0 100755 --- a/script/computerd-fuse-flush.mjs +++ b/script/computerd-fuse-flush.mjs @@ -73,11 +73,11 @@ async function bootContainer() { "debian:stable-slim", "bash", "-c", - // libfuse2 + a long-lived shell so we can `docker exec` into the + // libfuse 3 + a long-lived shell so we can `docker exec` into the // same mount namespace where computerd mounted FUSE. computerd backgrounds // and we tail /tmp/computerd.log for debugging. "apt-get update >/dev/null 2>&1 && " + - "apt-get install -y --no-install-recommends fuse3 libfuse2t64 >/dev/null 2>&1 && " + + "apt-get install -y --no-install-recommends fuse3 >/dev/null 2>&1 && " + "mkdir -p /workspace && " + "/usr/local/bin/computerd >/tmp/computerd.log 2>&1 & " + "COMPUTERD_PID=$!; " + diff --git a/script/computerd-soak.mjs b/script/computerd-soak.mjs index bf30822da..7fd80c8f3 100755 --- a/script/computerd-soak.mjs +++ b/script/computerd-soak.mjs @@ -72,7 +72,7 @@ const DISABLE_FUSE = process.env.SOAK_DISABLE_FUSE === "1" || !existsSync("/dev/ const MOUNT_POINT = "/workspace"; const TICK_MS = Number(process.env.SOAK_TICK_MS ?? 100); -const IMAGE_TAG = "computerd-harness:libfuse2"; +const IMAGE_TAG = "computerd-harness:libfuse3"; if (!existsSync(BINARY)) { console.error(`computerd binary not found at ${BINARY}`); @@ -93,7 +93,7 @@ async function ensureImage() { proc.stdin.end( `FROM --platform=linux/amd64 debian:stable-slim RUN apt-get update >/dev/null && apt-get install -y --no-install-recommends \\ - fuse3 libfuse2t64 attr util-linux coreutils findutils \\ + fuse3 attr util-linux coreutils findutils \\ >/dev/null && rm -rf /var/lib/apt/lists/* `, ); diff --git a/script/npm-bench.sh b/script/npm-bench.sh index 0407f35c2..d07962a46 100755 --- a/script/npm-bench.sh +++ b/script/npm-bench.sh @@ -11,6 +11,9 @@ # express npm install express --prefer-offline # computer npm install for the cloudflare/computer monorepo # synthetic a synthetic package tree with many tiny files +# large a typical front-end toolchain (TypeScript, Vite, webpack, +# ESLint, React): several hundred packages, tens of +# thousands of files, and some large ones # # Knobs (environment variables): # MOUNT FUSE mount point (default: /tmp/workspace) @@ -22,6 +25,11 @@ # HEARTBEAT_SEC heartbeat interval in seconds (default: 10) # COMPUTERD_FUSE_TRACE set to "summary" to collect a FUSE op trace per run # NPM_CACHE_DIR npm cache directory (default: /tmp/npm-cache) +# TARGETS comma-separated label:directory pairs to install into +# (default: native:$BASE,fuse:$MOUNT) +# +# Each run also reports readMs, the time to read every installed file +# back once: the data path, where an install itself is mostly metadata. set -euo pipefail MOUNT="${MOUNT:-/tmp/workspace}" @@ -33,6 +41,8 @@ OUTPUT_JSON="${OUTPUT_JSON:-}" HEARTBEAT_SEC="${HEARTBEAT_SEC:-10}" COMPUTERD_FUSE_TRACE="${COMPUTERD_FUSE_TRACE:-}" NPM_CACHE_DIR="${NPM_CACHE_DIR:-/tmp/npm-cache}" +LARGE_PACKAGES="typescript@5 vite@7 webpack@5 eslint@9 react@19 react-dom@19 @types/node@22" +TARGETS="${TARGETS:-native:${BASE},fuse:${MOUNT}}" # Warm the npm cache for express so the FUSE install is not I/O bound. warm_npm_cache() { @@ -63,10 +73,22 @@ warm_npm_cache() { >/dev/null 2>&1 || true fi ;; + large) + # shellcheck disable=SC2086 + npm install $LARGE_PACKAGES \ + --prefix "$warmdir" \ + --cache "$NPM_CACHE_DIR" \ + --prefer-offline \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + >/dev/null 2>&1 || true + ;; synthetic) # Nothing to warm for the synthetic scenario. ;; esac + release_native_roots rm -rf "$warmdir" echo "[bench] cache warm for $scenario complete" } @@ -94,6 +116,16 @@ stop_heartbeat() { fi } +# A native root is a mount point, and rmdir of a mount point fails with +# EBUSY. computerd's removal guard handles that for the commands it runs, +# but this script is not one of them, so detach them first; COMPUTERD_URL +# is set when MOUNT_IGNORE is. A detach is always safe. +release_native_roots() { + if [ -n "${COMPUTERD_URL:-}" ]; then + curl -fsS -X POST "$COMPUTERD_URL/__computerd/native-roots/detach" >/dev/null 2>&1 || true + fi +} + # Run a single npm install and return timing + stats via echoed JSON fields. run_install() { local scenario="$1" @@ -102,6 +134,7 @@ run_install() { local run_idx="$4" local work_dir="${target_dir}/run-${run_idx}" + release_native_roots rm -rf "$work_dir" mkdir -p "$work_dir" @@ -142,6 +175,18 @@ run_install() { --loglevel warn \ >"$npm_log" 2>&1 || exit_code=$? ;; + large) + # shellcheck disable=SC2086 + npm install $LARGE_PACKAGES \ + --prefix "$work_dir" \ + --cache "$NPM_CACHE_DIR" \ + --prefer-offline \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + --loglevel warn \ + >"$npm_log" 2>&1 || exit_code=$? + ;; synthetic) # Generate a small package with many tiny files. local pkgdir="${work_dir}/synthetic-pkg" @@ -170,13 +215,21 @@ run_install() { end_ts="$(date +%s%N)" local elapsed_ms=$(( (end_ts - start_ts) / 1000000 )) + local read_start read_end read_ms=0 + if [ -d "$work_dir/node_modules" ]; then + read_start="$(date +%s%N)" + find "$work_dir/node_modules" -type f -print0 | xargs -0 -r cat >/dev/null + read_end="$(date +%s%N)" + read_ms=$(( (read_end - read_start) / 1000000 )) + fi + # Count files and directories. local file_count dir_count apparent_size file_count="$(find "$work_dir" -type f | wc -l || echo 0)" dir_count="$(find "$work_dir" -type d | wc -l || echo 0)" apparent_size="$(du -sb "$work_dir" 2>/dev/null | awk '{print $1}' || echo 0)" - echo "{\"scenario\":\"${scenario}\",\"target\":\"${label}\",\"run\":${run_idx},\"elapsedMs\":${elapsed_ms},\"exitCode\":${exit_code},\"files\":${file_count},\"dirs\":${dir_count},\"apparentBytes\":${apparent_size}}" + echo "{\"scenario\":\"${scenario}\",\"target\":\"${label}\",\"run\":${run_idx},\"elapsedMs\":${elapsed_ms},\"readMs\":${read_ms},\"exitCode\":${exit_code},\"files\":${file_count},\"dirs\":${dir_count},\"apparentBytes\":${apparent_size}}" # Keep the last npm log for inspection. if [ "$exit_code" -ne 0 ]; then @@ -195,7 +248,8 @@ for scenario in "${scenario_list[@]}"; do echo "[bench] scenario: $scenario" warm_npm_cache "$scenario" - for target_label in "native:${BASE}" "fuse:${MOUNT}"; do + IFS=',' read -ra target_list <<< "$TARGETS" + for target_label in "${target_list[@]}"; do label="${target_label%%:*}" target_dir="${target_label#*:}" mkdir -p "$target_dir" diff --git a/script/run-fs-bench.sh b/script/run-fs-bench.sh index d3a4c8a1c..92c2d2d99 100755 --- a/script/run-fs-bench.sh +++ b/script/run-fs-bench.sh @@ -11,20 +11,35 @@ # npm + git for the language-tool scenarios. go is still skipped # because it's not in debian:stable-slim's apt and pulling the # binary release would bloat the bootstrap time. +# +# LOCAL_ONLY=1 runs the bench under /tmp/workspace/bench and lists that +# directory in MOUNT_IGNORE, to measure local-only paths. +# COMPUTERD_FUSE_PASSTHROUGH=0 measures them without kernel passthrough. +# That directory is a native root (bind-mounted) by default; +# COMPUTERD_FUSE_NATIVE_ROOTS=0 measures it through FUSE passthrough instead. set -u apt-get update >/dev/null 2>&1 -apt-get install -y --no-install-recommends fuse3 libfuse2t64 attr util-linux coreutils findutils git ca-certificates curl npm >/dev/null 2>&1 +apt-get install -y --no-install-recommends fuse3 attr util-linux coreutils findutils git ca-certificates curl npm >/dev/null 2>&1 # /tmp/baseline gives the bench a native target to compare against. The # previous version forgot to create it, so fs-bench silently dropped the # baseline column from its output. mkdir -p /tmp/workspace /tmp/baseline +BENCH_MOUNT=/tmp/workspace +MOUNT_IGNORE="" +if [ "${LOCAL_ONLY:-0}" = "1" ]; then + BENCH_MOUNT=/tmp/workspace/bench + MOUNT_IGNORE=/bench +fi # Forward optional tracer config to computerd. When COMPUTERD_FUSE_TRACE=summary is # set the daemon writes a JSON summary on unmount or SIGUSR2; with # COMPUTERD_FUSE_TRACE_FILE pointed at a host-mounted path the trace survives # the container. COMPUTERD_FUSE_TRACE="${COMPUTERD_FUSE_TRACE:-}" \ COMPUTERD_FUSE_TRACE_FILE="${COMPUTERD_FUSE_TRACE_FILE:-}" \ + COMPUTERD_FUSE_PASSTHROUGH="${COMPUTERD_FUSE_PASSTHROUGH:-}" \ + MOUNT_IGNORE="$MOUNT_IGNORE" \ + COMPUTERD_FUSE_NATIVE_ROOTS="${COMPUTERD_FUSE_NATIVE_ROOTS:-}" \ PORT=45678 MOUNT_POINT=/tmp/workspace /usr/local/bin/computerd >/tmp/computerd.log 2>&1 & COMPUTERD_PID=$! @@ -44,9 +59,16 @@ fi # Forward bench knobs (REPS, WARMUP, RANDOMIZE_TARGETS, OUTPUT_JSON, # SCENARIOS) into the bench. fs-bench reads them from the environment. -MOUNT=/tmp/workspace BASE=/tmp/baseline /usr/local/bin/fs-bench +mkdir -p "$BENCH_MOUNT" +MOUNT="$BENCH_MOUNT" BASE=/tmp/baseline /usr/local/bin/fs-bench status=$? +if [ -n "$MOUNT_IGNORE" ]; then + echo "[bench] native roots:" + grep " $BENCH_MOUNT " /proc/self/mountinfo | cut -d' ' -f4,5 || echo " (not mounted)" + curl -fsS http://127.0.0.1:45678/__computerd/stats | tr '{,' '\n\n' | grep -E '"(mounts|failures|active)"' || true +fi + # Ask computerd to dump the FUSE trace (if enabled) before the SIGTERM. The # trace handler is async, so give it a moment to land on disk before we # wait on the process. diff --git a/script/run-fs-tests.sh b/script/run-fs-tests.sh index 4da14ea31..c39938bc8 100755 --- a/script/run-fs-tests.sh +++ b/script/run-fs-tests.sh @@ -6,7 +6,7 @@ # fs-tests' status code. set -u apt-get update >/dev/null 2>&1 -apt-get install -y --no-install-recommends fuse3 libfuse2t64 attr util-linux coreutils findutils git ca-certificates curl python3 >/dev/null 2>&1 +apt-get install -y --no-install-recommends fuse3 attr util-linux coreutils findutils git ca-certificates curl python3 >/dev/null 2>&1 mkdir -p /tmp/workspace # node_modules is local-only, so fs-tests covers that layer as well as the diff --git a/script/run-npm-bench-inner.sh b/script/run-npm-bench-inner.sh index adc9ddbae..ec233695f 100755 --- a/script/run-npm-bench-inner.sh +++ b/script/run-npm-bench-inner.sh @@ -6,13 +6,23 @@ set -u apt-get update >/dev/null 2>&1 apt-get install -y --no-install-recommends \ - fuse3 libfuse2t64 attr util-linux coreutils findutils \ + fuse3 attr util-linux coreutils findutils \ ca-certificates curl nodejs npm >/dev/null 2>&1 mkdir -p /tmp/workspace /tmp/baseline +# Only node_modules is local-only, so package.json and the lock stay +# synced, as in a real project. +MOUNT_IGNORE="" +if [ "${IGNORE_NODE_MODULES:-0}" = "1" ]; then + MOUNT_IGNORE="/ignored/*/node_modules" +fi + COMPUTERD_FUSE_TRACE="${COMPUTERD_FUSE_TRACE:-}" \ COMPUTERD_FUSE_TRACE_FILE="${COMPUTERD_FUSE_TRACE_FILE:-}" \ + COMPUTERD_FUSE_PASSTHROUGH="${COMPUTERD_FUSE_PASSTHROUGH:-}" \ + MOUNT_IGNORE="$MOUNT_IGNORE" \ + COMPUTERD_FUSE_NATIVE_ROOTS="${COMPUTERD_FUSE_NATIVE_ROOTS:-}" \ PORT=45678 MOUNT_POINT=/tmp/workspace /usr/local/bin/computerd >/tmp/computerd.log 2>&1 & COMPUTERD_PID=$! @@ -30,9 +40,19 @@ if ! kill -0 "$COMPUTERD_PID" 2>/dev/null; then exit 1 fi -MOUNT=/tmp/workspace BASE=/tmp/baseline /usr/local/bin/npm-bench +COMPUTERD_URL="" +if [ -n "$MOUNT_IGNORE" ]; then + COMPUTERD_URL=http://127.0.0.1:45678 +fi + +COMPUTERD_URL="$COMPUTERD_URL" MOUNT=/tmp/workspace BASE=/tmp/baseline /usr/local/bin/npm-bench status=$? +if [ -n "$MOUNT_IGNORE" ]; then + echo "[bench] passthrough status:" + curl -fsS http://127.0.0.1:45678/__computerd/info | tr ',' '\n' | grep -i -E 'passthrough|nativeRoots|"mounts"|"failures"' || true +fi + kill -USR2 "$COMPUTERD_PID" 2>/dev/null && sleep 1 kill "$COMPUTERD_PID" 2>/dev/null wait "$COMPUTERD_PID" 2>/dev/null diff --git a/script/run-npm-bench.sh b/script/run-npm-bench.sh index 70e079ef7..95d143f01 100755 --- a/script/run-npm-bench.sh +++ b/script/run-npm-bench.sh @@ -16,6 +16,19 @@ # SCENARIOS comma-separated list of scenarios to run # (default: express) # FUSE_TRACE set to "summary" to capture a FUSE op trace per run +# TARGETS label:directory pairs to install into, comma-separated +# (default: native:/tmp/baseline,fuse:/tmp/workspace). +# Add fuse-ignored:/tmp/workspace/ignored to measure +# local-only node_modules. +# IGNORE_NODE_MODULES set to 1 to make each run's node_modules under +# /tmp/workspace/ignored local-only (MOUNT_IGNORE) +# COMPUTERD_FUSE_PASSTHROUGH set to 0 to measure local-only paths +# without kernel passthrough +# COMPUTERD_FUSE_NATIVE_ROOTS set to 0 to serve those node_modules +# through FUSE passthrough instead of bind-mounting them +# PLATFORM docker platform (default: linux/amd64). On an arm64 +# machine use linux/arm64 and a computerd-linux-arm64 +# binary; emulated timings are not meaningful. # # Requirements: # docker available and able to run --privileged containers @@ -34,6 +47,11 @@ REPS="${REPS:-3}" WARMUP="${WARMUP:-1}" SCENARIOS="${SCENARIOS:-express}" FUSE_TRACE="${FUSE_TRACE:-}" +TARGETS="${TARGETS:-native:/tmp/baseline,fuse:/tmp/workspace}" +IGNORE_NODE_MODULES="${IGNORE_NODE_MODULES:-0}" +COMPUTERD_FUSE_PASSTHROUGH="${COMPUTERD_FUSE_PASSTHROUGH:-}" +COMPUTERD_FUSE_NATIVE_ROOTS="${COMPUTERD_FUSE_NATIVE_ROOTS:-}" +PLATFORM="${PLATFORM:-linux/amd64}" if [ ! -f "$COMPUTERD_BINARY" ]; then echo "computerd binary not found at $COMPUTERD_BINARY" @@ -43,7 +61,7 @@ fi mkdir -p "$(dirname "$OUTPUT_JSON")" -docker run --rm --platform linux/amd64 --privileged \ +docker run --rm --platform "$PLATFORM" --privileged \ --device /dev/fuse --cap-add SYS_ADMIN --cap-add MKNOD \ -v "$COMPUTERD_BINARY:/usr/local/bin/computerd:ro" \ -v "$SCRIPT_DIR/npm-bench.sh:/usr/local/bin/npm-bench:ro" \ @@ -54,4 +72,8 @@ docker run --rm --platform linux/amd64 --privileged \ -e "SCENARIOS=$SCENARIOS" \ -e "OUTPUT_JSON=/out/$(basename "$OUTPUT_JSON")" \ -e "COMPUTERD_FUSE_TRACE=${FUSE_TRACE}" \ + -e "TARGETS=$TARGETS" \ + -e "IGNORE_NODE_MODULES=$IGNORE_NODE_MODULES" \ + -e "COMPUTERD_FUSE_PASSTHROUGH=$COMPUTERD_FUSE_PASSTHROUGH" \ + -e "COMPUTERD_FUSE_NATIVE_ROOTS=$COMPUTERD_FUSE_NATIVE_ROOTS" \ debian:stable-slim bash /run-bench.sh diff --git a/script/shell b/script/shell index c4ca4cf99..63c159be8 100755 --- a/script/shell +++ b/script/shell @@ -23,4 +23,4 @@ exec docker run --rm -it \ -v "${repo_root}/script/fs-bench.sh:/usr/local/bin/fs-bench:ro" \ -w /root \ debian:stable-slim \ - bash -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends fuse3 libfuse2t64 attr util-linux coreutils findutils git ca-certificates python3 >/dev/null 2>&1; exec bash' + bash -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends fuse3 attr util-linux coreutils findutils git ca-certificates python3 >/dev/null 2>&1; exec bash'