@stonyx/utils 0.2.3-beta.25 → 0.2.3-beta.27
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/README.md +11 -1
- package/dist/file.d.ts +36 -0
- package/dist/file.js +117 -4
- package/dist/prompt.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,10 +75,16 @@ Creates a file at the given path.
|
|
|
75
75
|
|
|
76
76
|
#### `updateFile(filePath, data, options={})`
|
|
77
77
|
|
|
78
|
-
Updates a file atomically by writing to a
|
|
78
|
+
Updates a file atomically by writing to a sibling swap file first, then renaming it over the target. Throws if the file does not exist.
|
|
79
79
|
|
|
80
80
|
* `options.json` — boolean, serialize as JSON.
|
|
81
81
|
|
|
82
|
+
**Concurrency: last writer wins.** Concurrent `updateFile` calls on the same path are safe — each uses a swap file unique to its process and call, so neither caller's swap file can collide with the other's. The rename-time `ENOENT` and the silent cross-caller byte clobber of [#44](https://github.com/abofs/stonyx-utils/issues/44) are both gone. (`updateFile` still throws `ENOENT` from its own precondition when the target does not exist — that is the documented contract above, and it is unrelated to concurrency.) Calls are not serialized: the surviving value is whichever caller renames last.
|
|
83
|
+
|
|
84
|
+
**File mode is preserved; other inode metadata is not.** The swap file is created with the target's permission bits and `chmod`ed to them before the rename, so a `0600` file stays `0600`. The `chmod` addresses the open file descriptor rather than the swap path, so it cannot be redirected onto another file by anyone who can write to the directory. ACLs, extended attributes, hard links and ownership are *not* carried across — the target necessarily gets a new inode.
|
|
85
|
+
|
|
86
|
+
**Swap files after abnormal termination.** Cleanup runs on every failure path `updateFile` controls, but a process killed between the write and the rename leaves a `<path>.temp-<pid>-<token>` sibling behind, and no module ships a sweeper. This is a deliberate trade against the previous whole-second swap name, which was reused — and reuse is exactly the collision that caused #44, so uniqueness has to win. Be precise about what that costs: the old name only ever collapsed two orphans that were abandoned within the *same whole second*, so at any ordinary crash cadence it reclaimed nothing either (measured: five crashes 1200 ms apart leave five orphans under both names). The accumulation uniqueness genuinely adds is confined to a sub-second crash loop. Consumers that persist into a directory they scan, sync or commit should sweep or ignore `*.temp-*` siblings of their targets; an app that runs `git add` over its data directory will otherwise commit one.
|
|
87
|
+
|
|
82
88
|
#### `copyFile(sourcePath, targetPath, options={})`
|
|
83
89
|
|
|
84
90
|
Copies a file from source to target.
|
|
@@ -245,6 +251,8 @@ Prompts the user with `(y/N)` and resolves to `true` only if the answer is `"y"`
|
|
|
245
251
|
* `options.input` — Readable stream (default: `process.stdin`).
|
|
246
252
|
* `options.output` — Writable stream (default: `process.stdout`).
|
|
247
253
|
|
|
254
|
+
**TTY required:** Rejects with an error if `process.stdin` is not a TTY and no custom `input` stream is provided. For headless/container deployments, use `autoMigrate` config or provide a custom input stream.
|
|
255
|
+
|
|
248
256
|
#### `prompt(question, options={})`
|
|
249
257
|
|
|
250
258
|
Prompts the user with a question and resolves to the trimmed input string.
|
|
@@ -252,6 +260,8 @@ Prompts the user with a question and resolves to the trimmed input string.
|
|
|
252
260
|
* `options.input` — Readable stream (default: `process.stdin`).
|
|
253
261
|
* `options.output` — Writable stream (default: `process.stdout`).
|
|
254
262
|
|
|
263
|
+
**TTY required:** Rejects with an error if `process.stdin` is not a TTY and no custom `input` stream is provided.
|
|
264
|
+
|
|
255
265
|
```js
|
|
256
266
|
import { confirm, prompt } from '@stonyx/utils/prompt';
|
|
257
267
|
|
package/dist/file.d.ts
CHANGED
|
@@ -23,6 +23,42 @@ interface FileImportMeta {
|
|
|
23
23
|
path: string;
|
|
24
24
|
}
|
|
25
25
|
export declare function createFile(filePath: string, data: string | Record<string, unknown>, options?: FileOptions): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Atomically replace the contents of an existing file.
|
|
28
|
+
*
|
|
29
|
+
* Writes to a swap file that is a **sibling** of the target — so `rename` stays
|
|
30
|
+
* on one filesystem and therefore stays atomic — then renames it over the
|
|
31
|
+
* target. The swap name carries `process.pid` and a short random token rather
|
|
32
|
+
* than a timestamp: a whole-second token collided between concurrent callers,
|
|
33
|
+
* which made one caller throw `ENOENT` and the other silently persist bytes it
|
|
34
|
+
* had not written (abofs/stonyx-utils#44).
|
|
35
|
+
*
|
|
36
|
+
* Concurrency contract: **last writer wins**. Unique swap names remove the
|
|
37
|
+
* `ENOENT` and the byte-level clobber, but overlapping calls on one path still
|
|
38
|
+
* race on which value lands last. `updateFile` does not serialize, and callers
|
|
39
|
+
* needing a serialization guarantee must supply their own — an in-process queue
|
|
40
|
+
* would not provide one across processes anyway.
|
|
41
|
+
*
|
|
42
|
+
* The target's mode is read before the write and reapplied to the swap file, so
|
|
43
|
+
* the rename does not widen a deliberately restrictive file (a `0600` database
|
|
44
|
+
* would otherwise become `0666 & ~umask`). It is applied to the open file
|
|
45
|
+
* *descriptor*, never to the swap path: a path-based `chmod` follows symlinks,
|
|
46
|
+
* so an attacker holding write access to the directory could hijack the swap
|
|
47
|
+
* path mid-write and have the mode land on a file of their choosing. Other
|
|
48
|
+
* inode-bound metadata — ACLs, extended attributes, hard links, ownership — is
|
|
49
|
+
* *not* carried across; the target gets a new inode by construction.
|
|
50
|
+
*
|
|
51
|
+
* `rename` is atomic against concurrent *readers*, not against power loss:
|
|
52
|
+
* there is no `fsync`, so this is namespace atomicity, not crash durability.
|
|
53
|
+
* If the target is a symlink it is replaced by a regular file — the link's
|
|
54
|
+
* destination is never written through.
|
|
55
|
+
*
|
|
56
|
+
* Swap files are cleaned up on every failure path this function controls, but
|
|
57
|
+
* a process killed between the write and the rename leaves a permanently
|
|
58
|
+
* distinct `<path>.temp-<pid>-<token>` sibling that nothing reclaims. No
|
|
59
|
+
* sweeper ships with this module; `*.temp-*` siblings of a target are safe for
|
|
60
|
+
* a consumer to delete.
|
|
61
|
+
*/
|
|
26
62
|
export declare function updateFile(filePath: string, data: string | Record<string, unknown>, options?: FileOptions): Promise<void>;
|
|
27
63
|
interface CopyFileOptions {
|
|
28
64
|
overwrite?: boolean;
|
package/dist/file.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { getTimestamp } from './date.js';
|
|
2
1
|
import { kebabCaseToCamelCase } from './string.js';
|
|
3
2
|
import { objToJson } from './object.js';
|
|
4
3
|
import fs from 'fs';
|
|
5
4
|
import { promises as fsp } from 'fs';
|
|
6
5
|
import path from 'path';
|
|
6
|
+
import { randomBytes } from 'crypto';
|
|
7
7
|
function isNodeError(error) {
|
|
8
8
|
return error instanceof Error && 'code' in error;
|
|
9
9
|
}
|
|
@@ -17,12 +17,125 @@ export async function createFile(filePath, data, options = {}) {
|
|
|
17
17
|
throw error instanceof Error ? error : new Error(String(error));
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Atomically replace the contents of an existing file.
|
|
22
|
+
*
|
|
23
|
+
* Writes to a swap file that is a **sibling** of the target — so `rename` stays
|
|
24
|
+
* on one filesystem and therefore stays atomic — then renames it over the
|
|
25
|
+
* target. The swap name carries `process.pid` and a short random token rather
|
|
26
|
+
* than a timestamp: a whole-second token collided between concurrent callers,
|
|
27
|
+
* which made one caller throw `ENOENT` and the other silently persist bytes it
|
|
28
|
+
* had not written (abofs/stonyx-utils#44).
|
|
29
|
+
*
|
|
30
|
+
* Concurrency contract: **last writer wins**. Unique swap names remove the
|
|
31
|
+
* `ENOENT` and the byte-level clobber, but overlapping calls on one path still
|
|
32
|
+
* race on which value lands last. `updateFile` does not serialize, and callers
|
|
33
|
+
* needing a serialization guarantee must supply their own — an in-process queue
|
|
34
|
+
* would not provide one across processes anyway.
|
|
35
|
+
*
|
|
36
|
+
* The target's mode is read before the write and reapplied to the swap file, so
|
|
37
|
+
* the rename does not widen a deliberately restrictive file (a `0600` database
|
|
38
|
+
* would otherwise become `0666 & ~umask`). It is applied to the open file
|
|
39
|
+
* *descriptor*, never to the swap path: a path-based `chmod` follows symlinks,
|
|
40
|
+
* so an attacker holding write access to the directory could hijack the swap
|
|
41
|
+
* path mid-write and have the mode land on a file of their choosing. Other
|
|
42
|
+
* inode-bound metadata — ACLs, extended attributes, hard links, ownership — is
|
|
43
|
+
* *not* carried across; the target gets a new inode by construction.
|
|
44
|
+
*
|
|
45
|
+
* `rename` is atomic against concurrent *readers*, not against power loss:
|
|
46
|
+
* there is no `fsync`, so this is namespace atomicity, not crash durability.
|
|
47
|
+
* If the target is a symlink it is replaced by a regular file — the link's
|
|
48
|
+
* destination is never written through.
|
|
49
|
+
*
|
|
50
|
+
* Swap files are cleaned up on every failure path this function controls, but
|
|
51
|
+
* a process killed between the write and the rename leaves a permanently
|
|
52
|
+
* distinct `<path>.temp-<pid>-<token>` sibling that nothing reclaims. No
|
|
53
|
+
* sweeper ships with this module; `*.temp-*` siblings of a target are safe for
|
|
54
|
+
* a consumer to delete.
|
|
55
|
+
*/
|
|
20
56
|
export async function updateFile(filePath, data, options = {}) {
|
|
21
57
|
try {
|
|
58
|
+
filePath = path.resolve(filePath);
|
|
22
59
|
await fsp.access(filePath);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
await fsp.
|
|
60
|
+
// The swap file becomes the target's inode, so it has to carry the target's
|
|
61
|
+
// permission bits or a routine save silently widens them.
|
|
62
|
+
const { mode } = await fsp.stat(filePath);
|
|
63
|
+
const targetMode = mode & 0o7777;
|
|
64
|
+
// pid + random token, never a timestamp: the swap name is a uniqueness
|
|
65
|
+
// token, and whole seconds cannot discriminate between concurrent callers.
|
|
66
|
+
// The token is deliberately short — a full 36-char UUID pushed the name
|
|
67
|
+
// overhead to 48 characters and made ~210-character basenames fail
|
|
68
|
+
// ENAMETOOLONG on a 255-byte NAME_MAX. 6 CSPRNG bytes as base64url is 8
|
|
69
|
+
// characters carrying 48 bits, where the first 8 hex characters of a UUID
|
|
70
|
+
// would be the same width but only 32 bits; at 32 bits a 1000-sample
|
|
71
|
+
// distinctness check collides about once in 8,600 runs. `wx` below makes
|
|
72
|
+
// any residual collision loud rather than corrupting.
|
|
73
|
+
const swapFile = `${filePath}.temp-${process.pid}-${randomBytes(6).toString('base64url')}`;
|
|
74
|
+
// Which stage failed, so the catch can tell "the open collided" from "the
|
|
75
|
+
// open succeeded and something later failed" — see the catch.
|
|
76
|
+
let created = false;
|
|
77
|
+
try {
|
|
78
|
+
// `wx` turns any residual name collision into a loud EEXIST rather than a
|
|
79
|
+
// silent overwrite of another caller's swap bytes. `mode` here is masked
|
|
80
|
+
// by the umask, so it only narrows — the fchmod below sets the exact bits.
|
|
81
|
+
//
|
|
82
|
+
// The open is deliberately separate from the write. Everything after this
|
|
83
|
+
// line addresses the *descriptor*, never the path again, so an attacker
|
|
84
|
+
// with write access to the directory cannot redirect it: unlinking the
|
|
85
|
+
// swap path and replacing it with a symlink after this point leaves the
|
|
86
|
+
// handle bound to the original inode. `fsp.chmod(swapFile, ...)` on the
|
|
87
|
+
// path did not have that property — it followed such a symlink and
|
|
88
|
+
// widened an arbitrary victim file with this process's privileges
|
|
89
|
+
// (abofs/stonyx-utils#45, Phase 3 HIGH-3).
|
|
90
|
+
const handle = await fsp.open(swapFile, 'wx', targetMode);
|
|
91
|
+
created = true;
|
|
92
|
+
try {
|
|
93
|
+
await handle.writeFile(options.json ? objToJson(data) : String(data), 'utf8');
|
|
94
|
+
// fchmod(2) on the descriptor, not chmod(2) on the path. Needed at all
|
|
95
|
+
// because the `mode` above is umask-masked and therefore only narrows:
|
|
96
|
+
// a 0666 target would come back 0644 under the usual 022.
|
|
97
|
+
await handle.chmod(targetMode);
|
|
98
|
+
}
|
|
99
|
+
catch (writeError) {
|
|
100
|
+
await handle.close().catch(() => { });
|
|
101
|
+
throw writeError;
|
|
102
|
+
}
|
|
103
|
+
// Not in a `finally`: a close failure is a write failure (the flush can
|
|
104
|
+
// land here), so it has to surface rather than be swallowed — which is
|
|
105
|
+
// what `fsp.writeFile` did when it owned this close.
|
|
106
|
+
await handle.close();
|
|
107
|
+
await fsp.rename(swapFile, filePath);
|
|
108
|
+
}
|
|
109
|
+
catch (swapError) {
|
|
110
|
+
// Leave no orphan behind, and never let the cleanup mask the real error.
|
|
111
|
+
//
|
|
112
|
+
// Exactly one case is not ours to remove: the `wx` open lost a race, so
|
|
113
|
+
// the swap file is another writer's and unlinking it would reintroduce
|
|
114
|
+
// #44.
|
|
115
|
+
//
|
|
116
|
+
// The stage is the half that decides it, and only because `created` is
|
|
117
|
+
// set the instant the `wx` open returns. That is the sole reading of the
|
|
118
|
+
// stage that means "this call owns the path": a flag set after the write
|
|
119
|
+
// instead would answer "did the write finish?", and an ENOSPC mid-write
|
|
120
|
+
// would take the skip path and reinstate the orphaned partial payload
|
|
121
|
+
// this PR exists to close. Splitting `writeFile` into an `open` and a
|
|
122
|
+
// write is what makes the correct reading expressible at all.
|
|
123
|
+
//
|
|
124
|
+
// The code half is kept as a narrowing guard, not because the stage is
|
|
125
|
+
// insufficient. Measured: mutating this to `!created` alone survives the
|
|
126
|
+
// suite, because `O_EXCL` cannot create and then fail, so a false
|
|
127
|
+
// `created` means no file exists and the unlink would be a no-op anyway.
|
|
128
|
+
// It earns its place by refusing to widen if the open and the write are
|
|
129
|
+
// ever re-merged — and note that the code alone does *not* hold up:
|
|
130
|
+
// mutating to the code alone reds `an EEXIST raised after the swap file
|
|
131
|
+
// was created is still cleaned up`, because `rename` surfaces EEXIST on
|
|
132
|
+
// Windows and some network filesystems and that file is one we created.
|
|
133
|
+
const lostTheOpenRace = !created && isNodeError(swapError) && swapError.code === 'EEXIST';
|
|
134
|
+
if (!lostTheOpenRace) {
|
|
135
|
+
await fsp.unlink(swapFile).catch(() => { });
|
|
136
|
+
}
|
|
137
|
+
throw swapError;
|
|
138
|
+
}
|
|
26
139
|
}
|
|
27
140
|
catch (error) {
|
|
28
141
|
throw error instanceof Error ? error : new Error(String(error));
|
package/dist/prompt.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createInterface } from 'readline';
|
|
2
2
|
export function confirm(question, { input, output } = {}) {
|
|
3
|
+
if (!input && !process.stdin.isTTY) {
|
|
4
|
+
return Promise.reject(new Error('Interactive confirm() requires a TTY on stdin. ' +
|
|
5
|
+
'For headless/container deployments, use the autoMigrate config option instead.'));
|
|
6
|
+
}
|
|
3
7
|
const rl = createInterface({
|
|
4
8
|
input: input ?? process.stdin,
|
|
5
9
|
output: output ?? process.stdout,
|
|
@@ -12,6 +16,10 @@ export function confirm(question, { input, output } = {}) {
|
|
|
12
16
|
});
|
|
13
17
|
}
|
|
14
18
|
export function prompt(question, { input, output } = {}) {
|
|
19
|
+
if (!input && !process.stdin.isTTY) {
|
|
20
|
+
return Promise.reject(new Error('Interactive prompt() requires a TTY on stdin. ' +
|
|
21
|
+
'For headless/container deployments, configure non-interactive alternatives instead.'));
|
|
22
|
+
}
|
|
15
23
|
const rl = createInterface({
|
|
16
24
|
input: input ?? process.stdin,
|
|
17
25
|
output: output ?? process.stdout,
|