@evomap/evolver-proxy 2.0.0-beta.2 → 2.0.0-beta.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.
@@ -0,0 +1,1174 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { constants } from 'node:fs';
4
+ import { chmod, lstat, mkdir, open, realpath, rename, rm, writeFile, } from 'node:fs/promises';
5
+ import { basename, dirname, isAbsolute, join, resolve, win32 } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { ops } from '@evomap/evolver-core';
8
+ import { resolveSelfUpdateTarget } from './releaseBinary.js';
9
+ import { SELF_UPDATE_FAILURE_CODES, selfUpdateFailure } from './failureCodes.js';
10
+ import { bindWindowsManagedExecutable, prepareWindowsExecutableSwap, resolveWindowsUpdaterPaths, } from './windowsUpdater.js';
11
+ const execFileAsync = promisify(execFile);
12
+ const JOURNAL_SCHEMA_VERSION = 2;
13
+ const MAX_JOURNAL_BYTES = 64 * 1024;
14
+ const MAX_RECOVERY_ATTEMPTS = 2;
15
+ const STAGED_BINARY_PREFLIGHT_TIMEOUT_MS = 15_000;
16
+ const UNIX_CONTROLLER_DIRECTORY = 'unix-controller';
17
+ const UNIX_CONTROLLER_NAME = 'evolver-recovery-controller';
18
+ const WINDOWS_CONTROLLER_DIRECTORY = 'windows-controller';
19
+ const WINDOWS_CONTROLLER_NAME = 'evolver-recovery-controller.exe';
20
+ export async function inspectDurableSelfUpdate(options) {
21
+ const paths = await resolveTransactionPaths(options, false);
22
+ if (!paths)
23
+ return { outcome: 'none' };
24
+ const loadedJournal = await readJournal(paths.journal);
25
+ if (!loadedJournal)
26
+ return { outcome: 'none' };
27
+ const journal = await bindJournalToTarget(paths, loadedJournal);
28
+ const terminal = recoveryResultForTerminal(journal);
29
+ if (terminal)
30
+ return terminal;
31
+ return recoveryResult(journal, 'pending_health');
32
+ }
33
+ export async function resolveStableUnixRecoveryControllerPath(options) {
34
+ assertUnixControllerPlatform(options.platform ?? process.platform);
35
+ const configuredTargetPath = resolveSelfUpdateTarget(options).path;
36
+ const targetPath = await canonicalLogicalTargetPath(configuredTargetPath);
37
+ const configuredStateDir = nonBlank(options.stateDir) ?? nonBlank(options.env?.['EVOLVER_SELF_UPDATE_STATE_DIR']);
38
+ return stableUnixRecoveryControllerPathForTarget(targetPath, configuredStateDir);
39
+ }
40
+ export function stableUnixRecoveryControllerPathForTarget(targetPath, stateDir) {
41
+ const root = resolve(nonBlank(stateDir) ?? join(dirname(resolve(targetPath)), '.evolver-update'));
42
+ return join(root, UNIX_CONTROLLER_DIRECTORY, UNIX_CONTROLLER_NAME);
43
+ }
44
+ /**
45
+ * Installs an executable copy outside the mutable target path. The transaction
46
+ * lock and the existing no-follow file primitives keep service installation
47
+ * from racing an update or copying through a symlink.
48
+ */
49
+ export async function provisionStableUnixRecoveryController(options) {
50
+ assertUnixControllerPlatform(options.platform ?? process.platform);
51
+ const paths = await resolveTransactionPaths(options, true);
52
+ const owner = await acquireLock(paths.lock, options.pid ?? process.pid);
53
+ const controllerDirectory = join(paths.root, UNIX_CONTROLLER_DIRECTORY);
54
+ const controllerPath = join(controllerDirectory, UNIX_CONTROLLER_NAME);
55
+ const temporaryPath = join(controllerDirectory, `.${UNIX_CONTROLLER_NAME}.${randomBytes(8).toString('hex')}.tmp`);
56
+ try {
57
+ const loadedJournal = await readJournal(paths.journal);
58
+ if (loadedJournal) {
59
+ const journal = await bindJournalToTarget(paths, loadedJournal);
60
+ if (!isTerminal(journal.stage) || journal.stage === 'rollback_failed') {
61
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, `controller_install_pending_${journal.stage}`);
62
+ }
63
+ }
64
+ await ensureSecureDirectory(paths.root);
65
+ await ensureSecureDirectory(controllerDirectory);
66
+ const targetIdentity = await assertRegularNonSymlink(paths.targetPath, 'target');
67
+ const targetBytes = await readRegularFile(paths.targetPath);
68
+ await writeExclusiveFile(temporaryPath, targetBytes, 0o700);
69
+ await assertSameFileIdentity(paths.targetPath, targetIdentity);
70
+ await rename(temporaryPath, controllerPath);
71
+ await chmod(controllerPath, 0o700);
72
+ return controllerPath;
73
+ }
74
+ finally {
75
+ await rm(temporaryPath, { force: true }).catch(() => { });
76
+ await releaseLock(paths.lock, owner).catch(() => { });
77
+ }
78
+ }
79
+ export async function bindStableUnixRecoveryController(options, processExecPath) {
80
+ assertUnixControllerPlatform(options.platform ?? process.platform);
81
+ const paths = await resolveTransactionPaths(options, false);
82
+ if (!paths) {
83
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'unix_controller_state_missing');
84
+ }
85
+ const controllerPath = join(paths.root, UNIX_CONTROLLER_DIRECTORY, UNIX_CONTROLLER_NAME);
86
+ await assertRegularNonSymlink(controllerPath, 'unix_controller');
87
+ await assertRegularNonSymlink(paths.targetPath, 'target');
88
+ const [actualController, expectedController] = await Promise.all([
89
+ realpath(processExecPath),
90
+ realpath(controllerPath),
91
+ ]);
92
+ if (actualController !== expectedController) {
93
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'unix_controller_exec_mismatch');
94
+ }
95
+ return { controllerPath: expectedController, targetPath: paths.targetPath };
96
+ }
97
+ export async function bindStableWindowsRecoveryController(options, processExecPath) {
98
+ if ((options.platform ?? process.platform) !== 'win32') {
99
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'windows_controller_unsupported_platform');
100
+ }
101
+ const paths = await resolveTransactionPaths(options, false);
102
+ if (!paths) {
103
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'windows_controller_state_missing');
104
+ }
105
+ await assertRegularNonSymlink(paths.targetPath, 'target');
106
+ const boundController = await bindWindowsManagedExecutable({
107
+ stateDir: paths.root,
108
+ executablePath: processExecPath,
109
+ relativePath: [WINDOWS_CONTROLLER_DIRECTORY, WINDOWS_CONTROLLER_NAME],
110
+ label: 'windows_controller',
111
+ platform: 'win32',
112
+ });
113
+ return {
114
+ controllerPath: boundController.executablePath,
115
+ stateDir: boundController.stateDir,
116
+ targetPath: paths.targetPath,
117
+ };
118
+ }
119
+ export function stableWindowsRecoveryControllerPathForStateDir(stateDir) {
120
+ return join(resolve(stateDir), WINDOWS_CONTROLLER_DIRECTORY, WINDOWS_CONTROLLER_NAME);
121
+ }
122
+ /**
123
+ * Provision or refresh the long-lived controller while it is not running.
124
+ * Service installation stops the Scheduled Task before calling this command;
125
+ * each self-update only replaces the separate windows-updater worker path.
126
+ */
127
+ export async function provisionStableWindowsRecoveryController(options, processExecPath) {
128
+ if ((options.platform ?? process.platform) !== 'win32') {
129
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'windows_controller_unsupported_platform');
130
+ }
131
+ const paths = await resolveTransactionPaths(options, true);
132
+ const owner = await acquireLock(paths.lock, options.pid ?? process.pid);
133
+ const controllerDirectory = join(paths.root, WINDOWS_CONTROLLER_DIRECTORY);
134
+ const controllerPath = stableWindowsRecoveryControllerPathForStateDir(paths.root);
135
+ const temporaryPath = join(controllerDirectory, `.${WINDOWS_CONTROLLER_NAME}.${randomBytes(8).toString('hex')}.tmp`);
136
+ try {
137
+ const loadedJournal = await readJournal(paths.journal);
138
+ if (loadedJournal) {
139
+ const journal = await bindJournalToTarget(paths, loadedJournal);
140
+ if (!isTerminal(journal.stage) || journal.stage === 'rollback_failed') {
141
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, `windows_controller_install_pending_${journal.stage}`);
142
+ }
143
+ }
144
+ const [actualSource, expectedSource] = await Promise.all([
145
+ realpath(processExecPath),
146
+ realpath(paths.targetPath),
147
+ ]);
148
+ if (normalizeCanonicalSelfUpdateTargetPath(actualSource, 'win32')
149
+ !== normalizeCanonicalSelfUpdateTargetPath(expectedSource, 'win32')) {
150
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'windows_controller_source_mismatch');
151
+ }
152
+ await ensureSecureDirectory(paths.root);
153
+ await ensureSecureDirectory(controllerDirectory);
154
+ const targetIdentity = await assertRegularNonSymlink(paths.targetPath, 'target');
155
+ const targetBytes = await readRegularFile(paths.targetPath);
156
+ await writeExclusiveFile(temporaryPath, targetBytes, 0o700);
157
+ await assertSameFileIdentity(paths.targetPath, targetIdentity);
158
+ try {
159
+ await assertRegularNonSymlink(controllerPath, 'windows_controller');
160
+ await rm(controllerPath);
161
+ }
162
+ catch (error) {
163
+ if (!isErrno(error, 'ENOENT'))
164
+ throw error;
165
+ }
166
+ await rename(temporaryPath, controllerPath);
167
+ await chmod(controllerPath, 0o700);
168
+ return controllerPath;
169
+ }
170
+ finally {
171
+ await rm(temporaryPath, { force: true }).catch(() => { });
172
+ await releaseLock(paths.lock, owner).catch(() => { });
173
+ }
174
+ }
175
+ export async function beginDurableSelfUpdate(targetVersion, options) {
176
+ const paths = await resolveTransactionPaths(options, true);
177
+ const owner = await acquireLock(paths.lock, options.pid ?? process.pid, options.beforeStaleLockReclaim);
178
+ const now = options.now ?? (() => new Date());
179
+ let journal;
180
+ let released = false;
181
+ try {
182
+ const loadedExisting = await readJournal(paths.journal);
183
+ const existing = loadedExisting ? await bindJournalToTarget(paths, loadedExisting) : undefined;
184
+ if (existing?.stage === 'rollback_failed') {
185
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.ROLLBACK_FAILED, 'pending_rollback_failed');
186
+ }
187
+ if (existing && !isTerminal(existing.stage)) {
188
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, `pending_${existing.stage}`);
189
+ }
190
+ await ensureSecureDirectory(paths.root);
191
+ await ensureSecureDirectory(paths.staging);
192
+ await ensureSecureDirectory(paths.backups);
193
+ if (existing)
194
+ await rm(paths.journal, { force: true });
195
+ const transactionId = `${Date.now()}-${randomBytes(8).toString('hex')}`;
196
+ const createdAt = now().toISOString();
197
+ journal = {
198
+ schema_version: JOURNAL_SCHEMA_VERSION,
199
+ transaction_id: transactionId,
200
+ stage: 'preparing',
201
+ from_version: options.currentVersion,
202
+ target_version: targetVersion,
203
+ platform: options.platform ?? process.platform,
204
+ arch: options.arch ?? process.arch,
205
+ installing_pid: options.pid ?? process.pid,
206
+ created_at: createdAt,
207
+ updated_at: createdAt,
208
+ recovery_attempts: 0,
209
+ target_path: await canonicalLogicalTargetPath(paths.targetPath),
210
+ configured_target_path: paths.configuredTargetPath,
211
+ };
212
+ await writeJournal(paths.journal, journal);
213
+ const persist = async (stage, patch = {}) => {
214
+ journal = { ...journal, ...patch, stage, updated_at: now().toISOString() };
215
+ await writeJournal(paths.journal, journal);
216
+ return journal;
217
+ };
218
+ const rollback = async (failureCode) => {
219
+ if (!journal)
220
+ return;
221
+ const result = await rollbackJournal(paths, persist, failureCode, options);
222
+ if (result.outcome === 'blocked') {
223
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.ROLLBACK_FAILED, failureCode);
224
+ }
225
+ };
226
+ return {
227
+ async adoptDownloaded(download) {
228
+ const stagedName = `${journal.transaction_id}.staged`;
229
+ const managedPath = join(paths.staging, stagedName);
230
+ await moveRegularFile(download.stagedPath, managedPath, 0o700);
231
+ await persist('downloaded', { staged_name: stagedName });
232
+ return { ...download, stagedPath: managedPath };
233
+ },
234
+ async markVerified(artifacts) {
235
+ if (!journal?.staged_name)
236
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'staging_missing');
237
+ if (artifacts.length !== 1) {
238
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.REJECTED_VERIFICATION, 'staged_artifact_count_invalid');
239
+ }
240
+ const artifact = artifacts[0];
241
+ const verifiedSha256 = artifact.sha256?.toLowerCase()
242
+ ?? (artifact.bytes ? createHash('sha256').update(artifact.bytes).digest('hex') : undefined);
243
+ if (!verifiedSha256 || !/^[0-9a-f]{64}$/.test(verifiedSha256)) {
244
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.REJECTED_VERIFICATION, 'staged_artifact_digest_missing');
245
+ }
246
+ await persist('verified', { verified_sha256: verifiedSha256 });
247
+ },
248
+ async install() {
249
+ if (!journal?.staged_name || !journal.verified_sha256 || journal.stage !== 'verified') {
250
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'install_before_verified');
251
+ }
252
+ const stagedPath = join(paths.staging, journal.staged_name);
253
+ try {
254
+ await preflightManagedStagedBinary(stagedPath, journal.target_version, options.stagedBinaryProbe);
255
+ }
256
+ catch (error) {
257
+ await persist('rolled_back', { failure_code: SELF_UPDATE_FAILURE_CODES.REJECTED_VERIFICATION });
258
+ await cleanupTransactionFiles(paths, journal);
259
+ throw error;
260
+ }
261
+ const backupName = `${journal.transaction_id}.backup`;
262
+ const backupPath = join(paths.backups, backupName);
263
+ const targetStat = await assertRegularNonSymlink(paths.targetPath, 'target');
264
+ const targetMode = Number(targetStat.mode) & 0o777;
265
+ await copyRegularFile(paths.targetPath, backupPath, targetMode);
266
+ await persist('backed_up', { backup_name: backupName });
267
+ if ((options.platform ?? process.platform) === 'win32') {
268
+ try {
269
+ const stagedBytes = await readRegularFile(stagedPath);
270
+ const stagedSha256 = createHash('sha256').update(stagedBytes).digest('hex');
271
+ if (stagedSha256 !== journal.verified_sha256) {
272
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.REJECTED_VERIFICATION, 'staged_artifact_changed_after_verification');
273
+ }
274
+ // Persist intent before publishing pending.json. A crash before
275
+ // publication restarts the untouched old target; a crash after
276
+ // publication lets the stable controller run the worker and complete the swap.
277
+ await persist('install_pending');
278
+ await prepareWindowsExecutableSwap({
279
+ operation: 'install',
280
+ targetPath: paths.targetPath,
281
+ stagedPath,
282
+ expectedStagedSha256: journal.verified_sha256,
283
+ backupPath,
284
+ stateDir: paths.root,
285
+ platform: 'win32',
286
+ });
287
+ return;
288
+ }
289
+ catch (error) {
290
+ await persist('rolled_back', { failure_code: SELF_UPDATE_FAILURE_CODES.COPY_FAILED });
291
+ await cleanupTransactionFiles(paths, journal);
292
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.COPY_FAILED, errorCode(error), { cause: error });
293
+ }
294
+ }
295
+ const installTmp = join(dirname(paths.targetPath), `.${journal.transaction_id}.evolver-install`);
296
+ try {
297
+ const stagedBytes = await readRegularFile(stagedPath);
298
+ const stagedSha256 = createHash('sha256').update(stagedBytes).digest('hex');
299
+ if (stagedSha256 !== journal.verified_sha256) {
300
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.REJECTED_VERIFICATION, 'staged_artifact_changed_after_verification');
301
+ }
302
+ await writeExclusiveFile(installTmp, stagedBytes, targetMode);
303
+ await assertSameFileIdentity(paths.targetPath, targetStat);
304
+ await rename(installTmp, paths.targetPath);
305
+ await persist('installed');
306
+ }
307
+ catch (error) {
308
+ await rm(installTmp, { force: true }).catch(() => { });
309
+ await rollback(SELF_UPDATE_FAILURE_CODES.COPY_FAILED);
310
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.COPY_FAILED, errorCode(error), { cause: error });
311
+ }
312
+ },
313
+ async markRestartRequested() {
314
+ if (journal?.stage !== 'installed' && journal?.stage !== 'install_pending') {
315
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'restart_before_install');
316
+ }
317
+ await persist('restarted');
318
+ },
319
+ async abort(failureCode) {
320
+ if (!journal)
321
+ return;
322
+ await persist('rolled_back', { failure_code: failureCode });
323
+ await cleanupTransactionFiles(paths, journal);
324
+ },
325
+ rollback,
326
+ async release() {
327
+ if (released)
328
+ return;
329
+ released = true;
330
+ await releaseLock(paths.lock, owner);
331
+ },
332
+ };
333
+ }
334
+ catch (error) {
335
+ await releaseLock(paths.lock, owner).catch(() => { });
336
+ throw error;
337
+ }
338
+ }
339
+ export async function recoverDurableSelfUpdate(options) {
340
+ const paths = await resolveTransactionPaths(options, false);
341
+ if (!paths)
342
+ return { outcome: 'none' };
343
+ const owner = await acquireLock(paths.lock, options.pid ?? process.pid, options.beforeStaleLockReclaim);
344
+ const now = options.now ?? (() => new Date());
345
+ try {
346
+ const loadedJournal = await readJournal(paths.journal);
347
+ if (!loadedJournal)
348
+ return { outcome: 'none' };
349
+ const boundJournal = await bindJournalToTarget(paths, loadedJournal);
350
+ await options.beforeJournalMutation?.();
351
+ let journal = boundJournal;
352
+ const persist = async (stage, patch = {}) => {
353
+ journal = { ...journal, ...patch, stage, updated_at: now().toISOString() };
354
+ await writeJournal(paths.journal, journal);
355
+ return journal;
356
+ };
357
+ const terminal = recoveryResultForTerminal(journal);
358
+ if (terminal) {
359
+ if (terminal.outcome !== 'blocked')
360
+ await cleanupTransactionFiles(paths, journal);
361
+ return terminal;
362
+ }
363
+ if (journal.stage === 'preparing' || journal.stage === 'downloaded' || journal.stage === 'verified') {
364
+ await persist('rolled_back', { failure_code: 'interrupted_before_install' });
365
+ await cleanupTransactionFiles(paths, journal);
366
+ return recoveryResult(journal, 'rolled_back');
367
+ }
368
+ if (journal.stage === 'backed_up') {
369
+ if (journal.platform === 'win32') {
370
+ await persist('rolled_back', { failure_code: 'interrupted_before_install' });
371
+ await cleanupTransactionFiles(paths, journal);
372
+ return recoveryResult(journal, 'rolled_back');
373
+ }
374
+ return rollbackJournal(paths, persist, journal.failure_code ?? 'interrupted_before_install', options);
375
+ }
376
+ if (journal.stage === 'rolling_back' || journal.stage === 'rollback_pending') {
377
+ return rollbackJournal(paths, persist, journal.failure_code ?? 'interrupted_before_install', options);
378
+ }
379
+ if (journal.stage === 'install_pending') {
380
+ return {
381
+ ...recoveryResult(journal, 'blocked'),
382
+ failureCode: 'windows_install_not_applied',
383
+ };
384
+ }
385
+ const attempts = journal.recovery_attempts + 1;
386
+ await persist(journal.stage === 'health_check_pending' ? 'health_check_pending' : 'restarted', {
387
+ recovery_attempts: attempts,
388
+ });
389
+ const readBack = options.readBackVersion ?? readInstalledVersion;
390
+ let installedVersion;
391
+ if (attempts <= MAX_RECOVERY_ATTEMPTS) {
392
+ try {
393
+ installedVersion = ops.normalizeConcreteVersion(await readBack(paths.targetPath));
394
+ }
395
+ catch {
396
+ installedVersion = undefined;
397
+ }
398
+ }
399
+ if (installedVersion === ops.normalizeConcreteVersion(journal.target_version)) {
400
+ await persist('health_check_pending');
401
+ return recoveryResult(journal, 'pending_health');
402
+ }
403
+ if (installedVersion === ops.normalizeConcreteVersion(journal.from_version)) {
404
+ await persist('rolled_back', { failure_code: journal.failure_code ?? SELF_UPDATE_FAILURE_CODES.READ_BACK_FAILED });
405
+ await cleanupTransactionFiles(paths, journal);
406
+ return recoveryResult(journal, 'rolled_back');
407
+ }
408
+ return rollbackJournal(paths, persist, SELF_UPDATE_FAILURE_CODES.READ_BACK_FAILED, options);
409
+ }
410
+ finally {
411
+ await releaseLock(paths.lock, owner).catch(() => { });
412
+ }
413
+ }
414
+ export async function markWindowsInstallApplied(options) {
415
+ return mutateRecoveredTransaction(options, async (paths, journal, persist) => {
416
+ if (journal.platform !== 'win32' || journal.stage !== 'install_pending' || !journal.verified_sha256) {
417
+ return {
418
+ ...recoveryResult(journal, 'blocked'),
419
+ failureCode: 'windows_install_not_applied',
420
+ };
421
+ }
422
+ const targetSha256 = createHash('sha256').update(await readRegularFile(paths.targetPath)).digest('hex');
423
+ if (targetSha256 !== journal.verified_sha256) {
424
+ return {
425
+ ...recoveryResult(journal, 'blocked'),
426
+ failureCode: 'windows_install_not_applied',
427
+ };
428
+ }
429
+ journal = await persist('restarted');
430
+ return recoveryResult(journal, 'pending_health');
431
+ });
432
+ }
433
+ export async function confirmDurableSelfUpdate(options) {
434
+ return mutateRecoveredTransaction(options, async (paths, journal, persist) => {
435
+ const terminal = recoveryResultForTerminal(journal);
436
+ if (terminal)
437
+ return terminal;
438
+ if (journal.stage !== 'health_check_pending') {
439
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, `confirm_before_health_check:${journal.stage}`);
440
+ }
441
+ journal = await persist('confirmed');
442
+ await cleanupTransactionFiles(paths, journal);
443
+ return recoveryResult(journal, 'confirmed');
444
+ });
445
+ }
446
+ export async function rollbackDurableSelfUpdate(options, failureCode) {
447
+ return mutateRecoveredTransaction(options, async (paths, journal, persist) => {
448
+ const terminal = recoveryResultForTerminal(journal);
449
+ if (terminal)
450
+ return terminal;
451
+ return rollbackJournal(paths, persist, failureCode, options);
452
+ });
453
+ }
454
+ async function mutateRecoveredTransaction(options, mutate) {
455
+ const paths = await resolveTransactionPaths(options, false);
456
+ if (!paths)
457
+ return { outcome: 'none' };
458
+ const owner = await acquireLock(paths.lock, options.pid ?? process.pid, options.beforeStaleLockReclaim);
459
+ const now = options.now ?? (() => new Date());
460
+ try {
461
+ const loadedJournal = await readJournal(paths.journal);
462
+ if (!loadedJournal)
463
+ return { outcome: 'none' };
464
+ const boundJournal = await bindJournalToTarget(paths, loadedJournal);
465
+ await options.beforeJournalMutation?.();
466
+ let journal = boundJournal;
467
+ const persist = async (stage, patch = {}) => {
468
+ journal = { ...journal, ...patch, stage, updated_at: now().toISOString() };
469
+ await writeJournal(paths.journal, journal);
470
+ return journal;
471
+ };
472
+ return await mutate(paths, journal, persist);
473
+ }
474
+ finally {
475
+ await releaseLock(paths.lock, owner).catch(() => { });
476
+ }
477
+ }
478
+ async function rollbackJournal(paths, persist, failureCode, options) {
479
+ const current = await readJournal(paths.journal);
480
+ if (!current)
481
+ return { outcome: 'none' };
482
+ const boundCurrent = await bindJournalToTarget(paths, current);
483
+ if (boundCurrent.platform === 'win32') {
484
+ return prepareWindowsRollback(paths, boundCurrent, persist, failureCode, options);
485
+ }
486
+ let journal = await persist('rolling_back', { failure_code: failureCode });
487
+ let alreadyRestored = false;
488
+ try {
489
+ await restoreBackup(paths, journal, true);
490
+ }
491
+ catch (error) {
492
+ alreadyRestored = isErrno(error, 'ENOENT')
493
+ && await targetMatchesVersion(paths.targetPath, journal.from_version, options.readBackVersion);
494
+ if (!alreadyRestored) {
495
+ journal = await persist('rollback_failed', { failure_code: SELF_UPDATE_FAILURE_CODES.ROLLBACK_FAILED });
496
+ return recoveryResult(journal, 'blocked');
497
+ }
498
+ }
499
+ journal = await persist('rolled_back', { failure_code: failureCode });
500
+ await cleanupTransactionFiles(paths, journal);
501
+ return recoveryResult(journal, 'rolled_back', !alreadyRestored);
502
+ }
503
+ async function prepareWindowsRollback(paths, current, persist, failureCode, options) {
504
+ const pendingPath = resolveWindowsUpdaterPaths(paths.root).pendingPath;
505
+ let pendingExists = await regularFileExists(pendingPath);
506
+ if ((current.stage === 'install_pending' || current.stage === 'restarted') && pendingExists) {
507
+ const targetStillMatchesBackup = await targetMatchesBackup(paths, current);
508
+ await removeRegularWindowsPending(paths.root);
509
+ pendingExists = false;
510
+ if (targetStillMatchesBackup) {
511
+ const cancelled = await persist('rolled_back', { failure_code: failureCode });
512
+ await cleanupTransactionFiles(paths, cancelled);
513
+ return recoveryResult(cancelled, 'rolled_back');
514
+ }
515
+ }
516
+ let journal = current.stage === 'rollback_pending'
517
+ ? current
518
+ : await persist('rollback_pending', { failure_code: failureCode });
519
+ if (await targetMatchesVersion(paths.targetPath, journal.from_version, options.readBackVersion)) {
520
+ try {
521
+ await removeRegularWindowsPending(paths.root);
522
+ }
523
+ catch {
524
+ journal = await persist('rollback_failed', { failure_code: SELF_UPDATE_FAILURE_CODES.ROLLBACK_FAILED });
525
+ return recoveryResult(journal, 'blocked');
526
+ }
527
+ journal = await persist('rolled_back', { failure_code: failureCode });
528
+ await cleanupTransactionFiles(paths, journal);
529
+ return recoveryResult(journal, 'rolled_back');
530
+ }
531
+ if (!journal.backup_name) {
532
+ journal = await persist('rollback_failed', { failure_code: SELF_UPDATE_FAILURE_CODES.ROLLBACK_FAILED });
533
+ return recoveryResult(journal, 'blocked');
534
+ }
535
+ if (!pendingExists) {
536
+ try {
537
+ await prepareWindowsExecutableSwap({
538
+ operation: 'rollback',
539
+ targetPath: paths.targetPath,
540
+ backupPath: join(paths.backups, journal.backup_name),
541
+ stateDir: paths.root,
542
+ helperSourcePath: options.processExecPath ?? process.execPath,
543
+ platform: 'win32',
544
+ });
545
+ }
546
+ catch {
547
+ journal = await persist('rollback_failed', { failure_code: SELF_UPDATE_FAILURE_CODES.ROLLBACK_FAILED });
548
+ return recoveryResult(journal, 'blocked');
549
+ }
550
+ }
551
+ return recoveryResult(journal, 'rollback_pending', true);
552
+ }
553
+ async function removeRegularWindowsPending(stateDir) {
554
+ const pendingPath = resolveWindowsUpdaterPaths(stateDir).pendingPath;
555
+ try {
556
+ const info = await lstat(pendingPath);
557
+ if (info.isSymbolicLink() || !info.isFile()) {
558
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'unsafe_windows_updater_pending');
559
+ }
560
+ await rm(pendingPath);
561
+ }
562
+ catch (error) {
563
+ if (!isErrno(error, 'ENOENT'))
564
+ throw error;
565
+ }
566
+ }
567
+ async function regularFileExists(path) {
568
+ try {
569
+ const info = await lstat(path);
570
+ if (info.isSymbolicLink() || !info.isFile()) {
571
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'unsafe_windows_updater_pending');
572
+ }
573
+ return true;
574
+ }
575
+ catch (error) {
576
+ if (isErrno(error, 'ENOENT'))
577
+ return false;
578
+ throw error;
579
+ }
580
+ }
581
+ async function targetMatchesVersion(targetPath, expectedVersion, readBackVersion) {
582
+ try {
583
+ const actual = ops.normalizeConcreteVersion(await (readBackVersion ?? readInstalledVersion)(targetPath));
584
+ return actual === ops.normalizeConcreteVersion(expectedVersion);
585
+ }
586
+ catch {
587
+ return false;
588
+ }
589
+ }
590
+ async function targetMatchesBackup(paths, journal) {
591
+ if (!journal.backup_name)
592
+ return false;
593
+ try {
594
+ const [targetBytes, backupBytes] = await Promise.all([
595
+ readRegularFile(paths.targetPath),
596
+ readRegularFile(join(paths.backups, journal.backup_name)),
597
+ ]);
598
+ return targetBytes.equals(backupBytes);
599
+ }
600
+ catch {
601
+ return false;
602
+ }
603
+ }
604
+ function recoveryResultForTerminal(journal) {
605
+ if (journal.stage === 'confirmed')
606
+ return recoveryResult(journal, 'confirmed');
607
+ if (journal.stage === 'rolled_back')
608
+ return recoveryResult(journal, 'rolled_back');
609
+ if (journal.stage === 'rollback_failed')
610
+ return recoveryResult(journal, 'blocked');
611
+ return undefined;
612
+ }
613
+ function recoveryResult(journal, outcome, restartRequired = false) {
614
+ return {
615
+ outcome,
616
+ stage: journal.stage,
617
+ targetVersion: journal.target_version,
618
+ fromVersion: journal.from_version,
619
+ ...(restartRequired ? { restartRequired: true } : {}),
620
+ ...(journal.failure_code ? { failureCode: journal.failure_code } : {}),
621
+ };
622
+ }
623
+ async function resolveTransactionPaths(options, create) {
624
+ let configuredTargetPath;
625
+ try {
626
+ configuredTargetPath = resolveSelfUpdateTarget(options).path;
627
+ }
628
+ catch (error) {
629
+ if (!create)
630
+ return undefined;
631
+ throw error;
632
+ }
633
+ const logicalTargetPath = resolve(configuredTargetPath);
634
+ const normalizedConfiguredTargetPath = normalizeCanonicalSelfUpdateTargetPath(logicalTargetPath);
635
+ const targetPath = create
636
+ ? await canonicalLogicalTargetPath(logicalTargetPath)
637
+ : logicalTargetPath;
638
+ const configuredStateDir = nonBlank(options.stateDir) ?? nonBlank(options.env?.['EVOLVER_SELF_UPDATE_STATE_DIR']);
639
+ // Keep the default state location tied to the configured spelling. The
640
+ // journal itself binds mutations to the canonical target, while this path
641
+ // preserves discovery of state created through a symlinked launcher path.
642
+ const root = resolve(configuredStateDir ?? join(dirname(logicalTargetPath), '.evolver-update'));
643
+ if (create) {
644
+ await ensureStateRootForLock(root);
645
+ }
646
+ else {
647
+ try {
648
+ await assertDirectoryNonSymlink(root);
649
+ }
650
+ catch (error) {
651
+ if (isErrno(error, 'ENOENT'))
652
+ return undefined;
653
+ throw error;
654
+ }
655
+ }
656
+ return {
657
+ configuredTargetPath: normalizedConfiguredTargetPath,
658
+ targetPath,
659
+ root,
660
+ journal: join(root, 'journal.json'),
661
+ lock: join(root, 'update.lock'),
662
+ staging: join(root, 'staging'),
663
+ backups: join(root, 'backups'),
664
+ };
665
+ }
666
+ function nonBlank(value) {
667
+ const trimmed = value?.trim();
668
+ return trimmed ? trimmed : undefined;
669
+ }
670
+ function assertUnixControllerPlatform(platform) {
671
+ if (platform === 'win32') {
672
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'unix_controller_unsupported_platform');
673
+ }
674
+ }
675
+ async function ensureSecureDirectory(path) {
676
+ await mkdir(path, { recursive: true, mode: 0o700 });
677
+ await assertDirectoryNonSymlink(path);
678
+ await chmod(path, 0o700);
679
+ }
680
+ async function ensureStateRootForLock(path) {
681
+ try {
682
+ await assertDirectoryNonSymlink(path);
683
+ return;
684
+ }
685
+ catch (error) {
686
+ if (!isErrno(error, 'ENOENT'))
687
+ throw error;
688
+ }
689
+ await mkdir(path, { recursive: true, mode: 0o700 });
690
+ await assertDirectoryNonSymlink(path);
691
+ await chmod(path, 0o700);
692
+ }
693
+ async function bindJournalToTarget(paths, journal) {
694
+ if (journal.schema_version !== JOURNAL_SCHEMA_VERSION || !journal.target_path) {
695
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'journal_target_missing');
696
+ }
697
+ const expected = normalizeCanonicalSelfUpdateTargetPath(journal.target_path);
698
+ if (journal.target_path !== expected) {
699
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'journal_target_not_canonical');
700
+ }
701
+ const expectedConfigured = journal.configured_target_path === undefined
702
+ ? expected
703
+ : normalizeCanonicalSelfUpdateTargetPath(journal.configured_target_path);
704
+ if (journal.configured_target_path !== undefined && journal.configured_target_path !== expectedConfigured) {
705
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'journal_configured_target_not_normalized');
706
+ }
707
+ let actual;
708
+ try {
709
+ actual = await canonicalLogicalTargetPath(paths.targetPath);
710
+ }
711
+ catch (error) {
712
+ // An explicit durable state directory can outlive the install parent. In
713
+ // that case symlinks cannot be resolved, so only the caller's matching
714
+ // logical absolute path can preserve the journal binding.
715
+ if (!hasErrnoCause(error, 'ENOENT'))
716
+ throw error;
717
+ if (paths.configuredTargetPath !== expectedConfigured) {
718
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'journal_target_mismatch');
719
+ }
720
+ paths.targetPath = expected;
721
+ return journal;
722
+ }
723
+ if (actual !== expected) {
724
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'journal_target_mismatch');
725
+ }
726
+ paths.targetPath = expected;
727
+ return journal;
728
+ }
729
+ async function canonicalLogicalTargetPath(targetPath) {
730
+ const absoluteTarget = resolve(targetPath);
731
+ let canonicalParent;
732
+ try {
733
+ canonicalParent = await realpath(dirname(absoluteTarget));
734
+ }
735
+ catch (error) {
736
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'target_parent_unresolvable', { cause: error });
737
+ }
738
+ return normalizeCanonicalSelfUpdateTargetPath(join(canonicalParent, basename(absoluteTarget)));
739
+ }
740
+ export function normalizeCanonicalSelfUpdateTargetPath(targetPath, platform = process.platform) {
741
+ if (platform !== 'win32')
742
+ return resolve(targetPath);
743
+ const withoutNamespace = targetPath
744
+ .replace(/^\\\\\?\\UNC\\/i, '\\\\')
745
+ .replace(/^\\\\\?\\/i, '');
746
+ return win32.normalize(withoutNamespace).toLowerCase();
747
+ }
748
+ async function assertDirectoryNonSymlink(path) {
749
+ const info = await lstat(path);
750
+ if (info.isSymbolicLink() || !info.isDirectory()) {
751
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'unsafe_state_directory');
752
+ }
753
+ await realpath(path);
754
+ }
755
+ async function assertRegularNonSymlink(path, kind) {
756
+ const info = await lstat(path);
757
+ if (info.isSymbolicLink() || !info.isFile()) {
758
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, `${kind}_not_regular`);
759
+ }
760
+ return info;
761
+ }
762
+ async function moveRegularFile(source, destination, mode) {
763
+ await assertRegularNonSymlink(source, 'staged');
764
+ try {
765
+ await rename(source, destination);
766
+ await chmod(destination, mode);
767
+ }
768
+ catch (error) {
769
+ if (!isErrno(error, 'EXDEV'))
770
+ throw error;
771
+ await copyRegularFile(source, destination, mode);
772
+ await rm(source, { force: true });
773
+ }
774
+ }
775
+ async function copyRegularFile(source, destination, mode) {
776
+ await writeExclusiveFile(destination, await readRegularFile(source), mode);
777
+ }
778
+ async function readRegularFile(source) {
779
+ const sourceHandle = await open(source, constants.O_RDONLY | noFollowFlag());
780
+ try {
781
+ const sourceStat = await sourceHandle.stat();
782
+ if (!sourceStat.isFile())
783
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'source_not_regular');
784
+ return await sourceHandle.readFile();
785
+ }
786
+ finally {
787
+ await sourceHandle.close().catch(() => { });
788
+ }
789
+ }
790
+ async function writeExclusiveFile(destination, bytes, mode) {
791
+ const destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
792
+ try {
793
+ await destinationHandle.writeFile(bytes);
794
+ await destinationHandle.sync();
795
+ await destinationHandle.chmod(mode);
796
+ }
797
+ finally {
798
+ await destinationHandle.close().catch(() => { });
799
+ }
800
+ }
801
+ async function restoreBackup(paths, journal, force = false) {
802
+ if (!journal.backup_name)
803
+ return;
804
+ const backupPath = join(paths.backups, journal.backup_name);
805
+ try {
806
+ await assertRegularNonSymlink(backupPath, 'backup');
807
+ }
808
+ catch (error) {
809
+ if (!force && isErrno(error, 'ENOENT'))
810
+ return;
811
+ throw error;
812
+ }
813
+ const targetInfo = await lstat(paths.targetPath).catch((error) => {
814
+ if (isErrno(error, 'ENOENT'))
815
+ return undefined;
816
+ throw error;
817
+ });
818
+ if (targetInfo?.isSymbolicLink()) {
819
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'target_became_symlink');
820
+ }
821
+ const restoreTmp = join(dirname(paths.targetPath), `.${journal.transaction_id}.evolver-rollback`);
822
+ await rm(restoreTmp, { force: true });
823
+ await copyRegularFile(backupPath, restoreTmp, Number(targetInfo?.mode ?? 0o755) & 0o777);
824
+ await rename(restoreTmp, paths.targetPath);
825
+ }
826
+ async function cleanupTransactionFiles(paths, journal) {
827
+ if (journal.staged_name)
828
+ await rm(join(paths.staging, journal.staged_name), { force: true }).catch(() => { });
829
+ if (journal.backup_name)
830
+ await rm(join(paths.backups, journal.backup_name), { force: true }).catch(() => { });
831
+ }
832
+ async function writeJournal(path, journal) {
833
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
834
+ await writeFile(tmp, `${JSON.stringify(journal)}\n`, { flag: 'wx', mode: 0o600 });
835
+ await rename(tmp, path);
836
+ await chmod(path, 0o600);
837
+ }
838
+ async function readJournal(path) {
839
+ let journalHandle;
840
+ try {
841
+ const pathInfo = await lstat(path);
842
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) {
843
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'unsafe_journal');
844
+ }
845
+ journalHandle = await open(path, constants.O_RDONLY | noFollowFlag());
846
+ }
847
+ catch (error) {
848
+ if (isErrno(error, 'ENOENT'))
849
+ return undefined;
850
+ throw error;
851
+ }
852
+ try {
853
+ const info = await journalHandle.stat();
854
+ const currentPathInfo = await lstat(path);
855
+ if (!info.isFile()
856
+ || currentPathInfo.isSymbolicLink()
857
+ || !currentPathInfo.isFile()
858
+ || Number(currentPathInfo.dev) !== Number(info.dev)
859
+ || Number(currentPathInfo.ino) !== Number(info.ino)
860
+ || info.size > MAX_JOURNAL_BYTES) {
861
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'unsafe_journal');
862
+ }
863
+ const parsed = JSON.parse(await journalHandle.readFile('utf8'));
864
+ if (!isJournal(parsed))
865
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.RECOVERY_REQUIRED, 'invalid_journal');
866
+ return parsed;
867
+ }
868
+ finally {
869
+ await journalHandle.close().catch(() => { });
870
+ }
871
+ }
872
+ function isJournal(value) {
873
+ if (!value || typeof value !== 'object' || Array.isArray(value))
874
+ return false;
875
+ const journal = value;
876
+ const validBase = typeof journal.transaction_id === 'string'
877
+ && isJournalStage(journal.stage)
878
+ && typeof journal.from_version === 'string'
879
+ && typeof journal.target_version === 'string'
880
+ && typeof journal.installing_pid === 'number'
881
+ && typeof journal.recovery_attempts === 'number'
882
+ && (journal.staged_name === undefined || isSafeManagedName(journal.staged_name, '.staged'))
883
+ && (journal.backup_name === undefined || isSafeManagedName(journal.backup_name, '.backup'))
884
+ && (journal.verified_sha256 === undefined || /^[0-9a-f]{64}$/.test(journal.verified_sha256));
885
+ if (!validBase)
886
+ return false;
887
+ if (journal.schema_version === 1) {
888
+ return journal.target_path === undefined && journal.configured_target_path === undefined;
889
+ }
890
+ return journal.schema_version === JOURNAL_SCHEMA_VERSION
891
+ && typeof journal.target_path === 'string'
892
+ && journal.target_path.length > 0
893
+ && journal.target_path.length <= 4_096
894
+ && (process.platform === 'win32' ? win32.isAbsolute(journal.target_path) : isAbsolute(journal.target_path))
895
+ && (journal.configured_target_path === undefined || (typeof journal.configured_target_path === 'string'
896
+ && journal.configured_target_path.length > 0
897
+ && journal.configured_target_path.length <= 4_096
898
+ && (process.platform === 'win32'
899
+ ? win32.isAbsolute(journal.configured_target_path)
900
+ : isAbsolute(journal.configured_target_path))
901
+ && journal.configured_target_path === normalizeCanonicalSelfUpdateTargetPath(journal.configured_target_path)));
902
+ }
903
+ function isSafeManagedName(value, suffix) {
904
+ return typeof value === 'string'
905
+ && value.length >= suffix.length + 1
906
+ && value.length <= 160
907
+ && value.endsWith(suffix)
908
+ && /^[0-9A-Za-z.-]+$/.test(value)
909
+ && !value.includes('..');
910
+ }
911
+ function isJournalStage(value) {
912
+ return value === 'preparing' || value === 'downloaded' || value === 'verified' || value === 'backed_up' || value === 'installed'
913
+ || value === 'install_pending' || value === 'restarted' || value === 'health_check_pending' || value === 'rolling_back'
914
+ || value === 'rollback_pending'
915
+ || value === 'confirmed' || value === 'rolled_back' || value === 'rollback_failed';
916
+ }
917
+ function isTerminal(stage) {
918
+ return stage === 'confirmed' || stage === 'rolled_back';
919
+ }
920
+ async function acquireLock(path, pid, beforeStaleLockReclaim) {
921
+ const owner = { pid, token: randomBytes(16).toString('hex') };
922
+ const visited = new Set();
923
+ let generationPath = path;
924
+ while (true) {
925
+ const existing = await readLockGeneration(generationPath);
926
+ if (!existing) {
927
+ if (await publishLockGeneration(path, generationPath, owner)) {
928
+ return { ...owner, generationPath };
929
+ }
930
+ continue;
931
+ }
932
+ if (visited.has(generationPath)) {
933
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UPDATE_LOCKED, 'update_lock_invalid_chain');
934
+ }
935
+ visited.add(generationPath);
936
+ const released = await lockGenerationReleased(path, existing.owner.token);
937
+ if (!released && processAlive(existing.owner.pid)) {
938
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UPDATE_LOCKED, 'update_lock_held');
939
+ }
940
+ await beforeStaleLockReclaim?.();
941
+ generationPath = successorLockGenerationPath(path, existing.owner.token);
942
+ }
943
+ }
944
+ async function releaseLock(path, lease) {
945
+ const current = await readLockGeneration(lease.generationPath);
946
+ if (current?.owner.pid !== lease.pid || current.owner.token !== lease.token)
947
+ return;
948
+ try {
949
+ await mkdir(lockReleaseMarkerPath(path, lease.token), { mode: 0o700 });
950
+ }
951
+ catch (error) {
952
+ if (!isErrno(error, 'EEXIST'))
953
+ throw error;
954
+ await lockGenerationReleased(path, lease.token);
955
+ }
956
+ }
957
+ async function publishLockGeneration(rootPath, generationPath, owner) {
958
+ const candidatePath = `${rootPath}.${owner.token}.candidate`;
959
+ let candidateCreated = false;
960
+ try {
961
+ await mkdir(candidatePath, { mode: 0o700 });
962
+ candidateCreated = true;
963
+ await writeExclusiveFile(join(candidatePath, 'owner.json'), Buffer.from(`${JSON.stringify(owner)}\n`), 0o600);
964
+ try {
965
+ // A fully populated, non-empty directory is published atomically. Unlike
966
+ // renaming the stale shared lock itself, a losing rename cannot replace a
967
+ // winner's non-empty generation directory on POSIX or Windows.
968
+ await rename(candidatePath, generationPath);
969
+ return true;
970
+ }
971
+ catch (error) {
972
+ if (!isLockPublishContention(error))
973
+ throw error;
974
+ // Only a valid, fully published generation proves that this rename lost
975
+ // the ownership race. Do not turn unrelated I/O or permission failures
976
+ // into an unbounded contention retry.
977
+ const published = await readLockGeneration(generationPath);
978
+ if (!published)
979
+ throw error;
980
+ return false;
981
+ }
982
+ }
983
+ finally {
984
+ if (candidateCreated) {
985
+ await rm(candidatePath, { force: true, recursive: true }).catch(() => { });
986
+ }
987
+ }
988
+ }
989
+ async function readLockGeneration(path) {
990
+ let info;
991
+ try {
992
+ info = await lstat(path);
993
+ }
994
+ catch (error) {
995
+ if (isErrno(error, 'ENOENT'))
996
+ return undefined;
997
+ throw error;
998
+ }
999
+ if (info.isSymbolicLink()) {
1000
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UPDATE_LOCKED, 'update_lock_unsafe');
1001
+ }
1002
+ const ownerPath = info.isDirectory()
1003
+ ? join(path, 'owner.json')
1004
+ : info.isFile()
1005
+ ? path
1006
+ : undefined;
1007
+ if (!ownerPath) {
1008
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UPDATE_LOCKED, 'update_lock_unsafe');
1009
+ }
1010
+ const owner = await readLockOwner(ownerPath);
1011
+ if (!owner) {
1012
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UPDATE_LOCKED, 'update_lock_invalid');
1013
+ }
1014
+ return { owner };
1015
+ }
1016
+ async function readLockOwner(path) {
1017
+ let handle;
1018
+ try {
1019
+ const before = await lstat(path);
1020
+ if (before.isSymbolicLink() || !before.isFile() || before.size > 4_096)
1021
+ return undefined;
1022
+ handle = await open(path, constants.O_RDONLY | noFollowFlag());
1023
+ const opened = await handle.stat();
1024
+ if (!opened.isFile()
1025
+ || opened.size > 4_096
1026
+ || Number(opened.dev) !== Number(before.dev)
1027
+ || Number(opened.ino) !== Number(before.ino))
1028
+ return undefined;
1029
+ const bytes = await handle.readFile();
1030
+ if (bytes.byteLength > 4_096)
1031
+ return undefined;
1032
+ const parsed = JSON.parse(bytes.toString('utf8'));
1033
+ if (!parsed || typeof parsed !== 'object')
1034
+ return undefined;
1035
+ const owner = parsed;
1036
+ return Number.isSafeInteger(owner.pid)
1037
+ && typeof owner.pid === 'number'
1038
+ && owner.pid > 0
1039
+ && typeof owner.token === 'string'
1040
+ && /^[0-9a-f]{32}$/.test(owner.token)
1041
+ ? { pid: owner.pid, token: owner.token }
1042
+ : undefined;
1043
+ }
1044
+ catch (error) {
1045
+ if (isErrno(error, 'ENOENT') || isErrno(error, 'ELOOP') || error instanceof SyntaxError)
1046
+ return undefined;
1047
+ throw error;
1048
+ }
1049
+ finally {
1050
+ await handle?.close().catch(() => { });
1051
+ }
1052
+ }
1053
+ async function lockGenerationReleased(rootPath, token) {
1054
+ try {
1055
+ const info = await lstat(lockReleaseMarkerPath(rootPath, token));
1056
+ if (info.isSymbolicLink() || !info.isDirectory()) {
1057
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UPDATE_LOCKED, 'update_lock_release_unsafe');
1058
+ }
1059
+ return true;
1060
+ }
1061
+ catch (error) {
1062
+ if (isErrno(error, 'ENOENT'))
1063
+ return false;
1064
+ throw error;
1065
+ }
1066
+ }
1067
+ function successorLockGenerationPath(rootPath, token) {
1068
+ return `${rootPath}.${token}.next`;
1069
+ }
1070
+ function lockReleaseMarkerPath(rootPath, token) {
1071
+ return `${rootPath}.${token}.released`;
1072
+ }
1073
+ function isLockPublishContention(error) {
1074
+ return isErrno(error, 'EEXIST')
1075
+ || isErrno(error, 'ENOTEMPTY')
1076
+ || isErrno(error, 'ENOTDIR')
1077
+ || isErrno(error, 'EISDIR')
1078
+ || (process.platform === 'win32' && isErrno(error, 'EPERM'));
1079
+ }
1080
+ function processAlive(pid) {
1081
+ if (!Number.isInteger(pid) || pid <= 0)
1082
+ return false;
1083
+ try {
1084
+ process.kill(pid, 0);
1085
+ return true;
1086
+ }
1087
+ catch (error) {
1088
+ return isErrno(error, 'EPERM');
1089
+ }
1090
+ }
1091
+ async function assertSameFileIdentity(path, expected) {
1092
+ const current = await assertRegularNonSymlink(path, 'target');
1093
+ if (Number(current.dev) !== Number(expected.dev) || Number(current.ino) !== Number(expected.ino)) {
1094
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.UNSAFE_UPDATE_PATH, 'target_changed_during_update');
1095
+ }
1096
+ }
1097
+ export async function preflightManagedStagedBinary(targetPath, expectedVersion, probe = execStagedBinaryProbe) {
1098
+ await assertRegularNonSymlink(targetPath, 'staged');
1099
+ const probeOptions = {
1100
+ cwd: dirname(targetPath),
1101
+ timeout: STAGED_BINARY_PREFLIGHT_TIMEOUT_MS,
1102
+ windowsHide: true,
1103
+ maxBuffer: 16 * 1024,
1104
+ env: stagedBinaryPreflightEnvironment(),
1105
+ };
1106
+ let versionOutput;
1107
+ try {
1108
+ versionOutput = (await probe(targetPath, ['--version'], probeOptions)).stdout;
1109
+ }
1110
+ catch (error) {
1111
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.REJECTED_VERIFICATION, 'staged_preflight_version_failed', { cause: error });
1112
+ }
1113
+ const actualVersion = versionOutput.trim().split(/\r?\n/, 1)[0] ?? '';
1114
+ let versionMatches = false;
1115
+ try {
1116
+ versionMatches = ops.normalizeConcreteVersion(actualVersion)
1117
+ === ops.normalizeConcreteVersion(expectedVersion);
1118
+ }
1119
+ catch {
1120
+ versionMatches = false;
1121
+ }
1122
+ if (!versionMatches) {
1123
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.REJECTED_VERIFICATION, 'staged_preflight_version_mismatch');
1124
+ }
1125
+ try {
1126
+ await probe(targetPath, ['proxy', '--help'], probeOptions);
1127
+ }
1128
+ catch (error) {
1129
+ throw selfUpdateFailure(SELF_UPDATE_FAILURE_CODES.REJECTED_VERIFICATION, 'staged_preflight_proxy_failed', { cause: error });
1130
+ }
1131
+ }
1132
+ async function execStagedBinaryProbe(targetPath, args, options) {
1133
+ const { stdout } = await execFileAsync(targetPath, [...args], options);
1134
+ return { stdout };
1135
+ }
1136
+ function stagedBinaryPreflightEnvironment() {
1137
+ const allowed = ['SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'TMPDIR'];
1138
+ return Object.fromEntries(allowed.flatMap((key) => process.env[key] === undefined ? [] : [[key, process.env[key]]]));
1139
+ }
1140
+ async function readInstalledVersion(targetPath) {
1141
+ const { stdout } = await execFileAsync(targetPath, ['--version'], {
1142
+ cwd: dirname(targetPath),
1143
+ timeout: 15_000,
1144
+ windowsHide: true,
1145
+ maxBuffer: 16 * 1024,
1146
+ env: readBackEnvironment(),
1147
+ });
1148
+ return stdout.trim().split(/\r?\n/, 1)[0] ?? '';
1149
+ }
1150
+ function readBackEnvironment() {
1151
+ const allowed = ['PATH', 'Path', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'TMPDIR', 'HOME'];
1152
+ return Object.fromEntries(allowed.flatMap((key) => process.env[key] === undefined ? [] : [[key, process.env[key]]]));
1153
+ }
1154
+ function noFollowFlag() {
1155
+ return typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
1156
+ }
1157
+ function isErrno(error, code) {
1158
+ return typeof error === 'object' && error !== null && error.code === code;
1159
+ }
1160
+ function hasErrnoCause(error, code) {
1161
+ let current = error;
1162
+ for (let depth = 0; depth < 4 && current && typeof current === 'object'; depth += 1) {
1163
+ if (isErrno(current, code))
1164
+ return true;
1165
+ current = current.cause;
1166
+ }
1167
+ return false;
1168
+ }
1169
+ function errorCode(error) {
1170
+ if (typeof error === 'object' && error !== null && typeof error.code === 'string') {
1171
+ return error.code;
1172
+ }
1173
+ return 'install_failed';
1174
+ }