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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackbite",
3
- "version": "0.5.97",
3
+ "version": "0.5.99",
4
4
  "description": "Verified native Stackbite CLI bootstrapper",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -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.schema !== 'stackbite-rust-terminal-build-v1' || manifest.product !== 'Stackbite'
175
- || manifest.version !== spec.version || manifest.commit !== spec.commit || manifest.target !== target || manifest.protocol !== 1
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
- let current = null;
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
- current = value.installation_id;
223
- break;
237
+ return value.installation_id ?? null;
224
238
  }
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');
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
- const pin = expected.preparation, file = path.join(root, '.stackbite-update', 'update.lock');
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
- let current = null;
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
- current = value.installation_id ?? null;
294
- break;
338
+ return value.installation_id ?? null;
295
339
  }
296
- if (expected.root !== root || expected.incarnation !== current) fail('Installation changed while staging');
297
- if (current === null) {
298
- if (!Array.isArray(expected.legacy) || !expected.legacy.length || expected.legacy.length > 32) fail('Invalid legacy installation baseline');
299
- for (const pin of expected.legacy) {
300
- const actual = await baselinePin(pin.path);
301
- let identity = pin.identity;
302
- // Python-origin restrictions retain FILE_ID_INFO's full volume serial;
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
- let handle, created = false, preparation;
401
- try {
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)) fail('Pinned runtime is not cached; run stackbite setup.');
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) fail('Pinned runtime is not cached; run stackbite setup.');
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 archive = path.join(staging, spec.asset), extraction = path.join(staging, 'extract');
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 commandParent(shim);
92
- let info;
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 options = JSON.stringify({ packageRoot: root, platform, nodeExecutable: node, npmCli: cli,
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 commandParent(file);
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
- async function registrationOnly(argv, environment, launch, options = {}) {
192
- if (!explicitApply(argv)) return null;
193
- const platform = options.platform || process.platform;
194
- const packageRoot = options.packageRoot || path.resolve(__dirname, '../../..');
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
- const root = options.installRoot || path.join(data, platform === 'win32' ? 'Stackbite' : 'stackbite');
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, ...['app', 'cache', 'state', 'default-install.json', 'install.json'].map(name => path.join(old, name)),
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
- // Directory shells left by a completed physical tail contain no artifacts.
215
- // The pinned canonical preparation below is the only permitted file.
216
- let preparation, preparationHandle;
217
- async function empty(location) {
218
- try {
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
- const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
227
- async function preparationCurrent() {
228
- const held = await preparation.handle.stat({ bigint: true });
229
- const current = await fs.lstat(preparation.file, { bigint: true });
230
- const bytes = Buffer.alloc(2);
231
- const { bytesRead } = await preparation.handle.read(bytes, 0, bytes.length, 0);
232
- return held.isFile() && current.isFile() && !current.isSymbolicLink()
233
- && held.nlink === 1n && current.nlink === 1n && same(held, preparation.info) && same(current, held)
234
- && held.size <= 1n && current.size === held.size && BigInt(bytesRead) === held.size
235
- && (bytesRead === 0 || bytesRead === 1 && bytes[0] === 0x30);
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
- async function preparationDirectories() {
238
- for (let parent = preparation.directory; ; parent = path.dirname(parent)) {
239
- const info = await fs.lstat(parent);
240
- if (!info.isDirectory() || info.isSymbolicLink() || await fs.realpath(parent) !== parent) return false;
241
- try { await fs.lstat(path.join(parent, '.git')); return false; }
242
- catch (error) { if (error.code !== 'ENOENT') throw error; }
243
- if (parent === path.dirname(parent)) break;
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
- // A first-ever publisher pins this existing control object across download.
257
- // Unlinking its verified name invalidates that publisher; this FD is no mutex.
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
- const info = await fs.lstat(file, { bigint: true });
262
- if (root !== canonical || !path.isAbsolute(root) || path.resolve(root) !== root
263
- || root.split(path.sep).includes('..') || /[\0\r\n]/.test(root)
264
- || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1n || info.size > 1n) return null;
265
- for (let parent = directory; ; parent = path.dirname(parent)) {
266
- const parentInfo = await fs.lstat(parent);
267
- if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink() || await fs.realpath(parent) !== parent) return null;
268
- try { await fs.lstat(path.join(parent, '.git')); return null; }
269
- catch (error) { if (error.code !== 'ENOENT') throw error; }
270
- if (parent === path.dirname(parent)) break;
271
- }
272
- preparationHandle = await fs.open(file, require('node:fs').constants.O_RDONLY | (require('node:fs').constants.O_NOFOLLOW || 0));
273
- preparation = { file, directory, info, handle: preparationHandle, rootInfo: await fs.lstat(root, { bigint: true }),
274
- directoryInfo: await fs.lstat(directory, { bigint: true }) };
275
- } catch (error) { if (error.code !== 'ENOENT') throw error; }
276
- if (preparation && !await preparationDirectories()) return null;
277
- const commands = platform === 'win32' ? ['stackbite.cmd', 'stackbite-portable.cmd', 'stackbite.exe', 'stackbite.exe.next'] : ['stackbite'];
278
- for (const name of commands) {
279
- const native = path.join(home, '.local', 'bin', name);
280
- const own = platform === 'win32' ? path.join(prefix, name) : path.join(prefix, 'bin', name);
281
- if (native !== own) roots.push(native);
282
- }
283
- for (const location of new Set(roots)) {
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
- for (let parent = path.dirname(absolute); ; parent = path.dirname(parent)) {
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 preparationDirectories()) return null;
294
- await fs.unlink(preparation.file);
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 (preparationHandle) await preparationHandle.close(); }
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 home = (platform === 'win32' ? environment.USERPROFILE || environment.HOME : environment.HOME) || require('node:os').homedir();
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
- let record;
317
- try {
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
- const info = await fs.lstat(helper);
338
- if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > 128 * 1024
339
- || await fs.realpath(helper) !== helper
340
- || require('node:crypto').createHash('sha256').update(await fs.readFile(helper)).digest('hex') !== pin.sha256) {
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
- if (platform !== 'win32') {
344
- const capsule = journal.replace(/\.json$/, '.cleanup');
345
- const pinned = record.helper_files.find(item => item.path === capsule);
346
- const current = await fs.lstat(capsule);
347
- if (!pinned || !current.isFile() || current.isSymbolicLink() || current.nlink !== 1
348
- || await fs.realpath(capsule) !== capsule
349
- || require('node:crypto').createHash('sha256').update(await fs.readFile(capsule)).digest('hex') !== pinned.sha256) {
350
- throw new Error('Removal capsule changed; preserved');
351
- }
352
- // Source invocations retain their existing interpreter/entry outside the
353
- // removal inventory. Validate those exact pins before executing the shell
354
- // capsule; neither environment PATH nor journal argv selects a runtime.
355
- if (record.source_cleanup_pins !== undefined) {
356
- if (!Array.isArray(record.source_cleanup_pins) || record.source_cleanup_pins.length !== 4) {
357
- throw new Error('Source cleanup identity changed');
358
- }
359
- for (const pin of record.source_cleanup_pins) {
360
- const location = pin.path;
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.97",
4
- "commit": "d93c92bb4a58734a087aa463f38b341d2a0658fa",
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.97-windows-x64-d93c92bb4a58.zip",
9
- "url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.97/stackbite-0.5.97-windows-x64-d93c92bb4a58.zip",
10
- "sha256": "9f7a56954fe214291480a80133b3af06cfb357a0b0519f522bb64e9b97499aa5",
11
- "size": 95783978,
12
- "root": "stackbite-0.5.97-windows-x64-d93c92bb4a58",
13
- "manifestSha256": "aea3457360e22abbe83a3a6d8dc0fc7c542efc1e67db1afd060427a4006b1a47",
14
- "metadataSha256": "c881ace2ffd9d7e7368f65efa97ad3ff6c6b6ffe5433ed9ea0642eb2819c7d7a"
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.97-linux-x86_64-d93c92bb4a58.tar.gz",
18
- "url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.97/stackbite-0.5.97-linux-x86_64-d93c92bb4a58.tar.gz",
19
- "sha256": "3f4f9c56f70e53012f15c1bf11cd1674bc1768e06e5fbe10f3fdc92c4228fd6b",
20
- "size": 76061466,
21
- "root": "stackbite-0.5.97-linux-x86_64-d93c92bb4a58",
22
- "manifestSha256": "392ff108097c47dcd93f0bbc32d1cde6e37085c41ede3e3cb6a781794c5f33bb",
23
- "metadataSha256": "c0efc702716482ae3f7634a0e1c311dd69885912511955746a5d00e1c21575b1"
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.97-macos-arm64-d93c92bb4a58.tar.gz",
27
- "url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.97/stackbite-0.5.97-macos-arm64-d93c92bb4a58.tar.gz",
28
- "sha256": "2e1b3ee9fe9f03dcd01be97f67d640b4b04b59d2549fed9cef33e26dad36d942",
29
- "size": 31019387,
30
- "root": "stackbite-0.5.97-macos-arm64-d93c92bb4a58",
31
- "manifestSha256": "84cc726b662f4f6152c2c35f698314c0c22d88a04511a1a881e4c4a97a0b0a0b",
32
- "metadataSha256": "bb2dbebb6d0dc5116e483a947fdb53d8e1e35f72c4a0ee3df35e522b43765f16"
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
  }