@cocalc/openat2 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +135 -0
  3. package/index.d.ts +38 -0
  4. package/index.js +315 -0
  5. package/package.json +49 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SageMath, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @cocalc/openat2
2
+
3
+ Linux-only `napi-rs` addon that exposes `openat2`-anchored filesystem operations for race-safe sandbox mutation.
4
+
5
+ ## Why this exists
6
+
7
+ In CoCalc safe mode, we need a strong guarantee:
8
+
9
+ - filesystem operations for a project stay inside that project root
10
+ - even if the project owner (or another process) is changing paths concurrently
11
+
12
+ The subtle failure mode is a classic race:
13
+
14
+ 1. We validate a string path (e.g. `a/b/file.txt`) and it looks safe.
15
+ 2. Before the actual mutation syscall runs, an attacker swaps an intermediate path component (or leaf) to a symlink.
16
+ 3. The mutation then lands outside the sandbox.
17
+
18
+ That `validate(path) -> mutate(path)` pattern is fundamentally fragile under concurrency, because validation and mutation happen at different times on a mutable namespace.
19
+
20
+ `openat2` changes the model from string-based trust to descriptor-based trust:
21
+
22
+ - we first open a **root directory handle** (`dirfd`) for the sandbox root
23
+ - each operation resolves relative paths under that root via `openat2`
24
+ - kernel-enforced resolve rules (`RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS`) prevent escaping during resolution
25
+ - we then mutate via `*at` syscalls (`mkdirat`, `renameat`, `unlinkat`, etc.) anchored to validated dirfds
26
+
27
+ This is closer to a capability model: possession of the root `dirfd` defines the authority boundary, and every derived operation stays constrained to that boundary. In practice, this removes dependence on ad-hoc deny/allow path filtering as the primary safety mechanism.
28
+
29
+ Why not just use Node `fs` + file descriptors?
30
+
31
+ - File descriptors help for **existing-file content I/O** (`read`/`write` on an already opened inode), and we do use that pattern.
32
+ - But many dangerous operations are **path mutators** (`mkdir`, `rename`, `unlink`, `rmdir`, `chmod`, `utimes`, create paths) that still require pathname resolution at operation time.
33
+ - In plain Node, those mutators are path-based. You can pre-check with `realpath`/`lstat`, but that is still a user-space check followed by a later path syscall, so there is still a race window.
34
+ - For create flows, there may be no target inode yet to pin with an fd. The critical security question is whether parent-chain resolution stayed inside the sandbox at the exact syscall boundary.
35
+ - Node does not currently expose a complete `openat2`/`*at` capability API that lets us anchor all resolution to a sandbox dirfd with kernel-enforced constraints.
36
+
37
+ So fd-only hardening in Node is necessary but not sufficient: it meaningfully improves read/write safety, but it cannot fully eliminate TOCTOU escape classes for path-mutating operations. `openat2` + `*at` is the piece that closes that remaining gap.
38
+
39
+ Tradeoffs:
40
+
41
+ - implementation is more tedious than plain Node `fs` path calls
42
+ - Linux-specific (`openat2` is a Linux syscall)
43
+ - existing path-oriented code needs adapter layers for migration
44
+
45
+ For our situation, that tradeoff is worth it: mutators become fail-closed under symlink/path-swap races, which is exactly the remaining hardening gap in backend sandbox safe mode.
46
+
47
+ ## Current API
48
+
49
+ ```ts
50
+ import { SandboxRoot } from '@cocalc/openat2'
51
+
52
+ const root = new SandboxRoot('/srv/project')
53
+ root.mkdir('a/b', true)
54
+ root.rename('a/b/file.txt', 'a/b/file2.txt')
55
+ root.unlink('a/b/file2.txt')
56
+ const st = root.stat('a/b')
57
+ ```
58
+
59
+ Methods implemented now:
60
+
61
+ - `mkdir(path, recursive?, mode?)`
62
+ - `unlink(path)`
63
+ - `rmdir(path)`
64
+ - `rename(oldPath, newPath)`
65
+ - `renameNoReplace(oldPath, newPath)`
66
+ - `link(oldPath, newPath)`
67
+ - `symlink(target, newPath)`
68
+ - `chmod(path, mode)`
69
+ - `truncate(path, len)`
70
+ - `copyFile(src, dest, mode?)`
71
+ - `rm(path, recursive?, force?)`
72
+ - `utimes(path, atimeNs, mtimeNs)`
73
+ - `stat(path)`
74
+ - `openRead(path) -> fd`
75
+ - `openWrite(path, create?, truncate?, append?, mode?) -> fd`
76
+
77
+ `openRead`/`openWrite` return numeric file descriptors intended for high-frequency
78
+ I/O paths in Node. The caller owns the descriptor and must close it.
79
+
80
+ ## Security model
81
+
82
+ - Absolute paths are rejected.
83
+ - `..` traversal is rejected.
84
+ - Symlink traversal is blocked by `openat2` resolve flags.
85
+ - Operations are anchored to a root dirfd opened once in constructor.
86
+
87
+ ## Build
88
+
89
+ ```bash
90
+ pnpm install
91
+ pnpm build
92
+ ```
93
+
94
+ Requirements:
95
+
96
+ - Linux kernel with `openat2` support (>=5.6)
97
+ - Rust toolchain
98
+ - Node 18+
99
+
100
+ ## Test
101
+
102
+ ```bash
103
+ pnpm run test:rust
104
+ ```
105
+
106
+ ## Packaging notes
107
+
108
+ This repository is configured to publish Linux prebuilt binaries for:
109
+
110
+ - `x86_64-unknown-linux-gnu`
111
+ - `aarch64-unknown-linux-gnu`
112
+
113
+ Runtime loading prefers:
114
+
115
+ 1. local `cocalc_openat2.linux-*.node` files (for CI/dev artifacts)
116
+ 2. optional npm packages (`@cocalc/openat2-linux-x64-gnu` / `@cocalc/openat2-linux-arm64-gnu`)
117
+ 3. local `cocalc_openat2.node` fallback for local development
118
+
119
+ ### Release automation
120
+
121
+ The workflow in [.github/workflows/release.yml](./.github/workflows/release.yml):
122
+
123
+ 1. builds both linux targets
124
+ 2. uploads `.node` artifacts
125
+ 3. on `v*` tags, downloads artifacts and publishes:
126
+ - `@cocalc/openat2-linux-x64-gnu`
127
+ - `@cocalc/openat2-linux-arm64-gnu`
128
+ - `@cocalc/openat2` (root package)
129
+
130
+ Publishing relies on the root `prepublishOnly` script:
131
+
132
+ ```bash
133
+ pnpm run create-npm-dirs
134
+ napi prepublish -t npm --tagstyle npm --skip-gh-release
135
+ ```
package/index.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /* auto-generated by NAPI-RS */
5
+
6
+ export interface StatResult {
7
+ dev: number
8
+ ino: number
9
+ mode: number
10
+ nlink: number
11
+ uid: number
12
+ gid: number
13
+ rdev: number
14
+ size: number
15
+ blksize: number
16
+ blocks: number
17
+ atimeNs: number
18
+ mtimeNs: number
19
+ ctimeNs: number
20
+ }
21
+ export declare class SandboxRoot {
22
+ constructor(root: string)
23
+ mkdir(path: string, recursive?: boolean | undefined | null, mode?: number | undefined | null): void
24
+ unlink(path: string): void
25
+ rmdir(path: string): void
26
+ rename(oldPath: string, newPath: string): void
27
+ renameNoReplace(oldPath: string, newPath: string): void
28
+ link(oldPath: string, newPath: string): void
29
+ symlink(target: string, newPath: string): void
30
+ chmod(path: string, mode: number): void
31
+ truncate(path: string, len: number): void
32
+ copyFile(src: string, dest: string, mode?: number | undefined | null): void
33
+ openRead(path: string): number
34
+ openWrite(path: string, create?: boolean | undefined | null, truncate?: boolean | undefined | null, append?: boolean | undefined | null, mode?: number | undefined | null): number
35
+ rm(path: string, recursive?: boolean | undefined | null, force?: boolean | undefined | null): void
36
+ utimes(path: string, atimeNs: number, mtimeNs: number): void
37
+ stat(path: string): StatResult
38
+ }
package/index.js ADDED
@@ -0,0 +1,315 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ /* prettier-ignore */
4
+
5
+ /* auto-generated by NAPI-RS */
6
+
7
+ const { existsSync, readFileSync } = require('fs')
8
+ const { join } = require('path')
9
+
10
+ const { platform, arch } = process
11
+
12
+ let nativeBinding = null
13
+ let localFileExisted = false
14
+ let loadError = null
15
+
16
+ function isMusl() {
17
+ // For Node 10
18
+ if (!process.report || typeof process.report.getReport !== 'function') {
19
+ try {
20
+ const lddPath = require('child_process').execSync('which ldd').toString().trim()
21
+ return readFileSync(lddPath, 'utf8').includes('musl')
22
+ } catch (e) {
23
+ return true
24
+ }
25
+ } else {
26
+ const { glibcVersionRuntime } = process.report.getReport().header
27
+ return !glibcVersionRuntime
28
+ }
29
+ }
30
+
31
+ switch (platform) {
32
+ case 'android':
33
+ switch (arch) {
34
+ case 'arm64':
35
+ localFileExisted = existsSync(join(__dirname, 'cocalc_openat2.android-arm64.node'))
36
+ try {
37
+ if (localFileExisted) {
38
+ nativeBinding = require('./cocalc_openat2.android-arm64.node')
39
+ } else {
40
+ nativeBinding = require('@cocalc/openat2-android-arm64')
41
+ }
42
+ } catch (e) {
43
+ loadError = e
44
+ }
45
+ break
46
+ case 'arm':
47
+ localFileExisted = existsSync(join(__dirname, 'cocalc_openat2.android-arm-eabi.node'))
48
+ try {
49
+ if (localFileExisted) {
50
+ nativeBinding = require('./cocalc_openat2.android-arm-eabi.node')
51
+ } else {
52
+ nativeBinding = require('@cocalc/openat2-android-arm-eabi')
53
+ }
54
+ } catch (e) {
55
+ loadError = e
56
+ }
57
+ break
58
+ default:
59
+ throw new Error(`Unsupported architecture on Android ${arch}`)
60
+ }
61
+ break
62
+ case 'win32':
63
+ switch (arch) {
64
+ case 'x64':
65
+ localFileExisted = existsSync(
66
+ join(__dirname, 'cocalc_openat2.win32-x64-msvc.node')
67
+ )
68
+ try {
69
+ if (localFileExisted) {
70
+ nativeBinding = require('./cocalc_openat2.win32-x64-msvc.node')
71
+ } else {
72
+ nativeBinding = require('@cocalc/openat2-win32-x64-msvc')
73
+ }
74
+ } catch (e) {
75
+ loadError = e
76
+ }
77
+ break
78
+ case 'ia32':
79
+ localFileExisted = existsSync(
80
+ join(__dirname, 'cocalc_openat2.win32-ia32-msvc.node')
81
+ )
82
+ try {
83
+ if (localFileExisted) {
84
+ nativeBinding = require('./cocalc_openat2.win32-ia32-msvc.node')
85
+ } else {
86
+ nativeBinding = require('@cocalc/openat2-win32-ia32-msvc')
87
+ }
88
+ } catch (e) {
89
+ loadError = e
90
+ }
91
+ break
92
+ case 'arm64':
93
+ localFileExisted = existsSync(
94
+ join(__dirname, 'cocalc_openat2.win32-arm64-msvc.node')
95
+ )
96
+ try {
97
+ if (localFileExisted) {
98
+ nativeBinding = require('./cocalc_openat2.win32-arm64-msvc.node')
99
+ } else {
100
+ nativeBinding = require('@cocalc/openat2-win32-arm64-msvc')
101
+ }
102
+ } catch (e) {
103
+ loadError = e
104
+ }
105
+ break
106
+ default:
107
+ throw new Error(`Unsupported architecture on Windows: ${arch}`)
108
+ }
109
+ break
110
+ case 'darwin':
111
+ localFileExisted = existsSync(join(__dirname, 'cocalc_openat2.darwin-universal.node'))
112
+ try {
113
+ if (localFileExisted) {
114
+ nativeBinding = require('./cocalc_openat2.darwin-universal.node')
115
+ } else {
116
+ nativeBinding = require('@cocalc/openat2-darwin-universal')
117
+ }
118
+ break
119
+ } catch {}
120
+ switch (arch) {
121
+ case 'x64':
122
+ localFileExisted = existsSync(join(__dirname, 'cocalc_openat2.darwin-x64.node'))
123
+ try {
124
+ if (localFileExisted) {
125
+ nativeBinding = require('./cocalc_openat2.darwin-x64.node')
126
+ } else {
127
+ nativeBinding = require('@cocalc/openat2-darwin-x64')
128
+ }
129
+ } catch (e) {
130
+ loadError = e
131
+ }
132
+ break
133
+ case 'arm64':
134
+ localFileExisted = existsSync(
135
+ join(__dirname, 'cocalc_openat2.darwin-arm64.node')
136
+ )
137
+ try {
138
+ if (localFileExisted) {
139
+ nativeBinding = require('./cocalc_openat2.darwin-arm64.node')
140
+ } else {
141
+ nativeBinding = require('@cocalc/openat2-darwin-arm64')
142
+ }
143
+ } catch (e) {
144
+ loadError = e
145
+ }
146
+ break
147
+ default:
148
+ throw new Error(`Unsupported architecture on macOS: ${arch}`)
149
+ }
150
+ break
151
+ case 'freebsd':
152
+ if (arch !== 'x64') {
153
+ throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
154
+ }
155
+ localFileExisted = existsSync(join(__dirname, 'cocalc_openat2.freebsd-x64.node'))
156
+ try {
157
+ if (localFileExisted) {
158
+ nativeBinding = require('./cocalc_openat2.freebsd-x64.node')
159
+ } else {
160
+ nativeBinding = require('@cocalc/openat2-freebsd-x64')
161
+ }
162
+ } catch (e) {
163
+ loadError = e
164
+ }
165
+ break
166
+ case 'linux':
167
+ switch (arch) {
168
+ case 'x64':
169
+ if (isMusl()) {
170
+ localFileExisted = existsSync(
171
+ join(__dirname, 'cocalc_openat2.linux-x64-musl.node')
172
+ )
173
+ try {
174
+ if (localFileExisted) {
175
+ nativeBinding = require('./cocalc_openat2.linux-x64-musl.node')
176
+ } else {
177
+ nativeBinding = require('@cocalc/openat2-linux-x64-musl')
178
+ }
179
+ } catch (e) {
180
+ loadError = e
181
+ }
182
+ } else {
183
+ localFileExisted = existsSync(
184
+ join(__dirname, 'cocalc_openat2.linux-x64-gnu.node')
185
+ )
186
+ try {
187
+ if (localFileExisted) {
188
+ nativeBinding = require('./cocalc_openat2.linux-x64-gnu.node')
189
+ } else {
190
+ nativeBinding = require('@cocalc/openat2-linux-x64-gnu')
191
+ }
192
+ } catch (e) {
193
+ loadError = e
194
+ }
195
+ }
196
+ break
197
+ case 'arm64':
198
+ if (isMusl()) {
199
+ localFileExisted = existsSync(
200
+ join(__dirname, 'cocalc_openat2.linux-arm64-musl.node')
201
+ )
202
+ try {
203
+ if (localFileExisted) {
204
+ nativeBinding = require('./cocalc_openat2.linux-arm64-musl.node')
205
+ } else {
206
+ nativeBinding = require('@cocalc/openat2-linux-arm64-musl')
207
+ }
208
+ } catch (e) {
209
+ loadError = e
210
+ }
211
+ } else {
212
+ localFileExisted = existsSync(
213
+ join(__dirname, 'cocalc_openat2.linux-arm64-gnu.node')
214
+ )
215
+ try {
216
+ if (localFileExisted) {
217
+ nativeBinding = require('./cocalc_openat2.linux-arm64-gnu.node')
218
+ } else {
219
+ nativeBinding = require('@cocalc/openat2-linux-arm64-gnu')
220
+ }
221
+ } catch (e) {
222
+ loadError = e
223
+ }
224
+ }
225
+ break
226
+ case 'arm':
227
+ if (isMusl()) {
228
+ localFileExisted = existsSync(
229
+ join(__dirname, 'cocalc_openat2.linux-arm-musleabihf.node')
230
+ )
231
+ try {
232
+ if (localFileExisted) {
233
+ nativeBinding = require('./cocalc_openat2.linux-arm-musleabihf.node')
234
+ } else {
235
+ nativeBinding = require('@cocalc/openat2-linux-arm-musleabihf')
236
+ }
237
+ } catch (e) {
238
+ loadError = e
239
+ }
240
+ } else {
241
+ localFileExisted = existsSync(
242
+ join(__dirname, 'cocalc_openat2.linux-arm-gnueabihf.node')
243
+ )
244
+ try {
245
+ if (localFileExisted) {
246
+ nativeBinding = require('./cocalc_openat2.linux-arm-gnueabihf.node')
247
+ } else {
248
+ nativeBinding = require('@cocalc/openat2-linux-arm-gnueabihf')
249
+ }
250
+ } catch (e) {
251
+ loadError = e
252
+ }
253
+ }
254
+ break
255
+ case 'riscv64':
256
+ if (isMusl()) {
257
+ localFileExisted = existsSync(
258
+ join(__dirname, 'cocalc_openat2.linux-riscv64-musl.node')
259
+ )
260
+ try {
261
+ if (localFileExisted) {
262
+ nativeBinding = require('./cocalc_openat2.linux-riscv64-musl.node')
263
+ } else {
264
+ nativeBinding = require('@cocalc/openat2-linux-riscv64-musl')
265
+ }
266
+ } catch (e) {
267
+ loadError = e
268
+ }
269
+ } else {
270
+ localFileExisted = existsSync(
271
+ join(__dirname, 'cocalc_openat2.linux-riscv64-gnu.node')
272
+ )
273
+ try {
274
+ if (localFileExisted) {
275
+ nativeBinding = require('./cocalc_openat2.linux-riscv64-gnu.node')
276
+ } else {
277
+ nativeBinding = require('@cocalc/openat2-linux-riscv64-gnu')
278
+ }
279
+ } catch (e) {
280
+ loadError = e
281
+ }
282
+ }
283
+ break
284
+ case 's390x':
285
+ localFileExisted = existsSync(
286
+ join(__dirname, 'cocalc_openat2.linux-s390x-gnu.node')
287
+ )
288
+ try {
289
+ if (localFileExisted) {
290
+ nativeBinding = require('./cocalc_openat2.linux-s390x-gnu.node')
291
+ } else {
292
+ nativeBinding = require('@cocalc/openat2-linux-s390x-gnu')
293
+ }
294
+ } catch (e) {
295
+ loadError = e
296
+ }
297
+ break
298
+ default:
299
+ throw new Error(`Unsupported architecture on Linux: ${arch}`)
300
+ }
301
+ break
302
+ default:
303
+ throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
304
+ }
305
+
306
+ if (!nativeBinding) {
307
+ if (loadError) {
308
+ throw loadError
309
+ }
310
+ throw new Error(`Failed to load native binding`)
311
+ }
312
+
313
+ const { SandboxRoot } = nativeBinding
314
+
315
+ module.exports.SandboxRoot = SandboxRoot
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@cocalc/openat2",
3
+ "version": "0.1.0",
4
+ "description": "Linux openat2-based filesystem primitives for secure sandboxing",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "main": "index.js",
8
+ "types": "index.d.ts",
9
+ "files": [
10
+ "index.js",
11
+ "index.d.ts",
12
+ "LICENSE",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "artifacts": "napi artifacts -d artifacts",
17
+ "build": "napi build --platform --release",
18
+ "build:debug": "napi build --platform",
19
+ "build:platform": "napi build --platform --release",
20
+ "build:target": "napi build --platform --release --target",
21
+ "create-npm-dirs": "napi create-npm-dir -t .",
22
+ "prepublishOnly": "pnpm run create-npm-dirs && napi prepublish -t npm --tagstyle npm --skip-gh-release",
23
+ "test:node": "node --test test/*.test.cjs",
24
+ "test:rust": "cargo test",
25
+ "test": "pnpm run test:rust && pnpm run test:node",
26
+ "lint:rust": "cargo clippy --all-targets --all-features -- -D warnings",
27
+ "version": "napi version"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "devDependencies": {
33
+ "@napi-rs/cli": "^2.18.4"
34
+ },
35
+ "napi": {
36
+ "name": "cocalc_openat2",
37
+ "triples": {
38
+ "defaults": false,
39
+ "additional": [
40
+ "x86_64-unknown-linux-gnu",
41
+ "aarch64-unknown-linux-gnu"
42
+ ]
43
+ }
44
+ },
45
+ "optionalDependencies": {
46
+ "@cocalc/openat2-linux-x64-gnu": "0.1.0",
47
+ "@cocalc/openat2-linux-arm64-gnu": "0.1.0"
48
+ }
49
+ }