stackbite 0.5.97 → 0.5.99
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/package.json +1 -1
- package/packaging/npm/lib/bootstrap.js +172 -74
- package/packaging/npm/lib/uninstall-registration.js +246 -174
- package/packaging/npm/release.json +23 -23
package/package.json
CHANGED
|
@@ -171,17 +171,26 @@ async function publicExecutable(root, spec, hashes) {
|
|
|
171
171
|
const manifest = JSON.parse((await readSmall(path.join(root, 'manifest.json'))).toString('utf8'));
|
|
172
172
|
const entry = manifest.binaries?.['stackbite-launcher'], info = entry?.build_info;
|
|
173
173
|
const target = { win32: 'x86_64-pc-windows-msvc', linux: 'x86_64-unknown-linux-gnu', darwin: 'aarch64-apple-darwin' }[spec.platform];
|
|
174
|
-
if (manifest
|
|
175
|
-
||
|
|
176
|
-
|| !entry || Object.keys(entry).sort().join(',') !== 'build_info,path,sha256'
|
|
177
|
-
|| entry.path !== name || entry.sha256 !== hashes[name] || manifest.artifacts?.[name]?.sha256 !== hashes[name]
|
|
178
|
-
|| manifest.artifacts?.[name]?.kind !== 'binary' || !Number.isSafeInteger(manifest.artifacts?.[name]?.size) || manifest.artifacts[name].size < 1
|
|
179
|
-
|| !info || Object.keys(info).sort().join(',') !== 'binary,commit,product,protocol,target,version'
|
|
180
|
-
|| info.product !== 'Stackbite' || info.binary !== 'stackbite-launcher' || info.version !== spec.version
|
|
181
|
-
|| info.commit !== spec.commit || info.target !== target || info.protocol !== 1) fail('Native entry package identity or pin is invalid');
|
|
174
|
+
if (!terminalBuildMatches(manifest, spec, target) || !launcherPinMatches(manifest, entry, name, hashes)
|
|
175
|
+
|| !launcherBuildInfoMatches(info, spec, target)) fail('Native entry package identity or pin is invalid');
|
|
182
176
|
const launcher = await nativeExecutable(root, spec, 'stackbite-launcher');
|
|
183
177
|
return spec.platform === 'win32' ? engine : launcher;
|
|
184
178
|
}
|
|
179
|
+
function terminalBuildMatches(manifest, spec, target) {
|
|
180
|
+
return manifest.schema === 'stackbite-rust-terminal-build-v1' && manifest.product === 'Stackbite'
|
|
181
|
+
&& manifest.version === spec.version && manifest.commit === spec.commit && manifest.target === target && manifest.protocol === 1;
|
|
182
|
+
}
|
|
183
|
+
// The manifest entry and its artifact record both pin the release hash of `name`.
|
|
184
|
+
function launcherPinMatches(manifest, entry, name, hashes) {
|
|
185
|
+
return !!entry && Object.keys(entry).sort().join(',') === 'build_info,path,sha256'
|
|
186
|
+
&& entry.path === name && entry.sha256 === hashes[name] && manifest.artifacts?.[name]?.sha256 === hashes[name]
|
|
187
|
+
&& manifest.artifacts?.[name]?.kind === 'binary' && Number.isSafeInteger(manifest.artifacts?.[name]?.size) && !(manifest.artifacts[name].size < 1);
|
|
188
|
+
}
|
|
189
|
+
function launcherBuildInfoMatches(info, spec, target) {
|
|
190
|
+
return !!info && Object.keys(info).sort().join(',') === 'binary,commit,product,protocol,target,version'
|
|
191
|
+
&& info.product === 'Stackbite' && info.binary === 'stackbite-launcher' && info.version === spec.version
|
|
192
|
+
&& info.commit === spec.commit && info.target === target && info.protocol === 1;
|
|
193
|
+
}
|
|
185
194
|
async function validatePayload(directory, spec, { includePhone = true } = {}) {
|
|
186
195
|
const root = await payloadRoot(directory);
|
|
187
196
|
const { hashes, metadata } = await pinnedDocuments(root, spec);
|
|
@@ -212,20 +221,45 @@ async function admitPublication(environment, platform) {
|
|
|
212
221
|
if (record.schema !== 'stackbite.uninstall.v1' || !['prepared', 'stopped', 'deferred', 'complete'].includes(record.phase)) {
|
|
213
222
|
fail('Stackbite removal is in progress');
|
|
214
223
|
}
|
|
215
|
-
|
|
224
|
+
const current = await locatorIncarnation(root);
|
|
225
|
+
const saved = record.install_identity;
|
|
226
|
+
if (saved != null && (saved.root !== root || !/^[a-f0-9]{32}$/.test(saved.incarnation))) fail('Invalid removal installation identity');
|
|
227
|
+
if (current && (!saved || current !== saved.incarnation)) return;
|
|
228
|
+
fail('Stackbite removal is in progress');
|
|
229
|
+
}
|
|
230
|
+
async function locatorIncarnation(root) {
|
|
216
231
|
for (const name of ['default-install.json', 'install.json']) {
|
|
217
232
|
const locator = path.join(root, name);
|
|
218
233
|
if (!await exists(locator)) continue;
|
|
219
234
|
const value = JSON.parse((await readSmall(locator, 16384)).toString('utf8').replace(/^\uFEFF/, ''));
|
|
220
235
|
if (!value || Array.isArray(value) || typeof value !== 'object'
|
|
221
236
|
|| (value.installation_id != null && !/^[a-f0-9]{32}$/.test(value.installation_id))) fail('Invalid Stackbite installation locator');
|
|
222
|
-
|
|
223
|
-
break;
|
|
237
|
+
return value.installation_id ?? null;
|
|
224
238
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
// An accepted removal of the CURRENT installation that stopped before its
|
|
242
|
+
// capsule existed (no recovery_command). Only a release-pinned runtime from this
|
|
243
|
+
// package can continue it. The digest binds the same request bytes until launch.
|
|
244
|
+
async function acceptedRemovalWithoutCapsule(environment, platform) {
|
|
245
|
+
const root = installationRoot(environment, platform);
|
|
246
|
+
if (!await existingCacheDirectory(root)) return null;
|
|
247
|
+
const journal = path.join(root, '.stackbite-uninstall.json');
|
|
248
|
+
let info;
|
|
249
|
+
try { info = await fs.lstat(journal); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
250
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) fail('Unsafe removal journal');
|
|
251
|
+
const bytes = await readSmall(journal, 8 * 1024 * 1024);
|
|
252
|
+
const record = JSON.parse(bytes.toString('utf8').replace(/^\uFEFF/, ''));
|
|
253
|
+
const saved = record?.install_identity;
|
|
254
|
+
if (record?.schema !== 'stackbite.uninstall.v1' || record.phase !== 'prepared' || record.accepted !== true
|
|
255
|
+
|| record.removal_requested !== true || record.recovery_command != null
|
|
256
|
+
|| typeof record.request_id !== 'string' || !record.request_id
|
|
257
|
+
|| saved?.root !== root || !/^[a-f0-9]{32}$/.test(saved.incarnation)) return null;
|
|
258
|
+
// A reinstall after this request (another incarnation) is not this removal's target.
|
|
259
|
+
const current = await locatorIncarnation(root);
|
|
260
|
+
if (current !== null && current !== saved.incarnation) return null;
|
|
261
|
+
return { journal, request: record.request_id, incarnation: saved.incarnation,
|
|
262
|
+
digest: createHash('sha256').update(bytes).digest('hex') };
|
|
229
263
|
}
|
|
230
264
|
|
|
231
265
|
async function baselinePin(file) {
|
|
@@ -276,34 +310,40 @@ async function assertBaseline(environment, platform) {
|
|
|
276
310
|
if (Buffer.byteLength(raw) > 16384) fail('Invalid installation baseline');
|
|
277
311
|
const expected = JSON.parse(raw), root = installationRoot(environment, platform);
|
|
278
312
|
if (expected.preparation !== undefined) {
|
|
279
|
-
|
|
280
|
-
if (Object.keys(expected).sort().join(',') !== 'preparation,root' || expected.root !== root
|
|
281
|
-
|| !pin || Object.keys(pin).sort().join(',') !== 'identity,path' || pin.path !== file || !pin.identity) fail('Invalid installation preparation');
|
|
282
|
-
let identity = pin.identity;
|
|
283
|
-
const actual = await baselinePin(file);
|
|
284
|
-
if (platform === 'win32' && actual) identity = { ...identity, device: (BigInt(identity.device) & 0xffffffffn).toString() };
|
|
285
|
-
if (JSON.stringify(actual) !== JSON.stringify(identity)) fail('Installation changed while staging');
|
|
313
|
+
await assertPreparationBaseline(expected, root, platform);
|
|
286
314
|
return;
|
|
287
315
|
}
|
|
288
|
-
|
|
316
|
+
const current = await locatorInstallationId(root);
|
|
317
|
+
if (expected.root !== root || expected.incarnation !== current) fail('Installation changed while staging');
|
|
318
|
+
if (current === null) await assertLegacyBaseline(expected, platform);
|
|
319
|
+
}
|
|
320
|
+
// Python-origin restrictions retain FILE_ID_INFO's full volume serial;
|
|
321
|
+
// libuv reports its 32-bit component. The engine still checks the full pin.
|
|
322
|
+
function libuvIdentity(platform, actual, identity) {
|
|
323
|
+
return platform === 'win32' && actual && identity ? { ...identity, device: (BigInt(identity.device) & 0xffffffffn).toString() } : identity;
|
|
324
|
+
}
|
|
325
|
+
async function assertPreparationBaseline(expected, root, platform) {
|
|
326
|
+
const pin = expected.preparation, file = path.join(root, '.stackbite-update', 'update.lock');
|
|
327
|
+
if (Object.keys(expected).sort().join(',') !== 'preparation,root' || expected.root !== root
|
|
328
|
+
|| !pin || Object.keys(pin).sort().join(',') !== 'identity,path' || pin.path !== file || !pin.identity) fail('Invalid installation preparation');
|
|
329
|
+
const actual = await baselinePin(file);
|
|
330
|
+
if (JSON.stringify(actual) !== JSON.stringify(libuvIdentity(platform, actual, pin.identity))) fail('Installation changed while staging');
|
|
331
|
+
}
|
|
332
|
+
// The first present locator's incarnation; null for a legacy or absent locator.
|
|
333
|
+
async function locatorInstallationId(root) {
|
|
289
334
|
for (const name of ['default-install.json', 'install.json']) {
|
|
290
335
|
const file = path.join(root, name);
|
|
291
336
|
if (!await exists(file)) continue;
|
|
292
337
|
const value = JSON.parse((await readSmall(file, 16384)).toString('utf8').replace(/^\uFEFF/, ''));
|
|
293
|
-
|
|
294
|
-
break;
|
|
338
|
+
return value.installation_id ?? null;
|
|
295
339
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// libuv reports its 32-bit component. The engine still checks the full pin.
|
|
304
|
-
if (platform === 'win32' && actual && identity) identity = { ...identity, device: (BigInt(identity.device) & 0xffffffffn).toString() };
|
|
305
|
-
if (JSON.stringify(actual) !== JSON.stringify(identity)) fail('Installation changed while staging');
|
|
306
|
-
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
async function assertLegacyBaseline(expected, platform) {
|
|
343
|
+
if (!Array.isArray(expected.legacy) || !expected.legacy.length || expected.legacy.length > 32) fail('Invalid legacy installation baseline');
|
|
344
|
+
for (const pin of expected.legacy) {
|
|
345
|
+
const actual = await baselinePin(pin.path);
|
|
346
|
+
if (JSON.stringify(actual) !== JSON.stringify(libuvIdentity(platform, actual, pin.identity))) fail('Installation changed while staging');
|
|
307
347
|
}
|
|
308
348
|
}
|
|
309
349
|
|
|
@@ -365,6 +405,30 @@ async function settlePreparation(preparation, environment, platform, source) {
|
|
|
365
405
|
} finally { await preparation.handle.close(); }
|
|
366
406
|
}
|
|
367
407
|
|
|
408
|
+
// Identities of the candidates that this mkdir created (the first created directory and below).
|
|
409
|
+
async function createdDirectories(firstCreated, candidates) {
|
|
410
|
+
const directories = [];
|
|
411
|
+
if (firstCreated) {
|
|
412
|
+
for (const selected of candidates) {
|
|
413
|
+
// libuv may return a Windows namespace spelling for the first directory.
|
|
414
|
+
const createdName = path.toNamespacedPath(firstCreated), selectedName = path.toNamespacedPath(selected);
|
|
415
|
+
if (selectedName === createdName || selectedName.startsWith(createdName + path.sep)) {
|
|
416
|
+
directories.push([selected, await fs.lstat(selected, { bigint: true })]);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return directories;
|
|
421
|
+
}
|
|
422
|
+
// Creates the control file, or opens the existing one; `created` marks ours.
|
|
423
|
+
async function openPreparationFile(file) {
|
|
424
|
+
const constants = require('node:fs').constants;
|
|
425
|
+
try {
|
|
426
|
+
return { handle: await fs.open(file, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | (constants.O_NOFOLLOW || 0), 0o600), created: true };
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (error.code !== 'EEXIST') throw error;
|
|
429
|
+
return { handle: await fs.open(file, constants.O_RDWR | (constants.O_NOFOLLOW || 0)), created: false };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
368
432
|
async function reservePreparation(environment, platform, target, source) {
|
|
369
433
|
if (Object.hasOwn(environment, 'STACKBITE_INSTALL_BASELINE')) return null;
|
|
370
434
|
let baseline = await captureBaseline(environment, platform, target);
|
|
@@ -385,26 +449,10 @@ async function reservePreparation(environment, platform, target, source) {
|
|
|
385
449
|
if (source) await source.validate();
|
|
386
450
|
const firstCreated = await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
387
451
|
await existingCacheDirectory(directory);
|
|
388
|
-
const directories = [];
|
|
389
|
-
if (firstCreated) {
|
|
390
|
-
for (const selected of [directory, root]) {
|
|
391
|
-
// libuv may return a Windows namespace spelling for the first directory.
|
|
392
|
-
const createdName = path.toNamespacedPath(firstCreated), selectedName = path.toNamespacedPath(selected);
|
|
393
|
-
if (selectedName === createdName || selectedName.startsWith(createdName + path.sep)) {
|
|
394
|
-
directories.push([selected, await fs.lstat(selected, { bigint: true })]);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
const constants = require('node:fs').constants;
|
|
452
|
+
const directories = await createdDirectories(firstCreated, [directory, root]);
|
|
399
453
|
const file = path.join(directory, 'update.lock');
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
handle = await fs.open(file, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | (constants.O_NOFOLLOW || 0), 0o600);
|
|
403
|
-
created = true;
|
|
404
|
-
} catch (error) {
|
|
405
|
-
if (error.code !== 'EEXIST') throw error;
|
|
406
|
-
handle = await fs.open(file, constants.O_RDWR | (constants.O_NOFOLLOW || 0));
|
|
407
|
-
}
|
|
454
|
+
const { handle, created } = await openPreparationFile(file);
|
|
455
|
+
let preparation;
|
|
408
456
|
try {
|
|
409
457
|
const held = await handle.stat({ bigint: true });
|
|
410
458
|
const identity = await baselinePin(file);
|
|
@@ -455,7 +503,7 @@ async function ensureBinary(spec, { cache = cacheDirectory(), downloadImpl = dow
|
|
|
455
503
|
await existingCacheDirectory(cache); // Validate existing ancestors without creating a cache.
|
|
456
504
|
}
|
|
457
505
|
if (checkOnly) {
|
|
458
|
-
if (!await existingCacheDirectory(cache))
|
|
506
|
+
if (!await existingCacheDirectory(cache)) throw notCached();
|
|
459
507
|
}
|
|
460
508
|
// Full identity remains SHA-256 addressed while avoiding Windows MAX_PATH.
|
|
461
509
|
const cacheKey = createHash('sha256').update(JSON.stringify([spec.version, spec.commit, spec.key, spec.sha256])).digest('hex');
|
|
@@ -471,7 +519,7 @@ async function ensureBinary(spec, { cache = cacheDirectory(), downloadImpl = dow
|
|
|
471
519
|
if (checkOnly || reportCached) progress('ready');
|
|
472
520
|
return installed;
|
|
473
521
|
}
|
|
474
|
-
if (checkOnly)
|
|
522
|
+
if (checkOnly) throw notCached();
|
|
475
523
|
// An owned system-temp stage keeps downloads out of product caches. Only the
|
|
476
524
|
// verified engine below can create/publish a canonical cache installation.
|
|
477
525
|
await admitPublication(environment, spec.platform);
|
|
@@ -479,18 +527,7 @@ async function ensureBinary(spec, { cache = cacheDirectory(), downloadImpl = dow
|
|
|
479
527
|
if (registrationSource) await registrationSource.validate();
|
|
480
528
|
const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'stackbite-npm-'));
|
|
481
529
|
try {
|
|
482
|
-
const
|
|
483
|
-
await fs.mkdir(extraction, { mode: 0o700 });
|
|
484
|
-
progress('download');
|
|
485
|
-
await downloadImpl(spec, archive);
|
|
486
|
-
// Recheck at the extraction boundary, including for injected test transports.
|
|
487
|
-
progress('archive');
|
|
488
|
-
if ((await fs.stat(archive)).size !== spec.size || await hashFile(archive, LIMITS.download) !== spec.sha256) fail('Pinned archive SHA-256 or size mismatch');
|
|
489
|
-
progress('extract');
|
|
490
|
-
await extractImpl(archive, extraction, spec);
|
|
491
|
-
const payload = path.join(extraction, spec.root);
|
|
492
|
-
progress('validate');
|
|
493
|
-
await validatePayload(payload, spec);
|
|
530
|
+
const { payload } = await stageRelease(spec, staging, { downloadImpl, extractImpl, progress });
|
|
494
531
|
progress('publish');
|
|
495
532
|
await admitPublication(environment, spec.platform);
|
|
496
533
|
await assertBaseline(environment, spec.platform);
|
|
@@ -501,15 +538,71 @@ async function ensureBinary(spec, { cache = cacheDirectory(), downloadImpl = dow
|
|
|
501
538
|
const installed = { home: target, executable: await validatePayload(target, spec) };
|
|
502
539
|
progress('ready');
|
|
503
540
|
return installed;
|
|
504
|
-
} finally {
|
|
505
|
-
// Only this mkdtemp handle is owned by this invocation.
|
|
506
|
-
if (path.dirname(staging) !== path.resolve(os.tmpdir()) || !path.basename(staging).startsWith('stackbite-npm-')) fail('Unsafe staging cleanup path');
|
|
507
|
-
await fs.rm(staging, { recursive: true, force: true });
|
|
508
|
-
}
|
|
541
|
+
} finally { await removeStage(staging); }
|
|
509
542
|
} finally {
|
|
510
543
|
if (preparation) await settlePreparation(preparation, environment, spec.platform, registrationSource);
|
|
511
544
|
}
|
|
512
545
|
}
|
|
546
|
+
function notCached() {
|
|
547
|
+
return Object.assign(new Error('Pinned runtime is not cached; run stackbite setup.'), { code: 'STACKBITE_NOT_CACHED' });
|
|
548
|
+
}
|
|
549
|
+
// Download, size/hash, extract and validate the pinned archive inside an owned stage.
|
|
550
|
+
async function stageRelease(spec, staging, { downloadImpl, extractImpl, progress }) {
|
|
551
|
+
const archive = path.join(staging, spec.asset), extraction = path.join(staging, 'extract');
|
|
552
|
+
await fs.mkdir(extraction, { mode: 0o700 });
|
|
553
|
+
progress('download');
|
|
554
|
+
await downloadImpl(spec, archive);
|
|
555
|
+
// Recheck at the extraction boundary, including for injected test transports.
|
|
556
|
+
progress('archive');
|
|
557
|
+
if ((await fs.stat(archive)).size !== spec.size || await hashFile(archive, LIMITS.download) !== spec.sha256) fail('Pinned archive SHA-256 or size mismatch');
|
|
558
|
+
progress('extract');
|
|
559
|
+
await extractImpl(archive, extraction, spec);
|
|
560
|
+
const payload = path.join(extraction, spec.root);
|
|
561
|
+
progress('validate');
|
|
562
|
+
return { payload, executable: await validatePayload(payload, spec) };
|
|
563
|
+
}
|
|
564
|
+
async function removeStage(staging) {
|
|
565
|
+
// Only this invocation's mkdtemp directory is owned.
|
|
566
|
+
if (path.dirname(staging) !== path.resolve(os.tmpdir()) || !path.basename(staging).startsWith('stackbite-npm-')) fail('Unsafe staging cleanup path');
|
|
567
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
568
|
+
}
|
|
569
|
+
// The fixed engine may hand removal to a helper started from its own image
|
|
570
|
+
// (parent_resume.runtime). While the same journal still names that image, the
|
|
571
|
+
// stage is the helper's runtime and stays; finalization removes the journal.
|
|
572
|
+
async function stageStillNeeded(pending, staging) {
|
|
573
|
+
let record;
|
|
574
|
+
try { record = JSON.parse((await readSmall(pending.journal, 8 * 1024 * 1024)).toString('utf8').replace(/^\uFEFF/, '')); }
|
|
575
|
+
catch (error) { if (error.code === 'ENOENT') return false; return true; }
|
|
576
|
+
const runtime = record?.parent_resume?.runtime?.path;
|
|
577
|
+
if (record?.request_id !== pending.request || typeof runtime !== 'string' || !path.isAbsolute(runtime)) return false;
|
|
578
|
+
const roots = [staging, await fs.realpath(staging)];
|
|
579
|
+
return roots.some(root => inside(root, path.resolve(runtime)));
|
|
580
|
+
}
|
|
581
|
+
// Continue an accepted removal with a private, verified copy of this package's
|
|
582
|
+
// pinned runtime. Nothing is published: no cache, locator, preparation or
|
|
583
|
+
// installation identity is created, and only the original argv runs.
|
|
584
|
+
async function continueAcceptedRemoval(spec, argv, environment, options) {
|
|
585
|
+
const pending = await acceptedRemovalWithoutCapsule(environment, spec.platform);
|
|
586
|
+
if (!pending) return null;
|
|
587
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'stackbite-npm-'));
|
|
588
|
+
let retained = false;
|
|
589
|
+
try {
|
|
590
|
+
const { payload, executable } = await stageRelease(spec, staging, {
|
|
591
|
+
downloadImpl: options.downloadImpl || download, extractImpl: options.extractImpl || extractArchive,
|
|
592
|
+
progress: options.progress || stageReporter(options.stderr || process.stderr) });
|
|
593
|
+
if (JSON.stringify(await acceptedRemovalWithoutCapsule(environment, spec.platform)) !== JSON.stringify(pending)) {
|
|
594
|
+
fail('Stackbite removal changed while staging its runtime; nothing was run');
|
|
595
|
+
}
|
|
596
|
+
const npm = await require('./uninstall-registration').descriptor(options);
|
|
597
|
+
const result = await launchNative(executable, argv, { ...environment,
|
|
598
|
+
...(npm ? { STACKBITE_NPM_REGISTRATION: JSON.stringify(npm) } : {}),
|
|
599
|
+
STACKBITE_HOME: path.resolve(payload) }, options);
|
|
600
|
+
retained = await stageStillNeeded(pending, staging);
|
|
601
|
+
return result;
|
|
602
|
+
} finally {
|
|
603
|
+
if (!retained) await removeStage(staging);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
513
606
|
async function launchNative(executable, argv, environment, { spawnImpl = spawn, signalSource = process } = {}) {
|
|
514
607
|
return new Promise((resolve, reject) => {
|
|
515
608
|
const child = spawnImpl(executable, argv, { env: environment, stdio: 'inherit', shell: false });
|
|
@@ -553,6 +646,11 @@ async function main(argv = process.argv.slice(2), options = {}) {
|
|
|
553
646
|
const { resumeWithoutRuntime, registrationOnly } = require('./uninstall-registration');
|
|
554
647
|
const recovered = await resumeWithoutRuntime(argv, environment, launchNative, options);
|
|
555
648
|
if (recovered) return recovered;
|
|
649
|
+
// Only an absent runtime qualifies; a corrupt or foreign cache stays an error.
|
|
650
|
+
if (error.code === 'STACKBITE_NOT_CACHED' && explicitApply(argv)) {
|
|
651
|
+
const continued = await continueAcceptedRemoval(spec, argv, environment, options);
|
|
652
|
+
if (continued) return continued;
|
|
653
|
+
}
|
|
556
654
|
const registration = await registrationOnly(argv, environment, launchNative, options);
|
|
557
655
|
if (registration) return registration;
|
|
558
656
|
throw error;
|
|
@@ -88,36 +88,51 @@ async function captureCommand(root, prefix, platform, node, cli, recoveryOptions
|
|
|
88
88
|
if (!entryInfo.isFile() || entryInfo.isSymbolicLink() || entryInfo.nlink !== 1) throw new Error('Linked npm command preserved');
|
|
89
89
|
const entryData = await fs.readFile(entry);
|
|
90
90
|
for (const shim of platform === 'win32' ? ['stackbite', 'stackbite.cmd', 'stackbite.ps1'].map(name => path.join(prefix, name)) : [path.join(prefix, 'bin', 'stackbite')]) {
|
|
91
|
-
await
|
|
92
|
-
|
|
93
|
-
try { info = await fs.lstat(shim); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
94
|
-
if (info.isSymbolicLink()) {
|
|
95
|
-
const target = await fs.readlink(shim);
|
|
96
|
-
if (path.resolve(path.dirname(shim), target) !== entry) throw new Error('Foreign npm command preserved');
|
|
97
|
-
shims.push({ path: shim, info, target });
|
|
98
|
-
} else {
|
|
99
|
-
const data = await fs.readFile(shim);
|
|
100
|
-
if (!info.isFile() || info.nlink !== 1 || data.length > 128 * 1024 || !data.toString('utf8').replaceAll('\\', '/').includes(relative)) throw new Error('Foreign npm command preserved');
|
|
101
|
-
shims.push({ path: shim, info, data });
|
|
102
|
-
}
|
|
91
|
+
const captured = await captureShim(shim, entry, relative);
|
|
92
|
+
if (captured) shims.push(captured);
|
|
103
93
|
}
|
|
104
94
|
if (!shims.length) return null;
|
|
105
|
-
const
|
|
106
|
-
installRoot: recoveryOptions.installRoot, cache: recoveryOptions.cache });
|
|
107
|
-
const recovery = Buffer.from(`const m=require('./uninstall-registration'),cp=require('node:child_process');\nconst launch=async(exe,args,env)=>{const r=cp.spawnSync(exe,args,{env,stdio:'inherit'});if(r.error)throw r.error;return {code:r.status,signal:r.signal};};\nmodule.exports.main=async()=>{const r=await m.registrationOnly(process.argv.slice(2),process.env,launch,${options});if(!r)throw Error('Native removal remains incomplete');return r;};\n`);
|
|
95
|
+
const recovery = recoveryModule(root, platform, node, cli, recoveryOptions);
|
|
108
96
|
const files = [];
|
|
109
97
|
for (const [file, data] of [[path.join(root, 'package.json'), metadata], [entry, entryData],
|
|
110
98
|
[path.join(root, 'packaging/npm/lib/bootstrap.js'), recovery],
|
|
111
99
|
[path.join(root, 'packaging/npm/lib/uninstall-registration.js'), await fs.readFile(__filename)]]) {
|
|
112
|
-
await
|
|
113
|
-
let info;
|
|
114
|
-
try { info = await fs.lstat(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
115
|
-
if (info && (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)) throw new Error('Linked npm command preserved');
|
|
116
|
-
files.push({ path: file, info, data });
|
|
100
|
+
files.push(await captureFile(file, data));
|
|
117
101
|
}
|
|
118
102
|
return { root, info: await fs.lstat(root), prefix, prefixInfo: await fs.lstat(prefix), shims, files };
|
|
119
103
|
}
|
|
120
104
|
|
|
105
|
+
// The file's current identity (undefined when absent) with the bytes to restore.
|
|
106
|
+
async function captureFile(file, data) {
|
|
107
|
+
await commandParent(file);
|
|
108
|
+
let info;
|
|
109
|
+
try { info = await fs.lstat(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
110
|
+
if (info && (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)) throw new Error('Linked npm command preserved');
|
|
111
|
+
return { path: file, info, data };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// An absent shim is null. A present one must link or point to this package's entry.
|
|
115
|
+
async function captureShim(shim, entry, relative) {
|
|
116
|
+
await commandParent(shim);
|
|
117
|
+
let info;
|
|
118
|
+
try { info = await fs.lstat(shim); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
119
|
+
if (info.isSymbolicLink()) {
|
|
120
|
+
const target = await fs.readlink(shim);
|
|
121
|
+
if (path.resolve(path.dirname(shim), target) !== entry) throw new Error('Foreign npm command preserved');
|
|
122
|
+
return { path: shim, info, target };
|
|
123
|
+
}
|
|
124
|
+
const data = await fs.readFile(shim);
|
|
125
|
+
if (!info.isFile() || info.nlink !== 1 || data.length > 128 * 1024 || !data.toString('utf8').replaceAll('\\', '/').includes(relative)) throw new Error('Foreign npm command preserved');
|
|
126
|
+
return { path: shim, info, data };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The bootstrap.js replacement that lets the original bin finish registration-only removal.
|
|
130
|
+
function recoveryModule(root, platform, node, cli, recoveryOptions) {
|
|
131
|
+
const options = JSON.stringify({ packageRoot: root, platform, nodeExecutable: node, npmCli: cli,
|
|
132
|
+
installRoot: recoveryOptions.installRoot, cache: recoveryOptions.cache });
|
|
133
|
+
return Buffer.from(`const m=require('./uninstall-registration'),cp=require('node:child_process');\nconst launch=async(exe,args,env)=>{const r=cp.spawnSync(exe,args,{env,stdio:'inherit'});if(r.error)throw r.error;return {code:r.status,signal:r.signal};};\nmodule.exports.main=async()=>{const r=await m.registrationOnly(process.argv.slice(2),process.env,launch,${options});if(!r)throw Error('Native removal remains incomplete');return r;};\n`);
|
|
134
|
+
}
|
|
135
|
+
|
|
121
136
|
async function restoreCommand(backup) {
|
|
122
137
|
if (!backup) return;
|
|
123
138
|
const info = await fs.lstat(backup.root);
|
|
@@ -125,32 +140,37 @@ async function restoreCommand(backup) {
|
|
|
125
140
|
const prefix = await fs.lstat(backup.prefix);
|
|
126
141
|
if (prefix.dev !== backup.prefixInfo.dev || prefix.ino !== backup.prefixInfo.ino) throw new Error('npm prefix replaced; preserved');
|
|
127
142
|
const items = [...backup.files, ...backup.shims];
|
|
128
|
-
for (const item of items)
|
|
129
|
-
await commandParent(item.path);
|
|
130
|
-
try {
|
|
131
|
-
const current = await fs.lstat(item.path);
|
|
132
|
-
if (!item.info || current.dev !== item.info.dev || current.ino !== item.info.ino) throw new Error('npm command replaced; preserved');
|
|
133
|
-
if (!item.target && (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1)) throw new Error('Linked npm command preserved');
|
|
134
|
-
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
135
|
-
}
|
|
143
|
+
for (const item of items) await assertRestorable(item);
|
|
136
144
|
for (const item of items) {
|
|
137
145
|
if (item.target) {
|
|
138
146
|
try { await fs.lstat(item.path); } catch (error) { if (error.code !== 'ENOENT') throw error; await fs.symlink(item.target, item.path); }
|
|
139
|
-
} else
|
|
140
|
-
// An npm partial uninstall can leave this original bin read/share-only.
|
|
141
|
-
// Its existing require('../lib/bootstrap') reaches the small recovery module.
|
|
142
|
-
try { if ((await fs.readFile(item.path)).equals(item.data)) continue; }
|
|
143
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
144
|
-
await fs.mkdir(path.dirname(item.path), { recursive: true });
|
|
145
|
-
await commandParent(item.path);
|
|
146
|
-
const file = await fs.open(item.path, require('node:fs').constants.O_WRONLY | require('node:fs').constants.O_CREAT
|
|
147
|
-
| require('node:fs').constants.O_TRUNC | (require('node:fs').constants.O_NOFOLLOW || 0), 0o755);
|
|
148
|
-
try { await file.writeFile(item.data); } finally { await file.close(); }
|
|
149
|
-
await fs.chmod(item.path, 0o755);
|
|
150
|
-
}
|
|
147
|
+
} else await restoreFile(item);
|
|
151
148
|
}
|
|
152
149
|
}
|
|
153
150
|
|
|
151
|
+
// A present item is still the captured object; a file is still unlinked.
|
|
152
|
+
async function assertRestorable(item) {
|
|
153
|
+
await commandParent(item.path);
|
|
154
|
+
try {
|
|
155
|
+
const current = await fs.lstat(item.path);
|
|
156
|
+
if (!item.info || current.dev !== item.info.dev || current.ino !== item.info.ino) throw new Error('npm command replaced; preserved');
|
|
157
|
+
if (!item.target && (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1)) throw new Error('Linked npm command preserved');
|
|
158
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function restoreFile(item) {
|
|
162
|
+
// An npm partial uninstall can leave this original bin read/share-only.
|
|
163
|
+
// Its existing require('../lib/bootstrap') reaches the small recovery module.
|
|
164
|
+
try { if ((await fs.readFile(item.path)).equals(item.data)) return; }
|
|
165
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
166
|
+
await fs.mkdir(path.dirname(item.path), { recursive: true });
|
|
167
|
+
await commandParent(item.path);
|
|
168
|
+
const file = await fs.open(item.path, require('node:fs').constants.O_WRONLY | require('node:fs').constants.O_CREAT
|
|
169
|
+
| require('node:fs').constants.O_TRUNC | (require('node:fs').constants.O_NOFOLLOW || 0), 0o755);
|
|
170
|
+
try { await file.writeFile(item.data); } finally { await file.close(); }
|
|
171
|
+
await fs.chmod(item.path, 0o755);
|
|
172
|
+
}
|
|
173
|
+
|
|
154
174
|
async function commandParent(file) {
|
|
155
175
|
for (let parent = path.dirname(file); ; parent = path.dirname(parent)) {
|
|
156
176
|
try { const info = await fs.lstat(parent); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Linked npm command preserved'); }
|
|
@@ -188,119 +208,154 @@ function windowsCleanupCommand(helper, pin) {
|
|
|
188
208
|
return ['powershell.exe', '-NoProfile', '-Command', dispatch];
|
|
189
209
|
}
|
|
190
210
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const prefix = await registration(packageRoot, platform);
|
|
196
|
-
if (prefix === null) return null;
|
|
211
|
+
const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
212
|
+
|
|
213
|
+
// The user's home and the data directory that holds the canonical installation root.
|
|
214
|
+
function productData(environment, platform) {
|
|
197
215
|
const home = (platform === 'win32' ? environment.USERPROFILE || environment.HOME : environment.HOME) || require('node:os').homedir();
|
|
198
216
|
const data = platform === 'win32' ? environment.LOCALAPPDATA || home
|
|
199
217
|
: environment.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
|
200
|
-
|
|
218
|
+
return { home, data };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Every location an installed native product could occupy. Registration-only
|
|
222
|
+
// removal is allowed only when all of them are absent or empty directory shells.
|
|
223
|
+
function nativeLocations(environment, platform, prefix, options) {
|
|
224
|
+
const { home, data } = productData(environment, platform);
|
|
201
225
|
const canonical = path.join(data, platform === 'win32' ? 'Stackbite' : 'stackbite');
|
|
226
|
+
const root = options.installRoot || canonical;
|
|
202
227
|
const old = path.join(data, platform === 'win32' ? 'Helm' : 'helm');
|
|
228
|
+
const legacyNames = ['app', 'cache', 'state', 'default-install.json', 'install.json'];
|
|
203
229
|
// Keep this recovery module standalone after npm removes the other modules.
|
|
204
230
|
// This is cache.js's npm namespace, not Python's locator/control fallback.
|
|
205
231
|
const npmData = environment.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
206
|
-
const roots = [root, ...
|
|
207
|
-
...(platform === 'win32' ? [path.join(npmData, 'Stackbite'),
|
|
208
|
-
...['app', 'cache', 'state', 'default-install.json', 'install.json'].map(name =>
|
|
209
|
-
path.join(npmData, 'Helm', name))] : []),
|
|
232
|
+
const roots = [root, ...legacyNames.map(name => path.join(old, name)),
|
|
233
|
+
...(platform === 'win32' ? [path.join(npmData, 'Stackbite'), ...legacyNames.map(name => path.join(npmData, 'Helm', name))] : []),
|
|
210
234
|
...(platform === 'win32' ? [] : ['stackbite', 'helm'].flatMap(name => [
|
|
211
235
|
path.join(environment.XDG_STATE_HOME || path.join(home, '.local', 'state'), name),
|
|
212
236
|
path.join(environment.XDG_CACHE_HOME || path.join(home, '.cache'), name)])),
|
|
213
237
|
...[options.cache, environment.STACKBITE_HOME, environment.STACKBITE_STATE_DIR].filter(Boolean)];
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const info = await fs.lstat(location);
|
|
220
|
-
if (preparation && location === preparation.file) return preparationCurrent();
|
|
221
|
-
if (!info.isDirectory() || info.isSymbolicLink() || await fs.realpath(location) !== path.resolve(location)) return false;
|
|
222
|
-
for (const name of await fs.readdir(location)) if (!await empty(path.join(location, name))) return false;
|
|
223
|
-
return true;
|
|
224
|
-
} catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
238
|
+
const commands = platform === 'win32' ? ['stackbite.cmd', 'stackbite-portable.cmd', 'stackbite.exe', 'stackbite.exe.next'] : ['stackbite'];
|
|
239
|
+
for (const name of commands) {
|
|
240
|
+
const native = path.join(home, '.local', 'bin', name);
|
|
241
|
+
const own = platform === 'win32' ? path.join(prefix, name) : path.join(prefix, 'bin', name);
|
|
242
|
+
if (native !== own) roots.push(native);
|
|
225
243
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
244
|
+
return { root, canonical, roots: [...new Set(roots)] };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// True when `start` and each ancestor is a real, unlinked directory outside a Git checkout.
|
|
248
|
+
async function plainDirectoryChain(start) {
|
|
249
|
+
for (let parent = start; ; parent = path.dirname(parent)) {
|
|
250
|
+
const info = await fs.lstat(parent);
|
|
251
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await fs.realpath(parent) !== parent) return false;
|
|
252
|
+
try { await fs.lstat(path.join(parent, '.git')); return false; }
|
|
253
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
254
|
+
if (parent === path.dirname(parent)) return true;
|
|
236
255
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
for (const [directory, expected, names] of [
|
|
246
|
-
[root, preparation.rootInfo, ['.stackbite-update']],
|
|
247
|
-
[preparation.directory, preparation.directoryInfo, ['update.lock']],
|
|
248
|
-
]) {
|
|
249
|
-
const info = await fs.lstat(directory, { bigint: true });
|
|
250
|
-
if (!info.isDirectory() || info.isSymbolicLink() || !same(info, expected)
|
|
251
|
-
|| await fs.realpath(directory) !== directory
|
|
252
|
-
|| JSON.stringify((await fs.readdir(directory)).sort()) !== JSON.stringify(names)) return false;
|
|
253
|
-
}
|
|
254
|
-
return preparationCurrent();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function ancestorsAreDirectories(location) {
|
|
259
|
+
for (let parent = path.dirname(location); ; parent = path.dirname(parent)) {
|
|
260
|
+
try { const info = await fs.lstat(parent); if (!info.isDirectory() || info.isSymbolicLink()) return false; }
|
|
261
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
262
|
+
if (parent === path.dirname(parent)) return true;
|
|
255
263
|
}
|
|
256
|
-
|
|
257
|
-
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// A first-ever publisher pins this existing control object across download.
|
|
267
|
+
// Unlinking its verified name invalidates that publisher; this FD is no mutex.
|
|
268
|
+
// Returns null when there is no preparation, false when it is not ours to remove.
|
|
269
|
+
async function openPreparation(root, canonical) {
|
|
258
270
|
const directory = path.join(root, '.stackbite-update'), file = path.join(directory, 'update.lock');
|
|
271
|
+
let info;
|
|
272
|
+
try { info = await fs.lstat(file, { bigint: true }); }
|
|
273
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
274
|
+
if (root !== canonical || !path.isAbsolute(root) || path.resolve(root) !== root
|
|
275
|
+
|| root.split(path.sep).includes('..') || /[\0\r\n]/.test(root)
|
|
276
|
+
|| !info.isFile() || info.isSymbolicLink() || info.nlink !== 1n || info.size > 1n) return false;
|
|
259
277
|
try {
|
|
278
|
+
if (!await plainDirectoryChain(directory)) return false;
|
|
279
|
+
const { constants } = require('node:fs');
|
|
280
|
+
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
281
|
+
const preparation = { root, file, directory, info, handle };
|
|
260
282
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
283
|
+
preparation.rootInfo = await fs.lstat(root, { bigint: true });
|
|
284
|
+
preparation.directoryInfo = await fs.lstat(directory, { bigint: true });
|
|
285
|
+
} catch (error) { await handle.close(); throw error; }
|
|
286
|
+
return preparation;
|
|
287
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The held lock is still the same single-link file holding nothing or the byte '0'.
|
|
291
|
+
async function preparationCurrent(preparation) {
|
|
292
|
+
const held = await preparation.handle.stat({ bigint: true });
|
|
293
|
+
const current = await fs.lstat(preparation.file, { bigint: true });
|
|
294
|
+
const bytes = Buffer.alloc(2);
|
|
295
|
+
const { bytesRead } = await preparation.handle.read(bytes, 0, bytes.length, 0);
|
|
296
|
+
return held.isFile() && current.isFile() && !current.isSymbolicLink()
|
|
297
|
+
&& held.nlink === 1n && current.nlink === 1n && sameIdentity(held, preparation.info) && sameIdentity(current, held)
|
|
298
|
+
&& held.size <= 1n && current.size === held.size && BigInt(bytesRead) === held.size
|
|
299
|
+
&& (bytesRead === 0 || bytesRead === 1 && bytes[0] === 0x30);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function preparationUnchanged(preparation) {
|
|
303
|
+
if (!await plainDirectoryChain(preparation.directory)) return false;
|
|
304
|
+
for (const [directory, expected, names] of [
|
|
305
|
+
[preparation.root, preparation.rootInfo, ['.stackbite-update']],
|
|
306
|
+
[preparation.directory, preparation.directoryInfo, ['update.lock']],
|
|
307
|
+
]) {
|
|
308
|
+
const info = await fs.lstat(directory, { bigint: true });
|
|
309
|
+
if (!info.isDirectory() || info.isSymbolicLink() || !sameIdentity(info, expected)
|
|
310
|
+
|| await fs.realpath(directory) !== directory
|
|
311
|
+
|| JSON.stringify((await fs.readdir(directory)).sort()) !== JSON.stringify(names)) return false;
|
|
312
|
+
}
|
|
313
|
+
return preparationCurrent(preparation);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Directory shells left by a completed physical tail contain no artifacts.
|
|
317
|
+
// The pinned canonical preparation is the only permitted file.
|
|
318
|
+
async function emptyLocation(location, preparation) {
|
|
319
|
+
try {
|
|
320
|
+
const info = await fs.lstat(location);
|
|
321
|
+
if (preparation && location === preparation.file) return preparationCurrent(preparation);
|
|
322
|
+
if (!info.isDirectory() || info.isSymbolicLink() || await fs.realpath(location) !== path.resolve(location)) return false;
|
|
323
|
+
for (const name of await fs.readdir(location)) if (!await emptyLocation(path.join(location, name), preparation)) return false;
|
|
324
|
+
return true;
|
|
325
|
+
} catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function removePreparation(preparation) {
|
|
329
|
+
await fs.unlink(preparation.file);
|
|
330
|
+
for (const [location, expected] of [[preparation.directory, preparation.directoryInfo], [preparation.root, preparation.rootInfo]]) {
|
|
331
|
+
const current = await fs.lstat(location, { bigint: true });
|
|
332
|
+
if (!sameIdentity(current, expected) || !current.isDirectory() || current.isSymbolicLink()
|
|
333
|
+
|| await fs.realpath(location) !== location) throw new Error('Installation preparation replaced; preserved');
|
|
334
|
+
await fs.rmdir(location);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function registrationOnly(argv, environment, launch, options = {}) {
|
|
339
|
+
if (!explicitApply(argv)) return null;
|
|
340
|
+
const platform = options.platform || process.platform;
|
|
341
|
+
const packageRoot = options.packageRoot || path.resolve(__dirname, '../../..');
|
|
342
|
+
const prefix = await registration(packageRoot, platform);
|
|
343
|
+
if (prefix === null) return null;
|
|
344
|
+
const { root, canonical, roots } = nativeLocations(environment, platform, prefix, options);
|
|
345
|
+
const preparation = await openPreparation(root, canonical);
|
|
346
|
+
if (preparation === false) return null;
|
|
347
|
+
try {
|
|
348
|
+
if (preparation && !await preparationUnchanged(preparation)) return null;
|
|
349
|
+
for (const location of roots) {
|
|
284
350
|
const absolute = path.resolve(location);
|
|
285
|
-
|
|
286
|
-
try { const info = await fs.lstat(parent); if (!info.isDirectory() || info.isSymbolicLink()) return null; }
|
|
287
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
288
|
-
if (parent === path.dirname(parent)) break;
|
|
289
|
-
}
|
|
290
|
-
if (!await empty(absolute)) return null;
|
|
351
|
+
if (!await ancestorsAreDirectories(absolute) || !await emptyLocation(absolute, preparation)) return null;
|
|
291
352
|
}
|
|
292
353
|
if (preparation) {
|
|
293
|
-
if (!await
|
|
294
|
-
await
|
|
295
|
-
for (const [location, expected] of [[directory, preparation.directoryInfo], [root, preparation.rootInfo]]) {
|
|
296
|
-
const current = await fs.lstat(location, { bigint: true });
|
|
297
|
-
if (!same(current, expected) || !current.isDirectory() || current.isSymbolicLink()
|
|
298
|
-
|| await fs.realpath(location) !== location) throw new Error('Installation preparation replaced; preserved');
|
|
299
|
-
await fs.rmdir(location);
|
|
300
|
-
}
|
|
354
|
+
if (!await preparationUnchanged(preparation)) return null;
|
|
355
|
+
await removePreparation(preparation);
|
|
301
356
|
}
|
|
302
357
|
return removeRegistration(argv, { code: 0, signal: null }, environment, launch, { ...options, packageRoot });
|
|
303
|
-
} finally { if (
|
|
358
|
+
} finally { if (preparation) await preparation.handle.close(); }
|
|
304
359
|
}
|
|
305
360
|
|
|
306
361
|
// Recovery never installs/downloads another runtime. Only the accepted journal's
|
|
@@ -308,22 +363,46 @@ async function registrationOnly(argv, environment, launch, options = {}) {
|
|
|
308
363
|
async function resumeWithoutRuntime(argv, environment, launch, options = {}) {
|
|
309
364
|
if (!explicitApply(argv)) return null;
|
|
310
365
|
const platform = options.platform || process.platform;
|
|
311
|
-
const
|
|
312
|
-
const root = options.installRoot || (platform === 'win32'
|
|
313
|
-
? path.join(environment.LOCALAPPDATA || home, 'Stackbite')
|
|
314
|
-
: path.join(environment.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'stackbite'));
|
|
366
|
+
const root = options.installRoot || path.join(productData(environment, platform).data, platform === 'win32' ? 'Stackbite' : 'stackbite');
|
|
315
367
|
const journal = path.join(root, '.stackbite-uninstall.json');
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const info = await fs.lstat(journal);
|
|
319
|
-
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 8 * 1024 * 1024
|
|
320
|
-
|| await fs.realpath(journal) !== journal) throw new Error('Unsafe removal journal');
|
|
321
|
-
record = JSON.parse(await fs.readFile(journal, 'utf8'));
|
|
322
|
-
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
368
|
+
const record = await readRemovalJournal(journal);
|
|
369
|
+
if (record === undefined) return null;
|
|
323
370
|
if (record.schema !== 'stackbite.uninstall.v1' || !record.accepted || !record.processes_settled
|
|
324
371
|
|| record.install_identity?.root !== root) return null;
|
|
325
372
|
// The capsule takes the existing installation/removal locks and owns all
|
|
326
373
|
// journal mutation, including npm settlement. This reader never rewrites it.
|
|
374
|
+
const recovery = recoveryCommand(record, journal, platform);
|
|
375
|
+
if (recovery === null) return null;
|
|
376
|
+
const { selected, helper, pin } = recovery;
|
|
377
|
+
const info = await fs.lstat(helper);
|
|
378
|
+
if (!await soleRegularFile(helper, info, 128 * 1024) || await sha256File(helper) !== pin.sha256) {
|
|
379
|
+
throw new Error('Removal capsule changed; preserved');
|
|
380
|
+
}
|
|
381
|
+
if (platform !== 'win32') await assertShellCapsule(record, journal);
|
|
382
|
+
return launch(selected[0], selected.slice(1), environment, options);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// The parsed journal; undefined when there is none.
|
|
386
|
+
async function readRemovalJournal(journal) {
|
|
387
|
+
try {
|
|
388
|
+
const info = await fs.lstat(journal);
|
|
389
|
+
if (!await soleRegularFile(journal, info, 8 * 1024 * 1024)) throw new Error('Unsafe removal journal');
|
|
390
|
+
return JSON.parse(await fs.readFile(journal, 'utf8'));
|
|
391
|
+
} catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// A regular, singly linked file within `limit` bytes that is its own real path.
|
|
395
|
+
async function soleRegularFile(location, info, limit = Infinity) {
|
|
396
|
+
return info.isFile() && !info.isSymbolicLink() && info.nlink === 1 && !(info.size > limit)
|
|
397
|
+
&& await fs.realpath(location) === location;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function sha256File(location) {
|
|
401
|
+
return require('node:crypto').createHash('sha256').update(await fs.readFile(location)).digest('hex');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The journal's recovery argv when it is exactly this platform's pinned helper invocation.
|
|
405
|
+
function recoveryCommand(record, journal, platform) {
|
|
327
406
|
const command = record.recovery_command;
|
|
328
407
|
const suffix = platform === 'win32' ? '.ps1' : '.sh';
|
|
329
408
|
const helper = journal.replace(/\.json$/, suffix);
|
|
@@ -334,43 +413,36 @@ async function resumeWithoutRuntime(argv, environment, launch, options = {}) {
|
|
|
334
413
|
const selected = platform === 'win32' && JSON.stringify(command) === JSON.stringify(legacy) ? legacy : expected;
|
|
335
414
|
if (!Array.isArray(command) || JSON.stringify(command) !== JSON.stringify(selected)
|
|
336
415
|
|| !record.helper_files?.some(item => item.path === helper)) return null;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
416
|
+
return { selected, helper, pin };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function assertShellCapsule(record, journal) {
|
|
420
|
+
const capsule = journal.replace(/\.json$/, '.cleanup');
|
|
421
|
+
const pinned = record.helper_files.find(item => item.path === capsule);
|
|
422
|
+
const current = await fs.lstat(capsule);
|
|
423
|
+
if (!pinned || !await soleRegularFile(capsule, current) || await sha256File(capsule) !== pinned.sha256) {
|
|
341
424
|
throw new Error('Removal capsule changed; preserved');
|
|
342
425
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (typeof location !== 'string' || !path.isAbsolute(location) || /[\x00\r\n]/.test(location)
|
|
362
|
-
|| location.split(path.sep).includes('..')) throw new Error('Source cleanup runtime changed');
|
|
363
|
-
const current = await fs.lstat(location);
|
|
364
|
-
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1
|
|
365
|
-
|| await fs.realpath(location) !== location || current.dev !== pin.identity?.device
|
|
366
|
-
|| current.ino !== pin.identity?.inode
|
|
367
|
-
|| require('node:crypto').createHash('sha256').update(await fs.readFile(location)).digest('hex') !== pin.sha256) {
|
|
368
|
-
throw new Error('Source cleanup runtime changed');
|
|
369
|
-
}
|
|
370
|
-
}
|
|
426
|
+
// Source invocations retain their existing interpreter/entry outside the
|
|
427
|
+
// removal inventory. Validate those exact pins before executing the shell
|
|
428
|
+
// capsule; neither environment PATH nor journal argv selects a runtime.
|
|
429
|
+
if (record.source_cleanup_pins !== undefined) await assertSourceCleanupPins(record.source_cleanup_pins);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function assertSourceCleanupPins(pins) {
|
|
433
|
+
if (!Array.isArray(pins) || pins.length !== 4) {
|
|
434
|
+
throw new Error('Source cleanup identity changed');
|
|
435
|
+
}
|
|
436
|
+
for (const pin of pins) {
|
|
437
|
+
const location = pin.path;
|
|
438
|
+
if (typeof location !== 'string' || !path.isAbsolute(location) || /[\x00\r\n]/.test(location)
|
|
439
|
+
|| location.split(path.sep).includes('..')) throw new Error('Source cleanup runtime changed');
|
|
440
|
+
const current = await fs.lstat(location);
|
|
441
|
+
if (!await soleRegularFile(location, current) || current.dev !== pin.identity?.device
|
|
442
|
+
|| current.ino !== pin.identity?.inode || await sha256File(location) !== pin.sha256) {
|
|
443
|
+
throw new Error('Source cleanup runtime changed');
|
|
371
444
|
}
|
|
372
445
|
}
|
|
373
|
-
return launch(selected[0], selected.slice(1), environment, options);
|
|
374
446
|
}
|
|
375
447
|
|
|
376
448
|
module.exports = { registration, npmProgram, removeRegistration, explicitApply, descriptor, resumeWithoutRuntime, registrationOnly };
|
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"commit": "
|
|
3
|
+
"version": "0.5.99",
|
|
4
|
+
"commit": "90aae4864b24be65ebfb31fcf6fe17b9d5b28cb5",
|
|
5
5
|
"repository": "Shinick-Han/stackbite",
|
|
6
6
|
"platforms": {
|
|
7
7
|
"win32-x64": {
|
|
8
|
-
"asset": "stackbite-0.5.
|
|
9
|
-
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.
|
|
10
|
-
"sha256": "
|
|
11
|
-
"size":
|
|
12
|
-
"root": "stackbite-0.5.
|
|
13
|
-
"manifestSha256": "
|
|
14
|
-
"metadataSha256": "
|
|
8
|
+
"asset": "stackbite-0.5.99-windows-x64-90aae4864b24.zip",
|
|
9
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.99/stackbite-0.5.99-windows-x64-90aae4864b24.zip",
|
|
10
|
+
"sha256": "1d2e469d71802398b095a1ef77abe26050c7a43523005119449e01108350f13c",
|
|
11
|
+
"size": 95935897,
|
|
12
|
+
"root": "stackbite-0.5.99-windows-x64-90aae4864b24",
|
|
13
|
+
"manifestSha256": "4544eb749c94ef633748650466298a013cbf950533e42a54c3fc1a87537e0475",
|
|
14
|
+
"metadataSha256": "91e564369939e31d1e2e5d549d1185fc081b2ad80e478fa0d0658f8870898b70"
|
|
15
15
|
},
|
|
16
16
|
"linux-x64": {
|
|
17
|
-
"asset": "stackbite-0.5.
|
|
18
|
-
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.
|
|
19
|
-
"sha256": "
|
|
20
|
-
"size":
|
|
21
|
-
"root": "stackbite-0.5.
|
|
22
|
-
"manifestSha256": "
|
|
23
|
-
"metadataSha256": "
|
|
17
|
+
"asset": "stackbite-0.5.99-linux-x86_64-90aae4864b24.tar.gz",
|
|
18
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.99/stackbite-0.5.99-linux-x86_64-90aae4864b24.tar.gz",
|
|
19
|
+
"sha256": "84e78372b8ef337480fe230a4eaf4cf2ff39048ff8af2c21929e8b4535122d5a",
|
|
20
|
+
"size": 76221146,
|
|
21
|
+
"root": "stackbite-0.5.99-linux-x86_64-90aae4864b24",
|
|
22
|
+
"manifestSha256": "a892cd3e8ff2d338e6bd8580df67b357b7e15b2129107d359d97e12cb98d1673",
|
|
23
|
+
"metadataSha256": "441c8da312ee420767e761665c83753018a01e20d2d954670f43bdc640a1369c"
|
|
24
24
|
},
|
|
25
25
|
"darwin-arm64": {
|
|
26
|
-
"asset": "stackbite-0.5.
|
|
27
|
-
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.
|
|
28
|
-
"sha256": "
|
|
29
|
-
"size":
|
|
30
|
-
"root": "stackbite-0.5.
|
|
31
|
-
"manifestSha256": "
|
|
32
|
-
"metadataSha256": "
|
|
26
|
+
"asset": "stackbite-0.5.99-macos-arm64-90aae4864b24.tar.gz",
|
|
27
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.99/stackbite-0.5.99-macos-arm64-90aae4864b24.tar.gz",
|
|
28
|
+
"sha256": "e40810f5c5b18728bddbc494e27c89d46b32ce4eb3c0db4fe5bca1b0f0e712f8",
|
|
29
|
+
"size": 31167159,
|
|
30
|
+
"root": "stackbite-0.5.99-macos-arm64-90aae4864b24",
|
|
31
|
+
"manifestSha256": "49b23c300843d261c6195b252cb6d10dec2810795fc6d7a599d57d71d6a3d288",
|
|
32
|
+
"metadataSha256": "5768005ea1b1c5beecbc3f5933818db04f2f5cdd533f29b511ad6e0d6af46b19"
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
}
|