From 99babd6bf839572aecfacb381e6d9ca1eacbad37 Mon Sep 17 00:00:00 2001 From: Aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:11:07 +0000 Subject: [PATCH 01/28] computerd: vendor fuse-napi and port the mount to libfuse 3 Replaces fuse-native (libfuse 2.9) with a vendored, patched fuse-napi 2.3.1 (MIT) built against libfuse 3, which is what can negotiate FOPEN_PASSTHROUGH. Backward compatibility with the 2.9 binding is not kept; there is one mount backend. Binding (vendor/fuse-napi, changes in PATCHES.md): - backingOpen(fd) / backingClose(id), the ioctl libfuse only exposes through its low-level API - backingId on the open/create result, written to info->backing_id - maxBackingStackDepth init config and Fuse.CAP_PASSTHROUGH - a backing id combined with keepCache or directIO is refused, because the kernel rejects that pairing with an opaque EIO from open() Mount options (options.ts) split in two, because libfuse 3 moved some of these into the init config: - buildFuseInitConfig() carries max_write, now an init field - big_writes is gone; libfuse 3 removed it and batching is the default - auto_cache and kernel_cache are gone, and are refused in EXTRA_OPTS Driver: - the _fuseOptions() monkey patch is deleted; options go through the constructor and the init callback - keepCache is decided per open for VFS-backed files, applying auto_cache's own rule (reuse when mtime and size are unchanged since the last open) because libfuse sets keep_cache after the open callback returns and does not check for passthrough - a created or recreated path never inherits a previous cache mark Verified on kernel 6.18 with libfuse 3.17.2: reads and writes through a passthrough handle never reached the JavaScript handlers, including two concurrent opens sharing one refcounted backing id. --- .gitignore | 5 + package-lock.json | 2694 ++++++++++- packages/computerd/package.json | 2 +- packages/computerd/src/fuse/driver.ts | 131 +- packages/computerd/src/fuse/fuse-native.d.ts | 12 - packages/computerd/src/fuse/options.ts | 140 +- .../computerd/vendor/fuse-napi/CHANGELOG.md | 263 ++ .../vendor/fuse-napi/COMPATIBILITY.md | 197 + .../vendor/fuse-napi/CONTRIBUTING.md | 49 + packages/computerd/vendor/fuse-napi/LICENSE | 21 + .../computerd/vendor/fuse-napi/PATCHES.md | 58 + packages/computerd/vendor/fuse-napi/README.md | 728 +++ .../computerd/vendor/fuse-napi/RELEASING.md | 172 + .../computerd/vendor/fuse-napi/SECURITY.md | 58 + .../computerd/vendor/fuse-napi/UPSTREAM.md | 36 + .../computerd/vendor/fuse-napi/binding.gyp | 65 + .../computerd/vendor/fuse-napi/fuse-native.c | 4186 +++++++++++++++++ .../computerd/vendor/fuse-napi/index.d.ts | 733 +++ packages/computerd/vendor/fuse-napi/index.js | 2343 +++++++++ .../vendor/fuse-napi/lib/environment.js | 228 + .../vendor/fuse-napi/lib/fuse3-options.js | 140 + .../computerd/vendor/fuse-napi/lib/macfuse.js | 20 + .../vendor/fuse-napi/lib/native-binding.js | 79 + .../vendor/fuse-napi/package-lock.json | 3089 ++++++++++++ .../computerd/vendor/fuse-napi/package.json | 112 + .../vendor/fuse-napi/scripts/fuse-config.js | 110 + .../fuse-napi/scripts/verify-release.js | 118 + 27 files changed, 15468 insertions(+), 321 deletions(-) delete mode 100644 packages/computerd/src/fuse/fuse-native.d.ts create mode 100644 packages/computerd/vendor/fuse-napi/CHANGELOG.md create mode 100644 packages/computerd/vendor/fuse-napi/COMPATIBILITY.md create mode 100644 packages/computerd/vendor/fuse-napi/CONTRIBUTING.md create mode 100644 packages/computerd/vendor/fuse-napi/LICENSE create mode 100644 packages/computerd/vendor/fuse-napi/PATCHES.md create mode 100644 packages/computerd/vendor/fuse-napi/README.md create mode 100644 packages/computerd/vendor/fuse-napi/RELEASING.md create mode 100644 packages/computerd/vendor/fuse-napi/SECURITY.md create mode 100644 packages/computerd/vendor/fuse-napi/UPSTREAM.md create mode 100644 packages/computerd/vendor/fuse-napi/binding.gyp create mode 100644 packages/computerd/vendor/fuse-napi/fuse-native.c create mode 100644 packages/computerd/vendor/fuse-napi/index.d.ts create mode 100644 packages/computerd/vendor/fuse-napi/index.js create mode 100644 packages/computerd/vendor/fuse-napi/lib/environment.js create mode 100644 packages/computerd/vendor/fuse-napi/lib/fuse3-options.js create mode 100644 packages/computerd/vendor/fuse-napi/lib/macfuse.js create mode 100644 packages/computerd/vendor/fuse-napi/lib/native-binding.js create mode 100644 packages/computerd/vendor/fuse-napi/package-lock.json create mode 100644 packages/computerd/vendor/fuse-napi/package.json create mode 100644 packages/computerd/vendor/fuse-napi/scripts/fuse-config.js create mode 100644 packages/computerd/vendor/fuse-napi/scripts/verify-release.js 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/package-lock.json b/package-lock.json index 7c13ad42a..a419b9aff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3567,6 +3567,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", @@ -4086,6 +4099,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", @@ -6411,166 +6469,516 @@ "@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.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "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.11", - "@vitest/utils": "4.1.11", - "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.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "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.11", - "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.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "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.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "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.11", - "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.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "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.11", - "@vitest/utils": "4.1.11", - "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/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "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": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "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": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@workflow/serde": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "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.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "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.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", "license": "Apache-2.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/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -6793,17 +7201,95 @@ "dev": true, "license": "Apache-2.0", "dependencies": { - "dequal": "^2.0.3" + "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", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "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/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "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": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/assertion-error": { @@ -6834,6 +7320,16 @@ "url": "https://github.com/sponsors/sxzz" } }, + "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", @@ -6972,8 +7468,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", @@ -6984,6 +7480,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", @@ -6999,7 +7496,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -7009,8 +7505,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", @@ -7159,6 +7655,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", @@ -7365,8 +7868,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", @@ -7432,6 +7935,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", @@ -7593,6 +8134,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", @@ -7652,6 +8247,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", @@ -7679,6 +8307,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/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7776,6 +8432,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", @@ -7862,8 +8562,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" } @@ -7922,6 +8622,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", @@ -7932,6 +8642,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", @@ -7950,6 +8748,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", @@ -7969,6 +8788,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", @@ -8145,6 +9014,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", @@ -8470,8 +9346,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", @@ -8488,6 +9364,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", @@ -8511,50 +9394,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", @@ -8624,6 +9500,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", @@ -8679,6 +9565,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", @@ -8692,6 +9588,24 @@ "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.5", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.5.tgz", @@ -8715,6 +9629,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", @@ -8745,6 +9681,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", @@ -8811,6 +9795,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", @@ -8823,6 +9838,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", @@ -9091,6 +10122,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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", @@ -9112,6 +10155,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.2", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", @@ -9121,44 +10179,183 @@ "node": ">= 12" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "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", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "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": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "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": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "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==", + "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" }, @@ -9176,6 +10373,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", @@ -9186,6 +10399,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", @@ -9209,6 +10458,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", @@ -9219,6 +10494,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", @@ -9244,6 +10536,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", @@ -9257,6 +10614,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", @@ -9284,6 +10659,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", @@ -11655,12 +13076,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", @@ -11740,8 +13207,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" }, @@ -11779,6 +13246,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", @@ -11825,6 +13321,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", @@ -11836,6 +13357,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.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.1.tgz", + "integrity": "sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==", + "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", @@ -11868,6 +13425,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", @@ -11889,6 +13475,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", @@ -11984,6 +13634,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", @@ -12317,6 +13986,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", @@ -12326,6 +14005,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", @@ -12500,6 +14186,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", @@ -12531,6 +14235,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", @@ -12611,8 +14325,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" @@ -12882,6 +14596,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", @@ -12906,6 +14643,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", @@ -12987,6 +14745,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", @@ -13188,6 +14970,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", @@ -13208,6 +15010,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", @@ -13320,15 +15157,46 @@ "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", + "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/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -13819,6 +15687,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", @@ -13844,6 +15726,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -13967,6 +15909,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", @@ -14008,12 +15963,69 @@ "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": "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==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -14025,8 +16037,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", @@ -14042,8 +16054,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", @@ -14053,6 +16065,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", @@ -14375,6 +16397,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", @@ -14401,6 +16492,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/undici": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", @@ -14585,8 +16695,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", @@ -15133,6 +17243,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", @@ -15873,6 +18050,16 @@ "node": ">=10" } }, + "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", @@ -16055,7 +18242,7 @@ "@cloudflare/computer-rpc": "*", "@cloudflare/dofs": "*", "@platformatic/vfs": "^0.4.0", - "fuse-native": "^2.2.6" + "fuse-napi": "file:./vendor/fuse-napi" }, "bin": { "computerd": "dist/cli/computerd.cjs" @@ -16067,6 +18254,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.5.1", diff --git a/packages/computerd/package.json b/packages/computerd/package.json index b3262813c..4ed2a2a69 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" }, "devDependencies": { "vitest": "^4.1.11" diff --git a/packages/computerd/src/fuse/driver.ts b/packages/computerd/src/fuse/driver.ts index 1af773cbb..a05a64a8a 100644 --- a/packages/computerd/src/fuse/driver.ts +++ b/packages/computerd/src/fuse/driver.ts @@ -1,7 +1,7 @@ import { writeFileSync as nodeWriteFileSync } from "node:fs"; import { posix } from "node:path"; import type { FUSEBackend } from "./backend.js"; -import { buildFuseOptionString } from "./options.js"; +import { buildFuseInitConfig, buildFuseOptionString } from "./options.js"; import { type LocalPassthroughOptions, type PassthroughStats, @@ -50,15 +50,40 @@ type StatusCallback = (errnoOrBytes: number) => void; type ResultCallback = (errno: number, result: T) => void; type NotImplementedOperation = (...args: unknown[]) => void; +/** + * What open/create may hand back under libfuse 3. + * + * A bare number is still accepted and means "just the handle". The object + * form carries the per-open cache decision, and a backing id when the + * local-only layer has registered the file for passthrough. + */ +export interface FuseFileInfoResult { + readonly fd: number; + /** + * Reuse the kernel page cache for this file. Replaces the auto_cache + * mount option, which libfuse applies after this callback returns and + * which the kernel refuses alongside passthrough. + */ + readonly keepCache?: boolean; + /** + * Backing id from the FUSE passthrough registration. When set, the + * kernel serves reads and writes for this handle directly from the + * backing file and the daemon is out of the data path. + */ + readonly backingId?: number; +} + +export type FuseOpenResult = number | FuseFileInfoResult; + export interface FuseOps { init(cb?: StatusCallback): void; error: NotImplementedOperation; readdir(path: string, cb: ResultCallback): 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, @@ -145,10 +170,9 @@ export interface FuseMount { getLocalPathStats?: () => PassthroughStats; } -interface FuseNativeInstance { +interface FuseInstance { mount(cb: (error: Error | null) => void): void; unmount(cb: (error: Error | null) => void): void; - _fuseOptions(): string; } export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseOps { @@ -181,6 +205,39 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO return openHandle(path); }; + /* + * Per-open page cache decision, replacing the auto_cache mount option. + * + * libfuse applies auto_cache *after* the open callback returns and does + * not check for passthrough, and the kernel rejects FOPEN_KEEP_CACHE + * alongside FOPEN_PASSTHROUGH with EIO. So the rule moves here, where it + * can be applied per file and skipped for a passthrough handle. + * + * The rule is auto_cache's own: keep the cache when neither mtime nor + * size has changed since this path was last opened. Anything else -- a + * first open, a changed file, a stat we could not take -- invalidates, + * which is the safe direction. + */ + 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); @@ -519,7 +576,8 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO try { const entry = files.get(path); if (entry?.pendingCreate === true) { - cb(0, openFileHandle(path)); + // 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)); @@ -530,7 +588,8 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO if (hasBufferedWrites) { directWriteVfs.openWriteBufferSync?.(toVfs(path)); } - cb(0, openFileHandle(path)); + const keepCache = resolveKeepCache(path); + cb(0, { fd: openFileHandle(path), keepCache }); } catch (error) { cb(toErrno(error), 0); } @@ -556,11 +615,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) { @@ -568,7 +629,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 @@ -588,7 +649,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); } @@ -978,14 +1039,14 @@ 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); @@ -1002,10 +1063,36 @@ export async function mountFuse(options: { tracer === undefined ? fuseOps : wrapFuseOpsWithTracer(fuseOps as unknown as Record, tracer); - const fuse = new Fuse(options.mountPoint, ops, { + // buildFuseOptionString reads COMPUTERD_FUSE_* env vars; with none set it + // emits the production-safe profile (use_ino, 512 KiB max_read, and + // one-second metadata timeouts). + const extraOpts = buildFuseOptionString(process.env); + // libfuse 3 takes max_write through the init config rather than as a + // mount option, and the passthrough capability has to be requested at + // init or the backing-file ioctl fails with EPERM. + const initConfig = buildFuseInitConfig(process.env); + const CAP_PASSTHROUGH: number = Fuse.CAP_PASSTHROUGH ?? 1 << 29; + let passthroughNegotiated = false; + + const opsWithInit = { + ...ops, + initWithConfig(info: { capable: number; want: number }, cb: (errno: number, config?: unknown) => void) { + const capable = info.capable >>> 0; + passthroughNegotiated = (capable & CAP_PASSTHROUGH) !== 0; + const config: Record = { maxWrite: initConfig.maxWrite }; + if (passthroughNegotiated) { + config.want = (info.want >>> 0) | CAP_PASSTHROUGH; + config.maxBackingStackDepth = initConfig.maxBackingStackDepth; + } + cb(0, config); + }, + }; + + const fuse = new Fuse(options.mountPoint, opsWithInit, { autoUnmount: true, debug: false, - }) as FuseNativeInstance; + options: extraOpts.split(","), + }) as FuseInstance; const emitTrace = (reason: string): void => { if (tracer === undefined) return; @@ -1028,20 +1115,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) => { 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/options.ts b/packages/computerd/src/fuse/options.ts index b86a2b082..d1cce01e3 100644 --- a/packages/computerd/src/fuse/options.ts +++ b/packages/computerd/src/fuse/options.ts @@ -1,54 +1,69 @@ -// FUSE mount option assembly. +// FUSE mount option assembly, for libfuse 3. // -// 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. +// Split into two parts because libfuse 3 moved some of these out of the +// mount option string and into the init config: +// +// - buildFuseOptionString() emits what still belongs on the command +// line: use_ino, max_read, and the metadata timeouts. +// - buildFuseInitConfig() emits max_write, which libfuse 3 only +// accepts through fuse_conn_info at init. // // 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. +// benchmark report and the mtime-propagation contract tests. +// 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. +// 512 KiB max_read and max_write match the dofs CHUNK_SIZE so a single +// FUSE read maps to a single chunk fetch. // -// 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. +// Two options the libfuse 2.9 build set are deliberately gone: // -// 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. +// - big_writes. libfuse 3 removed it; batching up to max_write is the +// default there, which is what the option used to buy. +// - auto_cache. libfuse sets keep_cache *after* the open callback +// returns and does not check for passthrough, and the kernel +// refuses FOPEN_PASSTHROUGH combined with FOPEN_KEEP_CACHE +// (fs/fuse/iomode.c) with EIO. The driver now decides keepCache per +// open instead, applying auto_cache's own rule — reuse the cache +// when mtime and size are unchanged since the last open — for +// VFS-backed files only. See resolveKeepCache in driver.ts. +// +// 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. // 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"]); +// Options that must never reach the mount. +// +// - big_writes: removed in libfuse 3, which fails the mount on it. +// - max_write: init config in libfuse 3, not a mount option. +// - auto_cache / kernel_cache: incompatible with passthrough, and the +// driver now owns the keep-cache decision per open. +// - writeback_cache: possible under libfuse 3 but it changes +// behaviour for the synced mount; a separate change. +const DISALLOWED_OPTS = new Set([ + "big_writes", + "max_write", + "auto_cache", + "kernel_cache", + "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; @@ -56,34 +71,40 @@ export interface FuseOptionEnv { COMPUTERD_FUSE_EXTRA_OPTS?: string; } +/** The subset of the libfuse 3 init config computerd sets. */ +export interface FuseInitConfig { + /** Maximum bytes per write request. Mount option in 2.9, init in 3. */ + readonly maxWrite: number; + /** + * Stacking depth allowed for a passthrough backing file. 1 covers a + * backing store on overlayfs; without it registration fails ELOOP. + */ + readonly maxBackingStackDepth: number; +} + /** - * 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. + * Build the init config handed to libfuse 3 from the init callback. + * + * Separate from the option string because libfuse 3 rejects max_write as + * a mount option, so the two cannot be assembled together. + */ +export function buildFuseInitConfig(env: FuseOptionEnv): FuseInitConfig { + return { + maxWrite: parsePositiveInt(env.COMPUTERD_FUSE_MAX_WRITE) ?? DEFAULT_MAX_WRITE, + maxBackingStackDepth: 1, + }; +} + +/** + * Build the comma-separated mount option string. Pure function over an + * env-like object, so tests can drive it directly. */ export function buildFuseOptionString(env: FuseOptionEnv): string { - const opts: string[] = ["big_writes", "use_ino"]; + const opts: string[] = ["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"); - } - 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( @@ -100,7 +121,7 @@ export function buildFuseOptionString(env: FuseOptionEnv): string { const trimmed = part.trim(); if (trimmed === "") continue; const head = trimmed.split("=")[0]; - if (DISALLOWED_OPTS.has(head)) continue; + if (head !== undefined && DISALLOWED_OPTS.has(head)) continue; opts.push(trimmed); } } @@ -122,19 +143,6 @@ function parseNonNegativeNumber(value: string | undefined): number | undefined { return n; } -// 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 pushTimeout( opts: string[], name: string, 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..52454e23b --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/PATCHES.md @@ -0,0 +1,58 @@ +# 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 + +All changes are in `fuse-native.c` and `index.js`. + +### `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`. + +## 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..a043bfff1 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/binding.gyp @@ -0,0 +1,65 @@ +{ + "targets": [{ + "target_name": "fuse", + 'variables': { + 'fuse__include_dirs%': ' +#include +#include + +#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_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) + +// 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; + } + + 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_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..604898a94 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/index.d.ts @@ -0,0 +1,733 @@ +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; + 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; + 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..09fd043a3 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/index.js @@ -0,0 +1,2343 @@ +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 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'], + ['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' +]) + +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.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('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 +} + +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' + ] + const numberOptions = ['entryTimeout', 'attrTimeout', 'acAttrTimeout'] + 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..c21bf8b31 --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/package.json @@ -0,0 +1,112 @@ +{ + "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", + "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/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) +} From 758685a927a61cb5358ffbf263d72f7390cf5acd Mon Sep 17 00:00:00 2001 From: Aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:18:39 +0000 Subject: [PATCH 02/28] computerd: update the mount option and driver tests for libfuse 3 Follows the libfuse 3 port. The option string no longer carries big_writes (removed upstream), max_write (an init config field now), or auto_cache / kernel_cache (incompatible with passthrough, and the keep-cache decision moved into the driver). - options.test.ts covers the new default profile, max_write arriving through buildFuseInitConfig, the backing stack depth request, and that a typo in EXTRA_OPTS naming a removed option is stripped rather than failing the mount - driver.test.ts reads open/create handles through a helper, since those now reply with a file-info object, and pins that a freshly created file never reuses the page cache --- packages/computerd/src/fuse/driver.test.ts | 109 ++++++++------- packages/computerd/src/fuse/options.test.ts | 142 ++++++++------------ 2 files changed, 118 insertions(+), 133 deletions(-) diff --git a/packages/computerd/src/fuse/driver.test.ts b/packages/computerd/src/fuse/driver.test.ts index 67c5e737c..4d6a30696 100644 --- a/packages/computerd/src/fuse/driver.test.ts +++ b/packages/computerd/src/fuse/driver.test.ts @@ -9,6 +9,14 @@ 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))); +/** + * open/create reply with a libfuse 3 file-info object carrying the + * per-open cache decision; opendir still replies with a bare handle. + */ +function handleOf(result: unknown): number { + return typeof result === "number" ? result : (result as { fd: number }).fd; +} + const fuseNativeOperationNames = [ "init", "error", @@ -98,23 +106,26 @@ 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"); + // A newly created file has no previous mtime/size to match, so the + // kernel page cache is never reused for it. + 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 +138,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,8 +149,8 @@ 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.flush("/dir/file.txt", handleOf(create.result), cb))).toBe(0); + expect(await status((cb) => ops.fsync("/dir/file.txt", handleOf(create.result), 0, cb))).toBe(0); expect(await status((cb) => ops.fsyncdir("/dir", rootDir.result as number, 0, cb))).toBe(0); expect( @@ -171,7 +182,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 +190,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 +227,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 +259,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 +293,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) => @@ -320,7 +331,7 @@ 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), @@ -348,7 +359,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), @@ -375,7 +386,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), @@ -416,7 +427,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 +436,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 +456,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); @@ -485,7 +496,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 +515,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 +559,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 +579,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 +625,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 +644,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 +672,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 +706,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 +719,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 +782,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 +809,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 +826,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 +856,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 +872,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 +905,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 +958,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 +975,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 +996,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 +1018,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 +1043,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); @@ -1057,7 +1068,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/options.test.ts b/packages/computerd/src/fuse/options.test.ts index 40a4b29ee..eaff4c77b 100644 --- a/packages/computerd/src/fuse/options.test.ts +++ b/packages/computerd/src/fuse/options.test.ts @@ -1,37 +1,45 @@ import { describe, expect, test } from "vitest"; -import { buildFuseOptionString, type FuseOptionEnv } from "./options.js"; +import { buildFuseInitConfig, buildFuseOptionString, type FuseOptionEnv } from "./options.js"; const empty: FuseOptionEnv = {}; describe("buildFuseOptionString", () => { 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. + // 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. + // + // No big_writes (libfuse 3 removed it, and batching is the default), + // no max_write (an init field now), and no auto_cache: the keep-cache + // decision moved into the driver so it can be skipped for a + // passthrough handle, which the kernel refuses to combine with it. 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", + "use_ino,max_read=524288,attr_timeout=1,entry_timeout=1,negative_timeout=0,ac_attr_timeout=1", ); }); + test("max_write moves to the init config, not the option string", () => { + // libfuse 3 fails the mount if max_write arrives as a mount option. + expect(buildFuseOptionString(empty)).not.toContain("max_write"); + expect(buildFuseInitConfig(empty).maxWrite).toBe(524288); + }); + + test("requests a backing stack depth so an overlayfs store can register", () => { + // Without this the ioctl fails ELOOP when MOUNT_IGNORE_PATH is on + // overlayfs, which is the common Docker case. + 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(buildFuseOptionString(env)).toContain("max_read=1048576"); + expect(buildFuseInitConfig(env).maxWrite).toBe(1048576); }); test("ignores non-numeric size overrides and falls back to the default", () => { @@ -40,34 +48,39 @@ describe("buildFuseOptionString", () => { }); 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(buildFuseOptionString({ COMPUTERD_FUSE_MAX_READ: "0" })).toContain("max_read=524288"); + expect(buildFuseInitConfig({ COMPUTERD_FUSE_MAX_WRITE: "-1" }).maxWrite).toBe(524288); + }); + + test("never emits auto_cache or kernel_cache", () => { + // Both are incompatible with passthrough: libfuse applies keep_cache + // after the open callback returns without checking, and the kernel + // refuses FOPEN_KEEP_CACHE alongside FOPEN_PASSTHROUGH with EIO. The + // driver decides keepCache per open instead. + for (const env of [ + empty, + { COMPUTERD_FUSE_EXTRA_OPTS: "auto_cache" }, + { COMPUTERD_FUSE_EXTRA_OPTS: "kernel_cache" }, + ]) { + const out = buildFuseOptionString(env); + expect(out).not.toContain("auto_cache"); + expect(out).not.toContain("kernel_cache"); + } + }); + + test("strips options libfuse 3 would fail the mount on", () => { + // A typo in EXTRA_OPTS should not take the daemon down at startup. + const out = buildFuseOptionString({ + COMPUTERD_FUSE_EXTRA_OPTS: "big_writes,max_write=4096,writeback_cache,allow_other", + }); + expect(out).not.toContain("big_writes"); + expect(out).not.toContain("max_write"); + expect(out).not.toContain("writeback_cache"); + // ...but anything legitimate still passes through. + expect(out).toContain("allow_other"); }); 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({ COMPUTERD_FUSE_ATTR_TIMEOUT: "", COMPUTERD_FUSE_ENTRY_TIMEOUT: "", @@ -78,45 +91,6 @@ describe("buildFuseOptionString", () => { 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", - }); - 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"); - }); - test("overrides default metadata timeouts when explicit values are set", () => { const out = buildFuseOptionString({ COMPUTERD_FUSE_ATTR_TIMEOUT: "5", From 094f239f465c7f36463e8c1f4a395dcf6465fccd Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:53:16 +0100 Subject: [PATCH 03/28] computerd: Keep oxlint and oxfmt out of the vendored fuse-napi tree --- .oxfmtrc.json | 1 + .oxlintrc.json | 1 + 2 files changed, 2 insertions(+) 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", From b15e32eff0bd7057768e3e15a3d9821c4d02f6cd Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:25:36 +0000 Subject: [PATCH 04/28] computerd, computer: Build the binary against the system libfuse 3 `build:bin` still read the fuse-native prebuild and its bundled libfuse.so.2, both gone since the move to the vendored fuse-napi binding, so it failed before producing anything. The addon is now compiled from the vendored source inside a node:22-trixie-slim container for linux/amd64. A container rather than the build host because the CI runners carry libfuse 3.14, older than the 3.17 that passthrough needs, and a developer machine may not be x64. The result is cached under dist/sea and keyed on the addon sources, since an emulated compile takes minutes. COMPUTERD_FUSE_ADDON points at a prebuilt fuse.node to skip the container entirely. The binary no longer embeds a copy of libfuse or prepends LD_LIBRARY_PATH. The addon links the system libfuse 3, so the image that runs it needs Debian trixie's fuse3 package or an equivalent; a missing library now fails with a message that says so. The macOS target is gone, since the vendored binding is only wired for Linux. The container is run detached and then waited on. An attached `docker run` against a remote daemon can lose its stream and return early, which looked like a build that produced no addon. The test harness image moves to debian:trixie-slim with fuse3 only, so the harness and the runner's real-FUSE test boot the new binary. --- .../computer/test-harness/run-computerd.sh | 14 +- packages/computerd/scripts/build-bin.mjs | 174 ++++++++++++------ packages/computerd/scripts/sea/bundle.mjs | 69 +++---- .../computerd/src/cli/bundle-port.test.ts | 20 +- 4 files changed, 160 insertions(+), 117 deletions(-) diff --git a/packages/computer/test-harness/run-computerd.sh b/packages/computer/test-harness/run-computerd.sh index 3f6f6614f..4e84cbda2 100755 --- a/packages/computer/test-harness/run-computerd.sh +++ b/packages/computer/test-harness/run-computerd.sh @@ -32,24 +32,24 @@ 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" +# 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. +IMAGE_TAG="computerd-harness:libfuse3" if ! 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 +FROM --platform=linux/amd64 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 # 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 \ diff --git a/packages/computerd/scripts/build-bin.mjs b/packages/computerd/scripts/build-bin.mjs index 34f561485..329d67998 100644 --- a/packages/computerd/scripts/build-bin.mjs +++ b/packages/computerd/scripts/build-bin.mjs @@ -1,20 +1,22 @@ #!/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), +// Build computerd as a self-contained Node SEA binary for linux-x64. Steps: +// 1. Compile the vendored fuse-napi addon inside a Debian trixie +// container for the target platform (see buildAddon below). +// 2. esbuild a single ESM bundle (see scripts/sea/bundle.mjs). +// 3. Write a sea-config.json that names the bundle as main and embeds +// the addon as an asset. +// 4. Generate the SEA blob via `node --experimental-sea-config`. +// 5. 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. // -// Everything used at runtime is embedded; the resulting artifact has no -// sidecar files. +// The addon links the system libfuse 3 at runtime rather than carrying a +// copy, so the image that runs the binary needs libfuse 3.17 or newer +// (the `fuse3` package on Debian trixie). Set COMPUTERD_FUSE_ADDON to the +// path of a prebuilt fuse.node to skip the container build. import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { createWriteStream } from "node:fs"; -import { chmod, copyFile, mkdir, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; @@ -30,34 +32,22 @@ 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"); const nodeVersion = "v22.22.3"; +// The addon is built against the same Node major as the SEA runtime and +// the same Debian release as the container images, so the libfuse it +// links is the one that is present when the binary runs. +const addonBuildImage = "node:22-trixie-slim"; +const minimumLibfuse = [3, 17]; + const targets = [ { 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, - }, - { - 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, + dockerPlatform: "linux/amd64", }, ]; @@ -82,7 +72,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, @@ -95,8 +86,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, @@ -110,13 +100,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, @@ -126,11 +109,104 @@ async function buildTarget(target) { "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2", "--overwrite", ]; - if (target.isMac) postjectArgs.push("--macho-segment-name", "NODE_SEA"); await execFileP(process.execPath, postjectArgs); +} - if (target.isMac) { - await maybe(execFileP("codesign", ["--sign", "-", outBin])); +// Compile fuse-napi for the target inside a container, so the result +// does not depend on the build host's architecture or on its libfuse. +// CI runners carry an older libfuse 3 than the 3.17 that passthrough +// needs, and a developer machine may not be x64 at all. +// +// The result is cached under dist/sea, keyed on the addon sources and +// the build image, 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 stampPath = resolve(outDir, "fuse.node.key"); + const key = await addonCacheKey(target); + if ((await exists(addonPath)) && (await readText(stampPath)) === 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}`); + + const script = [ + "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", + // The image ships the Node headers, so node-gyp does not download them. + "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", + ].join("\n"); + + // Detached, then waited on, rather than an attached `docker run`: an + // attached client can lose its stream on a remote daemon and return + // before the container finishes, which would read as a missing addon. + const { stdout: containerId } = await execFileP("docker", [ + "run", + "-d", + "--platform", + target.dockerPlatform, + "-v", + `${addonSourceDir}:/src:ro`, + "-v", + `${outDir}:/out`, + addonBuildImage, + "sh", + "-c", + script, + ]); + const id = containerId.trim(); + try { + const { stdout: status } = await execFileP("docker", ["wait", id]); + if (status.trim() !== "0") { + const { stdout, stderr } = await execFileP("docker", ["logs", id], { + maxBuffer: 64 * 1024 * 1024, + }); + throw new Error(`fuse-napi build exited ${status.trim()}:\n${stdout}${stderr}`); + } + } finally { + await execFileP("docker", ["rm", "-f", id]).catch(() => {}); + } + + const libfuseVersion = (await readText(resolve(outDir, "libfuse-version"))).trim(); + const [major = 0, minor = 0] = libfuseVersion.split(".").map(Number); + if (major < minimumLibfuse[0] || (major === minimumLibfuse[0] && minor < minimumLibfuse[1])) { + throw new Error( + `${addonBuildImage} carries libfuse ${libfuseVersion}; passthrough needs ` + + `${minimumLibfuse.join(".")} or newer`, + ); + } + await writeFile(stampPath, key); + return addonPath; +} + +async function addonCacheKey(target) { + const hash = createHash("sha256").update(`${addonBuildImage}\n${target.dockerPlatform}\n`); + for (const file of ["fuse-native.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 ""; } } @@ -165,14 +241,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 e6cb03419..cc6f31f26 100644 --- a/packages/computerd/scripts/sea/bundle.mjs +++ b/packages/computerd/scripts/sea/bundle.mjs @@ -5,16 +5,11 @@ // capnweb's top-level await survives without an async-IIFE wrapper. A banner // re-establishes `require`, `__filename`, and `__dirname` for any CJS code // 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. +// * fuse-napi loads its addon through `require('node-gyp-build')(root)`. An +// esbuild plugin replaces that module with a shim that reads the addon +// from the SEA assets (via `node:sea`), writes it to a temp directory +// keyed by its sha256, and dlopens it. The addon links the system +// libfuse 3, which the dynamic loader finds on its default path. import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,7 +19,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 +27,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 8d2341e27..32aa2428e 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.cjs"); 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/); }); From 6fba45b4274a2605c52038007b88fee16adbb30c Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:25:51 +0000 Subject: [PATCH 05/28] computerd: Make the libfuse 3 mount work with fuse-napi fuse-napi takes typed mount options, not a raw -o list. buildFuseOptionString becomes buildFuseMountOptions. COMPUTERD_FUSE_EXTRA_OPTS keeps the libfuse spelling operators know and is translated entry by entry. ac_attr_timeout is gone: it only tunes auto_cache, and the binding refuses it without auto_cache. A new test runs the profile through the binding's validator. The binding exposed neither use_ino nor negative_timeout, so the vendored copy gains both. use_ino is a fuse_config field in FUSE 3 that fuse_new() rejects on the command line. PATCHES.md records the change. toBindingOps adapts the op table. It drops `error`, `init` (mutually exclusive with initWithConfig), and getBufferStats, all of which the binding refuses. It also swaps utimens for utimensWithTimespec. Plain utimens answers EOPNOTSUPP for UTIME_NOW and UTIME_OMIT, which is what a bare `touch` sends. Omitted times are now read back and kept so `touch -a` and `touch -m` work. fsync and fsyncdir take (path, datasync, fd) in fuse-napi. With the old order the boolean landed where the handle should be, and fsync on a local-only file leaked into the VFS. listxattr must return an array of names rather than a buffer. libfuse 3 negotiates atomic O_TRUNC by default, so `: > f` arrives as open(O_TRUNC) with no separate truncate. open now honors the flag; the old bytes survived before. --- .../computerd/src/fuse/binding-ops.test.ts | 120 +++++++++++++ packages/computerd/src/fuse/binding-ops.ts | 85 +++++++++ packages/computerd/src/fuse/driver.test.ts | 75 ++++++-- packages/computerd/src/fuse/driver.ts | 91 ++++++---- packages/computerd/src/fuse/options.test.ts | 164 ++++++++++-------- packages/computerd/src/fuse/options.ts | 106 ++++++----- .../computerd/src/fuse/passthrough.test.ts | 19 +- packages/computerd/src/fuse/passthrough.ts | 6 +- .../computerd/vendor/fuse-napi/PATCHES.md | 11 +- .../computerd/vendor/fuse-napi/fuse-native.c | 9 +- .../computerd/vendor/fuse-napi/index.d.ts | 8 + packages/computerd/vendor/fuse-napi/index.js | 12 +- 12 files changed, 535 insertions(+), 171 deletions(-) create mode 100644 packages/computerd/src/fuse/binding-ops.test.ts create mode 100644 packages/computerd/src/fuse/binding-ops.ts 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..26aa22add --- /dev/null +++ b/packages/computerd/src/fuse/binding-ops.test.ts @@ -0,0 +1,120 @@ +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", () => { + // `error` is not an operation it knows, `init` is mutually exclusive + // with the initWithConfig the mount adds, and getBufferStats is not + // an operation at all. + 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", () => { + // Plain utimens in fuse-napi answers EOPNOTSUPP whenever the kernel + // sends UTIME_NOW or UTIME_OMIT, which is what a bare `touch` does. + 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..24d0add22 --- /dev/null +++ b/packages/computerd/src/fuse/binding-ops.ts @@ -0,0 +1,85 @@ +// Adapts computerd's op table to what the vendored fuse-napi binding +// accepts. Kept apart from makeFUSEOps so the VFS driver and the +// local-only layer keep one simple shape, and the binding's rules live +// in one place. + +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; +} + +export interface BindingOpsOptions { + /** Clock for UTIME_NOW. Injected for tests. */ + readonly now?: () => number; +} + +/** + * Returns the op table to hand to `new Fuse()`. + * + * fuse-napi validates the table before it mounts, and refuses three of + * our entries: `error` is not an operation it knows, `init` is mutually + * exclusive with the `initWithConfig` the mount adds, and + * `getBufferStats` is not an operation at all. + * + * `utimens` is swapped for `utimensWithTimespec`. Plain `utimens` in + * fuse-napi answers EOPNOTSUPP when either time is UTIME_NOW or + * UTIME_OMIT, and that is what `touch`, `touch -m`, and most archive + * tools send. + */ +export function toBindingOps( + ops: FuseOps, + options: BindingOpsOptions = {}, +): Record { + const now = options.now ?? Date.now; + const { getBufferStats: _stats, error: _error, init: _init, utimens, ...rest } = ops; + + const utimensWithTimespec = ( + path: string, + atime: Timespec, + mtime: Timespec, + cb: (code: number) => void, + ): void => { + const omitAtime = atime.nanoseconds === UTIME_OMIT; + const omitMtime = mtime.nanoseconds === UTIME_OMIT; + if (omitAtime && omitMtime) { + cb(0); + return; + } + + const apply = (currentAtime: number, currentMtime: number): void => { + const resolve = (time: Timespec, current: number): number => { + if (time.nanoseconds === UTIME_OMIT) return current; + if (time.nanoseconds === UTIME_NOW) return now(); + return toMilliseconds(time); + }; + utimens.call(ops, path, resolve(atime, currentAtime), resolve(mtime, currentMtime), cb); + }; + + if (!omitAtime && !omitMtime) { + apply(0, 0); + return; + } + + // Only one side is changing. Keep the other as it is by reading it + // back first, since our utimens always sets both. + ops.getattr(path, (code, stat) => { + if (code !== 0 || stat === null) { + cb(code !== 0 ? code : -2); + return; + } + apply(stat.atime.getTime(), stat.mtime.getTime()); + }); + }; + + return { ...rest, utimensWithTimespec }; +} + +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 4d6a30696..47124ad49 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.js"; @@ -150,8 +152,10 @@ 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", handleOf(create.result), cb))).toBe(0); - expect(await status((cb) => ops.fsync("/dir/file.txt", handleOf(create.result), 0, cb))).toBe(0); - expect(await status((cb) => ops.fsyncdir("/dir", rootDir.result as number, 0, 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) => @@ -161,8 +165,8 @@ 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); + // fuse-napi wants the attribute names as an array of strings. + 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); @@ -304,7 +308,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 @@ -316,6 +320,53 @@ test("FUSE write is visible through the backing VFS after release", async () => ); }); +test("FUSE open with O_TRUNC empties the file", async () => { + // libfuse 3 negotiates atomic O_TRUNC by default, so `: > f` arrives + // as open(O_TRUNC) with no separate truncate. Ignoring the flag + // leaves the old bytes in place. + 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 @@ -336,12 +387,12 @@ test("FUSE truncate is visible through the backing VFS after fsync", async () => 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"); @@ -368,7 +419,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. @@ -401,7 +452,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), ); @@ -473,7 +524,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); @@ -1052,10 +1103,10 @@ 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); }); diff --git a/packages/computerd/src/fuse/driver.ts b/packages/computerd/src/fuse/driver.ts index a05a64a8a..10bef8942 100644 --- a/packages/computerd/src/fuse/driver.ts +++ b/packages/computerd/src/fuse/driver.ts @@ -1,7 +1,8 @@ -import { writeFileSync as nodeWriteFileSync } from "node:fs"; +import { constants as fsConstants, writeFileSync as nodeWriteFileSync } from "node:fs"; import { posix } from "node:path"; import type { FUSEBackend } from "./backend.js"; -import { buildFuseInitConfig, buildFuseOptionString } from "./options.js"; +import { toBindingOps } from "./binding-ops.js"; +import { buildFuseInitConfig, buildFuseMountOptions } from "./options.js"; import { type LocalPassthroughOptions, type PassthroughStats, @@ -35,6 +36,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. @@ -113,8 +115,9 @@ 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; + // fuse-napi puts datasync before the handle, unlike fuse-native. + 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; @@ -127,7 +130,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; @@ -517,6 +520,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); @@ -572,27 +598,21 @@ 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) { - // 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); + open(path, flags, cb) { + // libfuse 3 negotiates atomic O_TRUNC by default, so the kernel + // folds `: > f` into this open rather than sending a separate + // truncate. Honor it here or the old bytes survive. + 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) { @@ -936,13 +956,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); }, @@ -975,7 +995,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) { @@ -1058,15 +1078,15 @@ export async function mountFuse(options: { ? undefined : withLocalPassthrough(baseOps, options.localPaths); const routedOps = localPaths === undefined ? baseOps : localPaths.ops; - const { getBufferStats: _getBufferStats, ...fuseOps } = routedOps; + const fuseOps = toBindingOps(routedOps); const ops = tracer === undefined ? fuseOps : wrapFuseOpsWithTracer(fuseOps as unknown as Record, tracer); - // buildFuseOptionString reads COMPUTERD_FUSE_* env vars; with none set it + // buildFuseMountOptions reads COMPUTERD_FUSE_* env vars; with none set it // emits the production-safe profile (use_ino, 512 KiB max_read, and // one-second metadata timeouts). - const extraOpts = buildFuseOptionString(process.env); + const mountOptions = buildFuseMountOptions(process.env); // libfuse 3 takes max_write through the init config rather than as a // mount option, and the passthrough capability has to be requested at // init or the backing-file ioctl fails with EPERM. @@ -1076,7 +1096,10 @@ export async function mountFuse(options: { const opsWithInit = { ...ops, - initWithConfig(info: { capable: number; want: number }, cb: (errno: number, config?: unknown) => void) { + initWithConfig( + info: { capable: number; want: number }, + cb: (errno: number, config?: unknown) => void, + ) { const capable = info.capable >>> 0; passthroughNegotiated = (capable & CAP_PASSTHROUGH) !== 0; const config: Record = { maxWrite: initConfig.maxWrite }; @@ -1089,9 +1112,9 @@ export async function mountFuse(options: { }; const fuse = new Fuse(options.mountPoint, opsWithInit, { + ...mountOptions, autoUnmount: true, debug: false, - options: extraOpts.split(","), }) as FuseInstance; const emitTrace = (reason: string): void => { @@ -1142,7 +1165,7 @@ export async function mountFuse(options: { }); }); }, - getBufferStats: _getBufferStats, + getBufferStats: routedOps.getBufferStats, ...(localPaths === undefined ? {} : { getLocalPathStats: localPaths.stats }), }; } diff --git a/packages/computerd/src/fuse/options.test.ts b/packages/computerd/src/fuse/options.test.ts index eaff4c77b..8ce71bc41 100644 --- a/packages/computerd/src/fuse/options.test.ts +++ b/packages/computerd/src/fuse/options.test.ts @@ -1,29 +1,33 @@ import { describe, expect, test } from "vitest"; -import { buildFuseInitConfig, buildFuseOptionString, type FuseOptionEnv } from "./options.js"; +import { buildFuseInitConfig, buildFuseMountOptions, type FuseOptionEnv } from "./options.js"; const empty: FuseOptionEnv = {}; -describe("buildFuseOptionString", () => { +describe("buildFuseMountOptions", () => { test("emits the production-safe profile when no env vars are set", () => { - // 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. + // attr_timeout and entry_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 hard links stat as the same inode. // // No big_writes (libfuse 3 removed it, and batching is the default), // no max_write (an init field now), and no auto_cache: the keep-cache // decision moved into the driver so it can be skipped for a // passthrough handle, which the kernel refuses to combine with it. - expect(buildFuseOptionString(empty)).toBe( - "use_ino,max_read=524288,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 option string", () => { + test("max_write moves to the init config, not the mount options", () => { // libfuse 3 fails the mount if max_write arrives as a mount option. - expect(buildFuseOptionString(empty)).not.toContain("max_write"); + expect(buildFuseMountOptions(empty)).not.toHaveProperty("maxWrite"); expect(buildFuseInitConfig(empty).maxWrite).toBe(524288); }); @@ -38,21 +42,20 @@ describe("buildFuseOptionString", () => { COMPUTERD_FUSE_MAX_READ: "1048576", COMPUTERD_FUSE_MAX_WRITE: "1048576", }; - expect(buildFuseOptionString(env)).toContain("max_read=1048576"); + 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", () => { - expect(buildFuseOptionString({ COMPUTERD_FUSE_MAX_READ: "0" })).toContain("max_read=524288"); + expect(buildFuseMountOptions({ COMPUTERD_FUSE_MAX_READ: "0" }).maxRead).toBe(524288); expect(buildFuseInitConfig({ COMPUTERD_FUSE_MAX_WRITE: "-1" }).maxWrite).toBe(524288); }); - test("never emits auto_cache or kernel_cache", () => { + test("never sets auto_cache or kernel_cache", () => { // Both are incompatible with passthrough: libfuse applies keep_cache // after the open callback returns without checking, and the kernel // refuses FOPEN_KEEP_CACHE alongside FOPEN_PASSTHROUGH with EIO. The @@ -62,95 +65,110 @@ describe("buildFuseOptionString", () => { { COMPUTERD_FUSE_EXTRA_OPTS: "auto_cache" }, { COMPUTERD_FUSE_EXTRA_OPTS: "kernel_cache" }, ]) { - const out = buildFuseOptionString(env); - expect(out).not.toContain("auto_cache"); - expect(out).not.toContain("kernel_cache"); + const out = buildFuseMountOptions(env); + expect(out).not.toHaveProperty("autoCache"); + expect(out).not.toHaveProperty("kernelCache"); } }); - test("strips options libfuse 3 would fail the mount on", () => { + test("strips options that would fail the mount", () => { // A typo in EXTRA_OPTS should not take the daemon down at startup. - const out = buildFuseOptionString({ - COMPUTERD_FUSE_EXTRA_OPTS: "big_writes,max_write=4096,writeback_cache,allow_other", + // ac_attr_timeout is refused by the binding unless auto_cache is on, + // and auto_cache is never on. + const out = buildFuseMountOptions({ + COMPUTERD_FUSE_EXTRA_OPTS: + "big_writes,max_write=4096,writeback_cache,ac_attr_timeout=3,allow_other", }); - expect(out).not.toContain("big_writes"); - expect(out).not.toContain("max_write"); - expect(out).not.toContain("writeback_cache"); + 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"); // ...but anything legitimate still passes through. - expect(out).toContain("allow_other"); + expect(out.allowOther).toBe(true); }); - test("drops metadata timeouts when explicitly disabled with zero", () => { - 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: "", + COMPUTERD_FUSE_NEGATIVE_TIMEOUT: "", }); - expect(off).not.toContain("attr_timeout"); - expect(off).not.toContain("entry_timeout"); - expect(off).not.toContain("ac_attr_timeout"); + 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", () => { + // The binding takes typed options rather than a raw -o string, so a + // libfuse spelling is translated: a bare flag becomes true, a number + // stays a number, and anything else is passed as a string. + 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", () => { + // The binding validates its options object before it mounts, and an + // unknown or conflicting name throws there rather than at libfuse. + // Unit tests never mount, so this is the check that keeps the profile + // and the binding in step. + 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(); + } }); }); diff --git a/packages/computerd/src/fuse/options.ts b/packages/computerd/src/fuse/options.ts index d1cce01e3..b483d9ff8 100644 --- a/packages/computerd/src/fuse/options.ts +++ b/packages/computerd/src/fuse/options.ts @@ -1,25 +1,27 @@ // FUSE mount option assembly, for libfuse 3. // // Split into two parts because libfuse 3 moved some of these out of the -// mount option string and into the init config: +// mount options and into the init config: // -// - buildFuseOptionString() emits what still belongs on the command -// line: use_ino, max_read, and the metadata timeouts. +// - buildFuseMountOptions() emits what still belongs on the mount: +// use_ino, max_read, and the metadata timeouts. The vendored +// fuse-napi binding takes these as a typed object, not a raw -o +// string, and validates every name before it mounts. // - buildFuseInitConfig() emits max_write, which libfuse 3 only // accepts through fuse_conn_info at init. // // The defaults are the production-safe profile derived from the // benchmark report and the mtime-propagation contract tests. -// 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. -// 512 KiB max_read and max_write match the dofs CHUNK_SIZE so a single -// FUSE read maps to a single chunk fetch. +// attr_timeout and entry_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 hard links to stat as the same inode. 512 KiB max_read +// and max_write match the dofs CHUNK_SIZE so a single FUSE read maps to +// a single chunk fetch. // -// Two options the libfuse 2.9 build set are deliberately gone: +// Three options the libfuse 2.9 build set are deliberately gone: // // - big_writes. libfuse 3 removed it; batching up to max_write is the // default there, which is what the option used to buy. @@ -27,13 +29,15 @@ // returns and does not check for passthrough, and the kernel // refuses FOPEN_PASSTHROUGH combined with FOPEN_KEEP_CACHE // (fs/fuse/iomode.c) with EIO. The driver now decides keepCache per -// open instead, applying auto_cache's own rule — reuse the cache -// when mtime and size are unchanged since the last open — for +// open instead, applying auto_cache's own rule (reuse the cache +// when mtime and size are unchanged since the last open) for // VFS-backed files only. See resolveKeepCache in driver.ts. +// - ac_attr_timeout. It only tunes auto_cache, and the binding refuses +// it without auto_cache. // // 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. +// Setting an option to "" turns it off; a valid value overrides the +// default. // 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 @@ -42,7 +46,6 @@ const DEFAULT_MAX_READ = 524288; const DEFAULT_MAX_WRITE = 524288; const DEFAULT_ATTR_TIMEOUT = "1"; const DEFAULT_ENTRY_TIMEOUT = "1"; -const DEFAULT_AC_ATTR_TIMEOUT = "1"; const DEFAULT_NEGATIVE_TIMEOUT = "0"; // Options that must never reach the mount. @@ -52,13 +55,16 @@ const DEFAULT_NEGATIVE_TIMEOUT = "0"; // - auto_cache / kernel_cache: incompatible with passthrough, and the // driver now owns the keep-cache decision per open. // - writeback_cache: possible under libfuse 3 but it changes -// behaviour for the synced mount; a separate change. +// behavior for the synced mount; a separate change. +// - ac_attr_timeout: tunes auto_cache only, and the binding refuses it +// without auto_cache. const DISALLOWED_OPTS = new Set([ "big_writes", "max_write", "auto_cache", "kernel_cache", "writeback_cache", + "ac_attr_timeout", ]); export interface FuseOptionEnv { @@ -67,10 +73,15 @@ export interface FuseOptionEnv { 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; } +/** + * Mount options in the binding's own spelling: camelCase names, a bare + * flag as `true`, numbers as numbers. + */ +export type FuseMountOptions = Readonly>; + /** The subset of the libfuse 3 init config computerd sets. */ export interface FuseInitConfig { /** Maximum bytes per write request. Mount option in 2.9, init in 3. */ @@ -96,37 +107,52 @@ export function buildFuseInitConfig(env: FuseOptionEnv): FuseInitConfig { } /** - * Build the comma-separated mount option string. Pure function over an + * Build the mount options handed to the binding. Pure function over an * env-like object, so tests can drive it directly. */ -export function buildFuseOptionString(env: FuseOptionEnv): string { - const opts: string[] = ["use_ino"]; - - const maxRead = parsePositiveInt(env.COMPUTERD_FUSE_MAX_READ) ?? DEFAULT_MAX_READ; - opts.push(`max_read=${maxRead}`); +export function buildFuseMountOptions(env: FuseOptionEnv): FuseMountOptions { + const opts: Record = { + useIno: true, + maxRead: parsePositiveInt(env.COMPUTERD_FUSE_MAX_READ) ?? DEFAULT_MAX_READ, + }; - 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( + setTimeoutOption(opts, "attrTimeout", env.COMPUTERD_FUSE_ATTR_TIMEOUT, DEFAULT_ATTR_TIMEOUT); + setTimeoutOption(opts, "entryTimeout", env.COMPUTERD_FUSE_ENTRY_TIMEOUT, DEFAULT_ENTRY_TIMEOUT); + setTimeoutOption( opts, - "negative_timeout", + "negativeTimeout", env.COMPUTERD_FUSE_NEGATIVE_TIMEOUT, DEFAULT_NEGATIVE_TIMEOUT, ); - pushTimeout(opts, "ac_attr_timeout", env.COMPUTERD_FUSE_AC_ATTR_TIMEOUT, DEFAULT_AC_ATTR_TIMEOUT); + // EXTRA_OPTS keeps the libfuse spelling operators already know + // ("allow_other,fsname=x"). Each entry is translated to the binding's + // name, so it overrides a default instead of conflicting with it. An + // unknown name still fails the mount, in the binding, with its name in + // the error. 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 (head !== undefined && DISALLOWED_OPTS.has(head)) continue; - opts.push(trimmed); + const separator = trimmed.indexOf("="); + const name = separator === -1 ? trimmed : trimmed.slice(0, separator); + if (DISALLOWED_OPTS.has(name)) continue; + const raw = separator === -1 ? undefined : trimmed.slice(separator + 1); + opts[toCamelCase(name)] = raw === undefined ? true : parseOptionValue(raw); } } - return opts.join(","); + return opts; +} + +function toCamelCase(name: string): string { + return name.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()); +} + +function parseOptionValue(raw: string): number | string { + const n = Number(raw); + return raw.trim() !== "" && Number.isFinite(n) ? n : raw; } function parsePositiveInt(value: string | undefined): number | undefined { @@ -143,20 +169,16 @@ function parseNonNegativeNumber(value: string | undefined): number | undefined { return n; } -function pushTimeout( - opts: string[], +function setTimeoutOption( + opts: Record, name: string, raw: string | undefined, - fallback?: string, + 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}`); + opts[name] = n; } diff --git a/packages/computerd/src/fuse/passthrough.test.ts b/packages/computerd/src/fuse/passthrough.test.ts index 212f55d31..07b7c788d 100644 --- a/packages/computerd/src/fuse/passthrough.test.ts +++ b/packages/computerd/src/fuse/passthrough.test.ts @@ -126,6 +126,21 @@ 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). With the arguments + // swapped, the boolean lands where the handle should be, misses + // the local handle range, and the call leaks into the VFS. + 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)); @@ -600,8 +615,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"]); }); diff --git a/packages/computerd/src/fuse/passthrough.ts b/packages/computerd/src/fuse/passthrough.ts index 3177a4d22..73683d97c 100644 --- a/packages/computerd/src/fuse/passthrough.ts +++ b/packages/computerd/src/fuse/passthrough.ts @@ -422,9 +422,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); @@ -434,7 +434,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) { diff --git a/packages/computerd/vendor/fuse-napi/PATCHES.md b/packages/computerd/vendor/fuse-napi/PATCHES.md index 52454e23b..c73a46a3d 100644 --- a/packages/computerd/vendor/fuse-napi/PATCHES.md +++ b/packages/computerd/vendor/fuse-napi/PATCHES.md @@ -14,7 +14,7 @@ it is a much shorter path to passthrough than patching libfuse 2. ## What we changed -All changes are in `fuse-native.c` and `index.js`. +All changes are in `fuse-native.c`, `index.js`, and `index.d.ts`. ### `fuse-native.c` @@ -49,6 +49,15 @@ All changes are in `fuse-native.c` and `index.js`. 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`. ## Verified on this host diff --git a/packages/computerd/vendor/fuse-napi/fuse-native.c b/packages/computerd/vendor/fuse-napi/fuse-native.c index 1802055dc..52ca92e13 100644 --- a/packages/computerd/vendor/fuse-napi/fuse-native.c +++ b/packages/computerd/vendor/fuse-napi/fuse-native.c @@ -303,10 +303,11 @@ static const uint32_t op_lseek = 44; #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_POLL_HANDLE | FUSE_OPERATION_FLAG_USE_INO) // Data structures @@ -2543,6 +2544,12 @@ static void * fuse_native_init ( (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; diff --git a/packages/computerd/vendor/fuse-napi/index.d.ts b/packages/computerd/vendor/fuse-napi/index.d.ts index 604898a94..0315cb334 100644 --- a/packages/computerd/vendor/fuse-napi/index.d.ts +++ b/packages/computerd/vendor/fuse-napi/index.d.ts @@ -514,6 +514,14 @@ declare namespace Fuse { 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; diff --git a/packages/computerd/vendor/fuse-napi/index.js b/packages/computerd/vendor/fuse-napi/index.js index 09fd043a3..8fecc06c7 100644 --- a/packages/computerd/vendor/fuse-napi/index.js +++ b/packages/computerd/vendor/fuse-napi/index.js @@ -36,6 +36,7 @@ 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) @@ -234,6 +235,8 @@ const OPTION_ALIASES = new Map([ ['auto_cache', 'autoCache'], ['entry_timeout', 'entryTimeout'], ['attr_timeout', 'attrTimeout'], + ['negative_timeout', 'negativeTimeout'], + ['use_ino', 'useIno'], ['ac_attr_timeout', 'acAttrTimeout'], ['nonempty', 'nonEmpty'], ['direct_io', 'directIo'], @@ -245,7 +248,8 @@ const KNOWN_OPTIONS = new Set([ 'blksize', 'maxRead', 'nonEmpty', 'fd', 'userId', 'fsname', 'subtype', 'kernelCache', 'autoCache', 'umask', 'entryTimeout', 'attrTimeout', 'acAttrTimeout', 'noforget', 'remember', 'modules', 'name', 'onError', - 'maxConcurrency', 'nullPathOk', 'noPath', 'directIo' + 'maxConcurrency', 'nullPathOk', 'noPath', 'directIo', 'useIno', + 'negativeTimeout' ]) const pollHandleFinalizer = new FinalizationRegistry(state => { @@ -399,6 +403,7 @@ class Fuse extends Nanoresource { ? 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 @@ -457,6 +462,7 @@ class Fuse extends Nanoresource { 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)) @@ -2007,9 +2013,9 @@ function validateOptions (opts) { const booleanOptions = [ 'displayFolder', 'debug', 'force', 'mkdir', 'allowOther', 'allowRoot', 'autoUnmount', 'defaultPermissions', 'blkdev', 'kernelCache', 'autoCache', - 'noforget', 'nonEmpty', 'nullPathOk', 'noPath', 'directIo' + 'noforget', 'nonEmpty', 'nullPathOk', 'noPath', 'directIo', 'useIno' ] - const numberOptions = ['entryTimeout', 'attrTimeout', 'acAttrTimeout'] + const numberOptions = ['entryTimeout', 'attrTimeout', 'acAttrTimeout', 'negativeTimeout'] const stringOptions = ['fsname', 'subtype', 'modules', 'name'] for (const name of Reflect.ownKeys(opts)) { From c5c6ba3ab52b1999a8853d67cb3960f0a4c0a04b Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:31:15 +0000 Subject: [PATCH 06/28] computerd: Hand local-only files to the kernel with FUSE passthrough An open or create of a local-only file now registers the file with the kernel and replies with its backing id. The kernel then serves reads, writes, and mmap for that handle straight from the backing file, and computerd is out of the data path. Writing 8 MiB and reading it back twice under an ignored path cost the daemon 16 writes and 64 reads before; it now costs none. The kernel allows one backing file per inode, and a second registration of the same file does not fail. It returns a new id, and the open that uses it fails with EIO. So the local-only layer keeps one id per file, keyed on dev:ino so a hard link shares it, counts the opens using it, and closes it on the last release. If registration fails, the file is served by computerd as before and the first failure is logged with a hint for the usual causes: EPERM without CAP_SYS_ADMIN, ELOOP on a stacked backing store, and ENOTSUP when the kernel did not offer passthrough. Fallbacks are counted rather than logged after that. A passthrough reply never carries keepCache or directIO, which the kernel refuses alongside a backing id. mountFuse passes the layer a late-bound registrar. The op table has to exist before the Fuse instance, so the closure reads the instance assigned after construction and answers ENOTSUP until init confirms the capability. The capability is only requested when there are local-only paths, so a mount without MOUNT_IGNORE negotiates what it did before, and COMPUTERD_FUSE_PASSTHROUGH=0 turns it off for debugging. --- packages/computerd/src/fuse/driver.ts | 51 ++++- packages/computerd/src/fuse/options.test.ts | 25 ++- packages/computerd/src/fuse/options.ts | 13 ++ .../computerd/src/fuse/passthrough.test.ts | 207 +++++++++++++++++- packages/computerd/src/fuse/passthrough.ts | 152 +++++++++++-- 5 files changed, 427 insertions(+), 21 deletions(-) diff --git a/packages/computerd/src/fuse/driver.ts b/packages/computerd/src/fuse/driver.ts index 10bef8942..0fb5d9e22 100644 --- a/packages/computerd/src/fuse/driver.ts +++ b/packages/computerd/src/fuse/driver.ts @@ -2,9 +2,10 @@ import { constants as fsConstants, writeFileSync as nodeWriteFileSync } from "no import { posix } from "node:path"; import type { FUSEBackend } from "./backend.js"; import { toBindingOps } from "./binding-ops.js"; -import { buildFuseInitConfig, buildFuseMountOptions } from "./options.js"; +import { buildFuseInitConfig, buildFuseMountOptions, passthroughRequested } from "./options.js"; import { type LocalPassthroughOptions, + type PassthroughRegistrar, type PassthroughStats, withLocalPassthrough, } from "./passthrough.js"; @@ -178,6 +179,12 @@ interface FuseInstance { unmount(cb: (error: Error | null) => void): void; } +/** The binding's backing-file registration, from the vendored patch. */ +interface BackingRegistrar { + backingOpen(fd: number): number; + backingClose(backingId: number): void; +} + export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseOps { // The kernel hands us paths relative to the mount root ("/", // "/repo/a.txt"). The backing VFS stores them under `mountPoint` @@ -1070,13 +1077,47 @@ export async function mountFuse(options: { const traceMode = process.env.COMPUTERD_FUSE_TRACE; const tracer: FuseTracer | undefined = traceMode === "summary" ? createFuseTracer() : undefined; const baseOps = makeFUSEOps(options.vfs, options.mountPoint); + + // Kernel passthrough for local-only files. Only requested when there + // are local-only paths to use it on, so a mount without MOUNT_IGNORE + // negotiates exactly what it did before. + const wantPassthrough = + options.localPaths !== undefined && + !options.localPaths.ignore.isEmpty && + passthroughRequested(process.env); + let passthroughNegotiated = false; + + // The registrar is late-bound. The op table has to exist before the + // Fuse instance does, because the constructor takes it, so the + // closure reads `mountedFuse`, assigned just after construction. No + // open can arrive before init, and until init confirms the + // capability the answer is ENOTSUP, which the local-only layer turns + // into a fallback to serving the file 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 fuseOps = toBindingOps(routedOps); const ops = @@ -1092,7 +1133,6 @@ export async function mountFuse(options: { // init or the backing-file ioctl fails with EPERM. const initConfig = buildFuseInitConfig(process.env); const CAP_PASSTHROUGH: number = Fuse.CAP_PASSTHROUGH ?? 1 << 29; - let passthroughNegotiated = false; const opsWithInit = { ...ops, @@ -1101,7 +1141,7 @@ export async function mountFuse(options: { cb: (errno: number, config?: unknown) => void, ) { const capable = info.capable >>> 0; - passthroughNegotiated = (capable & CAP_PASSTHROUGH) !== 0; + passthroughNegotiated = wantPassthrough && (capable & CAP_PASSTHROUGH) !== 0; const config: Record = { maxWrite: initConfig.maxWrite }; if (passthroughNegotiated) { config.want = (info.want >>> 0) | CAP_PASSTHROUGH; @@ -1115,7 +1155,8 @@ export async function mountFuse(options: { ...mountOptions, autoUnmount: true, debug: false, - }) as FuseInstance; + }) as FuseInstance & BackingRegistrar; + mountedFuse = fuse; const emitTrace = (reason: string): void => { if (tracer === undefined) return; diff --git a/packages/computerd/src/fuse/options.test.ts b/packages/computerd/src/fuse/options.test.ts index 8ce71bc41..044c280f0 100644 --- a/packages/computerd/src/fuse/options.test.ts +++ b/packages/computerd/src/fuse/options.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "vitest"; -import { buildFuseInitConfig, buildFuseMountOptions, type FuseOptionEnv } from "./options.js"; +import { + buildFuseInitConfig, + buildFuseMountOptions, + type FuseOptionEnv, + passthroughRequested, +} from "./options.js"; const empty: FuseOptionEnv = {}; @@ -172,3 +177,21 @@ describe("the vendored binding", () => { } }); }); + +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); + } + }); +}); diff --git a/packages/computerd/src/fuse/options.ts b/packages/computerd/src/fuse/options.ts index b483d9ff8..7f90415b0 100644 --- a/packages/computerd/src/fuse/options.ts +++ b/packages/computerd/src/fuse/options.ts @@ -74,6 +74,7 @@ export interface FuseOptionEnv { COMPUTERD_FUSE_ENTRY_TIMEOUT?: string; COMPUTERD_FUSE_NEGATIVE_TIMEOUT?: string; COMPUTERD_FUSE_EXTRA_OPTS?: string; + COMPUTERD_FUSE_PASSTHROUGH?: string; } /** @@ -146,6 +147,18 @@ export function buildFuseMountOptions(env: FuseOptionEnv): FuseMountOptions { return opts; } +/** + * Whether to ask the kernel for FUSE passthrough on local-only paths. + * + * On unless COMPUTERD_FUSE_PASSTHROUGH says otherwise, as a switch for + * ruling passthrough in or out while chasing a problem. Only explicit + * negatives turn it off. + */ +export function passthroughRequested(env: FuseOptionEnv): boolean { + const value = env.COMPUTERD_FUSE_PASSTHROUGH?.trim().toLowerCase(); + return !(value === "0" || value === "false" || value === "no" || value === "off"); +} + function toCamelCase(name: string): string { return name.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()); } diff --git a/packages/computerd/src/fuse/passthrough.test.ts b/packages/computerd/src/fuse/passthrough.test.ts index 07b7c788d..02c1249ea 100644 --- a/packages/computerd/src/fuse/passthrough.test.ts +++ b/packages/computerd/src/fuse/passthrough.test.ts @@ -15,7 +15,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import type { FuseOps } from "./driver.js"; +import type { FuseOpenResult, FuseOps } from "./driver.js"; import { resolveMountIgnore } from "./ignore.js"; import { type PassthroughFs, withLocalPassthrough } from "./passthrough.js"; @@ -862,3 +862,208 @@ 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 }); + }); + + /** Stands in for the binding's backingOpen / backingClose. */ + 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", () => { + // The kernel refuses either combination with EIO at open time. + 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", () => { + // Two registrations of the same file both succeed and return + // distinct ids, and then the second open fails EIO in the kernel. + // There is no error to catch at registration, so reuse is the only + // correct behavior. + 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", () => { + // The driver's registrar answers ENOTSUP until init has negotiated + // the capability. Advice about CAP_SYS_ADMIN would mislead here. + 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"); + }); +}); diff --git a/packages/computerd/src/fuse/passthrough.ts b/packages/computerd/src/fuse/passthrough.ts index 73683d97c..b72db813d 100644 --- a/packages/computerd/src/fuse/passthrough.ts +++ b/packages/computerd/src/fuse/passthrough.ts @@ -6,17 +6,23 @@ // 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, an open of a local-only +// file registers the file with the kernel and replies with its backing +// id. The kernel then serves reads, writes, and mmap for that handle +// straight from the backing file, and this process is out of the data +// path. Metadata (lookup, getattr, readdir) still comes here. // -// 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. +// When passthrough is unavailable, for the mount or for one file, 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. +// +// Those fallback 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. import { accessSync, @@ -48,7 +54,7 @@ import { } from "node:fs"; import { dirname, join } from "node:path"; -import type { FuseOps, FuseStat } from "./driver.js"; +import type { FuseOpenResult, FuseOps, FuseStat } from "./driver.js"; import type { MountIgnoreSet } from "./ignore.js"; // Mirrors driver.ts. Duplicated rather than exported across modules @@ -84,6 +90,22 @@ export interface LocalPassthroughOptions { readonly onMaterialize?: (relativePath: string) => void; /** Operator-facing warnings. Defaults to console.warn; injected for tests. */ readonly warn?: (message: string) => void; + /** + * Registers backing files for kernel passthrough. Omitted means every + * local-only open is served by this process. + */ + readonly passthrough?: PassthroughRegistrar; +} + +/** + * The binding's backing-file registration, as a seam. + * + * `backingOpen` throws an Error whose `code` is the errno name when the + * kernel refuses, or when the mount did not negotiate passthrough. + */ +export interface PassthroughRegistrar { + backingOpen(fd: number): number; + backingClose(backingId: number): void; } /** @@ -157,6 +179,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 +210,9 @@ export function withLocalPassthrough( localOps: 0, openHandles: 0, crossLayerRenames: 0, + passthroughOpens: 0, + passthroughFallbacks: 0, + passthroughFiles: 0, }), }; } @@ -192,7 +223,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 +245,17 @@ 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(); + + // One backing id per file, shared by every open of it. The kernel + // allows a single backing file per inode: a second registration of + // the same file succeeds and returns a new id, and then the open that + // uses it fails with EIO. Nothing reports the conflict at + // registration, so the table is the only thing preventing it. + // + // Keyed on dev:ino rather than the path, because a hard link reaches + // the same inode through a different name. + const backing = new Map(); const isLocalHandle = (fh: number): boolean => fh >= LOCAL_HANDLE_BASE; const ensureParent = (target: string): void => { @@ -298,7 +342,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); } @@ -341,7 +385,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); } @@ -399,6 +443,8 @@ export function withLocalPassthrough( cb(0); } catch (error) { cb(toErrno(error)); + } finally { + releaseBacking(handle.backingKey); } }, @@ -715,6 +761,81 @@ export function withLocalPassthrough( return handle; } + // Attach a backing id to a freshly opened handle, reusing the file's + // existing id when it has one. Returns what open/create reply with. + // + // Never sets keepCache or directIO alongside the id: the kernel + // refuses either combination with EIO. + function registerOpen(fh: number): FuseOpenResult { + const handle = handles.get(fh); + if (registrar === undefined || handle === undefined) return fh; + + let key: string; + try { + const stat = fs.fstatSync(handle.fd); + key = `${stat.dev}:${stat.ino}`; + } catch { + return fh; + } + + const existing = backing.get(key); + if (existing !== undefined) { + existing.refs += 1; + handle.backingKey = key; + passthroughOpens += 1; + return { fd: fh, backingId: existing.id }; + } + + let id: number; + try { + id = registrar.backingOpen(handle.fd); + } catch (error) { + // Served by this process instead, through read/write below. A + // refusal is a property of the file or the mount (EPERM without + // CAP_SYS_ADMIN, ELOOP for a backing store stacked too deep), so + // every open of the same file falls back the same way and the + // kernel never sees a mix of the two modes on one inode. + reportFallback(handle.path, error); + return fh; + } + backing.set(key, { id, refs: 1 }); + handle.backingKey = key; + passthroughOpens += 1; + return { fd: fh, backingId: id }; + } + + function releaseBacking(key: string | undefined): void { + if (key === undefined || registrar === undefined) return; + const entry = backing.get(key); + if (entry === undefined) return; + entry.refs -= 1; + if (entry.refs > 0) return; + backing.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, @@ -797,6 +918,9 @@ export function withLocalPassthrough( localOps, openHandles: handles.size, crossLayerRenames, + passthroughOpens, + passthroughFallbacks, + passthroughFiles: backing.size, }), }; } From 8e148a7402c35590006cbc873f6d8513fd22ac3c Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:34:49 +0000 Subject: [PATCH 07/28] computerd: Report live passthrough state on /__computerd/info `fastPaths.passthrough` was a constant false with a reason naming libfuse 2.9. It now reports what the mount is doing, read on each request: `passthrough` is true once the kernel is serving local-only file data, and `passthroughOpens` and `passthroughFallbacks` count how opens were served. When passthrough is not active, `passthroughReason` says which of the cases applies, because each has a different fix: no local-only paths configured, no kernel FUSE mount running, turned off with COMPUTERD_FUSE_PASSTHROUGH, a kernel that never offered the capability, a kernel that offered it but refused every registration, and a mount that negotiated it but has not opened a local-only file yet. mountFuse exposes getPassthroughStatus on FuseMount for this, and the info object reads it through a getter. PASSTHROUGH_UNAVAILABLE_REASON is gone, along with the comments that still described the fuse-native behavior. --- packages/computerd/src/cli/computerd.test.ts | 13 ++- packages/computerd/src/cli/computerd.ts | 8 +- packages/computerd/src/fuse/driver.test.ts | 8 +- packages/computerd/src/fuse/driver.ts | 19 +++- .../computerd/src/fuse/ignore-config.test.ts | 59 ++++++++++-- packages/computerd/src/fuse/ignore-config.ts | 92 ++++++++++++++++--- packages/computerd/src/fuse/index.ts | 8 +- 7 files changed, 177 insertions(+), 30 deletions(-) diff --git a/packages/computerd/src/cli/computerd.test.ts b/packages/computerd/src/cli/computerd.test.ts index 1846daed6..56d003d85 100644 --- a/packages/computerd/src/cli/computerd.test.ts +++ b/packages/computerd/src/cli/computerd.test.ts @@ -111,7 +111,9 @@ 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, }, }, @@ -165,6 +167,15 @@ test("MOUNT_IGNORE keeps matching paths on local disk and out of the VFS", async "module.exports=1", ); + // The open is accounted for on /__computerd/info, read live: handed to + // the kernel where the host supports passthrough, served by computerd + // otherwise. Which one depends on the kernel and libfuse of the host + // running the test, 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 {}"); diff --git a/packages/computerd/src/cli/computerd.ts b/packages/computerd/src/cli/computerd.ts index 7ab8be657..81eeeb39a 100644 --- a/packages/computerd/src/cli/computerd.ts +++ b/packages/computerd/src/cli/computerd.ts @@ -682,15 +682,19 @@ 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 /__computerd/info request reports what the + // mount is doing now rather than a snapshot from startup. + get ignore() { + return describeMountIgnore(ignoreConfig, fuse?.getPassthroughStatus?.()); + }, }; - let fuse: FuseMount | 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 diff --git a/packages/computerd/src/fuse/driver.test.ts b/packages/computerd/src/fuse/driver.test.ts index 47124ad49..62f8a2509 100644 --- a/packages/computerd/src/fuse/driver.test.ts +++ b/packages/computerd/src/fuse/driver.test.ts @@ -19,7 +19,7 @@ function handleOf(result: unknown): number { return typeof result === "number" ? result : (result as { fd: number }).fd; } -const fuseNativeOperationNames = [ +const operationNames = [ "init", "error", "access", @@ -69,10 +69,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`); } }); @@ -1111,7 +1111,7 @@ test("xattr: getxattr for a VFS-backed file returns correct codes", async () => 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); diff --git a/packages/computerd/src/fuse/driver.ts b/packages/computerd/src/fuse/driver.ts index 0fb5d9e22..35178ff4c 100644 --- a/packages/computerd/src/fuse/driver.ts +++ b/packages/computerd/src/fuse/driver.ts @@ -2,6 +2,7 @@ import { constants as fsConstants, writeFileSync as nodeWriteFileSync } from "no import { posix } from "node:path"; 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, @@ -172,6 +173,9 @@ 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; + // Live passthrough state for /__computerd/info. Only present on a + // kernel FUSE mount. + getPassthroughStatus?: () => PassthroughStatus; } interface FuseInstance { @@ -978,9 +982,9 @@ 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. + // Always sets both. toBindingOps resolves UTIME_NOW and reads back + // the current value for UTIME_OMIT before calling this, so + // `touch -a` and `touch -m` arrive here with both times filled in. updateMeta(path, { atime: new Date(atime), mtime: new Date(mtime) }); cb(0); }, @@ -1208,6 +1212,15 @@ export async function mountFuse(options: { }, getBufferStats: routedOps.getBufferStats, ...(localPaths === undefined ? {} : { getLocalPathStats: localPaths.stats }), + 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/ignore-config.test.ts b/packages/computerd/src/fuse/ignore-config.test.ts index ea99ad610..8d5c5aa61 100644 --- a/packages/computerd/src/fuse/ignore-config.test.ts +++ b/packages/computerd/src/fuse/ignore-config.test.ts @@ -127,13 +127,58 @@ 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, + }); + }); + + // Each reason below has a different fix, so each gets its own text. + // An operator should be able to act on the string without reading + // the source. + + 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/); }); }); diff --git a/packages/computerd/src/fuse/ignore-config.ts b/packages/computerd/src/fuse/ignore-config.ts index b84bd0d81..83dcb9b66 100644 --- a/packages/computerd/src/fuse/ignore-config.ts +++ b/packages/computerd/src/fuse/ignore-config.ts @@ -69,6 +69,18 @@ export function resolveMountIgnoreConfig( return { root: normalizedRoot, ignore, enabled: !ignore.isEmpty }; } +/** What the mount negotiated and did with passthrough, read live. */ +export interface PassthroughStatus { + /** Asked for at init: local-only paths exist and it was not turned off. */ + readonly requested: boolean; + /** The kernel offered the capability and computerd took it. */ + readonly negotiated: boolean; + /** Opens handed to the kernel with a backing id. */ + readonly opens: number; + /** Opens served by computerd because registration failed. */ + readonly fallbacks: number; +} + /** The `ignore` block reported on /__computerd/info. */ export interface MountIgnoreInfo { readonly supported: true; @@ -79,22 +91,32 @@ export interface MountIgnoreInfo { readonly patterns: readonly string[]; readonly ineffectiveExclusions: readonly string[]; readonly fastPaths: { + /** True once the kernel is serving local-only file data directly. */ + readonly passthrough: boolean; /** - * 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. + * Why passthrough is or is not active. Each inactive case has a + * different fix, so each has its own wording. */ - readonly passthrough: false; readonly passthroughReason: string; - /** Also unavailable: libfuse 2.9 fails the mount on the option. */ + readonly passthroughOpens: number; + readonly passthroughFallbacks: number; + /** + * Off. libfuse 3 can negotiate it, but it changes write behavior for + * the synced mount, which is a separate decision. + */ readonly writebackCache: false; }; } -export const PASSTHROUGH_UNAVAILABLE_REASON = - "fuse-native binds libfuse 2.9; FOPEN_PASSTHROUGH requires the libfuse 3.17 API"; - -export function describeMountIgnore(config: MountIgnoreConfig): MountIgnoreInfo { +/** + * Builds the `ignore` block. `status` is the live passthrough state + * from the mount, or undefined when no kernel FUSE mount is running. + */ +export function describeMountIgnore( + config: MountIgnoreConfig, + status?: PassthroughStatus, +): MountIgnoreInfo { + const { passthrough, reason } = describePassthrough(config, status); return { supported: true, enabled: config.enabled, @@ -102,9 +124,57 @@ export function describeMountIgnore(config: MountIgnoreConfig): MountIgnoreInfo patterns: config.ignore.patterns, ineffectiveExclusions: config.ignore.ineffectiveExclusions, fastPaths: { - passthrough: false, - passthroughReason: PASSTHROUGH_UNAVAILABLE_REASON, + passthrough, + passthroughReason: reason, + passthroughOpens: status?.opens ?? 0, + passthroughFallbacks: status?.fallbacks ?? 0, writebackCache: false, }, }; } + +function describePassthrough( + config: MountIgnoreConfig, + status: PassthroughStatus | undefined, +): { passthrough: boolean; reason: string } { + if (!config.enabled) { + return { passthrough: false, reason: "no local-only paths are configured (MOUNT_IGNORE)" }; + } + if (status === undefined) { + return { + passthrough: false, + reason: "no kernel FUSE mount is running, so local-only paths are not passed through", + }; + } + if (!status.requested) { + return { passthrough: false, reason: "turned off with COMPUTERD_FUSE_PASSTHROUGH" }; + } + if (!status.negotiated) { + return { + passthrough: false, + reason: "the kernel did not offer FUSE passthrough at init; it needs Linux 6.9 or newer", + }; + } + if (status.opens === 0 && status.fallbacks > 0) { + return { + passthrough: false, + reason: + `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`, + }; + } + if (status.opens === 0) { + return { + passthrough: false, + reason: "negotiated, but no local-only file has been opened yet", + }; + } + return { + passthrough: true, + reason: + status.fallbacks === 0 + ? "active" + : `active; ${status.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 267e31e1d..dbd9e798d 100644 --- a/packages/computerd/src/fuse/index.ts +++ b/packages/computerd/src/fuse/index.ts @@ -4,11 +4,15 @@ export type { FuseMount, FuseOps, FuseStat } from "./driver.js"; export { makeFUSEOps, mountFuse } from "./driver.js"; export type { MountIgnoreSet } from "./ignore.js"; export { MountIgnorePathError, parseMountIgnore, resolveMountIgnore } from "./ignore.js"; -export type { MountIgnoreConfig, MountIgnoreEnv, MountIgnoreInfo } from "./ignore-config.js"; +export type { + MountIgnoreConfig, + MountIgnoreEnv, + MountIgnoreInfo, + PassthroughStatus, +} from "./ignore-config.js"; export { defaultIgnoreRoot, describeMountIgnore, - PASSTHROUGH_UNAVAILABLE_REASON, resolveMountIgnoreConfig, } from "./ignore-config.js"; export type { LocalPassthrough, LocalPassthroughOptions, PassthroughStats } from "./passthrough.js"; From 6aa78c0e70c09c35eca81d09841ffec23373ce2c Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:37:09 +0000 Subject: [PATCH 08/28] computerd: Test FUSE passthrough against a real mount The unit tests drive the backing-id table through a fake registrar so they cannot show the kernel's side. passthrough.fuse.test.ts boots the binary in a privileged container, as runner.fuse.test.ts does, and counts the daemon's read and write callbacks with the op tracer. It checks three things. Writing 4 MiB under an ignored path and reading it back twice costs the daemon no reads or writes, and /__computerd/info reports passthrough active. Concurrent opens of one file, and of a hard link to it, all succeed; a second backing id for one inode would make the kernel fail the open with EIO. And a synced file still keeps its page cache across opens now that auto_cache is gone from the mount options. The passthrough cases skip on a kernel that does not offer the capability, since that is a property of the host. run-computerd.sh now forwards MOUNT_IGNORE, MOUNT_IGNORE_PATH, and COMPUTERD_FUSE_* into the container so a test can set them. --- .../computer/test-harness/run-computerd.sh | 10 + .../src/fuse/passthrough.fuse.test.ts | 199 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 packages/computerd/src/fuse/passthrough.fuse.test.ts diff --git a/packages/computer/test-harness/run-computerd.sh b/packages/computer/test-harness/run-computerd.sh index 4e84cbda2..1ba9f82cd 100755 --- a/packages/computer/test-harness/run-computerd.sh +++ b/packages/computer/test-harness/run-computerd.sh @@ -48,6 +48,15 @@ RUN apt-get update >/dev/null \ DOCKERFILE fi +# Forward local-only path and FUSE tuning settings from the caller, so a +# test can boot computerd with MOUNT_IGNORE or COMPUTERD_FUSE_TRACE set. +FORWARD_ENV=() +while IFS='=' read -r name _; do + case "$name" in + MOUNT_IGNORE | MOUNT_IGNORE_PATH | 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 the FUSE binding # actually mount. @@ -62,6 +71,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/src/fuse/passthrough.fuse.test.ts b/packages/computerd/src/fuse/passthrough.fuse.test.ts new file mode 100644 index 000000000..cbb3df2ea --- /dev/null +++ b/packages/computerd/src/fuse/passthrough.fuse.test.ts @@ -0,0 +1,199 @@ +// Kernel FUSE passthrough for local-only paths, against a real mount. +// +// The unit tests drive the backing-id table through a fake registrar. +// What they cannot show is the kernel's side: that a passthrough open +// really takes computerd out of the data path, and that concurrent +// opens of one file do not trip the EIO the kernel gives a second +// backing id for the same inode. This boots the linux-x64 binary in a +// privileged container, the same way 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, +// rather than fail, when the host kernel does not offer passthrough +// (Linux before 6.9), since that is a property of the machine running +// the test. + +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 kernelOffersPassthrough = false; + + beforeAll(async () => { + const proc = spawn("bash", [RUN_COMPUTERD_SCRIPT], { + env: { + ...process.env, + COMPUTERD_HARNESS_PORT: "0", + MOUNT_IGNORE: "/node_modules", + 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, + }); + // Before any open the reason says whether the kernel offered the + // capability at all, or whether COMPUTERD_FUSE_PASSTHROUGH in the + // caller's environment turned it off. + kernelOffersPassthrough = !/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 (!kernelOffersPassthrough) 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`); + + // Through computerd this would be 8 writes and 16 or more reads at + // the 512 KiB request size. Passthrough makes it none. + 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) => { + // A second backing id for an inode that already has one makes the + // kernel fail the open with EIO. Every open here goes through the + // one shared id instead. + if (!kernelOffersPassthrough) 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 () => { + // auto_cache is gone from the mount options, because the kernel + // refuses it alongside passthrough. The driver applies the same rule + // per open instead: an unchanged file keeps its cached pages. + 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())); +} From 0a6815652ea62689483d5d281c7a4263e8caf173 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:44:52 +0000 Subject: [PATCH 09/28] ci: Install libfuse 3 headers and drop libfuse 2 w --- .github/actions/install/action.yml | 10 ++++++++++ .github/workflows/ci.yml | 9 ++++----- .github/workflows/next-computerd-image.yml | 7 ++----- .github/workflows/release.yml | 8 +++----- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 601d72ac6..2b28478f9 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -16,6 +16,16 @@ runs: registry-url: ${{ inputs.registry-url }} cache: npm + # npm ci compiles the vendored fuse-napi addon for + # @cloudflare/computerd, which needs the libfuse 3 headers. Without + # them the install, and with it every job, fails. + - 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..2654617b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,11 +67,10 @@ 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 ships fusermount3, which the real-FUSE tests mount + # through. The addon itself is compiled during npm ci. xz-utils + # extracts the target Node binary used by build:bin. + 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 87f574965..b5709907d 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.22.3 target binary. - name: Use the target Node version for the binary build @@ -72,6 +67,8 @@ jobs: - name: Build all workspaces run: npm run build --workspaces --if-present + # build:bin compiles the FUSE addon inside a Debian trixie container, + # so the runner needs Docker but no FUSE packages of its own. - 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 From b6f7887596ea5fc895b6a5b8769dd95254e5b50e Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:44:52 +0000 Subject: [PATCH 10/28] chore: Move the FUSE images to libfuse 3 computerd now links the system libfuse 3 and needs 3.17 or newer, which means Debian trixie. The scripts and harnesses that run the freshly built binary drop libfuse2t64 and keep fuse3, and the soak harness image is renamed to match. The examples pin the published 0.4.1 image, which links libfuse 2, and the Version Packages pull request moves those pins to the new release in one step. Until then each example keeps libfuse2t64 next to fuse3, with a comment saying when it can go. The egress and mcp examples move from bookworm, whose libfuse 3.14 is too old for the new binary, to trixie. Both images were checked with the 0.4.1 binary and with a fresh build mounted over it. AGENTS.md describes the vendored addon, the libfuse 3 headers it builds against, the Docker step in build:bin, and the build:types step that --ignore-scripts skips, in place of the fuse-native and arm64 notes. The FUSE debugging skill gains a recipe for checking that passthrough is engaged. --- .../skills/debugging-computerd-fuse/SKILL.md | 39 +++++++++++++--- AGENTS.md | 44 ++++++++++--------- examples/container/Dockerfile | 3 ++ examples/egress/Dockerfile | 7 ++- examples/mcp/Dockerfile | 7 ++- .../Dockerfile.workspace | 3 ++ examples/think/Dockerfile | 3 ++ examples/tutorial/Dockerfile | 3 ++ script/computerd-fuse-flush.mjs | 4 +- script/computerd-soak.mjs | 4 +- script/run-fs-bench.sh | 2 +- script/run-fs-tests.sh | 2 +- script/run-npm-bench-inner.sh | 2 +- script/shell | 2 +- 14 files changed, 86 insertions(+), 39 deletions(-) diff --git a/.agents/skills/debugging-computerd-fuse/SKILL.md b/.agents/skills/debugging-computerd-fuse/SKILL.md index fca758fe7..16eb3f415 100644 --- a/.agents/skills/debugging-computerd-fuse/SKILL.md +++ b/.agents/skills/debugging-computerd-fuse/SKILL.md @@ -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 @@ -179,8 +182,8 @@ 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`. @@ -261,9 +264,31 @@ 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. + ### 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 @@ -369,7 +394,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/AGENTS.md b/AGENTS.md index 6334db180..5d03c5f91 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/examples/container/Dockerfile b/examples/container/Dockerfile index a5265b967..ccc311dea 100644 --- a/examples/container/Dockerfile +++ b/examples/container/Dockerfile @@ -18,6 +18,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.1 AS computerd FROM debian:stable-slim +# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 +# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 +# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl gnupg git \ diff --git a/examples/egress/Dockerfile b/examples/egress/Dockerfile index 72f84bd88..8acb30f01 100644 --- a/examples/egress/Dockerfile +++ b/examples/egress/Dockerfile @@ -1,10 +1,13 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.1 AS computerd -FROM debian:bookworm-slim +FROM debian:trixie-slim +# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 +# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 +# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. 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 7872e9084..b22f2301d 100644 --- a/examples/mcp/Dockerfile +++ b/examples/mcp/Dockerfile @@ -1,10 +1,13 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.1 AS computerd -FROM node:22-bookworm-slim +FROM node:22-trixie-slim +# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 +# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 +# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates curl fuse3 git libfuse2 \ + ca-certificates curl fuse3 git libfuse2t64 \ && rm -rf /var/lib/apt/lists/* COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd diff --git a/examples/think-compare-runtimes/Dockerfile.workspace b/examples/think-compare-runtimes/Dockerfile.workspace index c1ca1879b..385cb0148 100644 --- a/examples/think-compare-runtimes/Dockerfile.workspace +++ b/examples/think-compare-runtimes/Dockerfile.workspace @@ -16,6 +16,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.1 AS computerd FROM --platform=linux/amd64 node:22-trixie-slim +# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 +# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 +# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates git fuse3 libfuse2t64 \ diff --git a/examples/think/Dockerfile b/examples/think/Dockerfile index bcb03b8f0..def699f92 100644 --- a/examples/think/Dockerfile +++ b/examples/think/Dockerfile @@ -20,6 +20,9 @@ FROM debian:stable-slim # come in handy for ad-hoc agent commands too. The cost is ~120 MB # of layer over the base; acceptable for an example whose whole # point is running real build/test workflows. +# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 +# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 +# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl gnupg git \ diff --git a/examples/tutorial/Dockerfile b/examples/tutorial/Dockerfile index 08d3170e4..a003fbaf8 100644 --- a/examples/tutorial/Dockerfile +++ b/examples/tutorial/Dockerfile @@ -18,6 +18,9 @@ ARG TYPST_VERSION=0.15.1 # pandoc converts the agent's markdown; typst is the PDF engine, a # single 30 MB binary instead of the several hundred megabytes a LaTeX # install would cost. curl is only the download tool for those two. +# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 +# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 +# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl xz-utils \ 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/run-fs-bench.sh b/script/run-fs-bench.sh index d3a4c8a1c..f1906b6c8 100755 --- a/script/run-fs-bench.sh +++ b/script/run-fs-bench.sh @@ -13,7 +13,7 @@ # binary release would bloat the bootstrap time. 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 diff --git a/script/run-fs-tests.sh b/script/run-fs-tests.sh index ef3971604..381aca399 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 >/dev/null 2>&1 +apt-get install -y --no-install-recommends fuse3 attr util-linux coreutils findutils git ca-certificates curl >/dev/null 2>&1 mkdir -p /tmp/workspace PORT=45678 MOUNT_POINT=/tmp/workspace /usr/local/bin/computerd >/tmp/computerd.log 2>&1 & diff --git a/script/run-npm-bench-inner.sh b/script/run-npm-bench-inner.sh index adc9ddbae..dc3ec22e3 100755 --- a/script/run-npm-bench-inner.sh +++ b/script/run-npm-bench-inner.sh @@ -6,7 +6,7 @@ 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 diff --git a/script/shell b/script/shell index b514ece09..b573549da 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 >/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 >/dev/null 2>&1; exec bash' From 016ddba291da1a6ffe06efaaee9d14fd422d6ab2 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:00:42 +0000 Subject: [PATCH 11/28] computerd: Benchmark local-only paths natively on arm64 The benchmarks ran the x64 binary, which on an arm64 machine means emulation and timings that say nothing about the mount. build:bin now takes COMPUTERD_BIN_TARGETS, so `linux-arm64` builds a native binary alongside or instead of the published linux-x64 one. Building one target no longer deletes another's binary. The npm bench gains a TARGETS list, a `large` scenario (TypeScript, Vite, webpack, ESLint, React; about 5,400 files and 88 MB), and a timed read of every installed file after each install. An install is mostly metadata, while the read-back is the data path that passthrough changes. IGNORE_NODE_MODULES=1 makes each run's node_modules local-only while its package.json stays synced, which is how MOUNT_IGNORE is meant to be used, and PLATFORM picks the Docker platform. run-fs-bench.sh gains LOCAL_ONLY=1, which runs the whole bench under a local-only directory. Both harnesses forward COMPUTERD_FUSE_PASSTHROUGH, so the same run can be repeated with passthrough off. --- packages/computerd/scripts/build-bin.mjs | 35 +++++++++++++++-- script/npm-bench.sh | 49 +++++++++++++++++++++++- script/run-fs-bench.sh | 15 +++++++- script/run-npm-bench-inner.sh | 15 ++++++++ script/run-npm-bench.sh | 21 +++++++++- 5 files changed, 127 insertions(+), 8 deletions(-) diff --git a/packages/computerd/scripts/build-bin.mjs b/packages/computerd/scripts/build-bin.mjs index 329d67998..9cdea9e82 100644 --- a/packages/computerd/scripts/build-bin.mjs +++ b/packages/computerd/scripts/build-bin.mjs @@ -1,5 +1,8 @@ #!/usr/bin/env node -// Build computerd as a self-contained Node SEA binary for linux-x64. Steps: +// Build computerd as a self-contained Node SEA binary for linux-x64, the +// published target. COMPUTERD_BIN_TARGETS=linux-arm64 (or a comma-separated +// list) builds others, for running the binary natively on an arm64 machine +// while debugging or benchmarking. Steps per target: // 1. Compile the vendored fuse-napi addon inside a Debian trixie // container for the target platform (see buildAddon below). // 2. esbuild a single ESM bundle (see scripts/sea/bundle.mjs). @@ -12,7 +15,8 @@ // The addon links the system libfuse 3 at runtime rather than carrying a // copy, so the image that runs the binary needs libfuse 3.17 or newer // (the `fuse3` package on Debian trixie). Set COMPUTERD_FUSE_ADDON to the -// path of a prebuilt fuse.node to skip the container build. +// path of a prebuilt fuse.node to skip the container build; it applies to +// every target built in that run, so use it with a single target. import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { createWriteStream } from "node:fs"; @@ -41,7 +45,7 @@ const nodeVersion = "v22.22.3"; const addonBuildImage = "node:22-trixie-slim"; const minimumLibfuse = [3, 17]; -const targets = [ +const allTargets = [ { name: "linux-x64", outputName: "computerd-linux-x64", @@ -49,11 +53,34 @@ const targets = [ nodeBinaryInArchive: `node-${nodeVersion}-linux-x64/bin/node`, dockerPlatform: "linux/amd64", }, + { + 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 }); + // Each target overwrites only its own file, so building one target + // leaves another's binary in place. await mkdir(outputDir, { recursive: true }); await mkdir(seaWorkDir, { recursive: true }); await mkdir(nodeCacheDir, { recursive: true }); diff --git a/script/npm-bench.sh b/script/npm-bench.sh index 0407f35c2..1275580bc 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,13 @@ # 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 times reading every installed file back once, after the +# install, as readMs. An install is mostly metadata (create, mkdir, +# rename); the read-back is the data path, which is what FUSE +# passthrough changes for local-only paths. set -euo pipefail MOUNT="${MOUNT:-/tmp/workspace}" @@ -33,6 +43,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,6 +75,17 @@ 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. ;; @@ -142,6 +165,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 +205,22 @@ run_install() { end_ts="$(date +%s%N)" local elapsed_ms=$(( (end_ts - start_ts) / 1000000 )) + # Read every installed file back once. + 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 +239,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 f1906b6c8..e6f392167 100755 --- a/script/run-fs-bench.sh +++ b/script/run-fs-bench.sh @@ -11,6 +11,10 @@ # 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. set -u apt-get update >/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 @@ -19,12 +23,20 @@ apt-get install -y --no-install-recommends fuse3 attr util-linux coreutils findu # 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" \ PORT=45678 MOUNT_POINT=/tmp/workspace /usr/local/bin/computerd >/tmp/computerd.log 2>&1 & COMPUTERD_PID=$! @@ -44,7 +56,8 @@ 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=$? # Ask computerd to dump the FUSE trace (if enabled) before the SIGTERM. The diff --git a/script/run-npm-bench-inner.sh b/script/run-npm-bench-inner.sh index dc3ec22e3..556c6a3e6 100755 --- a/script/run-npm-bench-inner.sh +++ b/script/run-npm-bench-inner.sh @@ -11,8 +11,18 @@ apt-get install -y --no-install-recommends \ mkdir -p /tmp/workspace /tmp/baseline +# With IGNORE_NODE_MODULES=1, each run's node_modules under +# /tmp/workspace/ignored is local-only while its package.json and lock +# stay synced, which is how MOUNT_IGNORE is meant to be used. +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" \ PORT=45678 MOUNT_POINT=/tmp/workspace /usr/local/bin/computerd >/tmp/computerd.log 2>&1 & COMPUTERD_PID=$! @@ -33,6 +43,11 @@ fi 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 passthrough || 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..82f08d6f7 100755 --- a/script/run-npm-bench.sh +++ b/script/run-npm-bench.sh @@ -16,6 +16,18 @@ # 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 +# PLATFORM docker platform (default: linux/amd64). Use +# linux/arm64 with a computerd-linux-arm64 binary to run +# natively on an arm64 machine; emulated timings are +# not meaningful. # # Requirements: # docker available and able to run --privileged containers @@ -34,6 +46,10 @@ 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:-}" +PLATFORM="${PLATFORM:-linux/amd64}" if [ ! -f "$COMPUTERD_BINARY" ]; then echo "computerd binary not found at $COMPUTERD_BINARY" @@ -43,7 +59,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 +70,7 @@ 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" \ debian:stable-slim bash /run-bench.sh From 543da34a490da0cb39fc640e9667a83e9f7e44c5 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:00:42 +0000 Subject: [PATCH 12/28] computerd: Document libfuse 3 and local-only passthrough The docs described a binary that embedded libfuse 2.9 and a passthrough that could not exist. They now describe the libfuse 3 build: the runtime needs libfuse 3.17 or newer, which is Debian trixie, and no longer needs libfuse2t64 from the release after 0.4.1; the snippets that still pin 0.4.1 say so. No macOS binary is published. The local-only section of the computerd README replaces the libfuse 2.9 fastPaths example with the live block, and a table of every passthroughReason with its fix. docs/19_performance.md drops the "track the synced mount" guidance for local-only paths and gains fs-bench and npm install numbers for the synced mount, local-only paths with and without passthrough, and raw disk. Large-file I/O under a local-only path runs at 0.9 to 1.3 times raw disk, against 3 to 10 times on the synced mount, while small-file metadata still goes through computerd. The numbers come from a local arm64 virtual machine, and the page says so and how to reproduce them. The Cloudflare Containers cells stay empty until that install is run there. The computerd README also covers the runtime and build requirements, COMPUTERD_FUSE_PASSTHROUGH, the passthrough test suite, and the arm64 build target. bench-results.md marks its auto_cache section as a record of the libfuse 2.9 build. A changeset covers the libfuse 3 requirement, the dropped macOS binary, passthrough, and the three behavior changes on the synced mount. --- .changeset/libfuse3-passthrough.md | 32 ++++++++ docs/07_injected_service.md | 19 ++--- docs/10_project_layout.md | 13 +++- docs/19_performance.md | 78 +++++++++++++++---- docs/README.md | 8 +- examples/tutorial/README.md | 7 +- .../computer-computerd-linux-x64/README.md | 13 ++-- packages/computerd/README.md | 59 ++++++++++---- packages/computerd/bench-results.md | 8 ++ 9 files changed, 184 insertions(+), 53 deletions(-) create mode 100644 .changeset/libfuse3-passthrough.md diff --git a/.changeset/libfuse3-passthrough.md b/.changeset/libfuse3-passthrough.md new file mode 100644 index 000000000..af2de81c9 --- /dev/null +++ b/.changeset/libfuse3-passthrough.md @@ -0,0 +1,32 @@ +--- +"@cloudflare/computerd": minor +--- + +Move to libfuse 3, and serve local-only paths through FUSE passthrough + +computerd now mounts through a vendored, patched copy of `fuse-napi`, which +binds libfuse 3, instead of `fuse-native` and libfuse 2.9. This changes what +the container image needs. + +The binary links the system libfuse 3 and needs 3.17 or newer. On Debian that +is the `fuse3` package from trixie onward; bookworm's 3.14 is too old. +`libfuse2t64` is no longer needed. A missing or too-old library stops the +binary at startup with a message naming the package to install. + +No macOS binary is published any more. Running from source with macFUSE has +not been tested since the move. + +With `MOUNT_IGNORE` set and a kernel of Linux 6.9 or newer, opening a +local-only file hands it to the kernel, which then serves its reads, writes, +and mmap straight from the container's disk without going through computerd. +Without kernel support, or without `CAP_SYS_ADMIN`, those paths work as +before and are served by computerd. `/__computerd/info` reports which case +applies in `ignore.fastPaths`, with counts, and `COMPUTERD_FUSE_PASSTHROUGH=0` +turns passthrough off. + +Three behavior changes on the synced mount come with the new binding. +`touch -a` and `touch -m` now set the one time they name and leave the other +alone, where before they had no effect. `COMPUTERD_FUSE_AC_ATTR_TIMEOUT` is gone, along with +`ac_attr_timeout` in `COMPUTERD_FUSE_EXTRA_OPTS`: it only tuned `auto_cache`, +which the mount no longer uses. And `COMPUTERD_FUSE_EXTRA_OPTS` entries now +fail the mount, naming the option, when the binding does not recognize them. diff --git a/docs/07_injected_service.md b/docs/07_injected_service.md index a863262e2..5f9422bda 100644 --- a/docs/07_injected_service.md +++ b/docs/07_injected_service.md @@ -11,14 +11,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 @@ -68,11 +68,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 @@ -90,9 +90,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`. computerd needs libfuse + 3.17 or newer, which is Debian trixie or later; bookworm's 3.14 is too + old. computerd 0.4.1 and earlier linked libfuse 2 instead 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 28c3567d7..0dcc1ccdf 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,6 +161,8 @@ packages/computerd/ │ ├── types.ts │ ├── log.ts │ └── index.ts +├── vendor/ +│ └── fuse-napi/ # patched libfuse 3 binding; see PATCHES.md ├── scripts/ │ ├── build.mjs # → dist/cli/computerd.cjs │ ├── build-bin.mjs # SEA driver @@ -164,8 +170,7 @@ packages/computerd/ │ └── bundle.mjs # esbuild → SEA bundle ├── artifacts/ │ └── computerd/ -│ ├── computerd-linux-x64 -│ └── computerd-macos-x64 +│ └── computerd-linux-x64 ├── tsconfig.json └── package.json ``` diff --git a/docs/19_performance.md b/docs/19_performance.md index 207e0768f..344286dff 100644 --- a/docs/19_performance.md +++ b/docs/19_performance.md @@ -66,18 +66,67 @@ 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. + +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 +134,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 35c2dfa8b..1780dd295 100644 --- a/docs/README.md +++ b/docs/README.md @@ -75,11 +75,11 @@ Debian base: ```dockerfile FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.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 \ + fuse3 ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd @@ -92,6 +92,10 @@ EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/computerd"] ``` +`fuse3` brings libfuse 3, which computerd links and needs at 3.17 or +newer, so the base has to be Debian trixie or later. computerd 0.4.1 and +earlier linked libfuse 2 instead and also need `libfuse2t64`. + 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/tutorial/README.md b/examples/tutorial/README.md index 64f7741fb..edd6a07f0 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/packages/computer-computerd-linux-x64/README.md b/packages/computer-computerd-linux-x64/README.md index 8fdaadc35..6a4a3230d 100644 --- a/packages/computer-computerd-linux-x64/README.md +++ b/packages/computer-computerd-linux-x64/README.md @@ -11,9 +11,12 @@ 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. libfuse is not: the binary links the +system's libfuse 3 and needs 3.17 or newer, which is the `fuse3` package +on Debian trixie or later. The host also needs `/dev/fuse`, and Linux +6.9 or newer for local-only paths to use FUSE passthrough. computerd +0.4.1 and earlier linked libfuse 2 instead and also need `libfuse2t64`. 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 @@ -21,10 +24,10 @@ instead: ```dockerfile FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.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 \ + fuse3 ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd diff --git a/packages/computerd/README.md b/packages/computerd/README.md index 81766a352..09ed17a4d 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. When the kernel supports it, their file data goes through FUSE passthrough and never reaches `computerd`. - 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 @@ -318,14 +338,15 @@ Durable Object should be deleted in favor of `MOUNT_IGNORE`. ## 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. + +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. -### macOS: macFUSE +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. -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. +### 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 +366,12 @@ 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_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 +408,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 +418,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 From fa693f8203b3e11b5c218afccd78e6c8924f9711 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:18:45 +0000 Subject: [PATCH 13/28] computerd: cleanup code comments --- .github/actions/install/action.yml | 4 +- .github/workflows/ci.yml | 5 +- .github/workflows/next-computerd-image.yml | 3 +- examples/container/Dockerfile | 5 +- examples/egress/Dockerfile | 5 +- examples/mcp/Dockerfile | 5 +- .../Dockerfile.workspace | 5 +- examples/think/Dockerfile | 5 +- examples/tutorial/Dockerfile | 5 +- .../computer/test-harness/run-computerd.sh | 3 +- packages/computerd/scripts/build-bin.mjs | 118 +++++----- packages/computerd/scripts/sea/bundle.mjs | 8 +- packages/computerd/src/cli/computerd.test.ts | 6 +- packages/computerd/src/cli/computerd.ts | 3 +- .../computerd/src/fuse/binding-ops.test.ts | 5 - packages/computerd/src/fuse/binding-ops.ts | 54 ++--- packages/computerd/src/fuse/driver.test.ts | 10 - packages/computerd/src/fuse/driver.ts | 75 ++----- .../computerd/src/fuse/ignore-config.test.ts | 4 - packages/computerd/src/fuse/ignore-config.ts | 85 +++----- packages/computerd/src/fuse/options.test.ts | 30 +-- packages/computerd/src/fuse/options.ts | 202 ++++++------------ .../src/fuse/passthrough.fuse.test.ts | 38 +--- .../computerd/src/fuse/passthrough.test.ts | 12 +- packages/computerd/src/fuse/passthrough.ts | 116 +++++----- script/npm-bench.sh | 7 +- script/run-npm-bench-inner.sh | 5 +- script/run-npm-bench.sh | 7 +- 28 files changed, 265 insertions(+), 565 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 2b28478f9..1f85c0ae0 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -16,9 +16,7 @@ runs: registry-url: ${{ inputs.registry-url }} cache: npm - # npm ci compiles the vendored fuse-napi addon for - # @cloudflare/computerd, which needs the libfuse 3 headers. Without - # them the install, and with it every job, fails. + # npm ci compiles computerd's vendored FUSE addon against these. - name: Install the libfuse 3 headers if: runner.os == 'Linux' shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2654617b4..c4fb951c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,9 +67,8 @@ jobs: path: packages/computerd needs-siblings: true build-computerd-bin: true - # fuse3 ships fusermount3, which the real-FUSE tests mount - # through. The addon itself is compiled during npm ci. xz-utils - # extracts the target Node binary used by build:bin. + # 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 diff --git a/.github/workflows/next-computerd-image.yml b/.github/workflows/next-computerd-image.yml index b5709907d..20ccc18eb 100644 --- a/.github/workflows/next-computerd-image.yml +++ b/.github/workflows/next-computerd-image.yml @@ -67,8 +67,7 @@ jobs: - name: Build all workspaces run: npm run build --workspaces --if-present - # build:bin compiles the FUSE addon inside a Debian trixie container, - # so the runner needs Docker but no FUSE packages of its own. + # 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/examples/container/Dockerfile b/examples/container/Dockerfile index ccc311dea..00c18a29d 100644 --- a/examples/container/Dockerfile +++ b/examples/container/Dockerfile @@ -18,9 +18,8 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.1 AS computerd FROM debian:stable-slim -# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 -# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 -# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. +# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only +# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl gnupg git \ diff --git a/examples/egress/Dockerfile b/examples/egress/Dockerfile index 8acb30f01..3baf58d57 100644 --- a/examples/egress/Dockerfile +++ b/examples/egress/Dockerfile @@ -2,9 +2,8 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.1 AS computerd FROM debian:trixie-slim -# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 -# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 -# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. +# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only +# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl \ diff --git a/examples/mcp/Dockerfile b/examples/mcp/Dockerfile index b22f2301d..6b464455f 100644 --- a/examples/mcp/Dockerfile +++ b/examples/mcp/Dockerfile @@ -2,9 +2,8 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.1 AS computerd FROM node:22-trixie-slim -# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 -# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 -# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. +# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only +# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates curl fuse3 git libfuse2t64 \ diff --git a/examples/think-compare-runtimes/Dockerfile.workspace b/examples/think-compare-runtimes/Dockerfile.workspace index 385cb0148..cd62e4ff5 100644 --- a/examples/think-compare-runtimes/Dockerfile.workspace +++ b/examples/think-compare-runtimes/Dockerfile.workspace @@ -16,9 +16,8 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.1 AS computerd FROM --platform=linux/amd64 node:22-trixie-slim -# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 -# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 -# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. +# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only +# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates git fuse3 libfuse2t64 \ diff --git a/examples/think/Dockerfile b/examples/think/Dockerfile index def699f92..b7e487d19 100644 --- a/examples/think/Dockerfile +++ b/examples/think/Dockerfile @@ -20,9 +20,8 @@ FROM debian:stable-slim # come in handy for ad-hoc agent commands too. The cost is ~120 MB # of layer over the base; acceptable for an example whose whole # point is running real build/test workflows. -# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 -# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 -# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. +# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only +# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl gnupg git \ diff --git a/examples/tutorial/Dockerfile b/examples/tutorial/Dockerfile index a003fbaf8..911127678 100644 --- a/examples/tutorial/Dockerfile +++ b/examples/tutorial/Dockerfile @@ -18,9 +18,8 @@ ARG TYPST_VERSION=0.15.1 # pandoc converts the agent's markdown; typst is the PDF engine, a # single 30 MB binary instead of the several hundred megabytes a LaTeX # install would cost. curl is only the download tool for those two. -# fuse3 brings libfuse 3. Releases after 0.4.1 link it and need 3.17 -# or newer, which is Debian trixie. 0.4.1 and earlier link libfuse 2 -# instead, hence libfuse2t64; drop it once the pin above is past 0.4.1. +# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only +# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl xz-utils \ diff --git a/packages/computer/test-harness/run-computerd.sh b/packages/computer/test-harness/run-computerd.sh index 1ba9f82cd..271b84ae0 100755 --- a/packages/computer/test-harness/run-computerd.sh +++ b/packages/computer/test-harness/run-computerd.sh @@ -48,8 +48,7 @@ RUN apt-get update >/dev/null \ DOCKERFILE fi -# Forward local-only path and FUSE tuning settings from the caller, so a -# test can boot computerd with MOUNT_IGNORE or COMPUTERD_FUSE_TRACE set. +# Lets a test boot computerd with MOUNT_IGNORE or COMPUTERD_FUSE_* set. FORWARD_ENV=() while IFS='=' read -r name _; do case "$name" in diff --git a/packages/computerd/scripts/build-bin.mjs b/packages/computerd/scripts/build-bin.mjs index 9cdea9e82..42b887dd6 100644 --- a/packages/computerd/scripts/build-bin.mjs +++ b/packages/computerd/scripts/build-bin.mjs @@ -1,22 +1,12 @@ #!/usr/bin/env node -// Build computerd as a self-contained Node SEA binary for linux-x64, the -// published target. COMPUTERD_BIN_TARGETS=linux-arm64 (or a comma-separated -// list) builds others, for running the binary natively on an arm64 machine -// while debugging or benchmarking. Steps per target: -// 1. Compile the vendored fuse-napi addon inside a Debian trixie -// container for the target platform (see buildAddon below). -// 2. esbuild a single ESM bundle (see scripts/sea/bundle.mjs). -// 3. Write a sea-config.json that names the bundle as main and embeds -// the addon as an asset. -// 4. Generate the SEA blob via `node --experimental-sea-config`. -// 5. Download the target's Node binary (cached under .devbox/node-binaries), -// copy it into artifacts/, and inject the blob with postject. +// 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. // -// The addon links the system libfuse 3 at runtime rather than carrying a -// copy, so the image that runs the binary needs libfuse 3.17 or newer -// (the `fuse3` package on Debian trixie). Set COMPUTERD_FUSE_ADDON to the -// path of a prebuilt fuse.node to skip the container build; it applies to -// every target built in that run, so use it with a single target. +// 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 { createHash } from "node:crypto"; import { createWriteStream } from "node:fs"; @@ -39,9 +29,8 @@ const nodeCacheDir = resolve(repoRoot, ".devbox/node-binaries"); const addonSourceDir = resolve(computerdRoot, "vendor/fuse-napi"); const nodeVersion = "v22.22.3"; -// The addon is built against the same Node major as the SEA runtime and -// the same Debian release as the container images, so the libfuse it -// links is the one that is present when the binary runs. +// 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]; @@ -79,8 +68,6 @@ const targets = requestedTargets.map((name) => { async function main() { await runNpm("build"); - // Each target overwrites only its own file, so building one target - // leaves another's binary in place. await mkdir(outputDir, { recursive: true }); await mkdir(seaWorkDir, { recursive: true }); await mkdir(nodeCacheDir, { recursive: true }); @@ -139,13 +126,9 @@ async function buildTarget(target) { await execFileP(process.execPath, postjectArgs); } -// Compile fuse-napi for the target inside a container, so the result -// does not depend on the build host's architecture or on its libfuse. -// CI runners carry an older libfuse 3 than the 3.17 that passthrough -// needs, and a developer machine may not be x64 at all. -// -// The result is cached under dist/sea, keyed on the addon sources and -// the build image, because an emulated compile takes minutes. +// 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 !== "") { @@ -155,70 +138,71 @@ async function buildAddon(target) { const outDir = resolve(seaWorkDir, `${target.name}-addon`); const addonPath = resolve(outDir, "fuse.node"); - const stampPath = resolve(outDir, "fuse.node.key"); + const keyPath = resolve(outDir, "fuse.node.key"); const key = await addonCacheKey(target); - if ((await exists(addonPath)) && (await readText(stampPath)) === key) { - return addonPath; - } + 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", + ], + ); - const script = [ - "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", - // The image ships the Node headers, so node-gyp does not download them. - "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", - ].join("\n"); + assertLibfuseVersion((await readText(resolve(outDir, "libfuse-version"))).trim()); + await writeFile(keyPath, key); + return addonPath; +} - // Detached, then waited on, rather than an attached `docker run`: an - // attached client can lose its stream on a remote daemon and return - // before the container finishes, which would read as a missing addon. - const { stdout: containerId } = await execFileP("docker", [ +// 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", - target.dockerPlatform, - "-v", - `${addonSourceDir}:/src:ro`, - "-v", - `${outDir}:/out`, + platform, + ...volumeArgs, addonBuildImage, "sh", "-c", - script, + scriptLines.join("\n"), ]); - const id = containerId.trim(); + const id = stdout.trim(); try { const { stdout: status } = await execFileP("docker", ["wait", id]); if (status.trim() !== "0") { - const { stdout, stderr } = await execFileP("docker", ["logs", id], { - maxBuffer: 64 * 1024 * 1024, - }); - throw new Error(`fuse-napi build exited ${status.trim()}:\n${stdout}${stderr}`); + 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(() => {}); } +} - const libfuseVersion = (await readText(resolve(outDir, "libfuse-version"))).trim(); - const [major = 0, minor = 0] = libfuseVersion.split(".").map(Number); - if (major < minimumLibfuse[0] || (major === minimumLibfuse[0] && minor < minimumLibfuse[1])) { +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 ${libfuseVersion}; passthrough needs ` + + `${addonBuildImage} carries libfuse ${version}; passthrough needs ` + `${minimumLibfuse.join(".")} or newer`, ); } - await writeFile(stampPath, key); - return addonPath; } async function addonCacheKey(target) { diff --git a/packages/computerd/scripts/sea/bundle.mjs b/packages/computerd/scripts/sea/bundle.mjs index cc6f31f26..89a005026 100644 --- a/packages/computerd/scripts/sea/bundle.mjs +++ b/packages/computerd/scripts/sea/bundle.mjs @@ -5,11 +5,9 @@ // capnweb's top-level await survives without an async-IIFE wrapper. A banner // 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 replaces that module with a shim that reads the addon -// from the SEA assets (via `node:sea`), writes it to a temp directory -// keyed by its sha256, and dlopens it. The addon links the system -// libfuse 3, which the dynamic loader finds on its default path. +// * 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"; diff --git a/packages/computerd/src/cli/computerd.test.ts b/packages/computerd/src/cli/computerd.test.ts index 56d003d85..23719977a 100644 --- a/packages/computerd/src/cli/computerd.test.ts +++ b/packages/computerd/src/cli/computerd.test.ts @@ -167,10 +167,8 @@ test("MOUNT_IGNORE keeps matching paths on local disk and out of the VFS", async "module.exports=1", ); - // The open is accounted for on /__computerd/info, read live: handed to - // the kernel where the host supports passthrough, served by computerd - // otherwise. Which one depends on the kernel and libfuse of the host - // running the test, so only the sum is pinned. + // 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); diff --git a/packages/computerd/src/cli/computerd.ts b/packages/computerd/src/cli/computerd.ts index 81eeeb39a..0e6cae086 100644 --- a/packages/computerd/src/cli/computerd.ts +++ b/packages/computerd/src/cli/computerd.ts @@ -688,8 +688,7 @@ async function main(): Promise { mountPoint, port, store, - // A getter, so each /__computerd/info request reports what the - // mount is doing now rather than a snapshot from startup. + // A getter, so each request reports live passthrough state. get ignore() { return describeMountIgnore(ignoreConfig, fuse?.getPassthroughStatus?.()); }, diff --git a/packages/computerd/src/fuse/binding-ops.test.ts b/packages/computerd/src/fuse/binding-ops.test.ts index 26aa22add..8a135b51d 100644 --- a/packages/computerd/src/fuse/binding-ops.test.ts +++ b/packages/computerd/src/fuse/binding-ops.test.ts @@ -46,9 +46,6 @@ function call(ops: Record, atime: object, mtime: object): Promi describe("toBindingOps", () => { test("drops the entries fuse-napi refuses", () => { - // `error` is not an operation it knows, `init` is mutually exclusive - // with the initWithConfig the mount adds, and getBufferStats is not - // an operation at all. const out = toBindingOps(fakeOps().ops); expect(out).not.toHaveProperty("error"); expect(out).not.toHaveProperty("init"); @@ -57,8 +54,6 @@ describe("toBindingOps", () => { }); test("replaces utimens with the timespec variant", () => { - // Plain utimens in fuse-napi answers EOPNOTSUPP whenever the kernel - // sends UTIME_NOW or UTIME_OMIT, which is what a bare `touch` does. const out = toBindingOps(fakeOps().ops); expect(out).not.toHaveProperty("utimens"); expect(typeof out.utimensWithTimespec).toBe("function"); diff --git a/packages/computerd/src/fuse/binding-ops.ts b/packages/computerd/src/fuse/binding-ops.ts index 24d0add22..699303d22 100644 --- a/packages/computerd/src/fuse/binding-ops.ts +++ b/packages/computerd/src/fuse/binding-ops.ts @@ -1,8 +1,3 @@ -// Adapts computerd's op table to what the vendored fuse-napi binding -// accepts. Kept apart from makeFUSEOps so the VFS driver and the -// local-only layer keep one simple shape, and the binding's rules live -// in one place. - import type { FuseOps } from "./driver.js"; // The kernel's sentinel nanosecond values, as fuse-napi passes them. @@ -14,37 +9,31 @@ export interface Timespec { readonly nanoseconds: number; } +type StatusCallback = (code: number) => void; + export interface BindingOpsOptions { - /** Clock for UTIME_NOW. Injected for tests. */ readonly now?: () => number; } /** - * Returns the op table to hand to `new Fuse()`. - * - * fuse-napi validates the table before it mounts, and refuses three of - * our entries: `error` is not an operation it knows, `init` is mutually - * exclusive with the `initWithConfig` the mount adds, and - * `getBufferStats` is not an operation at all. + * The op table to hand to `new Fuse()`. * - * `utimens` is swapped for `utimensWithTimespec`. Plain `utimens` in - * fuse-napi answers EOPNOTSUPP when either time is UTIME_NOW or - * UTIME_OMIT, and that is what `touch`, `touch -m`, and most archive - * tools send. + * 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 now = options.now ?? Date.now; - const { getBufferStats: _stats, error: _error, init: _init, utimens, ...rest } = ops; + const { getBufferStats: _stats, error: _error, init: _init, utimens: _utimens, ...rest } = ops; + return { ...rest, utimensWithTimespec: utimensWithTimespec(ops, options.now ?? Date.now) }; +} - const utimensWithTimespec = ( - path: string, - atime: Timespec, - mtime: Timespec, - cb: (code: number) => void, - ): void => { +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) { @@ -52,13 +41,13 @@ export function toBindingOps( 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 => { - const resolve = (time: Timespec, current: number): number => { - if (time.nanoseconds === UTIME_OMIT) return current; - if (time.nanoseconds === UTIME_NOW) return now(); - return toMilliseconds(time); - }; - utimens.call(ops, path, resolve(atime, currentAtime), resolve(mtime, currentMtime), cb); + ops.utimens(path, resolve(atime, currentAtime), resolve(mtime, currentMtime), cb); }; if (!omitAtime && !omitMtime) { @@ -66,8 +55,7 @@ export function toBindingOps( return; } - // Only one side is changing. Keep the other as it is by reading it - // back first, since our utimens always sets both. + // 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); @@ -76,8 +64,6 @@ export function toBindingOps( apply(stat.atime.getTime(), stat.mtime.getTime()); }); }; - - return { ...rest, utimensWithTimespec }; } function toMilliseconds(time: Timespec): number { diff --git a/packages/computerd/src/fuse/driver.test.ts b/packages/computerd/src/fuse/driver.test.ts index 62f8a2509..cc93cdbc8 100644 --- a/packages/computerd/src/fuse/driver.test.ts +++ b/packages/computerd/src/fuse/driver.test.ts @@ -11,10 +11,6 @@ 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))); -/** - * open/create reply with a libfuse 3 file-info object carrying the - * per-open cache decision; opendir still replies with a bare handle. - */ function handleOf(result: unknown): number { return typeof result === "number" ? result : (result as { fd: number }).fd; } @@ -109,8 +105,6 @@ 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 handleOf(create.result)).toBe("number"); - // A newly created file has no previous mtime/size to match, so the - // kernel page cache is never reused for it. expect((create.result as { keepCache?: boolean }).keepCache).toBe(false); const open = await callback((cb) => ops.open("/dir/file.txt", 0, cb)); @@ -165,7 +159,6 @@ 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); - // fuse-napi wants the attribute names as an array of strings. 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); @@ -321,9 +314,6 @@ test("FUSE write is visible through the backing VFS after release", async () => }); test("FUSE open with O_TRUNC empties the file", async () => { - // libfuse 3 negotiates atomic O_TRUNC by default, so `: > f` arrives - // as open(O_TRUNC) with no separate truncate. Ignoring the flag - // leaves the old bytes in place. const { vfs } = await createNodeVirtualFileSystem(); vfs.writeFileSync("/existing.txt", "old contents\n"); const ops = makeFUSEOps(vfs); diff --git a/packages/computerd/src/fuse/driver.ts b/packages/computerd/src/fuse/driver.ts index 35178ff4c..f981352c9 100644 --- a/packages/computerd/src/fuse/driver.ts +++ b/packages/computerd/src/fuse/driver.ts @@ -54,26 +54,9 @@ type StatusCallback = (errnoOrBytes: number) => void; type ResultCallback = (errno: number, result: T) => void; type NotImplementedOperation = (...args: unknown[]) => void; -/** - * What open/create may hand back under libfuse 3. - * - * A bare number is still accepted and means "just the handle". The object - * form carries the per-open cache decision, and a backing id when the - * local-only layer has registered the file for passthrough. - */ export interface FuseFileInfoResult { readonly fd: number; - /** - * Reuse the kernel page cache for this file. Replaces the auto_cache - * mount option, which libfuse applies after this callback returns and - * which the kernel refuses alongside passthrough. - */ readonly keepCache?: boolean; - /** - * Backing id from the FUSE passthrough registration. When set, the - * kernel serves reads and writes for this handle directly from the - * backing file and the daemon is out of the data path. - */ readonly backingId?: number; } @@ -117,7 +100,6 @@ 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; - // fuse-napi puts datasync before the handle, unlike fuse-native. 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; @@ -173,8 +155,7 @@ 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; - // Live passthrough state for /__computerd/info. Only present on a - // kernel FUSE mount. + // Only present on a kernel FUSE mount. getPassthroughStatus?: () => PassthroughStatus; } @@ -183,7 +164,6 @@ interface FuseInstance { unmount(cb: (error: Error | null) => void): void; } -/** The binding's backing-file registration, from the vendored patch. */ interface BackingRegistrar { backingOpen(fd: number): number; backingClose(backingId: number): void; @@ -219,19 +199,10 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO return openHandle(path); }; - /* - * Per-open page cache decision, replacing the auto_cache mount option. - * - * libfuse applies auto_cache *after* the open callback returns and does - * not check for passthrough, and the kernel rejects FOPEN_KEEP_CACHE - * alongside FOPEN_PASSTHROUGH with EIO. So the rule moves here, where it - * can be applied per file and skipped for a passthrough handle. - * - * The rule is auto_cache's own: keep the cache when neither mtime nor - * size has changed since this path was last opened. Anything else -- a - * first open, a changed file, a stat we could not take -- invalidates, - * which is the safe direction. - */ + // 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 => { @@ -610,9 +581,8 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO }, open(path, flags, cb) { - // libfuse 3 negotiates atomic O_TRUNC by default, so the kernel - // folds `: > f` into this open rather than sending a separate - // truncate. Honor it here or the old bytes survive. + // 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) { @@ -982,9 +952,7 @@ export function makeFUSEOps(vfs: NodeVirtualFileSystem, mountPoint = "/"): FuseO cb(ERRNO.ENOENT); return; } - // Always sets both. toBindingOps resolves UTIME_NOW and reads back - // the current value for UTIME_OMIT before calling this, so - // `touch -a` and `touch -m` arrive here with both times filled in. + // toBindingOps has already resolved UTIME_NOW and UTIME_OMIT. updateMeta(path, { atime: new Date(atime), mtime: new Date(mtime) }); cb(0); }, @@ -1082,21 +1050,17 @@ export async function mountFuse(options: { const tracer: FuseTracer | undefined = traceMode === "summary" ? createFuseTracer() : undefined; const baseOps = makeFUSEOps(options.vfs, options.mountPoint); - // Kernel passthrough for local-only files. Only requested when there - // are local-only paths to use it on, so a mount without MOUNT_IGNORE - // negotiates exactly what it did before. + // 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; - // The registrar is late-bound. The op table has to exist before the - // Fuse instance does, because the constructor takes it, so the - // closure reads `mountedFuse`, assigned just after construction. No - // open can arrive before init, and until init confirms the - // capability the answer is ENOTSUP, which the local-only layer turns - // into a fallback to serving the file itself. + // 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) { @@ -1124,17 +1088,8 @@ export async function mountFuse(options: { }); const routedOps = localPaths === undefined ? baseOps : localPaths.ops; const fuseOps = toBindingOps(routedOps); - const ops = - tracer === undefined - ? fuseOps - : wrapFuseOpsWithTracer(fuseOps as unknown as Record, tracer); - // buildFuseMountOptions reads COMPUTERD_FUSE_* env vars; with none set it - // emits the production-safe profile (use_ino, 512 KiB max_read, and - // one-second metadata timeouts). + const ops = tracer === undefined ? fuseOps : wrapFuseOpsWithTracer(fuseOps, tracer); const mountOptions = buildFuseMountOptions(process.env); - // libfuse 3 takes max_write through the init config rather than as a - // mount option, and the passthrough capability has to be requested at - // init or the backing-file ioctl fails with EPERM. const initConfig = buildFuseInitConfig(process.env); const CAP_PASSTHROUGH: number = Fuse.CAP_PASSTHROUGH ?? 1 << 29; @@ -1148,6 +1103,8 @@ export async function mountFuse(options: { 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; } diff --git a/packages/computerd/src/fuse/ignore-config.test.ts b/packages/computerd/src/fuse/ignore-config.test.ts index 8d5c5aa61..fb4a725ac 100644 --- a/packages/computerd/src/fuse/ignore-config.test.ts +++ b/packages/computerd/src/fuse/ignore-config.test.ts @@ -140,10 +140,6 @@ describe("describeMountIgnore", () => { }); }); - // Each reason below has a different fix, so each gets its own text. - // An operator should be able to act on the string without reading - // the source. - test("says so when no kernel FUSE mount is running", () => { const info = describeMountIgnore(enabled); expect(info.fastPaths.passthrough).toBe(false); diff --git a/packages/computerd/src/fuse/ignore-config.ts b/packages/computerd/src/fuse/ignore-config.ts index 83dcb9b66..3e24bb13d 100644 --- a/packages/computerd/src/fuse/ignore-config.ts +++ b/packages/computerd/src/fuse/ignore-config.ts @@ -69,15 +69,11 @@ export function resolveMountIgnoreConfig( return { root: normalizedRoot, ignore, enabled: !ignore.isEmpty }; } -/** What the mount negotiated and did with passthrough, read live. */ export interface PassthroughStatus { - /** Asked for at init: local-only paths exist and it was not turned off. */ + /** Local-only paths exist and COMPUTERD_FUSE_PASSTHROUGH did not turn it off. */ readonly requested: boolean; - /** The kernel offered the capability and computerd took it. */ readonly negotiated: boolean; - /** Opens handed to the kernel with a backing id. */ readonly opens: number; - /** Opens served by computerd because registration failed. */ readonly fallbacks: number; } @@ -91,32 +87,21 @@ export interface MountIgnoreInfo { readonly patterns: readonly string[]; readonly ineffectiveExclusions: readonly string[]; readonly fastPaths: { - /** True once the kernel is serving local-only file data directly. */ readonly passthrough: boolean; - /** - * Why passthrough is or is not active. Each inactive case has a - * different fix, so each has its own wording. - */ readonly passthroughReason: string; readonly passthroughOpens: number; readonly passthroughFallbacks: number; - /** - * Off. libfuse 3 can negotiate it, but it changes write behavior for - * the synced mount, which is a separate decision. - */ + /** Possible under libfuse 3, but it would change writes on the synced mount. */ readonly writebackCache: false; }; } -/** - * Builds the `ignore` block. `status` is the live passthrough state - * from the mount, or undefined when no kernel FUSE mount is running. - */ +/** `status` is undefined when no kernel FUSE mount is running. */ export function describeMountIgnore( config: MountIgnoreConfig, status?: PassthroughStatus, ): MountIgnoreInfo { - const { passthrough, reason } = describePassthrough(config, status); + const inactive = inactivePassthroughReason(config, status); return { supported: true, enabled: config.enabled, @@ -124,8 +109,8 @@ export function describeMountIgnore( patterns: config.ignore.patterns, ineffectiveExclusions: config.ignore.ineffectiveExclusions, fastPaths: { - passthrough, - passthroughReason: reason, + passthrough: inactive === undefined, + passthroughReason: inactive ?? activeReason(status?.fallbacks ?? 0), passthroughOpens: status?.opens ?? 0, passthroughFallbacks: status?.fallbacks ?? 0, writebackCache: false, @@ -133,48 +118,32 @@ export function describeMountIgnore( }; } -function describePassthrough( +// Each case has a different fix, so each gets its own wording. +function inactivePassthroughReason( config: MountIgnoreConfig, status: PassthroughStatus | undefined, -): { passthrough: boolean; reason: string } { - if (!config.enabled) { - return { passthrough: false, reason: "no local-only paths are configured (MOUNT_IGNORE)" }; - } +): string | undefined { + if (!config.enabled) return "no local-only paths are configured (MOUNT_IGNORE)"; if (status === undefined) { - return { - passthrough: false, - reason: "no kernel FUSE mount is running, so local-only paths are not passed through", - }; - } - if (!status.requested) { - return { passthrough: false, reason: "turned off with COMPUTERD_FUSE_PASSTHROUGH" }; + 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 { - passthrough: false, - reason: "the kernel did not offer FUSE passthrough at init; it needs Linux 6.9 or newer", - }; - } - if (status.opens === 0 && status.fallbacks > 0) { - return { - passthrough: false, - reason: - `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 "the kernel did not offer FUSE passthrough at init; it needs Linux 6.9 or newer"; } - if (status.opens === 0) { - return { - passthrough: false, - reason: "negotiated, but no local-only file has been opened yet", - }; + 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 { - passthrough: true, - reason: - status.fallbacks === 0 - ? "active" - : `active; ${status.fallbacks} open(s) fell back to computerd after a refused registration`, - }; + 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/options.test.ts b/packages/computerd/src/fuse/options.test.ts index 044c280f0..8271baeb7 100644 --- a/packages/computerd/src/fuse/options.test.ts +++ b/packages/computerd/src/fuse/options.test.ts @@ -11,16 +11,6 @@ const empty: FuseOptionEnv = {}; describe("buildFuseMountOptions", () => { test("emits the production-safe profile when no env vars are set", () => { - // attr_timeout and entry_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 hard links stat as the same inode. - // - // No big_writes (libfuse 3 removed it, and batching is the default), - // no max_write (an init field now), and no auto_cache: the keep-cache - // decision moved into the driver so it can be skipped for a - // passthrough handle, which the kernel refuses to combine with it. expect(buildFuseMountOptions(empty)).toEqual({ useIno: true, maxRead: 524288, @@ -31,14 +21,11 @@ describe("buildFuseMountOptions", () => { }); test("max_write moves to the init config, not the mount options", () => { - // libfuse 3 fails the mount if max_write arrives as a mount option. expect(buildFuseMountOptions(empty)).not.toHaveProperty("maxWrite"); expect(buildFuseInitConfig(empty).maxWrite).toBe(524288); }); test("requests a backing stack depth so an overlayfs store can register", () => { - // Without this the ioctl fails ELOOP when MOUNT_IGNORE_PATH is on - // overlayfs, which is the common Docker case. expect(buildFuseInitConfig(empty).maxBackingStackDepth).toBe(1); }); @@ -61,10 +48,6 @@ describe("buildFuseMountOptions", () => { }); test("never sets auto_cache or kernel_cache", () => { - // Both are incompatible with passthrough: libfuse applies keep_cache - // after the open callback returns without checking, and the kernel - // refuses FOPEN_KEEP_CACHE alongside FOPEN_PASSTHROUGH with EIO. The - // driver decides keepCache per open instead. for (const env of [ empty, { COMPUTERD_FUSE_EXTRA_OPTS: "auto_cache" }, @@ -77,9 +60,6 @@ describe("buildFuseMountOptions", () => { }); test("strips options that would fail the mount", () => { - // A typo in EXTRA_OPTS should not take the daemon down at startup. - // ac_attr_timeout is refused by the binding unless auto_cache is on, - // and auto_cache is never on. const out = buildFuseMountOptions({ COMPUTERD_FUSE_EXTRA_OPTS: "big_writes,max_write=4096,writeback_cache,ac_attr_timeout=3,allow_other", @@ -88,7 +68,6 @@ describe("buildFuseMountOptions", () => { expect(Object.keys(out)).not.toContain("maxWrite"); expect(Object.keys(out)).not.toContain("writebackCache"); expect(Object.keys(out)).not.toContain("acAttrTimeout"); - // ...but anything legitimate still passes through. expect(out.allowOther).toBe(true); }); @@ -126,9 +105,6 @@ describe("buildFuseMountOptions", () => { }); test("maps COMPUTERD_FUSE_EXTRA_OPTS onto the binding's option names", () => { - // The binding takes typed options rather than a raw -o string, so a - // libfuse spelling is translated: a bare flag becomes true, a number - // stays a number, and anything else is passed as a string. const out = buildFuseMountOptions({ COMPUTERD_FUSE_EXTRA_OPTS: "default_permissions,fsname=computerd,umask=18", }); @@ -154,10 +130,8 @@ describe("buildFuseMountOptions", () => { }); describe("the vendored binding", () => { - // The binding validates its options object before it mounts, and an - // unknown or conflicting name throws there rather than at libfuse. - // Unit tests never mount, so this is the check that keeps the profile - // and the binding in step. + // 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"); diff --git a/packages/computerd/src/fuse/options.ts b/packages/computerd/src/fuse/options.ts index 7f90415b0..a91b45e5f 100644 --- a/packages/computerd/src/fuse/options.ts +++ b/packages/computerd/src/fuse/options.ts @@ -1,70 +1,26 @@ -// FUSE mount option assembly, for libfuse 3. -// -// Split into two parts because libfuse 3 moved some of these out of the -// mount options and into the init config: -// -// - buildFuseMountOptions() emits what still belongs on the mount: -// use_ino, max_read, and the metadata timeouts. The vendored -// fuse-napi binding takes these as a typed object, not a raw -o -// string, and validates every name before it mounts. -// - buildFuseInitConfig() emits max_write, which libfuse 3 only -// accepts through fuse_conn_info at init. -// -// The defaults are the production-safe profile derived from the -// benchmark report and the mtime-propagation contract tests. -// attr_timeout and entry_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 hard links to stat as the same inode. 512 KiB max_read -// and max_write match the dofs CHUNK_SIZE so a single FUSE read maps to -// a single chunk fetch. -// -// Three options the libfuse 2.9 build set are deliberately gone: -// -// - big_writes. libfuse 3 removed it; batching up to max_write is the -// default there, which is what the option used to buy. -// - auto_cache. libfuse sets keep_cache *after* the open callback -// returns and does not check for passthrough, and the kernel -// refuses FOPEN_PASSTHROUGH combined with FOPEN_KEEP_CACHE -// (fs/fuse/iomode.c) with EIO. The driver now decides keepCache per -// open instead, applying auto_cache's own rule (reuse the cache -// when mtime and size are unchanged since the last open) for -// VFS-backed files only. See resolveKeepCache in driver.ts. -// - ac_attr_timeout. It only tunes auto_cache, and the binding refuses -// it without auto_cache. -// -// Every default is opt-out via the matching COMPUTERD_FUSE_* env var. -// Setting an option to "" turns it off; a valid value overrides the -// default. - -// 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_ATTR_TIMEOUT = "1"; -const DEFAULT_ENTRY_TIMEOUT = "1"; -const DEFAULT_NEGATIVE_TIMEOUT = "0"; - -// Options that must never reach the mount. -// -// - big_writes: removed in libfuse 3, which fails the mount on it. -// - max_write: init config in libfuse 3, not a mount option. -// - auto_cache / kernel_cache: incompatible with passthrough, and the -// driver now owns the keep-cache decision per open. -// - writeback_cache: possible under libfuse 3 but it changes -// behavior for the synced mount; a separate change. -// - ac_attr_timeout: tunes auto_cache only, and the binding refuses it -// without auto_cache. -const DISALLOWED_OPTS = new Set([ +// 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", - "writeback_cache", "ac_attr_timeout", + "writeback_cache", ]); export interface FuseOptionEnv { @@ -77,88 +33,67 @@ export interface FuseOptionEnv { COMPUTERD_FUSE_PASSTHROUGH?: string; } -/** - * Mount options in the binding's own spelling: camelCase names, a bare - * flag as `true`, numbers as numbers. - */ -export type FuseMountOptions = Readonly>; +type OptionValue = boolean | number | string; + +/** Mount options in the binding's spelling: camelCase names, flags as `true`. */ +export type FuseMountOptions = Readonly>; -/** The subset of the libfuse 3 init config computerd sets. */ export interface FuseInitConfig { - /** Maximum bytes per write request. Mount option in 2.9, init in 3. */ readonly maxWrite: number; - /** - * Stacking depth allowed for a passthrough backing file. 1 covers a - * backing store on overlayfs; without it registration fails ELOOP. - */ readonly maxBackingStackDepth: number; } -/** - * Build the init config handed to libfuse 3 from the init callback. - * - * Separate from the option string because libfuse 3 rejects max_write as - * a mount option, so the two cannot be assembled together. - */ export function buildFuseInitConfig(env: FuseOptionEnv): FuseInitConfig { return { - maxWrite: parsePositiveInt(env.COMPUTERD_FUSE_MAX_WRITE) ?? DEFAULT_MAX_WRITE, + 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, }; } -/** - * Build the mount options handed to the binding. Pure function over an - * env-like object, so tests can drive it directly. - */ export function buildFuseMountOptions(env: FuseOptionEnv): FuseMountOptions { - const opts: Record = { + return { + // Trust getattr's inode numbers, so hard links stat as one inode. useIno: true, - maxRead: parsePositiveInt(env.COMPUTERD_FUSE_MAX_READ) ?? DEFAULT_MAX_READ, + 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), }; - - setTimeoutOption(opts, "attrTimeout", env.COMPUTERD_FUSE_ATTR_TIMEOUT, DEFAULT_ATTR_TIMEOUT); - setTimeoutOption(opts, "entryTimeout", env.COMPUTERD_FUSE_ENTRY_TIMEOUT, DEFAULT_ENTRY_TIMEOUT); - setTimeoutOption( - opts, - "negativeTimeout", - env.COMPUTERD_FUSE_NEGATIVE_TIMEOUT, - DEFAULT_NEGATIVE_TIMEOUT, - ); - - // EXTRA_OPTS keeps the libfuse spelling operators already know - // ("allow_other,fsname=x"). Each entry is translated to the binding's - // name, so it overrides a default instead of conflicting with it. An - // unknown name still fails the mount, in the binding, with its name in - // the error. - 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 separator = trimmed.indexOf("="); - const name = separator === -1 ? trimmed : trimmed.slice(0, separator); - if (DISALLOWED_OPTS.has(name)) continue; - const raw = separator === -1 ? undefined : trimmed.slice(separator + 1); - opts[toCamelCase(name)] = raw === undefined ? true : parseOptionValue(raw); - } - } - - return opts; } -/** - * Whether to ask the kernel for FUSE passthrough on local-only paths. - * - * On unless COMPUTERD_FUSE_PASSTHROUGH says otherwise, as a switch for - * ruling passthrough in or out while chasing a problem. Only explicit - * negatives turn it off. - */ export function passthroughRequested(env: FuseOptionEnv): boolean { const value = env.COMPUTERD_FUSE_PASSTHROUGH?.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; +} + +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 toCamelCase(name: string): string { return name.replace(/_([a-z])/g, (_match, letter: string) => letter.toUpperCase()); } @@ -171,27 +106,10 @@ function parseOptionValue(raw: string): number | string { 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; + return Number.isInteger(n) && n > 0 ? n : undefined; } -function parseNonNegativeNumber(value: string | undefined): number | undefined { - if (value === undefined || value === "") return undefined; +function parseNonNegativeNumber(value: string): number | undefined { const n = Number(value); - if (!Number.isFinite(n) || n < 0) return undefined; - return n; -} - -function setTimeoutOption( - opts: Record, - 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; - opts[name] = n; + 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 index cbb3df2ea..34cdef50d 100644 --- a/packages/computerd/src/fuse/passthrough.fuse.test.ts +++ b/packages/computerd/src/fuse/passthrough.fuse.test.ts @@ -1,17 +1,8 @@ -// Kernel FUSE passthrough for local-only paths, against a real mount. -// -// The unit tests drive the backing-id table through a fake registrar. -// What they cannot show is the kernel's side: that a passthrough open -// really takes computerd out of the data path, and that concurrent -// opens of one file do not trip the EIO the kernel gives a second -// backing id for the same inode. This boots the linux-x64 binary in a -// privileged container, the same way 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, -// rather than fail, when the host kernel does not offer passthrough -// (Linux before 6.9), since that is a property of the machine running -// the test. +// 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"; @@ -52,7 +43,7 @@ describeIfReal("FUSE passthrough under a real mount", () => { let cid: string | undefined; let url = ""; let client: ReturnType | undefined; - let kernelOffersPassthrough = false; + let passthroughAvailable = false; beforeAll(async () => { const proc = spawn("bash", [RUN_COMPUTERD_SCRIPT], { @@ -75,10 +66,7 @@ describeIfReal("FUSE passthrough under a real mount", () => { url: `${url.replace(/^http(s?):\/\//, "ws$1://")}/api`, WebSocketImpl: WebSocket, }); - // Before any open the reason says whether the kernel offered the - // capability at all, or whether COMPUTERD_FUSE_PASSTHROUGH in the - // caller's environment turned it off. - kernelOffersPassthrough = !/kernel did not offer|turned off/.test( + passthroughAvailable = !/kernel did not offer|turned off/.test( (await fastPaths()).passthroughReason, ); }, 120_000); @@ -129,7 +117,7 @@ describeIfReal("FUSE passthrough under a real mount", () => { } test("the kernel serves local-only file data without the daemon", async (ctx) => { - if (!kernelOffersPassthrough) ctx.skip(); + if (!passthroughAvailable) ctx.skip(); const before = await dataOps(); const result = await run( [ @@ -142,8 +130,6 @@ describeIfReal("FUSE passthrough under a real mount", () => { expect(result.code).toBe(0); expect(result.stdout).toBe(`${8 * 1024 * 1024}\nsame\n`); - // Through computerd this would be 8 writes and 16 or more reads at - // the 512 KiB request size. Passthrough makes it none. expect(await dataOps()).toEqual(before); const reported = await fastPaths(); @@ -153,10 +139,7 @@ describeIfReal("FUSE passthrough under a real mount", () => { }, 60_000); test("concurrent opens of one file, and of a hard link to it, all work", async (ctx) => { - // A second backing id for an inode that already has one makes the - // kernel fail the open with EIO. Every open here goes through the - // one shared id instead. - if (!kernelOffersPassthrough) ctx.skip(); + if (!passthroughAvailable) ctx.skip(); const result = await run( [ "mkdir -p node_modules", @@ -173,9 +156,6 @@ describeIfReal("FUSE passthrough under a real mount", () => { }, 60_000); test("a synced file still reuses the page cache across opens", async () => { - // auto_cache is gone from the mount options, because the kernel - // refuses it alongside passthrough. The driver applies the same rule - // per open instead: an unchanged file keeps its cached pages. 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"); diff --git a/packages/computerd/src/fuse/passthrough.test.ts b/packages/computerd/src/fuse/passthrough.test.ts index 02c1249ea..473cc9a69 100644 --- a/packages/computerd/src/fuse/passthrough.test.ts +++ b/packages/computerd/src/fuse/passthrough.test.ts @@ -127,9 +127,7 @@ describe("withLocalPassthrough: routing", () => { }); test("fsync on a local handle stays local, whatever datasync is", () => { - // fuse-napi passes (path, datasync, fd, cb). With the arguments - // swapped, the boolean lands where the handle should be, misses - // the local handle range, and the call leaks into the VFS. + // 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) => { @@ -876,7 +874,6 @@ describe("withLocalPassthrough: kernel passthrough", () => { rmSync(root, { recursive: true, force: true }); }); - /** Stands in for the binding's backingOpen / backingClose. */ const registrar = (failWith?: string) => { const opened: number[] = []; const closed: number[] = []; @@ -945,7 +942,6 @@ describe("withLocalPassthrough: kernel passthrough", () => { }); test("never sets keepCache or directIO alongside a backing id", () => { - // The kernel refuses either combination with EIO at open time. const { ops } = build(registrar().passthrough); const reply = open(ops, "/node_modules/a.js") as Record; expect(reply).not.toHaveProperty("keepCache"); @@ -953,10 +949,6 @@ describe("withLocalPassthrough: kernel passthrough", () => { }); test("shares one backing id between concurrent opens of one file", () => { - // Two registrations of the same file both succeed and return - // distinct ids, and then the second open fails EIO in the kernel. - // There is no error to catch at registration, so reuse is the only - // correct behavior. const backing = registrar(); const { ops } = build(backing.passthrough); const first = open(ops, "/node_modules/a.js"); @@ -1021,8 +1013,6 @@ describe("withLocalPassthrough: kernel passthrough", () => { }); test("says the kernel did not offer passthrough when that is the reason", () => { - // The driver's registrar answers ENOTSUP until init has negotiated - // the capability. Advice about CAP_SYS_ADMIN would mislead here. const { ops, warnings } = build(registrar("ENOTSUP").passthrough); open(ops, "/node_modules/a.js"); expect(warnings[0]).toContain("did not negotiate"); diff --git a/packages/computerd/src/fuse/passthrough.ts b/packages/computerd/src/fuse/passthrough.ts index b72db813d..575e70a03 100644 --- a/packages/computerd/src/fuse/passthrough.ts +++ b/packages/computerd/src/fuse/passthrough.ts @@ -6,23 +6,16 @@ // is provably a no-op: `withLocalPassthrough` returns the source object // unchanged. // -// When the mount negotiated FUSE passthrough, an open of a local-only -// file registers the file with the kernel and replies with its backing -// id. The kernel then serves reads, writes, and mmap for that handle -// straight from the backing file, and this process is out of the data -// path. Metadata (lookup, getattr, readdir) still comes here. +// 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. // -// When passthrough is unavailable, for the mount or for one file, 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. -// -// Those fallback 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, @@ -90,19 +83,11 @@ export interface LocalPassthroughOptions { readonly onMaterialize?: (relativePath: string) => void; /** Operator-facing warnings. Defaults to console.warn; injected for tests. */ readonly warn?: (message: string) => void; - /** - * Registers backing files for kernel passthrough. Omitted means every - * local-only open is served by this process. - */ + /** Omitted means every local-only open is served by this process. */ readonly passthrough?: PassthroughRegistrar; } -/** - * The binding's backing-file registration, as a seam. - * - * `backingOpen` throws an Error whose `code` is the errno name when the - * kernel refuses, or when the mount did not negotiate passthrough. - */ +/** `backingOpen` throws an Error whose `code` is the errno name. */ export interface PassthroughRegistrar { backingOpen(fd: number): number; backingClose(backingId: number): void; @@ -247,15 +232,11 @@ export function withLocalPassthrough( let nextHandle = LOCAL_HANDLE_BASE; const handles = new Map(); - // One backing id per file, shared by every open of it. The kernel - // allows a single backing file per inode: a second registration of - // the same file succeeds and returns a new id, and then the open that - // uses it fails with EIO. Nothing reports the conflict at - // registration, so the table is the only thing preventing it. - // - // Keyed on dev:ino rather than the path, because a hard link reaches - // the same inode through a different name. - const backing = 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 => { @@ -761,56 +742,57 @@ export function withLocalPassthrough( return handle; } - // Attach a backing id to a freshly opened handle, reusing the file's - // existing id when it has one. Returns what open/create reply with. - // - // Never sets keepCache or directIO alongside the id: the kernel - // refuses either combination with EIO. + // 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 }; + } - let key: string; + function inodeKey(fd: number): string | undefined { try { - const stat = fs.fstatSync(handle.fd); - key = `${stat.dev}:${stat.ino}`; + const stat = fs.fstatSync(fd); + return `${stat.dev}:${stat.ino}`; } catch { - return fh; - } - - const existing = backing.get(key); - if (existing !== undefined) { - existing.refs += 1; - handle.backingKey = key; - passthroughOpens += 1; - return { fd: fh, backingId: existing.id }; + return undefined; } + } - let id: number; + // 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 { - id = registrar.backingOpen(handle.fd); + return target.backingOpen(handle.fd); } catch (error) { - // Served by this process instead, through read/write below. A - // refusal is a property of the file or the mount (EPERM without - // CAP_SYS_ADMIN, ELOOP for a backing store stacked too deep), so - // every open of the same file falls back the same way and the - // kernel never sees a mix of the two modes on one inode. reportFallback(handle.path, error); - return fh; + return undefined; } - backing.set(key, { id, refs: 1 }); - handle.backingKey = key; - passthroughOpens += 1; - return { fd: fh, backingId: id }; } function releaseBacking(key: string | undefined): void { if (key === undefined || registrar === undefined) return; - const entry = backing.get(key); + const entry = backingIds.get(key); if (entry === undefined) return; entry.refs -= 1; if (entry.refs > 0) return; - backing.delete(key); + backingIds.delete(key); try { registrar.backingClose(entry.id); } catch (error) { @@ -920,7 +902,7 @@ export function withLocalPassthrough( crossLayerRenames, passthroughOpens, passthroughFallbacks, - passthroughFiles: backing.size, + passthroughFiles: backingIds.size, }), }; } diff --git a/script/npm-bench.sh b/script/npm-bench.sh index 1275580bc..cbe089af6 100755 --- a/script/npm-bench.sh +++ b/script/npm-bench.sh @@ -28,10 +28,8 @@ # TARGETS comma-separated label:directory pairs to install into # (default: native:$BASE,fuse:$MOUNT) # -# Each run also times reading every installed file back once, after the -# install, as readMs. An install is mostly metadata (create, mkdir, -# rename); the read-back is the data path, which is what FUSE -# passthrough changes for local-only paths. +# 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}" @@ -205,7 +203,6 @@ run_install() { end_ts="$(date +%s%N)" local elapsed_ms=$(( (end_ts - start_ts) / 1000000 )) - # Read every installed file back once. local read_start read_end read_ms=0 if [ -d "$work_dir/node_modules" ]; then read_start="$(date +%s%N)" diff --git a/script/run-npm-bench-inner.sh b/script/run-npm-bench-inner.sh index 556c6a3e6..235c6e6d2 100755 --- a/script/run-npm-bench-inner.sh +++ b/script/run-npm-bench-inner.sh @@ -11,9 +11,8 @@ apt-get install -y --no-install-recommends \ mkdir -p /tmp/workspace /tmp/baseline -# With IGNORE_NODE_MODULES=1, each run's node_modules under -# /tmp/workspace/ignored is local-only while its package.json and lock -# stay synced, which is how MOUNT_IGNORE is meant to be used. +# 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" diff --git a/script/run-npm-bench.sh b/script/run-npm-bench.sh index 82f08d6f7..2851c95d4 100755 --- a/script/run-npm-bench.sh +++ b/script/run-npm-bench.sh @@ -24,10 +24,9 @@ # /tmp/workspace/ignored local-only (MOUNT_IGNORE) # COMPUTERD_FUSE_PASSTHROUGH set to 0 to measure local-only paths # without kernel passthrough -# PLATFORM docker platform (default: linux/amd64). Use -# linux/arm64 with a computerd-linux-arm64 binary to run -# natively on an arm64 machine; emulated timings are -# not meaningful. +# 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 From d48158da286a1e954a9fab80074150d144f51d80 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:13:52 +0100 Subject: [PATCH 14/28] computer: Let the computerd harness pick the platform and image The harness always ran a linux/amd64 container, so an arm64 binary could not be tested with it and an x64 one ran under emulation on an arm64 machine. COMPUTERD_HARNESS_PLATFORM now picks the platform, and the cached image is tagged per platform so the two do not overwrite each other. COMPUTERD_HARNESS_IMAGE boots a prepared image instead of the stock one, for instance one committed from a running container to stand in for a snapshot restore. MOUNT_NATIVE and COMPUTERD_DB are forwarded into the container along with the variables already passed through. --- .../computer/test-harness/run-computerd.sh | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/computer/test-harness/run-computerd.sh b/packages/computer/test-harness/run-computerd.sh index 271b84ae0..516e1718f 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 @@ -35,11 +38,13 @@ fi # 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. -IMAGE_TAG="computerd-harness:libfuse3" -if ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then +# 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:trixie-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 attr util-linux coreutils findutils \ @@ -48,11 +53,12 @@ RUN apt-get update >/dev/null \ DOCKERFILE fi -# Lets a test boot computerd with MOUNT_IGNORE or COMPUTERD_FUSE_* set. +# Lets a test boot computerd with MOUNT_IGNORE, MOUNT_NATIVE, COMPUTERD_DB, or +# COMPUTERD_FUSE_* set. FORWARD_ENV=() while IFS='=' read -r name _; do case "$name" in - MOUNT_IGNORE | MOUNT_IGNORE_PATH | COMPUTERD_FUSE_*) FORWARD_ENV+=(-e "$name") ;; + MOUNT_IGNORE | MOUNT_IGNORE_PATH | MOUNT_NATIVE | COMPUTERD_DB | COMPUTERD_FUSE_*) FORWARD_ENV+=(-e "$name") ;; esac done < <(env) @@ -60,7 +66,7 @@ done < <(env) # 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 \ From 51d291571337f75aade02dcf92fa6cb93ded81d9 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:13:52 +0100 Subject: [PATCH 15/28] computerd: Bind-mount local-only directories named in MOUNT_NATIVE FUSE passthrough sends a local-only file's data straight to the kernel, but every lookup, stat, create, and listing under node_modules still reaches computerd's event loop, and for a dependency tree that is most of the work. MOUNT_NATIVE names a subset of the MOUNT_IGNORE patterns whose directories are bind-mounted onto the mount instead, so nothing under them reaches computerd at all. The source of each bind mount is the directory the local-only layer already serves the path from. That is what keeps the feature small: a mount only changes how fast a path is served, never what it contains, so a mount that lands late, fails, or is detached needs no handling beyond leaving the path to the FUSE layer. /proc/self/mountinfo is the only record of what is mounted, since anything under the mount point other than the FUSE mount itself was mounted here, and a lazy detach is always safe. There is no watcher. A native root's parent is synced, so the mkdir that creates one reaches the local-only layer, which reports it after replying; computerd then mounts it from a child process, because resolving a path under the mount from the event loop would wait on a request that same loop has to answer. A lookup that finds a root on disk but not mounted, as after a snapshot restore once the host has pushed the synced parent back, mounts it too, and startup mounts roots already present. A refused root is retried only when created again, and one detached on request is parked so a lookup cannot remount it under the rm that follows. Renames of a synced ancestor need nothing: the kernel moves the mount with the dentry. Native roots require negotiated passthrough, which shares their capability and kernel requirements and rules out a second page cache for files opened through FUSE before the mount landed. The one behavior change is that rmdir and rename of a root itself return EBUSY, as for a Docker volume, since the kernel never forwards them; POST /__computerd/native-roots/detach detaches roots on request. /__computerd/info reports the state under fastPaths.nativeRoots, and everything is detached before FUSE is unmounted on SIGTERM. MOUNT_IGNORE sets gain matches(), which tests a single path against the patterns rather than asking whether it lies under a match. --- .changeset/native-roots.md | 23 + packages/computerd/README.md | 104 +++++ packages/computerd/src/cli/computerd.test.ts | 49 +++ packages/computerd/src/cli/computerd.ts | 103 ++++- .../computerd/src/fuse/ignore-config.test.ts | 87 ++++ packages/computerd/src/fuse/ignore-config.ts | 133 +++++- packages/computerd/src/fuse/ignore.test.ts | 26 ++ packages/computerd/src/fuse/ignore.ts | 19 + packages/computerd/src/fuse/index.ts | 5 + .../src/fuse/native-roots.fuse.test.ts | 415 ++++++++++++++++++ .../computerd/src/fuse/native-roots.test.ts | 397 +++++++++++++++++ packages/computerd/src/fuse/native-roots.ts | 330 ++++++++++++++ .../computerd/src/fuse/passthrough.test.ts | 80 ++++ packages/computerd/src/fuse/passthrough.ts | 26 +- 14 files changed, 1789 insertions(+), 8 deletions(-) create mode 100644 .changeset/native-roots.md create mode 100644 packages/computerd/src/fuse/native-roots.fuse.test.ts create mode 100644 packages/computerd/src/fuse/native-roots.test.ts create mode 100644 packages/computerd/src/fuse/native-roots.ts diff --git a/.changeset/native-roots.md b/.changeset/native-roots.md new file mode 100644 index 000000000..16aeacd04 --- /dev/null +++ b/.changeset/native-roots.md @@ -0,0 +1,23 @@ +--- +"@cloudflare/computerd": minor +--- + +Bind-mount local-only directories named in `MOUNT_NATIVE` + +`MOUNT_NATIVE` names a subset of the `MOUNT_IGNORE` patterns whose +directories computerd bind-mounts onto the mount from the place it already +stores them, under `MOUNT_IGNORE_PATH`. Everything under such a directory is +then served by the kernel from the container's disk: lookups, `stat`, +creates, and listings no longer reach computerd, where FUSE passthrough alone +only took the file data off its path. Content is the same either way, so a +mount that fails or lands late changes speed, not what a command sees. + +A directory is mounted when a command creates it, when a lookup finds one on +disk that is not mounted yet (after a snapshot restore), and at startup. +Native roots need negotiated FUSE passthrough, `CAP_SYS_ADMIN`, and `mount` +from util-linux; without them nothing changes. `/__computerd/info` reports +the state under `ignore.fastPaths.nativeRoots`. + +Removing or renaming a mounted directory itself fails with `EBUSY`, as for a +Docker volume. `POST /__computerd/native-roots/detach` detaches one, or all, +first. Unset `MOUNT_NATIVE` leaves behavior exactly as it was. diff --git a/packages/computerd/README.md b/packages/computerd/README.md index 09ed17a4d..d1005a202 100644 --- a/packages/computerd/README.md +++ b/packages/computerd/README.md @@ -336,6 +336,109 @@ 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 (`MOUNT_NATIVE`) + +A local-only path is served from the container's disk, but every +lookup, `stat`, create, and listing under it still reaches computerd. +FUSE passthrough sends the file data straight to the kernel; the +metadata keeps coming through. For a dependency tree that is most of +the work. + +`MOUNT_NATIVE` names local-only directories to bind-mount instead. The +mount's source is the directory computerd already serves the path from, +under `MOUNT_IGNORE_PATH`, so everything under it is served by the +kernel from the container's disk and never reaches computerd at all. +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 is never a +problem for what a command sees. + +```ts +new ContainerBackend({ + ignore: ["**/node_modules", "/dist"], + containerEnv: { MOUNT_NATIVE: "**/node_modules" }, +}); +``` + +Each entry must also be in `MOUNT_IGNORE`, written the same way after +normalization, and must not be an exclusion; anything else fails the +daemon at startup. A native root is the topmost directory a +`MOUNT_NATIVE` pattern names: `packages/app/node_modules` is one, the +`node_modules` directories nested inside it are already under it. A +symlink or a file is never mounted. + +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, +and `COMPUTERD_FUSE_PASSTHROUGH` not turned off), `CAP_SYS_ADMIN`, and +`mount` from util-linux in the image. Without any of these, the paths +are served exactly as before. + +#### The one difference: 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 never asks computerd, so it cannot +intervene: + +| Command | Without a native root | With one | +| --- | --- | --- | +| `rm -rf node_modules` | works | empties it, then `EBUSY` on the directory itself | +| `rm -rf node_modules/*` | works | works | +| `mv node_modules elsewhere` | `EXDEV`, so `mv` copies | `EBUSY` | +| `rm -rf packages/app` with a root inside | works | `EBUSY` on the root | +| `mv packages/app packages/web` | works | works; the mount moves with it | + +These are the rules of a Docker volume mounted at `node_modules`, which +most JavaScript tooling already lives with. To remove a root outright, +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. A detach is always safe: the directory stays where it +was 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", "patterns": ["**/node_modules"], + "active": 3, "mounts": 4, "failures": 0, "detaches": 1 } +``` + +`active` is read from `/proc/self/mountinfo`, which is the only record +of what is mounted: anything mounted under `MOUNT_POINT` other than the +FUSE mount itself is a native root. `reason` explains a disabled state +(not configured, no kernel FUSE mount, passthrough off or not offered) +or names the last failure when mounts are being refused. +`/__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 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. @@ -368,6 +471,7 @@ 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 +MOUNT_NATIVE=**/node_modules # local-only directories to bind-mount; each must also be in MOUNT_IGNORE ``` `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. diff --git a/packages/computerd/src/cli/computerd.test.ts b/packages/computerd/src/cli/computerd.test.ts index 23719977a..dba3b959e 100644 --- a/packages/computerd/src/cli/computerd.test.ts +++ b/packages/computerd/src/cli/computerd.test.ts @@ -115,6 +115,15 @@ test("computerd exposes file IO through real FUSE when FUSE_MOUNT=fuse", async ( passthroughOpens: 0, passthroughFallbacks: 0, writebackCache: false, + nativeRoots: { + enabled: false, + reason: expect.stringContaining("MOUNT_NATIVE"), + patterns: [], + active: 0, + mounts: 0, + failures: 0, + detaches: 0, + }, }, }, }); @@ -1174,6 +1183,46 @@ 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 refuses a MOUNT_NATIVE pattern that is not in MOUNT_IGNORE", async () => { + // A native root is a local-only path served faster. One that is not + // local-only would be a synced path bypassing the VFS. + const port = await getAvailablePort(); + const mountPoint = await fs.mkdtemp(path.join(os.tmpdir(), "computerd-mount-")); + const child = spawn(cliPath, { + cwd: packageRoot, + env: { + ...process.env, + MOUNT_POINT: mountPoint, + PORT: String(port), + FUSE_MOUNT: "none", + MOUNT_IGNORE: "/dist", + MOUNT_NATIVE: "**/node_modules", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + + const { code, stderr } = await waitForExit(child); + expect(code).toBe(1); + expect(stderr).toMatch(/MOUNT_NATIVE pattern "\*\*\/node_modules" is not in MOUNT_IGNORE/); +}); + 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 0e6cae086..4a99a64c7 100644 --- a/packages/computerd/src/cli/computerd.ts +++ b/packages/computerd/src/cli/computerd.ts @@ -1,7 +1,7 @@ #!/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 { isAbsolute } from "node:path"; @@ -14,12 +14,14 @@ import { WebSocket, WebSocketServer } from "ws"; import { Runner } from "../exec/index.js"; import type { ExecEvent as ComputerdExecEvent } from "../exec/types.js"; import { + createNativeRoots, createNodeVirtualFileSystem, describeMountIgnore, type FUSEBackend, type FuseMount, type MountIgnoreInfo, mountFuse, + type NativeRoots, parseFuseMountMode, parseStoreMode, type ResolvedStore, @@ -202,6 +204,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 +284,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 +593,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) { @@ -690,10 +737,31 @@ async function main(): Promise { store, // A getter, so each request reports live passthrough state. get ignore() { - return describeMountIgnore(ignoreConfig, fuse?.getPassthroughStatus?.()); + return describeMountIgnore( + ignoreConfig, + fuse?.getPassthroughStatus?.(), + nativeRoots?.stats(), + ); }, }; + // Native roots (MOUNT_NATIVE). 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 nativeRoots: NativeRoots | undefined = + ignoreConfig.native.enabled && backend.kind !== "none" && backend.kind !== "shim" + ? createNativeRoots({ + root: ignoreConfig.root, + mountPoint, + ignore: ignoreConfig.ignore, + native: ignoreConfig.native.matches, + active: () => fuse?.getPassthroughStatus?.().negotiated === true, + }) + : undefined; + if (nativeRoots !== undefined) { + console.log(`[info] MOUNT_NATIVE active: ${ignoreConfig.native.patterns.join(", ")}`); + } + // 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 @@ -718,6 +786,9 @@ async function main(): Promise { if (ignoreConfig.enabled) { await mkdir(ignoreConfig.root, { recursive: true }); } + // Bind mounts a previous run left under the mount point, if any. A + // lazy detach is always safe, so there is nothing to check first. + await nativeRoots?.detachAll(); fuse = await mountFuse({ backend, mountPoint, @@ -728,10 +799,25 @@ 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(() => {}); + // 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 @@ -808,12 +894,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; @@ -832,6 +926,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/ignore-config.test.ts b/packages/computerd/src/fuse/ignore-config.test.ts index fb4a725ac..42b5fb021 100644 --- a/packages/computerd/src/fuse/ignore-config.test.ts +++ b/packages/computerd/src/fuse/ignore-config.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest"; import { defaultIgnoreRoot, describeMountIgnore, + MountNativeError, resolveMountIgnoreConfig, } from "./ignore-config.js"; @@ -178,3 +179,89 @@ describe("describeMountIgnore", () => { expect(info.fastPaths.passthroughReason).toMatch(/no local-only file has been opened/); }); }); + +describe("resolveMountIgnoreConfig: MOUNT_NATIVE", () => { + const resolve = (env: Record) => + resolveMountIgnoreConfig( + { MOUNT_IGNORE: "**/node_modules,/dist,!/vendor/node_modules", ...env }, + "/workspace", + ); + + test("is off when unset or empty", () => { + expect(resolve({}).native).toMatchObject({ enabled: false, patterns: [] }); + expect(resolve({ MOUNT_NATIVE: " , " }).native).toMatchObject({ enabled: false, patterns: [] }); + }); + + test("accepts a subset of MOUNT_IGNORE, normalized the same way", () => { + const native = resolve({ MOUNT_NATIVE: "**/node_modules, /workspace/dist/" }).native; + expect(native.enabled).toBe(true); + expect(native.patterns).toEqual(["**/node_modules", "/dist"]); + expect(native.matches("app/node_modules")).toBe(true); + expect(native.matches("dist")).toBe(true); + expect(native.matches("app/node_modules/react")).toBe(false); + }); + + test("rejects a pattern that is not in MOUNT_IGNORE", () => { + expect(() => resolve({ MOUNT_NATIVE: "**/.venv" })).toThrow(/not in MOUNT_IGNORE/); + expect(() => resolve({ MOUNT_NATIVE: "/node_modules" })).toThrow(/not in MOUNT_IGNORE/); + }); + + test("rejects MOUNT_NATIVE without MOUNT_IGNORE", () => { + expect(() => + resolveMountIgnoreConfig({ MOUNT_NATIVE: "**/node_modules" }, "/workspace"), + ).toThrow(/not in MOUNT_IGNORE/); + }); + + test("rejects an exclusion", () => { + expect(() => resolve({ MOUNT_NATIVE: "!/vendor/node_modules" })).toThrow(MountNativeError); + }); + + test("reports a malformed pattern under its own name", () => { + expect(() => resolve({ MOUNT_NATIVE: "node_modules" })).toThrow(/^MOUNT_NATIVE pattern/); + }); +}); + +describe("describeMountIgnore: nativeRoots", () => { + const configured = resolveMountIgnoreConfig( + { MOUNT_IGNORE: "**/node_modules", MOUNT_NATIVE: "**/node_modules" }, + "/workspace", + ); + const live = { requested: true, negotiated: true, opens: 0, fallbacks: 0 }; + const counters = { active: 2, mounts: 3, failures: 0, detaches: 1, lastFailure: undefined }; + + test("reports the patterns and counters when active", () => { + expect(describeMountIgnore(configured, live, counters).fastPaths.nativeRoots).toEqual({ + enabled: true, + reason: "active", + patterns: ["**/node_modules"], + active: 2, + mounts: 3, + failures: 0, + detaches: 1, + }); + }); + + test("is off when MOUNT_NATIVE is unset", () => { + const config = resolveMountIgnoreConfig({ MOUNT_IGNORE: "**/node_modules" }, "/workspace"); + const info = describeMountIgnore(config, live).fastPaths.nativeRoots; + expect(info.enabled).toBe(false); + expect(info.reason).toMatch(/MOUNT_NATIVE/); + }); + + 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 }); + expect(off.fastPaths.nativeRoots.reason).toMatch(/COMPUTERD_FUSE_PASSTHROUGH/); + const old = describeMountIgnore(configured, { ...live, negotiated: false }); + 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 = { ...counters, active: 0, mounts: 0, failures: 4, lastFailure: "a: exited 32" }; + 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(/exited 32/); + }); +}); diff --git a/packages/computerd/src/fuse/ignore-config.ts b/packages/computerd/src/fuse/ignore-config.ts index 3e24bb13d..fe9cf9f60 100644 --- a/packages/computerd/src/fuse/ignore-config.ts +++ b/packages/computerd/src/fuse/ignore-config.ts @@ -7,7 +7,12 @@ import { isAbsolute, join, resolve } from "node:path"; -import { type MountIgnoreSet, parseMountIgnore, resolveMountIgnore } from "./ignore.js"; +import { + MountIgnorePathError, + type MountIgnoreSet, + parseMountIgnore, + resolveMountIgnore, +} from "./ignore.js"; export interface MountIgnoreConfig { /** Where local-only paths are stored. Absolute, outside the mount. */ @@ -16,11 +21,30 @@ export interface MountIgnoreConfig { readonly ignore: MountIgnoreSet; /** True when at least one path is configured. */ readonly enabled: boolean; + /** Local-only directories to bind-mount from `root` (MOUNT_NATIVE). */ + readonly native: NativeRootsConfig; +} + +export interface NativeRootsConfig { + /** Normalized MOUNT_NATIVE patterns, in the order written. */ + readonly patterns: readonly string[]; + /** True when at least one pattern is configured. */ + readonly enabled: boolean; + /** Whether a mount-relative path is one a MOUNT_NATIVE pattern names. */ + readonly matches: (relativePath: string) => boolean; } export interface MountIgnoreEnv { MOUNT_IGNORE?: string; MOUNT_IGNORE_PATH?: string; + MOUNT_NATIVE?: string; +} + +export class MountNativeError extends Error { + constructor(message: string) { + super(message); + this.name = "MountNativeError"; + } } /** @@ -66,7 +90,50 @@ export function resolveMountIgnoreConfig( throw new Error("MOUNT_IGNORE_PATH must not be the filesystem root"); } - return { root: normalizedRoot, ignore, enabled: !ignore.isEmpty }; + const native = resolveNativeRoots(env.MOUNT_NATIVE, ignore, mountPoint); + return { root: normalizedRoot, ignore, enabled: !ignore.isEmpty, native }; +} + +/** + * MOUNT_NATIVE names a subset of MOUNT_IGNORE. Each entry must be written + * as one of the MOUNT_IGNORE inclusions (after the same normalization), + * so a native root is always a path that is already local-only and the + * bind mount changes how it is served, never whether it is synced. + */ +export function resolveNativeRoots( + raw: string | undefined, + ignore: MountIgnoreSet, + mountPoint: string, +): NativeRootsConfig { + const entries = parseMountIgnore(raw); + const patterns: string[] = []; + for (const entry of entries) { + if (entry.startsWith("!")) { + throw new MountNativeError( + `MOUNT_NATIVE pattern ${JSON.stringify(entry)} is an exclusion. MOUNT_NATIVE lists ` + + `local-only directories to mount; keep exclusions in MOUNT_IGNORE.`, + ); + } + let normalized: string; + try { + normalized = resolveMountIgnore([entry], mountPoint).patterns[0] as string; + } catch (error) { + if (error instanceof MountIgnorePathError) { + throw new MountNativeError(error.message.replace(/^MOUNT_IGNORE/, "MOUNT_NATIVE")); + } + throw error; + } + if (!ignore.patterns.includes(normalized)) { + throw new MountNativeError( + `MOUNT_NATIVE pattern ${JSON.stringify(entry)} is not in MOUNT_IGNORE. Only ` + + `local-only paths can be mounted natively; add ${JSON.stringify(normalized)} ` + + `to MOUNT_IGNORE as well.`, + ); + } + patterns.push(normalized); + } + const set = resolveMountIgnore(patterns, "/"); + return { patterns, enabled: patterns.length > 0, matches: set.matches }; } export interface PassthroughStatus { @@ -93,15 +160,38 @@ export interface MountIgnoreInfo { readonly passthroughFallbacks: number; /** Possible under libfuse 3, but it would change writes on the synced mount. */ readonly writebackCache: false; + readonly nativeRoots: NativeRootsInfo; }; } +/** Counters from the native-roots module, as `describeMountIgnore` reads them. */ +export interface NativeRootsCounters { + readonly active: number; + readonly mounts: number; + readonly failures: number; + readonly detaches: number; + readonly lastFailure: string | undefined; +} + +export interface NativeRootsInfo { + /** True when native roots are being mounted. */ + readonly enabled: boolean; + readonly reason: string; + readonly patterns: readonly string[]; + readonly active: number; + readonly mounts: number; + readonly failures: number; + readonly detaches: number; +} + /** `status` is undefined when no kernel FUSE mount is running. */ export function describeMountIgnore( config: MountIgnoreConfig, status?: PassthroughStatus, + native?: NativeRootsCounters, ): MountIgnoreInfo { const inactive = inactivePassthroughReason(config, status); + const nativeInactive = inactiveNativeRootsReason(config, status); return { supported: true, enabled: config.enabled, @@ -114,10 +204,49 @@ export function describeMountIgnore( passthroughOpens: status?.opens ?? 0, passthroughFallbacks: status?.fallbacks ?? 0, writebackCache: false, + nativeRoots: { + enabled: nativeInactive === undefined, + reason: nativeInactive ?? nativeActiveReason(native), + patterns: config.native.patterns, + active: native?.active ?? 0, + mounts: native?.mounts ?? 0, + failures: native?.failures ?? 0, + detaches: native?.detaches ?? 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, +): string | undefined { + if (!config.native.enabled) return "no native roots are configured (MOUNT_NATIVE)"; + if (status === undefined) return "no kernel FUSE mount is running"; + 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: NativeRootsCounters | 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 that computerd has CAP_SYS_ADMIN and ` + + `that util-linux mount is installed` + ); + } + 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, diff --git a/packages/computerd/src/fuse/ignore.test.ts b/packages/computerd/src/fuse/ignore.test.ts index 18794caf2..49296e9a4 100644 --- a/packages/computerd/src/fuse/ignore.test.ts +++ b/packages/computerd/src/fuse/ignore.test.ts @@ -167,6 +167,32 @@ describe("resolveMountIgnore: exclusions", () => { }); }); +describe("resolveMountIgnore: matches", () => { + test("is true only for the path a pattern names, not what lies under it", () => { + const ignore = set(["**/node_modules", "/dist"]); + expect(ignore.matches("node_modules")).toBe(true); + expect(ignore.matches("app/node_modules")).toBe(true); + expect(ignore.matches("dist")).toBe(true); + expect(ignore.matches("node_modules/react")).toBe(false); + expect(ignore.matches("dist/main.js")).toBe(false); + expect(ignore.matches("src")).toBe(false); + expect(ignore.matches("")).toBe(false); + }); + + test("the last matching pattern wins, as for ignores", () => { + const ignore = set(["**/node_modules", "!/vendor/node_modules"]); + expect(ignore.matches("vendor/node_modules")).toBe(false); + expect(ignore.matches("app/node_modules")).toBe(true); + }); + + test("a nested match under a matched directory still matches", () => { + // matches() looks at one path; whether an ancestor is local is + // ignores()' question. + const ignore = set(["**/node_modules"]); + expect(ignore.matches("node_modules/a/node_modules")).toBe(true); + }); +}); + describe("resolveMountIgnore: normalization", () => { test("keeps patterns in the order written", () => { // Order changes the meaning once exclusions are involved. diff --git a/packages/computerd/src/fuse/ignore.ts b/packages/computerd/src/fuse/ignore.ts index 174798653..991c007f2 100644 --- a/packages/computerd/src/fuse/ignore.ts +++ b/packages/computerd/src/fuse/ignore.ts @@ -31,6 +31,12 @@ export class MountIgnorePathError extends Error { export interface MountIgnoreSet { /** Whether a mount-relative path is local-only. Never touches disk. */ readonly ignores: (relativePath: string) => boolean; + /** + * Whether the last pattern matching exactly this path, not a directory + * above it, is an inclusion. `ignores` answers "is this path local?"; + * this answers "is this the path a pattern names?". + */ + readonly matches: (relativePath: string) => boolean; /** Normalized patterns, in the order written. Order matters. */ readonly patterns: readonly string[]; /** Exclusions under a local-only directory, which therefore do nothing. */ @@ -99,8 +105,21 @@ export function resolveMountIgnore(entries: readonly string[], mountPoint = "/") return false; }; + const matches = (relativePath: string): boolean => { + if (isEmpty) return false; + const path = stripSlashes(relativePath); + if (path === "") return false; + const segments = path.split("/"); + let matched = false; + for (const pattern of compiled) { + if (matchSegments(pattern.segments, 0, segments, 0)) matched = !pattern.exclude; + } + return matched; + }; + return { ignores, + matches, isEmpty, patterns: compiled.map((pattern) => pattern.source), ineffectiveExclusions: compiled diff --git a/packages/computerd/src/fuse/index.ts b/packages/computerd/src/fuse/index.ts index dbd9e798d..425e0d62f 100644 --- a/packages/computerd/src/fuse/index.ts +++ b/packages/computerd/src/fuse/index.ts @@ -8,13 +8,18 @@ export type { MountIgnoreConfig, MountIgnoreEnv, MountIgnoreInfo, + NativeRootsConfig, + NativeRootsInfo, PassthroughStatus, } from "./ignore-config.js"; export { defaultIgnoreRoot, describeMountIgnore, + MountNativeError, resolveMountIgnoreConfig, } from "./ignore-config.js"; +export type { NativeRoots, NativeRootsOptions, NativeRootsStats } 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"; 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..e0ac8827c --- /dev/null +++ b/packages/computerd/src/fuse/native-roots.fuse.test.ts @@ -0,0 +1,415 @@ +// Boots the binary in a privileged container, as passthrough.fuse.test.ts +// does, with MOUNT_NATIVE 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 { afterAll, beforeAll, describe, expect, test } from "vitest"; + +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"; +const NATIVE = "**/node_modules"; + +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; +} + +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 available = false; + + beforeAll(async () => { + booted = await boot({ MOUNT_IGNORE: IGNORE, MOUNT_NATIVE: NATIVE }); + available = (await nativeInfo(booted)).enabled; + }, 120_000); + + afterAll(() => kill(booted)); + + 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("nothing but a topmost native 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", + // Local-only, but not in MOUNT_NATIVE. + "mkdir -p dist/assets", + // 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/dist"); + expect(all).not.toContain("/workspace/linked/node_modules"); + }, 30_000); + + test("removing or renaming a root gets EBUSY until it is detached", async (ctx) => { + 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("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, MOUNT_NATIVE: NATIVE, ...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, + MOUNT_NATIVE: NATIVE, + 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("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, MOUNT_NATIVE: NATIVE }); + 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("without passthrough, native roots stay off and nothing is mounted", async () => { + const b = await boot({ + MOUNT_IGNORE: IGNORE, + MOUNT_NATIVE: NATIVE, + 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..d3eb21f40 --- /dev/null +++ b/packages/computerd/src/fuse/native-roots.test.ts @@ -0,0 +1,397 @@ +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), + * `mount --bind` adds the target to the fake mount table, and the mounted + * target stats as its source unless `mismatch` is set. + */ +function harness( + options: { + ignore?: string[]; + native?: 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; + } = {}, +): Harness { + const ignore = resolveMountIgnore(options.ignore ?? ["**/node_modules", "/dist"], MOUNT); + const native = resolveMountIgnore(options.native ?? ["**/node_modules"], "/"); + 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 = { + run: async (argv) => { + commands.push([...argv]); + if (argv[0] === "mount" && argv[1] === "--bind") { + if ((options.mountExit ?? 0) !== 0) return options.mountExit as number; + mounted.add(argv[3] as string); + } + if (argv[0] === "umount") mounted.delete(argv[2] as string); + 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) => { + if (options.missingTargets?.has(path.slice(MOUNT.length + 1))) { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + } + return { isDirectory: () => true }; + }, + stat: async (path) => { + const source = `${ROOT}${path.slice(MOUNT.length)}`; + return { dev: 1, ino: options.mismatch ? 0 : inode(source) }; + }, + 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), + }; + + const roots = createNativeRoots({ + root: ROOT, + mountPoint: MOUNT, + ignore, + native: native.matches, + ...(options.active ? { active: options.active } : {}), + ...seams, + }); + return { roots, commands, mounted, warnings }; +} + +describe("isRoot", () => { + test("is the topmost local-only directory a MOUNT_NATIVE pattern names", () => { + 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 false for a local-only path that MOUNT_NATIVE does not name", () => { + const { roots } = harness(); + expect(roots.isRoot("dist")).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([ + ["mount", "--bind", `${ROOT}/app/node_modules`, `${MOUNT}/app/node_modules`], + ["mount", "--make-private", `${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", "node_modules/react"] }); + for (const path of ["src", "dist", "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[1] === "--bind")).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[1] === "--bind")).toHaveLength(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("exited 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[1] === "--bind").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 mount that does not show the backing directory is detached", async () => { + const { roots, commands, mounted } = harness({ dirs: ["node_modules"], mismatch: true }); + roots.ensure("node_modules"); + await roots.settled(); + expect(commands.at(-1)).toEqual(["umount", "-l", `${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(2); + }); +}); + +describe("scan", () => { + 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), + native: resolveMountIgnore(["**/node_modules"], "/").matches, + run: async (argv) => { + h.commands.push([...argv]); + return 0; + }, + mountinfo: () => "", + lstat: () => ({ isDirectory: () => true, dev: 1, ino: 1 }), + stat: async () => ({ 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[1] === "--bind").map((argv) => argv[3]); + expect(bound.sort()).toEqual([`${MOUNT}/node_modules`, `${MOUNT}/packages/a/node_modules`]); + // Never inside a root, and never inside another local-only directory. + 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), + native: resolveMountIgnore(["**/node_modules"], "/").matches, + run: async (argv) => (argv[1] === "--bind" ? 32 : 0), + 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 }; + }, + stat: async () => ({ dev: 1, ino: 1 }), + warn: (message) => h.warnings.push(message), + }); + roots.ensure("node_modules"); + await roots.settled(); + expect(roots.stats().failures).toBe(0); + expect(h.warnings).toEqual([]); + }); +}); + +describe("detach", () => { + 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([ + ["umount", "-l", `${MOUNT}/a/b/node_modules`], + ["umount", "-l", `${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..704c1d94d --- /dev/null +++ b/packages/computerd/src/fuse/native-roots.ts @@ -0,0 +1,330 @@ +// Native roots: bind-mounts local-only directories onto the mount. See +// packages/computerd/README.md#native-roots-mount_native. +// +// 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 state. Anything mounted strictly +// under MOUNT_POINT was mounted by this module, so there is no registry +// to write, recover, or get out of step. +// - A lazy detach is always safe: the detached tree is the same backing +// directory the FUSE layer serves, and it is never synced. +// - 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. mount and umount run as child processes, and the one +// stat of a mounted target goes through the libuv thread pool. + +import { spawn } from "node:child_process"; +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; + /** Whether a mount-relative path is named by a MOUNT_NATIVE pattern. */ + readonly native: (relativePath: string) => boolean; + /** + * 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; + /** Injected for tests. Each seam defaults to the real system. */ + readonly run?: (argv: readonly string[]) => Promise; + readonly mountinfo?: () => string; + readonly lstat?: (path: string) => { isDirectory(): boolean; dev: number; ino: number }; + readonly stat?: (path: string) => Promise<{ 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; +} + +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. */ + readonly detaches: 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: the topmost local-only directory named by MOUNT_NATIVE. */ + isRoot(relativePath: string): boolean; + stats(): NativeRootsStats; +} + +export function createNativeRoots(options: NativeRootsOptions): NativeRoots { + const root = trimTrailing(options.root); + const mountPoint = trimTrailing(options.mountPoint) || "/"; + const run = options.run ?? runCommand; + const readMountinfo = options.mountinfo ?? (() => readFileSync("/proc/self/mountinfo", "utf8")); + const lstat = options.lstat ?? ((path: string) => lstatSync(path)); + const stat = options.stat ?? ((path: string) => fsPromises.stat(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>(); + // 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; + + 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)) && options.native(rel) + ); + }; + + // Everything strictly under the mount point. 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; dev: number; ino: number }; + 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; + } + + const bound = await run(["mount", "--bind", source, target]); + if (bound !== 0) { + // Removed while the mount was on its way, as `rm -rf` does right + // after a lookup. Gone is not refused. + if (!(await stillThere(source, target))) return; + recordFailure(rel, `mount --bind exited ${bound}`); + return; + } + // Keep the mount out of any shared peer group the container runtime + // set up, so it never propagates beyond this namespace. + await run(["mount", "--make-private", target]); + + try { + const mounted = await stat(target); + if (mounted.dev !== sourceStat.dev || mounted.ino !== sourceStat.ino) { + await run(["umount", "-l", target]); + recordFailure(rel, "the mounted directory is not the backing directory"); + return; + } + } catch (error) { + await run(["umount", "-l", target]); + recordFailure(rel, `could not verify the mount: ${String(error)}`); + return; + } + mounts += 1; + } + + const ensure = (relativePath: string, created = false): void => { + if (!active()) 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)) { + walk(child, visit); + } + } + }; + + 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)) { + if ((await run(["umount", "-l", target])) === 0) { + detaches += 1; + detached.push(target); + } + } + return detached; + }; + + return { + ensure, + scan: () => { + if (active()) walk("", (rel) => ensure(rel)); + }, + settled: async () => { + while (inflight.size > 0) await Promise.all([...inflight.values()]); + }, + detach: async (relativePath) => { + const all = owned(); + const targets = + relativePath === undefined + ? all + : all.filter((candidate) => candidate === targetOf(stripSlashes(relativePath))); + const detached = await detachTargets(targets); + for (const target of detached) parked.add(relOf(target)); + return detached; + }, + detachAll: () => detachTargets(owned()), + isRoot, + stats: () => ({ active: owned().length, mounts, failures, detaches, 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 runCommand(argv: readonly string[]): Promise { + return new Promise((resolve) => { + const [command, ...args] = argv; + if (command === undefined) { + resolve(127); + return; + } + const child = spawn(command, args, { stdio: "ignore" }); + child.once("error", () => resolve(127)); + child.once("exit", (code) => resolve(code ?? 1)); + }); +} + +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/passthrough.test.ts b/packages/computerd/src/fuse/passthrough.test.ts index 473cc9a69..7b5210b66 100644 --- a/packages/computerd/src/fuse/passthrough.test.ts +++ b/packages/computerd/src/fuse/passthrough.test.ts @@ -1057,3 +1057,83 @@ describe("withLocalPassthrough: kernel passthrough", () => { 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 575e70a03..51d9d2373 100644 --- a/packages/computerd/src/fuse/passthrough.ts +++ b/packages/computerd/src/fuse/passthrough.ts @@ -85,6 +85,13 @@ export interface LocalPassthroughOptions { 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. */ @@ -286,11 +293,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) { @@ -528,10 +542,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) { @@ -613,10 +631,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) { From ac1712565e3fadb33dc5f8ca9b161289307b60da Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:24:55 +0100 Subject: [PATCH 16/28] computerd, docs: Benchmark native roots The fs and npm benches gain a NATIVE_ROOTS=1 knob, which lists the local-only path they already use in MOUNT_NATIVE as well, so a run measures work inside a bind-mounted native root. The fs bench prints the mount and the native-root counters at the end, so a run shows it measured what it meant to. The npm bench deletes each run's directory before the next, and a native root inside it is a mount point that rm cannot remove. It now detaches native roots through the daemon first when they are on, which is the same step a host has to take before removing one. docs/19_performance.md gains a native-roots section with fs-bench and npm install numbers beside the synced mount, local-only paths with passthrough, and raw disk. Every scenario under a native root lands at raw disk within noise, against 15 to 25 times raw for metadata-heavy work with passthrough alone; the front-end toolchain installs in 1,365 ms against 1,336 ms on raw disk and 2,165 ms with passthrough. The numbers come from a local arm64 virtual machine, and the page says so. --- docs/19_performance.md | 45 +++++++++++++++++++++++++++++++++++ script/npm-bench.sh | 12 ++++++++++ script/run-fs-bench.sh | 13 ++++++++++ script/run-npm-bench-inner.sh | 15 ++++++++++-- script/run-npm-bench.sh | 4 ++++ 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/docs/19_performance.md b/docs/19_performance.md index 344286dff..102411b45 100644 --- a/docs/19_performance.md +++ b/docs/19_performance.md @@ -122,6 +122,51 @@ transfer that no longer follows it. > and `script/run-npm-bench.sh` (with `IGNORE_NODE_MODULES=1`) reproduce > them. +### Native roots (`MOUNT_NATIVE`) + +Passthrough leaves metadata on computerd's event loop. A native root +removes that too: the directory is bind-mounted from the container's +disk, so nothing under it reaches computerd at all. See +[`computerd`: Native roots](../packages/computerd/README.md#native-roots-mount_native). + +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 | + +Under a native root every scenario lands at raw disk, within run-to-run +noise, where passthrough alone left metadata-heavy work at 15 to 25 +times raw. `git clone` is dominated by the network and moves with it. + +> [!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 NATIVE_ROOTS=1`) and +> `script/run-npm-bench.sh` (`IGNORE_NODE_MODULES=1 NATIVE_ROOTS=1`). + 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. diff --git a/script/npm-bench.sh b/script/npm-bench.sh index cbe089af6..0ae4c6815 100755 --- a/script/npm-bench.sh +++ b/script/npm-bench.sh @@ -88,6 +88,7 @@ warm_npm_cache() { # Nothing to warm for the synthetic scenario. ;; esac + release_native_roots rm -rf "$warmdir" echo "[bench] cache warm for $scenario complete" } @@ -115,6 +116,16 @@ stop_heartbeat() { fi } +# A native root (MOUNT_NATIVE) is a mount point, and rmdir of a mount +# point fails with EBUSY, so `rm -rf` of a previous run's directory stops +# at its node_modules. Detach them first; COMPUTERD_URL is set when the +# daemon mounts native roots. 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" @@ -123,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" diff --git a/script/run-fs-bench.sh b/script/run-fs-bench.sh index e6f392167..db214edbc 100755 --- a/script/run-fs-bench.sh +++ b/script/run-fs-bench.sh @@ -15,6 +15,8 @@ # 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. +# NATIVE_ROOTS=1, with LOCAL_ONLY=1, also lists it in MOUNT_NATIVE, so the +# bench runs inside a bind-mounted native root. set -u apt-get update >/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 @@ -29,6 +31,10 @@ if [ "${LOCAL_ONLY:-0}" = "1" ]; then BENCH_MOUNT=/tmp/workspace/bench MOUNT_IGNORE=/bench fi +MOUNT_NATIVE="" +if [ "${NATIVE_ROOTS:-0}" = "1" ]; then + MOUNT_NATIVE="$MOUNT_IGNORE" +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 @@ -37,6 +43,7 @@ COMPUTERD_FUSE_TRACE="${COMPUTERD_FUSE_TRACE:-}" \ COMPUTERD_FUSE_TRACE_FILE="${COMPUTERD_FUSE_TRACE_FILE:-}" \ COMPUTERD_FUSE_PASSTHROUGH="${COMPUTERD_FUSE_PASSTHROUGH:-}" \ MOUNT_IGNORE="$MOUNT_IGNORE" \ + MOUNT_NATIVE="$MOUNT_NATIVE" \ PORT=45678 MOUNT_POINT=/tmp/workspace /usr/local/bin/computerd >/tmp/computerd.log 2>&1 & COMPUTERD_PID=$! @@ -60,6 +67,12 @@ mkdir -p "$BENCH_MOUNT" MOUNT="$BENCH_MOUNT" BASE=/tmp/baseline /usr/local/bin/fs-bench status=$? +if [ -n "$MOUNT_NATIVE" ]; 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-npm-bench-inner.sh b/script/run-npm-bench-inner.sh index 235c6e6d2..f10513c9c 100755 --- a/script/run-npm-bench-inner.sh +++ b/script/run-npm-bench-inner.sh @@ -17,11 +17,17 @@ MOUNT_IGNORE="" if [ "${IGNORE_NODE_MODULES:-0}" = "1" ]; then MOUNT_IGNORE="/ignored/*/node_modules" fi +# NATIVE_ROOTS=1 bind-mounts those node_modules too (MOUNT_NATIVE). +MOUNT_NATIVE="" +if [ "${NATIVE_ROOTS:-0}" = "1" ]; then + MOUNT_NATIVE="$MOUNT_IGNORE" +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" \ + MOUNT_NATIVE="$MOUNT_NATIVE" \ PORT=45678 MOUNT_POINT=/tmp/workspace /usr/local/bin/computerd >/tmp/computerd.log 2>&1 & COMPUTERD_PID=$! @@ -39,12 +45,17 @@ 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_NATIVE" ]; 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 passthrough || true + 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 diff --git a/script/run-npm-bench.sh b/script/run-npm-bench.sh index 2851c95d4..656c41b83 100755 --- a/script/run-npm-bench.sh +++ b/script/run-npm-bench.sh @@ -24,6 +24,8 @@ # /tmp/workspace/ignored local-only (MOUNT_IGNORE) # COMPUTERD_FUSE_PASSTHROUGH set to 0 to measure local-only paths # without kernel passthrough +# NATIVE_ROOTS set to 1, with IGNORE_NODE_MODULES=1, to bind-mount +# those node_modules as native roots (MOUNT_NATIVE) # PLATFORM docker platform (default: linux/amd64). On an arm64 # machine use linux/arm64 and a computerd-linux-arm64 # binary; emulated timings are not meaningful. @@ -48,6 +50,7 @@ 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:-}" +NATIVE_ROOTS="${NATIVE_ROOTS:-0}" PLATFORM="${PLATFORM:-linux/amd64}" if [ ! -f "$COMPUTERD_BINARY" ]; then @@ -72,4 +75,5 @@ docker run --rm --platform "$PLATFORM" --privileged \ -e "TARGETS=$TARGETS" \ -e "IGNORE_NODE_MODULES=$IGNORE_NODE_MODULES" \ -e "COMPUTERD_FUSE_PASSTHROUGH=$COMPUTERD_FUSE_PASSTHROUGH" \ + -e "NATIVE_ROOTS=$NATIVE_ROOTS" \ debian:stable-slim bash /run-bench.sh From a3010734be6f704b61302b7604b0965c3c331b13 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:29:02 +0100 Subject: [PATCH 17/28] docs, examples: Date libfuse 2 to 0.6.0 and earlier The docs and example Dockerfiles said computerd 0.4.1 and earlier link libfuse 2, but 0.5.1 and 0.6.0 were released from main without the libfuse 3 binding and link libfuse 2 as well. The prose now says 0.6.0 and earlier. The Dockerfile comments explaining libfuse2t64 named the pinned version, which the release process rewrites on every release, so they went stale at the first bump. They now tie the package to the tag being 0.6.0 or earlier and say to drop it once the tag is past that, which stays true when the tag moves. The container-legacy example installed it without saying why and gains the same comment. The snippets in docs/README.md and the linux-x64 image README pin the 0.6.0 image on trixie but left libfuse2t64 out, so the binary they copy would not have found its library. They install it, with the same comment, until the pinned tag is a libfuse 3 release. --- docs/07_injected_service.md | 4 ++-- docs/README.md | 10 +++++++--- examples/container-legacy/Dockerfile | 3 +++ examples/container/Dockerfile | 5 +++-- examples/egress/Dockerfile | 5 +++-- examples/mcp/Dockerfile | 5 +++-- examples/think-compare-runtimes/Dockerfile.workspace | 5 +++-- examples/think/Dockerfile | 5 +++-- examples/tutorial/Dockerfile | 5 +++-- packages/computer-computerd-linux-x64/README.md | 7 +++++-- 10 files changed, 35 insertions(+), 19 deletions(-) diff --git a/docs/07_injected_service.md b/docs/07_injected_service.md index c8e061058..0e2ee5478 100644 --- a/docs/07_injected_service.md +++ b/docs/07_injected_service.md @@ -97,8 +97,8 @@ Notes: embeds everything. - `fuse3` provides libfuse 3 and `fusermount3`. computerd needs libfuse 3.17 or newer, which is Debian trixie or later; bookworm's 3.14 is too - old. computerd 0.4.1 and earlier linked libfuse 2 instead and also - need `libfuse2t64`. + old. computerd 0.6.0 and earlier link libfuse 2 instead 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/README.md b/docs/README.md index 92d34cb34..208427730 100644 --- a/docs/README.md +++ b/docs/README.md @@ -76,9 +76,12 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.6.0 AS computerd FROM debian:trixie-slim +# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, +# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 +# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 ca-certificates \ + fuse3 libfuse2t64 ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd @@ -92,8 +95,9 @@ ENTRYPOINT ["/usr/local/bin/computerd"] ``` `fuse3` brings libfuse 3, which computerd links and needs at 3.17 or -newer, so the base has to be Debian trixie or later. computerd 0.4.1 and -earlier linked libfuse 2 instead and also need `libfuse2t64`. +newer, so the base has to be Debian trixie or later. computerd 0.6.0 and +earlier link libfuse 2 instead and also need `libfuse2t64`, which is why +the snippet installs it while it pins one of those. To build the binary from source instead, run `npm run build:bin --workspace @cloudflare/computerd`, which emits diff --git a/examples/container-legacy/Dockerfile b/examples/container-legacy/Dockerfile index 02a19ac53..a557c0f98 100644 --- a/examples/container-legacy/Dockerfile +++ b/examples/container-legacy/Dockerfile @@ -18,6 +18,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.6.0 AS computerd FROM debian:stable-slim +# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, +# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 +# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl gnupg git \ diff --git a/examples/container/Dockerfile b/examples/container/Dockerfile index 93e9f1e1d..a557c0f98 100644 --- a/examples/container/Dockerfile +++ b/examples/container/Dockerfile @@ -18,8 +18,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.6.0 AS computerd FROM debian:stable-slim -# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only -# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. +# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, +# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 +# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl gnupg git \ diff --git a/examples/egress/Dockerfile b/examples/egress/Dockerfile index 28b72894d..304a5fe5a 100644 --- a/examples/egress/Dockerfile +++ b/examples/egress/Dockerfile @@ -2,8 +2,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.6.0 AS computerd FROM debian:trixie-slim -# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only -# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. +# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, +# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 +# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl \ diff --git a/examples/mcp/Dockerfile b/examples/mcp/Dockerfile index eaa82ac74..02c4e7ab2 100644 --- a/examples/mcp/Dockerfile +++ b/examples/mcp/Dockerfile @@ -2,8 +2,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.6.0 AS computerd FROM node:22-trixie-slim -# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only -# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. +# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, +# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 +# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates curl fuse3 git libfuse2t64 \ diff --git a/examples/think-compare-runtimes/Dockerfile.workspace b/examples/think-compare-runtimes/Dockerfile.workspace index 8b0228ea3..6c631354d 100644 --- a/examples/think-compare-runtimes/Dockerfile.workspace +++ b/examples/think-compare-runtimes/Dockerfile.workspace @@ -16,8 +16,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.6.0 AS computerd FROM --platform=linux/amd64 node:22-trixie-slim -# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only -# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. +# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, +# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 +# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates git fuse3 libfuse2t64 \ diff --git a/examples/think/Dockerfile b/examples/think/Dockerfile index a9d3080de..5473b2b43 100644 --- a/examples/think/Dockerfile +++ b/examples/think/Dockerfile @@ -20,8 +20,9 @@ FROM debian:stable-slim # come in handy for ad-hoc agent commands too. The cost is ~120 MB # of layer over the base; acceptable for an example whose whole # point is running real build/test workflows. -# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only -# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. +# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, +# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 +# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl gnupg git \ diff --git a/examples/tutorial/Dockerfile b/examples/tutorial/Dockerfile index 86fed6f07..329fa1d7b 100644 --- a/examples/tutorial/Dockerfile +++ b/examples/tutorial/Dockerfile @@ -18,8 +18,9 @@ ARG TYPST_VERSION=0.15.1 # pandoc converts the agent's markdown; typst is the PDF engine, a # single 30 MB binary instead of the several hundred megabytes a LaTeX # install would cost. curl is only the download tool for those two. -# libfuse2t64 is for the pinned 0.4.1 binary. Later releases need only -# fuse3, at libfuse 3.17 or newer (Debian trixie); drop it then. +# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, +# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 +# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ fuse3 libfuse2t64 ca-certificates curl xz-utils \ diff --git a/packages/computer-computerd-linux-x64/README.md b/packages/computer-computerd-linux-x64/README.md index e7d424ef7..16737de3a 100644 --- a/packages/computer-computerd-linux-x64/README.md +++ b/packages/computer-computerd-linux-x64/README.md @@ -16,7 +16,7 @@ the FUSE addon are baked in. libfuse is not: the binary links the system's libfuse 3 and needs 3.17 or newer, which is the `fuse3` package on Debian trixie or later. The host also needs `/dev/fuse`, and Linux 6.9 or newer for local-only paths to use FUSE passthrough. computerd -0.4.1 and earlier linked libfuse 2 instead and also need `libfuse2t64`. +0.6.0 and earlier link libfuse 2 instead and also need `libfuse2t64`. 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 @@ -25,9 +25,12 @@ instead: ```dockerfile FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.6.0 AS computerd FROM debian:trixie-slim +# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, +# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 +# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 ca-certificates \ + fuse3 libfuse2t64 ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd From 68414d1aaa21fd19c1bd0763d01cb5f61255e143 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:39:07 +0100 Subject: [PATCH 18/28] computerd: Mount every local-only directory natively MOUNT_NATIVE made native roots an opt-in subset of MOUNT_IGNORE, to keep the one behavior change, EBUSY when removing or renaming a mounted directory itself, away from existing MOUNT_IGNORE deployments. There are none yet, and a second list that has to repeat entries of the first is configuration nobody wants to write. MOUNT_IGNORE now implies native roots: every local-only directory whose parent is synced is bind-mounted when passthrough is negotiated, and a file pattern never is. COMPUTERD_FUSE_NATIVE_ROOTS=0 turns them off, with the same spellings as COMPUTERD_FUSE_PASSTHROUGH, for ruling them in or out while chasing a problem. The passthrough suites set it, so they keep testing the passthrough layer rather than a mount that may have landed first. MOUNT_NATIVE, its validation, and the matches() helper it needed are gone. /__computerd/info drops nativeRoots.patterns and reports the off switch in nativeRoots.reason. The benches measure native roots by default under LOCAL_ONLY and IGNORE_NODE_MODULES; the switch measures passthrough alone. --- .changeset/native-roots.md | 21 ++-- docs/19_performance.md | 18 ++-- .../computer/test-harness/run-computerd.sh | 6 +- packages/computerd/README.md | 71 ++++++-------- packages/computerd/src/cli/computerd.test.ts | 32 ++----- packages/computerd/src/cli/computerd.ts | 33 +++++-- .../computerd/src/fuse/ignore-config.test.ts | 88 ++++++----------- packages/computerd/src/fuse/ignore-config.ts | 96 ++++--------------- packages/computerd/src/fuse/ignore.test.ts | 26 ----- packages/computerd/src/fuse/ignore.ts | 19 ---- packages/computerd/src/fuse/index.ts | 4 +- .../src/fuse/native-roots.fuse.test.ts | 44 +++++---- .../computerd/src/fuse/native-roots.test.ts | 24 ++--- packages/computerd/src/fuse/native-roots.ts | 10 +- packages/computerd/src/fuse/options.test.ts | 13 +++ packages/computerd/src/fuse/options.ts | 18 +++- .../src/fuse/passthrough.fuse.test.ts | 3 + script/npm-bench.sh | 8 +- script/run-fs-bench.sh | 12 +-- script/run-npm-bench-inner.sh | 9 +- script/run-npm-bench.sh | 8 +- 21 files changed, 221 insertions(+), 342 deletions(-) diff --git a/.changeset/native-roots.md b/.changeset/native-roots.md index 16aeacd04..c9157a519 100644 --- a/.changeset/native-roots.md +++ b/.changeset/native-roots.md @@ -2,22 +2,23 @@ "@cloudflare/computerd": minor --- -Bind-mount local-only directories named in `MOUNT_NATIVE` +Bind-mount local-only directories -`MOUNT_NATIVE` names a subset of the `MOUNT_IGNORE` patterns whose -directories computerd bind-mounts onto the mount from the place it already -stores them, under `MOUNT_IGNORE_PATH`. Everything under such a directory is -then served by the kernel from the container's disk: lookups, `stat`, -creates, and listings no longer reach computerd, where FUSE passthrough alone -only took the file data off its path. Content is the same either way, so a -mount that fails or lands late changes speed, not what a command sees. +With `MOUNT_IGNORE` set, computerd now bind-mounts each local-only directory +onto the mount from the place it already stores it, under `MOUNT_IGNORE_PATH`. +Everything under such a directory is then served by the kernel from the +container's disk: lookups, `stat`, creates, and listings no longer reach +computerd, where FUSE passthrough alone only took the file data off its path. +Content is the same either way, so a mount that fails or lands late changes +speed, not what a command sees. A directory is mounted when a command creates it, when a lookup finds one on disk that is not mounted yet (after a snapshot restore), and at startup. Native roots need negotiated FUSE passthrough, `CAP_SYS_ADMIN`, and `mount` -from util-linux; without them nothing changes. `/__computerd/info` reports +from util-linux; without them local-only paths are served as before. +`COMPUTERD_FUSE_NATIVE_ROOTS=0` turns them off. `/__computerd/info` reports the state under `ignore.fastPaths.nativeRoots`. Removing or renaming a mounted directory itself fails with `EBUSY`, as for a Docker volume. `POST /__computerd/native-roots/detach` detaches one, or all, -first. Unset `MOUNT_NATIVE` leaves behavior exactly as it was. +first. diff --git a/docs/19_performance.md b/docs/19_performance.md index 102411b45..dd94521ff 100644 --- a/docs/19_performance.md +++ b/docs/19_performance.md @@ -120,14 +120,15 @@ transfer that no longer follows it. > 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. +> them, with `COMPUTERD_FUSE_NATIVE_ROOTS=0`: local-only directories are +> now bind-mounted by default (next section). -### Native roots (`MOUNT_NATIVE`) +### Native roots -Passthrough leaves metadata on computerd's event loop. A native root -removes that too: the directory is bind-mounted from the container's -disk, so nothing under it reaches computerd at all. See -[`computerd`: Native roots](../packages/computerd/README.md#native-roots-mount_native). +Passthrough leaves metadata on computerd's event loop. computerd now +bind-mounts each local-only directory from the container's disk, so +nothing under it reaches computerd at all. 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 @@ -164,8 +165,9 @@ times raw. `git clone` is dominated by the network and moves with it. > [!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 NATIVE_ROOTS=1`) and -> `script/run-npm-bench.sh` (`IGNORE_NODE_MODULES=1 NATIVE_ROOTS=1`). +> `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. Writeback caching would cut metadata round trips further on writes. It stays off: libfuse 3 can negotiate it, but it changes write behavior for diff --git a/packages/computer/test-harness/run-computerd.sh b/packages/computer/test-harness/run-computerd.sh index 516e1718f..61032a22b 100755 --- a/packages/computer/test-harness/run-computerd.sh +++ b/packages/computer/test-harness/run-computerd.sh @@ -53,12 +53,12 @@ RUN apt-get update >/dev/null \ DOCKERFILE fi -# Lets a test boot computerd with MOUNT_IGNORE, MOUNT_NATIVE, COMPUTERD_DB, or -# COMPUTERD_FUSE_* set. +# 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 | MOUNT_NATIVE | COMPUTERD_DB | COMPUTERD_FUSE_*) FORWARD_ENV+=(-e "$name") ;; + MOUNT_IGNORE | MOUNT_IGNORE_PATH | COMPUTERD_DB | COMPUTERD_FUSE_*) FORWARD_ENV+=(-e "$name") ;; esac done < <(env) diff --git a/packages/computerd/README.md b/packages/computerd/README.md index e582077b5..644021a24 100644 --- a/packages/computerd/README.md +++ b/packages/computerd/README.md @@ -43,7 +43,7 @@ Current filesystem support: - `@platformatic/vfs` in-memory filesystem provided by `@cloudflare/dofs`'s node provider. - 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. When the kernel supports it, their file data goes through FUSE passthrough and never reaches `computerd`. +- Local-only paths (`MOUNT_IGNORE`) served from the container's disk and kept out of sync. When the kernel supports it, local-only directories are bind-mounted and never reach `computerd` at all; 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. @@ -336,35 +336,24 @@ 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 (`MOUNT_NATIVE`) +### Native roots -A local-only path is served from the container's disk, but every -lookup, `stat`, create, and listing under it still reaches computerd. -FUSE passthrough sends the file data straight to the kernel; the -metadata keeps coming through. For a dependency tree that is most of -the work. +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. -`MOUNT_NATIVE` names local-only directories to bind-mount instead. The -mount's source is the directory computerd already serves the path from, -under `MOUNT_IGNORE_PATH`, so everything under it is served by the -kernel from the container's disk and never reaches computerd at all. -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 is never a -problem for what a command sees. +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. -```ts -new ContainerBackend({ - ignore: ["**/node_modules", "/dist"], - containerEnv: { MOUNT_NATIVE: "**/node_modules" }, -}); -``` - -Each entry must also be in `MOUNT_IGNORE`, written the same way after -normalization, and must not be an exclusion; anything else fails the -daemon at startup. A native root is the topmost directory a -`MOUNT_NATIVE` pattern names: `packages/app/node_modules` is one, the -`node_modules` directories nested inside it are already under it. A -symlink or a file is never mounted. +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 is never a +problem for what a command sees. Roots are mounted when they appear, with no watcher: @@ -377,18 +366,19 @@ Roots are mounted when they appear, with no watcher: 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, -and `COMPUTERD_FUSE_PASSTHROUGH` not turned off), `CAP_SYS_ADMIN`, and -`mount` from util-linux in the image. Without any of these, the paths -are served exactly as before. +Native roots need FUSE passthrough to be negotiated (Linux 6.9 or newer), +`CAP_SYS_ADMIN`, and `mount` from util-linux in the image. Without any +of these, local-only paths are served through FUSE as described above. +`COMPUTERD_FUSE_NATIVE_ROOTS=0` turns them off, for ruling them in or out +while chasing a problem. -#### The one difference: removing or renaming a root +#### 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 never asks computerd, so it cannot intervene: -| Command | Without a native root | With one | +| Command | Through FUSE | On a native root | | --- | --- | --- | | `rm -rf node_modules` | works | empties it, then `EBUSY` on the directory itself | | `rm -rf node_modules/*` | works | works | @@ -396,9 +386,8 @@ intervene: | `rm -rf packages/app` with a root inside | works | `EBUSY` on the root | | `mv packages/app packages/web` | works | works; the mount moves with it | -These are the rules of a Docker volume mounted at `node_modules`, which -most JavaScript tooling already lives with. To remove a root outright, -detach it first: +These are the rules of a Docker volume mounted at `node_modules`. To +remove a root outright, detach it first: ```sh curl -X POST http://127.0.0.1:$PORT/__computerd/native-roots/detach \ @@ -423,15 +412,15 @@ was and computerd serves it through FUSE. `/__computerd/info` reports `ignore.fastPaths.nativeRoots`: ```json -{ "enabled": true, "reason": "active", "patterns": ["**/node_modules"], - "active": 3, "mounts": 4, "failures": 0, "detaches": 1 } +{ "enabled": true, "reason": "active", "active": 3, "mounts": 4, + "failures": 0, "detaches": 1 } ``` `active` is read from `/proc/self/mountinfo`, which is the only record of what is mounted: anything mounted under `MOUNT_POINT` other than the FUSE mount itself is a native root. `reason` explains a disabled state -(not configured, no kernel FUSE mount, passthrough off or not offered) -or names the last failure when mounts are being refused. +(no `MOUNT_IGNORE`, no kernel FUSE mount, turned off, passthrough off or +not offered) or names the last failure when mounts are being refused. `/__computerd/stats` carries the same counters under `localPaths.nativeRoots`, along with `lastFailure`. @@ -471,7 +460,7 @@ 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 -MOUNT_NATIVE=**/node_modules # local-only directories to bind-mount; each must also be in MOUNT_IGNORE +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. diff --git a/packages/computerd/src/cli/computerd.test.ts b/packages/computerd/src/cli/computerd.test.ts index dba3b959e..07c434bf8 100644 --- a/packages/computerd/src/cli/computerd.test.ts +++ b/packages/computerd/src/cli/computerd.test.ts @@ -117,8 +117,7 @@ test("computerd exposes file IO through real FUSE when FUSE_MOUNT=fuse", async ( writebackCache: false, nativeRoots: { enabled: false, - reason: expect.stringContaining("MOUNT_NATIVE"), - patterns: [], + reason: expect.stringContaining("MOUNT_IGNORE"), active: 0, mounts: 0, failures: 0, @@ -149,6 +148,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, }, }); @@ -229,6 +231,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, }, }); @@ -1200,29 +1205,6 @@ test("POST /__computerd/native-roots/detach answers with what it detached", asyn expect(refused.statusCode).toBe(405); }); -test("computerd refuses a MOUNT_NATIVE pattern that is not in MOUNT_IGNORE", async () => { - // A native root is a local-only path served faster. One that is not - // local-only would be a synced path bypassing the VFS. - const port = await getAvailablePort(); - const mountPoint = await fs.mkdtemp(path.join(os.tmpdir(), "computerd-mount-")); - const child = spawn(cliPath, { - cwd: packageRoot, - env: { - ...process.env, - MOUNT_POINT: mountPoint, - PORT: String(port), - FUSE_MOUNT: "none", - MOUNT_IGNORE: "/dist", - MOUNT_NATIVE: "**/node_modules", - }, - stdio: ["ignore", "ignore", "pipe"], - }); - - const { code, stderr } = await waitForExit(child); - expect(code).toBe(1); - expect(stderr).toMatch(/MOUNT_NATIVE pattern "\*\*\/node_modules" is not in MOUNT_IGNORE/); -}); - 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 4a99a64c7..df2db14d2 100644 --- a/packages/computerd/src/cli/computerd.ts +++ b/packages/computerd/src/cli/computerd.ts @@ -22,6 +22,7 @@ import { type MountIgnoreInfo, mountFuse, type NativeRoots, + nativeRootsRequested, parseFuseMountMode, parseStoreMode, type ResolvedStore, @@ -151,6 +152,14 @@ function dropHeartbeats(stream: ReadableStream): ReadableStr ); } +const NO_NATIVE_ROOT_STATS = { + active: 0, + mounts: 0, + failures: 0, + detaches: 0, + lastFailure: undefined, +} as const; + interface ComputerdInfo { backend: FUSEBackend; mountPoint: string; @@ -737,30 +746,34 @@ async function main(): Promise { store, // A getter, so each request reports live passthrough state. get ignore() { + const passthrough = fuse?.getPassthroughStatus?.(); return describeMountIgnore( ignoreConfig, - fuse?.getPassthroughStatus?.(), - nativeRoots?.stats(), + passthrough, + passthrough === undefined + ? undefined + : { + requested: nativeRootsWanted, + ...(nativeRoots?.stats() ?? NO_NATIVE_ROOT_STATS), + }, ); }, }; - // Native roots (MOUNT_NATIVE). 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. + // Native roots: every local-only directory is bind-mounted from where + // it is 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); const nativeRoots: NativeRoots | undefined = - ignoreConfig.native.enabled && backend.kind !== "none" && backend.kind !== "shim" + ignoreConfig.enabled && nativeRootsWanted && backend.kind !== "none" && backend.kind !== "shim" ? createNativeRoots({ root: ignoreConfig.root, mountPoint, ignore: ignoreConfig.ignore, - native: ignoreConfig.native.matches, active: () => fuse?.getPassthroughStatus?.().negotiated === true, }) : undefined; - if (nativeRoots !== undefined) { - console.log(`[info] MOUNT_NATIVE active: ${ignoreConfig.native.patterns.join(", ")}`); - } // When running on the userspace shim, capture the typed handle // so we can wire `flush()` and `reconcileNow()` into the SyncRPC diff --git a/packages/computerd/src/fuse/ignore-config.test.ts b/packages/computerd/src/fuse/ignore-config.test.ts index 42b5fb021..f8f5945f6 100644 --- a/packages/computerd/src/fuse/ignore-config.test.ts +++ b/packages/computerd/src/fuse/ignore-config.test.ts @@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest"; import { defaultIgnoreRoot, describeMountIgnore, - MountNativeError, resolveMountIgnoreConfig, } from "./ignore-config.js"; @@ -180,60 +179,22 @@ describe("describeMountIgnore", () => { }); }); -describe("resolveMountIgnoreConfig: MOUNT_NATIVE", () => { - const resolve = (env: Record) => - resolveMountIgnoreConfig( - { MOUNT_IGNORE: "**/node_modules,/dist,!/vendor/node_modules", ...env }, - "/workspace", - ); - - test("is off when unset or empty", () => { - expect(resolve({}).native).toMatchObject({ enabled: false, patterns: [] }); - expect(resolve({ MOUNT_NATIVE: " , " }).native).toMatchObject({ enabled: false, patterns: [] }); - }); - - test("accepts a subset of MOUNT_IGNORE, normalized the same way", () => { - const native = resolve({ MOUNT_NATIVE: "**/node_modules, /workspace/dist/" }).native; - expect(native.enabled).toBe(true); - expect(native.patterns).toEqual(["**/node_modules", "/dist"]); - expect(native.matches("app/node_modules")).toBe(true); - expect(native.matches("dist")).toBe(true); - expect(native.matches("app/node_modules/react")).toBe(false); - }); - - test("rejects a pattern that is not in MOUNT_IGNORE", () => { - expect(() => resolve({ MOUNT_NATIVE: "**/.venv" })).toThrow(/not in MOUNT_IGNORE/); - expect(() => resolve({ MOUNT_NATIVE: "/node_modules" })).toThrow(/not in MOUNT_IGNORE/); - }); - - test("rejects MOUNT_NATIVE without MOUNT_IGNORE", () => { - expect(() => - resolveMountIgnoreConfig({ MOUNT_NATIVE: "**/node_modules" }, "/workspace"), - ).toThrow(/not in MOUNT_IGNORE/); - }); - - test("rejects an exclusion", () => { - expect(() => resolve({ MOUNT_NATIVE: "!/vendor/node_modules" })).toThrow(MountNativeError); - }); - - test("reports a malformed pattern under its own name", () => { - expect(() => resolve({ MOUNT_NATIVE: "node_modules" })).toThrow(/^MOUNT_NATIVE pattern/); - }); -}); - describe("describeMountIgnore: nativeRoots", () => { - const configured = resolveMountIgnoreConfig( - { MOUNT_IGNORE: "**/node_modules", MOUNT_NATIVE: "**/node_modules" }, - "/workspace", - ); + const configured = resolveMountIgnoreConfig({ MOUNT_IGNORE: "**/node_modules" }, "/workspace"); const live = { requested: true, negotiated: true, opens: 0, fallbacks: 0 }; - const counters = { active: 2, mounts: 3, failures: 0, detaches: 1, lastFailure: undefined }; - - test("reports the patterns and counters when active", () => { - expect(describeMountIgnore(configured, live, counters).fastPaths.nativeRoots).toEqual({ + const native = { + requested: true, + active: 2, + mounts: 3, + failures: 0, + detaches: 1, + lastFailure: undefined, + }; + + test("is on with MOUNT_IGNORE alone, reporting the counters", () => { + expect(describeMountIgnore(configured, live, native).fastPaths.nativeRoots).toEqual({ enabled: true, reason: "active", - patterns: ["**/node_modules"], active: 2, mounts: 3, failures: 0, @@ -241,24 +202,33 @@ describe("describeMountIgnore: nativeRoots", () => { }); }); - test("is off when MOUNT_NATIVE is unset", () => { - const config = resolveMountIgnoreConfig({ MOUNT_IGNORE: "**/node_modules" }, "/workspace"); - const info = describeMountIgnore(config, live).fastPaths.nativeRoots; - expect(info.enabled).toBe(false); - expect(info.reason).toMatch(/MOUNT_NATIVE/); + 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 }); + 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 }); + 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 = { ...counters, active: 0, mounts: 0, failures: 4, lastFailure: "a: exited 32" }; + const refused = { ...native, active: 0, mounts: 0, failures: 4, lastFailure: "a: exited 32" }; const info = describeMountIgnore(configured, live, refused).fastPaths.nativeRoots; expect(info.enabled).toBe(true); expect(info.reason).toMatch(/every mount so far was refused/); diff --git a/packages/computerd/src/fuse/ignore-config.ts b/packages/computerd/src/fuse/ignore-config.ts index fe9cf9f60..4e4628110 100644 --- a/packages/computerd/src/fuse/ignore-config.ts +++ b/packages/computerd/src/fuse/ignore-config.ts @@ -7,12 +7,7 @@ import { isAbsolute, join, resolve } from "node:path"; -import { - MountIgnorePathError, - type MountIgnoreSet, - parseMountIgnore, - resolveMountIgnore, -} from "./ignore.js"; +import { type MountIgnoreSet, parseMountIgnore, resolveMountIgnore } from "./ignore.js"; export interface MountIgnoreConfig { /** Where local-only paths are stored. Absolute, outside the mount. */ @@ -21,30 +16,11 @@ export interface MountIgnoreConfig { readonly ignore: MountIgnoreSet; /** True when at least one path is configured. */ readonly enabled: boolean; - /** Local-only directories to bind-mount from `root` (MOUNT_NATIVE). */ - readonly native: NativeRootsConfig; -} - -export interface NativeRootsConfig { - /** Normalized MOUNT_NATIVE patterns, in the order written. */ - readonly patterns: readonly string[]; - /** True when at least one pattern is configured. */ - readonly enabled: boolean; - /** Whether a mount-relative path is one a MOUNT_NATIVE pattern names. */ - readonly matches: (relativePath: string) => boolean; } export interface MountIgnoreEnv { MOUNT_IGNORE?: string; MOUNT_IGNORE_PATH?: string; - MOUNT_NATIVE?: string; -} - -export class MountNativeError extends Error { - constructor(message: string) { - super(message); - this.name = "MountNativeError"; - } } /** @@ -90,50 +66,7 @@ export function resolveMountIgnoreConfig( throw new Error("MOUNT_IGNORE_PATH must not be the filesystem root"); } - const native = resolveNativeRoots(env.MOUNT_NATIVE, ignore, mountPoint); - return { root: normalizedRoot, ignore, enabled: !ignore.isEmpty, native }; -} - -/** - * MOUNT_NATIVE names a subset of MOUNT_IGNORE. Each entry must be written - * as one of the MOUNT_IGNORE inclusions (after the same normalization), - * so a native root is always a path that is already local-only and the - * bind mount changes how it is served, never whether it is synced. - */ -export function resolveNativeRoots( - raw: string | undefined, - ignore: MountIgnoreSet, - mountPoint: string, -): NativeRootsConfig { - const entries = parseMountIgnore(raw); - const patterns: string[] = []; - for (const entry of entries) { - if (entry.startsWith("!")) { - throw new MountNativeError( - `MOUNT_NATIVE pattern ${JSON.stringify(entry)} is an exclusion. MOUNT_NATIVE lists ` + - `local-only directories to mount; keep exclusions in MOUNT_IGNORE.`, - ); - } - let normalized: string; - try { - normalized = resolveMountIgnore([entry], mountPoint).patterns[0] as string; - } catch (error) { - if (error instanceof MountIgnorePathError) { - throw new MountNativeError(error.message.replace(/^MOUNT_IGNORE/, "MOUNT_NATIVE")); - } - throw error; - } - if (!ignore.patterns.includes(normalized)) { - throw new MountNativeError( - `MOUNT_NATIVE pattern ${JSON.stringify(entry)} is not in MOUNT_IGNORE. Only ` + - `local-only paths can be mounted natively; add ${JSON.stringify(normalized)} ` + - `to MOUNT_IGNORE as well.`, - ); - } - patterns.push(normalized); - } - const set = resolveMountIgnore(patterns, "/"); - return { patterns, enabled: patterns.length > 0, matches: set.matches }; + return { root: normalizedRoot, ignore, enabled: !ignore.isEmpty }; } export interface PassthroughStatus { @@ -164,8 +97,13 @@ export interface MountIgnoreInfo { }; } -/** Counters from the native-roots module, as `describeMountIgnore` reads them. */ -export interface NativeRootsCounters { +/** + * 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; @@ -174,10 +112,9 @@ export interface NativeRootsCounters { } export interface NativeRootsInfo { - /** True when native roots are being mounted. */ + /** True when local-only directories are being bind-mounted. */ readonly enabled: boolean; readonly reason: string; - readonly patterns: readonly string[]; readonly active: number; readonly mounts: number; readonly failures: number; @@ -188,10 +125,10 @@ export interface NativeRootsInfo { export function describeMountIgnore( config: MountIgnoreConfig, status?: PassthroughStatus, - native?: NativeRootsCounters, + native?: NativeRootsStatus, ): MountIgnoreInfo { const inactive = inactivePassthroughReason(config, status); - const nativeInactive = inactiveNativeRootsReason(config, status); + const nativeInactive = inactiveNativeRootsReason(config, status, native); return { supported: true, enabled: config.enabled, @@ -207,7 +144,6 @@ export function describeMountIgnore( nativeRoots: { enabled: nativeInactive === undefined, reason: nativeInactive ?? nativeActiveReason(native), - patterns: config.native.patterns, active: native?.active ?? 0, mounts: native?.mounts ?? 0, failures: native?.failures ?? 0, @@ -223,9 +159,11 @@ export function describeMountIgnore( function inactiveNativeRootsReason( config: MountIgnoreConfig, status: PassthroughStatus | undefined, + native: NativeRootsStatus | undefined, ): string | undefined { - if (!config.native.enabled) return "no native roots are configured (MOUNT_NATIVE)"; - if (status === undefined) return "no kernel FUSE mount is running"; + 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"; } @@ -235,7 +173,7 @@ function inactiveNativeRootsReason( return undefined; } -function nativeActiveReason(native: NativeRootsCounters | undefined): string { +function nativeActiveReason(native: NativeRootsStatus | undefined): string { if (native === undefined || native.failures === 0) return "active"; if (native.mounts === 0) { return ( diff --git a/packages/computerd/src/fuse/ignore.test.ts b/packages/computerd/src/fuse/ignore.test.ts index 49296e9a4..18794caf2 100644 --- a/packages/computerd/src/fuse/ignore.test.ts +++ b/packages/computerd/src/fuse/ignore.test.ts @@ -167,32 +167,6 @@ describe("resolveMountIgnore: exclusions", () => { }); }); -describe("resolveMountIgnore: matches", () => { - test("is true only for the path a pattern names, not what lies under it", () => { - const ignore = set(["**/node_modules", "/dist"]); - expect(ignore.matches("node_modules")).toBe(true); - expect(ignore.matches("app/node_modules")).toBe(true); - expect(ignore.matches("dist")).toBe(true); - expect(ignore.matches("node_modules/react")).toBe(false); - expect(ignore.matches("dist/main.js")).toBe(false); - expect(ignore.matches("src")).toBe(false); - expect(ignore.matches("")).toBe(false); - }); - - test("the last matching pattern wins, as for ignores", () => { - const ignore = set(["**/node_modules", "!/vendor/node_modules"]); - expect(ignore.matches("vendor/node_modules")).toBe(false); - expect(ignore.matches("app/node_modules")).toBe(true); - }); - - test("a nested match under a matched directory still matches", () => { - // matches() looks at one path; whether an ancestor is local is - // ignores()' question. - const ignore = set(["**/node_modules"]); - expect(ignore.matches("node_modules/a/node_modules")).toBe(true); - }); -}); - describe("resolveMountIgnore: normalization", () => { test("keeps patterns in the order written", () => { // Order changes the meaning once exclusions are involved. diff --git a/packages/computerd/src/fuse/ignore.ts b/packages/computerd/src/fuse/ignore.ts index 991c007f2..174798653 100644 --- a/packages/computerd/src/fuse/ignore.ts +++ b/packages/computerd/src/fuse/ignore.ts @@ -31,12 +31,6 @@ export class MountIgnorePathError extends Error { export interface MountIgnoreSet { /** Whether a mount-relative path is local-only. Never touches disk. */ readonly ignores: (relativePath: string) => boolean; - /** - * Whether the last pattern matching exactly this path, not a directory - * above it, is an inclusion. `ignores` answers "is this path local?"; - * this answers "is this the path a pattern names?". - */ - readonly matches: (relativePath: string) => boolean; /** Normalized patterns, in the order written. Order matters. */ readonly patterns: readonly string[]; /** Exclusions under a local-only directory, which therefore do nothing. */ @@ -105,21 +99,8 @@ export function resolveMountIgnore(entries: readonly string[], mountPoint = "/") return false; }; - const matches = (relativePath: string): boolean => { - if (isEmpty) return false; - const path = stripSlashes(relativePath); - if (path === "") return false; - const segments = path.split("/"); - let matched = false; - for (const pattern of compiled) { - if (matchSegments(pattern.segments, 0, segments, 0)) matched = !pattern.exclude; - } - return matched; - }; - return { ignores, - matches, isEmpty, patterns: compiled.map((pattern) => pattern.source), ineffectiveExclusions: compiled diff --git a/packages/computerd/src/fuse/index.ts b/packages/computerd/src/fuse/index.ts index 425e0d62f..2543a8831 100644 --- a/packages/computerd/src/fuse/index.ts +++ b/packages/computerd/src/fuse/index.ts @@ -2,20 +2,20 @@ export type { FUSEBackend, FuseMountMode, ResolveFuseBackendOptions } from "./ba export { parseFuseMountMode, resolveFuseBackend } from "./backend.js"; export type { FuseMount, FuseOps, FuseStat } 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, - NativeRootsConfig, NativeRootsInfo, + NativeRootsStatus, PassthroughStatus, } from "./ignore-config.js"; export { defaultIgnoreRoot, describeMountIgnore, - MountNativeError, resolveMountIgnoreConfig, } from "./ignore-config.js"; export type { NativeRoots, NativeRootsOptions, NativeRootsStats } from "./native-roots.js"; diff --git a/packages/computerd/src/fuse/native-roots.fuse.test.ts b/packages/computerd/src/fuse/native-roots.fuse.test.ts index e0ac8827c..45b56a535 100644 --- a/packages/computerd/src/fuse/native-roots.fuse.test.ts +++ b/packages/computerd/src/fuse/native-roots.fuse.test.ts @@ -1,5 +1,5 @@ // Boots the binary in a privileged container, as passthrough.fuse.test.ts -// does, with MOUNT_NATIVE set, and checks the kernel's view directly: +// 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 @@ -52,8 +52,7 @@ function dockerAvailable(): boolean { const describeIfReal = target !== undefined && dockerAvailable() ? describe : describe.skip; -const IGNORE = "**/node_modules,!/vendor/node_modules,/dist"; -const NATIVE = "**/node_modules"; +const IGNORE = "**/node_modules,!/vendor/node_modules,/dist,**/*.tsbuildinfo"; interface Booted { cid: string; @@ -155,7 +154,7 @@ describeIfReal("native roots under a real mount", () => { let available = false; beforeAll(async () => { - booted = await boot({ MOUNT_IGNORE: IGNORE, MOUNT_NATIVE: NATIVE }); + booted = await boot({ MOUNT_IGNORE: IGNORE }); available = (await nativeInfo(booted)).enabled; }, 120_000); @@ -227,7 +226,14 @@ describeIfReal("native roots under a real mount", () => { expect(sh(b, "cat reval/node_modules/f").stdout).toBe("x\n"); }, 30_000); - test("nothing but a topmost native directory is mounted", async (ctx) => { + 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, @@ -236,8 +242,8 @@ describeIfReal("native roots under a real mount", () => { "mkdir -p top/node_modules/pkg/node_modules", // Kept synced by an exclusion. "mkdir -p vendor/node_modules", - // Local-only, but not in MOUNT_NATIVE. - "mkdir -p dist/assets", + // 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", @@ -248,7 +254,7 @@ describeIfReal("native roots under a real mount", () => { 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/dist"); + expect(all).not.toContain("/workspace/tsconfig.tsbuildinfo"); expect(all).not.toContain("/workspace/linked/node_modules"); }, 30_000); @@ -320,7 +326,7 @@ describeIfReal("native roots across restarts", () => { // 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, MOUNT_NATIVE: NATIVE, ...env }); + 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); @@ -330,7 +336,6 @@ describeIfReal("native roots across restarts", () => { kill(first); const second = await boot({ MOUNT_IGNORE: IGNORE, - MOUNT_NATIVE: NATIVE, COMPUTERD_HARNESS_IMAGE: name, ...env, }); @@ -363,7 +368,7 @@ describeIfReal("native roots across restarts", () => { }, 120_000); test("SIGTERM detaches the roots before unmounting FUSE", async (ctx) => { - const b = await boot({ MOUNT_IGNORE: IGNORE, MOUNT_NATIVE: NATIVE }); + 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"); @@ -384,12 +389,19 @@ describeIfReal("native roots across restarts", () => { 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, - MOUNT_NATIVE: NATIVE, - COMPUTERD_FUSE_PASSTHROUGH: "0", - }); + const b = await boot({ MOUNT_IGNORE: IGNORE, COMPUTERD_FUSE_PASSTHROUGH: "0" }); started.push(b); const info = await nativeInfo(b); expect(info.enabled).toBe(false); diff --git a/packages/computerd/src/fuse/native-roots.test.ts b/packages/computerd/src/fuse/native-roots.test.ts index d3eb21f40..6266fb593 100644 --- a/packages/computerd/src/fuse/native-roots.test.ts +++ b/packages/computerd/src/fuse/native-roots.test.ts @@ -21,7 +21,6 @@ interface Harness { function harness( options: { ignore?: string[]; - native?: string[]; dirs?: string[]; files?: string[]; symlinks?: string[]; @@ -33,7 +32,6 @@ function harness( } = {}, ): Harness { const ignore = resolveMountIgnore(options.ignore ?? ["**/node_modules", "/dist"], MOUNT); - const native = resolveMountIgnore(options.native ?? ["**/node_modules"], "/"); const dirs = new Set(options.dirs ?? []); const files = new Set(options.files ?? []); const symlinks = new Set(options.symlinks ?? []); @@ -94,7 +92,6 @@ function harness( root: ROOT, mountPoint: MOUNT, ignore, - native: native.matches, ...(options.active ? { active: options.active } : {}), ...seams, }); @@ -102,7 +99,7 @@ function harness( } describe("isRoot", () => { - test("is the topmost local-only directory a MOUNT_NATIVE pattern names", () => { + 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); @@ -117,9 +114,10 @@ describe("isRoot", () => { expect(roots.isRoot("")).toBe(false); }); - test("is false for a local-only path that MOUNT_NATIVE does not name", () => { + test("is any local-only pattern's directory, not only node_modules", () => { const { roots } = harness(); - expect(roots.isRoot("dist")).toBe(false); + expect(roots.isRoot("dist")).toBe(true); + expect(roots.isRoot("dist/assets")).toBe(false); }); test("respects an exclusion that keeps a path synced", () => { @@ -143,8 +141,8 @@ describe("ensure", () => { }); test("ignores paths that are not native roots", async () => { - const { roots, commands } = harness({ dirs: ["src", "dist", "node_modules/react"] }); - for (const path of ["src", "dist", "node_modules/react"]) roots.ensure(path); + 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([]); }); @@ -269,7 +267,6 @@ describe("scan", () => { root: ROOT, mountPoint: MOUNT, ignore: resolveMountIgnore(["**/node_modules", "/dist"], MOUNT), - native: resolveMountIgnore(["**/node_modules"], "/").matches, run: async (argv) => { h.commands.push([...argv]); return 0; @@ -293,8 +290,12 @@ describe("scan", () => { roots.scan(); await roots.settled(); const bound = h.commands.filter((argv) => argv[1] === "--bind").map((argv) => argv[3]); - expect(bound.sort()).toEqual([`${MOUNT}/node_modules`, `${MOUNT}/packages/a/node_modules`]); - // Never inside a root, and never inside another local-only directory. + 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); }); @@ -336,7 +337,6 @@ describe("parking", () => { root: ROOT, mountPoint: MOUNT, ignore: resolveMountIgnore(["**/node_modules"], MOUNT), - native: resolveMountIgnore(["**/node_modules"], "/").matches, run: async (argv) => (argv[1] === "--bind" ? 32 : 0), mountinfo: () => "", lstat: () => ({ isDirectory: () => true, dev: 1, ino: 1 }), diff --git a/packages/computerd/src/fuse/native-roots.ts b/packages/computerd/src/fuse/native-roots.ts index 704c1d94d..9c2ac1cd2 100644 --- a/packages/computerd/src/fuse/native-roots.ts +++ b/packages/computerd/src/fuse/native-roots.ts @@ -1,5 +1,5 @@ // Native roots: bind-mounts local-only directories onto the mount. See -// packages/computerd/README.md#native-roots-mount_native. +// 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 @@ -40,8 +40,6 @@ export interface NativeRootsOptions { readonly mountPoint: string; /** The decided ignore set. */ readonly ignore: MountIgnoreSet; - /** Whether a mount-relative path is named by a MOUNT_NATIVE pattern. */ - readonly native: (relativePath: string) => boolean; /** * Checked on every `ensure`. Lets the daemon build this before the * FUSE mount exists and switch it on once passthrough is negotiated. @@ -91,7 +89,7 @@ export interface NativeRoots { detach(relativePath?: string): Promise; /** Lazily detach everything, at startup and shutdown. Parks nothing. */ detachAll(): Promise; - /** Whether `rel` is a native root: the topmost local-only directory named by MOUNT_NATIVE. */ + /** Whether `rel` is a native root: a local-only directory whose parent is synced. */ isRoot(relativePath: string): boolean; stats(): NativeRootsStats; } @@ -124,9 +122,7 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { const isRoot = (relativePath: string): boolean => { const rel = stripSlashes(relativePath); if (rel === "") return false; - return ( - options.ignore.ignores(rel) && !options.ignore.ignores(parentOf(rel)) && options.native(rel) - ); + return options.ignore.ignores(rel) && !options.ignore.ignores(parentOf(rel)); }; // Everything strictly under the mount point. The FUSE mount itself sits diff --git a/packages/computerd/src/fuse/options.test.ts b/packages/computerd/src/fuse/options.test.ts index 8271baeb7..1af90f3bc 100644 --- a/packages/computerd/src/fuse/options.test.ts +++ b/packages/computerd/src/fuse/options.test.ts @@ -4,6 +4,7 @@ import { buildFuseInitConfig, buildFuseMountOptions, type FuseOptionEnv, + nativeRootsRequested, passthroughRequested, } from "./options.js"; @@ -169,3 +170,15 @@ describe("passthroughRequested", () => { } }); }); + +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 a91b45e5f..0d6a2cdce 100644 --- a/packages/computerd/src/fuse/options.ts +++ b/packages/computerd/src/fuse/options.ts @@ -31,6 +31,7 @@ export interface FuseOptionEnv { COMPUTERD_FUSE_NEGATIVE_TIMEOUT?: string; COMPUTERD_FUSE_EXTRA_OPTS?: string; COMPUTERD_FUSE_PASSTHROUGH?: string; + COMPUTERD_FUSE_NATIVE_ROOTS?: string; } type OptionValue = boolean | number | string; @@ -65,8 +66,21 @@ export function buildFuseMountOptions(env: FuseOptionEnv): FuseMountOptions { } export function passthroughRequested(env: FuseOptionEnv): boolean { - const value = env.COMPUTERD_FUSE_PASSTHROUGH?.trim().toLowerCase(); - return !(value === "0" || value === "false" || value === "no" || value === "off"); + return !isOff(env.COMPUTERD_FUSE_PASSTHROUGH); +} + +/** + * 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 nativeRootsRequested(env: FuseOptionEnv): boolean { + return !isOff(env.COMPUTERD_FUSE_NATIVE_ROOTS); +} + +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. diff --git a/packages/computerd/src/fuse/passthrough.fuse.test.ts b/packages/computerd/src/fuse/passthrough.fuse.test.ts index 34cdef50d..d1cd09cdd 100644 --- a/packages/computerd/src/fuse/passthrough.fuse.test.ts +++ b/packages/computerd/src/fuse/passthrough.fuse.test.ts @@ -51,6 +51,9 @@ describeIfReal("FUSE passthrough under a real mount", () => { ...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, }, diff --git a/script/npm-bench.sh b/script/npm-bench.sh index 0ae4c6815..5e20dc585 100755 --- a/script/npm-bench.sh +++ b/script/npm-bench.sh @@ -116,10 +116,10 @@ stop_heartbeat() { fi } -# A native root (MOUNT_NATIVE) is a mount point, and rmdir of a mount -# point fails with EBUSY, so `rm -rf` of a previous run's directory stops -# at its node_modules. Detach them first; COMPUTERD_URL is set when the -# daemon mounts native roots. A detach is always safe. +# A native root is a mount point, and rmdir of a mount point fails with +# EBUSY, so `rm -rf` of a previous run's directory stops at its +# node_modules. 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 diff --git a/script/run-fs-bench.sh b/script/run-fs-bench.sh index db214edbc..92c2d2d99 100755 --- a/script/run-fs-bench.sh +++ b/script/run-fs-bench.sh @@ -15,8 +15,8 @@ # 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. -# NATIVE_ROOTS=1, with LOCAL_ONLY=1, also lists it in MOUNT_NATIVE, so the -# bench runs inside a bind-mounted native root. +# 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 attr util-linux coreutils findutils git ca-certificates curl npm >/dev/null 2>&1 @@ -31,10 +31,6 @@ if [ "${LOCAL_ONLY:-0}" = "1" ]; then BENCH_MOUNT=/tmp/workspace/bench MOUNT_IGNORE=/bench fi -MOUNT_NATIVE="" -if [ "${NATIVE_ROOTS:-0}" = "1" ]; then - MOUNT_NATIVE="$MOUNT_IGNORE" -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 @@ -43,7 +39,7 @@ COMPUTERD_FUSE_TRACE="${COMPUTERD_FUSE_TRACE:-}" \ COMPUTERD_FUSE_TRACE_FILE="${COMPUTERD_FUSE_TRACE_FILE:-}" \ COMPUTERD_FUSE_PASSTHROUGH="${COMPUTERD_FUSE_PASSTHROUGH:-}" \ MOUNT_IGNORE="$MOUNT_IGNORE" \ - MOUNT_NATIVE="$MOUNT_NATIVE" \ + 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=$! @@ -67,7 +63,7 @@ mkdir -p "$BENCH_MOUNT" MOUNT="$BENCH_MOUNT" BASE=/tmp/baseline /usr/local/bin/fs-bench status=$? -if [ -n "$MOUNT_NATIVE" ]; then +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 diff --git a/script/run-npm-bench-inner.sh b/script/run-npm-bench-inner.sh index f10513c9c..ec233695f 100755 --- a/script/run-npm-bench-inner.sh +++ b/script/run-npm-bench-inner.sh @@ -17,17 +17,12 @@ MOUNT_IGNORE="" if [ "${IGNORE_NODE_MODULES:-0}" = "1" ]; then MOUNT_IGNORE="/ignored/*/node_modules" fi -# NATIVE_ROOTS=1 bind-mounts those node_modules too (MOUNT_NATIVE). -MOUNT_NATIVE="" -if [ "${NATIVE_ROOTS:-0}" = "1" ]; then - MOUNT_NATIVE="$MOUNT_IGNORE" -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" \ - MOUNT_NATIVE="$MOUNT_NATIVE" \ + 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=$! @@ -46,7 +41,7 @@ if ! kill -0 "$COMPUTERD_PID" 2>/dev/null; then fi COMPUTERD_URL="" -if [ -n "$MOUNT_NATIVE" ]; then +if [ -n "$MOUNT_IGNORE" ]; then COMPUTERD_URL=http://127.0.0.1:45678 fi diff --git a/script/run-npm-bench.sh b/script/run-npm-bench.sh index 656c41b83..95d143f01 100755 --- a/script/run-npm-bench.sh +++ b/script/run-npm-bench.sh @@ -24,8 +24,8 @@ # /tmp/workspace/ignored local-only (MOUNT_IGNORE) # COMPUTERD_FUSE_PASSTHROUGH set to 0 to measure local-only paths # without kernel passthrough -# NATIVE_ROOTS set to 1, with IGNORE_NODE_MODULES=1, to bind-mount -# those node_modules as native roots (MOUNT_NATIVE) +# 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. @@ -50,7 +50,7 @@ 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:-}" -NATIVE_ROOTS="${NATIVE_ROOTS:-0}" +COMPUTERD_FUSE_NATIVE_ROOTS="${COMPUTERD_FUSE_NATIVE_ROOTS:-}" PLATFORM="${PLATFORM:-linux/amd64}" if [ ! -f "$COMPUTERD_BINARY" ]; then @@ -75,5 +75,5 @@ docker run --rm --platform "$PLATFORM" --privileged \ -e "TARGETS=$TARGETS" \ -e "IGNORE_NODE_MODULES=$IGNORE_NODE_MODULES" \ -e "COMPUTERD_FUSE_PASSTHROUGH=$COMPUTERD_FUSE_PASSTHROUGH" \ - -e "NATIVE_ROOTS=$NATIVE_ROOTS" \ + -e "COMPUTERD_FUSE_NATIVE_ROOTS=$COMPUTERD_FUSE_NATIVE_ROOTS" \ debian:stable-slim bash /run-bench.sh From acf7700b55311117fbe6c1ec49f97a138de55528 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:50:08 +0100 Subject: [PATCH 19/28] computerd: Let commands remove and rename native roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rmdir and rename of a mount point fail with EBUSY in the kernel's VFS before FUSE is consulted, so with native roots `rm -rf node_modules` emptied the directory and then failed on it, and so did fs.rm, rimraf, and anything else that removes or renames a dependency tree. computerd never saw the call. computerd now installs a seccomp filter on its JavaScript main thread, the one every command is spawned from, so each command and everything it starts inherits it. The filter pauses rmdir, the rename family, and unlinkat with AT_REMOVEDIR, which BPF can tell apart from a file deletion, and allows everything else. A thread in the vendored binding reads the path, and if it names a native root, lazily detaches the mount and lets the call continue; computerd then serves it through FUSE like any local-only path, so rm succeeds and mv of a root gets the EXDEV it gets without a mount. The detach is reported back so the root is parked and a lookup cannot remount it under the removal. The thread is started before the filter is installed, so it is never filtered itself, and it never waits on the JavaScript thread, whose own removals and renames are trapped too: paths are read through /proc and process_vm_readv, checked against an in-memory table, and a root is detached through a descriptor rather than its path under the FUSE mount, whose walk could need a lookup that thread has to answer. It answers every call with CONTINUE. This is a convenience rather than a boundary; a path changed after it was read costs at most a needless detach or the old EBUSY. The filter is installed without no_new_privs, so setuid binaries keep working; that needs CAP_SYS_ADMIN, which native roots need already. On Cloudflare Containers PID 1 has it and seccomp user notification with CONTINUE works. Each directory removal or rename costs about 15 µs. Processes computerd did not start still get EBUSY and can use the detach endpoint, which the npm bench does. /__computerd/info reports the guard as nativeRoots.transparentRemoval with a reason. --- .changeset/native-roots.md | 13 +- packages/computerd/README.md | 48 +- packages/computerd/scripts/build-bin.mjs | 9 +- packages/computerd/src/cli/computerd.ts | 42 ++ packages/computerd/src/fuse/driver.ts | 11 + .../computerd/src/fuse/ignore-config.test.ts | 19 + packages/computerd/src/fuse/ignore-config.ts | 16 + packages/computerd/src/fuse/index.ts | 9 +- .../computerd/src/fuse/native-roots.test.ts | 95 +++ packages/computerd/src/fuse/native-roots.ts | 93 ++- .../computerd/vendor/fuse-napi/PATCHES.md | 31 + .../computerd/vendor/fuse-napi/binding.gyp | 3 +- .../computerd/vendor/fuse-napi/fuse-native.c | 5 + .../computerd/vendor/fuse-napi/index.d.ts | 7 + packages/computerd/vendor/fuse-napi/index.js | 19 + .../vendor/fuse-napi/removal-guard.c | 593 ++++++++++++++++++ .../vendor/fuse-napi/removal-guard.h | 12 + script/npm-bench.sh | 6 +- 18 files changed, 1004 insertions(+), 27 deletions(-) create mode 100644 packages/computerd/vendor/fuse-napi/removal-guard.c create mode 100644 packages/computerd/vendor/fuse-napi/removal-guard.h diff --git a/.changeset/native-roots.md b/.changeset/native-roots.md index c9157a519..272bcb884 100644 --- a/.changeset/native-roots.md +++ b/.changeset/native-roots.md @@ -19,6 +19,13 @@ from util-linux; without them local-only paths are served as before. `COMPUTERD_FUSE_NATIVE_ROOTS=0` turns them off. `/__computerd/info` reports the state under `ignore.fastPaths.nativeRoots`. -Removing or renaming a mounted directory itself fails with `EBUSY`, as for a -Docker volume. `POST /__computerd/native-roots/detach` detaches one, or all, -first. +Removing or renaming a mounted directory itself would fail with `EBUSY`, as +for a Docker volume. For the commands computerd runs, a removal guard prevents +that: computerd installs a seccomp filter on the thread it spawns commands +from, and when a command removes or renames a native root, it detaches the +mount first and lets the call continue. `rm -rf node_modules`, `fs.rm`, and +`mv` behave as they do without a mount. It needs `CAP_SYS_ADMIN` and costs +about 15 µs per directory removal or rename; file operations are not +affected. Processes computerd did not start still get `EBUSY`, and can call +`POST /__computerd/native-roots/detach` first. `/__computerd/info` reports +the guard as `nativeRoots.transparentRemoval`. diff --git a/packages/computerd/README.md b/packages/computerd/README.md index 644021a24..81c86131c 100644 --- a/packages/computerd/README.md +++ b/packages/computerd/README.md @@ -375,19 +375,35 @@ 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 never asks computerd, so it cannot -intervene: - -| Command | Through FUSE | On a native root | -| --- | --- | --- | -| `rm -rf node_modules` | works | empties it, then `EBUSY` on the directory itself | -| `rm -rf node_modules/*` | works | works | -| `mv node_modules elsewhere` | `EXDEV`, so `mv` copies | `EBUSY` | -| `rm -rf packages/app` with a root inside | works | `EBUSY` on the root | -| `mv packages/app packages/web` | works | works; the mount moves with it | - -These are the rules of a Docker volume mounted at `node_modules`. To -remove a root outright, detach it first: +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 \ @@ -413,7 +429,8 @@ was and computerd serves it through FUSE. ```json { "enabled": true, "reason": "active", "active": 3, "mounts": 4, - "failures": 0, "detaches": 1 } + "failures": 0, "detaches": 1, "transparentRemoval": true, + "transparentRemovalReason": "active", "removalDetaches": 1 } ``` `active` is read from `/proc/self/mountinfo`, which is the only record @@ -421,6 +438,9 @@ of what is mounted: anything mounted under `MOUNT_POINT` other than the FUSE mount itself is a native root. `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`. diff --git a/packages/computerd/scripts/build-bin.mjs b/packages/computerd/scripts/build-bin.mjs index 42b887dd6..6473148db 100644 --- a/packages/computerd/scripts/build-bin.mjs +++ b/packages/computerd/scripts/build-bin.mjs @@ -207,7 +207,14 @@ function assertLibfuseVersion(version) { async function addonCacheKey(target) { const hash = createHash("sha256").update(`${addonBuildImage}\n${target.dockerPlatform}\n`); - for (const file of ["fuse-native.c", "binding.gyp", "scripts/fuse-config.js", "package.json"]) { + for (const file of [ + "fuse-native.c", + "removal-guard.c", + "removal-guard.h", + "binding.gyp", + "scripts/fuse-config.js", + "package.json", + ]) { hash.update(await readFile(resolve(addonSourceDir, file))); } return hash.digest("hex"); diff --git a/packages/computerd/src/cli/computerd.ts b/packages/computerd/src/cli/computerd.ts index df2db14d2..b5aceb417 100644 --- a/packages/computerd/src/cli/computerd.ts +++ b/packages/computerd/src/cli/computerd.ts @@ -4,6 +4,7 @@ import { timingSafeEqual } from "node:crypto"; 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"; @@ -23,6 +24,7 @@ import { mountFuse, type NativeRoots, nativeRootsRequested, + type RemovalGuardBinding, parseFuseMountMode, parseStoreMode, type ResolvedStore, @@ -157,9 +159,42 @@ const NO_NATIVE_ROOT_STATS = { 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; @@ -755,6 +790,7 @@ async function main(): Promise { : { requested: nativeRootsWanted, ...(nativeRoots?.stats() ?? NO_NATIVE_ROOT_STATS), + ...(removal === undefined ? {} : { removal }), }, ); }, @@ -765,6 +801,7 @@ async function main(): Promise { // 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({ @@ -827,6 +864,11 @@ async function main(): Promise { // 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(); diff --git a/packages/computerd/src/fuse/driver.ts b/packages/computerd/src/fuse/driver.ts index f981352c9..6b68da6d0 100644 --- a/packages/computerd/src/fuse/driver.ts +++ b/packages/computerd/src/fuse/driver.ts @@ -157,6 +157,16 @@ export interface FuseMount { 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; +} + +/** 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 { @@ -1169,6 +1179,7 @@ export async function mountFuse(options: { }, getBufferStats: routedOps.getBufferStats, ...(localPaths === undefined ? {} : { getLocalPathStats: localPaths.stats }), + ...(Fuse.removalGuard === undefined ? {} : { removalGuard: Fuse.removalGuard }), getPassthroughStatus: () => { const stats = localPaths?.stats(); return { diff --git a/packages/computerd/src/fuse/ignore-config.test.ts b/packages/computerd/src/fuse/ignore-config.test.ts index f8f5945f6..0887425e0 100644 --- a/packages/computerd/src/fuse/ignore-config.test.ts +++ b/packages/computerd/src/fuse/ignore-config.test.ts @@ -188,7 +188,9 @@ describe("describeMountIgnore: nativeRoots", () => { 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", () => { @@ -199,6 +201,23 @@ describe("describeMountIgnore: nativeRoots", () => { 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", }); }); diff --git a/packages/computerd/src/fuse/ignore-config.ts b/packages/computerd/src/fuse/ignore-config.ts index 4e4628110..9352424c6 100644 --- a/packages/computerd/src/fuse/ignore-config.ts +++ b/packages/computerd/src/fuse/ignore-config.ts @@ -108,7 +108,13 @@ export interface NativeRootsStatus { 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 { @@ -119,6 +125,10 @@ export interface NativeRootsInfo { 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; } /** `status` is undefined when no kernel FUSE mount is running. */ @@ -148,6 +158,12 @@ export function describeMountIgnore( 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, }, }, }; diff --git a/packages/computerd/src/fuse/index.ts b/packages/computerd/src/fuse/index.ts index 2543a8831..7ed935970 100644 --- a/packages/computerd/src/fuse/index.ts +++ b/packages/computerd/src/fuse/index.ts @@ -1,6 +1,6 @@ export type { FUSEBackend, FuseMountMode, ResolveFuseBackendOptions } from "./backend.js"; export { parseFuseMountMode, resolveFuseBackend } from "./backend.js"; -export type { FuseMount, FuseOps, FuseStat } from "./driver.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"; @@ -18,7 +18,12 @@ export { describeMountIgnore, resolveMountIgnoreConfig, } from "./ignore-config.js"; -export type { NativeRoots, NativeRootsOptions, NativeRootsStats } from "./native-roots.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"; diff --git a/packages/computerd/src/fuse/native-roots.test.ts b/packages/computerd/src/fuse/native-roots.test.ts index 6266fb593..0bac3bfd7 100644 --- a/packages/computerd/src/fuse/native-roots.test.ts +++ b/packages/computerd/src/fuse/native-roots.test.ts @@ -86,6 +86,7 @@ function harness( return [...names]; }, warn: (message) => warnings.push(message), + openDirectory: async () => 100 + commands.length, }; const roots = createNativeRoots({ @@ -355,6 +356,100 @@ describe("parking", () => { }); }); +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), + run: async (argv) => { + commands.push([...argv]); + return 0; + }, + mountinfo: () => "", + lstat: () => ({ isDirectory: () => true, dev: 1, ino: 1 }), + lstatTarget: async () => { + await gate; + return { isDirectory: () => true }; + }, + stat: async () => ({ dev: 1, ino: 1 }), + }); + roots.ensure("node_modules"); + roots.guardDetached(`${MOUNT}/node_modules`); + release(); + await roots.settled(); + expect(commands).toEqual([]); + }); +}); + describe("detach", () => { 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"] }); diff --git a/packages/computerd/src/fuse/native-roots.ts b/packages/computerd/src/fuse/native-roots.ts index 9c2ac1cd2..d087ff4f4 100644 --- a/packages/computerd/src/fuse/native-roots.ts +++ b/packages/computerd/src/fuse/native-roots.ts @@ -16,6 +16,10 @@ // to write, recover, or get out of step. // - A lazy detach is always safe: the detached tree is the same backing // directory the FUSE layer serves, and it is never synced. +// - 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.) @@ -28,7 +32,15 @@ // stat of a mounted target goes through the libuv thread pool. import { spawn } from "node:child_process"; -import { lstatSync, readdirSync, readFileSync, promises as fsPromises } from "node:fs"; +import { + closeSync, + constants as fsConstants, + lstatSync, + open as openCallback, + readdirSync, + readFileSync, + promises as fsPromises, +} from "node:fs"; import { dirname, join } from "node:path"; import type { MountIgnoreSet } from "./ignore.js"; @@ -54,6 +66,17 @@ export interface NativeRootsOptions { 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 { @@ -63,8 +86,10 @@ export interface NativeRootsStats { readonly mounts: number; /** Mounts that were refused or failed verification. */ readonly failures: number; - /** Lazy detaches issued since start. */ + /** 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; } @@ -91,6 +116,16 @@ export interface NativeRoots { 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; } @@ -114,6 +149,35 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { let failures = 0; let detaches = 0; let lastFailure: string | undefined; + let removalDetaches = 0; + let guard: RemovalGuard | undefined; + const openDirectory = + options.openDirectory ?? + ((path: string) => + new Promise((resolve, reject) => { + // The callback form, for a raw descriptor the guard can own; a + // FileHandle would close itself when collected. + openCallback(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY, (error, fd) => { + if (error) reject(error); + else resolve(fd); + }); + })); + + // Through the thread pool: the target is under the mount. + const registerWithGuard = async (target: string): Promise => { + if (guard === undefined) return; + let fd: number; + try { + fd = await openDirectory(target); + } catch { + return; // Gone again; nothing to guard. + } + if (guard === undefined) { + closeSync(fd); + return; + } + guard.addRoot(target, fd); + }; const targetOf = (rel: string): string => join(mountPoint, rel); const relOf = (target: string): string => stripSlashes(target.slice(mountPoint.length)); @@ -190,6 +254,9 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { } 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; const bound = await run(["mount", "--bind", source, target]); if (bound !== 0) { @@ -216,6 +283,7 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { return; } mounts += 1; + await registerWithGuard(target); } const ensure = (relativePath: string, created = false): void => { @@ -257,6 +325,9 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { 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 run(["umount", "-l", target])) === 0) { detaches += 1; detached.push(target); @@ -285,7 +356,23 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { }, detachAll: () => detachTargets(owned()), isRoot, - stats: () => ({ active: owned().length, mounts, failures, detaches, lastFailure }), + 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, + }), }; } diff --git a/packages/computerd/vendor/fuse-napi/PATCHES.md b/packages/computerd/vendor/fuse-napi/PATCHES.md index c73a46a3d..b3e7e1b22 100644 --- a/packages/computerd/vendor/fuse-napi/PATCHES.md +++ b/packages/computerd/vendor/fuse-napi/PATCHES.md @@ -59,6 +59,37 @@ All changes are in `fuse-native.c`, `index.js`, and `index.d.ts`. 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`. + ## Verified on this host Kernel 6.18, libfuse 3.17.2, btrfs backing store. A mount that requests diff --git a/packages/computerd/vendor/fuse-napi/binding.gyp b/packages/computerd/vendor/fuse-napi/binding.gyp index a043bfff1..ef8d2376d 100644 --- a/packages/computerd/vendor/fuse-napi/binding.gyp +++ b/packages/computerd/vendor/fuse-napi/binding.gyp @@ -17,7 +17,8 @@ "libraries": ["<@(fuse__libraries)"]}, "libraries": [], "sources": [ - "fuse-native.c" + "fuse-native.c", + "removal-guard.c" ], 'defines': [ '_FILE_OFFSET_BITS=64' diff --git a/packages/computerd/vendor/fuse-napi/fuse-native.c b/packages/computerd/vendor/fuse-napi/fuse-native.c index 52ca92e13..db000ae1b 100644 --- a/packages/computerd/vendor/fuse-napi/fuse-native.c +++ b/packages/computerd/vendor/fuse-napi/fuse-native.c @@ -13,6 +13,7 @@ #include #include #include +#include "removal-guard.h" #include #include @@ -4120,6 +4121,10 @@ NAPI_INIT() { 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_signal_open) NAPI_EXPORT_FUNCTION(fuse_native_signal_opendir) NAPI_EXPORT_FUNCTION(fuse_native_signal_read) diff --git a/packages/computerd/vendor/fuse-napi/index.d.ts b/packages/computerd/vendor/fuse-napi/index.d.ts index 0315cb334..e4416b64d 100644 --- a/packages/computerd/vendor/fuse-napi/index.d.ts +++ b/packages/computerd/vendor/fuse-napi/index.d.ts @@ -714,6 +714,13 @@ declare class Fuse { /** FUSE capability bit for passthrough, for use with `want`. */ public static CAP_PASSTHROUGH: number; + /** 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; diff --git a/packages/computerd/vendor/fuse-napi/index.js b/packages/computerd/vendor/fuse-napi/index.js index 8fecc06c7..da20f9daf 100644 --- a/packages/computerd/vendor/fuse-napi/index.js +++ b/packages/computerd/vendor/fuse-napi/index.js @@ -1686,6 +1686,25 @@ 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. +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) { 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/script/npm-bench.sh b/script/npm-bench.sh index 5e20dc585..d07962a46 100755 --- a/script/npm-bench.sh +++ b/script/npm-bench.sh @@ -117,9 +117,9 @@ stop_heartbeat() { } # A native root is a mount point, and rmdir of a mount point fails with -# EBUSY, so `rm -rf` of a previous run's directory stops at its -# node_modules. Detach them first; COMPUTERD_URL is set when MOUNT_IGNORE -# is. A detach is always safe. +# 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 From f01e220eeb2527ab9fef90fc761746863ba16c0f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:50:08 +0100 Subject: [PATCH 20/28] computerd: Test root removal through the guard under real FUSE The native-roots suite runs commands through computerd's exec RPC, as a host does, so the removal guard covers them, and checks the kernel's view with docker exec as before. With the guard, rm -rf of a root and of a directory holding roots succeeds, a program calling rmdir(2) itself does too, renaming a root gets EXDEV so mv copies, and a background process that outlives its command can still remove one. Ordinary removals and renames are unaffected, and a removed root is mounted again when created again. The existing EBUSY case now names what it covers: processes computerd did not start, which the guard cannot see, and which still need the detach endpoint. --- .../src/fuse/native-roots.fuse.test.ts | 130 +++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/computerd/src/fuse/native-roots.fuse.test.ts b/packages/computerd/src/fuse/native-roots.fuse.test.ts index 45b56a535..a6eec8de5 100644 --- a/packages/computerd/src/fuse/native-roots.fuse.test.ts +++ b/packages/computerd/src/fuse/native-roots.fuse.test.ts @@ -14,7 +14,9 @@ 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"); @@ -132,6 +134,38 @@ interface NativeInfo { 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 { @@ -151,14 +185,28 @@ async function stats(booted: Booted): Promise<{ localOps: number; vfsNodes: numb 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(() => kill(booted)); + 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"); @@ -258,7 +306,9 @@ describeIfReal("native roots under a real mount", () => { expect(all).not.toContain("/workspace/linked/node_modules"); }, 30_000); - test("removing or renaming a root gets EBUSY until it is detached", async (ctx) => { + 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); @@ -305,6 +355,82 @@ describeIfReal("native roots under a real mount", () => { 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); From c6ed27df3337a828e1984d1a3e14257790595751 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:32:05 +0100 Subject: [PATCH 21/28] computerd: Reproduce restore-time bind escape A local-only backing directory can survive a container snapshot while the synced workspace parent becomes a symlink to a path outside the workspace. On restore, the path-based native-root bind follows that symlink and mounts the backing directory outside the FUSE tree. The mount is not counted as active there and normal cleanup cannot find it. Add a real-FUSE regression that plants the backing directory and outside victim, restores the snapshot, and checks the outside victim was neither covered nor changed. It fails against the current mount code. This commit records the vulnerability; it is not a fix and must not be deployed until the bind uses directory descriptors confined to the workspace and backing roots. --- .../src/fuse/native-roots.fuse.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/computerd/src/fuse/native-roots.fuse.test.ts b/packages/computerd/src/fuse/native-roots.fuse.test.ts index a6eec8de5..87da8988c 100644 --- a/packages/computerd/src/fuse/native-roots.fuse.test.ts +++ b/packages/computerd/src/fuse/native-roots.fuse.test.ts @@ -479,6 +479,36 @@ describeIfReal("native roots across restarts", () => { 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("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. From c40b7aa2b80b61377a48243fea136d7a4dd4d582 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:04:49 +0100 Subject: [PATCH 22/28] computerd: WIP bind native roots with pinned fds A restored workspace symlink can redirect a path-based privileged bind onto a directory outside /workspace. Resolve both mount endpoints under pinned directories in a native worker and attach a private clone by descriptor. If the helper is unavailable, leave the root on FUSE. Use confined opens and detaches for guard registration and cleanup as well. The focused unit and real-FUSE suites pass on arm64. Native x86_64 and Cloudflare Container behavior, deterministic path-swap tests, and a full-suite pass remain unverified. Keep native roots disabled in production until those checks and a security review are complete. --- packages/computerd/README.md | 15 +- packages/computerd/scripts/build-bin.mjs | 1 + .../src/fuse/native-roots.fuse.test.ts | 17 ++ .../computerd/src/fuse/native-roots.test.ts | 83 ++++--- packages/computerd/src/fuse/native-roots.ts | 107 ++++----- .../computerd/vendor/fuse-napi/binding.gyp | 3 +- .../vendor/fuse-napi/confined-bind.c | 218 ++++++++++++++++++ .../computerd/vendor/fuse-napi/fuse-native.c | 6 + .../computerd/vendor/fuse-napi/index.d.ts | 6 + packages/computerd/vendor/fuse-napi/index.js | 9 + .../computerd/vendor/fuse-napi/package.json | 3 + 11 files changed, 366 insertions(+), 102 deletions(-) create mode 100644 packages/computerd/vendor/fuse-napi/confined-bind.c diff --git a/packages/computerd/README.md b/packages/computerd/README.md index 81c86131c..0d0bb5b9a 100644 --- a/packages/computerd/README.md +++ b/packages/computerd/README.md @@ -352,8 +352,13 @@ whose parent is synced: `packages/app/node_modules` is one, the 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 is never a -problem for what a command sees. +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/info` for the most recent failed bind. Roots are mounted when they appear, with no watcher: @@ -367,8 +372,10 @@ Roots are mounted when they appear, with no watcher: - At startup, for roots already on disk. Native roots need FUSE passthrough to be negotiated (Linux 6.9 or newer), -`CAP_SYS_ADMIN`, and `mount` from util-linux in the image. Without any -of these, local-only paths are served through FUSE as described above. +`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. diff --git a/packages/computerd/scripts/build-bin.mjs b/packages/computerd/scripts/build-bin.mjs index 6473148db..1f37a3013 100644 --- a/packages/computerd/scripts/build-bin.mjs +++ b/packages/computerd/scripts/build-bin.mjs @@ -211,6 +211,7 @@ async function addonCacheKey(target) { "fuse-native.c", "removal-guard.c", "removal-guard.h", + "confined-bind.c", "binding.gyp", "scripts/fuse-config.js", "package.json", diff --git a/packages/computerd/src/fuse/native-roots.fuse.test.ts b/packages/computerd/src/fuse/native-roots.fuse.test.ts index 87da8988c..e4ffbc95a 100644 --- a/packages/computerd/src/fuse/native-roots.fuse.test.ts +++ b/packages/computerd/src/fuse/native-roots.fuse.test.ts @@ -509,6 +509,23 @@ describeIfReal("native roots across restarts", () => { 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. diff --git a/packages/computerd/src/fuse/native-roots.test.ts b/packages/computerd/src/fuse/native-roots.test.ts index 0bac3bfd7..8c259839d 100644 --- a/packages/computerd/src/fuse/native-roots.test.ts +++ b/packages/computerd/src/fuse/native-roots.test.ts @@ -15,8 +15,7 @@ interface Harness { /** * A fake system: `dirs` is the backing tree (paths relative to ROOT), - * `mount --bind` adds the target to the fake mount table, and the mounted - * target stats as its source unless `mismatch` is set. + * A confined bind adds the target to the fake mount table. */ function harness( options: { @@ -42,13 +41,16 @@ function harness( const inode = (path: string) => path.length; const seams: Partial = { - run: async (argv) => { - commands.push([...argv]); - if (argv[0] === "mount" && argv[1] === "--bind") { - if ((options.mountExit ?? 0) !== 0) return options.mountExit as number; - mounted.add(argv[3] as string); - } - if (argv[0] === "umount") mounted.delete(argv[2] as string); + 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; + mounted.add(`${target}/${rel}`); return 0; }, mountinfo: () => @@ -70,10 +72,6 @@ function harness( } return { isDirectory: () => true }; }, - stat: async (path) => { - const source = `${ROOT}${path.slice(MOUNT.length)}`; - return { dev: 1, ino: options.mismatch ? 0 : inode(source) }; - }, readdir: (path) => { const prefix = path === ROOT ? "" : `${rel(path)}/`; const names = new Set(); @@ -133,10 +131,7 @@ describe("ensure", () => { const { roots, commands, mounted } = harness({ dirs: ["app/node_modules"] }); roots.ensure("app/node_modules"); await roots.settled(); - expect(commands).toEqual([ - ["mount", "--bind", `${ROOT}/app/node_modules`, `${MOUNT}/app/node_modules`], - ["mount", "--make-private", `${MOUNT}/app/node_modules`], - ]); + 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 }); }); @@ -173,7 +168,7 @@ describe("ensure", () => { roots.ensure("node_modules"); roots.ensure("/node_modules/"); await roots.settled(); - expect(commands.filter((argv) => argv[1] === "--bind")).toHaveLength(1); + expect(commands.filter((argv) => argv[0] === "bindRoot")).toHaveLength(1); }); test("an already mounted root is left alone", async () => { @@ -182,7 +177,16 @@ describe("ensure", () => { await roots.settled(); roots.ensure("node_modules"); await roots.settled(); - expect(commands.filter((argv) => argv[1] === "--bind")).toHaveLength(1); + 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 () => { @@ -194,7 +198,7 @@ describe("ensure", () => { roots.ensure("b/node_modules"); await roots.settled(); expect(roots.stats()).toMatchObject({ active: 0, mounts: 0, failures: 2 }); - expect(roots.stats().lastFailure).toContain("exited 32"); + expect(roots.stats().lastFailure).toContain("failed (-32)"); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain("serving it through FUSE instead"); }); @@ -206,7 +210,7 @@ describe("ensure", () => { h.roots.ensure("node_modules"); h.roots.ensure("node_modules", false); await h.roots.settled(); - const binds = () => h.commands.filter((argv) => argv[1] === "--bind").length; + const binds = () => h.commands.filter((argv) => argv[0] === "bindRoot").length; expect(binds()).toBe(1); h.roots.ensure("node_modules", true); await h.roots.settled(); @@ -230,11 +234,11 @@ describe("ensure", () => { expect(roots.stats()).toMatchObject({ active: 1, mounts: 1 }); }); - test("a mount that does not show the backing directory is detached", async () => { + 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.at(-1)).toEqual(["umount", "-l", `${MOUNT}/node_modules`]); + expect(commands).toEqual([["bindRoot", ROOT, MOUNT, "node_modules"]]); expect(mounted.size).toBe(0); expect(roots.stats()).toMatchObject({ mounts: 0, failures: 1 }); }); @@ -249,11 +253,21 @@ describe("ensure", () => { on = true; roots.ensure("node_modules"); await roots.settled(); - expect(commands).toHaveLength(2); + 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({ @@ -268,13 +282,12 @@ describe("scan", () => { root: ROOT, mountPoint: MOUNT, ignore: resolveMountIgnore(["**/node_modules", "/dist"], MOUNT), - run: async (argv) => { - h.commands.push([...argv]); + bindRoot: async (source, target, rel) => { + h.commands.push(["bindRoot", source, target, rel]); return 0; }, mountinfo: () => "", lstat: () => ({ isDirectory: () => true, dev: 1, ino: 1 }), - stat: async () => ({ dev: 1, ino: 1 }), lstatTarget: async () => ({ isDirectory: () => true }), readdir: (path) => { readdirs.push(path); @@ -290,7 +303,9 @@ describe("scan", () => { }); roots.scan(); await roots.settled(); - const bound = h.commands.filter((argv) => argv[1] === "--bind").map((argv) => argv[3]); + const bound = h.commands + .filter((argv) => argv[0] === "bindRoot") + .map((argv) => `${MOUNT}/${argv[3]}`); expect(bound.sort()).toEqual([ `${MOUNT}/dist`, `${MOUNT}/node_modules`, @@ -338,7 +353,7 @@ describe("parking", () => { root: ROOT, mountPoint: MOUNT, ignore: resolveMountIgnore(["**/node_modules"], MOUNT), - run: async (argv) => (argv[1] === "--bind" ? 32 : 0), + bindRoot: async () => -2, mountinfo: () => "", lstat: () => ({ isDirectory: () => true, dev: 1, ino: 1 }), lstatTarget: async () => { @@ -346,7 +361,6 @@ describe("parking", () => { if (checks > 1) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); return { isDirectory: () => true }; }, - stat: async () => ({ dev: 1, ino: 1 }), warn: (message) => h.warnings.push(message), }); roots.ensure("node_modules"); @@ -430,8 +444,8 @@ describe("removal guard", () => { root: ROOT, mountPoint: MOUNT, ignore: resolveMountIgnore(["**/node_modules"], MOUNT), - run: async (argv) => { - commands.push([...argv]); + bindRoot: async (source, target, rel) => { + commands.push(["bindRoot", source, target, rel]); return 0; }, mountinfo: () => "", @@ -440,7 +454,6 @@ describe("removal guard", () => { await gate; return { isDirectory: () => true }; }, - stat: async () => ({ dev: 1, ino: 1 }), }); roots.ensure("node_modules"); roots.guardDetached(`${MOUNT}/node_modules`); @@ -460,8 +473,8 @@ describe("detach", () => { const detached = await roots.detachAll(); expect(detached).toEqual([`${MOUNT}/a/b/node_modules`, `${MOUNT}/a/node_modules`]); expect(commands).toEqual([ - ["umount", "-l", `${MOUNT}/a/b/node_modules`], - ["umount", "-l", `${MOUNT}/a/node_modules`], + ["unbindRoot", MOUNT, "a/b/node_modules"], + ["unbindRoot", MOUNT, "a/node_modules"], ]); expect(mounted.size).toBe(0); expect(roots.stats()).toMatchObject({ active: 0, detaches: 2 }); diff --git a/packages/computerd/src/fuse/native-roots.ts b/packages/computerd/src/fuse/native-roots.ts index d087ff4f4..3bb60e73a 100644 --- a/packages/computerd/src/fuse/native-roots.ts +++ b/packages/computerd/src/fuse/native-roots.ts @@ -28,19 +28,10 @@ // 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. mount and umount run as child processes, and the one -// stat of a mounted target goes through the libuv thread pool. +// /proc are fine. The native worker resolves and mounts the target off +// the main thread, using pinned directory descriptors. -import { spawn } from "node:child_process"; -import { - closeSync, - constants as fsConstants, - lstatSync, - open as openCallback, - readdirSync, - readFileSync, - promises as fsPromises, -} from "node:fs"; +import { closeSync, lstatSync, readdirSync, readFileSync, promises as fsPromises } from "node:fs"; import { dirname, join } from "node:path"; import type { MountIgnoreSet } from "./ignore.js"; @@ -58,10 +49,11 @@ export interface NativeRootsOptions { */ readonly active?: () => boolean; /** Injected for tests. Each seam defaults to the real system. */ - readonly run?: (argv: readonly string[]) => Promise; + /** Native operation: resolves both directories beneath pinned roots before binding. */ + 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 }; - readonly stat?: (path: string) => Promise<{ 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[]; @@ -132,10 +124,22 @@ export interface NativeRoots { export function createNativeRoots(options: NativeRootsOptions): NativeRoots { const root = trimTrailing(options.root); const mountPoint = trimTrailing(options.mountPoint) || "/"; - const run = options.run ?? runCommand; + 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 stat = options.stat ?? ((path: string) => fsPromises.stat(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)); @@ -153,15 +157,13 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { let guard: RemovalGuard | undefined; const openDirectory = options.openDirectory ?? - ((path: string) => - new Promise((resolve, reject) => { - // The callback form, for a raw descriptor the guard can own; a - // FileHandle would close itself when collected. - openCallback(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY, (error, fd) => { - if (error) reject(error); - else resolve(fd); - }); - })); + (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 => { @@ -236,7 +238,7 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { async function mountOne(rel: string): Promise { const source = sourceOf(rel); const target = targetOf(rel); - let sourceStat: { isDirectory(): boolean; dev: number; ino: number }; + let sourceStat: { isDirectory(): boolean }; try { // lstat, so a symlink at the root path is never mounted over. sourceStat = lstat(source); @@ -258,30 +260,18 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { // it could be removed. Mounting now would put it back under them. if (parked.has(rel)) return; - const bound = await run(["mount", "--bind", source, target]); + // 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) { - // Removed while the mount was on its way, as `rm -rf` does right - // after a lookup. Gone is not refused. - if (!(await stillThere(source, target))) return; - recordFailure(rel, `mount --bind exited ${bound}`); - return; - } - // Keep the mount out of any shared peer group the container runtime - // set up, so it never propagates beyond this namespace. - await run(["mount", "--make-private", target]); - - try { - const mounted = await stat(target); - if (mounted.dev !== sourceStat.dev || mounted.ino !== sourceStat.ino) { - await run(["umount", "-l", target]); - recordFailure(rel, "the mounted directory is not the backing directory"); - return; - } - } catch (error) { - await run(["umount", "-l", target]); - recordFailure(rel, `could not verify the mount: ${String(error)}`); + 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); } @@ -316,7 +306,13 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { if (isRoot(child)) { visit(child); } else if (!options.ignore.ignores(child)) { - walk(child, visit); + 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. + } } } }; @@ -328,7 +324,7 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { // Closes the guard's descriptor, which would otherwise keep the // detached mount alive. guard?.removeRoot(target); - if ((await run(["umount", "-l", target])) === 0) { + if ((await unbindRoot(mountPoint, relOf(target))) === 0) { detaches += 1; detached.push(target); } @@ -383,19 +379,6 @@ export function unescapeMountinfo(field: string): string { ); } -function runCommand(argv: readonly string[]): Promise { - return new Promise((resolve) => { - const [command, ...args] = argv; - if (command === undefined) { - resolve(127); - return; - } - const child = spawn(command, args, { stdio: "ignore" }); - child.once("error", () => resolve(127)); - child.once("exit", (code) => resolve(code ?? 1)); - }); -} - function parentOf(rel: string): string { const parent = dirname(rel); return parent === "." ? "" : parent; diff --git a/packages/computerd/vendor/fuse-napi/binding.gyp b/packages/computerd/vendor/fuse-napi/binding.gyp index ef8d2376d..86050a65b 100644 --- a/packages/computerd/vendor/fuse-napi/binding.gyp +++ b/packages/computerd/vendor/fuse-napi/binding.gyp @@ -18,7 +18,8 @@ "libraries": [], "sources": [ "fuse-native.c", - "removal-guard.c" + "removal-guard.c", + "confined-bind.c" ], 'defines': [ '_FILE_OFFSET_BITS=64' diff --git a/packages/computerd/vendor/fuse-napi/confined-bind.c b/packages/computerd/vendor/fuse-napi/confined-bind.c new file mode 100644 index 000000000..018dce11c --- /dev/null +++ b/packages/computerd/vendor/fuse-napi/confined-bind.c @@ -0,0 +1,218 @@ +#define _GNU_SOURCE +#include +#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 index db000ae1b..9671abf11 100644 --- a/packages/computerd/vendor/fuse-napi/fuse-native.c +++ b/packages/computerd/vendor/fuse-napi/fuse-native.c @@ -14,6 +14,9 @@ #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 @@ -4125,6 +4128,9 @@ NAPI_INIT() { 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) diff --git a/packages/computerd/vendor/fuse-napi/index.d.ts b/packages/computerd/vendor/fuse-napi/index.d.ts index e4416b64d..a4a1c0799 100644 --- a/packages/computerd/vendor/fuse-napi/index.d.ts +++ b/packages/computerd/vendor/fuse-napi/index.d.ts @@ -714,6 +714,12 @@ declare class Fuse { /** 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; diff --git a/packages/computerd/vendor/fuse-napi/index.js b/packages/computerd/vendor/fuse-napi/index.js index da20f9daf..3e80ffbc4 100644 --- a/packages/computerd/vendor/fuse-napi/index.js +++ b/packages/computerd/vendor/fuse-napi/index.js @@ -1689,6 +1689,15 @@ for (const [name, value] of Object.entries(os.constants.errno)) { // 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') diff --git a/packages/computerd/vendor/fuse-napi/package.json b/packages/computerd/vendor/fuse-napi/package.json index c21bf8b31..2fd89b5ce 100644 --- a/packages/computerd/vendor/fuse-napi/package.json +++ b/packages/computerd/vendor/fuse-napi/package.json @@ -39,6 +39,9 @@ "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", From eef8037e72577f705de27b1fcbc9fdaea316fdfb Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:29:34 +0100 Subject: [PATCH 23/28] computerd: Condense FUSE performance changeset --- .changeset/libfuse3-passthrough.md | 29 +--------------------------- .changeset/native-roots.md | 31 ------------------------------ 2 files changed, 1 insertion(+), 59 deletions(-) delete mode 100644 .changeset/native-roots.md diff --git a/.changeset/libfuse3-passthrough.md b/.changeset/libfuse3-passthrough.md index af2de81c9..87a967143 100644 --- a/.changeset/libfuse3-passthrough.md +++ b/.changeset/libfuse3-passthrough.md @@ -2,31 +2,4 @@ "@cloudflare/computerd": minor --- -Move to libfuse 3, and serve local-only paths through FUSE passthrough - -computerd now mounts through a vendored, patched copy of `fuse-napi`, which -binds libfuse 3, instead of `fuse-native` and libfuse 2.9. This changes what -the container image needs. - -The binary links the system libfuse 3 and needs 3.17 or newer. On Debian that -is the `fuse3` package from trixie onward; bookworm's 3.14 is too old. -`libfuse2t64` is no longer needed. A missing or too-old library stops the -binary at startup with a message naming the package to install. - -No macOS binary is published any more. Running from source with macFUSE has -not been tested since the move. - -With `MOUNT_IGNORE` set and a kernel of Linux 6.9 or newer, opening a -local-only file hands it to the kernel, which then serves its reads, writes, -and mmap straight from the container's disk without going through computerd. -Without kernel support, or without `CAP_SYS_ADMIN`, those paths work as -before and are served by computerd. `/__computerd/info` reports which case -applies in `ignore.fastPaths`, with counts, and `COMPUTERD_FUSE_PASSTHROUGH=0` -turns passthrough off. - -Three behavior changes on the synced mount come with the new binding. -`touch -a` and `touch -m` now set the one time they name and leave the other -alone, where before they had no effect. `COMPUTERD_FUSE_AC_ATTR_TIMEOUT` is gone, along with -`ac_attr_timeout` in `COMPUTERD_FUSE_EXTRA_OPTS`: it only tuned `auto_cache`, -which the mount no longer uses. And `COMPUTERD_FUSE_EXTRA_OPTS` entries now -fail the mount, naming the option, when the binding does not recognize them. +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/.changeset/native-roots.md b/.changeset/native-roots.md deleted file mode 100644 index 272bcb884..000000000 --- a/.changeset/native-roots.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@cloudflare/computerd": minor ---- - -Bind-mount local-only directories - -With `MOUNT_IGNORE` set, computerd now bind-mounts each local-only directory -onto the mount from the place it already stores it, under `MOUNT_IGNORE_PATH`. -Everything under such a directory is then served by the kernel from the -container's disk: lookups, `stat`, creates, and listings no longer reach -computerd, where FUSE passthrough alone only took the file data off its path. -Content is the same either way, so a mount that fails or lands late changes -speed, not what a command sees. - -A directory is mounted when a command creates it, when a lookup finds one on -disk that is not mounted yet (after a snapshot restore), and at startup. -Native roots need negotiated FUSE passthrough, `CAP_SYS_ADMIN`, and `mount` -from util-linux; without them local-only paths are served as before. -`COMPUTERD_FUSE_NATIVE_ROOTS=0` turns them off. `/__computerd/info` reports -the state under `ignore.fastPaths.nativeRoots`. - -Removing or renaming a mounted directory itself would fail with `EBUSY`, as -for a Docker volume. For the commands computerd runs, a removal guard prevents -that: computerd installs a seccomp filter on the thread it spawns commands -from, and when a command removes or renames a native root, it detaches the -mount first and lets the call continue. `rm -rf node_modules`, `fs.rm`, and -`mv` behave as they do without a mount. It needs `CAP_SYS_ADMIN` and costs -about 15 µs per directory removal or rename; file operations are not -affected. Processes computerd did not start still get `EBUSY`, and can call -`POST /__computerd/native-roots/detach` first. `/__computerd/info` reports -the guard as `nativeRoots.transparentRemoval`. From 49bef86cee6e870603640a8d6e77111d7dde6077 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:35:12 +0100 Subject: [PATCH 24/28] computerd, docs: Remove obsolete FUSE guidance The native-root bind no longer calls util-linux mount, and the 0.7.0 image no longer links libfuse 2. Update failure hints, example packages, and debugging instructions to match. Clarify the mount ownership assumption and distinguish earlier benchmarks from mount setup time. --- .../skills/debugging-computerd-fuse/SKILL.md | 82 ++++++++++++------- docs/19_performance.md | 9 +- docs/README.md | 10 +-- examples/container-legacy/Dockerfile | 5 +- examples/container/Dockerfile | 5 +- examples/egress/Dockerfile | 5 +- examples/mcp/Dockerfile | 5 +- .../Dockerfile.workspace | 5 +- examples/think/Dockerfile | 5 +- examples/tutorial/Dockerfile | 5 +- packages/computerd/README.md | 11 +-- packages/computerd/src/cli/computerd.ts | 8 +- .../computerd/src/fuse/ignore-config.test.ts | 12 ++- packages/computerd/src/fuse/ignore-config.ts | 4 +- .../computerd/vendor/fuse-napi/PATCHES.md | 17 +++- 15 files changed, 105 insertions(+), 83 deletions(-) diff --git a/.agents/skills/debugging-computerd-fuse/SKILL.md b/.agents/skills/debugging-computerd-fuse/SKILL.md index 16eb3f415..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 @@ -66,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 @@ -168,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. @@ -188,13 +188,10 @@ This is a real FUSE write: kernel → fuse-napi → computerd's (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 @@ -218,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 @@ -233,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 @@ -286,6 +279,37 @@ 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 zombie processes @@ -366,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 diff --git a/docs/19_performance.md b/docs/19_performance.md index dd94521ff..7f9a825a6 100644 --- a/docs/19_performance.md +++ b/docs/19_performance.md @@ -158,16 +158,17 @@ run, and the local-only columns have passthrough on. | 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 | -Under a native root every scenario lands at raw disk, within run-to-run -noise, where passthrough alone left metadata-heavy work at 15 to 25 -times raw. `git clone` is dominated by the network and moves with it. +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. +> 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 diff --git a/docs/README.md b/docs/README.md index f9f5796b2..9b4965d8c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -76,12 +76,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.0 AS computerd FROM debian:trixie-slim -# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, -# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 -# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. 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 --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd @@ -95,9 +92,8 @@ ENTRYPOINT ["/usr/local/bin/computerd"] ``` `fuse3` brings libfuse 3, which computerd links and needs at 3.17 or -newer, so the base has to be Debian trixie or later. computerd 0.6.0 and -earlier link libfuse 2 instead and also need `libfuse2t64`, which is why -the snippet installs it while it pins one of those. +newer, so the base has to be Debian trixie or later. Older binaries +(0.6.0 and earlier) also need `libfuse2t64`. To build the binary from source instead, run `npm run build:bin --workspace @cloudflare/computerd`, which emits diff --git a/examples/container-legacy/Dockerfile b/examples/container-legacy/Dockerfile index f35ff4c58..d1ad05972 100644 --- a/examples/container-legacy/Dockerfile +++ b/examples/container-legacy/Dockerfile @@ -18,12 +18,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.0 AS computerd FROM debian:stable-slim -# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, -# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 -# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 libfuse2t64 ca-certificates curl gnupg git \ + fuse3 ca-certificates curl gnupg git \ && mkdir -p /etc/apt/keyrings \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ diff --git a/examples/container/Dockerfile b/examples/container/Dockerfile index f35ff4c58..d1ad05972 100644 --- a/examples/container/Dockerfile +++ b/examples/container/Dockerfile @@ -18,12 +18,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.0 AS computerd FROM debian:stable-slim -# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, -# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 -# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 libfuse2t64 ca-certificates curl gnupg git \ + fuse3 ca-certificates curl gnupg git \ && mkdir -p /etc/apt/keyrings \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ diff --git a/examples/egress/Dockerfile b/examples/egress/Dockerfile index 8e43c5190..daeb086bd 100644 --- a/examples/egress/Dockerfile +++ b/examples/egress/Dockerfile @@ -2,12 +2,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.0 AS computerd FROM debian:trixie-slim -# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, -# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 -# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 libfuse2t64 ca-certificates curl \ + fuse3 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 f5ae2806a..48ad414f8 100644 --- a/examples/mcp/Dockerfile +++ b/examples/mcp/Dockerfile @@ -2,12 +2,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.0 AS computerd FROM node:22-trixie-slim -# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, -# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 -# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates curl fuse3 git libfuse2t64 \ + ca-certificates curl fuse3 git \ && rm -rf /var/lib/apt/lists/* COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd diff --git a/examples/think-compare-runtimes/Dockerfile.workspace b/examples/think-compare-runtimes/Dockerfile.workspace index 03efebbfb..dd2512e6a 100644 --- a/examples/think-compare-runtimes/Dockerfile.workspace +++ b/examples/think-compare-runtimes/Dockerfile.workspace @@ -16,12 +16,9 @@ FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.7.0 AS computerd FROM --platform=linux/amd64 node:22-trixie-slim -# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, -# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 -# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates git fuse3 libfuse2t64 \ + ca-certificates git fuse3 \ && rm -rf /var/lib/apt/lists/* COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd diff --git a/examples/think/Dockerfile b/examples/think/Dockerfile index 43c70e071..85922dbe9 100644 --- a/examples/think/Dockerfile +++ b/examples/think/Dockerfile @@ -20,12 +20,9 @@ FROM debian:stable-slim # come in handy for ad-hoc agent commands too. The cost is ~120 MB # of layer over the base; acceptable for an example whose whole # point is running real build/test workflows. -# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, -# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 -# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 libfuse2t64 ca-certificates curl gnupg git \ + fuse3 ca-certificates curl gnupg git \ && mkdir -p /etc/apt/keyrings \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ diff --git a/examples/tutorial/Dockerfile b/examples/tutorial/Dockerfile index 93ef44126..d85aa7ca0 100644 --- a/examples/tutorial/Dockerfile +++ b/examples/tutorial/Dockerfile @@ -18,12 +18,9 @@ ARG TYPST_VERSION=0.15.1 # pandoc converts the agent's markdown; typst is the PDF engine, a # single 30 MB binary instead of the several hundred megabytes a LaTeX # install would cost. curl is only the download tool for those two. -# libfuse2t64 is for the pinned binary while that tag is 0.6.0 or earlier, -# which link libfuse 2. Later releases need only fuse3, at libfuse 3.17 -# or newer (Debian trixie); drop libfuse2t64 once the tag is past 0.6.0. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - fuse3 libfuse2t64 ca-certificates curl xz-utils \ + fuse3 ca-certificates curl xz-utils \ && curl -fsSL -o /tmp/pandoc.deb \ "https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-1-amd64.deb" \ && dpkg -i /tmp/pandoc.deb \ diff --git a/packages/computerd/README.md b/packages/computerd/README.md index 0d0bb5b9a..8acc90024 100644 --- a/packages/computerd/README.md +++ b/packages/computerd/README.md @@ -419,8 +419,8 @@ curl -X POST http://127.0.0.1:$PORT/__computerd/native-roots/detach \ 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. A detach is always safe: the directory stays where it -was and computerd serves it through FUSE. +race a remount. Detaching a native root leaves its backing directory in +place and computerd serves it through FUSE. #### Other differences @@ -440,9 +440,10 @@ was and computerd serves it through FUSE. "transparentRemovalReason": "active", "removalDetaches": 1 } ``` -`active` is read from `/proc/self/mountinfo`, which is the only record -of what is mounted: anything mounted under `MOUNT_POINT` other than the -FUSE mount itself is a native root. `reason` explains a disabled state +`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 diff --git a/packages/computerd/src/cli/computerd.ts b/packages/computerd/src/cli/computerd.ts index b5aceb417..c0154c799 100644 --- a/packages/computerd/src/cli/computerd.ts +++ b/packages/computerd/src/cli/computerd.ts @@ -796,8 +796,8 @@ async function main(): Promise { }, }; - // Native roots: every local-only directory is bind-mounted from where - // it is stored. Built before the mount so the passthrough layer can call + // 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); @@ -836,8 +836,8 @@ async function main(): Promise { if (ignoreConfig.enabled) { await mkdir(ignoreConfig.root, { recursive: true }); } - // Bind mounts a previous run left under the mount point, if any. A - // lazy detach is always safe, so there is nothing to check first. + // 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, diff --git a/packages/computerd/src/fuse/ignore-config.test.ts b/packages/computerd/src/fuse/ignore-config.test.ts index 0887425e0..e00d97ae4 100644 --- a/packages/computerd/src/fuse/ignore-config.test.ts +++ b/packages/computerd/src/fuse/ignore-config.test.ts @@ -247,10 +247,18 @@ describe("describeMountIgnore: nativeRoots", () => { }); test("names the failure when every mount was refused", () => { - const refused = { ...native, active: 0, mounts: 0, failures: 4, lastFailure: "a: exited 32" }; + 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(/exited 32/); + 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 9352424c6..14f03405a 100644 --- a/packages/computerd/src/fuse/ignore-config.ts +++ b/packages/computerd/src/fuse/ignore-config.ts @@ -194,8 +194,8 @@ function nativeActiveReason(native: NativeRootsStatus | undefined): string { if (native.mounts === 0) { return ( `active, but every mount so far was refused (${native.failures}; last: ` + - `${native.lastFailure ?? "unknown"}); check that computerd has CAP_SYS_ADMIN and ` + - `that util-linux mount is installed` + `${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`; diff --git a/packages/computerd/vendor/fuse-napi/PATCHES.md b/packages/computerd/vendor/fuse-napi/PATCHES.md index b3e7e1b22..a6f20277e 100644 --- a/packages/computerd/vendor/fuse-napi/PATCHES.md +++ b/packages/computerd/vendor/fuse-napi/PATCHES.md @@ -14,7 +14,9 @@ it is a much shorter path to passthrough than patching libfuse 2. ## What we changed -All changes are in `fuse-native.c`, `index.js`, and `index.d.ts`. +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` @@ -90,6 +92,19 @@ point fail with `EBUSY` in the VFS before FUSE is consulted. `-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 From 7fc10f4fd8e8f5ffe8f58df42faa9299e1add9f6 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:35:36 +0100 Subject: [PATCH 25/28] computerd: Simplify native-root guard handoff The removal guard is installed once and never unset. Capture that instance before opening a mount descriptor, rather than rechecking a state that cannot become undefined. State explicitly that mountinfo ownership assumes no unrelated mounts below the workspace. --- packages/computerd/src/fuse/native-roots.ts | 28 +++++++++------------ 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/computerd/src/fuse/native-roots.ts b/packages/computerd/src/fuse/native-roots.ts index 3bb60e73a..1a9813670 100644 --- a/packages/computerd/src/fuse/native-roots.ts +++ b/packages/computerd/src/fuse/native-roots.ts @@ -11,11 +11,11 @@ // - 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 state. Anything mounted strictly -// under MOUNT_POINT was mounted by this module, so there is no registry -// to write, recover, or get out of step. -// - A lazy detach is always safe: the detached tree is the same backing -// directory the FUSE layer serves, and it is never synced. +// - /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 @@ -31,7 +31,7 @@ // /proc are fine. The native worker resolves and mounts the target off // the main thread, using pinned directory descriptors. -import { closeSync, lstatSync, readdirSync, readFileSync, promises as fsPromises } from "node:fs"; +import { lstatSync, readdirSync, readFileSync, promises as fsPromises } from "node:fs"; import { dirname, join } from "node:path"; import type { MountIgnoreSet } from "./ignore.js"; @@ -48,8 +48,7 @@ export interface NativeRootsOptions { * FUSE mount exists and switch it on once passthrough is negotiated. */ readonly active?: () => boolean; - /** Injected for tests. Each seam defaults to the real system. */ - /** Native operation: resolves both directories beneath pinned roots before binding. */ + /** 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; @@ -167,18 +166,15 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { // Through the thread pool: the target is under the mount. const registerWithGuard = async (target: string): Promise => { - if (guard === undefined) return; + const attachedGuard = guard; + if (attachedGuard === undefined) return; let fd: number; try { fd = await openDirectory(target); } catch { return; // Gone again; nothing to guard. } - if (guard === undefined) { - closeSync(fd); - return; - } - guard.addRoot(target, fd); + attachedGuard.addRoot(target, fd); // The guard owns the descriptor, even on failure. }; const targetOf = (rel: string): string => join(mountPoint, rel); @@ -191,8 +187,8 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { return options.ignore.ignores(rel) && !options.ignore.ignores(parentOf(rel)); }; - // Everything strictly under the mount point. The FUSE mount itself sits - // at the mount point and is excluded. + // 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 { From 377e1ca65f7af41035b8ff80801c3552658ca1c3 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:36:29 +0100 Subject: [PATCH 26/28] docs: Clarify native-root mount eligibility --- docs/19_performance.md | 9 +++++---- packages/computerd/README.md | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/19_performance.md b/docs/19_performance.md index 7f9a825a6..d584af2a8 100644 --- a/docs/19_performance.md +++ b/docs/19_performance.md @@ -121,13 +121,14 @@ transfer that no longer follows it. > 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 -> now bind-mounted by default (next section). +> eligible for native mounts by default where supported (next section). ### Native roots -Passthrough leaves metadata on computerd's event loop. computerd now -bind-mounts each local-only directory from the container's disk, so -nothing under it reaches computerd at all. See +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 diff --git a/packages/computerd/README.md b/packages/computerd/README.md index 8acc90024..a587b7e2c 100644 --- a/packages/computerd/README.md +++ b/packages/computerd/README.md @@ -43,7 +43,7 @@ Current filesystem support: - `@platformatic/vfs` in-memory filesystem provided by `@cloudflare/dofs`'s node provider. - 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. When the kernel supports it, local-only directories are bind-mounted and never reach `computerd` at all; see [Native roots](#native-roots). +- 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. @@ -358,7 +358,7 @@ 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/info` for the most recent failed bind. +`/__computerd/stats` for `localPaths.nativeRoots.lastFailure`. Roots are mounted when they appear, with no watcher: From bf09921877db4fa7977848f4bed36a6c8f4cf785 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:44:02 +0100 Subject: [PATCH 27/28] computerd: Expect removal guard fields in FUSE info The real-FUSE info test compares the whole response. Native-root status now includes removal guard availability and detach counts even when MOUNT_IGNORE is unset, so include those fields in the expected response rather than treating them as unexpected output. --- packages/computerd/src/cli/computerd.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/computerd/src/cli/computerd.test.ts b/packages/computerd/src/cli/computerd.test.ts index 07c434bf8..cc6e583bc 100644 --- a/packages/computerd/src/cli/computerd.test.ts +++ b/packages/computerd/src/cli/computerd.test.ts @@ -122,6 +122,9 @@ test("computerd exposes file IO through real FUSE when FUSE_MOUNT=fuse", async ( mounts: 0, failures: 0, detaches: 0, + transparentRemoval: false, + transparentRemovalReason: "native roots are off", + removalDetaches: 0, }, }, }, From d562de8109173490cfcdfe8cc08fba778b10d15e Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:42:37 +0100 Subject: [PATCH 28/28] computerd: Wait for pending native binds before detach A detach request could observe no mount while a bind was still in flight, return an empty result, and leave the root mounted after the caller tried to remove it. Park a requested root before waiting for its bind, then detach the mount that actually landed. Suspend new mount attempts while a detach-all operation waits for pending binds; startup cleanup still leaves roots available for its subsequent scan. --- .../computerd/src/fuse/native-roots.test.ts | 104 ++++++++++++++++++ packages/computerd/src/fuse/native-roots.ts | 41 ++++--- 2 files changed, 132 insertions(+), 13 deletions(-) diff --git a/packages/computerd/src/fuse/native-roots.test.ts b/packages/computerd/src/fuse/native-roots.test.ts index 8c259839d..b9aaf6beb 100644 --- a/packages/computerd/src/fuse/native-roots.test.ts +++ b/packages/computerd/src/fuse/native-roots.test.ts @@ -28,6 +28,8 @@ function harness( 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); @@ -50,6 +52,7 @@ function harness( 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; }, @@ -67,6 +70,7 @@ function harness( 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" }); } @@ -464,6 +468,106 @@ describe("removal guard", () => { }); 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"); diff --git a/packages/computerd/src/fuse/native-roots.ts b/packages/computerd/src/fuse/native-roots.ts index 1a9813670..d0cec1964 100644 --- a/packages/computerd/src/fuse/native-roots.ts +++ b/packages/computerd/src/fuse/native-roots.ts @@ -145,6 +145,7 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { 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(); @@ -273,7 +274,7 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { } const ensure = (relativePath: string, created = false): void => { - if (!active()) return; + if (!active() || suspended > 0) return; const rel = stripSlashes(relativePath); if (!isRoot(rel) || inflight.has(rel)) return; if (parked.has(rel)) { @@ -313,6 +314,10 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { } }; + 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. @@ -328,25 +333,35 @@ export function createNativeRoots(options: NativeRootsOptions): NativeRoots { 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: async () => { - while (inflight.size > 0) await Promise.all([...inflight.values()]); - }, + settled, detach: async (relativePath) => { - const all = owned(); - const targets = - relativePath === undefined - ? all - : all.filter((candidate) => candidate === targetOf(stripSlashes(relativePath))); - const detached = await detachTargets(targets); - for (const target of detached) parked.add(relOf(target)); - return detached; + 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: () => detachTargets(owned()), + detachAll: () => detachAllTargets(false), isRoot, attachGuard: async (attached) => { guard = attached;