@procedures/fs 0.2.2 → 0.3.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/CHANGELOG.md +52 -0
- package/README.md +73 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/handlers.d.ts +40 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +163 -0
- package/dist/handlers.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +23 -2
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +94 -3
- package/dist/module.js.map +1 -1
- package/dist/procedures/fs/hash.d.ts +12 -0
- package/dist/procedures/fs/hash.d.ts.map +1 -0
- package/dist/procedures/fs/hash.js +23 -0
- package/dist/procedures/fs/hash.js.map +1 -0
- package/dist/procedures/fs/index.d.ts +4 -0
- package/dist/procedures/fs/index.d.ts.map +1 -1
- package/dist/procedures/fs/index.js +4 -0
- package/dist/procedures/fs/index.js.map +1 -1
- package/dist/procedures/fs/temp.d.ts +14 -0
- package/dist/procedures/fs/temp.d.ts.map +1 -0
- package/dist/procedures/fs/temp.js +20 -0
- package/dist/procedures/fs/temp.js.map +1 -0
- package/dist/procedures/fs/watch.d.ts +14 -0
- package/dist/procedures/fs/watch.d.ts.map +1 -0
- package/dist/procedures/fs/watch.js +26 -0
- package/dist/procedures/fs/watch.js.map +1 -0
- package/dist/procedures/fs/write-json.d.ts +12 -0
- package/dist/procedures/fs/write-json.d.ts.map +1 -0
- package/dist/procedures/fs/write-json.js +33 -0
- package/dist/procedures/fs/write-json.js.map +1 -0
- package/dist/root.d.ts +52 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +129 -0
- package/dist/root.js.map +1 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +37 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/handlers.ts +214 -0
- package/src/index.ts +2 -0
- package/src/module.ts +127 -4
- package/src/procedures/fs/hash.ts +25 -0
- package/src/procedures/fs/index.ts +4 -0
- package/src/procedures/fs/temp.ts +22 -0
- package/src/procedures/fs/watch.ts +26 -0
- package/src/procedures/fs/write-json.ts +32 -0
- package/src/root.ts +139 -0
- package/src/types.ts +106 -0
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handlers of the file procedures with a root (roadmap R33).
|
|
3
|
+
*
|
|
4
|
+
* `fsModule` has the plain handlers: they operate on each path. `createFsModule({ root })`
|
|
5
|
+
* replaces each handler with the handler of `rootedHandlers(root)`. That handler sends the paths
|
|
6
|
+
* of its input through `inRoot()`, then starts the plain handler with the absolute path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { platform } from "node:process";
|
|
10
|
+
import { isAbsolute, resolve } from "node:path";
|
|
11
|
+
import type { ProcedurePath } from "@procedures/client";
|
|
12
|
+
import { read } from "./procedures/fs/read.js";
|
|
13
|
+
import { write } from "./procedures/fs/write.js";
|
|
14
|
+
import { exists } from "./procedures/fs/exists.js";
|
|
15
|
+
import { mkdir } from "./procedures/fs/mkdir.js";
|
|
16
|
+
import { rm } from "./procedures/fs/rm.js";
|
|
17
|
+
import { readdir } from "./procedures/fs/readdir.js";
|
|
18
|
+
import { stat } from "./procedures/fs/stat.js";
|
|
19
|
+
import { copy } from "./procedures/fs/copy.js";
|
|
20
|
+
import { move } from "./procedures/fs/move.js";
|
|
21
|
+
import { glob } from "./procedures/fs/glob.js";
|
|
22
|
+
import { readJson } from "./procedures/fs/json.js";
|
|
23
|
+
import { fsWriteJson } from "./procedures/fs/write-json.js";
|
|
24
|
+
import { fsHash } from "./procedures/fs/hash.js";
|
|
25
|
+
import { fsTemp } from "./procedures/fs/temp.js";
|
|
26
|
+
import { fsWatch } from "./procedures/fs/watch.js";
|
|
27
|
+
import { inRoot, isOutsideRoot, outsideRoot, realPathIsInside, resolveRoot, type ResolvedRoot } from "./root.js";
|
|
28
|
+
import type {
|
|
29
|
+
ReadInput,
|
|
30
|
+
ReadOutput,
|
|
31
|
+
WriteInput,
|
|
32
|
+
WriteOutput,
|
|
33
|
+
ExistsInput,
|
|
34
|
+
ExistsOutput,
|
|
35
|
+
MkdirInput,
|
|
36
|
+
MkdirOutput,
|
|
37
|
+
RmInput,
|
|
38
|
+
RmOutput,
|
|
39
|
+
ReaddirInput,
|
|
40
|
+
ReaddirOutput,
|
|
41
|
+
StatInput,
|
|
42
|
+
StatOutput,
|
|
43
|
+
CopyInput,
|
|
44
|
+
CopyOutput,
|
|
45
|
+
MoveInput,
|
|
46
|
+
MoveOutput,
|
|
47
|
+
GlobInput,
|
|
48
|
+
GlobOutput,
|
|
49
|
+
ReadJsonInput,
|
|
50
|
+
ReadJsonOutput,
|
|
51
|
+
FsWriteJsonInput,
|
|
52
|
+
FsWriteJsonOutput,
|
|
53
|
+
FsHashInput,
|
|
54
|
+
FsHashOutput,
|
|
55
|
+
FsTempInput,
|
|
56
|
+
FsTempOutput,
|
|
57
|
+
FsWatchInput,
|
|
58
|
+
FsWatchOutput,
|
|
59
|
+
} from "./types.js";
|
|
60
|
+
|
|
61
|
+
/** One handler for each file procedure. */
|
|
62
|
+
export interface FsHandlers {
|
|
63
|
+
read: (input: ReadInput) => Promise<ReadOutput>;
|
|
64
|
+
write: (input: WriteInput) => Promise<WriteOutput>;
|
|
65
|
+
exists: (input: ExistsInput) => Promise<ExistsOutput>;
|
|
66
|
+
mkdir: (input: MkdirInput) => Promise<MkdirOutput>;
|
|
67
|
+
rm: (input: RmInput) => Promise<RmOutput>;
|
|
68
|
+
readdir: (input: ReaddirInput) => Promise<ReaddirOutput>;
|
|
69
|
+
stat: (input: StatInput) => Promise<StatOutput>;
|
|
70
|
+
copy: (input: CopyInput) => Promise<CopyOutput>;
|
|
71
|
+
move: (input: MoveInput) => Promise<MoveOutput>;
|
|
72
|
+
glob: (input: GlobInput) => Promise<GlobOutput>;
|
|
73
|
+
readJson: (input: ReadJsonInput) => Promise<ReadJsonOutput>;
|
|
74
|
+
writeJson: (input: FsWriteJsonInput) => Promise<FsWriteJsonOutput>;
|
|
75
|
+
hash: (input: FsHashInput) => Promise<FsHashOutput>;
|
|
76
|
+
temp: (input: FsTempInput) => Promise<FsTempOutput>;
|
|
77
|
+
watch: (input: FsWatchInput, ctx?: { signal?: AbortSignal | undefined }) => AsyncGenerator<FsWatchOutput, void, undefined>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The name of the handler of each procedure, by the key of the procedure. `createFsModule()`
|
|
82
|
+
* refuses a procedure that is not in this list, so a new procedure cannot skip the root.
|
|
83
|
+
*/
|
|
84
|
+
export const HANDLER_NAMES: Readonly<Record<string, keyof FsHandlers>> = {
|
|
85
|
+
"fs.read": "read",
|
|
86
|
+
"fs.write": "write",
|
|
87
|
+
"fs.exists": "exists",
|
|
88
|
+
"fs.mkdir": "mkdir",
|
|
89
|
+
"fs.rm": "rm",
|
|
90
|
+
"fs.readdir": "readdir",
|
|
91
|
+
"fs.stat": "stat",
|
|
92
|
+
"fs.copy": "copy",
|
|
93
|
+
"fs.move": "move",
|
|
94
|
+
"fs.glob": "glob",
|
|
95
|
+
"fs.read.json": "readJson",
|
|
96
|
+
"fs.write.json": "writeJson",
|
|
97
|
+
"fs.hash": "hash",
|
|
98
|
+
"fs.temp": "temp",
|
|
99
|
+
"fs.watch": "watch",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* True for a glob pattern that can match outside its folder: an absolute pattern, a pattern that
|
|
104
|
+
* goes up with `..` (also in a brace or a group), or a pattern that starts with a drive on Windows.
|
|
105
|
+
* The handler also removes each match outside the root, so this check only gives a clear error.
|
|
106
|
+
*/
|
|
107
|
+
function patternLeavesFolder(pattern: string): boolean {
|
|
108
|
+
if (isAbsolute(pattern) || pattern.startsWith("/") || pattern.startsWith("\\")) return true;
|
|
109
|
+
if (platform === "win32" && /^[a-zA-Z]:/.test(pattern)) return true;
|
|
110
|
+
return pattern.split(/[\\/{},()|]/).includes("..");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* This function gives the handlers that confine each path to `root`. The root is resolved
|
|
115
|
+
* against the working folder of the process when the function starts. It must exist when a
|
|
116
|
+
* procedure starts.
|
|
117
|
+
*/
|
|
118
|
+
export function rootedHandlers(root: string): FsHandlers {
|
|
119
|
+
const folder = resolve(root);
|
|
120
|
+
/** The root of one call (its real path can change between two calls), and the check of its paths. */
|
|
121
|
+
const check = async (procedure: string): Promise<{ root: ResolvedRoot; inside: (path: string) => Promise<string> }> => {
|
|
122
|
+
const resolved = await resolveRoot(folder);
|
|
123
|
+
const at: ProcedurePath = ["fs", procedure];
|
|
124
|
+
return { root: resolved, inside: (path) => inRoot(resolved, path, at) };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
read: async (input) => {
|
|
129
|
+
const { inside } = await check("read");
|
|
130
|
+
return read({ ...input, path: await inside(input.path) });
|
|
131
|
+
},
|
|
132
|
+
write: async (input) => {
|
|
133
|
+
const { inside } = await check("write");
|
|
134
|
+
return write({ ...input, path: await inside(input.path) });
|
|
135
|
+
},
|
|
136
|
+
exists: async (input) => {
|
|
137
|
+
const { inside } = await check("exists");
|
|
138
|
+
return exists({ ...input, path: await inside(input.path) });
|
|
139
|
+
},
|
|
140
|
+
mkdir: async (input) => {
|
|
141
|
+
const { inside } = await check("mkdir");
|
|
142
|
+
return mkdir({ ...input, path: await inside(input.path) });
|
|
143
|
+
},
|
|
144
|
+
rm: async (input) => {
|
|
145
|
+
const { inside } = await check("rm");
|
|
146
|
+
return rm({ ...input, path: await inside(input.path) });
|
|
147
|
+
},
|
|
148
|
+
stat: async (input) => {
|
|
149
|
+
const { inside } = await check("stat");
|
|
150
|
+
return stat({ ...input, path: await inside(input.path) });
|
|
151
|
+
},
|
|
152
|
+
readJson: async (input) => {
|
|
153
|
+
const { inside } = await check("read.json");
|
|
154
|
+
return readJson({ ...input, path: await inside(input.path) });
|
|
155
|
+
},
|
|
156
|
+
writeJson: async (input) => {
|
|
157
|
+
const { inside } = await check("write.json");
|
|
158
|
+
return fsWriteJson({ ...input, path: await inside(input.path) });
|
|
159
|
+
},
|
|
160
|
+
hash: async (input) => {
|
|
161
|
+
const { inside } = await check("hash");
|
|
162
|
+
return fsHash({ ...input, path: await inside(input.path) });
|
|
163
|
+
},
|
|
164
|
+
// The temporary folders of a root are in its folder .tmp, so they stay inside the root
|
|
165
|
+
temp: async (input) => {
|
|
166
|
+
const { inside } = await check("temp");
|
|
167
|
+
return fsTemp(input, await inside(".tmp"));
|
|
168
|
+
},
|
|
169
|
+
watch: async function* (input, ctx) {
|
|
170
|
+
const { inside } = await check("watch");
|
|
171
|
+
yield* fsWatch({ ...input, path: await inside(input.path) }, ctx?.signal);
|
|
172
|
+
},
|
|
173
|
+
copy: async (input) => {
|
|
174
|
+
const { inside } = await check("copy");
|
|
175
|
+
return copy({ ...input, src: await inside(input.src), dest: await inside(input.dest) });
|
|
176
|
+
},
|
|
177
|
+
move: async (input) => {
|
|
178
|
+
const { inside } = await check("move");
|
|
179
|
+
return move({ ...input, src: await inside(input.src), dest: await inside(input.dest) });
|
|
180
|
+
},
|
|
181
|
+
readdir: async (input) => {
|
|
182
|
+
const { inside } = await check("readdir");
|
|
183
|
+
// The stats of an entry follow a symlink: an entry whose target is outside the root gets no stats
|
|
184
|
+
const result = await readdir({ ...input, path: await inside(input.path), includeStats: false });
|
|
185
|
+
if (input.includeStats) {
|
|
186
|
+
for (const entry of result.entries) {
|
|
187
|
+
try {
|
|
188
|
+
await inside(entry.path);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (isOutsideRoot(error)) continue;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
entry.stats = await stat({ path: entry.path });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
},
|
|
198
|
+
glob: async (input) => {
|
|
199
|
+
const { root: resolved, inside } = await check("glob");
|
|
200
|
+
const cwd = await inside(input.cwd ?? ".");
|
|
201
|
+
if (patternLeavesFolder(input.pattern)) {
|
|
202
|
+
throw outsideRoot(["fs", "glob"], `the pattern ${JSON.stringify(input.pattern)} can match outside the folder ${cwd}`);
|
|
203
|
+
}
|
|
204
|
+
// A match through a symlink whose target is outside the root is left out
|
|
205
|
+
const found = await glob({ ...input, cwd, absolute: false });
|
|
206
|
+
const matches: string[] = [];
|
|
207
|
+
for (const match of found.matches) {
|
|
208
|
+
const absolute = resolve(cwd, match);
|
|
209
|
+
if (await realPathIsInside(resolved, absolute)) matches.push(input.absolute ? absolute : match);
|
|
210
|
+
}
|
|
211
|
+
return { pattern: input.pattern, matches };
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/index.ts
CHANGED
package/src/module.ts
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Registers fs.* procedures with the client system.
|
|
7
7
|
* This file is referenced by package.json's client.procedures field.
|
|
8
|
+
*
|
|
9
|
+
* `fsModule` operates on each path. `createFsModule({ root })` gives the same procedures, and
|
|
10
|
+
* each path of their input must be in the root (roadmap R33, `root.ts`).
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
|
-
import { createProcedure, zodAdapter, defineModule, type Effects, type ProcedureModule, PROCEDURE_REGISTRY, registerModules, type ProcedureRegistry } from "@procedures/client";
|
|
13
|
+
import { createProcedure, zodAdapter, defineModule, type AnyProcedure, type Effects, type ProcedureModule, PROCEDURE_REGISTRY, registerModules, type ProcedureRegistry } from "@procedures/client";
|
|
14
|
+
import { HANDLER_NAMES, rootedHandlers } from "./handlers.js";
|
|
11
15
|
import { read } from "./procedures/fs/read.js";
|
|
12
16
|
import { write } from "./procedures/fs/write.js";
|
|
13
17
|
import { exists } from "./procedures/fs/exists.js";
|
|
@@ -65,6 +69,28 @@ import {
|
|
|
65
69
|
GlobOutputSchema,
|
|
66
70
|
ReadJsonOutputSchema,
|
|
67
71
|
} from "./types.js";
|
|
72
|
+
import { fsWriteJson } from "./procedures/fs/write-json.js";
|
|
73
|
+
import { fsHash } from "./procedures/fs/hash.js";
|
|
74
|
+
import { fsTemp } from "./procedures/fs/temp.js";
|
|
75
|
+
import { fsWatch } from "./procedures/fs/watch.js";
|
|
76
|
+
import {
|
|
77
|
+
FsWriteJsonInputSchema,
|
|
78
|
+
FsWriteJsonOutputSchema,
|
|
79
|
+
FsHashInputSchema,
|
|
80
|
+
FsHashOutputSchema,
|
|
81
|
+
FsTempInputSchema,
|
|
82
|
+
FsTempOutputSchema,
|
|
83
|
+
FsWatchInputSchema,
|
|
84
|
+
FsWatchOutputSchema,
|
|
85
|
+
type FsWriteJsonInput,
|
|
86
|
+
type FsWriteJsonOutput,
|
|
87
|
+
type FsHashInput,
|
|
88
|
+
type FsHashOutput,
|
|
89
|
+
type FsTempInput,
|
|
90
|
+
type FsTempOutput,
|
|
91
|
+
type FsWatchInput,
|
|
92
|
+
type FsWatchOutput,
|
|
93
|
+
} from "./types.js";
|
|
68
94
|
|
|
69
95
|
// =============================================================================
|
|
70
96
|
// Procedure Definitions
|
|
@@ -231,6 +257,68 @@ const fsReadJsonProcedure = createProcedure()
|
|
|
231
257
|
.handler(readJson)
|
|
232
258
|
.build();
|
|
233
259
|
|
|
260
|
+
// fs.write.json, fs.hash, fs.temp and fs.watch (roadmap R44)
|
|
261
|
+
|
|
262
|
+
const fsWriteJsonProcedure = createProcedure()
|
|
263
|
+
.path(["fs", "write.json"])
|
|
264
|
+
.input(zodAdapter<FsWriteJsonInput>(FsWriteJsonInputSchema))
|
|
265
|
+
.output(zodAdapter<FsWriteJsonOutput>(FsWriteJsonOutputSchema))
|
|
266
|
+
.meta({
|
|
267
|
+
description: "Write a value as a JSON file, with a rename at the end",
|
|
268
|
+
args: ["path"],
|
|
269
|
+
shorts: {},
|
|
270
|
+
output: "json",
|
|
271
|
+
// It replaces the file: destructive, and the same input gives the same file
|
|
272
|
+
effects: { reads: [], writes: ["fs"], destructive: true, idempotent: true },
|
|
273
|
+
})
|
|
274
|
+
.handler(fsWriteJson)
|
|
275
|
+
.build();
|
|
276
|
+
|
|
277
|
+
const fsHashProcedure = createProcedure()
|
|
278
|
+
.path(["fs", "hash"])
|
|
279
|
+
.input(zodAdapter<FsHashInput>(FsHashInputSchema))
|
|
280
|
+
.output(zodAdapter<FsHashOutput>(FsHashOutputSchema))
|
|
281
|
+
.meta({
|
|
282
|
+
description: "The hash of a file",
|
|
283
|
+
args: ["path"],
|
|
284
|
+
shorts: { algorithm: "a" },
|
|
285
|
+
output: "json",
|
|
286
|
+
effects: READS_FILES,
|
|
287
|
+
})
|
|
288
|
+
.handler((input: FsHashInput) => fsHash(input))
|
|
289
|
+
.build();
|
|
290
|
+
|
|
291
|
+
const fsTempProcedure = createProcedure()
|
|
292
|
+
.path(["fs", "temp"])
|
|
293
|
+
.input(zodAdapter<FsTempInput>(FsTempInputSchema))
|
|
294
|
+
.output(zodAdapter<FsTempOutput>(FsTempOutputSchema))
|
|
295
|
+
.meta({
|
|
296
|
+
description: "Make a temporary folder",
|
|
297
|
+
args: [],
|
|
298
|
+
shorts: {},
|
|
299
|
+
output: "json",
|
|
300
|
+
// It makes a new folder each time, and changes nothing that exists
|
|
301
|
+
effects: { reads: [], writes: ["fs"], destructive: false, idempotent: false },
|
|
302
|
+
})
|
|
303
|
+
.handler((input: FsTempInput) => fsTemp(input))
|
|
304
|
+
.build();
|
|
305
|
+
|
|
306
|
+
const fsWatchProcedure = createProcedure()
|
|
307
|
+
.path(["fs", "watch"])
|
|
308
|
+
.input(zodAdapter<FsWatchInput>(FsWatchInputSchema))
|
|
309
|
+
.output(zodAdapter<FsWatchOutput>(FsWatchOutputSchema))
|
|
310
|
+
.meta({
|
|
311
|
+
description: "The changes of a file or folder, as a stream",
|
|
312
|
+
args: ["path"],
|
|
313
|
+
shorts: { recursive: "r" },
|
|
314
|
+
output: "streaming",
|
|
315
|
+
effects: READS_FILES,
|
|
316
|
+
// The stream has no end: it stops when the reader stops (roadmap R15)
|
|
317
|
+
stream: { unbounded: true },
|
|
318
|
+
})
|
|
319
|
+
.handler(((input: FsWatchInput, ctx?: { signal?: AbortSignal }) => fsWatch(input, ctx?.signal)) as never)
|
|
320
|
+
.build();
|
|
321
|
+
|
|
234
322
|
// =============================================================================
|
|
235
323
|
// Registration
|
|
236
324
|
// =============================================================================
|
|
@@ -251,15 +339,50 @@ export const fsModule: ProcedureModule = defineModule("@procedures/fs", [
|
|
|
251
339
|
fsMoveProcedure,
|
|
252
340
|
fsGlobProcedure,
|
|
253
341
|
fsReadJsonProcedure,
|
|
342
|
+
fsWriteJsonProcedure,
|
|
343
|
+
fsHashProcedure,
|
|
344
|
+
fsTempProcedure,
|
|
345
|
+
fsWatchProcedure,
|
|
254
346
|
]);
|
|
255
347
|
|
|
256
348
|
export default fsModule;
|
|
257
349
|
|
|
350
|
+
/** The options of `createFsModule()`. */
|
|
351
|
+
export interface FsModuleOptions {
|
|
352
|
+
/**
|
|
353
|
+
* The folder that confines each path of the procedures (roadmap R33). A relative path of an
|
|
354
|
+
* input starts at the root. A path outside the root gives `FORBIDDEN` with the reason
|
|
355
|
+
* `outside-root`. A relative root starts at the working folder of the process. Without a root,
|
|
356
|
+
* the procedures operate on each path, as `fsModule` does.
|
|
357
|
+
*/
|
|
358
|
+
root?: string | undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* This function gives the module of this package. Without `root`, it gives `fsModule`. With
|
|
363
|
+
* `root`, it gives a module with the same name and procedures, whose handlers confine each path
|
|
364
|
+
* of the input to the root. A host gives it to `createRegistry()` before its bundles, so it
|
|
365
|
+
* replaces the `fsModule` that the bundles require (the first module of a name counts).
|
|
366
|
+
*/
|
|
367
|
+
export function createFsModule(options: FsModuleOptions = {}): ProcedureModule {
|
|
368
|
+
if (options.root === undefined) return fsModule;
|
|
369
|
+
const handlers = rootedHandlers(options.root);
|
|
370
|
+
const procedures = fsModule.procedures.map((procedure): AnyProcedure => {
|
|
371
|
+
const key = procedure.path.join(".");
|
|
372
|
+
const name = HANDLER_NAMES[key];
|
|
373
|
+
// A procedure without a rooted handler would operate outside the root
|
|
374
|
+
if (name === undefined) throw new Error(`The root of @procedures/fs has no handler for ${key}: add it to rootedHandlers()`);
|
|
375
|
+
return { ...procedure, handler: handlers[name] as AnyProcedure["handler"] };
|
|
376
|
+
});
|
|
377
|
+
return defineModule("@procedures/fs", procedures);
|
|
378
|
+
}
|
|
379
|
+
|
|
258
380
|
/**
|
|
259
381
|
* This function registers the procedures of @procedures/fs, and of the modules that it requires, in
|
|
260
382
|
* a registry. The default is the global registry. Importing this file registers nothing: the
|
|
261
|
-
* `./register` entry starts this function (ARCHITECTURE-PROPOSALS-2026-10, P9).
|
|
383
|
+
* `./register` entry starts this function (ARCHITECTURE-PROPOSALS-2026-10, P9). With
|
|
384
|
+
* `options.root`, each path of an input must be in the root.
|
|
262
385
|
*/
|
|
263
|
-
export function registerFsProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY): void {
|
|
264
|
-
registerModules(registry, [
|
|
386
|
+
export function registerFsProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY, options: FsModuleOptions = {}): void {
|
|
387
|
+
registerModules(registry, [createFsModule(options)]);
|
|
265
388
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs.hash procedure (roadmap R44)
|
|
3
|
+
*
|
|
4
|
+
* The hash of a file. The procedure reads the file as a stream, so a large file does not fill
|
|
5
|
+
* the memory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { createReadStream } from "node:fs";
|
|
10
|
+
import type { FsHashInput, FsHashOutput } from "../../types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The hash of a file
|
|
14
|
+
*/
|
|
15
|
+
export async function fsHash(input: FsHashInput): Promise<FsHashOutput> {
|
|
16
|
+
const algorithm = input.algorithm ?? "sha256";
|
|
17
|
+
const hash = createHash(algorithm);
|
|
18
|
+
let size = 0;
|
|
19
|
+
for await (const chunk of createReadStream(input.path)) {
|
|
20
|
+
const bytes = chunk as Buffer;
|
|
21
|
+
size += bytes.length;
|
|
22
|
+
hash.update(bytes);
|
|
23
|
+
}
|
|
24
|
+
return { path: input.path, algorithm, hash: hash.digest("hex"), size };
|
|
25
|
+
}
|
|
@@ -9,3 +9,7 @@ export { copy } from "./copy.js";
|
|
|
9
9
|
export { move } from "./move.js";
|
|
10
10
|
export { glob } from "./glob.js";
|
|
11
11
|
export { readJson } from "./json.js";
|
|
12
|
+
export { fsWriteJson } from "./write-json.js";
|
|
13
|
+
export { fsHash } from "./hash.js";
|
|
14
|
+
export { fsTemp } from "./temp.js";
|
|
15
|
+
export { fsWatch } from "./watch.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs.temp procedure (roadmap R44)
|
|
3
|
+
*
|
|
4
|
+
* Make a temporary folder with a name that no other folder has. Without a root, the folder is in
|
|
5
|
+
* the temporary folder of the system. With the root of `createFsModule({ root })`, it is in the
|
|
6
|
+
* folder `.tmp` of the root, so it stays inside the root.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir, mkdtemp } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { FsTempInput, FsTempOutput } from "../../types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Make a temporary folder. `base` is the folder that holds it (default: the temporary folder of
|
|
16
|
+
* the system).
|
|
17
|
+
*/
|
|
18
|
+
export async function fsTemp(input: FsTempInput, base: string = tmpdir()): Promise<FsTempOutput> {
|
|
19
|
+
await mkdir(base, { recursive: true });
|
|
20
|
+
const path = await mkdtemp(join(base, input.prefix ?? "procedures-"));
|
|
21
|
+
return { path };
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs.watch procedure (roadmap R44)
|
|
3
|
+
*
|
|
4
|
+
* The changes of a file or folder, as a stream: one item for each change that the platform
|
|
5
|
+
* reports. The stream has no end: it stops when the reader stops or the signal of the call
|
|
6
|
+
* aborts. The platform decides the details: a change can give two items, and some platforms
|
|
7
|
+
* give no file name.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { watch } from "node:fs/promises";
|
|
11
|
+
import type { FsWatchInput, FsWatchOutput } from "../../types.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The changes of a file or folder, as a stream
|
|
15
|
+
*/
|
|
16
|
+
export async function* fsWatch(input: FsWatchInput, signal?: AbortSignal): AsyncGenerator<FsWatchOutput, void, undefined> {
|
|
17
|
+
try {
|
|
18
|
+
for await (const change of watch(input.path, { recursive: input.recursive ?? false, ...(signal ? { signal } : {}) })) {
|
|
19
|
+
yield { event: change.eventType === "change" ? "change" : "rename", filename: change.filename ?? null };
|
|
20
|
+
}
|
|
21
|
+
} catch (error) {
|
|
22
|
+
// The abort of the call ends the stream without an error
|
|
23
|
+
if ((error as { name?: string }).name === "AbortError") return;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs.write.json procedure (roadmap R44)
|
|
3
|
+
*
|
|
4
|
+
* Write a value as a JSON file. The procedure writes a file next to the target, then renames it,
|
|
5
|
+
* so a reader sees the old file or the whole new file, never a half-written one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { rename, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import type { FsWriteJsonInput, FsWriteJsonOutput } from "../../types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Write a value as a JSON file, with a rename at the end
|
|
14
|
+
*/
|
|
15
|
+
export async function fsWriteJson(input: FsWriteJsonInput): Promise<FsWriteJsonOutput> {
|
|
16
|
+
const text = JSON.stringify(input.value ?? null, null, input.indent ?? 2);
|
|
17
|
+
if (text === undefined) throw new Error("fs.write.json: the value has no JSON form");
|
|
18
|
+
const content = `${text}\n`;
|
|
19
|
+
const temporary = `${input.path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
20
|
+
try {
|
|
21
|
+
await writeFile(temporary, content, "utf8");
|
|
22
|
+
await rename(temporary, input.path);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
try {
|
|
25
|
+
await rm(temporary, { force: true });
|
|
26
|
+
} catch {
|
|
27
|
+
// The error of the write counts, not the error of the cleanup
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
return { path: input.path, bytesWritten: Buffer.byteLength(content, "utf8") };
|
|
32
|
+
}
|
package/src/root.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root of the file procedures: one folder that confines each path (roadmap R33).
|
|
3
|
+
*
|
|
4
|
+
* `createFsModule({ root })` sends each path of an input through `inRoot()`:
|
|
5
|
+
* - A relative path starts at the root, not at the working folder of the process.
|
|
6
|
+
* - A path outside the root gives `FORBIDDEN` with the reason `outside-root`. This includes a
|
|
7
|
+
* path that goes up with `..`, an absolute path outside the root, a path on another drive, and
|
|
8
|
+
* a symlink or a junction that leads out of the root.
|
|
9
|
+
* - The check uses the real path (`fs.realpath`) of the path. For a path that does not exist, it
|
|
10
|
+
* uses the real path of the nearest existing ancestor. So a symlinked parent folder cannot
|
|
11
|
+
* lead a write or a `mkdir` out of the root.
|
|
12
|
+
* - The procedure gets the absolute path, so the paths of the output are absolute.
|
|
13
|
+
*
|
|
14
|
+
* On Windows, the comparison ignores the case of the letters, and `/` and `\` are the same.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readlink, realpath } from "node:fs/promises";
|
|
18
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
19
|
+
import { InvocationError, type ProcedurePath } from "@procedures/client";
|
|
20
|
+
|
|
21
|
+
/** The reason of the `FORBIDDEN` error for a path outside the root. */
|
|
22
|
+
export const OUTSIDE_ROOT = "outside-root";
|
|
23
|
+
|
|
24
|
+
/** The maximum number of symlinks that one check follows. After it, the check fails with `ELOOP`. */
|
|
25
|
+
const MAX_LINKS = 40;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This function tells if `path` is `folder` or a path in `folder`. The two paths are absolute.
|
|
29
|
+
* On Windows, the case of the letters is not significant.
|
|
30
|
+
*/
|
|
31
|
+
export function isInside(folder: string, path: string): boolean {
|
|
32
|
+
const rel = relative(folder, path);
|
|
33
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True for the error of a path that does not exist. */
|
|
37
|
+
function isMissing(error: unknown): boolean {
|
|
38
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
39
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The target of a symlink, or undefined when the path is not a symlink or does not exist. */
|
|
43
|
+
async function linkTarget(path: string): Promise<string | undefined> {
|
|
44
|
+
try {
|
|
45
|
+
return await readlink(path);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// EINVAL: the path is not a symlink
|
|
48
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
49
|
+
if (code === "EINVAL" || isMissing(error)) return undefined;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* This function gives the real path of an absolute path. For a path that does not exist, it
|
|
56
|
+
* gives the real path of the nearest existing ancestor and the rest of the path. A symlink whose
|
|
57
|
+
* target does not exist leads to its target, because a write follows it.
|
|
58
|
+
*/
|
|
59
|
+
export async function realPathOf(path: string, links = 0): Promise<string> {
|
|
60
|
+
const rest: string[] = [];
|
|
61
|
+
let current = path;
|
|
62
|
+
for (;;) {
|
|
63
|
+
try {
|
|
64
|
+
return join(await realpath(current), ...rest);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!isMissing(error)) throw error;
|
|
67
|
+
}
|
|
68
|
+
const target = await linkTarget(current);
|
|
69
|
+
if (target !== undefined) {
|
|
70
|
+
if (links >= MAX_LINKS) {
|
|
71
|
+
const loop = new Error(`Too many symlinks in the path ${path}`) as NodeJS.ErrnoException;
|
|
72
|
+
loop.code = "ELOOP";
|
|
73
|
+
throw loop;
|
|
74
|
+
}
|
|
75
|
+
return join(await realPathOf(resolve(dirname(current), target), links + 1), ...rest);
|
|
76
|
+
}
|
|
77
|
+
const parent = dirname(current);
|
|
78
|
+
// No ancestor exists, for example a drive that is not there
|
|
79
|
+
if (parent === current) return join(current, ...rest);
|
|
80
|
+
rest.unshift(basename(current));
|
|
81
|
+
current = parent;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A root: the absolute path that the host gave, and its real path. */
|
|
86
|
+
export interface ResolvedRoot {
|
|
87
|
+
folder: string;
|
|
88
|
+
real: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** This function gives the absolute path and the real path of a root. The root must exist. */
|
|
92
|
+
export async function resolveRoot(root: string): Promise<ResolvedRoot> {
|
|
93
|
+
const folder = resolve(root);
|
|
94
|
+
try {
|
|
95
|
+
return { folder, real: await realpath(folder) };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (!isMissing(error)) throw error;
|
|
98
|
+
const missing = new Error(`The root of the file procedures does not exist: ${folder}`) as NodeJS.ErrnoException;
|
|
99
|
+
missing.code = "ENOENT";
|
|
100
|
+
throw missing;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The `FORBIDDEN` error for a path outside the root. */
|
|
105
|
+
export function outsideRoot(procedure: ProcedurePath, message: string): InvocationError {
|
|
106
|
+
return new InvocationError("FORBIDDEN", `${procedure.join(".")}: ${message}`, procedure, OUTSIDE_ROOT);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True for the `FORBIDDEN` error of a path outside the root. */
|
|
110
|
+
export function isOutsideRoot(error: unknown): boolean {
|
|
111
|
+
return error instanceof InvocationError && error.code === "FORBIDDEN" && error.reason === OUTSIDE_ROOT;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* This function tells if the real path of an absolute path is in the root. It follows each
|
|
116
|
+
* symlink of the path, also a symlink whose target does not exist.
|
|
117
|
+
*/
|
|
118
|
+
export async function realPathIsInside(root: ResolvedRoot, absolute: string): Promise<boolean> {
|
|
119
|
+
return isInside(root.real, await realPathOf(absolute));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* This function gives the absolute path of `path` in the root. A relative path starts at the
|
|
124
|
+
* root. The real path decides: a path whose real path is outside the real path of the root gives
|
|
125
|
+
* `FORBIDDEN` with the reason `outside-root`.
|
|
126
|
+
*/
|
|
127
|
+
export async function inRoot(root: ResolvedRoot, path: string, procedure: ProcedurePath): Promise<string> {
|
|
128
|
+
const absolute = resolve(root.folder, path);
|
|
129
|
+
const real = await realPathOf(absolute);
|
|
130
|
+
// A path counts as inside by its given form or by its real form. On Windows, the two forms of a
|
|
131
|
+
// folder can differ by more than the links: a short 8.3 name (RUNNER~1) has a long real name.
|
|
132
|
+
if (!isInside(root.folder, absolute) && !isInside(root.real, absolute) && !isInside(root.real, real)) {
|
|
133
|
+
throw outsideRoot(procedure, `the path ${JSON.stringify(path)} is outside the root ${root.folder}`);
|
|
134
|
+
}
|
|
135
|
+
if (!isInside(root.real, real)) {
|
|
136
|
+
throw outsideRoot(procedure, `the path ${JSON.stringify(path)} leads out of the root ${root.folder} through a symlink`);
|
|
137
|
+
}
|
|
138
|
+
return absolute;
|
|
139
|
+
}
|