@udondan/avanti 0.24.0 → 0.26.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.
Files changed (100) hide show
  1. package/README.md +488 -64
  2. package/dist/commands/diff.d.ts.map +1 -1
  3. package/dist/commands/diff.js +82 -18
  4. package/dist/commands/diff.js.map +1 -1
  5. package/dist/commands/lock.d.ts.map +1 -1
  6. package/dist/commands/lock.js +6 -4
  7. package/dist/commands/lock.js.map +1 -1
  8. package/dist/commands/log.d.ts.map +1 -1
  9. package/dist/commands/log.js +7 -5
  10. package/dist/commands/log.js.map +1 -1
  11. package/dist/commands/pull.d.ts.map +1 -1
  12. package/dist/commands/pull.js +682 -42
  13. package/dist/commands/pull.js.map +1 -1
  14. package/dist/commands/reset.d.ts.map +1 -1
  15. package/dist/commands/reset.js +43 -11
  16. package/dist/commands/reset.js.map +1 -1
  17. package/dist/commands/revert.d.ts.map +1 -1
  18. package/dist/commands/revert.js +68 -14
  19. package/dist/commands/revert.js.map +1 -1
  20. package/dist/condition.d.ts.map +1 -1
  21. package/dist/condition.js +9 -6
  22. package/dist/condition.js.map +1 -1
  23. package/dist/config-writeback.d.ts.map +1 -1
  24. package/dist/config-writeback.js +17 -0
  25. package/dist/config-writeback.js.map +1 -1
  26. package/dist/config.d.ts +12 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +346 -3
  29. package/dist/config.js.map +1 -1
  30. package/dist/diff.d.ts +19 -0
  31. package/dist/diff.d.ts.map +1 -1
  32. package/dist/diff.js +317 -12
  33. package/dist/diff.js.map +1 -1
  34. package/dist/extract.d.ts +4 -0
  35. package/dist/extract.d.ts.map +1 -0
  36. package/dist/extract.js +142 -0
  37. package/dist/extract.js.map +1 -0
  38. package/dist/filter.d.ts +3 -0
  39. package/dist/filter.d.ts.map +1 -0
  40. package/dist/filter.js +126 -0
  41. package/dist/filter.js.map +1 -0
  42. package/dist/history.d.ts +7 -1
  43. package/dist/history.d.ts.map +1 -1
  44. package/dist/history.js +89 -9
  45. package/dist/history.js.map +1 -1
  46. package/dist/paths.d.ts +4 -0
  47. package/dist/paths.d.ts.map +1 -1
  48. package/dist/paths.js +97 -0
  49. package/dist/paths.js.map +1 -1
  50. package/dist/processors/ini.d.ts +33 -0
  51. package/dist/processors/ini.d.ts.map +1 -0
  52. package/dist/processors/ini.js +500 -0
  53. package/dist/processors/ini.js.map +1 -0
  54. package/dist/processors/insert.d.ts.map +1 -1
  55. package/dist/processors/insert.js +338 -15
  56. package/dist/processors/insert.js.map +1 -1
  57. package/dist/processors/on.d.ts +4 -0
  58. package/dist/processors/on.d.ts.map +1 -0
  59. package/dist/processors/on.js +54 -0
  60. package/dist/processors/on.js.map +1 -0
  61. package/dist/ref.d.ts +21 -0
  62. package/dist/ref.d.ts.map +1 -0
  63. package/dist/ref.js +65 -0
  64. package/dist/ref.js.map +1 -0
  65. package/dist/sources/bitbucket.d.ts.map +1 -1
  66. package/dist/sources/bitbucket.js +51 -12
  67. package/dist/sources/bitbucket.js.map +1 -1
  68. package/dist/sources/git.d.ts.map +1 -1
  69. package/dist/sources/git.js +54 -6
  70. package/dist/sources/git.js.map +1 -1
  71. package/dist/sources/github.d.ts.map +1 -1
  72. package/dist/sources/github.js +188 -51
  73. package/dist/sources/github.js.map +1 -1
  74. package/dist/sources/gitlab.d.ts.map +1 -1
  75. package/dist/sources/gitlab.js +242 -44
  76. package/dist/sources/gitlab.js.map +1 -1
  77. package/dist/sources/index.d.ts +4 -2
  78. package/dist/sources/index.d.ts.map +1 -1
  79. package/dist/sources/index.js +220 -49
  80. package/dist/sources/index.js.map +1 -1
  81. package/dist/sources/local.d.ts +3 -0
  82. package/dist/sources/local.d.ts.map +1 -1
  83. package/dist/sources/local.js +44 -0
  84. package/dist/sources/local.js.map +1 -1
  85. package/dist/types.d.ts +38 -2
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/types.js.map +1 -1
  88. package/dist/variables-remote.d.ts +1 -1
  89. package/dist/variables-remote.d.ts.map +1 -1
  90. package/dist/variables-remote.js +4 -3
  91. package/dist/variables-remote.js.map +1 -1
  92. package/dist/variables.d.ts +1 -0
  93. package/dist/variables.d.ts.map +1 -1
  94. package/dist/variables.js +37 -2
  95. package/dist/variables.js.map +1 -1
  96. package/dist/writer.d.ts +17 -0
  97. package/dist/writer.d.ts.map +1 -1
  98. package/dist/writer.js +848 -20
  99. package/dist/writer.js.map +1 -1
  100. package/package.json +14 -10
package/dist/writer.js CHANGED
@@ -33,18 +33,774 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.sudoUserArgs = sudoUserArgs;
37
+ exports.sudoAuth = sudoAuth;
38
+ exports.sudoAtomicWrite = sudoAtomicWrite;
39
+ exports.sudoRead = sudoRead;
40
+ exports.sudoDelete = sudoDelete;
41
+ exports.sudoFileExists = sudoFileExists;
42
+ exports.sudoIsSymlink = sudoIsSymlink;
43
+ exports.sudoIsDirectory = sudoIsDirectory;
44
+ exports.sudoIsFile = sudoIsFile;
45
+ exports.sudoReadlink = sudoReadlink;
46
+ exports.sudoRun = sudoRun;
47
+ exports.getSudoFileMode = getSudoFileMode;
36
48
  exports.atomicWrite = atomicWrite;
37
49
  const crypto = __importStar(require("crypto"));
38
50
  const fs = __importStar(require("fs"));
39
51
  const path = __importStar(require("path"));
