@evomap/evolver-proxy 2.0.2 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/evolver-proxy.d.ts +12 -0
- package/dist/bin/evolver-proxy.js +192 -59
- package/dist/daemon/proxyDaemon.js +10 -0
- package/dist/daemon/systemdNotifier.d.ts +2 -0
- package/dist/daemon/systemdNotifier.js +11 -1
- package/dist/lifecycle/claimNudge.js +1 -1
- package/dist/lifecycle/deployGuard.js +1 -1
- package/dist/lifecycle/legacyNodeId.js +1 -1
- package/dist/lifecycle/manager.js +1 -1
- package/dist/llm/bodyCapture.js +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/server.js +1 -1
- package/dist/llm/traceBackfill.js +1 -1
- package/dist/llm/traceConfig.js +1 -1
- package/dist/llm/traceControl.js +1 -1
- package/dist/llm/traceEnvelope.js +1 -1
- package/dist/llm/traceSink.js +1 -1
- package/dist/llm/traceUploadPayload.js +1 -1
- package/dist/llm/upstream.js +1 -1
- package/dist/router/cachePassthrough.js +1 -1
- package/dist/router/features.js +1 -1
- package/dist/router/index.js +1 -1
- package/dist/router/messagesRoute.js +1 -1
- package/dist/router/modelRouter.js +1 -1
- package/dist/router/providerRoutes.js +1 -1
- package/dist/router/sseScan.js +1 -1
- package/dist/selfUpdate/bootstrap.d.ts +106 -13
- package/dist/selfUpdate/bootstrap.js +3418 -176
- package/dist/selfUpdate/bootstrapReadiness.d.ts +9 -0
- package/dist/selfUpdate/bootstrapReadiness.js +153 -0
- package/dist/selfUpdate/controllerLifecycleAuthority.d.ts +45 -0
- package/dist/selfUpdate/controllerLifecycleAuthority.js +61 -0
- package/dist/selfUpdate/executor.d.ts +17 -6
- package/dist/selfUpdate/executor.js +158 -58
- package/dist/selfUpdate/index.d.ts +2 -1
- package/dist/selfUpdate/index.js +2 -1
- package/dist/selfUpdate/migration.d.ts +80 -15
- package/dist/selfUpdate/migration.js +2513 -156
- package/dist/selfUpdate/policy.js +2 -8
- package/dist/selfUpdate/recoveryChildStartGate.d.ts +29 -0
- package/dist/selfUpdate/recoveryChildStartGate.js +319 -0
- package/dist/selfUpdate/releaseBinary.d.ts +3 -0
- package/dist/selfUpdate/releaseBinary.js +46 -3
- package/dist/selfUpdate/transaction.d.ts +8 -0
- package/dist/selfUpdate/transaction.js +166 -18
- package/dist/selfUpdate/unixController.d.ts +8 -0
- package/dist/selfUpdate/unixController.js +364 -38
- package/dist/selfUpdate/windowsController.d.ts +14 -2
- package/dist/selfUpdate/windowsController.js +482 -103
- package/dist/selfUpdate/windowsUpdater.d.ts +25 -0
- package/dist/selfUpdate/windowsUpdater.js +174 -7
- package/dist/sync/engine.js +1 -1
- package/package.json +3 -3
|
@@ -9,40 +9,47 @@
|
|
|
9
9
|
// RESOLVE (version / asset name / dest path)
|
|
10
10
|
// DOWNLOAD (signed manifest + binary staged under tmpdir)
|
|
11
11
|
// VERIFY (ed25519 manifest signature + sha256 + real preflight probe)
|
|
12
|
-
// INSTALL (
|
|
13
|
-
// REGISTER (
|
|
12
|
+
// INSTALL (validate target ownership; publish verified bytes atomically without replacement)
|
|
13
|
+
// REGISTER (delegate to the lifecycle bootstrap transaction runner with absolute deadlines)
|
|
14
14
|
//
|
|
15
15
|
// Migration is a convenience, never an escalation: it is skipped when disabled
|
|
16
16
|
// (EVOLVER_BOOTSTRAP_MIGRATION=0|off), for root (non-win32), CI, containers, within a
|
|
17
|
-
// bootstrap-failure cooldown window, and on unsupported platforms.
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import {
|
|
17
|
+
// bootstrap-failure cooldown window, and on unsupported platforms. Clean failures degrade
|
|
18
|
+
// to the existing 'off + warning' startup; ambiguous child/process-tree ownership requires
|
|
19
|
+
// foreground exit. Clean failures use cooldown; ambiguous ownership writes a durable blocker
|
|
20
|
+
// that keeps later foreground startups fail-closed until signed lifecycle reconciliation
|
|
21
|
+
// proves a committed or cleanly rolled-back terminal outcome.
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
+
import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
25
|
+
import { chmod as fsChmod, link as fsLink, lstat, mkdir as fsMkdir, open as fsOpen, readFile as fsReadFile, realpath as fsRealpath, rename as fsRename, rm as fsRm, rmdir as fsRmdir, unlink as fsUnlink, writeFile as fsWriteFile, } from 'node:fs/promises';
|
|
23
26
|
import { homedir } from 'node:os';
|
|
24
|
-
import { dirname, join } from 'node:path';
|
|
25
|
-
import { ops } from '@evomap/evolver-core';
|
|
27
|
+
import { dirname, isAbsolute, join, parse as parsePath, relative, resolve as resolvePath, win32 } from 'node:path';
|
|
28
|
+
import { ops, util } from '@evomap/evolver-core';
|
|
26
29
|
import { SELF_UPDATE_FAILURE_CODES } from './failureCodes.js';
|
|
27
30
|
import { resolveSelfUpdatePublicKey } from './builtinKey.js';
|
|
28
31
|
import { downloadGithubReleaseArtifact, releaseAssetName, resolveGithubReleaseManifest, } from './releaseBinary.js';
|
|
29
32
|
import { preflightManagedStagedBinary } from './transaction.js';
|
|
30
33
|
import { getCurrentVersion } from './version.js';
|
|
31
|
-
import { looksLikeContainer, recentBootstrapFailure, recordBootstrapAttempt,
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
import { looksLikeContainer, recentBootstrapFailure, recordBootstrapAttempt, resolveBootstrapStateDir, } from './bootstrap.js';
|
|
35
|
+
/** Canonical migration transaction state written next to the bootstrap attempt marker. */
|
|
36
|
+
const MIGRATION_STATE_FILE = 'migration.json';
|
|
37
|
+
// Keep the child transaction deadline shorter than its parent observation deadline so the
|
|
38
|
+
// child can durably roll back before process-tree containment becomes necessary.
|
|
39
|
+
const MIGRATION_REGISTER_TRANSACTION_BUDGET_MS = 180_000;
|
|
40
|
+
const MIGRATION_REGISTER_TIMEOUT_MS = 210_000;
|
|
41
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
42
|
+
const MAX_MIGRATION_DETAIL_LENGTH = 512;
|
|
43
|
+
const MAX_MIGRATION_STATE_BYTES = 16 * 1024;
|
|
44
|
+
const MIGRATION_LOCK_FILE = '.evolver-standalone-migration.lock';
|
|
45
|
+
const BOOTSTRAP_ATTEMPT_FILE = 'bootstrap-attempt.json';
|
|
46
|
+
const HOST_WINDOWS_SYSTEM_ROOT = process.env['SystemRoot']?.trim() || 'C:\\Windows';
|
|
40
47
|
/**
|
|
41
48
|
* Migration install home — mirrors the CLI-side lifecyclePaths home resolution
|
|
42
49
|
* (kept dependency-free across packages): EVOLVER_HOME ?? EVOMAP_HOME ?? ~/.evomap.
|
|
43
50
|
*/
|
|
44
51
|
export function resolveMigrationHome(env) {
|
|
45
|
-
return env['EVOLVER_HOME'] ?? env['EVOMAP_HOME'] ?? join(homedir(), '.evomap');
|
|
52
|
+
return resolvePath(env['EVOLVER_HOME'] ?? env['EVOMAP_HOME'] ?? join(homedir(), '.evomap'));
|
|
46
53
|
}
|
|
47
54
|
/** Migration install path for this platform: <home>/bin/<releaseAssetName>. Throws on unsupported platforms. */
|
|
48
55
|
export function resolveMigrationDestPath(env, platform = process.platform, arch = process.arch) {
|
|
@@ -60,23 +67,480 @@ export function resolveMigrationVersion(env) {
|
|
|
60
67
|
return ops.normalizeConcreteVersion(getCurrentVersion());
|
|
61
68
|
}
|
|
62
69
|
const defaultReadTextFile = (path) => readFileSync(path, 'utf8');
|
|
70
|
+
function assertTrustedMigrationStateLeafSync(path, options = {}) {
|
|
71
|
+
const existing = statMigrationStateLeafSync(path);
|
|
72
|
+
if (options.assertStateFileTrust) {
|
|
73
|
+
options.assertStateFileTrust(path);
|
|
74
|
+
}
|
|
75
|
+
else if (process.platform === 'win32') {
|
|
76
|
+
assertWindowsMigrationAclTrusted([{ path, parentOnly: false }]);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const uid = resolveProcessUid();
|
|
80
|
+
if ((uid !== undefined && existing.uid !== BigInt(uid)) || (existing.mode & 18n) !== 0n) {
|
|
81
|
+
throw new Error('untrusted_migration_state_leaf');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return existing;
|
|
85
|
+
}
|
|
86
|
+
function statMigrationStateLeafSync(path) {
|
|
87
|
+
const existing = lstatSync(path, { bigint: true });
|
|
88
|
+
if (existing.isSymbolicLink() || !existing.isFile() || existing.nlink !== 1n) {
|
|
89
|
+
throw new Error('unsafe_migration_state_leaf');
|
|
90
|
+
}
|
|
91
|
+
return existing;
|
|
92
|
+
}
|
|
93
|
+
function sameMigrationStateFileIdentity(left, right) {
|
|
94
|
+
return left.dev === right.dev
|
|
95
|
+
&& left.ino === right.ino
|
|
96
|
+
&& left.nlink === 1n
|
|
97
|
+
&& right.nlink === 1n;
|
|
98
|
+
}
|
|
99
|
+
function sameMigrationStateFileSnapshot(left, right) {
|
|
100
|
+
return sameMigrationStateFileIdentity(left, right)
|
|
101
|
+
&& left.size === right.size
|
|
102
|
+
&& left.mtimeNs === right.mtimeNs
|
|
103
|
+
&& left.ctimeNs === right.ctimeNs
|
|
104
|
+
&& left.mode === right.mode
|
|
105
|
+
&& left.uid === right.uid
|
|
106
|
+
&& left.gid === right.gid;
|
|
107
|
+
}
|
|
108
|
+
function readBoundedMigrationStateFd(fd) {
|
|
109
|
+
const bytes = Buffer.allocUnsafe(MAX_MIGRATION_STATE_BYTES + 1);
|
|
110
|
+
let offset = 0;
|
|
111
|
+
while (offset < bytes.byteLength) {
|
|
112
|
+
const read = readSync(fd, bytes, offset, bytes.byteLength - offset, offset);
|
|
113
|
+
if (read === 0)
|
|
114
|
+
break;
|
|
115
|
+
offset += read;
|
|
116
|
+
}
|
|
117
|
+
if (offset > MAX_MIGRATION_STATE_BYTES)
|
|
118
|
+
throw new Error('migration_state_too_large');
|
|
119
|
+
return bytes.subarray(0, offset);
|
|
120
|
+
}
|
|
121
|
+
function readTrustedMigrationStateText(path, options = {}) {
|
|
122
|
+
let initial;
|
|
123
|
+
try {
|
|
124
|
+
initial = assertTrustedMigrationStateLeafSync(path, options);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (isErrno(error, 'ENOENT'))
|
|
128
|
+
return undefined;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
if (initial.size > BigInt(MAX_MIGRATION_STATE_BYTES)) {
|
|
132
|
+
throw new Error('migration_state_too_large');
|
|
133
|
+
}
|
|
134
|
+
const directory = dirname(resolvePath(path));
|
|
135
|
+
if (options.assertStateDirectoryTrust) {
|
|
136
|
+
options.assertStateDirectoryTrust(directory);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
assertTrustedMigrationDirectoryChainSync(directory);
|
|
140
|
+
}
|
|
141
|
+
const beforeOpen = statMigrationStateLeafSync(path);
|
|
142
|
+
if (!sameMigrationStateFileSnapshot(initial, beforeOpen)) {
|
|
143
|
+
throw new Error('migration_state_leaf_changed');
|
|
144
|
+
}
|
|
145
|
+
const fd = openSync(path, 'r');
|
|
146
|
+
try {
|
|
147
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
148
|
+
if (!sameMigrationStateFileSnapshot(beforeOpen, opened)
|
|
149
|
+
|| opened.size > BigInt(MAX_MIGRATION_STATE_BYTES)) {
|
|
150
|
+
throw new Error('migration_state_leaf_changed');
|
|
151
|
+
}
|
|
152
|
+
const raw = readBoundedMigrationStateFd(fd);
|
|
153
|
+
const afterRead = statMigrationStateLeafSync(path);
|
|
154
|
+
const afterFdRead = fstatSync(fd, { bigint: true });
|
|
155
|
+
const finalPath = assertTrustedMigrationStateLeafSync(path, options);
|
|
156
|
+
const finalFd = fstatSync(fd, { bigint: true });
|
|
157
|
+
const confirmedRaw = readBoundedMigrationStateFd(fd);
|
|
158
|
+
const confirmedPath = statMigrationStateLeafSync(path);
|
|
159
|
+
const confirmedFd = fstatSync(fd, { bigint: true });
|
|
160
|
+
if (!sameMigrationStateFileSnapshot(opened, afterRead)
|
|
161
|
+
|| !sameMigrationStateFileSnapshot(opened, afterFdRead)
|
|
162
|
+
|| !sameMigrationStateFileSnapshot(opened, finalPath)
|
|
163
|
+
|| !sameMigrationStateFileSnapshot(opened, finalFd)
|
|
164
|
+
|| !sameMigrationStateFileSnapshot(opened, confirmedPath)
|
|
165
|
+
|| !sameMigrationStateFileSnapshot(opened, confirmedFd)
|
|
166
|
+
|| finalFd.size !== BigInt(raw.byteLength)
|
|
167
|
+
|| confirmedFd.size !== BigInt(confirmedRaw.byteLength)
|
|
168
|
+
|| !raw.equals(confirmedRaw)) {
|
|
169
|
+
throw new Error('migration_state_leaf_changed');
|
|
170
|
+
}
|
|
171
|
+
return raw.toString('utf8');
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
closeSync(fd);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
63
177
|
const defaultWriteTextFile = (path, content) => {
|
|
64
|
-
|
|
65
|
-
|
|
178
|
+
const directory = dirname(resolvePath(path));
|
|
179
|
+
assertTrustedMigrationDirectoryChainSync(directory);
|
|
180
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
181
|
+
assertTrustedMigrationDirectoryChainSync(directory);
|
|
182
|
+
if (migrationPathKey(realpathSync(directory)) !== migrationPathKey(directory)) {
|
|
183
|
+
throw new Error('unsafe_migration_state_directory');
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
assertTrustedMigrationStateLeafSync(path);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (!isErrno(error, 'ENOENT'))
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
const temporaryPath = join(directory, `.${parsePath(path).base}.${process.pid}.${randomUUID()}.tmp`);
|
|
193
|
+
let fd;
|
|
194
|
+
try {
|
|
195
|
+
fd = openSync(temporaryPath, 'wx', 0o600);
|
|
196
|
+
writeFileSync(fd, content, { encoding: 'utf8' });
|
|
197
|
+
fsyncSync(fd);
|
|
198
|
+
closeSync(fd);
|
|
199
|
+
fd = undefined;
|
|
200
|
+
renameSync(temporaryPath, path);
|
|
201
|
+
const published = lstatSync(path, { bigint: true });
|
|
202
|
+
if (published.isSymbolicLink() || !published.isFile() || published.nlink !== 1n) {
|
|
203
|
+
throw new Error('unsafe_migration_state_publication');
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const directoryFd = openSync(directory, 'r');
|
|
207
|
+
try {
|
|
208
|
+
fsyncSync(directoryFd);
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
closeSync(directoryFd);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
// Windows does not consistently allow directory handles; POSIX durability requires it.
|
|
216
|
+
if (process.platform !== 'win32')
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
if (fd !== undefined) {
|
|
222
|
+
try {
|
|
223
|
+
closeSync(fd);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Advisory state publication has already failed; cleanup is best effort.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
unlinkSync(temporaryPath);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Atomic temp names are owner-only inside a trusted directory; retry on next state write.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
66
236
|
};
|
|
67
237
|
function resolveProcessUid() {
|
|
68
238
|
const getuid = process.getuid;
|
|
69
239
|
return typeof getuid === 'function' ? getuid.call(process) : undefined;
|
|
70
240
|
}
|
|
71
|
-
|
|
241
|
+
function sameMigrationInstallAnchor(left, right) {
|
|
242
|
+
return left.phase === right.phase
|
|
243
|
+
&& left.temporaryPath === right.temporaryPath
|
|
244
|
+
&& left.size === right.size
|
|
245
|
+
&& left.sha256 === right.sha256
|
|
246
|
+
&& left.directoryCreated === right.directoryCreated
|
|
247
|
+
&& (left.phase === 'planned'
|
|
248
|
+
|| (right.phase !== 'planned'
|
|
249
|
+
&& left.device === right.device
|
|
250
|
+
&& left.inode === right.inode));
|
|
251
|
+
}
|
|
252
|
+
const MIGRATION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
253
|
+
const MIGRATION_DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
254
|
+
const MIGRATION_UNSIGNED_INTEGER_RE = /^[0-9]+$/;
|
|
255
|
+
const MIGRATION_POSITIVE_INTEGER_RE = /^[1-9][0-9]*$/;
|
|
256
|
+
const MIGRATION_SIGNED_INTEGER_RE = /^-?[0-9]+$/;
|
|
257
|
+
function exactMigrationRecordKeys(record, expected) {
|
|
258
|
+
const actual = Object.keys(record).sort();
|
|
259
|
+
const sortedExpected = [...expected].sort();
|
|
260
|
+
return actual.length === sortedExpected.length
|
|
261
|
+
&& actual.every((key, index) => key === sortedExpected[index]);
|
|
262
|
+
}
|
|
263
|
+
function exactMigrationTimestamp(value) {
|
|
264
|
+
if (typeof value !== 'string')
|
|
265
|
+
return false;
|
|
266
|
+
const parsed = Date.parse(value);
|
|
267
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
268
|
+
}
|
|
269
|
+
function boundedMigrationStateText(value, maxLength) {
|
|
270
|
+
return typeof value === 'string'
|
|
271
|
+
&& value.length > 0
|
|
272
|
+
&& value.length <= maxLength
|
|
273
|
+
&& !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value);
|
|
274
|
+
}
|
|
275
|
+
function parseMigrationInstallAnchor(value, destPath) {
|
|
276
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
277
|
+
return undefined;
|
|
278
|
+
const anchor = value;
|
|
279
|
+
const phase = anchor['phase'];
|
|
280
|
+
const expectedKeys = phase === 'planned'
|
|
281
|
+
? ['phase', 'temporaryPath', 'size', 'sha256', 'directoryCreated']
|
|
282
|
+
: ['phase', 'temporaryPath', 'device', 'inode', 'size', 'sha256', 'directoryCreated'];
|
|
283
|
+
if (!exactMigrationRecordKeys(anchor, expectedKeys))
|
|
284
|
+
return undefined;
|
|
285
|
+
const temporaryPath = anchor['temporaryPath'];
|
|
286
|
+
const device = anchor['device'];
|
|
287
|
+
const inode = anchor['inode'];
|
|
288
|
+
const size = anchor['size'];
|
|
289
|
+
const digest = anchor['sha256'];
|
|
290
|
+
const directoryCreated = anchor['directoryCreated'];
|
|
291
|
+
if (typeof temporaryPath !== 'string'
|
|
292
|
+
|| !isAbsolute(temporaryPath)
|
|
293
|
+
|| temporaryPath.length > 4_096
|
|
294
|
+
|| /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(temporaryPath)
|
|
295
|
+
|| dirname(temporaryPath) !== dirname(destPath)
|
|
296
|
+
|| !parsePath(temporaryPath).base.startsWith(`.${parsePath(destPath).base}.`)
|
|
297
|
+
|| !parsePath(temporaryPath).base.endsWith('.migration')
|
|
298
|
+
|| typeof size !== 'number'
|
|
299
|
+
|| !Number.isSafeInteger(size)
|
|
300
|
+
|| size < 0
|
|
301
|
+
|| typeof digest !== 'string'
|
|
302
|
+
|| !MIGRATION_DIGEST_RE.test(digest)
|
|
303
|
+
|| typeof directoryCreated !== 'boolean') {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
if (phase === 'planned') {
|
|
307
|
+
return { phase, temporaryPath, size, sha256: digest, directoryCreated };
|
|
308
|
+
}
|
|
309
|
+
if ((phase !== 'materialized' && phase !== 'cleanup')
|
|
310
|
+
|| typeof device !== 'string'
|
|
311
|
+
|| !MIGRATION_POSITIVE_INTEGER_RE.test(device)
|
|
312
|
+
|| typeof inode !== 'string'
|
|
313
|
+
|| !MIGRATION_POSITIVE_INTEGER_RE.test(inode)) {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
return { phase, temporaryPath, device, inode, size, sha256: digest, directoryCreated };
|
|
317
|
+
}
|
|
318
|
+
function parseMigrationInstallOwnership(value) {
|
|
319
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
320
|
+
return undefined;
|
|
321
|
+
const ownership = value;
|
|
322
|
+
if (!exactMigrationRecordKeys(ownership, ['disposition', 'preimage', 'directoryCreated'])
|
|
323
|
+
|| (ownership['disposition'] !== 'installed' && ownership['disposition'] !== 'converged')
|
|
324
|
+
|| (ownership['preimage'] !== 'absent' && ownership['preimage'] !== 'matching_target')
|
|
325
|
+
|| typeof ownership['directoryCreated'] !== 'boolean'
|
|
326
|
+
|| (ownership['disposition'] === 'installed' && ownership['preimage'] !== 'absent')
|
|
327
|
+
|| (ownership['disposition'] === 'converged'
|
|
328
|
+
&& (ownership['preimage'] !== 'matching_target' || ownership['directoryCreated']))) {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
disposition: ownership['disposition'],
|
|
333
|
+
preimage: ownership['preimage'],
|
|
334
|
+
directoryCreated: ownership['directoryCreated'],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function parseMigrationTargetIdentity(value, destPath) {
|
|
338
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
339
|
+
return undefined;
|
|
340
|
+
const identity = value;
|
|
341
|
+
if (!exactMigrationRecordKeys(identity, ['path', 'device', 'inode', 'size', 'linkCount', 'mtimeNs', 'ctimeNs', 'sha256'])) {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
const path = identity['path'];
|
|
345
|
+
const device = identity['device'];
|
|
346
|
+
const inode = identity['inode'];
|
|
347
|
+
const size = identity['size'];
|
|
348
|
+
const linkCount = identity['linkCount'];
|
|
349
|
+
const mtimeNs = identity['mtimeNs'];
|
|
350
|
+
const ctimeNs = identity['ctimeNs'];
|
|
351
|
+
const digest = identity['sha256'];
|
|
352
|
+
if (path !== destPath
|
|
353
|
+
|| typeof device !== 'string'
|
|
354
|
+
|| !MIGRATION_UNSIGNED_INTEGER_RE.test(device)
|
|
355
|
+
|| typeof inode !== 'string'
|
|
356
|
+
|| !MIGRATION_UNSIGNED_INTEGER_RE.test(inode)
|
|
357
|
+
|| typeof size !== 'number'
|
|
358
|
+
|| !Number.isSafeInteger(size)
|
|
359
|
+
|| size < 0
|
|
360
|
+
|| linkCount !== 1
|
|
361
|
+
|| typeof mtimeNs !== 'string'
|
|
362
|
+
|| !MIGRATION_SIGNED_INTEGER_RE.test(mtimeNs)
|
|
363
|
+
|| typeof ctimeNs !== 'string'
|
|
364
|
+
|| !MIGRATION_SIGNED_INTEGER_RE.test(ctimeNs)
|
|
365
|
+
|| typeof digest !== 'string'
|
|
366
|
+
|| !MIGRATION_DIGEST_RE.test(digest)) {
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
path: destPath,
|
|
371
|
+
device,
|
|
372
|
+
inode,
|
|
373
|
+
size,
|
|
374
|
+
linkCount: 1,
|
|
375
|
+
mtimeNs,
|
|
376
|
+
ctimeNs,
|
|
377
|
+
sha256: digest,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function parseCanonicalMigrationState(raw) {
|
|
381
|
+
if (raw.length === 0 || Buffer.byteLength(raw, 'utf8') > MAX_MIGRATION_STATE_BYTES) {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
let parsed;
|
|
385
|
+
try {
|
|
386
|
+
parsed = JSON.parse(raw);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
392
|
+
return undefined;
|
|
393
|
+
const record = parsed;
|
|
394
|
+
const version = record['version'];
|
|
395
|
+
const destPath = record['destPath'];
|
|
396
|
+
const attemptedAt = record['attemptedAt'];
|
|
397
|
+
if (record['schemaVersion'] !== 1
|
|
398
|
+
|| typeof version !== 'string'
|
|
399
|
+
|| version.length > 128
|
|
400
|
+
|| ops.normalizeConcreteVersion(version) !== version
|
|
401
|
+
|| typeof destPath !== 'string'
|
|
402
|
+
|| !isAbsolute(destPath)
|
|
403
|
+
|| destPath.length > 4_096
|
|
404
|
+
|| /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(destPath)
|
|
405
|
+
|| !exactMigrationTimestamp(attemptedAt)) {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
const base = { schemaVersion: 1, version, destPath, attemptedAt };
|
|
409
|
+
const baseKeys = ['schemaVersion', 'state', 'version', 'destPath', 'attemptedAt'];
|
|
410
|
+
if (record['state'] === 'in_progress') {
|
|
411
|
+
if (record['reason'] === undefined) {
|
|
412
|
+
return exactMigrationRecordKeys(record, baseKeys) ? { ...base, state: 'in_progress' } : undefined;
|
|
413
|
+
}
|
|
414
|
+
return record['reason'] === 'install_recovered'
|
|
415
|
+
&& exactMigrationRecordKeys(record, [...baseKeys, 'reason'])
|
|
416
|
+
? { ...base, state: 'in_progress', reason: 'install_recovered' }
|
|
417
|
+
: undefined;
|
|
418
|
+
}
|
|
419
|
+
if (record['state'] === 'failed') {
|
|
420
|
+
return exactMigrationRecordKeys(record, [...baseKeys, 'reason'])
|
|
421
|
+
&& boundedMigrationStateText(record['reason'], 1_024)
|
|
422
|
+
? { ...base, state: 'failed', reason: record['reason'] }
|
|
423
|
+
: undefined;
|
|
424
|
+
}
|
|
425
|
+
if (record['state'] === 'installing') {
|
|
426
|
+
const installAnchor = parseMigrationInstallAnchor(record['installAnchor'], destPath);
|
|
427
|
+
return installAnchor && exactMigrationRecordKeys(record, [...baseKeys, 'installAnchor'])
|
|
428
|
+
? { ...base, state: 'installing', installAnchor }
|
|
429
|
+
: undefined;
|
|
430
|
+
}
|
|
431
|
+
const intentId = record['intentId'];
|
|
432
|
+
const targetIdentity = parseMigrationTargetIdentity(record['targetIdentity'], destPath);
|
|
433
|
+
const installOwnership = parseMigrationInstallOwnership(record['installOwnership']);
|
|
434
|
+
const hasRegistrationBase = typeof intentId === 'string'
|
|
435
|
+
&& MIGRATION_UUID_RE.test(intentId)
|
|
436
|
+
&& targetIdentity !== undefined
|
|
437
|
+
&& installOwnership !== undefined;
|
|
438
|
+
if (record['state'] === 'registering' || record['state'] === 'committed') {
|
|
439
|
+
return hasRegistrationBase
|
|
440
|
+
&& exactMigrationRecordKeys(record, [...baseKeys, 'intentId', 'targetIdentity', 'installOwnership'])
|
|
441
|
+
? { ...base, state: record['state'], intentId, targetIdentity, installOwnership }
|
|
442
|
+
: undefined;
|
|
443
|
+
}
|
|
444
|
+
if (record['state'] === 'rollback_pending') {
|
|
445
|
+
return hasRegistrationBase
|
|
446
|
+
&& boundedMigrationStateText(record['reason'], 1_024)
|
|
447
|
+
&& exactMigrationRecordKeys(record, [...baseKeys, 'intentId', 'targetIdentity', 'installOwnership', 'reason'])
|
|
448
|
+
? {
|
|
449
|
+
...base,
|
|
450
|
+
state: 'rollback_pending',
|
|
451
|
+
intentId,
|
|
452
|
+
targetIdentity,
|
|
453
|
+
installOwnership,
|
|
454
|
+
reason: record['reason'],
|
|
455
|
+
}
|
|
456
|
+
: undefined;
|
|
457
|
+
}
|
|
458
|
+
if (record['state'] === 'blocked') {
|
|
459
|
+
return hasRegistrationBase
|
|
460
|
+
&& boundedMigrationStateText(record['reason'], 1_024)
|
|
461
|
+
&& exactMigrationRecordKeys(record, [...baseKeys, 'intentId', 'targetIdentity', 'installOwnership', 'reason'])
|
|
462
|
+
? {
|
|
463
|
+
...base,
|
|
464
|
+
state: 'blocked',
|
|
465
|
+
intentId,
|
|
466
|
+
targetIdentity,
|
|
467
|
+
installOwnership,
|
|
468
|
+
reason: record['reason'],
|
|
469
|
+
}
|
|
470
|
+
: undefined;
|
|
471
|
+
}
|
|
472
|
+
if (record['state'] !== 'rolled_back'
|
|
473
|
+
|| !boundedMigrationStateText(record['reason'], 1_024)) {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
const installAnchor = parseMigrationInstallAnchor(record['installAnchor'], destPath);
|
|
477
|
+
if (installAnchor
|
|
478
|
+
&& exactMigrationRecordKeys(record, [...baseKeys, 'reason', 'installAnchor'])) {
|
|
479
|
+
return { ...base, state: 'rolled_back', reason: record['reason'], installAnchor };
|
|
480
|
+
}
|
|
481
|
+
return hasRegistrationBase
|
|
482
|
+
&& exactMigrationRecordKeys(record, [...baseKeys, 'reason', 'intentId', 'targetIdentity', 'installOwnership'])
|
|
483
|
+
? {
|
|
484
|
+
...base,
|
|
485
|
+
state: 'rolled_back',
|
|
486
|
+
reason: record['reason'],
|
|
487
|
+
intentId,
|
|
488
|
+
targetIdentity,
|
|
489
|
+
installOwnership,
|
|
490
|
+
}
|
|
491
|
+
: undefined;
|
|
492
|
+
}
|
|
493
|
+
function inspectMigrationStartupState(env, options) {
|
|
494
|
+
let raw;
|
|
495
|
+
try {
|
|
496
|
+
raw = readTrustedMigrationStateText(join(resolveBootstrapStateDir(env), MIGRATION_STATE_FILE), options);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
return { classification: 'unsafe', canonicalPresent: true, detail: errorDetail(error) };
|
|
500
|
+
}
|
|
501
|
+
if (raw === undefined)
|
|
502
|
+
return { classification: 'none', canonicalPresent: false };
|
|
503
|
+
const record = parseCanonicalMigrationState(raw);
|
|
504
|
+
if (!record) {
|
|
505
|
+
return { classification: 'unsafe', canonicalPresent: true, detail: 'migration_state_invalid' };
|
|
506
|
+
}
|
|
507
|
+
if (record.state === 'rolled_back' || record.state === 'failed') {
|
|
508
|
+
return { classification: 'none', canonicalPresent: true };
|
|
509
|
+
}
|
|
510
|
+
return { classification: 'retryable', canonicalPresent: true, record, raw };
|
|
511
|
+
}
|
|
512
|
+
function serializeMigrationState(record, options) {
|
|
513
|
+
const payload = {
|
|
514
|
+
schemaVersion: 1,
|
|
515
|
+
...record,
|
|
516
|
+
attemptedAt: new Date(options.now ?? Date.now()).toISOString(),
|
|
517
|
+
};
|
|
518
|
+
return `${JSON.stringify(payload)}\n`;
|
|
519
|
+
}
|
|
520
|
+
function persistAuthoritativeMigrationState(env, record, options) {
|
|
521
|
+
const payload = serializeMigrationState(record, options);
|
|
522
|
+
const path = join(resolveBootstrapStateDir(env), MIGRATION_STATE_FILE);
|
|
523
|
+
const write = options.writeFile ?? defaultWriteTextFile;
|
|
524
|
+
write(path, payload);
|
|
525
|
+
if (readTrustedMigrationStateText(path, options) !== payload) {
|
|
526
|
+
throw new Error('migration_state_durability_unconfirmed');
|
|
527
|
+
}
|
|
528
|
+
return payload;
|
|
529
|
+
}
|
|
530
|
+
function assertAuthoritativeMigrationStateCurrent(env, expectedPayload, options) {
|
|
531
|
+
if (readTrustedMigrationStateText(join(resolveBootstrapStateDir(env), MIGRATION_STATE_FILE), options)
|
|
532
|
+
!== expectedPayload) {
|
|
533
|
+
throw new Error('migration_registration_intent_changed');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
72
536
|
function writeMigrationState(env, record, options) {
|
|
73
537
|
try {
|
|
74
|
-
const payload =
|
|
538
|
+
const payload = serializeMigrationState(record, options);
|
|
75
539
|
const write = options.writeFile ?? defaultWriteTextFile;
|
|
76
|
-
write(join(resolveBootstrapStateDir(env), MIGRATION_STATE_FILE),
|
|
540
|
+
write(join(resolveBootstrapStateDir(env), MIGRATION_STATE_FILE), payload);
|
|
77
541
|
}
|
|
78
542
|
catch {
|
|
79
|
-
//
|
|
543
|
+
// Pre-registration diagnostics are advisory; registration intent is persisted separately.
|
|
80
544
|
}
|
|
81
545
|
}
|
|
82
546
|
async function rmSafe(rm, path) {
|
|
@@ -88,66 +552,1470 @@ async function rmSafe(rm, path) {
|
|
|
88
552
|
}
|
|
89
553
|
}
|
|
90
554
|
function errorDetail(err) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
555
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
556
|
+
const normalized = raw
|
|
557
|
+
.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ')
|
|
558
|
+
.replace(/\s+/gu, ' ')
|
|
559
|
+
.trim();
|
|
560
|
+
return normalized.slice(0, MAX_MIGRATION_DETAIL_LENGTH) || 'unknown';
|
|
561
|
+
}
|
|
562
|
+
function registrationReason(reason) {
|
|
563
|
+
const normalized = errorDetail(reason);
|
|
564
|
+
return /^[a-z][a-z0-9_]{0,63}$/.test(normalized) ? normalized : undefined;
|
|
565
|
+
}
|
|
566
|
+
function isErrno(error, code) {
|
|
567
|
+
return typeof error === 'object' && error !== null && error.code === code;
|
|
568
|
+
}
|
|
569
|
+
function migrationPathKey(path) {
|
|
570
|
+
const resolved = resolvePath(path);
|
|
571
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
572
|
+
}
|
|
573
|
+
function trustedWindowsSystemExecutable(name) {
|
|
574
|
+
if (!win32.isAbsolute(HOST_WINDOWS_SYSTEM_ROOT) || /[\r\n\0]/.test(HOST_WINDOWS_SYSTEM_ROOT)) {
|
|
575
|
+
throw new Error('Windows SystemRoot is not an absolute trusted path');
|
|
576
|
+
}
|
|
577
|
+
return win32.join(HOST_WINDOWS_SYSTEM_ROOT, 'System32', name);
|
|
578
|
+
}
|
|
579
|
+
function windowsMigrationAclScript(checks) {
|
|
580
|
+
const encodedChecks = Buffer.from(JSON.stringify(checks), 'utf8').toString('base64');
|
|
581
|
+
return [
|
|
582
|
+
`$ErrorActionPreference = 'Stop'`,
|
|
583
|
+
'try {',
|
|
584
|
+
` $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedChecks}'))`,
|
|
585
|
+
' $checks = $json | ConvertFrom-Json',
|
|
586
|
+
...windowsMigrationAclRules().map((line) => ` ${line}`),
|
|
587
|
+
'} catch { exit 24 }',
|
|
588
|
+
].join('; ');
|
|
589
|
+
}
|
|
590
|
+
function windowsMigrationAclRules() {
|
|
591
|
+
const d = String.fromCharCode(36);
|
|
592
|
+
return [
|
|
593
|
+
`${d}userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value`,
|
|
594
|
+
`${d}trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'`,
|
|
595
|
+
...windowsMigrationAclPermissionRules(d),
|
|
596
|
+
];
|
|
597
|
+
}
|
|
598
|
+
function windowsMigrationAclPermissionRules(d) {
|
|
599
|
+
return [
|
|
600
|
+
`${d}trustedOwners = @(${d}userSid, 'S-1-5-18', 'S-1-5-32-544', ${d}trustedInstaller)`,
|
|
601
|
+
`${d}trustedWriters = @(${d}userSid, 'S-1-5-18', 'S-1-5-32-544', ${d}trustedInstaller, 'S-1-3-0', 'S-1-3-4')`,
|
|
602
|
+
`${d}parentDanger = [System.Security.AccessControl.FileSystemRights]::Delete -bor [System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership`,
|
|
603
|
+
`${d}contentDanger = [System.Security.AccessControl.FileSystemRights]::WriteData -bor [System.Security.AccessControl.FileSystemRights]::AppendData -bor [System.Security.AccessControl.FileSystemRights]::CreateFiles -bor [System.Security.AccessControl.FileSystemRights]::CreateDirectories`,
|
|
604
|
+
...windowsMigrationAclLoopRules(d),
|
|
605
|
+
];
|
|
606
|
+
}
|
|
607
|
+
function windowsMigrationAclLoopRules(d) {
|
|
608
|
+
return [
|
|
609
|
+
`foreach (${d}check in @(${d}checks)) {`,
|
|
610
|
+
` ${d}acl = Get-Acl -LiteralPath ${d}check.path`,
|
|
611
|
+
` ${d}owner = ${d}acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value`,
|
|
612
|
+
` if (${d}trustedOwners -notcontains ${d}owner) { exit 21 }`,
|
|
613
|
+
` ${d}danger = if (${d}check.parentOnly) { ${d}parentDanger } else { ${d}parentDanger -bor ${d}contentDanger }`,
|
|
614
|
+
` foreach (${d}rule in @(${d}acl.Access)) {`,
|
|
615
|
+
` if (${d}rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue }`,
|
|
616
|
+
` if ((${d}rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue }`,
|
|
617
|
+
` if ((${d}rule.FileSystemRights -band ${d}danger) -eq 0) { continue }`,
|
|
618
|
+
` try { ${d}sid = ${d}rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { exit 22 }`,
|
|
619
|
+
` if (${d}trustedWriters -notcontains ${d}sid) { exit 23 }`,
|
|
620
|
+
' }',
|
|
621
|
+
'}',
|
|
622
|
+
];
|
|
623
|
+
}
|
|
624
|
+
function assertWindowsMigrationAclTrusted(checks) {
|
|
625
|
+
if (process.platform !== 'win32' || checks.length === 0)
|
|
626
|
+
return;
|
|
627
|
+
try {
|
|
628
|
+
execFileSync(trustedWindowsSystemExecutable('WindowsPowerShell\\v1.0\\powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', windowsMigrationAclScript(checks)], { stdio: 'ignore', timeout: 10_000, windowsHide: true });
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
throw new Error('migration Windows ACL chain is not trusted', { cause: error });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function assertTrustedMigrationDirectoryChainSync(directory) {
|
|
635
|
+
let current = resolvePath(directory);
|
|
636
|
+
const root = parsePath(current).root;
|
|
637
|
+
const windowsChecks = [];
|
|
638
|
+
const uid = resolveProcessUid();
|
|
639
|
+
let nearestExisting = true;
|
|
640
|
+
let privateUserAnchor = false;
|
|
641
|
+
for (;;) {
|
|
642
|
+
let info;
|
|
643
|
+
try {
|
|
644
|
+
info = lstatSync(current);
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
if (!isErrno(error, 'ENOENT'))
|
|
648
|
+
throw error;
|
|
649
|
+
if (current === root)
|
|
650
|
+
break;
|
|
651
|
+
current = dirname(current);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
655
|
+
throw new Error('unsafe_migration_state_directory');
|
|
656
|
+
}
|
|
657
|
+
if (process.platform === 'win32') {
|
|
658
|
+
windowsChecks.push({ path: current, parentOnly: windowsChecks.length > 0 });
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
if (uid !== undefined && info.uid !== uid && (nearestExisting || info.uid !== 0)) {
|
|
662
|
+
throw new Error('migration_state_directory_untrusted_owner');
|
|
663
|
+
}
|
|
664
|
+
const writableByOthers = (info.mode & 0o022) !== 0;
|
|
665
|
+
const trustedStickyAncestor = !nearestExisting
|
|
666
|
+
&& privateUserAnchor
|
|
667
|
+
&& info.uid === 0
|
|
668
|
+
&& (info.mode & 0o1000) !== 0;
|
|
669
|
+
if (writableByOthers && !trustedStickyAncestor) {
|
|
670
|
+
throw new Error('migration_state_directory_group_or_world_writable');
|
|
671
|
+
}
|
|
672
|
+
if (info.uid === uid && (info.mode & 0o077) === 0)
|
|
673
|
+
privateUserAnchor = true;
|
|
674
|
+
}
|
|
675
|
+
nearestExisting = false;
|
|
676
|
+
if (current === root)
|
|
677
|
+
break;
|
|
678
|
+
current = dirname(current);
|
|
679
|
+
}
|
|
680
|
+
assertWindowsMigrationAclTrusted(windowsChecks);
|
|
681
|
+
}
|
|
682
|
+
function unresolvedLegacyMigrationOwnership(env, readFile) {
|
|
683
|
+
try {
|
|
684
|
+
const raw = readFile(join(resolveBootstrapStateDir(env), BOOTSTRAP_ATTEMPT_FILE));
|
|
685
|
+
if (raw.length === 0 || raw.length > 4 * 1024)
|
|
686
|
+
return false;
|
|
687
|
+
const parsed = JSON.parse(raw);
|
|
688
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
689
|
+
return false;
|
|
690
|
+
const outcome = parsed['outcome'];
|
|
691
|
+
return outcome === 'migration_ambiguous' || outcome === 'migrated' || outcome === 'completed';
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async function assertTrustedMigrationDirectoryChain(directory, effectiveUid = resolveProcessUid()) {
|
|
698
|
+
let current = resolvePath(directory);
|
|
699
|
+
const root = parsePath(current).root;
|
|
700
|
+
const windowsChecks = [];
|
|
701
|
+
const uid = effectiveUid;
|
|
702
|
+
let nearestExisting = true;
|
|
703
|
+
let privateUserAnchor = false;
|
|
704
|
+
for (;;) {
|
|
705
|
+
let info;
|
|
706
|
+
try {
|
|
707
|
+
info = await lstat(current);
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
if (!isErrno(error, 'ENOENT'))
|
|
711
|
+
throw error;
|
|
712
|
+
if (current === root)
|
|
713
|
+
break;
|
|
714
|
+
current = dirname(current);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
718
|
+
throw new Error('unsafe_migration_target_directory');
|
|
719
|
+
}
|
|
720
|
+
if (process.platform === 'win32') {
|
|
721
|
+
windowsChecks.push({ path: current, parentOnly: windowsChecks.length > 0 });
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
if (uid !== undefined && info.uid !== uid && (nearestExisting || info.uid !== 0)) {
|
|
725
|
+
throw new Error('migration_target_directory_untrusted_owner');
|
|
726
|
+
}
|
|
727
|
+
const writableByOthers = (info.mode & 0o022) !== 0;
|
|
728
|
+
const trustedStickyAncestor = !nearestExisting
|
|
729
|
+
&& privateUserAnchor
|
|
730
|
+
&& info.uid === 0
|
|
731
|
+
&& (info.mode & 0o1000) !== 0;
|
|
732
|
+
if (writableByOthers && !trustedStickyAncestor) {
|
|
733
|
+
throw new Error('migration_target_directory_group_or_world_writable');
|
|
734
|
+
}
|
|
735
|
+
if (info.uid === uid && (info.mode & 0o077) === 0)
|
|
736
|
+
privateUserAnchor = true;
|
|
737
|
+
}
|
|
738
|
+
nearestExisting = false;
|
|
739
|
+
if (current === root)
|
|
740
|
+
break;
|
|
741
|
+
current = dirname(current);
|
|
742
|
+
}
|
|
743
|
+
assertWindowsMigrationAclTrusted(windowsChecks);
|
|
744
|
+
}
|
|
745
|
+
async function assertTrustedMigrationFile(path, effectiveUid = resolveProcessUid()) {
|
|
746
|
+
const info = await lstat(path);
|
|
747
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
748
|
+
throw new Error('destination_exists_unowned_or_invalid');
|
|
749
|
+
}
|
|
750
|
+
if (process.platform === 'win32') {
|
|
751
|
+
assertWindowsMigrationAclTrusted([{ path, parentOnly: false }]);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const uid = effectiveUid;
|
|
755
|
+
if (uid !== undefined && info.uid !== uid) {
|
|
756
|
+
throw new Error('migration_target_file_untrusted_owner');
|
|
757
|
+
}
|
|
758
|
+
if ((info.mode & 0o022) !== 0) {
|
|
759
|
+
throw new Error('migration_target_file_group_or_world_writable');
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async function ensureSafeMigrationTargetDirectory(home, directory, mkdir, assertDirectoryTrust) {
|
|
763
|
+
const absoluteHome = resolvePath(home);
|
|
764
|
+
const absoluteDirectory = resolvePath(directory);
|
|
765
|
+
if (relative(absoluteHome, absoluteDirectory) !== 'bin') {
|
|
766
|
+
throw new Error('migration_target_outside_home');
|
|
767
|
+
}
|
|
768
|
+
await ensureSafeMigrationDirectory(absoluteDirectory, mkdir, assertDirectoryTrust);
|
|
769
|
+
}
|
|
770
|
+
async function assertSafeExistingMigrationTargetDirectory(home, directory, assertDirectoryTrust) {
|
|
771
|
+
const absoluteHome = resolvePath(home);
|
|
772
|
+
const absoluteDirectory = resolvePath(directory);
|
|
773
|
+
if (relative(absoluteHome, absoluteDirectory) !== 'bin') {
|
|
774
|
+
throw new Error('migration_target_outside_home');
|
|
775
|
+
}
|
|
776
|
+
await assertDirectoryTrust(absoluteDirectory);
|
|
777
|
+
let info;
|
|
778
|
+
try {
|
|
779
|
+
info = await lstat(absoluteDirectory);
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
if (isErrno(error, 'ENOENT'))
|
|
783
|
+
return;
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
787
|
+
throw new Error('unsafe_migration_target_directory');
|
|
788
|
+
}
|
|
789
|
+
const canonicalDirectory = await fsRealpath(absoluteDirectory);
|
|
790
|
+
if (migrationPathKey(canonicalDirectory) !== migrationPathKey(absoluteDirectory)) {
|
|
791
|
+
throw new Error('unsafe_migration_target_directory');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async function ensureSafeMigrationDirectory(directory, mkdir, assertDirectoryTrust) {
|
|
795
|
+
const absoluteDirectory = resolvePath(directory);
|
|
796
|
+
// Validate the existing ancestor before mkdir, then validate the complete canonical tree.
|
|
797
|
+
// This rejects symlinked or non-directory components instead of writing through them.
|
|
798
|
+
await assertDirectoryTrust(absoluteDirectory);
|
|
799
|
+
await mkdir(absoluteDirectory, 0o700);
|
|
800
|
+
await assertDirectoryTrust(absoluteDirectory);
|
|
801
|
+
const canonicalDirectory = await fsRealpath(absoluteDirectory);
|
|
802
|
+
if (migrationPathKey(canonicalDirectory) !== migrationPathKey(absoluteDirectory)) {
|
|
803
|
+
throw new Error('unsafe_migration_target_directory');
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
function sha256(bytes) {
|
|
807
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
808
|
+
}
|
|
809
|
+
async function inspectExistingMigrationTarget(destPath, version, preflight, readBinary, expected, assertDirectoryTrust, assertFileTrust) {
|
|
810
|
+
let before;
|
|
811
|
+
try {
|
|
812
|
+
before = await lstat(destPath, { bigint: true });
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
if (isErrno(error, 'ENOENT'))
|
|
816
|
+
return undefined;
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
if (before.isSymbolicLink() || !before.isFile() || before.dev <= 0n || before.ino <= 0n) {
|
|
820
|
+
throw new Error('destination_exists_unowned_or_invalid');
|
|
821
|
+
}
|
|
822
|
+
await assertDirectoryTrust(dirname(destPath));
|
|
823
|
+
await assertFileTrust(destPath);
|
|
824
|
+
if (before.nlink !== 1n)
|
|
825
|
+
throw new Error('destination_exists_with_external_hardlink');
|
|
826
|
+
if (before.size !== BigInt(expected.size))
|
|
827
|
+
throw new Error('destination_exists_with_different_artifact');
|
|
828
|
+
const existingBytes = await readBinary(destPath);
|
|
829
|
+
if (existingBytes.byteLength !== expected.size || sha256(existingBytes) !== expected.sha256) {
|
|
830
|
+
throw new Error('destination_exists_with_different_artifact');
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
await preflight(destPath, version);
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
throw new Error(`destination_exists_unowned_or_invalid:${errorDetail(error)}`, { cause: error });
|
|
837
|
+
}
|
|
838
|
+
await assertFileTrust(destPath);
|
|
839
|
+
const after = await lstat(destPath, { bigint: true });
|
|
840
|
+
if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1n
|
|
841
|
+
|| after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size
|
|
842
|
+
|| after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
|
|
843
|
+
throw new Error('destination_changed_during_validation');
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
path: resolvePath(destPath),
|
|
847
|
+
device: before.dev.toString(),
|
|
848
|
+
inode: before.ino.toString(),
|
|
849
|
+
size: expected.size,
|
|
850
|
+
linkCount: 1,
|
|
851
|
+
mtimeNs: before.mtimeNs.toString(),
|
|
852
|
+
ctimeNs: before.ctimeNs.toString(),
|
|
853
|
+
sha256: expected.sha256,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function sameMigrationTargetIdentity(left, right) {
|
|
857
|
+
return left.path === right.path
|
|
858
|
+
&& left.device === right.device
|
|
859
|
+
&& left.inode === right.inode
|
|
860
|
+
&& left.size === right.size
|
|
861
|
+
&& left.linkCount === right.linkCount
|
|
862
|
+
&& left.mtimeNs === right.mtimeNs
|
|
863
|
+
&& left.ctimeNs === right.ctimeNs
|
|
864
|
+
&& left.sha256 === right.sha256;
|
|
865
|
+
}
|
|
866
|
+
function assertMigrationTargetIdentityShape(identity) {
|
|
867
|
+
if (!isAbsolute(identity.path) || migrationPathKey(identity.path) !== migrationPathKey(resolvePath(identity.path))) {
|
|
868
|
+
throw new Error('migration_target_identity_path_invalid');
|
|
869
|
+
}
|
|
870
|
+
if (!Number.isSafeInteger(identity.size) || identity.size < 0
|
|
871
|
+
|| identity.linkCount !== 1
|
|
872
|
+
|| !/^[0-9]+$/.test(identity.device)
|
|
873
|
+
|| !/^[0-9]+$/.test(identity.inode)
|
|
874
|
+
|| !/^-?[0-9]+$/.test(identity.mtimeNs)
|
|
875
|
+
|| !/^-?[0-9]+$/.test(identity.ctimeNs)
|
|
876
|
+
|| !/^[0-9a-f]{64}$/.test(identity.sha256)) {
|
|
877
|
+
throw new Error('migration_target_identity_invalid');
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Revalidate the complete signed executable identity immediately before lifecycle bootstrap
|
|
882
|
+
* spawns it. Callers must not substitute the request's presentation object for this check.
|
|
883
|
+
*/
|
|
884
|
+
async function assertMigrationTargetIdentityCurrent(identity, expectedVersion, options = {}) {
|
|
885
|
+
assertMigrationTargetIdentityShape(identity);
|
|
886
|
+
const readBinary = options.readBinary ?? ((path) => fsReadFile(path));
|
|
887
|
+
const preflight = options.preflightFn
|
|
888
|
+
?? ((path, version) => preflightManagedStagedBinary(path, version));
|
|
889
|
+
const assertDirectoryTrust = options.assertDirectoryTrust ?? assertTrustedMigrationDirectoryChain;
|
|
890
|
+
const assertFileTrust = options.assertFileTrust ?? assertTrustedMigrationFile;
|
|
891
|
+
const current = await inspectExistingMigrationTarget(identity.path, expectedVersion, preflight, readBinary, { sha256: identity.sha256, size: identity.size }, assertDirectoryTrust, assertFileTrust);
|
|
892
|
+
if (!current || !sameMigrationTargetIdentity(current, identity)) {
|
|
893
|
+
throw new Error('migration_target_identity_changed');
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const MAX_MIGRATION_LOCK_BYTES = 4_096;
|
|
897
|
+
function assertMigrationLockSnapshotShape(snapshot) {
|
|
898
|
+
if (snapshot.isSymbolicLink()
|
|
899
|
+
|| !snapshot.isFile()
|
|
900
|
+
|| snapshot.nlink !== 1n
|
|
901
|
+
|| snapshot.size > BigInt(MAX_MIGRATION_LOCK_BYTES)) {
|
|
902
|
+
throw new Error('migration_lock_ownership_changed');
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function sameMigrationLockSnapshot(left, right) {
|
|
906
|
+
return left.dev === right.dev
|
|
907
|
+
&& left.ino === right.ino
|
|
908
|
+
&& left.nlink === 1n
|
|
909
|
+
&& right.nlink === 1n
|
|
910
|
+
&& left.size === right.size
|
|
911
|
+
&& left.mtimeNs === right.mtimeNs
|
|
912
|
+
&& left.ctimeNs === right.ctimeNs;
|
|
913
|
+
}
|
|
914
|
+
function readBoundedMigrationLockFd(fd) {
|
|
915
|
+
const bytes = Buffer.allocUnsafe(MAX_MIGRATION_LOCK_BYTES + 1);
|
|
916
|
+
let offset = 0;
|
|
917
|
+
while (offset < bytes.byteLength) {
|
|
918
|
+
const read = readSync(fd, bytes, offset, bytes.byteLength - offset, offset);
|
|
919
|
+
if (read === 0)
|
|
920
|
+
break;
|
|
921
|
+
offset += read;
|
|
922
|
+
}
|
|
923
|
+
if (offset > MAX_MIGRATION_LOCK_BYTES)
|
|
924
|
+
throw new Error('migration_lock_ownership_changed');
|
|
925
|
+
return bytes.subarray(0, offset);
|
|
926
|
+
}
|
|
927
|
+
function readMigrationLockAuthority(lockPath) {
|
|
928
|
+
const beforeOpen = lstatSync(lockPath, { bigint: true });
|
|
929
|
+
assertMigrationLockSnapshotShape(beforeOpen);
|
|
930
|
+
const fd = openSync(lockPath, 'r');
|
|
931
|
+
try {
|
|
932
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
933
|
+
assertMigrationLockSnapshotShape(opened);
|
|
934
|
+
if (!sameMigrationLockSnapshot(beforeOpen, opened)) {
|
|
935
|
+
throw new Error('migration_lock_ownership_changed');
|
|
936
|
+
}
|
|
937
|
+
const firstBytes = readBoundedMigrationLockFd(fd);
|
|
938
|
+
const afterFirstFd = fstatSync(fd, { bigint: true });
|
|
939
|
+
const afterFirstPath = lstatSync(lockPath, { bigint: true });
|
|
940
|
+
const secondBytes = readBoundedMigrationLockFd(fd);
|
|
941
|
+
const finalFd = fstatSync(fd, { bigint: true });
|
|
942
|
+
const finalPath = lstatSync(lockPath, { bigint: true });
|
|
943
|
+
for (const snapshot of [afterFirstFd, afterFirstPath, finalFd, finalPath]) {
|
|
944
|
+
assertMigrationLockSnapshotShape(snapshot);
|
|
945
|
+
if (!sameMigrationLockSnapshot(opened, snapshot)) {
|
|
946
|
+
throw new Error('migration_lock_ownership_changed');
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (opened.size !== BigInt(firstBytes.byteLength)
|
|
950
|
+
|| finalFd.size !== BigInt(secondBytes.byteLength)
|
|
951
|
+
|| !firstBytes.equals(secondBytes)) {
|
|
952
|
+
throw new Error('migration_lock_ownership_changed');
|
|
953
|
+
}
|
|
954
|
+
return { snapshot: opened, bytes: Buffer.from(firstBytes) };
|
|
955
|
+
}
|
|
956
|
+
finally {
|
|
957
|
+
closeSync(fd);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function assertMigrationLockOwnerRecord(bytes, owner) {
|
|
961
|
+
let current;
|
|
962
|
+
try {
|
|
963
|
+
current = JSON.parse(bytes.toString('utf8'));
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
throw new Error('migration_lock_ownership_changed');
|
|
967
|
+
}
|
|
968
|
+
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
|
969
|
+
throw new Error('migration_lock_ownership_changed');
|
|
970
|
+
}
|
|
971
|
+
const record = current;
|
|
972
|
+
if (record['pid'] !== owner.pid
|
|
973
|
+
|| record['token'] !== owner.token
|
|
974
|
+
|| JSON.stringify(record['processStart']) !== JSON.stringify(owner.processStartIdentity)) {
|
|
975
|
+
throw new Error('migration_lock_ownership_changed');
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
async function withMigrationLock(directory, operation) {
|
|
979
|
+
const lockPath = join(directory, MIGRATION_LOCK_FILE);
|
|
980
|
+
let owner;
|
|
981
|
+
try {
|
|
982
|
+
owner = util.acquireLock(lockPath, { maxTries: 1, waitMs: 0 });
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
const code = typeof error === 'object' && error !== null ? error.code : undefined;
|
|
986
|
+
throw new Error(code === 'LOCK_TIMEOUT' ? 'migration_lock_busy' : `migration_lock_unsafe:${errorDetail(error)}`, {
|
|
987
|
+
cause: error,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
let acquiredAuthority;
|
|
991
|
+
try {
|
|
992
|
+
acquiredAuthority = readMigrationLockAuthority(lockPath);
|
|
993
|
+
assertMigrationLockOwnerRecord(acquiredAuthority.bytes, owner);
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
util.releaseLock(lockPath);
|
|
997
|
+
throw error;
|
|
998
|
+
}
|
|
999
|
+
let completed = false;
|
|
1000
|
+
let value;
|
|
1001
|
+
let operationError;
|
|
1002
|
+
const assertOwnershipCurrent = () => {
|
|
1003
|
+
const currentAuthority = readMigrationLockAuthority(lockPath);
|
|
1004
|
+
if (!sameMigrationLockSnapshot(acquiredAuthority.snapshot, currentAuthority.snapshot)
|
|
1005
|
+
|| !acquiredAuthority.bytes.equals(currentAuthority.bytes)) {
|
|
1006
|
+
throw new Error('migration_lock_ownership_changed');
|
|
1007
|
+
}
|
|
1008
|
+
assertMigrationLockOwnerRecord(currentAuthority.bytes, owner);
|
|
1009
|
+
};
|
|
1010
|
+
try {
|
|
1011
|
+
assertOwnershipCurrent();
|
|
1012
|
+
value = await operation(assertOwnershipCurrent);
|
|
1013
|
+
assertOwnershipCurrent();
|
|
1014
|
+
completed = true;
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
operationError = error;
|
|
1018
|
+
}
|
|
1019
|
+
const released = util.releaseLock(lockPath);
|
|
1020
|
+
if (!released.released
|
|
1021
|
+
|| (released.reason !== 'released' && released.reason !== 'released_with_cleanup_error')) {
|
|
1022
|
+
throw new Error(`migration_lock_release_failed:${released.reason}`, { cause: operationError });
|
|
1023
|
+
}
|
|
1024
|
+
if (!completed)
|
|
1025
|
+
throw operationError;
|
|
1026
|
+
return value;
|
|
1027
|
+
}
|
|
1028
|
+
async function installVerifiedMigrationBinary(input) {
|
|
1029
|
+
const directory = dirname(input.destPath);
|
|
1030
|
+
// Read-only validation and convergence detection happen before the durable plan. The plan is
|
|
1031
|
+
// then published before mkdir or O_EXCL temporary-file creation touches the target namespace.
|
|
1032
|
+
await input.assertDirectoryTrust(directory);
|
|
1033
|
+
let directoryCreated = false;
|
|
1034
|
+
try {
|
|
1035
|
+
await lstat(directory);
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
if (!isErrno(error, 'ENOENT'))
|
|
1039
|
+
throw error;
|
|
1040
|
+
directoryCreated = true;
|
|
1041
|
+
}
|
|
1042
|
+
const existingIdentity = await inspectExistingMigrationTarget(input.destPath, input.version, input.preflight, input.readBinary, { sha256: input.expectedSha256, size: input.bytes.byteLength }, input.assertDirectoryTrust, input.assertFileTrust);
|
|
1043
|
+
if (existingIdentity) {
|
|
1044
|
+
return { disposition: 'converged', targetIdentity: existingIdentity, directoryCreated: false };
|
|
1045
|
+
}
|
|
1046
|
+
const temporaryPath = join(directory, `.${parsePath(input.destPath).base}.${process.pid}.${randomUUID()}.migration`);
|
|
1047
|
+
const plannedAnchor = {
|
|
1048
|
+
phase: 'planned',
|
|
1049
|
+
temporaryPath,
|
|
1050
|
+
size: input.bytes.byteLength,
|
|
1051
|
+
sha256: input.expectedSha256,
|
|
1052
|
+
directoryCreated,
|
|
1053
|
+
};
|
|
1054
|
+
await input.publishInstallIntent(plannedAnchor);
|
|
1055
|
+
let publishedAnchor;
|
|
1056
|
+
let materializedAnchor;
|
|
1057
|
+
let concurrentIdentity;
|
|
1058
|
+
try {
|
|
1059
|
+
await ensureSafeMigrationTargetDirectory(input.home, directory, input.mkdir, input.assertDirectoryTrust);
|
|
1060
|
+
if (directoryCreated) {
|
|
1061
|
+
await input.syncDirectory(dirname(directory));
|
|
1062
|
+
}
|
|
1063
|
+
await input.writeBinary(temporaryPath, input.bytes, 0o755);
|
|
1064
|
+
await input.chmod(temporaryPath, 0o755);
|
|
1065
|
+
await input.syncFile(temporaryPath);
|
|
1066
|
+
const temporaryInfo = await input.stat(temporaryPath);
|
|
1067
|
+
const temporaryIdentity = await lstat(temporaryPath, { bigint: true });
|
|
1068
|
+
if (temporaryInfo.isSymbolicLink() || !temporaryInfo.isFile()
|
|
1069
|
+
|| temporaryIdentity.isSymbolicLink() || !temporaryIdentity.isFile()
|
|
1070
|
+
|| temporaryIdentity.nlink !== 1n || temporaryIdentity.dev <= 0n || temporaryIdentity.ino <= 0n) {
|
|
1071
|
+
throw new Error('migration_temporary_not_owned_regular_file');
|
|
1072
|
+
}
|
|
1073
|
+
const publishedBytes = await input.readBinary(temporaryPath);
|
|
1074
|
+
if (publishedBytes.byteLength !== input.bytes.byteLength || sha256(publishedBytes) !== input.expectedSha256) {
|
|
1075
|
+
throw new Error('migration_temporary_hash_mismatch');
|
|
1076
|
+
}
|
|
1077
|
+
if (temporaryIdentity.size !== BigInt(input.bytes.byteLength)) {
|
|
1078
|
+
throw new Error('migration_temporary_size_mismatch');
|
|
1079
|
+
}
|
|
1080
|
+
materializedAnchor = {
|
|
1081
|
+
phase: 'materialized',
|
|
1082
|
+
temporaryPath,
|
|
1083
|
+
device: temporaryIdentity.dev.toString(),
|
|
1084
|
+
inode: temporaryIdentity.ino.toString(),
|
|
1085
|
+
size: input.bytes.byteLength,
|
|
1086
|
+
sha256: input.expectedSha256,
|
|
1087
|
+
directoryCreated,
|
|
1088
|
+
};
|
|
1089
|
+
await input.publishInstallIntent(materializedAnchor);
|
|
1090
|
+
try {
|
|
1091
|
+
await input.link(temporaryPath, input.destPath);
|
|
1092
|
+
publishedAnchor = {
|
|
1093
|
+
device: temporaryIdentity.dev,
|
|
1094
|
+
inode: temporaryIdentity.ino,
|
|
1095
|
+
size: temporaryIdentity.size,
|
|
1096
|
+
};
|
|
1097
|
+
await input.syncDirectory(directory);
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
if (!isErrno(error, 'EEXIST'))
|
|
1101
|
+
throw error;
|
|
1102
|
+
concurrentIdentity = await inspectExistingMigrationTarget(input.destPath, input.version, input.preflight, input.readBinary, { sha256: input.expectedSha256, size: input.bytes.byteLength }, input.assertDirectoryTrust, input.assertFileTrust);
|
|
1103
|
+
if (!concurrentIdentity)
|
|
1104
|
+
throw error;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
catch (error) {
|
|
1108
|
+
if (!materializedAnchor) {
|
|
1109
|
+
throw new Error(`migration_install_rollback_unconfirmed:planned_materialization_unconfirmed:${errorDetail(error)}`, { cause: error });
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
await removeMaterializedMigrationTemporaryFile({
|
|
1113
|
+
anchor: materializedAnchor,
|
|
1114
|
+
readBinary: input.readBinary,
|
|
1115
|
+
rename: input.rename,
|
|
1116
|
+
link: input.link,
|
|
1117
|
+
unlink: input.unlink,
|
|
1118
|
+
syncDirectory: input.syncDirectory,
|
|
1119
|
+
assertFileTrust: input.assertFileTrust,
|
|
1120
|
+
});
|
|
1121
|
+
if (publishedAnchor) {
|
|
1122
|
+
await rollbackPublishedMigrationTarget(input, publishedAnchor, materializedAnchor, directoryCreated);
|
|
1123
|
+
}
|
|
1124
|
+
else if (directoryCreated) {
|
|
1125
|
+
await removeMigrationDirectoryIfEmpty(directory, input.rmdir);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
catch (rollbackError) {
|
|
1129
|
+
throw new Error(`migration_install_rollback_unconfirmed:${errorDetail(rollbackError)}`, { cause: error });
|
|
1130
|
+
}
|
|
1131
|
+
throw error;
|
|
1132
|
+
}
|
|
1133
|
+
try {
|
|
1134
|
+
// Remove our temporary hardlink before enforcing the final target's nlink=1 invariant.
|
|
1135
|
+
if (!materializedAnchor)
|
|
1136
|
+
throw new Error('migration_materialized_anchor_missing');
|
|
1137
|
+
await removeMaterializedMigrationTemporaryFile({
|
|
1138
|
+
anchor: materializedAnchor,
|
|
1139
|
+
readBinary: input.readBinary,
|
|
1140
|
+
rename: input.rename,
|
|
1141
|
+
link: input.link,
|
|
1142
|
+
unlink: input.unlink,
|
|
1143
|
+
syncDirectory: input.syncDirectory,
|
|
1144
|
+
assertFileTrust: input.assertFileTrust,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
catch (error) {
|
|
1148
|
+
throw new Error(`migration_install_rollback_unconfirmed:temporary_cleanup:${errorDetail(error)}`);
|
|
1149
|
+
}
|
|
1150
|
+
if (concurrentIdentity) {
|
|
1151
|
+
return { disposition: 'converged', targetIdentity: concurrentIdentity, directoryCreated: false };
|
|
1152
|
+
}
|
|
1153
|
+
if (!publishedAnchor)
|
|
1154
|
+
throw new Error('published_migration_target_missing');
|
|
1155
|
+
try {
|
|
1156
|
+
const installedIdentity = await inspectExistingMigrationTarget(input.destPath, input.version, input.preflight, input.readBinary, { sha256: input.expectedSha256, size: input.bytes.byteLength }, input.assertDirectoryTrust, input.assertFileTrust);
|
|
1157
|
+
if (!installedIdentity)
|
|
1158
|
+
throw new Error('published_migration_target_missing');
|
|
1159
|
+
return { disposition: 'installed', targetIdentity: installedIdentity, directoryCreated };
|
|
1160
|
+
}
|
|
1161
|
+
catch (error) {
|
|
1162
|
+
try {
|
|
1163
|
+
await rollbackPublishedMigrationTarget(input, publishedAnchor, materializedAnchor, directoryCreated);
|
|
1164
|
+
}
|
|
1165
|
+
catch (rollbackError) {
|
|
1166
|
+
throw new Error(`migration_install_rollback_unconfirmed:${errorDetail(rollbackError)}`, { cause: error });
|
|
1167
|
+
}
|
|
1168
|
+
throw error;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
function migrationTemporaryCleanupQuarantinePath(temporaryPath) {
|
|
1172
|
+
return `${temporaryPath}.cleanup`;
|
|
1173
|
+
}
|
|
1174
|
+
function migrationInstallTargetQuarantinePath(anchor) {
|
|
1175
|
+
return `${anchor.temporaryPath}.target-rollback`;
|
|
1176
|
+
}
|
|
1177
|
+
function migrationRegistrationTargetQuarantinePath(destPath, intentId) {
|
|
1178
|
+
return join(dirname(destPath), `.${parsePath(destPath).base}.${intentId}.rollback`);
|
|
1179
|
+
}
|
|
1180
|
+
async function migrationPathIdentity(path) {
|
|
1181
|
+
try {
|
|
1182
|
+
return await lstat(path, { bigint: true });
|
|
1183
|
+
}
|
|
1184
|
+
catch (error) {
|
|
1185
|
+
if (isErrno(error, 'ENOENT'))
|
|
1186
|
+
return undefined;
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function sameMigrationFileId(left, right) {
|
|
1191
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
1192
|
+
}
|
|
1193
|
+
async function inspectExactMigrationCleanupFile(input) {
|
|
1194
|
+
const before = await lstat(input.path, { bigint: true });
|
|
1195
|
+
await input.assertFileTrust(input.path);
|
|
1196
|
+
if (before.isSymbolicLink()
|
|
1197
|
+
|| !before.isFile()
|
|
1198
|
+
|| !input.allowedLinkCounts.includes(before.nlink)
|
|
1199
|
+
|| before.dev.toString() !== input.expected.device
|
|
1200
|
+
|| before.ino.toString() !== input.expected.inode
|
|
1201
|
+
|| before.size !== BigInt(input.expected.size)) {
|
|
1202
|
+
throw new Error(input.identityError);
|
|
1203
|
+
}
|
|
1204
|
+
const bytes = await input.readBinary(input.path);
|
|
1205
|
+
if (bytes.byteLength !== input.expected.size || sha256(bytes) !== input.expected.sha256) {
|
|
1206
|
+
throw new Error(input.hashError);
|
|
1207
|
+
}
|
|
1208
|
+
const after = await lstat(input.path, { bigint: true });
|
|
1209
|
+
if (after.isSymbolicLink()
|
|
1210
|
+
|| !after.isFile()
|
|
1211
|
+
|| after.dev !== before.dev
|
|
1212
|
+
|| after.ino !== before.ino
|
|
1213
|
+
|| after.nlink !== before.nlink
|
|
1214
|
+
|| after.size !== before.size
|
|
1215
|
+
|| after.mtimeNs !== before.mtimeNs
|
|
1216
|
+
|| after.ctimeNs !== before.ctimeNs) {
|
|
1217
|
+
throw new Error(input.identityError);
|
|
1218
|
+
}
|
|
1219
|
+
return after;
|
|
1220
|
+
}
|
|
1221
|
+
async function restoreMismatchedMigrationQuarantine(input) {
|
|
1222
|
+
const directory = dirname(input.sourcePath);
|
|
1223
|
+
const sourceIdentity = await migrationPathIdentity(input.sourcePath);
|
|
1224
|
+
if (sourceIdentity && !sameMigrationFileId(sourceIdentity, input.quarantineIdentity)) {
|
|
1225
|
+
throw new Error(`${input.errorPrefix}_quarantine_preserved_canonical_occupied`, { cause: input.reason });
|
|
1226
|
+
}
|
|
1227
|
+
if (!sourceIdentity) {
|
|
1228
|
+
try {
|
|
1229
|
+
await input.link(input.quarantinePath, input.sourcePath);
|
|
1230
|
+
await input.syncDirectory(directory);
|
|
1231
|
+
}
|
|
1232
|
+
catch (error) {
|
|
1233
|
+
if (!isErrno(error, 'EEXIST'))
|
|
1234
|
+
throw error;
|
|
1235
|
+
throw new Error(`${input.errorPrefix}_quarantine_preserved_restore_conflict`, { cause: input.reason });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
const restoredIdentity = await migrationPathIdentity(input.sourcePath);
|
|
1239
|
+
const currentQuarantineIdentity = await migrationPathIdentity(input.quarantinePath);
|
|
1240
|
+
if (!restoredIdentity || !currentQuarantineIdentity
|
|
1241
|
+
|| !sameMigrationFileId(restoredIdentity, currentQuarantineIdentity)
|
|
1242
|
+
|| !sameMigrationFileId(currentQuarantineIdentity, input.quarantineIdentity)) {
|
|
1243
|
+
throw new Error(`${input.errorPrefix}_quarantine_preserved_restore_inconclusive`, { cause: input.reason });
|
|
1244
|
+
}
|
|
1245
|
+
await input.unlink(input.quarantinePath);
|
|
1246
|
+
await input.syncDirectory(directory);
|
|
1247
|
+
throw new Error(`${input.errorPrefix}_moved_object_restored`, { cause: input.reason });
|
|
1248
|
+
}
|
|
1249
|
+
async function quarantineAndRemoveExactMigrationFile(input) {
|
|
1250
|
+
const directory = dirname(input.sourcePath);
|
|
1251
|
+
let quarantineIdentity = await migrationPathIdentity(input.quarantinePath);
|
|
1252
|
+
if (quarantineIdentity) {
|
|
108
1253
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
1254
|
+
quarantineIdentity = await inspectExactMigrationCleanupFile({
|
|
1255
|
+
path: input.quarantinePath,
|
|
1256
|
+
expected: input.expected,
|
|
1257
|
+
allowedLinkCounts: input.allowedLinkCounts,
|
|
1258
|
+
readBinary: input.readBinary,
|
|
1259
|
+
assertFileTrust: input.assertFileTrust,
|
|
1260
|
+
identityError: input.identityError,
|
|
1261
|
+
hashError: input.hashError,
|
|
1262
|
+
});
|
|
111
1263
|
}
|
|
112
1264
|
catch (error) {
|
|
113
|
-
|
|
1265
|
+
await restoreMismatchedMigrationQuarantine({
|
|
1266
|
+
sourcePath: input.sourcePath,
|
|
1267
|
+
quarantinePath: input.quarantinePath,
|
|
1268
|
+
quarantineIdentity,
|
|
1269
|
+
link: input.link,
|
|
1270
|
+
unlink: input.unlink,
|
|
1271
|
+
syncDirectory: input.syncDirectory,
|
|
1272
|
+
reason: error,
|
|
1273
|
+
errorPrefix: input.errorPrefix,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
const sourceIdentity = await migrationPathIdentity(input.sourcePath);
|
|
1277
|
+
if (sourceIdentity) {
|
|
1278
|
+
if (!sameMigrationFileId(sourceIdentity, quarantineIdentity)) {
|
|
1279
|
+
throw new Error(`${input.errorPrefix}_quarantine_preserved_canonical_occupied`);
|
|
1280
|
+
}
|
|
1281
|
+
await input.unlink(input.quarantinePath);
|
|
1282
|
+
await input.syncDirectory(directory);
|
|
1283
|
+
quarantineIdentity = undefined;
|
|
1284
|
+
}
|
|
1285
|
+
else {
|
|
1286
|
+
await inspectExactMigrationCleanupFile({
|
|
1287
|
+
path: input.quarantinePath,
|
|
1288
|
+
expected: input.expected,
|
|
1289
|
+
allowedLinkCounts: input.allowedLinkCounts,
|
|
1290
|
+
readBinary: input.readBinary,
|
|
1291
|
+
assertFileTrust: input.assertFileTrust,
|
|
1292
|
+
identityError: input.identityError,
|
|
1293
|
+
hashError: input.hashError,
|
|
1294
|
+
});
|
|
1295
|
+
await input.unlink(input.quarantinePath);
|
|
1296
|
+
await input.syncDirectory(directory);
|
|
114
1297
|
return;
|
|
115
1298
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
1299
|
+
}
|
|
1300
|
+
const sourceIdentity = await migrationPathIdentity(input.sourcePath);
|
|
1301
|
+
if (!sourceIdentity)
|
|
1302
|
+
return;
|
|
1303
|
+
await inspectExactMigrationCleanupFile({
|
|
1304
|
+
path: input.sourcePath,
|
|
1305
|
+
expected: input.expected,
|
|
1306
|
+
allowedLinkCounts: input.allowedLinkCounts,
|
|
1307
|
+
readBinary: input.readBinary,
|
|
1308
|
+
assertFileTrust: input.assertFileTrust,
|
|
1309
|
+
identityError: input.identityError,
|
|
1310
|
+
hashError: input.hashError,
|
|
1311
|
+
});
|
|
1312
|
+
if (await migrationPathIdentity(input.quarantinePath)) {
|
|
1313
|
+
throw new Error(`${input.errorPrefix}_quarantine_occupied`);
|
|
1314
|
+
}
|
|
1315
|
+
await input.rename(input.sourcePath, input.quarantinePath);
|
|
1316
|
+
await input.syncDirectory(directory);
|
|
1317
|
+
let movedIdentity = await migrationPathIdentity(input.quarantinePath);
|
|
1318
|
+
if (!movedIdentity)
|
|
1319
|
+
throw new Error(`${input.errorPrefix}_quarantine_missing_after_move`);
|
|
1320
|
+
try {
|
|
1321
|
+
movedIdentity = await inspectExactMigrationCleanupFile({
|
|
1322
|
+
path: input.quarantinePath,
|
|
1323
|
+
expected: input.expected,
|
|
1324
|
+
allowedLinkCounts: input.allowedLinkCounts,
|
|
1325
|
+
readBinary: input.readBinary,
|
|
1326
|
+
assertFileTrust: input.assertFileTrust,
|
|
1327
|
+
identityError: input.identityError,
|
|
1328
|
+
hashError: input.hashError,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
catch (error) {
|
|
1332
|
+
await restoreMismatchedMigrationQuarantine({
|
|
1333
|
+
sourcePath: input.sourcePath,
|
|
1334
|
+
quarantinePath: input.quarantinePath,
|
|
1335
|
+
quarantineIdentity: movedIdentity,
|
|
1336
|
+
link: input.link,
|
|
1337
|
+
unlink: input.unlink,
|
|
1338
|
+
syncDirectory: input.syncDirectory,
|
|
1339
|
+
reason: error,
|
|
1340
|
+
errorPrefix: input.errorPrefix,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
if (await migrationPathIdentity(input.sourcePath)) {
|
|
1344
|
+
throw new Error(`${input.errorPrefix}_quarantine_preserved_canonical_recreated`);
|
|
1345
|
+
}
|
|
1346
|
+
await inspectExactMigrationCleanupFile({
|
|
1347
|
+
path: input.quarantinePath,
|
|
1348
|
+
expected: input.expected,
|
|
1349
|
+
allowedLinkCounts: input.allowedLinkCounts,
|
|
1350
|
+
readBinary: input.readBinary,
|
|
1351
|
+
assertFileTrust: input.assertFileTrust,
|
|
1352
|
+
identityError: input.identityError,
|
|
1353
|
+
hashError: input.hashError,
|
|
1354
|
+
});
|
|
1355
|
+
await input.unlink(input.quarantinePath);
|
|
1356
|
+
await input.syncDirectory(directory);
|
|
1357
|
+
}
|
|
1358
|
+
async function removeMaterializedMigrationTemporaryFile(input) {
|
|
1359
|
+
await quarantineAndRemoveExactMigrationFile({
|
|
1360
|
+
sourcePath: input.anchor.temporaryPath,
|
|
1361
|
+
quarantinePath: migrationTemporaryCleanupQuarantinePath(input.anchor.temporaryPath),
|
|
1362
|
+
expected: input.anchor,
|
|
1363
|
+
allowedLinkCounts: [1n, 2n],
|
|
1364
|
+
readBinary: input.readBinary,
|
|
1365
|
+
rename: input.rename,
|
|
1366
|
+
link: input.link,
|
|
1367
|
+
unlink: input.unlink,
|
|
1368
|
+
syncDirectory: input.syncDirectory,
|
|
1369
|
+
assertFileTrust: input.assertFileTrust,
|
|
1370
|
+
identityError: 'migration_materialized_temporary_identity_changed',
|
|
1371
|
+
hashError: 'migration_materialized_temporary_hash_changed',
|
|
1372
|
+
errorPrefix: 'migration_materialized_temporary_cleanup',
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
function parsePendingMigrationInstall(raw) {
|
|
1376
|
+
const record = parseCanonicalMigrationState(raw);
|
|
1377
|
+
if (!record)
|
|
1378
|
+
throw new Error('migration_install_journal_invalid');
|
|
1379
|
+
if (record.state !== 'installing')
|
|
1380
|
+
return undefined;
|
|
1381
|
+
return {
|
|
1382
|
+
version: record.version,
|
|
1383
|
+
destPath: record.destPath,
|
|
1384
|
+
anchor: record.installAnchor,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
async function recoverPendingMigrationInstall(input) {
|
|
1388
|
+
const initialAuthoritativePayload = readTrustedMigrationStateText(join(resolveBootstrapStateDir(input.env), MIGRATION_STATE_FILE), input.stateOptions);
|
|
1389
|
+
if (initialAuthoritativePayload === undefined)
|
|
1390
|
+
return false;
|
|
1391
|
+
let authoritativePayload = initialAuthoritativePayload;
|
|
1392
|
+
const pending = parsePendingMigrationInstall(authoritativePayload);
|
|
1393
|
+
if (!pending)
|
|
1394
|
+
return false;
|
|
1395
|
+
input.markCanonicalStateActive();
|
|
1396
|
+
const directory = dirname(pending.destPath);
|
|
1397
|
+
const storedHome = dirname(directory);
|
|
1398
|
+
const assertPendingCurrent = () => {
|
|
1399
|
+
input.assertOwnerCurrent();
|
|
1400
|
+
assertAuthoritativeMigrationStateCurrent(input.env, authoritativePayload, input.stateOptions);
|
|
1401
|
+
};
|
|
1402
|
+
assertPendingCurrent();
|
|
1403
|
+
await assertSafeExistingMigrationTargetDirectory(storedHome, directory, input.assertDirectoryTrust);
|
|
1404
|
+
const completeRecovery = () => {
|
|
1405
|
+
assertPendingCurrent();
|
|
1406
|
+
persistAuthoritativeMigrationState(input.env, {
|
|
1407
|
+
state: 'rolled_back',
|
|
1408
|
+
version: pending.version,
|
|
1409
|
+
destPath: pending.destPath,
|
|
1410
|
+
reason: 'install_recovered',
|
|
1411
|
+
installAnchor: pending.anchor,
|
|
1412
|
+
}, input.stateOptions);
|
|
1413
|
+
};
|
|
1414
|
+
if (pending.anchor.phase === 'planned') {
|
|
1415
|
+
let before;
|
|
1416
|
+
try {
|
|
1417
|
+
before = await lstat(pending.anchor.temporaryPath, { bigint: true });
|
|
1418
|
+
}
|
|
1419
|
+
catch (error) {
|
|
1420
|
+
if (!isErrno(error, 'ENOENT'))
|
|
1421
|
+
throw error;
|
|
1422
|
+
if (pending.anchor.directoryCreated) {
|
|
1423
|
+
assertPendingCurrent();
|
|
1424
|
+
await removeMigrationDirectoryIfEmpty(directory, input.rmdir);
|
|
1425
|
+
await input.syncDirectory(dirname(directory));
|
|
1426
|
+
}
|
|
1427
|
+
completeRecovery();
|
|
1428
|
+
return true;
|
|
1429
|
+
}
|
|
1430
|
+
await input.assertFileTrust(pending.anchor.temporaryPath);
|
|
1431
|
+
if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1n
|
|
1432
|
+
|| before.dev <= 0n || before.ino <= 0n || before.size > BigInt(pending.anchor.size)) {
|
|
1433
|
+
throw new Error('migration_planned_temporary_unowned');
|
|
1434
|
+
}
|
|
1435
|
+
const bytes = await input.readBinary(pending.anchor.temporaryPath);
|
|
1436
|
+
const after = await lstat(pending.anchor.temporaryPath, { bigint: true });
|
|
1437
|
+
if (after.isSymbolicLink()
|
|
1438
|
+
|| !after.isFile()
|
|
1439
|
+
|| after.dev !== before.dev
|
|
1440
|
+
|| after.ino !== before.ino
|
|
1441
|
+
|| after.nlink !== before.nlink
|
|
1442
|
+
|| after.size !== before.size
|
|
1443
|
+
|| after.mtimeNs !== before.mtimeNs
|
|
1444
|
+
|| after.ctimeNs !== before.ctimeNs) {
|
|
1445
|
+
throw new Error('migration_planned_temporary_identity_changed');
|
|
1446
|
+
}
|
|
1447
|
+
if (bytes.byteLength !== Number(after.size)) {
|
|
1448
|
+
throw new Error('migration_planned_temporary_identity_changed');
|
|
1449
|
+
}
|
|
1450
|
+
const cleanupAnchor = {
|
|
1451
|
+
phase: 'cleanup',
|
|
1452
|
+
temporaryPath: pending.anchor.temporaryPath,
|
|
1453
|
+
device: after.dev.toString(),
|
|
1454
|
+
inode: after.ino.toString(),
|
|
1455
|
+
size: bytes.byteLength,
|
|
1456
|
+
sha256: sha256(bytes),
|
|
1457
|
+
directoryCreated: pending.anchor.directoryCreated,
|
|
123
1458
|
};
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
1459
|
+
assertPendingCurrent();
|
|
1460
|
+
authoritativePayload = persistAuthoritativeMigrationState(input.env, {
|
|
1461
|
+
state: 'installing',
|
|
1462
|
+
version: pending.version,
|
|
1463
|
+
destPath: pending.destPath,
|
|
1464
|
+
installAnchor: cleanupAnchor,
|
|
1465
|
+
}, input.stateOptions);
|
|
1466
|
+
await quarantineAndRemoveExactMigrationFile({
|
|
1467
|
+
sourcePath: cleanupAnchor.temporaryPath,
|
|
1468
|
+
quarantinePath: migrationTemporaryCleanupQuarantinePath(cleanupAnchor.temporaryPath),
|
|
1469
|
+
expected: cleanupAnchor,
|
|
1470
|
+
allowedLinkCounts: [1n],
|
|
1471
|
+
readBinary: input.readBinary,
|
|
1472
|
+
rename: input.rename,
|
|
1473
|
+
link: input.link,
|
|
1474
|
+
unlink: input.unlink,
|
|
1475
|
+
syncDirectory: input.syncDirectory,
|
|
1476
|
+
assertFileTrust: input.assertFileTrust,
|
|
1477
|
+
identityError: 'migration_planned_temporary_identity_changed',
|
|
1478
|
+
hashError: 'migration_planned_temporary_hash_changed',
|
|
1479
|
+
errorPrefix: 'migration_planned_temporary_cleanup',
|
|
130
1480
|
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
1481
|
+
if (pending.anchor.directoryCreated) {
|
|
1482
|
+
assertPendingCurrent();
|
|
1483
|
+
await removeMigrationDirectoryIfEmpty(directory, input.rmdir);
|
|
1484
|
+
await input.syncDirectory(dirname(directory));
|
|
1485
|
+
}
|
|
1486
|
+
completeRecovery();
|
|
1487
|
+
return true;
|
|
1488
|
+
}
|
|
1489
|
+
if (pending.anchor.phase === 'cleanup') {
|
|
1490
|
+
await quarantineAndRemoveExactMigrationFile({
|
|
1491
|
+
sourcePath: pending.anchor.temporaryPath,
|
|
1492
|
+
quarantinePath: migrationTemporaryCleanupQuarantinePath(pending.anchor.temporaryPath),
|
|
1493
|
+
expected: pending.anchor,
|
|
1494
|
+
allowedLinkCounts: [1n],
|
|
1495
|
+
readBinary: input.readBinary,
|
|
1496
|
+
rename: input.rename,
|
|
1497
|
+
link: input.link,
|
|
1498
|
+
unlink: input.unlink,
|
|
1499
|
+
syncDirectory: input.syncDirectory,
|
|
1500
|
+
assertFileTrust: input.assertFileTrust,
|
|
1501
|
+
identityError: 'migration_planned_temporary_identity_changed',
|
|
1502
|
+
hashError: 'migration_planned_temporary_hash_changed',
|
|
1503
|
+
errorPrefix: 'migration_planned_temporary_cleanup',
|
|
136
1504
|
});
|
|
1505
|
+
if (pending.anchor.directoryCreated) {
|
|
1506
|
+
assertPendingCurrent();
|
|
1507
|
+
await removeMigrationDirectoryIfEmpty(directory, input.rmdir);
|
|
1508
|
+
await input.syncDirectory(dirname(directory));
|
|
1509
|
+
}
|
|
1510
|
+
completeRecovery();
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
const materializedAnchor = pending.anchor;
|
|
1514
|
+
assertPendingCurrent();
|
|
1515
|
+
await removeMaterializedMigrationTemporaryFile({
|
|
1516
|
+
anchor: materializedAnchor,
|
|
1517
|
+
readBinary: input.readBinary,
|
|
1518
|
+
rename: input.rename,
|
|
1519
|
+
link: input.link,
|
|
1520
|
+
unlink: input.unlink,
|
|
1521
|
+
syncDirectory: input.syncDirectory,
|
|
1522
|
+
assertFileTrust: input.assertFileTrust,
|
|
1523
|
+
});
|
|
1524
|
+
assertPendingCurrent();
|
|
1525
|
+
await rollbackPublishedMigrationTarget({
|
|
1526
|
+
destPath: pending.destPath,
|
|
1527
|
+
expectedSha256: materializedAnchor.sha256,
|
|
1528
|
+
readBinary: input.readBinary,
|
|
1529
|
+
rename: input.rename,
|
|
1530
|
+
link: input.link,
|
|
1531
|
+
unlink: input.unlink,
|
|
1532
|
+
rmdir: input.rmdir,
|
|
1533
|
+
syncDirectory: input.syncDirectory,
|
|
1534
|
+
assertFileTrust: input.assertFileTrust,
|
|
1535
|
+
}, {
|
|
1536
|
+
device: BigInt(materializedAnchor.device),
|
|
1537
|
+
inode: BigInt(materializedAnchor.inode),
|
|
1538
|
+
size: BigInt(materializedAnchor.size),
|
|
1539
|
+
}, materializedAnchor, false);
|
|
1540
|
+
if (materializedAnchor.directoryCreated) {
|
|
1541
|
+
assertPendingCurrent();
|
|
1542
|
+
await removeMigrationDirectoryIfEmpty(directory, input.rmdir);
|
|
1543
|
+
await input.syncDirectory(dirname(directory));
|
|
1544
|
+
}
|
|
1545
|
+
completeRecovery();
|
|
1546
|
+
return true;
|
|
1547
|
+
}
|
|
1548
|
+
async function removeMigrationDirectoryIfEmpty(directory, rmdir) {
|
|
1549
|
+
try {
|
|
1550
|
+
await rmdir(directory);
|
|
1551
|
+
}
|
|
1552
|
+
catch (error) {
|
|
1553
|
+
if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY') && !isErrno(error, 'EEXIST')) {
|
|
1554
|
+
throw error;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
async function rollbackPublishedMigrationTarget(input, anchor, materializedAnchor, directoryCreated) {
|
|
1559
|
+
await quarantineAndRemoveExactMigrationFile({
|
|
1560
|
+
sourcePath: input.destPath,
|
|
1561
|
+
quarantinePath: migrationInstallTargetQuarantinePath(materializedAnchor),
|
|
1562
|
+
expected: {
|
|
1563
|
+
device: anchor.device.toString(),
|
|
1564
|
+
inode: anchor.inode.toString(),
|
|
1565
|
+
size: Number(anchor.size),
|
|
1566
|
+
sha256: input.expectedSha256,
|
|
1567
|
+
},
|
|
1568
|
+
allowedLinkCounts: [1n],
|
|
1569
|
+
readBinary: input.readBinary,
|
|
1570
|
+
rename: input.rename,
|
|
1571
|
+
link: input.link,
|
|
1572
|
+
unlink: input.unlink,
|
|
1573
|
+
syncDirectory: input.syncDirectory,
|
|
1574
|
+
assertFileTrust: input.assertFileTrust,
|
|
1575
|
+
identityError: 'migration_install_rollback_identity_changed',
|
|
1576
|
+
hashError: 'migration_install_rollback_hash_changed',
|
|
1577
|
+
errorPrefix: 'migration_install_target_cleanup',
|
|
1578
|
+
});
|
|
1579
|
+
if (directoryCreated) {
|
|
1580
|
+
await removeMigrationDirectoryIfEmpty(dirname(input.destPath), input.rmdir);
|
|
1581
|
+
await input.syncDirectory(dirname(dirname(input.destPath)));
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
async function cleanupInstalledMigrationBinary(input) {
|
|
1585
|
+
if (input.install.disposition !== 'installed')
|
|
1586
|
+
return;
|
|
1587
|
+
const directory = dirname(input.destPath);
|
|
1588
|
+
await input.assertAuthorityCurrent();
|
|
1589
|
+
await input.assertDirectoryTrust(directory);
|
|
1590
|
+
if (await migrationPathIdentity(input.destPath)) {
|
|
1591
|
+
const currentIdentity = await inspectExistingMigrationTarget(input.destPath, input.version, input.preflight, input.readBinary, { sha256: input.expectedSha256, size: input.install.targetIdentity.size }, input.assertDirectoryTrust, input.assertFileTrust);
|
|
1592
|
+
if (!currentIdentity || !sameMigrationTargetIdentity(currentIdentity, input.install.targetIdentity)) {
|
|
1593
|
+
throw new Error('migration_cleanup_target_identity_changed');
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
await input.assertAuthorityCurrent();
|
|
1597
|
+
await quarantineAndRemoveExactMigrationFile({
|
|
1598
|
+
sourcePath: input.destPath,
|
|
1599
|
+
quarantinePath: input.quarantinePath,
|
|
1600
|
+
expected: input.install.targetIdentity,
|
|
1601
|
+
allowedLinkCounts: [1n],
|
|
1602
|
+
readBinary: input.readBinary,
|
|
1603
|
+
rename: input.rename,
|
|
1604
|
+
link: input.link,
|
|
1605
|
+
unlink: input.unlink,
|
|
1606
|
+
syncDirectory: input.syncDirectory,
|
|
1607
|
+
assertFileTrust: input.assertFileTrust,
|
|
1608
|
+
identityError: 'migration_cleanup_target_identity_changed',
|
|
1609
|
+
hashError: 'migration_cleanup_target_hash_changed',
|
|
1610
|
+
errorPrefix: 'migration_cleanup_target',
|
|
137
1611
|
});
|
|
1612
|
+
if (input.install.directoryCreated) {
|
|
1613
|
+
await removeMigrationDirectoryIfEmpty(directory, input.rmdir);
|
|
1614
|
+
await input.syncDirectory(dirname(directory));
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
function isCanonicalMigrationRegistrationState(record) {
|
|
1618
|
+
return record.state === 'registering' || record.state === 'blocked' || record.state === 'committed';
|
|
1619
|
+
}
|
|
1620
|
+
async function completeMigrationRollbackPending(input) {
|
|
1621
|
+
const assertCleanupAuthorityCurrent = async () => {
|
|
1622
|
+
await input.assertOwnerCurrent();
|
|
1623
|
+
assertAuthoritativeMigrationStateCurrent(input.env, input.expectedPayload, input.stateOptions);
|
|
1624
|
+
};
|
|
1625
|
+
const directory = dirname(input.record.destPath);
|
|
1626
|
+
const storedHome = dirname(directory);
|
|
1627
|
+
await assertCleanupAuthorityCurrent();
|
|
1628
|
+
await assertSafeExistingMigrationTargetDirectory(storedHome, directory, input.assertDirectoryTrust);
|
|
1629
|
+
if (input.record.installOwnership.disposition === 'installed') {
|
|
1630
|
+
const quarantinePath = migrationRegistrationTargetQuarantinePath(input.record.destPath, input.record.intentId);
|
|
1631
|
+
let targetPresent = true;
|
|
1632
|
+
try {
|
|
1633
|
+
await lstat(input.record.destPath);
|
|
1634
|
+
}
|
|
1635
|
+
catch (error) {
|
|
1636
|
+
if (!isErrno(error, 'ENOENT'))
|
|
1637
|
+
throw error;
|
|
1638
|
+
targetPresent = false;
|
|
1639
|
+
}
|
|
1640
|
+
const quarantinePresent = await migrationPathIdentity(quarantinePath) !== undefined;
|
|
1641
|
+
if (targetPresent || quarantinePresent) {
|
|
1642
|
+
await cleanupInstalledMigrationBinary({
|
|
1643
|
+
install: {
|
|
1644
|
+
disposition: 'installed',
|
|
1645
|
+
targetIdentity: input.record.targetIdentity,
|
|
1646
|
+
directoryCreated: false,
|
|
1647
|
+
},
|
|
1648
|
+
destPath: input.record.destPath,
|
|
1649
|
+
version: input.record.version,
|
|
1650
|
+
expectedSha256: input.record.targetIdentity.sha256,
|
|
1651
|
+
quarantinePath,
|
|
1652
|
+
readBinary: input.readBinary,
|
|
1653
|
+
preflight: input.preflightFn,
|
|
1654
|
+
rename: input.rename,
|
|
1655
|
+
link: input.link,
|
|
1656
|
+
unlink: input.unlink,
|
|
1657
|
+
rmdir: input.rmdir,
|
|
1658
|
+
syncDirectory: input.syncDirectory,
|
|
1659
|
+
assertDirectoryTrust: input.assertDirectoryTrust,
|
|
1660
|
+
assertFileTrust: input.assertFileTrust,
|
|
1661
|
+
assertAuthorityCurrent: assertCleanupAuthorityCurrent,
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
else if (!input.record.installOwnership.directoryCreated) {
|
|
1665
|
+
let directoryPresent = true;
|
|
1666
|
+
try {
|
|
1667
|
+
await lstat(directory);
|
|
1668
|
+
}
|
|
1669
|
+
catch (error) {
|
|
1670
|
+
if (!isErrno(error, 'ENOENT'))
|
|
1671
|
+
throw error;
|
|
1672
|
+
directoryPresent = false;
|
|
1673
|
+
}
|
|
1674
|
+
await assertCleanupAuthorityCurrent();
|
|
1675
|
+
await input.syncDirectory(directoryPresent ? directory : dirname(directory));
|
|
1676
|
+
}
|
|
1677
|
+
if (input.record.installOwnership.directoryCreated) {
|
|
1678
|
+
await assertCleanupAuthorityCurrent();
|
|
1679
|
+
await removeMigrationDirectoryIfEmpty(directory, input.rmdir);
|
|
1680
|
+
await input.syncDirectory(dirname(directory));
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
await assertCleanupAuthorityCurrent();
|
|
1684
|
+
persistAuthoritativeMigrationState(input.env, {
|
|
1685
|
+
state: 'rolled_back',
|
|
1686
|
+
version: input.record.version,
|
|
1687
|
+
destPath: input.record.destPath,
|
|
1688
|
+
intentId: input.record.intentId,
|
|
1689
|
+
targetIdentity: input.record.targetIdentity,
|
|
1690
|
+
installOwnership: input.record.installOwnership,
|
|
1691
|
+
reason: input.record.reason,
|
|
1692
|
+
}, input.stateOptions);
|
|
1693
|
+
}
|
|
1694
|
+
async function reconcileStoredMigrationInstall(input) {
|
|
1695
|
+
const { env, options, record, expectedPayload, stateOptions } = input;
|
|
1696
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_INSTALL_FAILED;
|
|
1697
|
+
const failClosed = (reason, detail) => ({
|
|
1698
|
+
action: 'return',
|
|
1699
|
+
result: {
|
|
1700
|
+
outcome: 'failed',
|
|
1701
|
+
reason: `${code}:${reason}`,
|
|
1702
|
+
destPath: record.destPath,
|
|
1703
|
+
message: `one-time standalone migration recovery failed (${code}:${reason}:${errorDetail(detail)})`,
|
|
1704
|
+
requiresForegroundExit: true,
|
|
1705
|
+
},
|
|
1706
|
+
});
|
|
1707
|
+
const readBinary = options.readBinary ?? ((path) => fsReadFile(path));
|
|
1708
|
+
const rename = options.rename ?? ((from, to) => fsRename(from, to));
|
|
1709
|
+
const link = options.link ?? ((from, to) => fsLink(from, to));
|
|
1710
|
+
const unlink = options.unlink ?? ((path) => fsUnlink(path));
|
|
1711
|
+
const rmdir = options.rmdir ?? ((path) => fsRmdir(path));
|
|
1712
|
+
const uid = options.uid ?? resolveProcessUid();
|
|
1713
|
+
const assertDirectoryTrust = options.assertDirectoryTrust
|
|
1714
|
+
?? ((directory) => assertTrustedMigrationDirectoryChain(directory, uid));
|
|
1715
|
+
const assertFileTrust = options.assertFileTrust
|
|
1716
|
+
?? ((path) => assertTrustedMigrationFile(path, uid));
|
|
1717
|
+
const syncDirectory = options.syncDirectory ?? (async (path) => {
|
|
1718
|
+
let handle;
|
|
1719
|
+
try {
|
|
1720
|
+
handle = await fsOpen(path, 'r');
|
|
1721
|
+
await handle.sync();
|
|
1722
|
+
}
|
|
1723
|
+
catch (error) {
|
|
1724
|
+
if (process.platform !== 'win32')
|
|
1725
|
+
throw error;
|
|
1726
|
+
}
|
|
1727
|
+
finally {
|
|
1728
|
+
await handle?.close();
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
try {
|
|
1732
|
+
return await withMigrationLock(resolveBootstrapStateDir(env), async (assertOwnerLeaseCurrent) => {
|
|
1733
|
+
assertOwnerLeaseCurrent();
|
|
1734
|
+
assertAuthoritativeMigrationStateCurrent(env, expectedPayload, stateOptions);
|
|
1735
|
+
const recovered = await recoverPendingMigrationInstall({
|
|
1736
|
+
env,
|
|
1737
|
+
stateOptions,
|
|
1738
|
+
readBinary,
|
|
1739
|
+
rename,
|
|
1740
|
+
link,
|
|
1741
|
+
unlink,
|
|
1742
|
+
rmdir,
|
|
1743
|
+
syncDirectory,
|
|
1744
|
+
assertDirectoryTrust,
|
|
1745
|
+
assertFileTrust,
|
|
1746
|
+
assertOwnerCurrent: assertOwnerLeaseCurrent,
|
|
1747
|
+
markCanonicalStateActive: () => undefined,
|
|
1748
|
+
});
|
|
1749
|
+
if (!recovered)
|
|
1750
|
+
return failClosed('stored_install_changed', 'stored installing state changed');
|
|
1751
|
+
return { action: 'retry_current' };
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
catch (error) {
|
|
1755
|
+
if (error instanceof Error && error.message === 'migration_lock_busy') {
|
|
1756
|
+
return failClosed('migration_in_progress', 'another live process owns migration recovery');
|
|
1757
|
+
}
|
|
1758
|
+
return failClosed('recovery_failed', errorDetail(error));
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
async function reconcileStoredMigrationRollbackPending(input) {
|
|
1762
|
+
const { env, options, record, expectedPayload, stateOptions } = input;
|
|
1763
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
1764
|
+
const failClosed = (reason, detail) => ({
|
|
1765
|
+
action: 'return',
|
|
1766
|
+
result: {
|
|
1767
|
+
outcome: 'failed',
|
|
1768
|
+
reason: `${code}:${reason}`,
|
|
1769
|
+
destPath: record.destPath,
|
|
1770
|
+
message: `one-time standalone migration rollback recovery failed (${code}:${reason}:${errorDetail(detail)})`,
|
|
1771
|
+
requiresForegroundExit: true,
|
|
1772
|
+
},
|
|
1773
|
+
});
|
|
1774
|
+
const readBinary = options.readBinary ?? ((path) => fsReadFile(path));
|
|
1775
|
+
const preflightFn = options.preflightFn
|
|
1776
|
+
?? ((targetPath, expectedVersion) => preflightManagedStagedBinary(targetPath, expectedVersion, options.probe));
|
|
1777
|
+
const rename = options.rename ?? ((from, to) => fsRename(from, to));
|
|
1778
|
+
const link = options.link ?? ((from, to) => fsLink(from, to));
|
|
1779
|
+
const unlink = options.unlink ?? ((path) => fsUnlink(path));
|
|
1780
|
+
const rmdir = options.rmdir ?? ((path) => fsRmdir(path));
|
|
1781
|
+
const uid = options.uid ?? resolveProcessUid();
|
|
1782
|
+
const assertDirectoryTrust = options.assertDirectoryTrust
|
|
1783
|
+
?? ((directory) => assertTrustedMigrationDirectoryChain(directory, uid));
|
|
1784
|
+
const assertFileTrust = options.assertFileTrust
|
|
1785
|
+
?? ((path) => assertTrustedMigrationFile(path, uid));
|
|
1786
|
+
const syncDirectory = options.syncDirectory ?? (async (path) => {
|
|
1787
|
+
let handle;
|
|
1788
|
+
try {
|
|
1789
|
+
handle = await fsOpen(path, 'r');
|
|
1790
|
+
await handle.sync();
|
|
1791
|
+
}
|
|
1792
|
+
catch (error) {
|
|
1793
|
+
if (process.platform !== 'win32')
|
|
1794
|
+
throw error;
|
|
1795
|
+
}
|
|
1796
|
+
finally {
|
|
1797
|
+
await handle?.close();
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
try {
|
|
1801
|
+
return await withMigrationLock(resolveBootstrapStateDir(env), async (assertOwnerLeaseCurrent) => {
|
|
1802
|
+
await completeMigrationRollbackPending({
|
|
1803
|
+
env,
|
|
1804
|
+
record,
|
|
1805
|
+
expectedPayload,
|
|
1806
|
+
stateOptions,
|
|
1807
|
+
readBinary,
|
|
1808
|
+
preflightFn,
|
|
1809
|
+
rename,
|
|
1810
|
+
link,
|
|
1811
|
+
unlink,
|
|
1812
|
+
rmdir,
|
|
1813
|
+
syncDirectory,
|
|
1814
|
+
assertDirectoryTrust,
|
|
1815
|
+
assertFileTrust,
|
|
1816
|
+
assertOwnerCurrent: assertOwnerLeaseCurrent,
|
|
1817
|
+
});
|
|
1818
|
+
return { action: 'retry_current' };
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
catch (error) {
|
|
1822
|
+
if (error instanceof Error && error.message === 'migration_lock_busy') {
|
|
1823
|
+
return failClosed('migration_in_progress', 'another live process owns migration rollback recovery');
|
|
1824
|
+
}
|
|
1825
|
+
return failClosed('rollback_cleanup_failed', errorDetail(error));
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
async function reconcileStoredMigrationRegistration(input) {
|
|
1829
|
+
const { env, platform, options, record, expectedPayload, stateOptions } = input;
|
|
1830
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
1831
|
+
const failed = (reason, message, _detail, requiresForegroundExit = false) => ({
|
|
1832
|
+
outcome: 'failed',
|
|
1833
|
+
reason,
|
|
1834
|
+
destPath: record.destPath,
|
|
1835
|
+
message,
|
|
1836
|
+
...(requiresForegroundExit ? { requiresForegroundExit: true } : {}),
|
|
1837
|
+
});
|
|
1838
|
+
const timedOut = (reason, message, detail, requiresForegroundExit) => failed(reason, message, detail, requiresForegroundExit);
|
|
1839
|
+
const failClosed = (reason, detail) => ({
|
|
1840
|
+
action: 'return',
|
|
1841
|
+
result: {
|
|
1842
|
+
outcome: 'failed',
|
|
1843
|
+
reason: `${code}:${reason}`,
|
|
1844
|
+
destPath: record.destPath,
|
|
1845
|
+
message: `one-time standalone migration recovery failed (${code}:${reason}:${errorDetail(detail)})`,
|
|
1846
|
+
requiresForegroundExit: true,
|
|
1847
|
+
},
|
|
1848
|
+
});
|
|
1849
|
+
const timeoutMs = options.timeoutMs ?? MIGRATION_REGISTER_TIMEOUT_MS;
|
|
1850
|
+
const transactionBudgetMs = options.transactionBudgetMs ?? MIGRATION_REGISTER_TRANSACTION_BUDGET_MS;
|
|
1851
|
+
const readBinary = options.readBinary ?? ((path) => fsReadFile(path));
|
|
1852
|
+
const rename = options.rename ?? ((from, to) => fsRename(from, to));
|
|
1853
|
+
const link = options.link ?? ((from, to) => fsLink(from, to));
|
|
1854
|
+
const unlink = options.unlink ?? ((path) => fsUnlink(path));
|
|
1855
|
+
const rmdir = options.rmdir ?? ((path) => fsRmdir(path));
|
|
1856
|
+
const syncDirectory = options.syncDirectory ?? (async (path) => {
|
|
1857
|
+
let handle;
|
|
1858
|
+
try {
|
|
1859
|
+
handle = await fsOpen(path, 'r');
|
|
1860
|
+
await handle.sync();
|
|
1861
|
+
}
|
|
1862
|
+
catch (error) {
|
|
1863
|
+
if (process.platform !== 'win32')
|
|
1864
|
+
throw error;
|
|
1865
|
+
}
|
|
1866
|
+
finally {
|
|
1867
|
+
await handle?.close();
|
|
1868
|
+
}
|
|
1869
|
+
});
|
|
1870
|
+
const preflightFn = options.preflightFn
|
|
1871
|
+
?? ((targetPath, expectedVersion) => preflightManagedStagedBinary(targetPath, expectedVersion, options.probe));
|
|
1872
|
+
const uid = options.uid ?? resolveProcessUid();
|
|
1873
|
+
const assertDirectoryTrust = options.assertDirectoryTrust
|
|
1874
|
+
?? ((directory) => assertTrustedMigrationDirectoryChain(directory, uid));
|
|
1875
|
+
const assertFileTrust = options.assertFileTrust
|
|
1876
|
+
?? ((path) => assertTrustedMigrationFile(path, uid));
|
|
1877
|
+
try {
|
|
1878
|
+
return await withMigrationLock(resolveBootstrapStateDir(env), async (assertOwnerLeaseCurrent) => {
|
|
1879
|
+
const assertStoredIntentCurrent = async () => {
|
|
1880
|
+
assertOwnerLeaseCurrent();
|
|
1881
|
+
assertAuthoritativeMigrationStateCurrent(env, expectedPayload, stateOptions);
|
|
1882
|
+
};
|
|
1883
|
+
const sealedIdentity = Object.freeze({ ...record.targetIdentity });
|
|
1884
|
+
const revalidateStoredTarget = () => assertMigrationTargetIdentityCurrent(sealedIdentity, record.version, { readBinary, preflightFn, assertDirectoryTrust, assertFileTrust });
|
|
1885
|
+
if (!Number.isSafeInteger(timeoutMs)
|
|
1886
|
+
|| !Number.isSafeInteger(transactionBudgetMs)
|
|
1887
|
+
|| timeoutMs <= transactionBudgetMs
|
|
1888
|
+
|| timeoutMs > MAX_TIMER_DELAY_MS
|
|
1889
|
+
|| transactionBudgetMs <= 0) {
|
|
1890
|
+
return failClosed('invalid_registration_timeout_contract', 'invalid registration timeout contract');
|
|
1891
|
+
}
|
|
1892
|
+
if (!options.registrationRunner) {
|
|
1893
|
+
return failClosed('registration_runner_unavailable', 'registration runner unavailable');
|
|
1894
|
+
}
|
|
1895
|
+
try {
|
|
1896
|
+
await assertStoredIntentCurrent();
|
|
1897
|
+
await revalidateStoredTarget();
|
|
1898
|
+
}
|
|
1899
|
+
catch (error) {
|
|
1900
|
+
return failClosed('stored_target_revalidation_failed', errorDetail(error));
|
|
1901
|
+
}
|
|
1902
|
+
const registration = await register(env, platform, record.version, record.destPath, sealedIdentity, revalidateStoredTarget, assertStoredIntentCurrent, options, timeoutMs, transactionBudgetMs, failed, timedOut);
|
|
1903
|
+
try {
|
|
1904
|
+
await assertStoredIntentCurrent();
|
|
1905
|
+
}
|
|
1906
|
+
catch (error) {
|
|
1907
|
+
return failClosed('stored_intent_changed', errorDetail(error));
|
|
1908
|
+
}
|
|
1909
|
+
const intentBase = {
|
|
1910
|
+
version: record.version,
|
|
1911
|
+
destPath: record.destPath,
|
|
1912
|
+
intentId: record.intentId,
|
|
1913
|
+
targetIdentity: sealedIdentity,
|
|
1914
|
+
installOwnership: record.installOwnership,
|
|
1915
|
+
};
|
|
1916
|
+
if (registration.outcome === 'migrated') {
|
|
1917
|
+
persistAuthoritativeMigrationState(env, { state: 'committed', ...intentBase }, stateOptions);
|
|
1918
|
+
recordBootstrapAttempt(env, { ok: true, reason: 'migrated', detail: record.destPath }, stateOptions);
|
|
1919
|
+
return { action: 'return', result: registration };
|
|
1920
|
+
}
|
|
1921
|
+
if (registration.requiresForegroundExit === true) {
|
|
1922
|
+
try {
|
|
1923
|
+
persistAuthoritativeMigrationState(env, { state: 'blocked', ...intentBase, reason: registration.reason }, stateOptions);
|
|
1924
|
+
}
|
|
1925
|
+
catch {
|
|
1926
|
+
// Preserve the exact prior active record when blocked-state publication is uncertain.
|
|
1927
|
+
}
|
|
1928
|
+
return { action: 'return', result: registration };
|
|
1929
|
+
}
|
|
1930
|
+
const rollbackPendingRecord = {
|
|
1931
|
+
state: 'rollback_pending',
|
|
1932
|
+
...intentBase,
|
|
1933
|
+
reason: registration.reason,
|
|
1934
|
+
};
|
|
1935
|
+
let rollbackPendingPayload;
|
|
1936
|
+
try {
|
|
1937
|
+
await assertStoredIntentCurrent();
|
|
1938
|
+
rollbackPendingPayload = persistAuthoritativeMigrationState(env, rollbackPendingRecord, stateOptions);
|
|
1939
|
+
}
|
|
1940
|
+
catch (error) {
|
|
1941
|
+
return failClosed('rollback_intent_persistence_failed', errorDetail(error));
|
|
1942
|
+
}
|
|
1943
|
+
try {
|
|
1944
|
+
await completeMigrationRollbackPending({
|
|
1945
|
+
env,
|
|
1946
|
+
record: { ...intentBase, reason: registration.reason },
|
|
1947
|
+
expectedPayload: rollbackPendingPayload,
|
|
1948
|
+
stateOptions,
|
|
1949
|
+
readBinary,
|
|
1950
|
+
preflightFn,
|
|
1951
|
+
rename,
|
|
1952
|
+
link,
|
|
1953
|
+
unlink,
|
|
1954
|
+
rmdir,
|
|
1955
|
+
syncDirectory,
|
|
1956
|
+
assertDirectoryTrust,
|
|
1957
|
+
assertFileTrust,
|
|
1958
|
+
assertOwnerCurrent: assertOwnerLeaseCurrent,
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
catch (error) {
|
|
1962
|
+
return failClosed('stored_target_cleanup_failed', errorDetail(error));
|
|
1963
|
+
}
|
|
1964
|
+
return { action: 'retry_current' };
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
catch (error) {
|
|
1968
|
+
if (error instanceof Error && error.message === 'migration_lock_busy') {
|
|
1969
|
+
return failClosed('migration_in_progress', 'another live process owns migration recovery');
|
|
1970
|
+
}
|
|
1971
|
+
return failClosed('stored_reconciliation_failed', errorDetail(error));
|
|
1972
|
+
}
|
|
138
1973
|
}
|
|
139
1974
|
/**
|
|
140
1975
|
* One-time migration of the npm/JS install shape to the standalone release binary.
|
|
141
|
-
* Never throws: every failure/skip becomes a structured MigrationResult
|
|
142
|
-
*
|
|
1976
|
+
* Never throws: every failure/skip becomes a structured MigrationResult. Only a proven clean
|
|
1977
|
+
* failure may continue degraded; ambiguous registration ownership requires foreground exit.
|
|
143
1978
|
*/
|
|
144
1979
|
export async function migrateToStandaloneBinary(env, platform = process.platform, options = {}) {
|
|
145
1980
|
const now = options.now ?? Date.now();
|
|
146
1981
|
const exists = options.exists ?? existsSync;
|
|
147
1982
|
const readText = options.readFile ?? defaultReadTextFile;
|
|
148
1983
|
const recordOptions = { now, ...(options.writeFile ? { writeFile: options.writeFile } : {}) };
|
|
1984
|
+
const stateOptions = {
|
|
1985
|
+
...recordOptions,
|
|
1986
|
+
...(options.assertStateDirectoryTrust
|
|
1987
|
+
? { assertStateDirectoryTrust: options.assertStateDirectoryTrust }
|
|
1988
|
+
: {}),
|
|
1989
|
+
...(options.assertStateFileTrust
|
|
1990
|
+
? { assertStateFileTrust: options.assertStateFileTrust }
|
|
1991
|
+
: {}),
|
|
1992
|
+
};
|
|
1993
|
+
const startupState = inspectMigrationStartupState(env, stateOptions);
|
|
1994
|
+
if (startupState.classification === 'unsafe') {
|
|
1995
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
1996
|
+
return {
|
|
1997
|
+
outcome: 'failed',
|
|
1998
|
+
reason: `${code}:migration_state_unsafe`,
|
|
1999
|
+
message: `one-time standalone migration failed (${code}:migration_state_unsafe:${startupState.detail})`,
|
|
2000
|
+
requiresForegroundExit: true,
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
const hadPriorAmbiguity = startupState.classification === 'retryable'
|
|
2004
|
+
|| (!startupState.canonicalPresent && unresolvedLegacyMigrationOwnership(env, readText));
|
|
2005
|
+
let ownerLeaseHeld = false;
|
|
2006
|
+
// A prior active canonical record remains the recovery authority until this owner publishes
|
|
2007
|
+
// a new authoritative install/registration transition. Advisory progress/failure writes must
|
|
2008
|
+
// never erase the only durable evidence of possible artifact or child ownership.
|
|
2009
|
+
let canonicalMutationStateActive = startupState.classification === 'retryable';
|
|
149
2010
|
const skipped = (reason, message) => {
|
|
150
|
-
|
|
2011
|
+
if (hadPriorAmbiguity) {
|
|
2012
|
+
return {
|
|
2013
|
+
outcome: 'failed',
|
|
2014
|
+
reason: `${SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED}:ambiguous_pending:${reason}`,
|
|
2015
|
+
message: 'one-time standalone migration cannot skip while registration ownership is unresolved',
|
|
2016
|
+
requiresForegroundExit: true,
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
151
2019
|
return { outcome: 'skipped', reason, message };
|
|
152
2020
|
};
|
|
153
2021
|
// Gate order: switch → root → CI → container → cooldown. shouldBootstrap returns
|
|
@@ -168,9 +2036,57 @@ export async function migrateToStandaloneBinary(env, platform = process.platform
|
|
|
168
2036
|
if (platform === 'linux' && looksLikeContainer(exists, readText)) {
|
|
169
2037
|
return skipped('container_environment', 'one-time standalone migration skipped in a container');
|
|
170
2038
|
}
|
|
171
|
-
|
|
2039
|
+
// A prior ambiguous attempt is retried through signed artifact validation and the durable
|
|
2040
|
+
// lifecycle reconciler. Until a registration succeeds, every new failure stays fail-closed.
|
|
2041
|
+
if (!hadPriorAmbiguity && recentBootstrapFailure(env, readText, now)) {
|
|
172
2042
|
return skipped('cooldown', 'one-time standalone migration skipped (recent bootstrap failure cooldown)');
|
|
173
2043
|
}
|
|
2044
|
+
if (startupState.classification === 'retryable'
|
|
2045
|
+
&& startupState.record.state === 'installing') {
|
|
2046
|
+
const reconciliation = await reconcileStoredMigrationInstall({
|
|
2047
|
+
env,
|
|
2048
|
+
options,
|
|
2049
|
+
record: startupState.record,
|
|
2050
|
+
expectedPayload: startupState.raw,
|
|
2051
|
+
stateOptions,
|
|
2052
|
+
});
|
|
2053
|
+
if (reconciliation.action === 'return')
|
|
2054
|
+
return reconciliation.result;
|
|
2055
|
+
// Recovery used the exact stored version/destination transaction and durably terminalized
|
|
2056
|
+
// it. Re-read the environment only after that authority has been released.
|
|
2057
|
+
return migrateToStandaloneBinary(env, platform, options);
|
|
2058
|
+
}
|
|
2059
|
+
if (startupState.classification === 'retryable'
|
|
2060
|
+
&& startupState.record.state === 'rollback_pending') {
|
|
2061
|
+
const reconciliation = await reconcileStoredMigrationRollbackPending({
|
|
2062
|
+
env,
|
|
2063
|
+
options,
|
|
2064
|
+
record: startupState.record,
|
|
2065
|
+
expectedPayload: startupState.raw,
|
|
2066
|
+
stateOptions,
|
|
2067
|
+
});
|
|
2068
|
+
if (reconciliation.action === 'return')
|
|
2069
|
+
return reconciliation.result;
|
|
2070
|
+
// Cleanup is idempotently complete and terminal state is durable; only now may the current
|
|
2071
|
+
// version/home request replace the old transaction authority.
|
|
2072
|
+
return migrateToStandaloneBinary(env, platform, options);
|
|
2073
|
+
}
|
|
2074
|
+
if (startupState.classification === 'retryable'
|
|
2075
|
+
&& isCanonicalMigrationRegistrationState(startupState.record)) {
|
|
2076
|
+
const reconciliation = await reconcileStoredMigrationRegistration({
|
|
2077
|
+
env,
|
|
2078
|
+
platform,
|
|
2079
|
+
options,
|
|
2080
|
+
record: startupState.record,
|
|
2081
|
+
expectedPayload: startupState.raw,
|
|
2082
|
+
stateOptions,
|
|
2083
|
+
});
|
|
2084
|
+
if (reconciliation.action === 'return')
|
|
2085
|
+
return reconciliation.result;
|
|
2086
|
+
// The exact old transaction is now durably rolled back. Re-read canonical state before
|
|
2087
|
+
// beginning the current version/home request so no stale in-memory authority crosses it.
|
|
2088
|
+
return migrateToStandaloneBinary(env, platform, options);
|
|
2089
|
+
}
|
|
174
2090
|
// RESOLVE: version, asset name, dest path.
|
|
175
2091
|
const version = resolveMigrationVersion(env);
|
|
176
2092
|
if (!version) {
|
|
@@ -186,130 +2102,571 @@ export async function migrateToStandaloneBinary(env, platform = process.platform
|
|
|
186
2102
|
// Unsupported platform (e.g. win-arm64): skip without recording a cooldown-worthy attempt.
|
|
187
2103
|
return skipped('unsupported_platform', `one-time standalone migration skipped (unsupported platform ${platform}/${options.arch ?? process.arch})`);
|
|
188
2104
|
}
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
const fail = (reason, message, attemptDetail) => {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
2105
|
+
const migrationHome = resolveMigrationHome(env);
|
|
2106
|
+
const destPath = join(migrationHome, 'bin', assetName);
|
|
2107
|
+
const fail = (reason, message, attemptDetail, requiresForegroundExit = false, preserveCanonicalState = false) => {
|
|
2108
|
+
const mustExit = requiresForegroundExit || hadPriorAmbiguity;
|
|
2109
|
+
if (ownerLeaseHeld) {
|
|
2110
|
+
recordBootstrapAttempt(env, {
|
|
2111
|
+
ok: false,
|
|
2112
|
+
reason: mustExit ? 'migration_ambiguous' : 'migration_failed',
|
|
2113
|
+
detail: attemptDetail ?? reason,
|
|
2114
|
+
}, recordOptions);
|
|
2115
|
+
if (!canonicalMutationStateActive && !preserveCanonicalState && !mustExit) {
|
|
2116
|
+
writeMigrationState(env, { state: 'failed', version, destPath, reason }, options);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
return {
|
|
2120
|
+
outcome: 'failed',
|
|
2121
|
+
reason,
|
|
2122
|
+
destPath,
|
|
2123
|
+
message,
|
|
2124
|
+
...(mustExit ? { requiresForegroundExit: true } : {}),
|
|
2125
|
+
};
|
|
195
2126
|
};
|
|
196
|
-
const failTimeout = (reason, message, attemptDetail) => {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
2127
|
+
const failTimeout = (reason, message, attemptDetail, requiresForegroundExit = true) => {
|
|
2128
|
+
const mustExit = requiresForegroundExit || hadPriorAmbiguity;
|
|
2129
|
+
if (ownerLeaseHeld) {
|
|
2130
|
+
recordBootstrapAttempt(env, {
|
|
2131
|
+
ok: false,
|
|
2132
|
+
reason: mustExit ? 'migration_ambiguous' : 'migration_timeout',
|
|
2133
|
+
detail: attemptDetail,
|
|
2134
|
+
}, recordOptions);
|
|
2135
|
+
if (!canonicalMutationStateActive && !mustExit) {
|
|
2136
|
+
writeMigrationState(env, { state: 'failed', version, destPath, reason }, options);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
return {
|
|
2140
|
+
outcome: 'failed',
|
|
2141
|
+
reason,
|
|
2142
|
+
destPath,
|
|
2143
|
+
message,
|
|
2144
|
+
...(mustExit ? { requiresForegroundExit: true } : {}),
|
|
2145
|
+
};
|
|
200
2146
|
};
|
|
2147
|
+
const timeoutMs = options.timeoutMs ?? MIGRATION_REGISTER_TIMEOUT_MS;
|
|
2148
|
+
const transactionBudgetMs = options.transactionBudgetMs ?? MIGRATION_REGISTER_TRANSACTION_BUDGET_MS;
|
|
2149
|
+
if (!options.registrationRunner) {
|
|
2150
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
2151
|
+
return fail(`${code}:registration_runner_unavailable`, `one-time standalone migration failed (${code}:registration_runner_unavailable)`, `${code}: registration_runner_unavailable`);
|
|
2152
|
+
}
|
|
2153
|
+
if (!Number.isSafeInteger(timeoutMs)
|
|
2154
|
+
|| !Number.isSafeInteger(transactionBudgetMs)
|
|
2155
|
+
|| timeoutMs <= transactionBudgetMs
|
|
2156
|
+
|| timeoutMs > MAX_TIMER_DELAY_MS
|
|
2157
|
+
|| transactionBudgetMs <= 0) {
|
|
2158
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
2159
|
+
return fail(`${code}:invalid_registration_timeout_contract`, `one-time standalone migration failed (${code}:invalid_registration_timeout_contract)`, `${code}: invalid_registration_timeout_contract`);
|
|
2160
|
+
}
|
|
201
2161
|
const readBinary = options.readBinary ?? ((path) => fsReadFile(path));
|
|
202
2162
|
const writeBinary = options.writeBinary
|
|
203
|
-
?? ((path, content, mode) => fsWriteFile(path, content, { mode }));
|
|
2163
|
+
?? ((path, content, mode) => fsWriteFile(path, content, { mode, flag: 'wx' }));
|
|
204
2164
|
const mkdir = options.mkdir ?? ((path, mode) => fsMkdir(path, { recursive: true, mode }).then(() => undefined));
|
|
205
2165
|
const rm = options.rm ?? ((path) => fsRm(path, { recursive: true, force: true }));
|
|
206
|
-
const
|
|
2166
|
+
const unlink = options.unlink ?? ((path) => fsUnlink(path));
|
|
2167
|
+
const rmdir = options.rmdir ?? ((path) => fsRmdir(path));
|
|
207
2168
|
const chmod = options.chmod ?? ((path, mode) => fsChmod(path, mode));
|
|
2169
|
+
const syncFile = options.syncFile ?? (async (path) => {
|
|
2170
|
+
const handle = await fsOpen(path, 'r+');
|
|
2171
|
+
try {
|
|
2172
|
+
await handle.sync();
|
|
2173
|
+
}
|
|
2174
|
+
finally {
|
|
2175
|
+
await handle.close();
|
|
2176
|
+
}
|
|
2177
|
+
});
|
|
2178
|
+
const syncDirectory = options.syncDirectory ?? (async (path) => {
|
|
2179
|
+
let handle;
|
|
2180
|
+
try {
|
|
2181
|
+
handle = await fsOpen(path, 'r');
|
|
2182
|
+
await handle.sync();
|
|
2183
|
+
}
|
|
2184
|
+
catch (error) {
|
|
2185
|
+
// Windows does not expose reliable directory fsync handles; the executable is fsynced.
|
|
2186
|
+
if (process.platform !== 'win32')
|
|
2187
|
+
throw error;
|
|
2188
|
+
}
|
|
2189
|
+
finally {
|
|
2190
|
+
await handle?.close();
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
208
2193
|
const stat = options.stat ?? ((path) => lstat(path));
|
|
2194
|
+
const link = options.link ?? ((from, to) => fsLink(from, to));
|
|
2195
|
+
const rename = options.rename ?? ((from, to) => fsRename(from, to));
|
|
209
2196
|
const preflightFn = options.preflightFn
|
|
210
2197
|
?? ((targetPath, expectedVersion) => preflightManagedStagedBinary(targetPath, expectedVersion, options.probe));
|
|
211
2198
|
const downloadFn = options.downloadFn ?? downloadGithubReleaseArtifact;
|
|
212
2199
|
const verifyFn = options.verifyFn
|
|
213
2200
|
?? ((manifest, downloaded, publicKey) => ops.verifySelectedManifestArtifact(manifest, downloaded, publicKey));
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
// Existing binary is unusable — fall through and download a fresh copy over it.
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// DOWNLOAD: signed manifest + staged binary under tmpdir.
|
|
225
|
-
const directive = { required_version: version };
|
|
226
|
-
const releaseOpts = {
|
|
227
|
-
env,
|
|
228
|
-
platform,
|
|
229
|
-
arch: options.arch,
|
|
230
|
-
fetchFn: options.fetchFn,
|
|
231
|
-
requireSignedManifest: true,
|
|
232
|
-
};
|
|
233
|
-
let manifest;
|
|
234
|
-
let download;
|
|
2201
|
+
const assertDirectoryTrust = options.assertDirectoryTrust
|
|
2202
|
+
?? ((directory) => assertTrustedMigrationDirectoryChain(directory, uid));
|
|
2203
|
+
const assertFileTrust = options.assertFileTrust
|
|
2204
|
+
?? ((path) => assertTrustedMigrationFile(path, uid));
|
|
2205
|
+
// Pure contract and read-only target validation precede even lock-namespace creation.
|
|
235
2206
|
try {
|
|
236
|
-
|
|
237
|
-
download = await downloadFn(version, directive, releaseOpts);
|
|
2207
|
+
await assertSafeExistingMigrationTargetDirectory(migrationHome, dirname(destPath), assertDirectoryTrust);
|
|
238
2208
|
}
|
|
239
2209
|
catch (err) {
|
|
240
|
-
const code = SELF_UPDATE_FAILURE_CODES.
|
|
2210
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_INSTALL_FAILED;
|
|
241
2211
|
return fail(`${code}:${errorDetail(err)}`, `one-time standalone migration failed (${code})`, `${code}: ${errorDetail(err)}`);
|
|
242
2212
|
}
|
|
243
|
-
const
|
|
244
|
-
// VERIFY: ed25519 signature over the complete manifest + sha256 of the staged bytes.
|
|
245
|
-
// Fail closed: unverified bytes are NEVER written into the user's home.
|
|
246
|
-
const verification = verifyFn(manifest, download.artifacts, resolveSelfUpdatePublicKey(env));
|
|
247
|
-
if (!verification.ok) {
|
|
248
|
-
await rmSafe(rm, stagedDir);
|
|
249
|
-
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_VERIFY_FAILED;
|
|
250
|
-
return fail(`${code}:${verification.reason}`, `one-time standalone migration failed (${code})`, `${code}: ${verification.reason}`);
|
|
251
|
-
}
|
|
252
|
-
// Preflight the staged binary for real (`--version` + `proxy --help`) before install.
|
|
2213
|
+
const migrationLockDirectory = resolveBootstrapStateDir(env);
|
|
253
2214
|
try {
|
|
254
|
-
await
|
|
2215
|
+
await ensureSafeMigrationDirectory(migrationLockDirectory, mkdir, assertDirectoryTrust);
|
|
255
2216
|
}
|
|
256
2217
|
catch (err) {
|
|
257
|
-
await rmSafe(rm, stagedDir);
|
|
258
|
-
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_VERIFY_FAILED;
|
|
259
|
-
const detail = `preflight:${errorDetail(err)}`;
|
|
260
|
-
return fail(`${code}:${detail}`, `one-time standalone migration failed (${code})`, `${code}: ${detail}`);
|
|
261
|
-
}
|
|
262
|
-
// INSTALL: atomic copy into <home>/bin (verified bytes only).
|
|
263
|
-
try {
|
|
264
|
-
await mkdir(dirname(destPath), 0o700);
|
|
265
|
-
const stagedStat = await stat(download.stagedPath);
|
|
266
|
-
if (stagedStat.isSymbolicLink() || !stagedStat.isFile()) {
|
|
267
|
-
throw new Error('staged_artifact_not_regular_file');
|
|
268
|
-
}
|
|
269
|
-
const bytes = await readBinary(download.stagedPath);
|
|
270
|
-
const tmpDest = `${destPath}.tmp`;
|
|
271
|
-
try {
|
|
272
|
-
await writeBinary(tmpDest, bytes, 0o755);
|
|
273
|
-
await chmod(tmpDest, 0o755);
|
|
274
|
-
await rename(tmpDest, destPath);
|
|
275
|
-
}
|
|
276
|
-
catch (err) {
|
|
277
|
-
await rmSafe(rm, tmpDest);
|
|
278
|
-
throw err;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
await rmSafe(rm, stagedDir);
|
|
283
2218
|
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_INSTALL_FAILED;
|
|
284
2219
|
return fail(`${code}:${errorDetail(err)}`, `one-time standalone migration failed (${code})`, `${code}: ${errorDetail(err)}`);
|
|
285
2220
|
}
|
|
286
|
-
|
|
287
|
-
await
|
|
2221
|
+
try {
|
|
2222
|
+
return await withMigrationLock(migrationLockDirectory, async (assertOwnerLeaseCurrent) => {
|
|
2223
|
+
ownerLeaseHeld = true;
|
|
2224
|
+
try {
|
|
2225
|
+
const recoveredInstall = await recoverPendingMigrationInstall({
|
|
2226
|
+
env,
|
|
2227
|
+
stateOptions,
|
|
2228
|
+
readBinary,
|
|
2229
|
+
rename,
|
|
2230
|
+
link,
|
|
2231
|
+
unlink,
|
|
2232
|
+
rmdir,
|
|
2233
|
+
syncDirectory,
|
|
2234
|
+
assertDirectoryTrust,
|
|
2235
|
+
assertFileTrust,
|
|
2236
|
+
assertOwnerCurrent: assertOwnerLeaseCurrent,
|
|
2237
|
+
markCanonicalStateActive: () => {
|
|
2238
|
+
canonicalMutationStateActive = true;
|
|
2239
|
+
},
|
|
2240
|
+
});
|
|
2241
|
+
if (recoveredInstall)
|
|
2242
|
+
canonicalMutationStateActive = true;
|
|
2243
|
+
}
|
|
2244
|
+
catch (error) {
|
|
2245
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_INSTALL_FAILED;
|
|
2246
|
+
return fail(`${code}:recovery_failed`, `one-time standalone migration failed (${code}:recovery_failed)`, `${code}: recovery_failed:${errorDetail(error)}`, true);
|
|
2247
|
+
}
|
|
2248
|
+
// Revalidate after acquiring ownership so the read-before-mutation check cannot go stale.
|
|
2249
|
+
try {
|
|
2250
|
+
await assertSafeExistingMigrationTargetDirectory(migrationHome, dirname(destPath), assertDirectoryTrust);
|
|
2251
|
+
}
|
|
2252
|
+
catch (err) {
|
|
2253
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_INSTALL_FAILED;
|
|
2254
|
+
return fail(`${code}:${errorDetail(err)}`, `one-time standalone migration failed (${code})`, `${code}: ${errorDetail(err)}`);
|
|
2255
|
+
}
|
|
2256
|
+
if (!canonicalMutationStateActive) {
|
|
2257
|
+
writeMigrationState(env, { state: 'in_progress', version, destPath }, options);
|
|
2258
|
+
}
|
|
2259
|
+
// DOWNLOAD: signed manifest + staged binary under tmpdir.
|
|
2260
|
+
const directive = { required_version: version };
|
|
2261
|
+
const releaseOpts = {
|
|
2262
|
+
env,
|
|
2263
|
+
platform,
|
|
2264
|
+
arch: options.arch,
|
|
2265
|
+
fetchFn: options.fetchFn,
|
|
2266
|
+
requireSignedManifest: true,
|
|
2267
|
+
};
|
|
2268
|
+
let manifest;
|
|
2269
|
+
let download;
|
|
2270
|
+
try {
|
|
2271
|
+
manifest = await resolveGithubReleaseManifest(directive, releaseOpts);
|
|
2272
|
+
download = await downloadFn(version, directive, releaseOpts);
|
|
2273
|
+
}
|
|
2274
|
+
catch (err) {
|
|
2275
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_DOWNLOAD_FAILED;
|
|
2276
|
+
return fail(`${code}:${errorDetail(err)}`, `one-time standalone migration failed (${code})`, `${code}: ${errorDetail(err)}`);
|
|
2277
|
+
}
|
|
2278
|
+
const stagedDir = dirname(download.stagedPath);
|
|
2279
|
+
// VERIFY: ed25519 signature over the complete manifest + sha256 of the staged bytes.
|
|
2280
|
+
// Fail closed: unverified bytes are NEVER written into the user's home.
|
|
2281
|
+
const verification = verifyFn(manifest, download.artifacts, resolveSelfUpdatePublicKey(env));
|
|
2282
|
+
if (!verification.ok) {
|
|
2283
|
+
await rmSafe(rm, stagedDir);
|
|
2284
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_VERIFY_FAILED;
|
|
2285
|
+
const detail = errorDetail(verification.reason);
|
|
2286
|
+
return fail(`${code}:${detail}`, `one-time standalone migration failed (${code})`, `${code}: ${detail}`);
|
|
2287
|
+
}
|
|
2288
|
+
let stagedBytes;
|
|
2289
|
+
let expectedSha256;
|
|
2290
|
+
try {
|
|
2291
|
+
const stagedStat = await stat(download.stagedPath);
|
|
2292
|
+
if (stagedStat.isSymbolicLink() || !stagedStat.isFile()) {
|
|
2293
|
+
throw new Error('staged_artifact_not_regular_file');
|
|
2294
|
+
}
|
|
2295
|
+
stagedBytes = await readBinary(download.stagedPath);
|
|
2296
|
+
const selected = download.artifacts.length === 1 && download.artifacts[0]?.path === assetName
|
|
2297
|
+
? download.artifacts[0]
|
|
2298
|
+
: undefined;
|
|
2299
|
+
expectedSha256 = selected?.sha256 ?? (selected?.bytes ? sha256(selected.bytes) : '');
|
|
2300
|
+
if (!/^[0-9a-f]{64}$/.test(expectedSha256) || sha256(stagedBytes) !== expectedSha256) {
|
|
2301
|
+
throw new Error('staged_artifact_hash_mismatch');
|
|
2302
|
+
}
|
|
2303
|
+
// Preflight the staged binary for real (`--version` + `proxy --help`) before install.
|
|
2304
|
+
await preflightFn(download.stagedPath, version);
|
|
2305
|
+
}
|
|
2306
|
+
catch (err) {
|
|
2307
|
+
await rmSafe(rm, stagedDir);
|
|
2308
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_VERIFY_FAILED;
|
|
2309
|
+
const detail = `preflight:${errorDetail(err)}`;
|
|
2310
|
+
return fail(`${code}:${detail}`, `one-time standalone migration failed (${code})`, `${code}: ${detail}`);
|
|
2311
|
+
}
|
|
2312
|
+
await rmSafe(rm, stagedDir);
|
|
2313
|
+
// The same lease spans INSTALL -> identity revalidation -> REGISTER -> conditional cleanup.
|
|
2314
|
+
// A second process must never register the target while this owner can still roll it back.
|
|
2315
|
+
let installResult;
|
|
2316
|
+
let installAnchor;
|
|
2317
|
+
try {
|
|
2318
|
+
installResult = await installVerifiedMigrationBinary({
|
|
2319
|
+
home: migrationHome,
|
|
2320
|
+
destPath,
|
|
2321
|
+
version,
|
|
2322
|
+
bytes: stagedBytes,
|
|
2323
|
+
expectedSha256,
|
|
2324
|
+
mkdir,
|
|
2325
|
+
writeBinary,
|
|
2326
|
+
readBinary,
|
|
2327
|
+
chmod,
|
|
2328
|
+
syncFile,
|
|
2329
|
+
syncDirectory,
|
|
2330
|
+
stat,
|
|
2331
|
+
link,
|
|
2332
|
+
rename,
|
|
2333
|
+
unlink,
|
|
2334
|
+
rmdir,
|
|
2335
|
+
preflight: preflightFn,
|
|
2336
|
+
assertDirectoryTrust,
|
|
2337
|
+
assertFileTrust,
|
|
2338
|
+
publishInstallIntent: async (anchor) => {
|
|
2339
|
+
const previousAnchor = installAnchor;
|
|
2340
|
+
const installingRecord = {
|
|
2341
|
+
state: 'installing',
|
|
2342
|
+
version,
|
|
2343
|
+
destPath,
|
|
2344
|
+
installAnchor: anchor,
|
|
2345
|
+
};
|
|
2346
|
+
const expectedPayload = serializeMigrationState(installingRecord, recordOptions);
|
|
2347
|
+
assertOwnerLeaseCurrent();
|
|
2348
|
+
try {
|
|
2349
|
+
persistAuthoritativeMigrationState(env, installingRecord, stateOptions);
|
|
2350
|
+
installAnchor = anchor;
|
|
2351
|
+
canonicalMutationStateActive = true;
|
|
2352
|
+
}
|
|
2353
|
+
catch (error) {
|
|
2354
|
+
try {
|
|
2355
|
+
const expectedPublished = readTrustedMigrationStateText(join(resolveBootstrapStateDir(env), MIGRATION_STATE_FILE), stateOptions) === expectedPayload;
|
|
2356
|
+
if (expectedPublished) {
|
|
2357
|
+
canonicalMutationStateActive = true;
|
|
2358
|
+
installAnchor = anchor;
|
|
2359
|
+
}
|
|
2360
|
+
else if (previousAnchor) {
|
|
2361
|
+
canonicalMutationStateActive = true;
|
|
2362
|
+
installAnchor = previousAnchor;
|
|
2363
|
+
}
|
|
2364
|
+
else {
|
|
2365
|
+
canonicalMutationStateActive = false;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
catch {
|
|
2369
|
+
canonicalMutationStateActive = true;
|
|
2370
|
+
installAnchor = previousAnchor ?? anchor;
|
|
2371
|
+
}
|
|
2372
|
+
throw error;
|
|
2373
|
+
}
|
|
2374
|
+
},
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
catch (err) {
|
|
2378
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_INSTALL_FAILED;
|
|
2379
|
+
const detail = errorDetail(err);
|
|
2380
|
+
let ambiguous = detail.startsWith('migration_install_rollback_unconfirmed:');
|
|
2381
|
+
let rollbackTerminalized = false;
|
|
2382
|
+
if (canonicalMutationStateActive && installAnchor && !ambiguous) {
|
|
2383
|
+
try {
|
|
2384
|
+
assertOwnerLeaseCurrent();
|
|
2385
|
+
persistAuthoritativeMigrationState(env, { state: 'rolled_back', version, destPath, installAnchor, reason: detail }, stateOptions);
|
|
2386
|
+
rollbackTerminalized = true;
|
|
2387
|
+
}
|
|
2388
|
+
catch {
|
|
2389
|
+
ambiguous = true;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
return fail(`${code}:${detail}`, `one-time standalone migration failed (${code})`, `${code}: ${detail}`, ambiguous || (canonicalMutationStateActive && !rollbackTerminalized));
|
|
2393
|
+
}
|
|
2394
|
+
let registrationIdentity;
|
|
2395
|
+
try {
|
|
2396
|
+
const currentIdentity = await inspectExistingMigrationTarget(destPath, version, preflightFn, readBinary, { sha256: expectedSha256, size: stagedBytes.byteLength }, assertDirectoryTrust, assertFileTrust);
|
|
2397
|
+
if (!currentIdentity || !sameMigrationTargetIdentity(currentIdentity, installResult.targetIdentity)) {
|
|
2398
|
+
throw new Error('migration_target_identity_changed_before_registration');
|
|
2399
|
+
}
|
|
2400
|
+
registrationIdentity = currentIdentity;
|
|
2401
|
+
}
|
|
2402
|
+
catch (error) {
|
|
2403
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
2404
|
+
return fail(`${code}:target_identity_changed`, `one-time standalone migration failed (${code}:target_identity_changed)`, `${code}: target_identity_changed:${errorDetail(error)}`, true);
|
|
2405
|
+
}
|
|
2406
|
+
// REGISTER: delegate to the ordinary lifecycle bootstrap transaction runner.
|
|
2407
|
+
const sealedRegistrationIdentity = Object.freeze({ ...registrationIdentity });
|
|
2408
|
+
const revalidateTarget = () => assertMigrationTargetIdentityCurrent(sealedRegistrationIdentity, version, { readBinary, preflightFn, assertDirectoryTrust, assertFileTrust });
|
|
2409
|
+
const intentId = randomUUID();
|
|
2410
|
+
const intentBase = {
|
|
2411
|
+
version,
|
|
2412
|
+
destPath,
|
|
2413
|
+
intentId,
|
|
2414
|
+
targetIdentity: sealedRegistrationIdentity,
|
|
2415
|
+
installOwnership: {
|
|
2416
|
+
disposition: installResult.disposition,
|
|
2417
|
+
preimage: installResult.disposition === 'installed' ? 'absent' : 'matching_target',
|
|
2418
|
+
directoryCreated: installResult.directoryCreated,
|
|
2419
|
+
},
|
|
2420
|
+
};
|
|
2421
|
+
const registeringRecord = { state: 'registering', ...intentBase };
|
|
2422
|
+
const expectedIntentPayload = serializeMigrationState(registeringRecord, recordOptions);
|
|
2423
|
+
let registrationIntentPayload;
|
|
2424
|
+
try {
|
|
2425
|
+
assertOwnerLeaseCurrent();
|
|
2426
|
+
registrationIntentPayload = persistAuthoritativeMigrationState(env, registeringRecord, stateOptions);
|
|
2427
|
+
canonicalMutationStateActive = true;
|
|
2428
|
+
}
|
|
2429
|
+
catch (error) {
|
|
2430
|
+
let intentRollbackTerminalized = false;
|
|
2431
|
+
let intentAuthorityAmbiguous = false;
|
|
2432
|
+
let currentAuthoritativePayload;
|
|
2433
|
+
try {
|
|
2434
|
+
currentAuthoritativePayload = readTrustedMigrationStateText(join(resolveBootstrapStateDir(env), MIGRATION_STATE_FILE), stateOptions);
|
|
2435
|
+
}
|
|
2436
|
+
catch {
|
|
2437
|
+
canonicalMutationStateActive = true;
|
|
2438
|
+
intentAuthorityAmbiguous = true;
|
|
2439
|
+
}
|
|
2440
|
+
if (!intentAuthorityAmbiguous && currentAuthoritativePayload === expectedIntentPayload) {
|
|
2441
|
+
canonicalMutationStateActive = true;
|
|
2442
|
+
try {
|
|
2443
|
+
assertOwnerLeaseCurrent();
|
|
2444
|
+
const rollbackPendingPayload = persistAuthoritativeMigrationState(env, { state: 'rollback_pending', ...intentBase, reason: 'intent_persistence_failed' }, stateOptions);
|
|
2445
|
+
await completeMigrationRollbackPending({
|
|
2446
|
+
env,
|
|
2447
|
+
record: { ...intentBase, reason: 'intent_persistence_failed' },
|
|
2448
|
+
expectedPayload: rollbackPendingPayload,
|
|
2449
|
+
stateOptions,
|
|
2450
|
+
readBinary,
|
|
2451
|
+
preflightFn,
|
|
2452
|
+
rename,
|
|
2453
|
+
link,
|
|
2454
|
+
unlink,
|
|
2455
|
+
rmdir,
|
|
2456
|
+
syncDirectory,
|
|
2457
|
+
assertDirectoryTrust,
|
|
2458
|
+
assertFileTrust,
|
|
2459
|
+
assertOwnerCurrent: assertOwnerLeaseCurrent,
|
|
2460
|
+
});
|
|
2461
|
+
intentRollbackTerminalized = true;
|
|
2462
|
+
}
|
|
2463
|
+
catch (cleanupError) {
|
|
2464
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_INSTALL_FAILED;
|
|
2465
|
+
return fail(`${code}:cleanup_failed`, `one-time standalone migration failed (${code}:cleanup_failed)`, `${code}: cleanup_failed:${errorDetail(cleanupError)}`, true, true);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
else if (!intentAuthorityAmbiguous && currentAuthoritativePayload !== undefined) {
|
|
2469
|
+
const currentRecord = parseCanonicalMigrationState(currentAuthoritativePayload);
|
|
2470
|
+
if (currentRecord?.state !== 'installing'
|
|
2471
|
+
|| currentRecord.version !== version
|
|
2472
|
+
|| currentRecord.destPath !== destPath
|
|
2473
|
+
|| !installAnchor
|
|
2474
|
+
|| !sameMigrationInstallAnchor(currentRecord.installAnchor, installAnchor)) {
|
|
2475
|
+
canonicalMutationStateActive = true;
|
|
2476
|
+
intentAuthorityAmbiguous = true;
|
|
2477
|
+
}
|
|
2478
|
+
else {
|
|
2479
|
+
canonicalMutationStateActive = true;
|
|
2480
|
+
try {
|
|
2481
|
+
const recovered = await recoverPendingMigrationInstall({
|
|
2482
|
+
env,
|
|
2483
|
+
stateOptions,
|
|
2484
|
+
readBinary,
|
|
2485
|
+
rename,
|
|
2486
|
+
link,
|
|
2487
|
+
unlink,
|
|
2488
|
+
rmdir,
|
|
2489
|
+
syncDirectory,
|
|
2490
|
+
assertDirectoryTrust,
|
|
2491
|
+
assertFileTrust,
|
|
2492
|
+
assertOwnerCurrent: assertOwnerLeaseCurrent,
|
|
2493
|
+
markCanonicalStateActive: () => {
|
|
2494
|
+
canonicalMutationStateActive = true;
|
|
2495
|
+
},
|
|
2496
|
+
});
|
|
2497
|
+
if (!recovered)
|
|
2498
|
+
throw new Error('migration_install_journal_changed');
|
|
2499
|
+
intentRollbackTerminalized = true;
|
|
2500
|
+
}
|
|
2501
|
+
catch (cleanupError) {
|
|
2502
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_INSTALL_FAILED;
|
|
2503
|
+
return fail(`${code}:cleanup_failed`, `one-time standalone migration failed (${code}:cleanup_failed)`, `${code}: cleanup_failed:${errorDetail(cleanupError)}`, true, true);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
else if (!intentAuthorityAmbiguous) {
|
|
2508
|
+
// The installed artifact cannot be deleted without a durable transaction record.
|
|
2509
|
+
canonicalMutationStateActive = true;
|
|
2510
|
+
intentAuthorityAmbiguous = true;
|
|
2511
|
+
}
|
|
2512
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
2513
|
+
return fail(`${code}:intent_persistence_failed`, `one-time standalone migration failed (${code}:intent_persistence_failed)`, `${code}: intent_persistence_failed:${errorDetail(error)}`, intentAuthorityAmbiguous || !intentRollbackTerminalized, true);
|
|
2514
|
+
}
|
|
2515
|
+
const assertRegistrationIntentCurrent = async () => {
|
|
2516
|
+
assertOwnerLeaseCurrent();
|
|
2517
|
+
assertAuthoritativeMigrationStateCurrent(env, registrationIntentPayload, stateOptions);
|
|
2518
|
+
};
|
|
2519
|
+
const registration = await register(env, platform, version, destPath, sealedRegistrationIdentity, revalidateTarget, assertRegistrationIntentCurrent, options, timeoutMs, transactionBudgetMs, fail, failTimeout);
|
|
2520
|
+
if (registration.outcome === 'failed' && registration.requiresForegroundExit === true) {
|
|
2521
|
+
try {
|
|
2522
|
+
assertOwnerLeaseCurrent();
|
|
2523
|
+
persistAuthoritativeMigrationState(env, { state: 'blocked', ...intentBase, reason: registration.reason }, stateOptions);
|
|
2524
|
+
}
|
|
2525
|
+
catch {
|
|
2526
|
+
// Preserve the already durable registering intent if blocked-state publication fails.
|
|
2527
|
+
}
|
|
2528
|
+
return registration;
|
|
2529
|
+
}
|
|
2530
|
+
if (registration.outcome === 'failed') {
|
|
2531
|
+
try {
|
|
2532
|
+
await assertRegistrationIntentCurrent();
|
|
2533
|
+
const rollbackPendingPayload = persistAuthoritativeMigrationState(env, { state: 'rollback_pending', ...intentBase, reason: registration.reason }, stateOptions);
|
|
2534
|
+
canonicalMutationStateActive = true;
|
|
2535
|
+
await completeMigrationRollbackPending({
|
|
2536
|
+
env,
|
|
2537
|
+
record: { ...intentBase, reason: registration.reason },
|
|
2538
|
+
expectedPayload: rollbackPendingPayload,
|
|
2539
|
+
stateOptions,
|
|
2540
|
+
readBinary,
|
|
2541
|
+
preflightFn,
|
|
2542
|
+
rename,
|
|
2543
|
+
link,
|
|
2544
|
+
unlink,
|
|
2545
|
+
rmdir,
|
|
2546
|
+
syncDirectory,
|
|
2547
|
+
assertDirectoryTrust,
|
|
2548
|
+
assertFileTrust,
|
|
2549
|
+
assertOwnerCurrent: assertOwnerLeaseCurrent,
|
|
2550
|
+
});
|
|
2551
|
+
canonicalMutationStateActive = false;
|
|
2552
|
+
}
|
|
2553
|
+
catch (error) {
|
|
2554
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_INSTALL_FAILED;
|
|
2555
|
+
return fail(`${code}:cleanup_failed`, `one-time standalone migration failed (${code}:cleanup_failed)`, `${code}: cleanup_failed:${errorDetail(error)}`, true);
|
|
2556
|
+
}
|
|
2557
|
+
return registration;
|
|
2558
|
+
}
|
|
2559
|
+
try {
|
|
2560
|
+
assertOwnerLeaseCurrent();
|
|
2561
|
+
persistAuthoritativeMigrationState(env, { state: 'committed', ...intentBase }, stateOptions);
|
|
2562
|
+
canonicalMutationStateActive = false;
|
|
2563
|
+
}
|
|
2564
|
+
catch (error) {
|
|
2565
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
2566
|
+
return fail(`${code}:commit_persistence_failed`, `one-time standalone migration failed (${code}:commit_persistence_failed)`, `${code}: commit_persistence_failed:${errorDetail(error)}`, true);
|
|
2567
|
+
}
|
|
2568
|
+
recordBootstrapAttempt(env, { ok: true, reason: 'migrated', detail: destPath }, recordOptions);
|
|
2569
|
+
return registration;
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
catch (error) {
|
|
2573
|
+
if (error instanceof Error && error.message === 'migration_lock_busy') {
|
|
2574
|
+
return {
|
|
2575
|
+
outcome: 'failed',
|
|
2576
|
+
reason: `${SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED}:migration_in_progress`,
|
|
2577
|
+
destPath,
|
|
2578
|
+
message: 'one-time standalone migration is already owned by another live process',
|
|
2579
|
+
requiresForegroundExit: true,
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
2583
|
+
return fail(`${code}:owner_lease_failed`, `one-time standalone migration failed (${code}:owner_lease_failed)`, `${code}: owner_lease_failed:${errorDetail(error)}`, true, true);
|
|
288
2584
|
}
|
|
289
|
-
// REGISTER: hand the installed binary to `evolver lifecycle bootstrap`.
|
|
290
|
-
return register(env, version, destPath, options, exists, recordOptions, fail, failTimeout, writeMigrationState);
|
|
291
2585
|
}
|
|
292
|
-
async function register(env, version, destPath, options,
|
|
2586
|
+
async function register(env, platform, version, destPath, targetIdentity, revalidateTarget, assertRegistrationIntentCurrent, options, timeoutMs, transactionBudgetMs, fail, failTimeout) {
|
|
293
2587
|
const code = SELF_UPDATE_FAILURE_CODES.MIGRATION_REGISTER_FAILED;
|
|
294
|
-
const
|
|
295
|
-
if (
|
|
296
|
-
return fail(`${code}:
|
|
2588
|
+
const runner = options.registrationRunner;
|
|
2589
|
+
if (!runner) {
|
|
2590
|
+
return fail(`${code}:registration_runner_unavailable`, `one-time standalone migration failed (${code}:registration_runner_unavailable)`, `${code}: registration_runner_unavailable`);
|
|
2591
|
+
}
|
|
2592
|
+
let startedAtMs;
|
|
2593
|
+
try {
|
|
2594
|
+
startedAtMs = (options.clock ?? Date.now)();
|
|
2595
|
+
}
|
|
2596
|
+
catch (error) {
|
|
2597
|
+
return fail(`${code}:registration_clock_failed`, `one-time standalone migration failed (${code}:registration_clock_failed)`, `${code}: registration_clock_failed:${errorDetail(error)}`);
|
|
297
2598
|
}
|
|
298
|
-
|
|
299
|
-
|
|
2599
|
+
const transactionDeadlineMs = startedAtMs + transactionBudgetMs;
|
|
2600
|
+
const parentDeadlineMs = startedAtMs + timeoutMs;
|
|
2601
|
+
if (!Number.isSafeInteger(startedAtMs)
|
|
2602
|
+
|| startedAtMs < 0
|
|
2603
|
+
|| !Number.isSafeInteger(transactionDeadlineMs)
|
|
2604
|
+
|| !Number.isSafeInteger(parentDeadlineMs)) {
|
|
2605
|
+
return fail(`${code}:invalid_registration_deadline`, `one-time standalone migration failed (${code}:invalid_registration_deadline)`, `${code}: invalid_registration_deadline`);
|
|
300
2606
|
}
|
|
2607
|
+
const request = Object.freeze({
|
|
2608
|
+
env: Object.freeze({ ...env, EVOLVER_SELF_UPDATE_TARGET_PATH: destPath }),
|
|
2609
|
+
platform,
|
|
2610
|
+
version,
|
|
2611
|
+
startedAtMs,
|
|
2612
|
+
transactionDeadlineMs,
|
|
2613
|
+
parentDeadlineMs,
|
|
2614
|
+
transactionBudgetMs,
|
|
2615
|
+
timeoutMs,
|
|
2616
|
+
targetIdentity,
|
|
2617
|
+
revalidateTarget,
|
|
2618
|
+
assertRegistrationIntentCurrent,
|
|
2619
|
+
// Registration must execute the verified standalone binary, never the npm/JS Node entry.
|
|
2620
|
+
execPath: destPath,
|
|
2621
|
+
...(options.exists !== undefined ? { exists: options.exists } : {}),
|
|
2622
|
+
...(options.readFile !== undefined ? { readFile: options.readFile } : {}),
|
|
2623
|
+
...(options.writeFile !== undefined ? { writeFile: options.writeFile } : {}),
|
|
2624
|
+
...(options.spawnFn !== undefined ? { spawnFn: options.spawnFn } : {}),
|
|
2625
|
+
});
|
|
2626
|
+
let registration;
|
|
2627
|
+
try {
|
|
2628
|
+
registration = await runner(request);
|
|
2629
|
+
}
|
|
2630
|
+
catch (error) {
|
|
2631
|
+
return fail(`${code}:runner_threw`, `one-time standalone migration failed (${code}:runner_threw)`, errorDetail(`${code}: runner_threw:${errorDetail(error)}`), true);
|
|
2632
|
+
}
|
|
2633
|
+
if (!registration
|
|
2634
|
+
|| typeof registration.ok !== 'boolean'
|
|
2635
|
+
|| typeof registration.reason !== 'string'
|
|
2636
|
+
|| registrationReason(registration.reason) === undefined
|
|
2637
|
+
|| (registration.detail !== undefined && typeof registration.detail !== 'string')
|
|
2638
|
+
|| (registration.requiresForegroundExit !== undefined && registration.requiresForegroundExit !== true)) {
|
|
2639
|
+
return fail(`${code}:invalid_reconciliation_result`, `one-time standalone migration failed (${code}:invalid_reconciliation_result)`, `${code}: invalid_reconciliation_result`, true);
|
|
2640
|
+
}
|
|
2641
|
+
const safeReason = registrationReason(registration.reason);
|
|
2642
|
+
if (!safeReason) {
|
|
2643
|
+
return fail(`${code}:invalid_reconciliation_result`, `one-time standalone migration failed (${code}:invalid_reconciliation_result)`, `${code}: invalid_reconciliation_result`, true);
|
|
2644
|
+
}
|
|
2645
|
+
const safeDetail = registration.detail === undefined ? undefined : errorDetail(registration.detail);
|
|
301
2646
|
if (!registration.ok) {
|
|
302
|
-
const
|
|
303
|
-
|
|
2647
|
+
const ownershipAmbiguous = registration.requiresForegroundExit === true
|
|
2648
|
+
|| ['ambiguous', 'blocked', 'signal', 'termination_unconfirmed', 'timeout'].includes(safeReason);
|
|
2649
|
+
const detail = safeDetail ? `:${safeDetail}` : '';
|
|
2650
|
+
if (safeReason === 'timeout') {
|
|
2651
|
+
return failTimeout('migration_register_timeout', `one-time standalone migration failed (${code}:timeout)`, errorDetail(`${code}: timeout${detail}`), true);
|
|
2652
|
+
}
|
|
2653
|
+
return fail(`${code}:${safeReason}`, `one-time standalone migration failed (${code}:${safeReason})`, errorDetail(`${code}: ${safeReason}${detail}`), ownershipAmbiguous);
|
|
2654
|
+
}
|
|
2655
|
+
const lockReleaseUnconfirmed = safeReason === 'bootstrapped_lock_release_unconfirmed';
|
|
2656
|
+
if ((safeReason !== 'bootstrapped' && !lockReleaseUnconfirmed)
|
|
2657
|
+
|| (lockReleaseUnconfirmed && !safeDetail)
|
|
2658
|
+
|| registration.requiresForegroundExit === true) {
|
|
2659
|
+
return fail(`${code}:invalid_success_reconciliation`, `one-time standalone migration failed (${code}:invalid_success_reconciliation)`, `${code}: invalid_success_reconciliation:${safeReason}`, true);
|
|
304
2660
|
}
|
|
305
|
-
recordBootstrapAttempt(env, { ok: true, reason: 'migrated', detail: destPath }, recordOptions);
|
|
306
|
-
writeState(env, { state: 'migrated', version, destPath }, options);
|
|
307
2661
|
return {
|
|
308
2662
|
outcome: 'migrated',
|
|
309
2663
|
reason: 'migrated',
|
|
310
2664
|
destPath,
|
|
311
2665
|
message: `[evolver-proxy] self-update: installed standalone binary ${version} at ${destPath} and registered `
|
|
312
2666
|
+ 'durable service supervision via `evolver lifecycle bootstrap`; handing over to the service manager '
|
|
313
|
-
+ 'and exiting so it can take the IPC port.'
|
|
2667
|
+
+ 'and exiting so it can take the IPC port.'
|
|
2668
|
+
+ (lockReleaseUnconfirmed
|
|
2669
|
+
? ` The service commit is durable, but lifecycle lock release remains unconfirmed (${safeDetail}).`
|
|
2670
|
+
: ''),
|
|
314
2671
|
};
|
|
315
2672
|
}
|