@stonyx/utils 0.2.3-alpha.33 → 0.2.3-alpha.34

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 CHANGED
@@ -81,7 +81,7 @@ Updates a file atomically by writing to a sibling swap file first, then renaming
81
81
 
82
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
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`. ACLs, extended attributes, hard links and ownership are *not* carried across — the target necessarily gets a new inode.
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
85
 
86
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
87
 
package/dist/file.d.ts CHANGED
@@ -41,9 +41,12 @@ export declare function createFile(filePath: string, data: string | Record<strin
41
41
  *
42
42
  * The target's mode is read before the write and reapplied to the swap file, so
43
43
  * the rename does not widen a deliberately restrictive file (a `0600` database
44
- * would otherwise become `0666 & ~umask`). Other inode-bound metadata ACLs,
45
- * extended attributes, hard links, ownership is *not* carried across; the
46
- * target gets a new inode by construction.
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.
47
50
  *
48
51
  * `rename` is atomic against concurrent *readers*, not against power loss:
49
52
  * there is no `fsync`, so this is namespace atomicity, not crash durability.
package/dist/file.js CHANGED
@@ -35,9 +35,12 @@ export async function createFile(filePath, data, options = {}) {
35
35
  *
36
36
  * The target's mode is read before the write and reapplied to the swap file, so
37
37
  * the rename does not widen a deliberately restrictive file (a `0600` database
38
- * would otherwise become `0666 & ~umask`). Other inode-bound metadata ACLs,
39
- * extended attributes, hard links, ownership is *not* carried across; the
40
- * target gets a new inode by construction.
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.
41
44
  *
42
45
  * `rename` is atomic against concurrent *readers*, not against power loss:
43
46
  * there is no `fsync`, so this is namespace atomicity, not crash durability.
@@ -108,16 +111,25 @@ export async function updateFile(filePath, data, options = {}) {
108
111
  //
109
112
  // Exactly one case is not ours to remove: the `wx` open lost a race, so
110
113
  // the swap file is another writer's and unlinking it would reintroduce
111
- // #44. That is EEXIST *from the write stage* — both halves matter.
112
- // - Code alone is not enough: `rename` surfaces EEXIST on Windows and
113
- // some network filesystems, and that file is one we created.
114
- // - Stage alone is not enough: `rename` is the only stage after the
115
- // open that can raise EEXIST at all, and by then the file is ours —
116
- // so a stage flag that answered "did the write finish?" would send
117
- // an ENOSPC/EDQUOT/EIO mid-write down the skip path and reinstate
118
- // the orphaned partial payload this PR exists to close. `created` is
119
- // set the instant the `wx` open returns, which is the only reading
120
- // of the stage that means "this call owns the path".
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.
121
133
  const lostTheOpenRace = !created && isNodeError(swapError) && swapError.code === 'EEXIST';
122
134
  if (!lostTheOpenRace) {
123
135
  await fsp.unlink(swapFile).catch(() => { });
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.3-alpha.33",
6
+ "version": "0.2.3-alpha.34",
7
7
  "description": "Utils module for Stonyx Framework",
8
8
  "repository": {
9
9
  "type": "git",