@procedures/fs 0.27.4 → 0.27.5

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.
@@ -1,17 +1,30 @@
1
1
  /**
2
- * The atomic write of a file (roadmap O51).
2
+ * The atomic write of a file (roadmap O51 and O52).
3
3
  *
4
4
  * `writeFileAtomic()` writes the data to a new temporary file in the folder of the target. Then it
5
5
  * renames the temporary file over the target. Thus a reader sees the old file or the whole new
6
- * file, also after a crash.
6
+ * file, also after a crash. `writeFileAtomicSync()` does the same steps without a promise.
7
7
  */
8
8
 
9
9
  import { randomBytes } from "node:crypto";
10
+ import {
11
+ chmodSync,
12
+ closeSync,
13
+ fchmodSync,
14
+ fsyncSync,
15
+ openSync,
16
+ realpathSync,
17
+ renameSync,
18
+ statSync,
19
+ unlinkSync,
20
+ writeFileSync,
21
+ } from "node:fs";
10
22
  import { chmod, open, realpath, rename, stat, unlink, type FileHandle } from "node:fs/promises";
11
23
  import { basename, dirname, join, resolve } from "node:path";
12
24
  import { setTimeout as sleep } from "node:timers/promises";
25
+ import { perform, runAsync, runSync } from "./steps.js";
13
26
 
