@better-fs-tools/just-bash 0.0.0-tegami-trusted-publish-setup → 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gareth Andrew
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 CHANGED
@@ -1,6 +1,122 @@
1
- # Placeholder package
2
-
3
- This empty package was published by [Tegami](https://tegami.fuma-nama.dev) to configure npm trusted publishing.
4
-
5
- The real package contents will be published via CI with OIDC.
6
-
1
+ # @better-fs-tools/just-bash
2
+
3
+ A `WritableFileSystem` over a [just-bash](https://www.npmjs.com/package/just-bash) `IFileSystem`, for the Better Read tool and the `edit`, `write`, and `apply_patch` tools in a just-bash sandbox.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @better-fs-tools/just-bash @better-fs-tools/read @better-fs-tools/write just-bash
9
+ ```
10
+
11
+ `just-bash` is a required peer with the range `^3.4.2`. The tests run against 3.4.2.
12
+
13
+ ## Example
14
+
15
+ ```ts
16
+ import { InMemoryFs } from "just-bash";
17
+ import { readOnlyFileSystem } from "@better-fs-tools/fs";
18
+ import { justBashFileSystem } from "@better-fs-tools/just-bash";
19
+ import { createReadTool, textOf } from "@better-fs-tools/read";
20
+
21
+ const bash = new InMemoryFs({ "/workspace/src/index.ts": "const a = 1;\n" });
22
+
23
+ // readOnlyFileSystem drops the write methods, so no write tool can use this view.
24
+ const read = createReadTool({
25
+ fs: readOnlyFileSystem(
26
+ justBashFileSystem(bash, {
27
+ id: "sandbox",
28
+ cwd: "/workspace",
29
+ allowedRoots: ["/workspace"],
30
+ maxBufferedBytes: 4 * 1024 * 1024,
31
+ }),
32
+ ),
33
+ });
34
+
35
+ console.log(textOf(await read({ path: "src/index.ts" })));
36
+ // 1|const a = 1;
37
+ ```
38
+
39
+ The same filesystem, with the write tools:
40
+
41
+ ```ts
42
+ import { InMemoryFs } from "just-bash";
43
+ import { justBashFileSystem } from "@better-fs-tools/just-bash";
44
+ import { nodeDigest } from "@better-fs-tools/node";
45
+ import { createReadTool, memoryStore, textOf } from "@better-fs-tools/read";
46
+ import { createEditTool, memoryLocks } from "@better-fs-tools/write";
47
+
48
+ const bash = new InMemoryFs({ "/workspace/src/index.ts": "const a = 1;\n" });
49
+ const fs = justBashFileSystem(bash, {
50
+ id: "sandbox",
51
+ cwd: "/workspace",
52
+ allowedRoots: ["/workspace"],
53
+ maxBufferedBytes: 4 * 1024 * 1024,
54
+ });
55
+ const shared = { fs, state: memoryStore(), digest: nodeDigest() };
56
+ const read = createReadTool(shared);
57
+ const edit = createEditTool({ ...shared, locks: memoryLocks() });
58
+
59
+ await read({ path: "src/index.ts" });
60
+ console.log(textOf(await edit({ path: "src/index.ts", edits: [{ oldText: "1", newText: "2" }] })));
61
+ // Edited src/index.ts: 1 replacement at line 1.
62
+ console.log(await bash.readFile("/workspace/src/index.ts")); // const a = 2;
63
+ ```
64
+
65
+ ## What the filesystem does
66
+
67
+ - `justBashFileSystem(fs, options)` takes the shared `FileSystemRootOptions` and `maxBufferedBytes` from `@better-fs-tools/fs`. It checks each path against `allowedRoots` and `denyRoots` before it touches the backend, and again after `realpath()`. `cwd` must be absolute and defaults to `/`.
68
+ - `id` defaults to `"just-bash"`. It namespaces versions, so two backends that share one state store need two ids.
69
+ - `maxBufferedBytes` defaults to 16 MiB. `just-bash` returns whole buffers, so reads are buffered. A larger file gives `too-large`, which the tools report as `TOO_LARGE`.
70
+ - Reads are buffered inside `open()`, so the whole file has left the backend before the read tool's `authorize` runs. The core still passes no byte to a classifier, a converter, or the model until `authorize` allows it. If a denied read must not reach the backend, deny it in the adapter with `denyRoots` or `allowedRoots`.
71
+ - `identity` defaults to `"none"`: change detection compares size and modification time. With `identity: "required"`, the backend's `stat()` must give an identity or a device and inode for every file. A file without one is refused as `UNSUPPORTED_BACKEND`.
72
+ - `symlinks` defaults to `"reject"`, which refuses a symlink in any component of the path, as in the Node and Cloudflare adapters. `"follow-within-roots"` follows links, and the real path must still be inside the roots.
73
+ - The resolved options are on the filesystem as `cwd`, `allowedRoots`, `denyRoots`, `maxBufferedBytes`, `identity`, and `symlinks`.
74
+ - For a read-only view, wrap it: `readOnlyFileSystem(justBashFileSystem(bash, options))` from `@better-fs-tools/fs` drops the write methods.
75
+ - Backend errors are reduced to a POSIX code and a phase. Raw messages, which can contain paths, are not passed on.
76
+ - `list()` lists directories, so suggestions and `directoryListing()` work.
77
+
78
+ ## How it writes
79
+
80
+ `write()` needs `writeFile`, `mkdir`, `chmod`, and `utimes` on the backend, and `remove()` needs `rm`. They are checked when a write runs, not when the filesystem is built, so a backend without them still serves reads. A write to such a backend gives `unsupported`, which the tools report as `UNSUPPORTED_BACKEND`. The `fs` parameter has the type `JustBashBackend`: `lstat`, `realpath`, `stat`, `readFileBuffer`, and `readdir` are required, and `readdirWithFileTypes` and the write methods are optional. A full `IFileSystem` fits it, and so does a read-only backend, with no cast.
81
+
82
+ - Writes apply the same roots, deny roots, and symlink policy as `open()`. Under `"reject"`, a symlink anywhere on the path is refused. Under `"follow-within-roots"`, a link inside the roots is followed to its real path and the link stays. A link out of the roots and a dangling link are refused.
83
+ - `writeCapabilities` is `{ compareAndSwap: false }`. A write is `writeFile` and then `chmod`, so it is not atomic. `IFileSystem` has no version check, so the adapter checks the precondition with a fresh stat just before the write.
84
+ - `InMemoryFs` resets the mode to `0o644` on every write, so a replace calls `chmod` with the old mode.
85
+ - `InMemoryFs` keeps `mtime` when two writes fall in one millisecond. When a replace leaves `mtime` where it was, the adapter moves it on by one millisecond with `utimes`, so every write through the adapter changes the version.
86
+ - `identity: "required"` makes the write tools trust the version, which is the identity, size, and `mtime`. Another writer that changes a file to the same size inside the same millisecond is then not seen. With the default, `identity: "none"`, the write tools also compare the content hash of what the model read.
87
+ - There is no `stage()`. `apply_patch` writes each file in turn, and undoes them on a failure.
88
+ - `EEXIST` gives `exists`, `EROFS` gives `read-only`, and `ENOSPC` and `EDQUOT` give `no-space`.
89
+
90
+ ## Bash runner
91
+
92
+ `justBashCommandRunner(bash)` runs the `bash` tool from [`@better-fs-tools/shell`](https://www.npmjs.com/package/@better-fs-tools/shell) in a just-bash `Bash`. Nothing starts a process. stdin is empty, the tool's environment replaces the shell's own, and the cwd is set for the call only.
93
+
94
+ ```ts
95
+ import { Bash } from "just-bash";
96
+ import { justBashCommandRunner } from "@better-fs-tools/just-bash";
97
+ import { createBashTool, shellEnv, textOf } from "@better-fs-tools/shell";
98
+
99
+ // An emulated shell over an in-memory filesystem. Nothing starts a process.
100
+ // executionLimits stop a busy loop, because no timer fires while one runs.
101
+ const shell = new Bash({
102
+ files: { "/workspace/notes.txt": "one\ntwo\n" },
103
+ cwd: "/workspace",
104
+ executionLimits: { maxCommandCount: 100_000 },
105
+ });
106
+
107
+ // shellEnv() gives defaultShellEnv only: the emulated shell sees no host variables.
108
+ const bash = createBashTool({ runner: justBashCommandRunner(shell), env: shellEnv() });
109
+
110
+ console.log(textOf(await bash({ command: "wc -l notes.txt" })));
111
+ // Exit code 0 · 0 s
112
+ // 2 notes.txt
113
+ ```
114
+
115
+ just-bash buffers output, so stdout arrives whole before stderr, after the command ends. A stop aborts at the next statement boundary. A busy loop does not yield to the event loop, so no timer fires while it runs: set `executionLimits` as the backstop. Under Bun, just-bash 3.4.2 cannot apply its defense-in-depth patches and throws on `exec()`. Pass `defenseInDepth: false` there. Node runs the default.
116
+
117
+ ## Links
118
+
119
+ - `docs/hosts.md` in [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write): the defaults of every host and bundle, and what each backend can do
120
+ - [`@better-fs-tools/read`](https://www.npmjs.com/package/@better-fs-tools/read): every read option
121
+ - [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write): the write tools
122
+ - [`@better-fs-tools/fs`](https://www.npmjs.com/package/@better-fs-tools/fs): the filesystem contract
@@ -0,0 +1,18 @@
1
+ import type { Bash } from "just-bash";
2
+ import type { CommandRunner } from "@better-fs-tools/shell";
3
+ /** The part of just-bash's Bash class this runner uses. */
4
+ export type JustBashShell = Pick<Bash, "exec" | "getCwd" | "fs">;
5
+ export interface JustBashCommandRunnerOptions {
6
+ /** Default "just-bash (emulated)". The bash tool description shows it. */
7
+ readonly id?: string;
8
+ /** Default bash.getCwd() when the runner is made. */
9
+ readonly cwd?: string;
10
+ }
11
+ /**
12
+ * Runs each command with bash.exec() in an emulated shell, without starting a
13
+ * process. stdin is empty, the environment replaces the shell's own, and the
14
+ * cwd applies to the call only. Output arrives after the command ends, stdout
15
+ * before stderr. A stop takes effect at the next statement, and no timeout
16
+ * fires during a busy loop, so set just-bash's executionLimits to bound loops.
17
+ */
18
+ export declare function justBashCommandRunner(bash: JustBashShell, options?: JustBashCommandRunnerOptions): CommandRunner;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Runs each command with bash.exec() in an emulated shell, without starting a
3
+ * process. stdin is empty, the environment replaces the shell's own, and the
4
+ * cwd applies to the call only. Output arrives after the command ends, stdout
5
+ * before stderr. A stop takes effect at the next statement, and no timeout
6
+ * fires during a busy loop, so set just-bash's executionLimits to bound loops.
7
+ */
8
+ export function justBashCommandRunner(bash, options = {}) {
9
+ if (bash === null || typeof bash !== "object" || typeof bash.exec !== "function") {
10
+ throw new TypeError("justBashCommandRunner needs a just-bash Bash instance");
11
+ }
12
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
13
+ throw new TypeError("justBashCommandRunner options must be an object");
14
+ }
15
+ const { id = "just-bash (emulated)", cwd = bash.getCwd() } = options;
16
+ if (typeof id !== "string" || id === "")
17
+ throw new TypeError("id must be a non-empty string");
18
+ if (typeof cwd !== "string" || !cwd.startsWith("/")) {
19
+ throw new TypeError("cwd must be an absolute path: just-bash has no process cwd");
20
+ }
21
+ return Object.freeze({ id, cwd, run: (request) => run(bash, request) });
22
+ }
23
+ function run(bash, request) {
24
+ const chunks = [];
25
+ const exit = (async () => {
26
+ const problem = await checkCwd(bash, request.cwd);
27
+ if (problem !== null)
28
+ return { code: null, signal: null, error: { reason: problem } };
29
+ try {
30
+ const result = await bash.exec(request.command, {
31
+ cwd: request.cwd,
32
+ env: { ...request.env },
33
+ replaceEnv: true,
34
+ stdin: "",
35
+ signal: request.signal,
36
+ });
37
+ const encoder = new TextEncoder();
38
+ if (result.stdout !== "")
39
+ chunks.push({ stream: "stdout", bytes: encoder.encode(result.stdout) });
40
+ if (result.stderr !== "")
41
+ chunks.push({ stream: "stderr", bytes: encoder.encode(result.stderr) });
42
+ if (request.signal.aborted)
43
+ return { code: null, signal: "SIGTERM" };
44
+ return { code: result.exitCode, signal: null };
45
+ }
46
+ catch (error) {
47
+ if (request.signal.aborted)
48
+ return { code: null, signal: "SIGTERM" };
49
+ const detail = error instanceof Error ? error.message : String(error);
50
+ return { code: null, signal: null, error: { reason: "spawn-failed", detail } };
51
+ }
52
+ })();
53
+ const output = {
54
+ async *[Symbol.asyncIterator]() {
55
+ await exit;
56
+ yield* chunks;
57
+ },
58
+ };
59
+ return { output, exit };
60
+ }
61
+ async function checkCwd(bash, cwd) {
62
+ try {
63
+ return (await bash.fs.stat(cwd)).isDirectory ? null : "cwd-not-a-directory";
64
+ }
65
+ catch {
66
+ return "cwd-not-found";
67
+ }
68
+ }
@@ -0,0 +1,31 @@
1
+ import type { IFileSystem } from "just-bash";
2
+ import type { BufferedFileSystemOptions, FileSystemRootOptions, FileSystemRootSettings, ListOptions, ListOutcome, MutateOptions, MutationOutcome, WritableFileSystem } from "@better-fs-tools/fs";
3
+ /**
4
+ * The part of the just-bash `IFileSystem` this package needs. Reads need
5
+ * `lstat`, `realpath`, `stat`, `readFileBuffer`, and `readdir`, and use
6
+ * `readdirWithFileTypes` when it is there. The write methods are optional, so
7
+ * a read-only backend fits this type and still serves reads. A full
8
+ * `IFileSystem` fits too.
9
+ */
10
+ export type JustBashBackend = Pick<IFileSystem, "lstat" | "realpath" | "stat" | "readFileBuffer" | "readdir"> & Partial<Pick<IFileSystem, "readdirWithFileTypes" | "writeFile" | "mkdir" | "chmod" | "utimes" | "rm">>;
11
+ /**
12
+ * The shared root options, with virtual paths. `cwd` must be absolute and
13
+ * defaults to `/`; relative roots resolve against it. `id` defaults to
14
+ * `"just-bash"` and namespaces versions and identities, so give two backends
15
+ * that share one state store two ids. `symlinks` defaults to `"reject"`, which
16
+ * refuses a link in any component. `identity` defaults to `"none"`.
17
+ * `maxBufferedBytes` defaults to 16 MiB.
18
+ */
19
+ export interface JustBashFileSystemOptions extends FileSystemRootOptions, BufferedFileSystemOptions {
20
+ }
21
+ /**
22
+ * `writeCapabilities` is `{ compareAndSwap: false }`.
23
+ * The write methods need `writeFile`, `mkdir`, `chmod`, `utimes`, and `rm` on
24
+ * the backend. They are checked when a write runs, so a backend without them
25
+ * still serves reads.
26
+ */
27
+ export interface JustBashFileSystem extends WritableFileSystem, FileSystemRootSettings {
28
+ readonly maxBufferedBytes: number;
29
+ list(path: string, options: ListOptions): Promise<ListOutcome>;
30
+ remove(path: string, options: MutateOptions): Promise<MutationOutcome>;
31
+ }
File without changes
@@ -0,0 +1,19 @@
1
+ import type { JustBashBackend, JustBashFileSystem, JustBashFileSystemOptions } from "./contract.ts";
2
+ export { justBashCommandRunner } from "./command-runner.ts";
3
+ export type { JustBashCommandRunnerOptions, JustBashShell } from "./command-runner.ts";
4
+ export type { JustBashBackend, JustBashFileSystem, JustBashFileSystemOptions } from "./contract.ts";
5
+ /**
6
+ * Wraps a just-bash `IFileSystem`, or any `JustBashBackend`, as a file system
7
+ * for the better-fs-tools tools.
8
+ *
9
+ * `open()` reads the whole file into memory. An abort cannot stop a backend
10
+ * call already in progress. The `limit` of `list()` bounds the entries
11
+ * returned, not the backend's own directory read.
12
+ *
13
+ * `stat`, `write` and `remove` keep the same roots, deny roots and symlink
14
+ * policy as `open()`. `writeCapabilities` is `{ compareAndSwap: false }`.
15
+ * There is no `stage()`. For a
16
+ * read-only view, wrap the result in `readOnlyFileSystem()` from
17
+ * `@better-fs-tools/fs`.
18
+ */
19
+ export declare function justBashFileSystem(fs: JustBashBackend, options: JustBashFileSystemOptions): JustBashFileSystem;
package/dist/index.js ADDED
@@ -0,0 +1,234 @@
1
+ import { posixPaths, resolvePosix } from "@better-fs-tools/fs";
2
+ import { AdapterRefusal, authorizeCanonical, authorizeRequested, backendCall, canonicalPath, directoryEntry, display, fingerprint, inspect, invalid, refuse, refuseSymlinkComponents, requireRegularFile, requireStatKey, requireWithinCeiling, statType, toFileSystemError, validateDirent, validateEntryName, validateFileSystem, validateOptions, } from "./policy.js";
3
+ import { JUST_BASH_WRITE_CAPABILITIES, justBashWrites } from "./write.js";
4
+ export { justBashCommandRunner } from "./command-runner.js";
5
+ /**
6
+ * Wraps a just-bash `IFileSystem`, or any `JustBashBackend`, as a file system
7
+ * for the better-fs-tools tools.
8
+ *
9
+ * `open()` reads the whole file into memory. An abort cannot stop a backend
10
+ * call already in progress. The `limit` of `list()` bounds the entries
11
+ * returned, not the backend's own directory read.
12
+ *
13
+ * `stat`, `write` and `remove` keep the same roots, deny roots and symlink
14
+ * policy as `open()`. `writeCapabilities` is `{ compareAndSwap: false }`.
15
+ * There is no `stage()`. For a
16
+ * read-only view, wrap the result in `readOnlyFileSystem()` from
17
+ * `@better-fs-tools/fs`.
18
+ */
19
+ export function justBashFileSystem(fs, options) {
20
+ validateFileSystem(fs);
21
+ const configured = validateOptions(options);
22
+ return Object.freeze({
23
+ ...readMethods(fs, configured),
24
+ writeCapabilities: JUST_BASH_WRITE_CAPABILITIES,
25
+ ...justBashWrites(fs, configured),
26
+ });
27
+ }
28
+ function readMethods(fs, configured) {
29
+ const { id, cwd, allowedRoots, denyRoots, maxBufferedBytes, identity, symlinks } = configured;
30
+ return {
31
+ id,
32
+ capabilities: Object.freeze({ identity: identity === "required" }),
33
+ paths: posixPaths,
34
+ cwd,
35
+ allowedRoots,
36
+ denyRoots,
37
+ maxBufferedBytes,
38
+ identity,
39
+ symlinks,
40
+ async open(requested, callOptions = {}) {
41
+ const signal = callOptions.signal;
42
+ if (signal?.aborted)
43
+ return { ok: false, error: { reason: "aborted" } };
44
+ try {
45
+ const lexical = authorizeRequested(cwd, requested, allowedRoots, denyRoots);
46
+ await inspect(fs, "lstat", lexical, signal);
47
+ if (symlinks === "reject")
48
+ await refuseSymlinkComponents(fs, lexical, signal);
49
+ const resolved = await canonicalPath(fs, lexical, signal);
50
+ authorizeCanonical(resolved, allowedRoots, denyRoots);
51
+ const before = await inspect(fs, "stat", resolved, signal, "stat-before");
52
+ requireRegularFile(before, cwd, resolved);
53
+ requireWithinCeiling(before.size, maxBufferedBytes, "reported object");
54
+ const beforeKey = requireStatKey(before, identity);
55
+ const backendBytes = await backendCall(() => fs.readFileBuffer(resolved), "readFileBuffer", signal);
56
+ if (!(backendBytes instanceof Uint8Array)) {
57
+ throw invalid("readFileBuffer returned a non-Uint8Array", "readFileBuffer");
58
+ }
59
+ requireWithinCeiling(backendBytes.byteLength, maxBufferedBytes, "returned object");
60
+ // InMemoryFs returns its stored buffer, not a copy.
61
+ const bytes = Uint8Array.from(backendBytes);
62
+ const after = await inspect(fs, "stat", resolved, signal, "stat-after");
63
+ requireRegularFile(after, cwd, resolved);
64
+ const afterKey = requireStatKey(after, identity);
65
+ const beforeFingerprint = fingerprint(id, resolved, beforeKey, before);
66
+ const openedFingerprint = fingerprint(id, resolved, afterKey, after);
67
+ if (beforeFingerprint !== openedFingerprint || bytes.byteLength !== after.size) {
68
+ throw refuse({
69
+ reason: "io",
70
+ detail: "the path changed while opening the buffered snapshot",
71
+ cause: { code: "OPEN_CHANGED", phase: "open-verify" },
72
+ });
73
+ }
74
+ return {
75
+ ok: true,
76
+ file: justBashOpenFile({
77
+ fs,
78
+ id,
79
+ cwd,
80
+ lexical,
81
+ resolved,
82
+ allowedRoots,
83
+ denyRoots,
84
+ identity,
85
+ symlinks,
86
+ opened: after,
87
+ openedFingerprint,
88
+ bytes,
89
+ ...(signal === undefined ? {} : { signal }),
90
+ }),
91
+ };
92
+ }
93
+ catch (error) {
94
+ return { ok: false, error: toFileSystemError(error) };
95
+ }
96
+ },
97
+ async list(requested, listOptions) {
98
+ const signal = listOptions.signal;
99
+ if (signal?.aborted)
100
+ return { ok: false, error: { reason: "aborted" } };
101
+ const { limit } = listOptions;
102
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
103
+ return {
104
+ ok: false,
105
+ error: { reason: "io", detail: "list limit must be a positive safe integer" },
106
+ };
107
+ }
108
+ try {
109
+ const lexical = authorizeRequested(cwd, requested, allowedRoots, denyRoots);
110
+ await inspect(fs, "lstat", lexical, signal);
111
+ if (symlinks === "reject")
112
+ await refuseSymlinkComponents(fs, lexical, signal);
113
+ const resolved = await canonicalPath(fs, lexical, signal);
114
+ authorizeCanonical(resolved, allowedRoots, denyRoots);
115
+ const stat = await inspect(fs, "stat", resolved, signal);
116
+ if (!stat.isDirectory)
117
+ throw refuse({ reason: "not-found", detail: "not a directory" });
118
+ if (typeof fs.readdirWithFileTypes === "function") {
119
+ const readdirWithFileTypes = fs.readdirWithFileTypes;
120
+ const raw = await backendCall(() => readdirWithFileTypes.call(fs, resolved), "readdirWithFileTypes", signal);
121
+ if (!Array.isArray(raw)) {
122
+ throw invalid("readdirWithFileTypes returned a non-array", "readdirWithFileTypes");
123
+ }
124
+ const entries = [];
125
+ for (const value of raw.slice(0, limit)) {
126
+ if (signal?.aborted)
127
+ throw refuse({ reason: "aborted" });
128
+ entries.push(directoryEntry(validateDirent(value)));
129
+ }
130
+ return { ok: true, entries, truncated: raw.length > limit };
131
+ }
132
+ const raw = await backendCall(() => fs.readdir(resolved), "readdir", signal);
133
+ if (!Array.isArray(raw))
134
+ throw invalid("readdir returned a non-array", "readdir");
135
+ const names = raw.slice(0, limit).map((value) => validateEntryName(value, "readdir"));
136
+ const entries = [];
137
+ for (const name of names) {
138
+ const child = resolvePosix(resolved, name);
139
+ if (posixPaths.dirname(child) !== resolved) {
140
+ throw invalid("readdir returned an entry outside the listed directory", "readdir");
141
+ }
142
+ const childStat = await inspect(fs, "lstat", child, signal, "list-lstat");
143
+ entries.push({ name, type: statType(childStat) });
144
+ }
145
+ return { ok: true, entries, truncated: raw.length > limit };
146
+ }
147
+ catch (error) {
148
+ return { ok: false, error: toFileSystemError(error) };
149
+ }
150
+ },
151
+ };
152
+ }
153
+ function justBashOpenFile(context) {
154
+ let consumed = false;
155
+ let closed = false;
156
+ return {
157
+ info: {
158
+ resolvedPath: context.resolved,
159
+ displayPath: display(context.cwd, context.resolved),
160
+ size: context.bytes.byteLength,
161
+ mtimeMs: context.opened.mtimeMs,
162
+ identity: context.identity === "required" ? context.openedFingerprint : null,
163
+ mimeType: null,
164
+ version: context.openedFingerprint,
165
+ },
166
+ bytes() {
167
+ if (consumed)
168
+ throw new TypeError("just-bash byte source is single-use");
169
+ consumed = true;
170
+ let sent = closed || context.bytes.byteLength === 0;
171
+ return {
172
+ [Symbol.asyncIterator]() {
173
+ return {
174
+ async next() {
175
+ if (sent || closed || context.signal?.aborted)
176
+ return { done: true, value: undefined };
177
+ sent = true;
178
+ return { done: false, value: context.bytes };
179
+ },
180
+ async return() {
181
+ sent = true;
182
+ return { done: true, value: undefined };
183
+ },
184
+ };
185
+ },
186
+ };
187
+ },
188
+ async verify() {
189
+ try {
190
+ await inspect(context.fs, "lstat", context.lexical, context.signal, "verify-lstat");
191
+ if (context.symlinks === "reject") {
192
+ try {
193
+ await refuseSymlinkComponents(context.fs, context.lexical, context.signal);
194
+ }
195
+ catch (error) {
196
+ if (error instanceof AdapterRefusal && error.error.reason === "denied") {
197
+ return { ok: true, changed: true };
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+ const currentResolved = await canonicalPath(context.fs, context.lexical, context.signal, "verify-realpath");
203
+ try {
204
+ authorizeCanonical(currentResolved, context.allowedRoots, context.denyRoots);
205
+ }
206
+ catch (error) {
207
+ if (error instanceof AdapterRefusal)
208
+ return { ok: true, changed: true };
209
+ throw error;
210
+ }
211
+ if (currentResolved !== context.resolved)
212
+ return { ok: true, changed: true };
213
+ const current = await inspect(context.fs, "stat", currentResolved, context.signal, "verify-stat");
214
+ if (!current.isFile)
215
+ return { ok: true, changed: true };
216
+ const key = requireStatKey(current, context.identity);
217
+ return {
218
+ ok: true,
219
+ changed: fingerprint(context.id, context.resolved, key, current) !== context.openedFingerprint,
220
+ };
221
+ }
222
+ catch (error) {
223
+ const mapped = toFileSystemError(error);
224
+ if (mapped.reason === "not-found")
225
+ return { ok: true, changed: true };
226
+ return { ok: false, error: mapped };
227
+ }
228
+ },
229
+ async close() {
230
+ closed = true;
231
+ consumed = true;
232
+ },
233
+ };
234
+ }
@@ -0,0 +1,60 @@
1
+ import type { FsStat } from "just-bash";
2
+ import type { DirectoryEntry, FileSystemError, IdentityMode, SymlinkPolicy } from "@better-fs-tools/fs";
3
+ import type { JustBashBackend, JustBashFileSystemOptions } from "./contract.ts";
4
+ export interface ValidatedStat {
5
+ readonly isFile: boolean;
6
+ readonly isDirectory: boolean;
7
+ readonly isSymbolicLink: boolean;
8
+ readonly size: number;
9
+ readonly mtimeMs: number;
10
+ readonly identity?: string;
11
+ readonly dev?: number | bigint;
12
+ readonly ino?: number | bigint;
13
+ readonly mode?: number;
14
+ }
15
+ export interface ValidatedDirent {
16
+ readonly name: string;
17
+ readonly isFile: boolean;
18
+ readonly isDirectory: boolean;
19
+ readonly isSymbolicLink: boolean;
20
+ }
21
+ export interface JustBashSettings {
22
+ readonly id: string;
23
+ readonly cwd: string;
24
+ readonly allowedRoots: readonly string[];
25
+ readonly denyRoots: readonly string[];
26
+ readonly maxBufferedBytes: number;
27
+ readonly identity: IdentityMode;
28
+ readonly symlinks: SymlinkPolicy;
29
+ }
30
+ export declare function validateOptions(options: JustBashFileSystemOptions): JustBashSettings;
31
+ export declare function validateFileSystem(fs: JustBashBackend): void;
32
+ export declare function validateAbsolutePath(value: unknown, label: string): string;
33
+ export declare function validateConfiguredRoot(cwd: string, value: unknown): string;
34
+ export declare function authorizeRequested(cwd: string, requested: unknown, allowedRoots: readonly string[], denyRoots: readonly string[]): string;
35
+ export declare function authorizeCanonical(target: string, allowedRoots: readonly string[], denyRoots: readonly string[]): void;
36
+ export declare function canonicalPath(fs: JustBashBackend, lexical: string, signal?: AbortSignal, phase?: string): Promise<string>;
37
+ export declare const SYMLINK_REJECTED = "a path component is a symbolic link and this adapter refuses symlinks";
38
+ export declare function refuseSymlinkComponents(fs: JustBashBackend, lexical: string, signal?: AbortSignal): Promise<void>;
39
+ export declare function display(cwd: string, target: string): string;
40
+ export declare function inspect(fs: JustBashBackend, method: "stat" | "lstat", path: string, signal?: AbortSignal, phase?: string): Promise<ValidatedStat>;
41
+ export declare function validateStat(value: FsStat, phase: string): ValidatedStat;
42
+ export declare function validateInodePart(value: unknown, field: "dev" | "ino", phase: string): void;
43
+ export declare function validateDirent(value: unknown): ValidatedDirent;
44
+ export declare function validateEntryName(value: unknown, phase: string): string;
45
+ export declare function directoryEntry(entry: ValidatedDirent): DirectoryEntry;
46
+ export declare function statType(stat: ValidatedStat): DirectoryEntry["type"];
47
+ export declare function requireRegularFile(stat: ValidatedStat, cwd: string, resolved: string): void;
48
+ export declare function requireWithinCeiling(size: number, ceiling: number, subject: string): void;
49
+ export declare function requireStatKey(stat: ValidatedStat, mode: IdentityMode): string;
50
+ export declare function fingerprint(id: string, path: string, key: string, stat: ValidatedStat): string;
51
+ export declare function backendCall<T>(operation: () => Promise<T> | T, phase: string, signal?: AbortSignal): Promise<T>;
52
+ export declare function mapBackendError(error: unknown, phase: string): FileSystemError;
53
+ export declare function portableCode(error: unknown): string;
54
+ export declare class AdapterRefusal extends Error {
55
+ readonly error: FileSystemError;
56
+ constructor(error: FileSystemError);
57
+ }
58
+ export declare function refuse(error: FileSystemError): AdapterRefusal;
59
+ export declare function invalid(detail: string, phase: string): AdapterRefusal;
60
+ export declare function toFileSystemError(error: unknown): FileSystemError;
package/dist/policy.js ADDED
@@ -0,0 +1,341 @@
1
+ import { containsPosix, DEFAULT_MAX_BUFFERED_BYTES, posixPaths, resolvePosix, } from "@better-fs-tools/fs";
2
+ const MAX_IDENTITY_LENGTH = 1_024;
3
+ export function validateOptions(options) {
4
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
5
+ throw new TypeError("justBashFileSystem options must be an object");
6
+ }
7
+ const id = options.id ?? "just-bash";
8
+ if (typeof id !== "string" || id === "" || id.includes("\0")) {
9
+ throw new TypeError("id must be a non-empty string without NUL");
10
+ }
11
+ const cwd = validateAbsolutePath(options.cwd ?? "/", "cwd");
12
+ if (!Array.isArray(options.allowedRoots) || options.allowedRoots.length === 0) {
13
+ throw new TypeError("allowedRoots must be a non-empty array");
14
+ }
15
+ const allowedRoots = Object.freeze(options.allowedRoots.map((root) => validateConfiguredRoot(cwd, root)));
16
+ if (options.denyRoots !== undefined && !Array.isArray(options.denyRoots)) {
17
+ throw new TypeError("denyRoots must be an array");
18
+ }
19
+ const denyRoots = Object.freeze((options.denyRoots ?? []).map((root) => validateConfiguredRoot(cwd, root)));
20
+ const maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
21
+ if (!Number.isSafeInteger(maxBufferedBytes) || maxBufferedBytes <= 0) {
22
+ throw new TypeError("maxBufferedBytes must be a positive safe integer");
23
+ }
24
+ const identity = options.identity ?? "none";
25
+ if (identity !== "required" && identity !== "none") {
26
+ throw new TypeError('identity must be "required" or "none"');
27
+ }
28
+ const symlinks = options.symlinks ?? "reject";
29
+ if (symlinks !== "reject" && symlinks !== "follow-within-roots") {
30
+ throw new TypeError('symlinks must be "reject" or "follow-within-roots"');
31
+ }
32
+ return { id, cwd, allowedRoots, denyRoots, maxBufferedBytes, identity, symlinks };
33
+ }
34
+ export function validateFileSystem(fs) {
35
+ if (fs === null || typeof fs !== "object" || Array.isArray(fs)) {
36
+ throw new TypeError("justBashFileSystem needs an IFileSystem object");
37
+ }
38
+ const readMethods = ["lstat", "realpath", "stat", "readFileBuffer", "readdir"];
39
+ for (const method of readMethods) {
40
+ if (typeof fs[method] !== "function")
41
+ throw new TypeError(`the IFileSystem must implement ${method}()`);
42
+ }
43
+ if (fs.readdirWithFileTypes !== undefined && typeof fs.readdirWithFileTypes !== "function") {
44
+ throw new TypeError("IFileSystem readdirWithFileTypes must be a function when present");
45
+ }
46
+ }
47
+ export function validateAbsolutePath(value, label) {
48
+ if (typeof value !== "string" || value === "" || !value.startsWith("/") || value.includes("\0")) {
49
+ throw new TypeError(`${label} must be an absolute POSIX path without NUL`);
50
+ }
51
+ return resolvePosix("/", value);
52
+ }
53
+ export function validateConfiguredRoot(cwd, value) {
54
+ if (typeof value !== "string" || value === "" || value.includes("\0")) {
55
+ throw new TypeError("filesystem roots must be non-empty POSIX paths without NUL");
56
+ }
57
+ return resolvePosix(cwd, value);
58
+ }
59
+ export function authorizeRequested(cwd, requested, allowedRoots, denyRoots) {
60
+ if (typeof requested !== "string" || requested === "") {
61
+ throw refuse({ reason: "io", detail: "the requested path must be a non-empty string" });
62
+ }
63
+ if (requested.includes("\0")) {
64
+ throw refuse({ reason: "dangerous-path", detail: "the path contains NUL" });
65
+ }
66
+ const target = resolvePosix(cwd, requested);
67
+ authorizeCanonical(target, allowedRoots, denyRoots);
68
+ return target;
69
+ }
70
+ export function authorizeCanonical(target, allowedRoots, denyRoots) {
71
+ const denied = denyRoots.find((root) => containsPosix(root, target));
72
+ if (denied !== undefined)
73
+ throw refuse({ reason: "dangerous-path", detail: denied });
74
+ if (!allowedRoots.some((root) => containsPosix(root, target))) {
75
+ throw refuse({ reason: "outside-allowed-roots" });
76
+ }
77
+ }
78
+ export async function canonicalPath(fs, lexical, signal, phase = "realpath") {
79
+ const value = await backendCall(() => fs.realpath(lexical), phase, signal);
80
+ if (typeof value !== "string" || value === "" || !value.startsWith("/") || value.includes("\0")) {
81
+ throw invalid("realpath returned an invalid absolute POSIX path", phase);
82
+ }
83
+ return resolvePosix("/", value);
84
+ }
85
+ export const SYMLINK_REJECTED = "a path component is a symbolic link and this adapter refuses symlinks";
86
+ export async function refuseSymlinkComponents(fs, lexical, signal) {
87
+ let current = "";
88
+ for (const segment of lexical.split("/")) {
89
+ if (segment === "")
90
+ continue;
91
+ current = `${current}/${segment}`;
92
+ let stat;
93
+ try {
94
+ stat = await inspect(fs, "lstat", current, signal, "lstat-component");
95
+ }
96
+ catch (error) {
97
+ if (error instanceof AdapterRefusal && error.error.reason === "not-found")
98
+ return;
99
+ throw error;
100
+ }
101
+ if (stat.isSymbolicLink)
102
+ throw refuse({ reason: "denied", detail: SYMLINK_REJECTED });
103
+ }
104
+ }
105
+ export function display(cwd, target) {
106
+ if (!containsPosix(cwd, target))
107
+ return target;
108
+ if (target === cwd)
109
+ return posixPaths.basename(target) || "/";
110
+ const relative = target.slice(cwd === "/" ? 1 : cwd.length + 1);
111
+ return relative === "" ? posixPaths.basename(target) || "/" : relative;
112
+ }
113
+ export async function inspect(fs, method, path, signal, phase = method) {
114
+ const value = await backendCall(() => fs[method](path), phase, signal);
115
+ return validateStat(value, phase);
116
+ }
117
+ export function validateStat(value, phase) {
118
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
119
+ throw invalid(`${phase} returned a non-object`, phase);
120
+ }
121
+ const stat = value;
122
+ for (const field of ["isFile", "isDirectory", "isSymbolicLink"]) {
123
+ if (typeof stat[field] !== "boolean")
124
+ throw invalid(`${phase} returned an invalid ${field}`, phase);
125
+ }
126
+ if ([stat.isFile, stat.isDirectory, stat.isSymbolicLink].filter(Boolean).length > 1) {
127
+ throw invalid(`${phase} returned conflicting entry types`, phase);
128
+ }
129
+ if (!Number.isSafeInteger(stat.size) || stat.size < 0) {
130
+ throw invalid(`${phase} returned an unusable size`, phase);
131
+ }
132
+ if (!(stat.mtime instanceof Date) || !Number.isFinite(stat.mtime.getTime())) {
133
+ throw invalid(`${phase} returned an unusable modification time`, phase);
134
+ }
135
+ if (stat.identity !== undefined &&
136
+ (typeof stat.identity !== "string" ||
137
+ stat.identity === "" ||
138
+ stat.identity.length > MAX_IDENTITY_LENGTH)) {
139
+ throw invalid(`${phase} returned an unusable identity`, phase);
140
+ }
141
+ validateInodePart(stat.dev, "dev", phase);
142
+ validateInodePart(stat.ino, "ino", phase);
143
+ return {
144
+ isFile: stat.isFile,
145
+ isDirectory: stat.isDirectory,
146
+ isSymbolicLink: stat.isSymbolicLink,
147
+ size: stat.size,
148
+ mtimeMs: stat.mtime.getTime(),
149
+ ...(typeof stat.identity === "string" ? { identity: stat.identity } : {}),
150
+ ...(typeof stat.dev === "number" || typeof stat.dev === "bigint" ? { dev: stat.dev } : {}),
151
+ ...(typeof stat.ino === "number" || typeof stat.ino === "bigint" ? { ino: stat.ino } : {}),
152
+ ...permissionBitsIfUsable(stat.mode),
153
+ };
154
+ }
155
+ function permissionBitsIfUsable(mode) {
156
+ return Number.isSafeInteger(mode) && mode >= 0
157
+ ? { mode: mode & 0o7777 }
158
+ : {};
159
+ }
160
+ export function validateInodePart(value, field, phase) {
161
+ if (value === undefined)
162
+ return;
163
+ if (typeof value === "bigint") {
164
+ if (value >= 0n)
165
+ return;
166
+ }
167
+ else if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
168
+ return;
169
+ }
170
+ throw invalid(`${phase} returned an unusable ${field}`, phase);
171
+ }
172
+ export function validateDirent(value) {
173
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
174
+ throw invalid("readdirWithFileTypes returned a non-object entry", "readdirWithFileTypes");
175
+ }
176
+ const entry = value;
177
+ const name = validateEntryName(entry.name, "readdirWithFileTypes");
178
+ for (const field of ["isFile", "isDirectory", "isSymbolicLink"]) {
179
+ if (typeof entry[field] !== "boolean") {
180
+ throw invalid(`readdirWithFileTypes returned an invalid ${field}`, "readdirWithFileTypes");
181
+ }
182
+ }
183
+ const flags = [entry.isFile, entry.isDirectory, entry.isSymbolicLink].filter(Boolean).length;
184
+ if (flags > 1)
185
+ throw invalid("readdirWithFileTypes returned conflicting entry types", "readdirWithFileTypes");
186
+ return {
187
+ name,
188
+ isFile: entry.isFile,
189
+ isDirectory: entry.isDirectory,
190
+ isSymbolicLink: entry.isSymbolicLink,
191
+ };
192
+ }
193
+ export function validateEntryName(value, phase) {
194
+ if (typeof value !== "string" ||
195
+ value === "" ||
196
+ value === "." ||
197
+ value === ".." ||
198
+ value.includes("/") ||
199
+ value.includes("\0")) {
200
+ throw invalid(`${phase} returned an invalid entry name`, phase);
201
+ }
202
+ return value;
203
+ }
204
+ export function directoryEntry(entry) {
205
+ return {
206
+ name: entry.name,
207
+ type: entry.isFile ? "file" : entry.isDirectory ? "directory" : "other",
208
+ };
209
+ }
210
+ export function statType(stat) {
211
+ return stat.isFile ? "file" : stat.isDirectory ? "directory" : "other";
212
+ }
213
+ export function requireRegularFile(stat, cwd, resolved) {
214
+ if (stat.isFile)
215
+ return;
216
+ const error = {
217
+ reason: "not-a-file",
218
+ kind: stat.isDirectory ? "directory" : "other",
219
+ target: { resolvedPath: resolved, displayPath: display(cwd, resolved) },
220
+ };
221
+ throw refuse(error);
222
+ }
223
+ export function requireWithinCeiling(size, ceiling, subject) {
224
+ if (size > ceiling) {
225
+ throw refuse({
226
+ reason: "too-large",
227
+ limit: ceiling,
228
+ size,
229
+ detail: `the ${subject} exceeds the ${ceiling}-byte buffered ceiling`,
230
+ });
231
+ }
232
+ }
233
+ export function requireStatKey(stat, mode) {
234
+ if (mode === "none")
235
+ return `weak:${stat.size}:${stat.mtimeMs}`;
236
+ if (stat.identity !== undefined)
237
+ return `identity:${stat.identity}`;
238
+ if (stat.dev !== undefined && stat.ino !== undefined) {
239
+ return `inode:${String(stat.dev)}:${String(stat.ino)}`;
240
+ }
241
+ throw refuse({
242
+ reason: "unsupported",
243
+ detail: "the backend did not provide stable file identity",
244
+ cause: { code: "IDENTITY_UNAVAILABLE", phase: "stat-key" },
245
+ });
246
+ }
247
+ export function fingerprint(id, path, key, stat) {
248
+ return JSON.stringify([id, path, key, stat.size, stat.mtimeMs]);
249
+ }
250
+ export async function backendCall(operation, phase, signal) {
251
+ if (signal?.aborted)
252
+ throw refuse({ reason: "aborted", cause: { code: "ABORT_ERR", phase } });
253
+ try {
254
+ const value = await operation();
255
+ if (signal?.aborted)
256
+ throw refuse({ reason: "aborted", cause: { code: "ABORT_ERR", phase } });
257
+ return value;
258
+ }
259
+ catch (error) {
260
+ if (error instanceof AdapterRefusal)
261
+ throw error;
262
+ if (signal?.aborted)
263
+ throw refuse({ reason: "aborted", cause: { code: "ABORT_ERR", phase } });
264
+ throw refuse(mapBackendError(error, phase));
265
+ }
266
+ }
267
+ export function mapBackendError(error, phase) {
268
+ const code = portableCode(error);
269
+ const cause = { code, phase };
270
+ if (code === "ABORT_ERR")
271
+ return { reason: "aborted", cause };
272
+ if (code === "ENOENT" || code === "ENOTDIR")
273
+ return { reason: "not-found", cause };
274
+ if (code === "EISDIR")
275
+ return { reason: "not-a-file", kind: "directory", target: null, cause };
276
+ if (code === "EACCES")
277
+ return { reason: "permission-denied", cause };
278
+ if (code === "EPERM" || code === "ELOOP" || code === "EFBIG" || code === "ENAMETOOLONG") {
279
+ return { reason: "denied", cause };
280
+ }
281
+ if (code === "ENOSYS" || code === "ENOTSUP" || code === "EOPNOTSUPP") {
282
+ return { reason: "unsupported", cause };
283
+ }
284
+ return { reason: "io", detail: phase, cause };
285
+ }
286
+ // Backend messages can contain paths, so only a short error code is kept.
287
+ export function portableCode(error) {
288
+ if (error !== null && typeof error === "object") {
289
+ try {
290
+ if (Object.prototype.hasOwnProperty.call(error, "code")) {
291
+ const code = error.code;
292
+ if (typeof code === "string" && /^[A-Za-z0-9_.-]{1,40}$/u.test(code))
293
+ return code;
294
+ }
295
+ }
296
+ catch {
297
+ /* A hostile accessor is an unknown backend error, never a reason to leak it. */
298
+ }
299
+ }
300
+ try {
301
+ if (error instanceof Error) {
302
+ const message = typeof error.message === "string" ? error.message.slice(0, 64) : "";
303
+ const match = /^(E[A-Z]{2,12}):/u.exec(message);
304
+ if (match?.[1] !== undefined)
305
+ return match[1];
306
+ if (error.name === "AbortError")
307
+ return "ABORT_ERR";
308
+ }
309
+ }
310
+ catch {
311
+ /* Error subclasses and proxies do not get to make error mapping throw. */
312
+ }
313
+ return "UNKNOWN";
314
+ }
315
+ export class AdapterRefusal extends Error {
316
+ error;
317
+ constructor(error) {
318
+ super(error.reason);
319
+ this.error = error;
320
+ this.name = "AdapterRefusal";
321
+ }
322
+ }
323
+ export function refuse(error) {
324
+ return new AdapterRefusal(error);
325
+ }
326
+ export function invalid(detail, phase) {
327
+ return new AdapterRefusal({
328
+ reason: "io",
329
+ detail,
330
+ cause: { code: "INVALID_BACKEND_RESULT", phase },
331
+ });
332
+ }
333
+ export function toFileSystemError(error) {
334
+ if (error instanceof AdapterRefusal)
335
+ return error.error;
336
+ return {
337
+ reason: "io",
338
+ detail: "the just-bash adapter failed",
339
+ cause: { code: portableCode(error) },
340
+ };
341
+ }
@@ -0,0 +1,10 @@
1
+ import type { MutateOptions, MutationOutcome, OpenOptions, StatOutcome, WriteCapabilities, WriteOptions } from "@better-fs-tools/fs";
2
+ import type { JustBashBackend } from "./contract.ts";
3
+ import type { JustBashSettings } from "./policy.ts";
4
+ export declare const JUST_BASH_WRITE_CAPABILITIES: WriteCapabilities;
5
+ export interface JustBashWrites {
6
+ stat(path: string, options?: OpenOptions): Promise<StatOutcome>;
7
+ write(path: string, bytes: Uint8Array, options: WriteOptions): Promise<MutationOutcome>;
8
+ remove(path: string, options: MutateOptions): Promise<MutationOutcome>;
9
+ }
10
+ export declare function justBashWrites(fs: JustBashBackend, settings: JustBashSettings): JustBashWrites;
package/dist/write.js ADDED
@@ -0,0 +1,240 @@
1
+ import { posixPaths } from "@better-fs-tools/fs";
2
+ import { AdapterRefusal, authorizeCanonical, authorizeRequested, backendCall, canonicalPath, display, fingerprint, inspect, refuse, refuseSymlinkComponents, requireRegularFile, requireStatKey, toFileSystemError, } from "./policy.js";
3
+ export const JUST_BASH_WRITE_CAPABILITIES = Object.freeze({
4
+ compareAndSwap: false,
5
+ });
6
+ export function justBashWrites(fs, settings) {
7
+ const { id, cwd, allowedRoots, denyRoots, maxBufferedBytes, identity, symlinks } = settings;
8
+ const lstatOrNull = async (path, signal) => {
9
+ try {
10
+ return await inspect(fs, "lstat", path, signal);
11
+ }
12
+ catch (error) {
13
+ if (error instanceof AdapterRefusal && error.error.reason === "not-found")
14
+ return null;
15
+ throw error;
16
+ }
17
+ };
18
+ const existing = async (resolved, signal) => {
19
+ const stat = await inspect(fs, "stat", resolved, signal);
20
+ requireRegularFile(stat, cwd, resolved);
21
+ const version = fingerprint(id, resolved, requireStatKey(stat, identity), stat);
22
+ return { exists: true, resolved, stat, version };
23
+ };
24
+ const locate = async (requested, signal) => {
25
+ const lexical = authorizeRequested(cwd, requested, allowedRoots, denyRoots);
26
+ if (symlinks === "reject")
27
+ await refuseSymlinkComponents(fs, lexical, signal);
28
+ const leaf = await lstatOrNull(lexical, signal);
29
+ if (leaf !== null) {
30
+ let resolved;
31
+ try {
32
+ resolved = await canonicalPath(fs, lexical, signal);
33
+ }
34
+ catch (error) {
35
+ if (error instanceof AdapterRefusal && error.error.reason === "not-found") {
36
+ throw refuse({ reason: "denied", detail: "the path is a dangling symbolic link" });
37
+ }
38
+ throw error;
39
+ }
40
+ authorizeCanonical(resolved, allowedRoots, denyRoots);
41
+ return existing(resolved, signal);
42
+ }
43
+ return locateThroughNearestAncestor(lexical, signal);
44
+ };
45
+ const locateThroughNearestAncestor = async (lexical, signal) => {
46
+ const missing = [posixPaths.basename(lexical)];
47
+ let ancestor = posixPaths.dirname(lexical);
48
+ while ((await lstatOrNull(ancestor, signal)) === null) {
49
+ if (ancestor === "/")
50
+ throw refuse({ reason: "not-found", detail: "no ancestor exists" });
51
+ missing.unshift(posixPaths.basename(ancestor));
52
+ ancestor = posixPaths.dirname(ancestor);
53
+ }
54
+ const base = await canonicalPath(fs, ancestor, signal);
55
+ authorizeCanonical(base, allowedRoots, denyRoots);
56
+ if (!(await inspect(fs, "stat", base, signal)).isDirectory) {
57
+ throw refuse({ reason: "not-found", detail: "a path component is not a directory" });
58
+ }
59
+ const paths = [];
60
+ for (const name of missing)
61
+ paths.push(posixPaths.join(paths.at(-1) ?? base, name));
62
+ const resolved = paths.at(-1) ?? base;
63
+ authorizeCanonical(resolved, allowedRoots, denyRoots);
64
+ return { exists: false, resolved, missingDirectories: paths.slice(0, -1) };
65
+ };
66
+ const fileStat = (located) => located.exists
67
+ ? {
68
+ exists: true,
69
+ resolvedPath: located.resolved,
70
+ displayPath: display(cwd, located.resolved),
71
+ size: located.stat.size,
72
+ mtimeMs: located.stat.mtimeMs,
73
+ identity: identity === "required" ? located.version : null,
74
+ version: located.version,
75
+ mode: located.stat.mode ?? null,
76
+ hardLinks: null,
77
+ }
78
+ : {
79
+ exists: false,
80
+ resolvedPath: located.resolved,
81
+ displayPath: display(cwd, located.resolved),
82
+ missingDirectories: located.missingDirectories,
83
+ };
84
+ const makeDirectories = async (writable, directories) => {
85
+ const created = [];
86
+ for (const directory of directories) {
87
+ await mutate(() => writable.mkdir(directory, { recursive: true }), "mkdir");
88
+ const entry = await lstatOrNull(directory);
89
+ if (entry?.isDirectory !== true) {
90
+ throw refuse({ reason: "denied", detail: "a created parent is not a directory" });
91
+ }
92
+ created.push(directory);
93
+ }
94
+ return created;
95
+ };
96
+ const mutated = (resolved, after, created) => ({
97
+ resolvedPath: resolved,
98
+ displayPath: display(cwd, resolved),
99
+ version: after?.exists === true ? after.version : null,
100
+ identity: after?.exists === true && identity === "required" ? after.version : null,
101
+ size: after?.exists === true ? after.stat.size : null,
102
+ createdDirectories: created,
103
+ });
104
+ return {
105
+ async stat(requested, options = {}) {
106
+ if (options.signal?.aborted)
107
+ return { ok: false, error: { reason: "aborted" } };
108
+ try {
109
+ return { ok: true, stat: fileStat(await locate(requested, options.signal)) };
110
+ }
111
+ catch (error) {
112
+ return { ok: false, error: toFileSystemError(error) };
113
+ }
114
+ },
115
+ async write(requested, bytes, options) {
116
+ if (options.signal?.aborted)
117
+ return { ok: false, error: { reason: "aborted" } };
118
+ try {
119
+ const writable = requireWriteMethods(fs, WRITE_METHODS);
120
+ if (bytes.byteLength > maxBufferedBytes) {
121
+ throw new WriteRefusal({
122
+ reason: "too-large",
123
+ limit: maxBufferedBytes,
124
+ size: bytes.byteLength,
125
+ detail: `the object exceeds the ${maxBufferedBytes}-byte buffered ceiling`,
126
+ });
127
+ }
128
+ const located = await locate(requested, options.signal);
129
+ refuseConflict(located, options.precondition);
130
+ if (!located.exists && located.missingDirectories.length > 0 && !options.createParents) {
131
+ throw refuse({ reason: "not-found", detail: "the parent directory does not exist" });
132
+ }
133
+ if (options.signal?.aborted)
134
+ throw refuse({ reason: "aborted" });
135
+ const { resolved } = located;
136
+ const created = located.exists
137
+ ? []
138
+ : await makeDirectories(writable, located.missingDirectories);
139
+ await mutate(() => writable.writeFile(resolved, bytes), "writeFile");
140
+ // InMemoryFs writeFile resets the mode to 0o644.
141
+ const mode = located.exists ? located.stat.mode : options.mode;
142
+ if (mode !== undefined)
143
+ await mutate(() => writable.chmod(resolved, mode), "chmod");
144
+ if (located.exists) {
145
+ const after = await inspect(fs, "stat", resolved);
146
+ // InMemoryFs keeps mtime for two writes in one millisecond.
147
+ if (after.mtimeMs <= located.stat.mtimeMs) {
148
+ const mtime = new Date(located.stat.mtimeMs + 1);
149
+ await mutate(() => writable.utimes(resolved, mtime, mtime), "utimes");
150
+ }
151
+ }
152
+ return { ok: true, file: mutated(resolved, await existing(resolved), created) };
153
+ }
154
+ catch (error) {
155
+ return { ok: false, error: toMutationError(error) };
156
+ }
157
+ },
158
+ async remove(requested, options) {
159
+ if (options.signal?.aborted)
160
+ return { ok: false, error: { reason: "aborted" } };
161
+ try {
162
+ const writable = requireWriteMethods(fs, ["rm"]);
163
+ const located = await locate(requested, options.signal);
164
+ refuseConflict(located, options.precondition);
165
+ if (!located.exists)
166
+ throw refuse({ reason: "not-found" });
167
+ if (options.signal?.aborted)
168
+ throw refuse({ reason: "aborted" });
169
+ await mutate(() => writable.rm(located.resolved), "rm");
170
+ return { ok: true, file: mutated(located.resolved, null, []) };
171
+ }
172
+ catch (error) {
173
+ return { ok: false, error: toMutationError(error) };
174
+ }
175
+ },
176
+ };
177
+ }
178
+ const WRITE_METHODS = ["writeFile", "mkdir", "chmod", "utimes"];
179
+ function requireWriteMethods(fs, methods) {
180
+ for (const method of methods) {
181
+ if (typeof fs[method] !== "function") {
182
+ throw new WriteRefusal({
183
+ reason: "unsupported",
184
+ detail: `the IFileSystem has no ${method}(), so this backend cannot write`,
185
+ });
186
+ }
187
+ }
188
+ return fs;
189
+ }
190
+ function refuseConflict(located, precondition) {
191
+ switch (precondition.kind) {
192
+ case "absent":
193
+ if (located.exists)
194
+ throw new WriteRefusal({ reason: "exists" });
195
+ return;
196
+ case "version":
197
+ if (!located.exists || located.version !== precondition.version) {
198
+ throw new WriteRefusal({ reason: "changed" });
199
+ }
200
+ return;
201
+ case "any":
202
+ return;
203
+ default:
204
+ throw refuse({ reason: "io", detail: "unknown precondition" });
205
+ }
206
+ }
207
+ class WriteRefusal extends Error {
208
+ error;
209
+ constructor(error) {
210
+ super(error.reason);
211
+ this.error = error;
212
+ this.name = "WriteRefusal";
213
+ }
214
+ }
215
+ async function mutate(operation, phase) {
216
+ try {
217
+ return await backendCall(operation, phase);
218
+ }
219
+ catch (error) {
220
+ if (!(error instanceof AdapterRefusal))
221
+ throw error;
222
+ const code = error.error.cause?.code;
223
+ const cause = { code: code ?? "UNKNOWN", phase };
224
+ if (code === "EEXIST")
225
+ throw new WriteRefusal({ reason: "exists", cause });
226
+ if (code === "EROFS")
227
+ throw new WriteRefusal({ reason: "read-only", cause });
228
+ if (code === "ENOSPC" || code === "EDQUOT") {
229
+ throw new WriteRefusal({ reason: "no-space", cause });
230
+ }
231
+ throw error;
232
+ }
233
+ }
234
+ function toMutationError(error) {
235
+ if (error instanceof WriteRefusal)
236
+ return error.error;
237
+ if (error instanceof AdapterRefusal)
238
+ return error.error;
239
+ return toFileSystemError(error);
240
+ }
package/package.json CHANGED
@@ -1,5 +1,43 @@
1
1
  {
2
2
  "name": "@better-fs-tools/just-bash",
3
- "version": "0.0.0-tegami-trusted-publish-setup",
4
- "description": "Placeholder published by Tegami for npm trusted publishing setup."
3
+ "version": "0.2.0",
4
+ "description": "A readable and writable filesystem and a bash command runner over just-bash.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/gingerhendrix/better-fs-tools.git",
9
+ "directory": "packages/just-bash"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "publishConfig": {
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "@better-fs-tools/fs": "^0.2.0",
33
+ "@better-fs-tools/shell": "^0.2.0"
34
+ },
35
+ "devDependencies": {
36
+ "@better-fs-tools/read": "^0.2.0",
37
+ "@better-fs-tools/write": "^0.2.0",
38
+ "just-bash": "3.4.2"
39
+ },
40
+ "peerDependencies": {
41
+ "just-bash": "^3.4.2"
42
+ }
5
43
  }