@better-fs-tools/cloudflare-computer 0.0.0-tegami-trusted-publish-setup → 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.
- package/LICENSE +21 -0
- package/README.md +70 -6
- package/dist/contract.d.ts +70 -0
- package/dist/contract.js +0 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +211 -0
- package/dist/policy.d.ts +32 -0
- package/dist/policy.js +259 -0
- package/dist/write.d.ts +11 -0
- package/dist/write.js +223 -0
- package/package.json +36 -2
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,70 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
# @better-fs-tools/cloudflare-computer
|
|
2
|
+
|
|
3
|
+
A `WritableFileSystem` over a [Cloudflare Computer](https://www.npmjs.com/package/@cloudflare/computer) workspace filesystem, for the Better Read tool and the `edit`, `write`, and `apply_patch` tools.
|
|
4
|
+
|
|
5
|
+
This package is experimental. Cloudflare Computer is preview software, and this adapter follows the `@cloudflare/computer` 0.2.1 declarations.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @better-fs-tools/cloudflare-computer @better-fs-tools/read @better-fs-tools/write
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package has no peers. It declares the filesystem methods that it uses as a structural type, so `Workspace["fs"]` and `WorkspaceFilesystemStub` from `@cloudflare/computer` 0.2.1 fit without an import. It imports no `node:` module.
|
|
14
|
+
|
|
15
|
+
## Example
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import type { Workspace } from "@cloudflare/computer";
|
|
19
|
+
import { cloudflareComputerFileSystem } from "@better-fs-tools/cloudflare-computer";
|
|
20
|
+
import { createReadTool } from "@better-fs-tools/read";
|
|
21
|
+
|
|
22
|
+
export function computerReadTool(workspace: Workspace) {
|
|
23
|
+
return createReadTool({
|
|
24
|
+
fs: cloudflareComputerFileSystem(workspace.fs, { allowedRoots: ["/workspace"] }),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
With the write tools. `createFsTools()` from `@better-fs-tools/write` gives them and the read tool one store, one digest, and one lock manager. Its default digest, `sha256Digest()`, is plain JavaScript, so it runs in a Worker:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import type { Workspace } from "@cloudflare/computer";
|
|
33
|
+
import { cloudflareComputerFileSystem } from "@better-fs-tools/cloudflare-computer";
|
|
34
|
+
import { createFsTools } from "@better-fs-tools/write";
|
|
35
|
+
|
|
36
|
+
// One store, one sha256Digest(), and one lock manager. createFsTools needs no
|
|
37
|
+
// Node module, so it runs in a Worker.
|
|
38
|
+
export function computerTools(workspace: Workspace) {
|
|
39
|
+
const fs = cloudflareComputerFileSystem(workspace.fs, { allowedRoots: ["/workspace"] });
|
|
40
|
+
const { read, edit, write } = createFsTools({ fs });
|
|
41
|
+
return { read, edit, write };
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What the filesystem does
|
|
46
|
+
|
|
47
|
+
- `cloudflareComputerFileSystem(workspace.fs, options)` takes the shared `FileSystemRootOptions` from `@better-fs-tools/fs`: `allowedRoots` (required), `denyRoots`, `cwd`, `id`, `symlinks`, and `identity`. It refuses every path outside the allowed roots, or inside a deny root, before it calls the workspace. A deny root gives `dangerous-path`, as in every adapter.
|
|
48
|
+
- `cwd` must be absolute and defaults to the first allowed root. Relative paths and relative roots resolve against it. Display paths are relative to `cwd`, and absolute outside it. `id` defaults to `"cloudflare-computer"`. `symlinks` can only be `"reject"` and `identity` only `"none"`. Reads stream, so there is no `maxBufferedBytes`.
|
|
49
|
+
- The filesystem exposes the resolved `cwd`, `allowedRoots`, `denyRoots`, `symlinks`, and `identity`.
|
|
50
|
+
- It walks each path with `lstat` from `/`, so a symlink above the root, a symlinked root, parent, or file is refused before any byte is read.
|
|
51
|
+
- It streams the file that `readFile` returns. `open()` starts the `readFile` stream, so the request reaches the backend before the read tool's `authorize` runs, but no chunk is read until `authorize` allows it. If a denied read must not reach the backend, deny it in the adapter with `denyRoots` or `allowedRoots`.
|
|
52
|
+
- `verify()` compares size and modification time, so results have a `weak-identity` note.
|
|
53
|
+
- `list()` lists directories, so suggestions and `directoryListing()` work.
|
|
54
|
+
|
|
55
|
+
## How it writes
|
|
56
|
+
|
|
57
|
+
- `stat()`, `write()`, and `remove()` use `lstat`, `writeFile`, `mkdir`, and `rm`. They check the roots and refuse every symlink on the path before any write, as `open()` does.
|
|
58
|
+
- `writeFile`, `mkdir`, and `rm` are optional in `CloudflareComputerFileSystemLike`. They are checked when a write runs, not when the filesystem is built. A read-only wrapper backs the read tool, and a write to it gives `unsupported`, which the tools report as `UNSUPPORTED_BACKEND`.
|
|
59
|
+
- `writeCapabilities` is `{ atomic: true, compareAndSwap: false, preserveMode: true }`. `writeFile` runs in one transaction. It has no version check, so the adapter checks the precondition with a fresh `lstat` just before the write, and the write tools add a `no-compare-and-swap` note. A create passes `exclusive: true`, so two creators cannot both win.
|
|
60
|
+
- `writeFile` sets the mode to `0o644` unless it gets one, also on a replace. The adapter passes the mode that `lstat` reported, so a replace keeps it. `stat()` reports the mode.
|
|
61
|
+
- There is no `stage()`: Computer has no rename. `apply_patch` writes each file in turn, and undoes them on a failure.
|
|
62
|
+
- The version is the size and the modification time in milliseconds. The write tools also compare the content hash of what the model read, so a same-size change after the read is still `STALE`.
|
|
63
|
+
- `EEXIST` gives `exists`, `EROFS` gives `read-only`, and `ENOSPC` and `EDQUOT` give `no-space`.
|
|
64
|
+
|
|
65
|
+
## Links
|
|
66
|
+
|
|
67
|
+
- `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
|
|
68
|
+
- [`@better-fs-tools/read`](https://www.npmjs.com/package/@better-fs-tools/read): every read option
|
|
69
|
+
- [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write): the write tools
|
|
70
|
+
- [`@better-fs-tools/fs`](https://www.npmjs.com/package/@better-fs-tools/fs): the filesystem contract
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { FileSystemRootOptions, FileSystemRootSettings, ListOptions, ListOutcome, MutateOptions, MutationOutcome, WritableFileSystem } from "@better-fs-tools/fs";
|
|
2
|
+
/**
|
|
3
|
+
* A file's metadata as Computer's `stat` and `lstat` return it. A structural
|
|
4
|
+
* subset of `WorkspaceStatResult`.
|
|
5
|
+
*/
|
|
6
|
+
export interface CloudflareComputerStat {
|
|
7
|
+
name: string;
|
|
8
|
+
/** Epoch milliseconds. */
|
|
9
|
+
mtime: number;
|
|
10
|
+
size: number;
|
|
11
|
+
isFile: boolean;
|
|
12
|
+
isDirectory: boolean;
|
|
13
|
+
isSymbolicLink: boolean;
|
|
14
|
+
/** Permission bits. When absent, `stat()` reports a `null` mode. */
|
|
15
|
+
mode?: number;
|
|
16
|
+
}
|
|
17
|
+
/** A structural subset of Computer's `WorkspaceDirentResult`. */
|
|
18
|
+
export interface CloudflareComputerDirent {
|
|
19
|
+
name: string;
|
|
20
|
+
parentPath: string;
|
|
21
|
+
isFile: boolean;
|
|
22
|
+
isDirectory: boolean;
|
|
23
|
+
isSymbolicLink: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The filesystem methods this adapter uses. `WorkspaceFilesystem` and
|
|
27
|
+
* `WorkspaceFilesystemStub` from `@cloudflare/computer@0.2.1` both satisfy it.
|
|
28
|
+
*
|
|
29
|
+
* The write methods are optional: a filesystem without them still serves
|
|
30
|
+
* reads, and a write reports `unsupported`.
|
|
31
|
+
*/
|
|
32
|
+
export interface CloudflareComputerFileSystemLike {
|
|
33
|
+
readFile(path: string): Promise<ReadableStream<Uint8Array>>;
|
|
34
|
+
stat(path: string): Promise<CloudflareComputerStat>;
|
|
35
|
+
lstat(path: string): Promise<CloudflareComputerStat>;
|
|
36
|
+
readdir(path: string, options?: {
|
|
37
|
+
limit?: number;
|
|
38
|
+
offset?: number;
|
|
39
|
+
}): Promise<CloudflareComputerDirent[]>;
|
|
40
|
+
/** With `exclusive`, fails with `EEXIST` when the file exists. */
|
|
41
|
+
writeFile?(path: string, content: Uint8Array, options?: {
|
|
42
|
+
mode?: number;
|
|
43
|
+
exclusive?: boolean;
|
|
44
|
+
}): Promise<void>;
|
|
45
|
+
mkdir?(path: string, options?: {
|
|
46
|
+
recursive?: boolean;
|
|
47
|
+
mode?: number;
|
|
48
|
+
}): Promise<void>;
|
|
49
|
+
rm?(path: string, options?: {
|
|
50
|
+
recursive?: boolean;
|
|
51
|
+
force?: boolean;
|
|
52
|
+
}): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The shared root options, with workspace paths. `allowedRoots` holds absolute
|
|
56
|
+
* paths, or paths relative to `cwd`. `cwd` must be absolute and defaults to the
|
|
57
|
+
* first allowed root. `symlinks` can only be `"reject"` and `identity` only
|
|
58
|
+
* `"none"`. `id` defaults to `"cloudflare-computer"`. Reads stream, so there is
|
|
59
|
+
* no `maxBufferedBytes`.
|
|
60
|
+
*/
|
|
61
|
+
export interface CloudflareComputerFileSystemOptions extends FileSystemRootOptions<"reject", "none"> {
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A writable filesystem over a Cloudflare Computer workspace. Writes are atomic
|
|
65
|
+
* and keep the file mode. There is no compare-and-swap and no `stage()`.
|
|
66
|
+
*/
|
|
67
|
+
export interface CloudflareComputerFileSystem extends WritableFileSystem, FileSystemRootSettings<"reject", "none"> {
|
|
68
|
+
list(path: string, options: ListOptions): Promise<ListOutcome>;
|
|
69
|
+
remove(path: string, options: MutateOptions): Promise<MutationOutcome>;
|
|
70
|
+
}
|
package/dist/contract.js
ADDED
|
File without changes
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CloudflareComputerFileSystem, CloudflareComputerFileSystemLike, CloudflareComputerFileSystemOptions } from "./contract.ts";
|
|
2
|
+
export type { CloudflareComputerDirent, CloudflareComputerFileSystem, CloudflareComputerFileSystemLike, CloudflareComputerFileSystemOptions, CloudflareComputerStat, } from "./contract.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Adapt a Cloudflare Computer workspace filesystem to the filesystem contract.
|
|
5
|
+
*
|
|
6
|
+
* @experimental Cloudflare Computer is preview software and this adapter is
|
|
7
|
+
* pinned to the `0.2.1` declarations.
|
|
8
|
+
*/
|
|
9
|
+
export declare function cloudflareComputerFileSystem(workspaceFs: CloudflareComputerFileSystemLike, options: CloudflareComputerFileSystemOptions): CloudflareComputerFileSystem;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { posixPaths } from "@better-fs-tools/fs";
|
|
2
|
+
import { authorize, call, cancelWithoutWaiting, pathsFromSlashToTarget, displayPath, inspect, invalid, notAFile, refuse, resolveRoots, toFileSystemError, validateDirent, validateFileSystem, validateStream, } from "./policy.js";
|
|
3
|
+
import { COMPUTER_WRITE_CAPABILITIES, computerVersion, computerWrites } from "./write.js";
|
|
4
|
+
/**
|
|
5
|
+
* Adapt a Cloudflare Computer workspace filesystem to the filesystem contract.
|
|
6
|
+
*
|
|
7
|
+
* @experimental Cloudflare Computer is preview software and this adapter is
|
|
8
|
+
* pinned to the `0.2.1` declarations.
|
|
9
|
+
*/
|
|
10
|
+
export function cloudflareComputerFileSystem(workspaceFs, options) {
|
|
11
|
+
validateFileSystem(workspaceFs);
|
|
12
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
13
|
+
throw new TypeError("cloudflareComputerFileSystem options must be an object");
|
|
14
|
+
}
|
|
15
|
+
const roots = resolveRoots(options);
|
|
16
|
+
const { cwd } = roots;
|
|
17
|
+
const id = options.id ?? "cloudflare-computer";
|
|
18
|
+
if (typeof id !== "string" || id === "")
|
|
19
|
+
throw new TypeError("id must be a non-empty string");
|
|
20
|
+
const refuseSymlinksAlongPath = async (target) => {
|
|
21
|
+
for (const component of pathsFromSlashToTarget(target)) {
|
|
22
|
+
const stat = await inspect(workspaceFs, "lstat", component, component !== target);
|
|
23
|
+
if (stat.isSymbolicLink) {
|
|
24
|
+
throw refuse({
|
|
25
|
+
reason: "denied",
|
|
26
|
+
detail: component === target
|
|
27
|
+
? "the path is a symbolic link and this adapter refuses symlinks"
|
|
28
|
+
: "a path component is a symbolic link and this adapter refuses symlinks",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (component !== target && !stat.isDirectory) {
|
|
32
|
+
throw refuse({ reason: "not-found", detail: "a path component is not a directory" });
|
|
33
|
+
}
|
|
34
|
+
if (component === target)
|
|
35
|
+
return stat;
|
|
36
|
+
}
|
|
37
|
+
throw refuse(notAFile("directory", cwd, target));
|
|
38
|
+
};
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
id,
|
|
41
|
+
capabilities: Object.freeze({ streaming: true, identity: false }),
|
|
42
|
+
paths: posixPaths,
|
|
43
|
+
cwd,
|
|
44
|
+
allowedRoots: roots.allowedRoots,
|
|
45
|
+
denyRoots: roots.denyRoots,
|
|
46
|
+
symlinks: "reject",
|
|
47
|
+
identity: "none",
|
|
48
|
+
writeCapabilities: COMPUTER_WRITE_CAPABILITIES,
|
|
49
|
+
...computerWrites(workspaceFs, roots),
|
|
50
|
+
async open(requested, callOptions = {}) {
|
|
51
|
+
const signal = callOptions.signal;
|
|
52
|
+
if (signal?.aborted)
|
|
53
|
+
return { ok: false, error: { reason: "aborted" } };
|
|
54
|
+
try {
|
|
55
|
+
const { target } = authorize(roots, requested);
|
|
56
|
+
await refuseSymlinksAlongPath(target);
|
|
57
|
+
const stat = await inspect(workspaceFs, "stat", target, false);
|
|
58
|
+
if (!stat.isFile) {
|
|
59
|
+
throw refuse(notAFile(stat.isDirectory ? "directory" : "other", cwd, target));
|
|
60
|
+
}
|
|
61
|
+
if (signal?.aborted)
|
|
62
|
+
throw refuse({ reason: "aborted" });
|
|
63
|
+
/* Exactly one argument: other overloads buffer or return a string. */
|
|
64
|
+
const stream = await call(workspaceFs.readFile(target), "readFile");
|
|
65
|
+
validateStream(stream);
|
|
66
|
+
if (signal?.aborted) {
|
|
67
|
+
cancelWithoutWaiting(stream);
|
|
68
|
+
throw refuse({ reason: "aborted" });
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, file: computerOpenFile(workspaceFs, cwd, target, stat, stream) };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return { ok: false, error: toFileSystemError(error) };
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
async list(requested, listOptions) {
|
|
77
|
+
if (listOptions.signal?.aborted)
|
|
78
|
+
return { ok: false, error: { reason: "aborted" } };
|
|
79
|
+
const limit = listOptions.limit;
|
|
80
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: { reason: "io", detail: "list limit must be a positive safe integer" },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const { target } = authorize(roots, requested);
|
|
88
|
+
const stat = target === "/" ? null : await refuseSymlinksAlongPath(target);
|
|
89
|
+
if (stat !== null && !stat.isDirectory) {
|
|
90
|
+
throw refuse({ reason: "not-found", detail: "not a directory" });
|
|
91
|
+
}
|
|
92
|
+
const oneMoreThanLimit = limit + 1;
|
|
93
|
+
const listed = await call(workspaceFs.readdir(target, { limit: oneMoreThanLimit, offset: 0 }), "readdir");
|
|
94
|
+
if (!Array.isArray(listed))
|
|
95
|
+
throw invalid("readdir returned a non-array", "readdir");
|
|
96
|
+
const entries = [];
|
|
97
|
+
for (const value of listed.slice(0, limit)) {
|
|
98
|
+
entries.push(validateDirent(value, target));
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, entries, truncated: listed.length > limit };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
return { ok: false, error: toFileSystemError(error) };
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function computerOpenFile(workspaceFs, cwd, target, stat, stream) {
|
|
109
|
+
let consumed = false;
|
|
110
|
+
let closed = false;
|
|
111
|
+
let reader = null;
|
|
112
|
+
/* Not awaited: an RPC stub's cancel() may never settle once its peer is gone. */
|
|
113
|
+
const release = () => {
|
|
114
|
+
closed = true;
|
|
115
|
+
const held = reader;
|
|
116
|
+
reader = null;
|
|
117
|
+
try {
|
|
118
|
+
if (held === null) {
|
|
119
|
+
void Promise.resolve(stream.cancel()).catch(() => { });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
void Promise.resolve(held.cancel()).catch(() => { });
|
|
123
|
+
/* A spec-compliant reader releases on cancel, an RPC stub may not. */
|
|
124
|
+
try {
|
|
125
|
+
held.releaseLock();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* Best effort. */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* Best effort. */
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
info: {
|
|
137
|
+
resolvedPath: target,
|
|
138
|
+
displayPath: displayPath(cwd, target),
|
|
139
|
+
size: stat.size,
|
|
140
|
+
mtimeMs: stat.mtime,
|
|
141
|
+
identity: null,
|
|
142
|
+
mimeType: null,
|
|
143
|
+
version: computerVersion(stat),
|
|
144
|
+
},
|
|
145
|
+
bytes() {
|
|
146
|
+
if (consumed)
|
|
147
|
+
throw new TypeError("cloudflare computer byte source is single-use");
|
|
148
|
+
consumed = true;
|
|
149
|
+
return {
|
|
150
|
+
[Symbol.asyncIterator]() {
|
|
151
|
+
return {
|
|
152
|
+
async next() {
|
|
153
|
+
if (closed)
|
|
154
|
+
return { done: true, value: undefined };
|
|
155
|
+
const active = reader ?? (reader = acquireValidReader(stream));
|
|
156
|
+
const item = await active.read();
|
|
157
|
+
if (item === null || typeof item !== "object" || typeof item.done !== "boolean") {
|
|
158
|
+
throw new TypeError("Cloudflare Computer's reader returned an invalid result");
|
|
159
|
+
}
|
|
160
|
+
if (item.done) {
|
|
161
|
+
release();
|
|
162
|
+
return { done: true, value: undefined };
|
|
163
|
+
}
|
|
164
|
+
if (!(item.value instanceof Uint8Array)) {
|
|
165
|
+
throw new TypeError("Cloudflare Computer's stream yielded a non-Uint8Array chunk");
|
|
166
|
+
}
|
|
167
|
+
return { done: false, value: item.value };
|
|
168
|
+
},
|
|
169
|
+
async return() {
|
|
170
|
+
release();
|
|
171
|
+
return { done: true, value: undefined };
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
async verify() {
|
|
178
|
+
try {
|
|
179
|
+
const current = await inspect(workspaceFs, "lstat", target, false);
|
|
180
|
+
const changed = current.isFile !== stat.isFile ||
|
|
181
|
+
current.isDirectory !== stat.isDirectory ||
|
|
182
|
+
current.isSymbolicLink !== stat.isSymbolicLink ||
|
|
183
|
+
current.size !== stat.size ||
|
|
184
|
+
current.mtime !== stat.mtime;
|
|
185
|
+
return { ok: true, changed };
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
const mapped = toFileSystemError(error);
|
|
189
|
+
if (mapped.reason === "not-found")
|
|
190
|
+
return { ok: true, changed: true };
|
|
191
|
+
return { ok: false, error: mapped };
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
async close() {
|
|
195
|
+
consumed = true;
|
|
196
|
+
if (closed)
|
|
197
|
+
return;
|
|
198
|
+
release();
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function acquireValidReader(stream) {
|
|
203
|
+
const reader = stream.getReader();
|
|
204
|
+
if (reader === null ||
|
|
205
|
+
typeof reader !== "object" ||
|
|
206
|
+
typeof reader.read !== "function" ||
|
|
207
|
+
typeof reader.cancel !== "function") {
|
|
208
|
+
throw new TypeError("Cloudflare Computer's stream returned an unusable reader");
|
|
209
|
+
}
|
|
210
|
+
return reader;
|
|
211
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DirectoryEntry, FileSystemError, FileSystemRootOptions, NotAFileError } from "@better-fs-tools/fs";
|
|
2
|
+
import type { CloudflareComputerFileSystemLike, CloudflareComputerStat } from "./contract.ts";
|
|
3
|
+
export interface Roots {
|
|
4
|
+
readonly cwd: string;
|
|
5
|
+
readonly allowedRoots: readonly string[];
|
|
6
|
+
readonly denyRoots: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function resolveRoots(options: FileSystemRootOptions<"reject", "none">): Roots;
|
|
9
|
+
export declare function pathsFromSlashToTarget(target: string): string[];
|
|
10
|
+
export declare function authorize(roots: Roots, requested: unknown): {
|
|
11
|
+
readonly root: string;
|
|
12
|
+
readonly target: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function displayPath(cwd: string, target: string): string;
|
|
15
|
+
export declare function notAFile(kind: NotAFileError["kind"], cwd: string, target: string): NotAFileError;
|
|
16
|
+
export declare function inspect(workspaceFs: CloudflareComputerFileSystemLike, method: "stat" | "lstat", path: string, isAncestorComponent: boolean): Promise<CloudflareComputerStat>;
|
|
17
|
+
export declare function validateStat(value: unknown, path: string, phase: string): CloudflareComputerStat;
|
|
18
|
+
export declare function validateDirent(value: unknown, parent: string): DirectoryEntry;
|
|
19
|
+
export declare function typeFlags(entry: Record<string, unknown>, phase: string): [boolean, boolean, boolean];
|
|
20
|
+
export declare function validateStream(value: unknown): asserts value is ReadableStream<Uint8Array>;
|
|
21
|
+
export declare function cancelWithoutWaiting(stream: ReadableStream<Uint8Array>): void;
|
|
22
|
+
export declare function call<T>(pending: Promise<T> | T, phase: string): Promise<T>;
|
|
23
|
+
export declare function mapBackendError(error: unknown, phase: string): FileSystemError;
|
|
24
|
+
export declare function boundedCode(error: unknown): string;
|
|
25
|
+
export declare class AdapterRefusal extends Error {
|
|
26
|
+
readonly error: FileSystemError;
|
|
27
|
+
constructor(error: FileSystemError);
|
|
28
|
+
}
|
|
29
|
+
export declare function refuse(error: FileSystemError): AdapterRefusal;
|
|
30
|
+
export declare function invalid(detail: string, phase: string): AdapterRefusal;
|
|
31
|
+
export declare function toFileSystemError(error: unknown): FileSystemError;
|
|
32
|
+
export declare function validateFileSystem(workspaceFs: CloudflareComputerFileSystemLike): void;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { containsPosix, posixPaths, resolvePosix } from "@better-fs-tools/fs";
|
|
2
|
+
export function resolveRoots(options) {
|
|
3
|
+
const base = options.cwd === undefined ? "/" : absolutePath(options.cwd, "cwd");
|
|
4
|
+
const allowed = options.allowedRoots;
|
|
5
|
+
if (!Array.isArray(allowed) || allowed.length === 0) {
|
|
6
|
+
throw new TypeError("allowedRoots must be a non-empty array");
|
|
7
|
+
}
|
|
8
|
+
const allowedRoots = Object.freeze(allowed.map((root) => configuredRoot(base, root)));
|
|
9
|
+
if (options.denyRoots !== undefined && !Array.isArray(options.denyRoots)) {
|
|
10
|
+
throw new TypeError("denyRoots must be an array");
|
|
11
|
+
}
|
|
12
|
+
const denyRoots = Object.freeze((options.denyRoots ?? []).map((root) => configuredRoot(base, root)));
|
|
13
|
+
if (options.symlinks !== undefined && options.symlinks !== "reject") {
|
|
14
|
+
throw new TypeError('symlinks must be "reject": this adapter refuses every symbolic link');
|
|
15
|
+
}
|
|
16
|
+
if (options.identity !== undefined && options.identity !== "none") {
|
|
17
|
+
throw new TypeError('identity must be "none": a Computer inode is not a stable identity');
|
|
18
|
+
}
|
|
19
|
+
return { cwd: options.cwd === undefined ? allowedRoots[0] : base, allowedRoots, denyRoots };
|
|
20
|
+
}
|
|
21
|
+
function absolutePath(value, label) {
|
|
22
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.includes("\0")) {
|
|
23
|
+
throw new TypeError(`${label} must be an absolute POSIX path without NUL`);
|
|
24
|
+
}
|
|
25
|
+
return resolvePosix("/", value);
|
|
26
|
+
}
|
|
27
|
+
function configuredRoot(base, value) {
|
|
28
|
+
if (typeof value !== "string" || value === "" || value.includes("\0")) {
|
|
29
|
+
throw new TypeError("filesystem roots must be non-empty POSIX paths without NUL");
|
|
30
|
+
}
|
|
31
|
+
return resolvePosix(base, value);
|
|
32
|
+
}
|
|
33
|
+
export function pathsFromSlashToTarget(target) {
|
|
34
|
+
const found = [];
|
|
35
|
+
let current = "";
|
|
36
|
+
for (const segment of target.split("/")) {
|
|
37
|
+
if (segment === "")
|
|
38
|
+
continue;
|
|
39
|
+
current = `${current}/${segment}`;
|
|
40
|
+
found.push(current);
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
export function authorize(roots, requested) {
|
|
45
|
+
if (typeof requested !== "string" || requested === "") {
|
|
46
|
+
throw refuse({ reason: "io", detail: "the requested path must be a non-empty string" });
|
|
47
|
+
}
|
|
48
|
+
if (requested.includes("\0")) {
|
|
49
|
+
throw refuse({ reason: "dangerous-path", detail: "the path contains NUL" });
|
|
50
|
+
}
|
|
51
|
+
const target = resolvePosix(roots.cwd, requested);
|
|
52
|
+
const denied = roots.denyRoots.find((root) => containsPosix(root, target));
|
|
53
|
+
if (denied !== undefined)
|
|
54
|
+
throw refuse({ reason: "dangerous-path", detail: denied });
|
|
55
|
+
const innermostRoot = innermostAllowedRoot(roots, target);
|
|
56
|
+
if (innermostRoot === null)
|
|
57
|
+
throw refuse({ reason: "outside-allowed-roots" });
|
|
58
|
+
return { root: innermostRoot, target };
|
|
59
|
+
}
|
|
60
|
+
function innermostAllowedRoot(roots, target) {
|
|
61
|
+
let root = null;
|
|
62
|
+
for (const candidate of roots.allowedRoots) {
|
|
63
|
+
if (containsPosix(candidate, target) && (root === null || candidate.length > root.length)) {
|
|
64
|
+
root = candidate;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return root;
|
|
68
|
+
}
|
|
69
|
+
export function displayPath(cwd, target) {
|
|
70
|
+
if (!containsPosix(cwd, target))
|
|
71
|
+
return target;
|
|
72
|
+
if (target === cwd)
|
|
73
|
+
return posixPaths.basename(target) || "/";
|
|
74
|
+
return target.slice(cwd === "/" ? 1 : cwd.length + 1);
|
|
75
|
+
}
|
|
76
|
+
export function notAFile(kind, cwd, target) {
|
|
77
|
+
return {
|
|
78
|
+
reason: "not-a-file",
|
|
79
|
+
kind,
|
|
80
|
+
target: { resolvedPath: target, displayPath: displayPath(cwd, target) },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export async function inspect(workspaceFs, method, path, isAncestorComponent) {
|
|
84
|
+
let value;
|
|
85
|
+
try {
|
|
86
|
+
value = await call(workspaceFs[method](path), method);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (isAncestorComponent &&
|
|
90
|
+
error instanceof AdapterRefusal &&
|
|
91
|
+
error.error.reason === "not-found") {
|
|
92
|
+
throw refuse({ ...error.error, detail: "a path component does not exist" });
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
return validateStat(value, path, method);
|
|
97
|
+
}
|
|
98
|
+
export function validateStat(value, path, phase) {
|
|
99
|
+
if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) {
|
|
100
|
+
throw invalid(`${phase} did not return an entry`, phase);
|
|
101
|
+
}
|
|
102
|
+
const entry = value;
|
|
103
|
+
if (typeof entry.name !== "string" || entry.name === "" || entry.name.includes("\0")) {
|
|
104
|
+
throw invalid(`${phase} returned an entry without a usable name`, phase);
|
|
105
|
+
}
|
|
106
|
+
/* Computer's name for the root "/" is unconfirmed, so it is not compared. */
|
|
107
|
+
const expected = path === "/" ? null : posixPaths.basename(path);
|
|
108
|
+
if (expected !== null && entry.name !== expected) {
|
|
109
|
+
throw invalid(`${phase} returned an entry for a different path`, phase);
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
|
|
112
|
+
throw invalid(`${phase} returned an unusable size`, phase);
|
|
113
|
+
}
|
|
114
|
+
if (typeof entry.mtime !== "number" || !Number.isFinite(entry.mtime)) {
|
|
115
|
+
throw invalid(`${phase} returned an unusable modification time`, phase);
|
|
116
|
+
}
|
|
117
|
+
const [isFile, isDirectory, isSymbolicLink] = typeFlags(entry, phase);
|
|
118
|
+
/* An unusable mode is dropped, not refused, so reads keep working. */
|
|
119
|
+
const mode = Number.isSafeInteger(entry.mode) && entry.mode >= 0
|
|
120
|
+
? entry.mode & 0o7777
|
|
121
|
+
: undefined;
|
|
122
|
+
return {
|
|
123
|
+
name: entry.name,
|
|
124
|
+
size: entry.size,
|
|
125
|
+
mtime: entry.mtime,
|
|
126
|
+
isFile,
|
|
127
|
+
isDirectory,
|
|
128
|
+
isSymbolicLink,
|
|
129
|
+
...(mode === undefined ? {} : { mode }),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export function validateDirent(value, parent) {
|
|
133
|
+
if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) {
|
|
134
|
+
throw invalid("readdir returned an entry that is not an object", "readdir");
|
|
135
|
+
}
|
|
136
|
+
const entry = value;
|
|
137
|
+
if (typeof entry.parentPath !== "string" || resolvePosix("/", entry.parentPath) !== parent) {
|
|
138
|
+
throw invalid("readdir returned an entry outside the listed directory", "readdir");
|
|
139
|
+
}
|
|
140
|
+
const name = entry.name;
|
|
141
|
+
if (typeof name !== "string" ||
|
|
142
|
+
name === "" ||
|
|
143
|
+
name === "." ||
|
|
144
|
+
name === ".." ||
|
|
145
|
+
name.includes("/") ||
|
|
146
|
+
name.includes("\0")) {
|
|
147
|
+
throw invalid("readdir returned an entry without a usable name", "readdir");
|
|
148
|
+
}
|
|
149
|
+
const [isFile, isDirectory] = typeFlags(entry, "readdir");
|
|
150
|
+
return { name, type: isFile ? "file" : isDirectory ? "directory" : "other" };
|
|
151
|
+
}
|
|
152
|
+
export function typeFlags(entry, phase) {
|
|
153
|
+
for (const flag of ["isFile", "isDirectory", "isSymbolicLink"]) {
|
|
154
|
+
if (typeof entry[flag] !== "boolean") {
|
|
155
|
+
throw invalid(`${phase} returned an unusable ${flag} flag`, phase);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const flags = [
|
|
159
|
+
entry.isFile,
|
|
160
|
+
entry.isDirectory,
|
|
161
|
+
entry.isSymbolicLink,
|
|
162
|
+
];
|
|
163
|
+
if (flags.filter(Boolean).length !== 1) {
|
|
164
|
+
throw invalid(`${phase} did not identify exactly one entry type`, phase);
|
|
165
|
+
}
|
|
166
|
+
return flags;
|
|
167
|
+
}
|
|
168
|
+
export function validateStream(value) {
|
|
169
|
+
if (value === null ||
|
|
170
|
+
typeof value !== "object" ||
|
|
171
|
+
typeof value.getReader !== "function" ||
|
|
172
|
+
typeof value.cancel !== "function") {
|
|
173
|
+
throw invalid("readFile did not return a ReadableStream", "readFile");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function cancelWithoutWaiting(stream) {
|
|
177
|
+
try {
|
|
178
|
+
void Promise.resolve(stream.cancel()).catch(() => { });
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
/* Best effort. */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export async function call(pending, phase) {
|
|
185
|
+
try {
|
|
186
|
+
return await pending;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error instanceof AdapterRefusal)
|
|
190
|
+
throw error;
|
|
191
|
+
throw refuse(mapBackendError(error, phase));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export function mapBackendError(error, phase) {
|
|
195
|
+
const code = boundedCode(error);
|
|
196
|
+
const cause = { code, phase };
|
|
197
|
+
if (code === "ABORT_ERR")
|
|
198
|
+
return { reason: "aborted", cause };
|
|
199
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
200
|
+
return { reason: "not-found", cause };
|
|
201
|
+
if (code === "EISDIR")
|
|
202
|
+
return { reason: "not-a-file", kind: "directory", target: null, cause };
|
|
203
|
+
if (code === "EACCES" || code === "EPERM")
|
|
204
|
+
return { reason: "permission-denied", cause };
|
|
205
|
+
if (code === "ELOOP")
|
|
206
|
+
return { reason: "denied", detail: "too many symbolic links", cause };
|
|
207
|
+
if (code === "EINVAL") {
|
|
208
|
+
return { reason: "dangerous-path", detail: "the backend rejected the path", cause };
|
|
209
|
+
}
|
|
210
|
+
return { reason: "io", detail: phase, cause };
|
|
211
|
+
}
|
|
212
|
+
export function boundedCode(error) {
|
|
213
|
+
if (error !== null && typeof error === "object" && "code" in error) {
|
|
214
|
+
const code = error.code;
|
|
215
|
+
if (typeof code === "string" && /^[A-Za-z0-9_.-]{1,40}$/u.test(code))
|
|
216
|
+
return code;
|
|
217
|
+
}
|
|
218
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
219
|
+
return "ABORT_ERR";
|
|
220
|
+
return "UNKNOWN";
|
|
221
|
+
}
|
|
222
|
+
export class AdapterRefusal extends Error {
|
|
223
|
+
error;
|
|
224
|
+
constructor(error) {
|
|
225
|
+
super(error.reason);
|
|
226
|
+
this.error = error;
|
|
227
|
+
this.name = "AdapterRefusal";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
export function refuse(error) {
|
|
231
|
+
return new AdapterRefusal(error);
|
|
232
|
+
}
|
|
233
|
+
export function invalid(detail, phase) {
|
|
234
|
+
return new AdapterRefusal({
|
|
235
|
+
reason: "io",
|
|
236
|
+
detail,
|
|
237
|
+
cause: { code: "INVALID_BACKEND_RESULT", phase },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
export function toFileSystemError(error) {
|
|
241
|
+
if (error instanceof AdapterRefusal)
|
|
242
|
+
return error.error;
|
|
243
|
+
return {
|
|
244
|
+
reason: "io",
|
|
245
|
+
detail: "the Cloudflare Computer filesystem adapter failed",
|
|
246
|
+
cause: { code: boundedCode(error) },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
export function validateFileSystem(workspaceFs) {
|
|
250
|
+
if (workspaceFs === null || typeof workspaceFs !== "object" || Array.isArray(workspaceFs)) {
|
|
251
|
+
throw new TypeError("cloudflareComputerFileSystem needs a Cloudflare Computer workspace filesystem");
|
|
252
|
+
}
|
|
253
|
+
const methods = ["readFile", "stat", "lstat", "readdir"];
|
|
254
|
+
for (const method of methods) {
|
|
255
|
+
if (typeof workspaceFs[method] !== "function") {
|
|
256
|
+
throw new TypeError(`the Computer workspace filesystem must implement ${method}()`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
package/dist/write.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MutateOptions, MutationOutcome, OpenOptions, StatOutcome, WriteCapabilities, WriteOptions } from "@better-fs-tools/fs";
|
|
2
|
+
import type { CloudflareComputerFileSystemLike, CloudflareComputerStat } from "./contract.ts";
|
|
3
|
+
import type { Roots } from "./policy.ts";
|
|
4
|
+
export declare const COMPUTER_WRITE_CAPABILITIES: WriteCapabilities;
|
|
5
|
+
export interface ComputerWrites {
|
|
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 computerVersion(entry: CloudflareComputerStat): string;
|
|
11
|
+
export declare function computerWrites(workspaceFs: CloudflareComputerFileSystemLike, roots: Roots): ComputerWrites;
|
package/dist/write.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { containsPosix } from "@better-fs-tools/fs";
|
|
2
|
+
import { AdapterRefusal, authorize, boundedCode, pathsFromSlashToTarget, displayPath, inspect, mapBackendError, notAFile, refuse, toFileSystemError, } from "./policy.js";
|
|
3
|
+
export const COMPUTER_WRITE_CAPABILITIES = Object.freeze({
|
|
4
|
+
atomic: true,
|
|
5
|
+
compareAndSwap: false,
|
|
6
|
+
preserveMode: true,
|
|
7
|
+
});
|
|
8
|
+
export function computerVersion(entry) {
|
|
9
|
+
return `computer:${entry.size}:${entry.mtime}`;
|
|
10
|
+
}
|
|
11
|
+
export function computerWrites(workspaceFs, roots) {
|
|
12
|
+
const { cwd } = roots;
|
|
13
|
+
const locate = async (root, target) => {
|
|
14
|
+
const missing = [];
|
|
15
|
+
for (const component of pathsFromSlashToTarget(target)) {
|
|
16
|
+
if (missing.length > 0) {
|
|
17
|
+
missing.push(component);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const entry = await lstatOrNull(component);
|
|
21
|
+
if (entry === null) {
|
|
22
|
+
if (containsPosix(component, root)) {
|
|
23
|
+
throw refuse({ reason: "not-found", detail: "the root does not exist" });
|
|
24
|
+
}
|
|
25
|
+
missing.push(component);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (entry.isSymbolicLink) {
|
|
29
|
+
throw refuse({
|
|
30
|
+
reason: "denied",
|
|
31
|
+
detail: component === target
|
|
32
|
+
? "the path is a symbolic link and this adapter refuses symlinks"
|
|
33
|
+
: "a path component is a symbolic link and this adapter refuses symlinks",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (component === target) {
|
|
37
|
+
if (!entry.isFile) {
|
|
38
|
+
throw refuse(notAFile(entry.isDirectory ? "directory" : "other", cwd, target));
|
|
39
|
+
}
|
|
40
|
+
return { exists: true, entry };
|
|
41
|
+
}
|
|
42
|
+
if (!entry.isDirectory) {
|
|
43
|
+
throw refuse({ reason: "not-found", detail: "a path component is not a directory" });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (missing.length === 0)
|
|
47
|
+
throw refuse(notAFile("directory", cwd, target));
|
|
48
|
+
return { exists: false, missingDirectories: missing.slice(0, -1) };
|
|
49
|
+
};
|
|
50
|
+
const lstatOrNull = async (path) => {
|
|
51
|
+
try {
|
|
52
|
+
return await inspect(workspaceFs, "lstat", path, false);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof AdapterRefusal && error.error.reason === "not-found")
|
|
56
|
+
return null;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const fileStat = (target, located) => located.exists
|
|
61
|
+
? {
|
|
62
|
+
exists: true,
|
|
63
|
+
resolvedPath: target,
|
|
64
|
+
displayPath: displayPath(cwd, target),
|
|
65
|
+
size: located.entry.size,
|
|
66
|
+
mtimeMs: located.entry.mtime,
|
|
67
|
+
identity: null,
|
|
68
|
+
version: computerVersion(located.entry),
|
|
69
|
+
mode: located.entry.mode ?? null,
|
|
70
|
+
hardLinks: null,
|
|
71
|
+
}
|
|
72
|
+
: {
|
|
73
|
+
exists: false,
|
|
74
|
+
resolvedPath: target,
|
|
75
|
+
displayPath: displayPath(cwd, target),
|
|
76
|
+
missingDirectories: located.missingDirectories,
|
|
77
|
+
};
|
|
78
|
+
const makeDirectories = async (mkdir, directories) => {
|
|
79
|
+
const created = [];
|
|
80
|
+
for (const directory of directories) {
|
|
81
|
+
await mutate(mkdir(directory, { recursive: true }), "mkdir");
|
|
82
|
+
const entry = await lstatOrNull(directory);
|
|
83
|
+
if (entry?.isDirectory !== true) {
|
|
84
|
+
throw refuse({ reason: "denied", detail: "a created parent is not a directory" });
|
|
85
|
+
}
|
|
86
|
+
created.push(directory);
|
|
87
|
+
}
|
|
88
|
+
return created;
|
|
89
|
+
};
|
|
90
|
+
const mutated = (target, entry, created) => ({
|
|
91
|
+
resolvedPath: target,
|
|
92
|
+
displayPath: displayPath(cwd, target),
|
|
93
|
+
version: entry === null ? null : computerVersion(entry),
|
|
94
|
+
identity: null,
|
|
95
|
+
size: entry?.size ?? null,
|
|
96
|
+
createdDirectories: created,
|
|
97
|
+
atomic: COMPUTER_WRITE_CAPABILITIES.atomic,
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
async stat(requested, options = {}) {
|
|
101
|
+
if (options.signal?.aborted)
|
|
102
|
+
return { ok: false, error: { reason: "aborted" } };
|
|
103
|
+
try {
|
|
104
|
+
const { root, target } = authorize(roots, requested);
|
|
105
|
+
return { ok: true, stat: fileStat(target, await locate(root, target)) };
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return { ok: false, error: toFileSystemError(error) };
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
async write(requested, bytes, options) {
|
|
112
|
+
if (options.signal?.aborted)
|
|
113
|
+
return { ok: false, error: { reason: "aborted" } };
|
|
114
|
+
try {
|
|
115
|
+
const writeFile = requireMethod(workspaceFs, "writeFile");
|
|
116
|
+
const mkdir = requireMethod(workspaceFs, "mkdir");
|
|
117
|
+
const { root, target } = authorize(roots, requested);
|
|
118
|
+
const located = await locate(root, target);
|
|
119
|
+
refuseConflict(located, options.precondition);
|
|
120
|
+
if (!located.exists && located.missingDirectories.length > 0 && !options.createParents) {
|
|
121
|
+
throw refuse({ reason: "not-found", detail: "the parent directory does not exist" });
|
|
122
|
+
}
|
|
123
|
+
if (options.signal?.aborted)
|
|
124
|
+
throw refuse({ reason: "aborted" });
|
|
125
|
+
const created = located.exists
|
|
126
|
+
? []
|
|
127
|
+
: await makeDirectories(mkdir, located.missingDirectories);
|
|
128
|
+
/* Computer's writeFile resets the mode on every call unless one is passed. */
|
|
129
|
+
const mode = located.exists ? located.entry.mode : options.mode;
|
|
130
|
+
await mutate(writeFile(target, bytes, {
|
|
131
|
+
...(mode === undefined ? {} : { mode }),
|
|
132
|
+
...(options.precondition.kind === "absent" ? { exclusive: true } : {}),
|
|
133
|
+
}), "writeFile");
|
|
134
|
+
const after = await lstatOrNull(target);
|
|
135
|
+
if (after?.isFile !== true) {
|
|
136
|
+
throw refuse({ reason: "io", detail: "the file was not there after the write" });
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, file: mutated(target, after, created) };
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
return { ok: false, error: toMutationError(error) };
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
async remove(requested, options) {
|
|
145
|
+
if (options.signal?.aborted)
|
|
146
|
+
return { ok: false, error: { reason: "aborted" } };
|
|
147
|
+
try {
|
|
148
|
+
const rm = requireMethod(workspaceFs, "rm");
|
|
149
|
+
const { root, target } = authorize(roots, requested);
|
|
150
|
+
const located = await locate(root, target);
|
|
151
|
+
refuseConflict(located, options.precondition);
|
|
152
|
+
if (!located.exists)
|
|
153
|
+
throw refuse({ reason: "not-found" });
|
|
154
|
+
if (options.signal?.aborted)
|
|
155
|
+
throw refuse({ reason: "aborted" });
|
|
156
|
+
await mutate(rm(target), "rm");
|
|
157
|
+
return { ok: true, file: mutated(target, null, []) };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
return { ok: false, error: toMutationError(error) };
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function requireMethod(workspaceFs, method) {
|
|
166
|
+
const found = workspaceFs[method];
|
|
167
|
+
if (typeof found !== "function") {
|
|
168
|
+
throw new WriteRefusal({
|
|
169
|
+
reason: "unsupported",
|
|
170
|
+
detail: `the Computer workspace filesystem has no ${method}(), so this backend cannot write`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return found.bind(workspaceFs);
|
|
174
|
+
}
|
|
175
|
+
function refuseConflict(located, precondition) {
|
|
176
|
+
switch (precondition.kind) {
|
|
177
|
+
case "absent":
|
|
178
|
+
if (located.exists)
|
|
179
|
+
throw new WriteRefusal({ reason: "exists" });
|
|
180
|
+
return;
|
|
181
|
+
case "version":
|
|
182
|
+
if (!located.exists || computerVersion(located.entry) !== precondition.version) {
|
|
183
|
+
throw new WriteRefusal({ reason: "changed" });
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
case "any":
|
|
187
|
+
return;
|
|
188
|
+
default:
|
|
189
|
+
throw refuse({ reason: "io", detail: "unknown precondition" });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
class WriteRefusal extends Error {
|
|
193
|
+
error;
|
|
194
|
+
constructor(error) {
|
|
195
|
+
super(error.reason);
|
|
196
|
+
this.error = error;
|
|
197
|
+
this.name = "WriteRefusal";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function mutate(pending, phase) {
|
|
201
|
+
try {
|
|
202
|
+
return await pending;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
const code = boundedCode(error);
|
|
206
|
+
const cause = { code, phase };
|
|
207
|
+
if (code === "EEXIST")
|
|
208
|
+
throw new WriteRefusal({ reason: "exists", cause });
|
|
209
|
+
if (code === "EROFS")
|
|
210
|
+
throw new WriteRefusal({ reason: "read-only", cause });
|
|
211
|
+
if (code === "ENOSPC" || code === "EDQUOT") {
|
|
212
|
+
throw new WriteRefusal({ reason: "no-space", cause });
|
|
213
|
+
}
|
|
214
|
+
throw refuse(mapBackendError(error, phase));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function toMutationError(error) {
|
|
218
|
+
if (error instanceof WriteRefusal)
|
|
219
|
+
return error.error;
|
|
220
|
+
if (error instanceof AdapterRefusal)
|
|
221
|
+
return error.error;
|
|
222
|
+
return toFileSystemError(error);
|
|
223
|
+
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-fs-tools/cloudflare-computer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A readable and writable filesystem over a Cloudflare Computer workspace (experimental).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/gingerhendrix/better-fs-tools.git",
|
|
9
|
+
"directory": "packages/cloudflare-computer"
|
|
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.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@better-fs-tools/read": "^0.1.0",
|
|
36
|
+
"@better-fs-tools/write": "^0.1.0",
|
|
37
|
+
"@cloudflare/computer": "0.2.1"
|
|
38
|
+
}
|
|
5
39
|
}
|