52
+ const child_process_1 = require("child_process");
53
+ function sudoUserArgs(sudo) {
54
+ return typeof sudo === 'string' ? ['-u', sudo] : [];
55
+ }
56
+ function sudoAuth(sudo = true) {
57
+ if (process.platform === 'win32') {
58
+ throw new Error('sudo is not supported on Windows');
59
+ }
60
+ const result = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), '-v'], {
61
+ stdio: 'inherit',
62
+ });
63
+ if (result.status !== 0 || result.error) {
64
+ const detail = result.error
65
+ ? result.error.message
66
+ : `exit code ${result.status ?? 'unknown'}`;
67
+ throw new Error(`sudo authentication failed: ${detail}`);
68
+ }
69
+ }
70
+ // Each target is written atomically (mktemp → tee → mv for mv-style; tee for
71
+ // in-place), but the batch is NOT collectively atomic: a failure mid-way leaves
72
+ // earlier targets already written. This mirrors the shell-level constraint —
73
+ // true batch atomicity would require a two-phase stage+rename via a privileged
74
+ // helper, which is not implemented here.
75
+ function sudoAtomicWrite(targets) {
76
+ const symlinkTargets = targets.filter((t) => t.symlinkTarget !== undefined);
77
+ const regularTargets = targets.filter((t) => t.symlinkTarget === undefined);
78
+ const mvTargets = regularTargets.filter((t) => !t.writeInPlace);
79
+ const inPlaceTargets = regularTargets.filter((t) => t.writeInPlace);
80
+ for (const t of mvTargets) {
81
+ sudoWriteMv(t);
82
+ }
83
+ for (const t of inPlaceTargets) {
84
+ sudoWriteInPlace(t);
85
+ }
86
+ for (const t of symlinkTargets) {
87
+ sudoSymlinkWrite(t);
88
+ }
89
+ }
90
+ function sudoSymlinkWrite(t) {
91
+ const sudo = t.sudo;
92
+ const resolvedTarget = path.resolve(t.targetPath);
93
+ const dir = path.dirname(resolvedTarget);
94
+ // Build the trusted-UID set (same policy as sudoWriteMv/sudoWriteInPlace):
95
+ // root, the invoking user, and the named sudo target user if applicable.
96
+ const trustedUids = buildTrustedUids(sudo);
97
+ // Validate existing ancestors BEFORE any privileged mkdir to avoid creating
98
+ // root-owned directories under untrusted/world-writable paths.
99
+ checkAncestorsSafe(sudo, t.targetPath, trustedUids, 'destination');
100
+ sudoRun(sudo, ['mkdir', '-p', '--', dir]);
101
+ // Re-validate after mkdir: intermediate directories created by mkdir -p were
102
+ // not covered by the pre-mkdir check.
103
+ checkAncestorsSafe(sudo, t.targetPath, trustedUids, 'destination');
104
+ // Refuse to write if the target path is an existing *real* directory: ln -sf
105
+ // would place the symlink inside it rather than replacing it. A symlink that
106
+ // points to a directory is fine — it can be atomically replaced.
107
+ if (!sudoIsSymlink(sudo, t.targetPath) &&
108
+ sudoIsDirectory(sudo, t.targetPath)) {
109
+ throw new Error(`symlink: ${t.targetPath} is a directory; refusing to replace it with a symlink`);
110
+ }
111
+ if (t.backupPath) {
112
+ // Only back up when the target path already holds a symlink or regular file.
113
+ // Skip backup when the path is absent (new file) to mirror sudoWriteMv.
114
+ const existingIsSymlink = sudoIsSymlink(sudo, t.targetPath);
115
+ const existingIsFile = !existingIsSymlink && sudoIsFile(sudo, t.targetPath);
116
+ if (existingIsSymlink || existingIsFile) {
117
+ const backupDir = path.dirname(t.backupPath);
118
+ const resolvedBackup = path.resolve(t.backupPath);
119
+ // Validate backup ancestors BEFORE privileged mkdir to avoid creating
120
+ // root-owned directories in an untrusted path.
121
+ checkAncestorsSafe(sudo, t.backupPath, trustedUids, 'backup');
122
+ sudoRun(sudo, ['mkdir', '-p', '--', backupDir]);
123
+ // Re-validate after mkdir: newly created intermediates are now present.
124
+ checkAncestorsSafe(sudo, t.backupPath, trustedUids, 'backup');
125
+ // Use mktemp + cp -pP + mv (same pattern as sudoWriteMv) to avoid following
126
+ // an existing symlink at backupPath. -P preserves symlinks; -p preserves
127
+ // timestamps and mode bits. Backup failure is fatal (matches sudoWriteMv).
128
+ const mktempBackup = (0, child_process_1.spawnSync)('sudo', [
129
+ ...sudoUserArgs(sudo),
130
+ 'mktemp',
131
+ path.join(path.resolve(backupDir), '.avanti-backup-XXXXXXXXXX'),
132
+ ], { stdio: ['ignore', 'pipe', 'inherit'] });
133
+ if (mktempBackup.status !== 0 || mktempBackup.error) {
134
+ const detail = mktempBackup.error
135
+ ? mktempBackup.error.message
136
+ : `exit code ${mktempBackup.status ?? 'unknown'}`;
137
+ throw new Error(`sudo mktemp failed in ${backupDir}: ${detail}`);
138
+ }
139
+ const backupTmp = mktempBackup.stdout.toString().trim();
140
+ try {
141
+ if (existingIsSymlink) {
142
+ // Store the symlink as an absolute target so the backup resolves
143
+ // correctly from backupDir regardless of whether the original target
144
+ // was relative. mktemp already created a regular file; remove it
145
+ // before symlinking.
146
+ const rawLinkTarget = sudoReadlink(sudo, t.targetPath);
147
+ if (rawLinkTarget === null)
148
+ throw new Error(`sudoReadlink failed for ${t.targetPath}`);
149
+ const absLinkTarget = path.isAbsolute(rawLinkTarget)
150
+ ? rawLinkTarget
151
+ : path.resolve(path.dirname(resolvedTarget), rawLinkTarget);
152
+ sudoRun(sudo, ['rm', '-f', '--', backupTmp]);
153
+ sudoRun(sudo, ['ln', '-s', '--', absLinkTarget, backupTmp]);
154
+ }
155
+ else {
156
+ sudoRun(sudo, ['cp', '-pP', '--', resolvedTarget, backupTmp]);
157
+ }
158
+ const backupIsDir = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-d', resolvedBackup], {
159
+ stdio: 'ignore',
160
+ }).status === 0;
161
+ if (backupIsDir) {
162
+ throw new Error(`backup path is a directory: ${t.backupPath}`);
163
+ }
164
+ sudoMv(sudo, backupTmp, resolvedBackup);
165
+ }
166
+ catch (err) {
167
+ sudoRun(sudo, ['rm', '-f', '--', backupTmp]);
168
+ throw err;
169
+ }
170
+ } // end existingIsSymlink || existingIsFile
171
+ }
172
+ // Stage the new symlink at a temp path in the same directory, then rename
173
+ // atomically into place — same pattern as sudoWriteMv for files. ln -sf
174
+ // would call unlink+symlink with a visible window; rename(2) has none.
175
+ const mktempNew = (0, child_process_1.spawnSync)('sudo', [
176
+ ...sudoUserArgs(sudo),
177
+ 'mktemp',
178
+ path.join(dir, '.avanti-symlink-XXXXXXXXXX'),
179
+ ], { stdio: ['ignore', 'pipe', 'inherit'] });
180
+ if (mktempNew.status !== 0 || mktempNew.error) {
181
+ const detail = mktempNew.error
182
+ ? mktempNew.error.message
183
+ : `exit code ${mktempNew.status ?? 'unknown'}`;
184
+ throw new Error(`sudo mktemp failed in ${dir}: ${detail}`);
185
+ }
186
+ const newTmp = mktempNew.stdout.toString().trim();
187
+ try {
188
+ // mktemp creates a regular file placeholder; remove it so we can create a
189
+ // symlink at that path.
190
+ sudoRun(sudo, ['rm', '-f', '--', newTmp]);
191
+ sudoRun(sudo, ['ln', '-s', '--', t.symlinkTarget, newTmp]);
192
+ // macOS/BSD: mv without -T follows symlinks-to-directories, moving newTmp
193
+ // inside the target directory rather than replacing the symlink. Pre-remove
194
+ // only that case; rename(2) replaces symlinks-to-files atomically on all
195
+ // platforms. Linux uses mv -T which always calls rename(2) directly.
196
+ if (process.platform !== 'linux') {
197
+ const destIsSymlinkToDir = sudoIsSymlink(sudo, t.targetPath) &&
198
+ (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-d', resolvedTarget], {
199
+ stdio: 'ignore',
200
+ }).status === 0;
201
+ if (destIsSymlinkToDir) {
202
+ sudoRun(sudo, ['rm', '-f', '--', resolvedTarget]);
203
+ }
204
+ }
205
+ sudoMv(sudo, newTmp, resolvedTarget);
206
+ }
207
+ catch (err) {
208
+ sudoRun(sudo, ['rm', '-f', '--', newTmp]);
209
+ throw err;
210
+ }
211
+ }
212
+ function sudoRead(sudo, filePath) {
213
+ // Use sudo cat with piped stdout — no temp file, no world-writable surface,
214
+ // no TOCTOU. maxBuffer is set high enough to cover any config file avanti
215
+ // would manage (individual config files are always well under 100 MB).
216
+ const absPath = path.resolve(filePath);
217
+ const result = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'cat', '--', absPath], { stdio: ['ignore', 'pipe', 'inherit'], maxBuffer: 100 * 1024 * 1024 });
218
+ if (result.error) {
219
+ // Distinguish buffer overflow (file too large to manage via sudo) from a
220
+ // normal read failure (e.g. file absent, permission denied by sudo policy).
221
+ if (result.error.message.includes('maxBuffer length exceeded')) {
222
+ throw new Error(`${filePath} exceeds the 100 MiB sudo read limit — avanti is designed for config files, not large binaries`);
223
+ }
224
+ return null;
225
+ }
226
+ if (result.status !== 0)
227
+ return null;
228
+ return result.stdout;
229
+ }
230
+ function sudoDelete(p, sudo) {
231
+ const r = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'rm', '-f', '--', p], {
232
+ stdio: 'inherit',
233
+ });
234
+ if (r.status !== 0 || r.error) {
235
+ const detail = r.error
236
+ ? r.error.message
237
+ : `exit code ${r.status ?? 'unknown'}`;
238
+ console.warn(`Warning: could not delete ${p}: ${detail}`);
239
+ return false;
240
+ }
241
+ return true;
242
+ }
243
+ function sudoFileExists(sudo, targetPath) {
244
+ const r = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-e', path.resolve(targetPath)], { stdio: 'ignore' });
245
+ if (r.error)
246
+ throw new Error(`sudo test -e failed: ${r.error.message}`);
247
+ return r.status === 0;
248
+ }
249
+ function sudoIsSymlink(sudo, targetPath) {
250
+ const r = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-L', path.resolve(targetPath)], { stdio: 'ignore' });
251
+ if (r.error)
252
+ throw new Error(`sudo test -L failed: ${r.error.message}`);
253
+ return r.status === 0;
254
+ }
255
+ function sudoIsDirectory(sudo, targetPath) {
256
+ const r = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-d', path.resolve(targetPath)], { stdio: 'ignore' });
257
+ if (r.error)
258
+ throw new Error(`sudo test -d failed: ${r.error.message}`);
259
+ return r.status === 0;
260
+ }
261
+ function sudoIsFile(sudo, targetPath) {
262
+ const r = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-f', path.resolve(targetPath)], { stdio: 'ignore' });
263
+ if (r.error)
264
+ throw new Error(`sudo test -f failed: ${r.error.message}`);
265
+ return r.status === 0;
266
+ }
267
+ function sudoReadlink(sudo, targetPath) {
268
+ const r = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'readlink', path.resolve(targetPath)], { stdio: ['ignore', 'pipe', 'ignore'] });
269
+ if (r.error)
270
+ throw new Error(`sudo readlink failed: ${r.error.message}`);
271
+ if (r.status !== 0)
272
+ return null;
273
+ return r.stdout.toString().trim();
274
+ }
275
+ function sudoRun(sudo, args) {
276
+ const r = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), ...args], {
277
+ stdio: 'inherit',
278
+ });
279
+ if (r.status !== 0 || r.error) {
280
+ const detail = r.error
281
+ ? r.error.message
282
+ : `exit code ${r.status ?? 'unknown'}`;
283
+ throw new Error(`sudo ${args.join(' ')} failed: ${detail}`);
284
+ }
285
+ }
286
+ // Performs a privileged rename of src to dst. On Linux, GNU mv -T is used so
287
+ // mv refuses to move src *inside* dst when dst is a directory — preventing a
288
+ // TOCTOU race where dst is swapped for a directory after the precheck. BSD mv
289
+ // (macOS) does not support -T, so the flag is omitted on non-Linux platforms.
290
+ function sudoMv(sudo, src, dst) {
291
+ const atomicFlag = process.platform === 'linux' ? ['-T'] : [];
292
+ const r = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'mv', ...atomicFlag, '--', src, dst], { stdio: 'inherit' });
293
+ if (r.status !== 0 || r.error) {
294
+ const detail = r.error
295
+ ? r.error.message
296
+ : `exit code ${r.status ?? 'unknown'}`;
297
+ throw new Error(`sudo mv failed for ${dst}: ${detail}`);
298
+ }
299
+ }
300
+ // Returns the UID of the file/directory owner via sudo stat, trying GNU stat
301
+ // (-c %u) then BSD/macOS stat (-f %u). Returns undefined when the path does
302
+ // not exist or the UID cannot be determined.
303
+ function getSudoOwnerUid(sudo, targetPath) {
304
+ const absPath = path.resolve(targetPath);
305
+ const gnu = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'stat', '-L', '-c', '%u', '--', absPath], { stdio: ['ignore', 'pipe', 'ignore'] });
306
+ if (gnu.status === 0) {
307
+ const uid = parseInt(gnu.stdout.toString().trim(), 10);
308
+ if (!isNaN(uid))
309
+ return uid;
310
+ }
311
+ const bsd = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'stat', '-f', '%u', absPath], { stdio: ['ignore', 'pipe', 'ignore'] });
312
+ if (bsd.status === 0) {
313
+ const uid = parseInt(bsd.stdout.toString().trim(), 10);
314
+ if (!isNaN(uid))
315
+ return uid;
316
+ }
317
+ return undefined;
318
+ }
319
+ // Returns the UID of the named OS user using `id -u`. Returns undefined when
320
+ // the user does not exist or the UID cannot be determined.
321
+ function getUserUid(username) {
322
+ const r = (0, child_process_1.spawnSync)('id', ['-u', username], {
323
+ stdio: ['ignore', 'pipe', 'ignore'],
324
+ });
325
+ if (r.status === 0) {
326
+ const uid = parseInt(r.stdout.toString().trim(), 10);
327
+ if (!isNaN(uid))
328
+ return uid;
329
+ }
330
+ return undefined;
331
+ }
332
+ // Returns the set of UIDs that are trusted to own directories used as mktemp
333
+ // staging locations. Always includes root (0) and the invoking process's own
334
+ // UID — directories the caller already owns cannot be attacked by an outside
335
+ // party, since any "attack" would be the user racing their own process. For
336
+ // named-user sudo, the target user's UID is added so that directories owned
337
+ // by that user are also accepted.
338
+ function buildTrustedUids(sudo) {
339
+ const trusted = new Set([0]);
340
+ // process.getuid is not available on Windows; guard before calling.
341
+ if (typeof process.getuid === 'function')
342
+ trusted.add(process.getuid());
343
+ if (typeof sudo === 'string') {
344
+ const namedUid = getUserUid(sudo);
345
+ if (namedUid !== undefined)
346
+ trusted.add(namedUid);
347
+ }
348
+ return trusted;
349
+ }
350
+ // Returns the existing file's permission bits as an octal string via sudo stat,
351
+ // trying GNU stat (-c %a) then BSD/macOS stat (-f %Lp). Returns undefined when
352
+ // the file does not exist or the mode cannot be determined.
353
+ function getSudoFileMode(sudo, targetPath) {
354
+ const absPath = path.resolve(targetPath); // ensure never starts with '-'
355
+ const gnu = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'stat', '-L', '-c', '%a', '--', absPath], { stdio: ['ignore', 'pipe', 'ignore'] });
356
+ if (gnu.status === 0)
357
+ return gnu.stdout.toString().trim() || undefined;
358
+ // BSD stat (macOS) does not support '--'; path.resolve() ensures no leading '-'
359
+ const bsd = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'stat', '-f', '%Lp', absPath], { stdio: ['ignore', 'pipe', 'ignore'] });
360
+ if (bsd.status === 0)
361
+ return bsd.stdout.toString().trim() || undefined;
362
+ return undefined;
363
+ }
364
+ // Verifies that a directory is safe to use as a mktemp staging location.
365
+ // Rejects directories that are group- or world-writable (mode & 0o022) because
366
+ // any member of the group or any local user could rename the just-created temp
367
+ // path to a symlink before the subsequent tee/cp opens it, redirecting the
368
+ // privileged write. When trustedUids is provided, also rejects directories
369
+ // whose owner UID is not in that set — the owner can always rename entries.
370
+ function checkDirSafe(sudo, absDir, trustedUids, label) {
371
+ const modeStr = getSudoFileMode(sudo, absDir);
372
+ if (modeStr) {
373
+ const mode = parseInt(modeStr, 8);
374
+ if (!isNaN(mode) && mode & 0o022) {
375
+ throw new Error(`sudo write: ${label} directory ${absDir} is group- or world-writable; ` +
376
+ `cannot safely create a temp file here (TOCTOU risk).`);
377
+ }
378
+ }
379
+ if (trustedUids !== undefined) {
380
+ const ownerUid = getSudoOwnerUid(sudo, absDir);
381
+ if (ownerUid !== undefined && !trustedUids.has(ownerUid)) {
382
+ throw new Error(`sudo write: ${label} directory ${absDir} is owned by UID ${ownerUid}, ` +
383
+ `not a trusted identity; cannot safely create a temp file here (TOCTOU risk).`);
384
+ }
385
+ }
386
+ }
387
+ // Walks every ancestor of targetPath (from the filesystem root down to its
388
+ // parent directory) and calls checkDirSafe on each. A single writable or
389
+ // untrusted-owned ancestor anywhere in the path is sufficient for a race:
390
+ // an attacker can swap that component to a symlink between the sudo preflight
391
+ // checks and the sudo mktemp/tee/mv, redirecting the privileged write.
392
+ function checkAncestorsSafe(sudo, targetPath, trustedUids, label) {
393
+ const ancestors = [];
394
+ let anc = path.resolve(targetPath);
395
+ while (true) {
396
+ anc = path.dirname(anc);
397
+ ancestors.unshift(anc);
398
+ if (anc === path.dirname(anc))
399
+ break; // reached filesystem root
400
+ }
401
+ for (const ancestor of ancestors) {
402
+ checkDirSafe(sudo, ancestor, trustedUids, `${label} ancestor`);
403
+ }
404
+ }
405
+ function sudoWriteMv(t) {
406
+ const sudo = t.sudo;
407
+ const dir = path.dirname(t.targetPath);
408
+ // Build the trusted-UID set for this operation. Includes root (0), the
409
+ // invoking user (who already owns the process and cannot be attacked by an
410
+ // outside party when operating in their own dirs), and the named sudo target
411
+ // user if applicable. This set is reused for all directory safety checks so
412
+ // that the same ownership policy applies to both the staging dir and the
413
+ // backup dir.
414
+ const trustedUids = buildTrustedUids(sudo);
415
+ // Validate existing ancestors BEFORE any privileged mkdir: creating root-owned
416
+ // directories in an untrusted/world-writable path is itself a side effect that
417
+ // must be prevented.
418
+ checkAncestorsSafe(sudo, t.targetPath, trustedUids, 'destination');
419
+ // Safe to create the destination directory now that all existing ancestors
420
+ // have been validated.
421
+ sudoRun(sudo, ['mkdir', '-p', '--', dir]);
422
+ // Re-validate the full ancestor chain after mkdir: when mkdir -p created
423
+ // intermediate directories, those new dirs were not covered by the pre-mkdir
424
+ // checkAncestorsSafe above (they didn't exist then). Re-running it validates
425
+ // every level, including any newly created intermediates and the final dir.
426
+ checkAncestorsSafe(sudo, t.targetPath, trustedUids, 'destination');
427
+ // Capture existing mode before writing so we can restore it after mv.
428
+ // Explicit config mode wins; existing dest mode used as fallback.
429
+ const existingMode = t.mode ? undefined : getSudoFileMode(sudo, t.targetPath);
430
+ // Use sudo mktemp for exclusive O_EXCL creation — prevents symlink/hardlink tricks
431
+ // if the destination directory is writable by other users.
432
+ // path.resolve(dir) ensures the template is always an absolute path, so mktemp
433
+ // never misinterprets it as an option (macOS mktemp doesn't support '--').
434
+ const mktempResult = (0, child_process_1.spawnSync)('sudo', [
435
+ ...sudoUserArgs(sudo),
436
+ 'mktemp',
437
+ path.join(path.resolve(dir), '.avanti-XXXXXXXXXX'),
438
+ ], { stdio: ['ignore', 'pipe', 'inherit'] });
439
+ if (mktempResult.status !== 0 || mktempResult.error) {
440
+ const detail = mktempResult.error
441
+ ? mktempResult.error.message
442
+ : `exit code ${mktempResult.status ?? 'unknown'}`;
443
+ throw new Error(`sudo mktemp failed in ${dir}: ${detail}`);
444
+ }
445
+ const tmpFile = mktempResult.stdout.toString().trim();
446
+ let backupTmp;
447
+ try {
448
+ // No '--' before tmpFile: BSD tee(1) on macOS does not support '--', and
449
+ // the mktemp-generated path is always absolute so it cannot start with '-'.
450
+ const tee = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'tee', tmpFile], {
451
+ input: t.content,
452
+ stdio: ['pipe', 'ignore', 'inherit'],
453
+ });
454
+ if (tee.status !== 0 || tee.error) {
455
+ const detail = tee.error
456
+ ? tee.error.message
457
+ : `exit code ${tee.status ?? 'unknown'}`;
458
+ throw new Error(`sudo write failed for ${t.targetPath}: ${detail}`);
459
+ }
460
+ if (t.backupPath) {
461
+ const resolvedTarget = path.resolve(t.targetPath);
462
+ const isSymlink = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-L', resolvedTarget], { stdio: 'ignore' });
463
+ const isFile = isSymlink.status !== 0 &&
464
+ (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-f', resolvedTarget], { stdio: 'ignore' }).status === 0;
465
+ if (isFile) {
466
+ const backupDir = path.dirname(t.backupPath);
467
+ // Validate backup ancestors BEFORE privileged mkdir to avoid creating
468
+ // root-owned directories in an untrusted path.
469
+ checkAncestorsSafe(sudo, t.backupPath, trustedUids, 'backup');
470
+ sudoRun(sudo, ['mkdir', '-p', '--', backupDir]);
471
+ // Re-validate the full backup ancestor chain after mkdir: intermediate
472
+ // directories created by mkdir -p were not checked before (they didn't
473
+ // exist). Re-running checkAncestorsSafe covers all levels including
474
+ // newly created intermediates and the final backupDir.
475
+ checkAncestorsSafe(sudo, t.backupPath, trustedUids, 'backup');
476
+ // Use sudo mktemp so the backup temp is created with O_EXCL under
477
+ // the privileged identity, preventing a symlink race in the backup
478
+ // directory. path.resolve(backupDir) guarantees an absolute template.
479
+ const mktempBackup = (0, child_process_1.spawnSync)('sudo', [
480
+ ...sudoUserArgs(sudo),
481
+ 'mktemp',
482
+ path.join(path.resolve(backupDir), '.avanti-backup-XXXXXXXXXX'),
483
+ ], { stdio: ['ignore', 'pipe', 'inherit'] });
484
+ if (mktempBackup.status !== 0 || mktempBackup.error) {
485
+ const detail = mktempBackup.error
486
+ ? mktempBackup.error.message
487
+ : `exit code ${mktempBackup.status ?? 'unknown'}`;
488
+ throw new Error(`sudo mktemp failed in ${backupDir}: ${detail}`);
489
+ }
490
+ backupTmp = mktempBackup.stdout.toString().trim();
491
+ // -p preserves the source file's mode bits on the backup copy.
492
+ sudoRun(sudo, ['cp', '-p', '--', resolvedTarget, backupTmp]);
493
+ const resolvedBackup = path.resolve(t.backupPath);
494
+ // test -d follows symlinks, so this also catches symlinks-to-directories.
495
+ // mv into a symlink-to-directory moves the file inside the directory rather
496
+ // than replacing the symlink, which would silently write to the wrong place.
497
+ const backupIsDir = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-d', resolvedBackup], { stdio: 'ignore' }).status === 0;
498
+ if (backupIsDir) {
499
+ throw new Error(`backup path is a directory: ${t.backupPath}`);
500
+ }
501
+ sudoMv(sudo, backupTmp, resolvedBackup);
502
+ backupTmp = undefined; // renamed into place — no cleanup needed
503
+ }
504
+ }
505
+ const resolvedTarget = path.resolve(t.targetPath);
506
+ const destIsSymlink = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-L', resolvedTarget], {
507
+ stdio: 'ignore',
508
+ }).status === 0;
509
+ if (destIsSymlink) {
510
+ // Linux: sudoMv uses mv -T which atomically replaces any path including
511
+ // symlinks — rename(2) is used directly, no pre-rm needed.
512
+ // macOS/BSD: mv follows symlinks-to-directories and would move tmpFile
513
+ // inside the symlink target instead of replacing the symlink. Pre-rm
514
+ // only that case; symlinks-to-files are replaced atomically by rename(2).
515
+ if (process.platform !== 'linux') {
516
+ const destSymlinkIsDir = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-d', resolvedTarget], { stdio: 'ignore' }).status === 0;
517
+ if (destSymlinkIsDir) {
518
+ sudoRun(sudo, ['rm', '-f', '--', resolvedTarget]);
519
+ }
520
+ }
521
+ }
522
+ else {
523
+ // Only throw for a real directory (not through a symlink).
524
+ const destIsDir = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-d', resolvedTarget], { stdio: 'ignore' }).status === 0;
525
+ if (destIsDir) {
526
+ throw new Error(`target path is a directory: ${t.targetPath}`);
527
+ }
528
+ }
529
+ sudoMv(sudo, tmpFile, resolvedTarget);
530
+ // On non-Linux, mv lacks -T so it silently moves the temp file *inside* dst
531
+ // if dst was swapped for a directory between the precheck and the rename.
532
+ // Verify the target landed as a regular file to detect this race.
533
+ if (process.platform !== 'linux') {
534
+ const landed = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-f', resolvedTarget], { stdio: 'ignore' });
535
+ if (landed.status !== 0) {
536
+ throw new Error(`sudo mv: file did not land at expected path ${t.targetPath} (destination may have been swapped)`);
537
+ }
538
+ }
539
+ // Apply mode: explicit config value wins; existing dest mode is used as fallback
540
+ // for updates so sudo mv doesn't silently change permissions. For new files with
541
+ // no explicit mode, derive from the process umask (0o666 & ~umask) so sudo and
542
+ // non-sudo writes produce the same default permissions.
543
+ // process.umask() is deprecated due to worker-thread race concerns; avanti is a
544
+ // single-threaded CLI so the race does not apply.
545
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
546
+ const mask = process.umask();
547
+ const defaultMode = (0o666 & ~mask).toString(8).padStart(4, '0');
548
+ const effectiveMode = t.mode ?? existingMode ?? defaultMode;
549
+ sudoRun(sudo, ['chmod', '--', effectiveMode, resolvedTarget]);
550
+ }
551
+ finally {
552
+ try {
553
+ sudoRun(sudo, ['rm', '-f', '--', tmpFile]);
554
+ }
555
+ catch {
556
+ // best-effort cleanup
557
+ }
558
+ if (backupTmp) {
559
+ try {
560
+ sudoRun(sudo, ['rm', '-f', '--', backupTmp]);
561
+ }
562
+ catch {
563
+ // best-effort cleanup
564
+ }
565
+ }
566
+ }
567
+ }
568
+ function sudoWriteInPlace(t) {
569
+ const sudo = t.sudo;
570
+ const dir = path.dirname(t.targetPath);
571
+ // Validate ancestors BEFORE any privileged mkdir: creating root-owned
572
+ // directories in an untrusted/world-writable path is itself a side effect
573
+ // that must be prevented.
574
+ //
575
+ // Reject writeInPlace when any ancestor directory (from / down to dir) could
576
+ // be raced. Checking only the immediate parent is insufficient: a symlink
577
+ // anywhere in the path (e.g. /tmp/link/file where /tmp is world-writable)
578
+ // can be swapped between the preflight checks and the sudo tee, redirecting
579
+ // the privileged write. Validate every ancestor so that a world-writable or
580
+ // untrusted directory anywhere in the path is detected and rejected.
581
+ // Two checks per ancestor:
582
+ // 1. Mode bits: group-write (0o020) or others-write (0o002) allow any member
583
+ // of the group or any local user to swap a component → reject.
584
+ // 2. Owner UID: the directory owner can always modify its contents and could
585
+ // race even when group/other write bits are clear.
586
+ // Trusted UIDs: root (0), invoking user, and named sudo target user.
587
+ // Use mv-style writes (writeInPlace: false) for targets in untrusted paths.
588
+ const trustedUids = buildTrustedUids(sudo);
589
+ // Collect every ancestor from / to dir (inclusive), without resolving
590
+ // symlinks (path.resolve only canonicalises . and .., not symlink targets).
591
+ const ancestors = [];
592
+ let anc = path.resolve(t.targetPath);
593
+ while (true) {
594
+ anc = path.dirname(anc);
595
+ ancestors.unshift(anc);
596
+ if (anc === path.dirname(anc))
597
+ break; // reached filesystem root
598
+ }
599
+ for (const ancestor of ancestors) {
600
+ const ancModeStr = getSudoFileMode(sudo, ancestor);
601
+ if (ancModeStr) {
602
+ const ancMode = parseInt(ancModeStr, 8);
603
+ if (!isNaN(ancMode) && ancMode & 0o022) {
604
+ throw new Error(`writeInPlace: ancestor directory ${ancestor} is group- or world-writable; ` +
605
+ `sudo writeInPlace cannot be used safely here due to TOCTOU risk. ` +
606
+ `Remove writeInPlace: true to use atomic mv-style writes instead.`);
607
+ }
608
+ }
609
+ const ancOwnerUid = getSudoOwnerUid(sudo, ancestor);
610
+ if (ancOwnerUid !== undefined && !trustedUids.has(ancOwnerUid)) {
611
+ throw new Error(`writeInPlace: ancestor directory ${ancestor} is owned by UID ${ancOwnerUid}, ` +
612
+ `not a trusted identity for this sudo operation; ` +
613
+ `sudo writeInPlace cannot be used safely here due to TOCTOU risk. ` +
614
+ `Remove writeInPlace: true to use atomic mv-style writes instead.`);
615
+ }
616
+ }
617
+ // Safe to create the destination directory now that all existing ancestors
618
+ // have been validated.
619
+ sudoRun(sudo, ['mkdir', '-p', '--', dir]);
620
+ // Re-validate the full ancestor chain after mkdir: intermediate directories
621
+ // created by mkdir -p were not covered by the pre-mkdir checkAncestorsSafe
622
+ // (they didn't exist then). Re-running it validates all levels including
623
+ // any newly created intermediates and the final destination directory.
624
+ checkAncestorsSafe(sudo, t.targetPath, trustedUids, 'destination');
625
+ let backupTmp;
626
+ const resolvedTarget = path.resolve(t.targetPath);
627
+ let preTeeMode;
628
+ let modeApplied = false;
629
+ try {
630
+ if (t.backupPath) {
631
+ const isSymlink = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-L', resolvedTarget], { stdio: 'ignore' });
632
+ const isFile = isSymlink.status !== 0 &&
633
+ (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-f', resolvedTarget], { stdio: 'ignore' }).status === 0;
634
+ if (isFile) {
635
+ const backupDir = path.dirname(t.backupPath);
636
+ // Validate backup ancestors BEFORE privileged mkdir.
637
+ checkAncestorsSafe(sudo, t.backupPath, trustedUids, 'backup');
638
+ sudoRun(sudo, ['mkdir', '-p', '--', backupDir]);
639
+ // Re-validate the full backup ancestor chain after mkdir: intermediate
640
+ // directories created by mkdir -p were not covered before (they didn't
641
+ // exist). Re-running covers all levels including newly created
642
+ // intermediates and the final backupDir.
643
+ checkAncestorsSafe(sudo, t.backupPath, trustedUids, 'backup');
644
+ // Use sudo mktemp for O_EXCL creation — prevents symlink race in backupDir.
645
+ const mktempBackup = (0, child_process_1.spawnSync)('sudo', [
646
+ ...sudoUserArgs(sudo),
647
+ 'mktemp',
648
+ path.join(path.resolve(backupDir), '.avanti-backup-XXXXXXXXXX'),
649
+ ], { stdio: ['ignore', 'pipe', 'inherit'] });
650
+ if (mktempBackup.status !== 0 || mktempBackup.error) {
651
+ const detail = mktempBackup.error
652
+ ? mktempBackup.error.message
653
+ : `exit code ${mktempBackup.status ?? 'unknown'}`;
654
+ throw new Error(`sudo mktemp failed in ${backupDir}: ${detail}`);
655
+ }
656
+ backupTmp = mktempBackup.stdout.toString().trim();
657
+ // -p preserves the source file's mode bits on the backup copy.
658
+ sudoRun(sudo, ['cp', '-p', '--', resolvedTarget, backupTmp]);
659
+ const resolvedBackup = path.resolve(t.backupPath);
660
+ // test -d follows symlinks, so this also catches symlinks-to-directories.
661
+ // mv into a symlink-to-directory moves the file inside the directory rather
662
+ // than replacing the symlink, which would silently write to the wrong place.
663
+ const backupIsDir = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-d', resolvedBackup], { stdio: 'ignore' }).status === 0;
664
+ if (backupIsDir) {
665
+ throw new Error(`backup path is a directory: ${t.backupPath}`);
666
+ }
667
+ sudoMv(sudo, backupTmp, resolvedBackup);
668
+ backupTmp = undefined;
669
+ }
670
+ }
671
+ // Refuse symlinks (sudo tee would follow them to an unintended target)
672
+ // and refuse non-regular files (FIFOs, devices, sockets), mirroring the
673
+ // non-sudo writeInPlace path.
674
+ const symlinkCheck = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-L', resolvedTarget], { stdio: 'ignore' });
675
+ if (symlinkCheck.status === 0) {
676
+ throw new Error(`writeInPlace: ${t.targetPath} is a symlink; refusing to follow`);
677
+ }
678
+ const existsCheck = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-e', resolvedTarget], { stdio: 'ignore' });
679
+ if (existsCheck.status === 0) {
680
+ const regularCheck = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'test', '-f', resolvedTarget], { stdio: 'ignore' });
681
+ if (regularCheck.status !== 0) {
682
+ throw new Error(`writeInPlace: ${t.targetPath} is not a regular file; refusing to write`);
683
+ }
684
+ }
685
+ const isNewFile = existsCheck.status !== 0;
686
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
687
+ const mask = process.umask();
688
+ const defaultMode = (0o666 & ~mask).toString(8).padStart(4, '0');
689
+ const effectiveMode = t.mode ?? (isNewFile ? defaultMode : undefined);
690
+ if (isNewFile && effectiveMode !== undefined) {
691
+ // Pre-create with an owner-writable mode so tee can write regardless of
692
+ // the requested final mode. Using the final mode directly would break
693
+ // named-user (sudo:"user") writes when that mode removes the write bit
694
+ // (e.g. 0400 or 0444): install creates the file then the subsequent
695
+ // sudo -u user tee cannot open it for writing. Creating with 0600 keeps
696
+ // the window minimal (only owner can read/write during the tee phase)
697
+ // while ensuring tee always succeeds. The final mode is applied after.
698
+ // resolvedTarget is always absolute so no '--' needed after '-m'.
699
+ sudoRun(sudo, ['install', '-m', '0600', '/dev/null', resolvedTarget]);
700
+ }
701
+ // For existing files, temporarily ensure owner-write so tee can open the
702
+ // file even when its current mode has no write bit (e.g. 0400/0444 set on
703
+ // a previous pull). For named-user sudo, chmod u+w may fail when the target
704
+ // file is owned by a different account (e.g. root:www-data 0664 — www-data
705
+ // can write via group bit but is not the owner and cannot chmod). In that
706
+ // case proceed without chmod; tee will fail here too if the write is
707
+ // actually forbidden. Only set preTeeMode when chmod succeeded so the
708
+ // finally block knows to restore the original mode only when it was changed.
709
+ if (!isNewFile) {
710
+ const capturedMode = getSudoFileMode(sudo, resolvedTarget);
711
+ try {
712
+ sudoRun(sudo, ['chmod', 'u+w', '--', resolvedTarget]);
713
+ preTeeMode = capturedMode; // only set when chmod succeeded
714
+ }
715
+ catch {
716
+ // chmod u+w failed (not owner) — proceed; tee will fail if write is
717
+ // also forbidden. preTeeMode remains undefined (nothing to restore).
718
+ }
719
+ }
720
+ // No '--' before resolvedTarget: BSD tee(1) on macOS does not support '--',
721
+ // and the path is always absolute (path.resolve) so it cannot start with '-'.
722
+ const tee = (0, child_process_1.spawnSync)('sudo', [...sudoUserArgs(sudo), 'tee', resolvedTarget], { input: t.content, stdio: ['pipe', 'ignore', 'inherit'] });
723
+ if (tee.status !== 0 || tee.error) {
724
+ const detail = tee.error
725
+ ? tee.error.message
726
+ : `exit code ${tee.status ?? 'unknown'}`;
727
+ throw new Error(`sudo write failed for ${t.targetPath}: ${detail}`);
728
+ }
729
+ // Apply mode AFTER tee. effectiveMode wins; fall back to the captured
730
+ // pre-tee mode when no explicit mode is configured (undoes the u+w chmod).
731
+ if (effectiveMode !== undefined) {
732
+ sudoRun(sudo, ['chmod', '--', effectiveMode, resolvedTarget]);
733
+ modeApplied = true;
734
+ }
735
+ else if (preTeeMode !== undefined) {
736
+ sudoRun(sudo, ['chmod', '--', preTeeMode, resolvedTarget]);
737
+ modeApplied = true;
738
+ }
739
+ }
740
+ finally {
741
+ // If tee threw before modeApplied was set, restore the pre-tee mode so
742
+ // the file does not stay more permissive after a failed pull.
743
+ if (preTeeMode !== undefined && !modeApplied) {
744
+ try {
745
+ sudoRun(sudo, ['chmod', '--', preTeeMode, resolvedTarget]);
746
+ }
747
+ catch {
748
+ // best-effort mode restore
749
+ }
750
+ }
751
+ if (backupTmp) {
752
+ try {
753
+ sudoRun(sudo, ['rm', '-f', '--', backupTmp]);
754
+ }
755
+ catch {
756
+ // best-effort cleanup
757
+ }
758
+ }
759
+ }
760
+ }
40
761
  function atomicWrite(targets, deletions = []) {
41
762
  // Stage each file as a sibling temp file on the same filesystem as the
42
763
  // destination so that renameSync (rename(2)) is atomic on POSIX.
43
- const mvTargets = targets.filter((t) => !t.writeInPlace);
44
- const inPlaceTargets = targets.filter((t) => t.writeInPlace);
764
+ const symlinkTargets = targets.filter((t) => t.symlinkTarget !== undefined);
765
+ const regularTargets = targets.filter((t) => t.symlinkTarget === undefined);
766
+ const mvTargets = regularTargets.filter((t) => !t.writeInPlace);
767
+ const inPlaceTargets = regularTargets.filter((t) => t.writeInPlace);
768
+ // Symlinks and mv-target files both use a stage-then-rename approach so
769
+ // no destination path is touched until ALL staging AND backup work is done.
45
770
  const staged = [];
771
+ // Staged temp symlinks — renamed into place in Phase 3 alongside mv targets.
772
+ const stagedLinks = [];
46
773
  const backupTemps = [];
47
774
  try {
775
+ // Phase 0 (symlink staging): create temp symlinks but do NOT rename yet.
776
+ // Renames happen in Phase 3, after backups have captured the pre-write state.
777
+ if (symlinkTargets.length > 0 && process.platform === 'win32') {
778
+ throw new Error('symlink writes are not supported on Windows');
779
+ }
780
+ for (const t of symlinkTargets) {
781
+ const dir = path.dirname(t.targetPath);
782
+ if (!fs.existsSync(dir)) {
783
+ fs.mkdirSync(dir, { recursive: true });
784
+ }
785
+ let tmpLink;
786
+ for (;;) {
787
+ tmpLink = path.join(dir, '.' +
788
+ path.basename(t.targetPath) +
789
+ '.' +
790
+ crypto.randomBytes(8).toString('hex') +
791
+ '.avanti-tmp');
792
+ try {
793
+ fs.symlinkSync(t.symlinkTarget, tmpLink);
794
+ break;
795
+ }
796
+ catch (err) {
797
+ // Retry on collision — same strategy as O_EXCL temp files.
798
+ if (err.code !== 'EEXIST')
799
+ throw err;
800
+ }
801
+ }
802
+ stagedLinks.push({ tmp: tmpLink, dest: t.targetPath });
803
+ }
48
804
  // Phase 1 (mv targets): write all temp files. Backups are deferred to
49
805
  // Phase 2 so that a staging failure here never creates an orphaned backup
50
806
  // for a destination that hasn't been modified yet.
@@ -105,35 +861,99 @@ function atomicWrite(targets, deletions = []) {
105
861
  }
106
862
  stagingEntry.effectiveMode = effectiveMode;
107
863
  }
864
+ // Pre-validate writeInPlace targets before Phase 2: if any is a symlink,
865
+ // Phase 4 will refuse to write through it. Fail early so no backup is
866
+ // created for a write that will never proceed.
867
+ for (const t of inPlaceTargets) {
868
+ const entry = fs.lstatSync(t.targetPath, { throwIfNoEntry: false });
869
+ if (entry?.isSymbolicLink()) {
870
+ throw new Error(`writeInPlace: ${t.targetPath} is a symlink; refusing to follow`);
871
+ }
872
+ }
108
873
  // Phase 2: all staging succeeded — now create backups.
109
874
  // Phase 2a: copy each source file to a uniquely-named temp in the backup
110
875
  // dir. If any copy fails, no backup destination has been touched yet.
111
876
  const backupRenames = [];
112
877
  for (const t of targets) {
113
- if (t.backupPath &&
114
- fs.lstatSync(t.targetPath, { throwIfNoEntry: false })?.isFile()) {
115
- const backupDir = path.dirname(t.backupPath);
116
- if (!fs.existsSync(backupDir)) {
117
- fs.mkdirSync(backupDir, { recursive: true });
118
- }
119
- // Copy via a uniquely-named temp file then rename so that:
120
- // (a) a symlink at backupPath is replaced, not followed, and
121
- // (b) a predictable temp path cannot be pre-created as a symlink.
122
- const backupTmp = path.join(backupDir, '.' +
123
- path.basename(t.backupPath) +
124
- '.' +
125
- crypto.randomBytes(8).toString('hex') +
126
- '.avanti-tmp');
127
- backupTemps.push(backupTmp);
128
- fs.copyFileSync(t.targetPath, backupTmp);
129
- backupRenames.push({ tmp: backupTmp, dest: t.backupPath });
878
+ if (!t.backupPath)
879
+ continue;
880
+ const existing = fs.lstatSync(t.targetPath, { throwIfNoEntry: false });
881
+ if (!existing?.isFile() && !existing?.isSymbolicLink())
882
+ continue;
883
+ if (existing.isSymbolicLink() && process.platform === 'win32') {
884
+ // fs.symlinkSync requires elevated privileges on Windows; copyFileSync
885
+ // would dereference the link and copy its target's contents, which is
886
+ // misleading and can read files outside the working directory. Skip
887
+ // before creating backupDir so no empty directory is left behind.
888
+ console.warn(`Warning: cannot back up symlink ${t.targetPath} on Windows; backup skipped.`);
889
+ continue;
130
890
  }
891
+ const backupDir = path.dirname(t.backupPath);
892
+ if (!fs.existsSync(backupDir)) {
893
+ fs.mkdirSync(backupDir, { recursive: true });
894
+ }
895
+ // Copy via a uniquely-named temp file then rename so that:
896
+ // (a) a symlink at backupPath is replaced, not followed, and
897
+ // (b) a predictable temp path cannot be pre-created as a symlink.
898
+ let backupTmp;
899
+ if (existing.isSymbolicLink()) {
900
+ // Preserve the symlink itself (not the file it points to) in the backup.
901
+ // Resolve relative targets to absolute so the backup symlink resolves
902
+ // correctly from backupDir, not just from the original link's directory.
903
+ const rawLinkTarget = fs.readlinkSync(t.targetPath);
904
+ const absLinkTarget = path.isAbsolute(rawLinkTarget)
905
+ ? rawLinkTarget
906
+ : path.resolve(path.dirname(t.targetPath), rawLinkTarget);
907
+ // Retry on EEXIST — same strategy as the symlink staging loop (Phase 0).
908
+ for (;;) {
909
+ backupTmp = path.join(backupDir, '.' +
910
+ path.basename(t.backupPath) +
911
+ '.' +
912
+ crypto.randomBytes(8).toString('hex') +
913
+ '.avanti-backup-tmp');
914
+ try {
915
+ fs.symlinkSync(absLinkTarget, backupTmp);
916
+ break;
917
+ }
918
+ catch (err) {
919
+ if (err.code !== 'EEXIST')
920
+ throw err;
921
+ }
922
+ }
923
+ }
924
+ else {
925
+ for (;;) {
926
+ backupTmp = path.join(backupDir, '.' +
927
+ path.basename(t.backupPath) +
928
+ '.' +
929
+ crypto.randomBytes(8).toString('hex') +
930
+ '.avanti-backup-tmp');
931
+ try {
932
+ fs.copyFileSync(t.targetPath, backupTmp, fs.constants.COPYFILE_EXCL);
933
+ break;
934
+ }
935
+ catch (err) {
936
+ if (err.code !== 'EEXIST') {
937
+ // copyFileSync may have created a partial file before failing
938
+ // (e.g. ENOSPC, I/O error). Remove it so no orphan is left.
939
+ fs.rmSync(backupTmp, { force: true });
940
+ throw err;
941
+ }
942
+ }
943
+ }
944
+ }
945
+ backupTemps.push(backupTmp);
946
+ backupRenames.push({ tmp: backupTmp, dest: t.backupPath });
131
947
  }
132
948
  // Phase 2b: all copies succeeded — rename each backup temp into place.
133
949
  for (const { tmp, dest } of backupRenames) {
134
950
  fs.renameSync(tmp, dest);
135
951
  }
136
- // Phase 3: atomically rename each temp file into place
952
+ // Phase 3: atomically rename all staged temps (files and symlinks) into place.
953
+ // Only now are destination paths modified — all staging and backups succeeded.
954
+ for (const s of stagedLinks) {
955
+ fs.renameSync(s.tmp, s.dest);
956
+ }
137
957
  for (const s of staged) {
138
958
  fs.renameSync(s.tmp, s.dest);
139
959
  if (s.effectiveMode !== undefined) {
@@ -223,6 +1043,14 @@ function atomicWrite(targets, deletions = []) {
223
1043
  }
224
1044
  }
225
1045
  finally {
1046
+ for (const s of stagedLinks) {
1047
+ try {
1048
+ fs.rmSync(s.tmp, { force: true });
1049
+ }
1050
+ catch {
1051
+ // already renamed into place or never created
1052
+ }
1053
+ }
226
1054
  for (const tmp of backupTemps) {
227
1055
  try {
228
1056
  fs.rmSync(tmp, { force: true });