@stonyx/utils 0.2.3-alpha.31 → 0.2.3-alpha.32

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.
Files changed (2) hide show
  1. package/dist/file.js +36 -10
  2. package/package.json +1 -1
package/dist/file.js CHANGED
@@ -70,14 +70,37 @@ export async function updateFile(filePath, data, options = {}) {
70
70
  const swapFile = `${filePath}.temp-${process.pid}-${randomBytes(6).toString('base64url')}`;
71
71
  // Which stage failed, so the catch can tell "the open collided" from "the
72
72
  // open succeeded and something later failed" — see the catch.
73
- let wrote = false;
73
+ let created = false;
74
74
  try {
75
75
  // `wx` turns any residual name collision into a loud EEXIST rather than a
76
76
  // silent overwrite of another caller's swap bytes. `mode` here is masked
77
- // by the umask, so it only narrows — the chmod below sets the exact bits.
78
- await fsp.writeFile(swapFile, options.json ? objToJson(data) : String(data), { encoding: 'utf8', flag: 'wx', mode: targetMode });
79
- wrote = true;
80
- await fsp.chmod(swapFile, targetMode);
77
+ // by the umask, so it only narrows — the fchmod below sets the exact bits.
78
+ //
79
+ // The open is deliberately separate from the write. Everything after this
80
+ // line addresses the *descriptor*, never the path again, so an attacker
81
+ // with write access to the directory cannot redirect it: unlinking the
82
+ // swap path and replacing it with a symlink after this point leaves the
83
+ // handle bound to the original inode. `fsp.chmod(swapFile, ...)` on the
84
+ // path did not have that property — it followed such a symlink and
85
+ // widened an arbitrary victim file with this process's privileges
86
+ // (abofs/stonyx-utils#45, Phase 3 HIGH-3).
87
+ const handle = await fsp.open(swapFile, 'wx', targetMode);
88
+ created = true;
89
+ try {
90
+ await handle.writeFile(options.json ? objToJson(data) : String(data), 'utf8');
91
+ // fchmod(2) on the descriptor, not chmod(2) on the path. Needed at all
92
+ // because the `mode` above is umask-masked and therefore only narrows:
93
+ // a 0666 target would come back 0644 under the usual 022.
94
+ await handle.chmod(targetMode);
95
+ }
96
+ catch (writeError) {
97
+ await handle.close().catch(() => { });
98
+ throw writeError;
99
+ }
100
+ // Not in a `finally`: a close failure is a write failure (the flush can
101
+ // land here), so it has to surface rather than be swallowed — which is
102
+ // what `fsp.writeFile` did when it owned this close.
103
+ await handle.close();
81
104
  await fsp.rename(swapFile, filePath);
82
105
  }
83
106
  catch (swapError) {
@@ -88,11 +111,14 @@ export async function updateFile(filePath, data, options = {}) {
88
111
  // #44. That is EEXIST *from the write stage* — both halves matter.
89
112
  // - Code alone is not enough: `rename` surfaces EEXIST on Windows and
90
113
  // some network filesystems, and that file is one we created.
91
- // - Stage alone is not enough: a write that fails after the open
92
- // succeeded (ENOSPC, EDQUOT, EIO) also lands here with `wrote` false,
93
- // and skipping it is the orphaned partial payload this PR exists to
94
- // close.
95
- const lostTheOpenRace = !wrote && isNodeError(swapError) && swapError.code === 'EEXIST';
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".
121
+ const lostTheOpenRace = !created && isNodeError(swapError) && swapError.code === 'EEXIST';
96
122
  if (!lostTheOpenRace) {
97
123
  await fsp.unlink(swapFile).catch(() => { });
98
124
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.3-alpha.31",
6
+ "version": "0.2.3-alpha.32",
7
7
  "description": "Utils module for Stonyx Framework",
8
8
  "repository": {
9
9
  "type": "git",