14
- /** The options of `writeFileAtomic()`. */
27
+ /** The options of `writeFileAtomic()` and `writeFileAtomicSync()`. */
15
28
  export interface WriteFileAtomicOptions {
16
29
  /** The encoding of a string `data`. The default is `utf8`. */
17
30
  encoding?: BufferEncoding | undefined;
@@ -27,6 +40,15 @@ export interface WriteFileAtomicOptions {
27
40
  * is 2000 on Windows and 0 on the other platforms.
28
41
  */
29
42
  renameRetryMs?: number | undefined;
43
+ /**
44
+ * The function that gives the name of the temporary file. It gets the absolute path of the target,
45
+ * after the function follows a symlink.
46
+ *
47
+ * The temporary file is in the folder of the target, so a name with `/` or `\` is an error. The
48
+ * default name is `.<name>.<16 hex digits>.tmp`, where `<name>` is the first 32 characters of the
49
+ * name of the target.
50
+ */
51
+ tempName?: ((target: string) => string) | undefined;
30
52
  }
31
53
 
32
54
  /** On Windows, an antivirus or an indexer that holds the target open gives these errors for a short time. */
@@ -35,6 +57,30 @@ const LOCKED = new Set(["EPERM", "EACCES", "EBUSY"]);
35
57
  /** A file system that cannot flush a folder gives these errors. The write is complete without the flush. */
36
58
  const NO_FOLDER_SYNC = new Set(["EINVAL", "ENOTSUP", "EISDIR", "EBADF", "EPERM", "EACCES"]);
37
59
 
60
+ /** The permission bits that let the group and the others read a file. */
61
+ const OTHERS_READ = 0o044;
62
+
63
+ /** A file that the steps open: a `FileHandle` for the async form, a file descriptor for the sync form. */
64
+ type Handle = FileHandle | number;
65
+
66
+ /** One operation of the file system that the steps of a write need. */
67
+ type FileOp =
68
+ | { readonly op: "realpath"; readonly path: string }
69
+ | { readonly op: "mode"; readonly path: string }
70
+ | { readonly op: "create"; readonly path: string; readonly mode: number }
71
+ | { readonly op: "openFolder"; readonly path: string }
72
+ | { readonly op: "write"; readonly handle: Handle; readonly data: string | Uint8Array; readonly encoding: BufferEncoding }
73
+ | { readonly op: "fchmod"; readonly handle: Handle; readonly mode: number }
74
+ | { readonly op: "fsync"; readonly handle: Handle }
75
+ | { readonly op: "close"; readonly handle: Handle }
76
+ | { readonly op: "rename"; readonly from: string; readonly to: string }
77
+ | { readonly op: "unlink"; readonly path: string }
78
+ | { readonly op: "chmod"; readonly path: string; readonly mode: number }
79
+ | { readonly op: "sleep"; readonly ms: number };
80
+
81
+ /** The steps of a write. Each step yields one operation, and the runner gives back its result. */
82
+ type Steps<T> = Generator<FileOp, T, unknown>;
83
+
38
84
  /** True on Windows. The function reads the platform at each call, so a test can change it. */
39
85
  function isWindows(): boolean {
40
86
  return process.platform === "win32";
@@ -52,10 +98,10 @@ function isMissing(error: unknown): boolean {
52
98
  }
53
99
 
54
100
  /** The absolute path of the file to replace. The function follows a symlink, so the link stays a link. */
55
- async function targetOf(path: string): Promise<string> {
101
+ function* targetOf(path: string): Steps<string> {
56
102
  const absolute = resolve(path);
57
103
  try {
58
- return await realpath(absolute);
104
+ return yield* perform<FileOp, string>({ op: "realpath", path: absolute });
59
105
  } catch (error) {
60
106
  if (isMissing(error)) return absolute;
61
107
  throw error;
@@ -63,53 +109,75 @@ async function targetOf(path: string): Promise<string> {
63
109
  }
64
110
 
65
111
  /** The permission bits of the target, or undefined when the target does not exist. */
66
- async function modeOf(target: string): Promise<number | undefined> {
112
+ function* modeOf(target: string): Steps<number | undefined> {
67
113
  try {
68
- return (await stat(target)).mode & 0o7777;
114
+ return yield* perform<FileOp, number>({ op: "mode", path: target });
69
115
  } catch (error) {
70
116
  if (isMissing(error)) return undefined;
71
117
  throw error;
72
118
  }
73
119
  }
74
120
 
121
+ /** The `ERR_INVALID_ARG_VALUE` error of a name of a temporary file that is not in the folder of the target. */
122
+ function badTempName(name: unknown): TypeError {
123
+ const error = new TypeError(
124
+ `The option tempName gave ${JSON.stringify(name)}. Give the name of a new file in the folder of the target, without "/" or "\\".`,
125
+ );
126
+ return Object.assign(error, { code: "ERR_INVALID_ARG_VALUE" });
127
+ }
128
+
129
+ /**
130
+ * A new path next to the target. The default name starts with a dot and ends in `.tmp`. Thus a
131
+ * reader that looks for the extension of the target does not see it. The name keeps 32 characters
132
+ * of the target, so it does not become too long.
133
+ */
134
+ function tempPathOf(target: string, tempName: WriteFileAtomicOptions["tempName"]): string {
135
+ const own = basename(target);
136
+ if (!tempName) return join(dirname(target), `.${[...own].slice(0, 32).join("")}.${randomBytes(8).toString("hex")}.tmp`);
137
+ const name: unknown = tempName(target);
138
+ if (typeof name !== "string" || name === "" || name === "." || name === ".." || name === own || /[\\/]/.test(name)) {
139
+ throw badTempName(name);
140
+ }
141
+ return join(dirname(target), name);
142
+ }
143
+
75
144
  /**
76
- * A new path next to the target. The name starts with a dot and ends in `.tmp`, so a reader that
77
- * looks for the extension of the target does not see it. The name keeps 32 characters of the
78
- * target, so it does not become too long.
145
+ * The permissions of the new temporary file. When the final file hides its data from the group and
146
+ * the others, the temporary file gets `0o600` from the start. Then no other user can read it.
79
147
  */
80
- function tempPathOf(target: string): string {
81
- const name = [...basename(target)].slice(0, 32).join("");
82
- return join(dirname(target), `.${name}.${randomBytes(8).toString("hex")}.tmp`);
148
+ function createModeOf(mode: number | undefined): number {
149
+ if (mode === undefined) return 0o666;
150
+ return (mode & OTHERS_READ) === 0 ? 0o600 : mode;
83
151
  }
84
152
 
85
153
  /**
86
154
  * The rename, tried again with a longer wait each time until `retryMs` is over. A random part of
87
155
  * each wait keeps two writers of one target out of step.
88
156
  */
89
- async function renameWithRetry(from: string, to: string, retryMs: number): Promise<void> {
157
+ function* renameWithRetry(from: string, to: string, retryMs: number): Steps<void> {
90
158
  const deadline = Date.now() + retryMs;
91
159
  for (let step = 10; ; step = Math.min(step * 2, 100)) {
92
160
  try {
93
- await rename(from, to);
161
+ yield { op: "rename", from, to };
94
162
  return;
95
163
  } catch (error) {
96
164
  const delay = Math.round(step * (0.5 + Math.random()));
97
165
  if (!LOCKED.has(codeOf(error)) || Date.now() + delay > deadline) throw error;
98
- await sleep(delay);
166
+ yield { op: "sleep", ms: delay };
99
167
  }
100
168
  }
101
169
  }
102
170
 
103
171
  /** The removal of the temporary file after a failure. The original error is more important than an error here. */
104
- async function removeTemp(temp: string): Promise<void> {
172
+ function* removeTemp(temp: string): Steps<void> {
105
173
  try {
106
- await unlink(temp);
174
+ yield { op: "unlink", path: temp };
107
175
  } catch (error) {
108
176
  if (codeOf(error) !== "EPERM") return;
109
177
  // Windows does not remove a read-only file, so the function clears the flag and tries again
110
178
  try {
111
- await chmod(temp, 0o666);
112
- await unlink(temp);
179
+ yield { op: "chmod", path: temp, mode: 0o666 };
180
+ yield { op: "unlink", path: temp };
113
181
  } catch {
114
182
  // The temporary file stays. The error of the write is the error that the caller gets.
115
183
  }
@@ -117,63 +185,140 @@ async function removeTemp(temp: string): Promise<void> {
117
185
  }
118
186
 
119
187
  /** The flush of the folder, so that the rename is on the disk. Windows cannot open a folder for it. */
120
- async function syncFolder(folder: string): Promise<void> {
188
+ function* syncFolder(folder: string): Steps<void> {
121
189
  if (isWindows()) return;
122
- let handle: FileHandle | undefined;
190
+ let handle: Handle | undefined;
123
191
  try {
124
- handle = await open(folder, "r");
125
- await handle.sync();
192
+ handle = yield* perform<FileOp, Handle>({ op: "openFolder", path: folder });
193
+ yield { op: "fsync", handle };
126
194
  } catch (error) {
127
195
  if (!NO_FOLDER_SYNC.has(codeOf(error))) throw error;
128
196
  } finally {
129
- await handle?.close();
197
+ if (handle !== undefined) yield { op: "close", handle };
130
198
  }
131
199
  }
132
200
 
133
- /**
134
- * This function writes `data` to `path` in one step for each reader. A reader sees the old file or
135
- * the whole new file. It does not see a part of a file at any time.
136
- *
137
- * - The function writes a new temporary file in the folder of the target and flushes it to the disk.
138
- * - Then it renames the temporary file over the target and flushes the folder. Windows gets no flush of the folder.
139
- * - On Windows, an open handle of the target blocks the rename. Then the function tries the rename again for `renameRetryMs`.
140
- * - After a failure, the function removes the temporary file and throws the error.
141
- * - The file keeps the permissions of the target, unless `options.mode` is set. It does not keep the owner.
142
- * - When `path` is a symlink, the function replaces the target of the link.
143
- *
144
- * Two writes at the same time do not mix: the file gets the data of one of them. The folder must exist.
145
- */
146
- export async function writeFileAtomic(
147
- path: string,
148
- data: string | Uint8Array,
149
- options: WriteFileAtomicOptions = {},
150
- ): Promise<void> {
151
- const target = await targetOf(path);
152
- const mode = options.mode ?? (await modeOf(target));
201
+ /** The steps of `writeFileAtomic()` and `writeFileAtomicSync()`. */
202
+ function* writeSteps(path: string, data: string | Uint8Array, options: WriteFileAtomicOptions): Steps<void> {
203
+ const target = yield* targetOf(path);
204
+ const mode = options.mode ?? (yield* modeOf(target));
153
205
  const flush = options.fsync ?? true;
154
- const temp = tempPathOf(target);
206
+ const temp = tempPathOf(target, options.tempName);
155
207
  let created = false;
156
- let handle: FileHandle | undefined;
208
+ let handle: Handle | undefined;
157
209
  try {
158
- handle = await open(temp, "wx", mode ?? 0o666);
210
+ handle = yield* perform<FileOp, Handle>({ op: "create", path: temp, mode: createModeOf(mode) });
159
211
  created = true;
160
- await handle.writeFile(data, { encoding: options.encoding ?? "utf8" });
161
- // The umask changes the mode of open(), so the function sets the mode again
162
- if (mode !== undefined) await handle.chmod(mode);
163
- if (flush) await handle.sync();
164
- await handle.close();
212
+ yield { op: "write", handle, data, encoding: options.encoding ?? "utf8" };
213
+ // The umask changes the mode of the open, and a secret file starts at 0o600, so the function sets the final mode
214
+ if (mode !== undefined) yield { op: "fchmod", handle, mode };
215
+ if (flush) yield { op: "fsync", handle };
216
+ yield { op: "close", handle };
165
217
  handle = undefined;
166
- await renameWithRetry(temp, target, options.renameRetryMs ?? (isWindows() ? 2000 : 0));
218
+ yield* renameWithRetry(temp, target, options.renameRetryMs ?? (isWindows() ? 2000 : 0));
167
219
  } catch (error) {
168
- if (handle) {
220
+ if (handle !== undefined) {
169
221
  try {
170
- await handle.close();
222
+ yield { op: "close", handle };
171
223
  } catch {
172
224
  // The error of the write is the error that the caller gets
173
225
  }
174
226
  }
175
- if (created) await removeTemp(temp);
227
+ if (created) yield* removeTemp(temp);
176
228
  throw error;
177
229
  }
178
- if (flush) await syncFolder(dirname(target));
230
+ if (flush) yield* syncFolder(dirname(target));
231
+ }
232
+
233
+ /** The async operations of the file system, with `node:fs/promises`. */
234
+ async function doAsync(op: FileOp): Promise<unknown> {
235
+ switch (op.op) {
236
+ case "realpath":
237
+ return realpath(op.path);
238
+ case "mode":
239
+ return (await stat(op.path)).mode & 0o7777;
240
+ case "create":
241
+ return open(op.path, "wx", op.mode);
242
+ case "openFolder":
243
+ return open(op.path, "r");
244
+ case "write":
245
+ return (op.handle as FileHandle).writeFile(op.data, { encoding: op.encoding });
246
+ case "fchmod":
247
+ return (op.handle as FileHandle).chmod(op.mode);
248
+ case "fsync":
249
+ return (op.handle as FileHandle).sync();
250
+ case "close":
251
+ return (op.handle as FileHandle).close();
252
+ case "rename":
253
+ return rename(op.from, op.to);
254
+ case "unlink":
255
+ return unlink(op.path);
256
+ case "chmod":
257
+ return chmod(op.path, op.mode);
258
+ case "sleep":
259
+ return sleep(op.ms);
260
+ }
261
+ }
262
+
263
+ let sleeper: Int32Array | undefined;
264
+
265
+ /** The sync operations of the file system, with `node:fs`. A wait blocks the thread with `Atomics.wait()`. */
266
+ function doSync(op: FileOp): unknown {
267
+ switch (op.op) {
268
+ case "realpath":
269
+ return realpathSync.native(op.path);
270
+ case "mode":
271
+ return statSync(op.path).mode & 0o7777;
272
+ case "create":
273
+ return openSync(op.path, "wx", op.mode);
274
+ case "openFolder":
275
+ return openSync(op.path, "r");
276
+ case "write":
277
+ return writeFileSync(op.handle as number, op.data, { encoding: op.encoding });
278
+ case "fchmod":
279
+ return fchmodSync(op.handle as number, op.mode);
280
+ case "fsync":
281
+ return fsyncSync(op.handle as number);
282
+ case "close":
283
+ return closeSync(op.handle as number);
284
+ case "rename":
285
+ return renameSync(op.from, op.to);
286
+ case "unlink":
287
+ return unlinkSync(op.path);
288
+ case "chmod":
289
+ return chmodSync(op.path, op.mode);
290
+ case "sleep":
291
+ sleeper ??= new Int32Array(new SharedArrayBuffer(4));
292
+ return Atomics.wait(sleeper, 0, 0, op.ms);
293
+ }
294
+ }
295
+
296
+ /**
297
+ * This function writes `data` to `path` in one step for each reader. A reader sees the old file or
298
+ * the whole new file. It does not see a part of a file at any time.
299
+ *
300
+ * - The function writes a new temporary file in the folder of the target and flushes it to the disk.
301
+ * - Then it renames the temporary file over the target and flushes the folder. Windows gets no flush of the folder.
302
+ * - On Windows, an open handle of the target blocks the rename. Then the function tries the rename again for `renameRetryMs`.
303
+ * - After a failure, the function removes the temporary file and throws the error.
304
+ * - The file keeps the permissions of the target, unless `options.mode` is set. It does not keep the owner.
305
+ * - When the group and the others cannot read the final file, the temporary file gets `0o600` before the write of the data.
306
+ * - When `path` is a symlink, the function replaces the target of the link.
307
+ *
308
+ * Two writes at the same time do not mix: the file gets the data of one of them. The folder must exist.
309
+ * The name of the temporary file is `.<name>.<16 hex digits>.tmp`, unless `options.tempName` gives a name.
310
+ */
311
+ export function writeFileAtomic(path: string, data: string | Uint8Array, options: WriteFileAtomicOptions = {}): Promise<void> {
312
+ return runAsync(writeSteps(path, data, options), doAsync);
313
+ }
314
+
315
+ /**
316
+ * This function is the sync form of `writeFileAtomic()`. It does the same steps, with the same
317
+ * options and the same errors.
318
+ *
319
+ * - It blocks the thread until the file is in place.
320
+ * - On Windows, a locked target can block the thread for `renameRetryMs` (2000 by default) while the function tries the rename again.
321
+ */
322
+ export function writeFileAtomicSync(path: string, data: string | Uint8Array, options: WriteFileAtomicOptions = {}): void {
323
+ runSync(writeSteps(path, data, options), doSync);
179
324
  }
package/src/files/edit.ts CHANGED
@@ -62,7 +62,8 @@ async function readOrUndefined(path: string, encoding: BufferEncoding): Promise<
62
62
  * - A transform that gives the same text, or `undefined`, writes nothing.
63
63
  * - Two paths of the same file share the lock, also when the case of the letters is not the same on Windows.
64
64
  *
65
- * The lock is in the process. It does not stop another process. The options of `writeFileAtomic()` apply to the write.
65
+ * The lock is in the process. It does not stop another process. The options of `writeFileAtomic()` apply to the write,
66
+ * also `tempName` and the `0o600` temporary file of a secret file.
66
67
  */
67
68
  export function editFile(
68
69
  path: string,
@@ -1,15 +1,22 @@
1
1
  /**
2
- * The file helpers of the package (roadmap O51): the `./files` entry. They are functions, not
3
- * procedures, and an import of this entry registers nothing.
2
+ * The file helpers of the package (roadmap O51 and O52): the `./files` entry. They are functions,
3
+ * not procedures, and an import of this entry registers nothing.
4
4
  *
5
- * - `writeFileAtomic()` writes a file in one step for each reader.
6
- * - `healNdjsonTail()` repairs the end of an append-only NDJSON file after a crash.
5
+ * - `writeFileAtomic()` and `writeFileAtomicSync()` write a file in one step for each reader.
6
+ * - `healNdjsonTail()` and `healNdjsonTailSync()` repair the end of an append-only NDJSON file after a crash.
7
7
  * - `KeyedLock` and `withLock()` let one caller at a time hold a key.
8
8
  * - `editFile()` changes a file, one edit at a time for each path.
9
9
  */
10
10
 
11
- export { writeFileAtomic, type WriteFileAtomicOptions } from "./atomic.js";
12
- export { healNdjsonTail, type HealNdjsonTailOptions, type NdjsonTailAction, type NdjsonTailResult } from "./ndjson-tail.js";
11
+ export { writeFileAtomic, writeFileAtomicSync, type WriteFileAtomicOptions } from "./atomic.js";
12
+ export {
13
+ healNdjsonTail,
14
+ healNdjsonTailSync,
15
+ NDJSON_LINE_TOO_LONG,
16
+ type HealNdjsonTailOptions,
17
+ type NdjsonTailAction,
18
+ type NdjsonTailResult,
19
+ } from "./ndjson-tail.js";
13
20
  export {
14
21
  KeyedLock,
15
22
  withLock,