stackbite 0.5.98 → 0.5.100

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.98",
3
+ "version": "0.5.100",
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);
@@ -301,34 +310,40 @@ async function assertBaseline(environment, platform) {
301
310
  if (Buffer.byteLength(raw) > 16384) fail('Invalid installation baseline');
302
311
  const expected = JSON.parse(raw), root = installationRoot(environment, platform);
303
312
  if (expected.preparation !== undefined) {
304
- const pin = expected.preparation, file = path.join(root, '.stackbite-update', 'update.lock');
305
- if (Object.keys(expected).sort().join(',') !== 'preparation,root' || expected.root !== root
306
- || !pin || Object.keys(pin).sort().join(',') !== 'identity,path' || pin.path !== file || !pin.identity) fail('Invalid installation preparation');
307
- let identity = pin.identity;
308
- const actual = await baselinePin(file);
309
- if (platform === 'win32' && actual) identity = { ...identity, device: (BigInt(identity.device) & 0xffffffffn).toString() };
310
- if (JSON.stringify(actual) !== JSON.stringify(identity)) fail('Installation changed while staging');
313
+ await assertPreparationBaseline(expected, root, platform);
311
314
  return;
312
315
  }
313
- 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) {
314
334
  for (const name of ['default-install.json', 'install.json']) {
315
335
  const file = path.join(root, name);
316
336
  if (!await exists(file)) continue;
317
337
  const value = JSON.parse((await readSmall(file, 16384)).toString('utf8').replace(/^\uFEFF/, ''));
318
- current = value.installation_id ?? null;
319
- break;
338
+ return value.installation_id ?? null;
320
339
  }
321
- if (expected.root !== root || expected.incarnation !== current) fail('Installation changed while staging');
322
- if (current === null) {
323
- if (!Array.isArray(expected.legacy) || !expected.legacy.length || expected.legacy.length > 32) fail('Invalid legacy installation baseline');
324
- for (const pin of expected.legacy) {
325
- const actual = await baselinePin(pin.path);
326
- let identity = pin.identity;
327
- // Python-origin restrictions retain FILE_ID_INFO's full volume serial;
328
- // libuv reports its 32-bit component. The engine still checks the full pin.
329
- if (platform === 'win32' && actual && identity) identity = { ...identity, device: (BigInt(identity.device) & 0xffffffffn).toString() };
330
- if (JSON.stringify(actual) !== JSON.stringify(identity)) fail('Installation changed while staging');
331
- }
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');
332
347
  }
333
348
  }
334
349
 
@@ -390,6 +405,30 @@ async function settlePreparation(preparation, environment, platform, source) {
390
405
  } finally { await preparation.handle.close(); }
391
406
  }
392
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
+ }
393
432
  async function reservePreparation(environment, platform, target, source) {
394
433
  if (Object.hasOwn(environment, 'STACKBITE_INSTALL_BASELINE')) return null;
395
434
  let baseline = await captureBaseline(environment, platform, target);
@@ -410,26 +449,10 @@ async function reservePreparation(environment, platform, target, source) {
410
449
  if (source) await source.validate();
411
450
  const firstCreated = await fs.mkdir(directory, { recursive: true, mode: 0o700 });
412
451
  await existingCacheDirectory(directory);
413
- const directories = [];
414
- if (firstCreated) {
415
- for (const selected of [directory, root]) {
416
- // libuv may return a Windows namespace spelling for the first directory.
417
- const createdName = path.toNamespacedPath(firstCreated), selectedName = path.toNamespacedPath(selected);
418
- if (selectedName === createdName || selectedName.startsWith(createdName + path.sep)) {
419
- directories.push([selected, await fs.lstat(selected, { bigint: true })]);
420
- }
421
- }
422
- }
423
- const constants = require('node:fs').constants;
452
+ const directories = await createdDirectories(firstCreated, [directory, root]);
424
453
  const file = path.join(directory, 'update.lock');
425
- let handle, created = false, preparation;
426
- try {
427
- handle = await fs.open(file, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | (constants.O_NOFOLLOW || 0), 0o600);
428
- created = true;
429
- } catch (error) {
430
- if (error.code !== 'EEXIST') throw error;
431
- handle = await fs.open(file, constants.O_RDWR | (constants.O_NOFOLLOW || 0));
432
- }
454
+ const { handle, created } = await openPreparationFile(file);
455
+ let preparation;
433
456
  try {
434
457
  const held = await handle.stat({ bigint: true });
435
458
  const identity = await baselinePin(file);
@@ -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.98",
4
- "commit": "4c23b408dfb8e87b7bb93154b895769fa17c4af3",
3
+ "version": "0.5.100",
4
+ "commit": "22d4d67b4166dfa49d2837f65066d85eb7a25924",
5
5
  "repository": "Shinick-Han/stackbite",
6
6
  "platforms": {
7
7
  "win32-x64": {
8
- "asset": "stackbite-0.5.98-windows-x64-4c23b408dfb8.zip",
9
- "url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.98/stackbite-0.5.98-windows-x64-4c23b408dfb8.zip",
10
- "sha256": "3b587ba43f67158a14e55ee4d4c9431f93cc0b576360d42e281ecc4736ae6f6d",
11
- "size": 95796531,
12
- "root": "stackbite-0.5.98-windows-x64-4c23b408dfb8",
13
- "manifestSha256": "6e5c387b3af706690f372b831dab60ca923211ffb02d481c9bda3c40a599fd1c",
14
- "metadataSha256": "152b7c85dccafc2cedb9ce8ea498f4a26e43834c534f6dd086d047d7539b05c1"
8
+ "asset": "stackbite-0.5.100-windows-x64-22d4d67b4166.zip",
9
+ "url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.100/stackbite-0.5.100-windows-x64-22d4d67b4166.zip",
10
+ "sha256": "95fdda902c8a15d36b17d279b0b653705bd07caa3d881cb488a89b57918ac532",
11
+ "size": 95973047,
12
+ "root": "stackbite-0.5.100-windows-x64-22d4d67b4166",
13
+ "manifestSha256": "cc332f8510d0f469fe5315d29c085321abb85334a69f06f7a5ebfef3f1f7d131",
14
+ "metadataSha256": "988f1d0c36abb2cf657781a762dbaee17259ba6c62f9769679d35fc6b44d1201"
15
15
  },
16
16
  "linux-x64": {
17
- "asset": "stackbite-0.5.98-linux-x86_64-4c23b408dfb8.tar.gz",
18
- "url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.98/stackbite-0.5.98-linux-x86_64-4c23b408dfb8.tar.gz",
19
- "sha256": "eaf429b2521cbbb7f7f3675379f855ec5f62510f10c1ab2f1d3f0dec413b6eb3",
20
- "size": 76072178,
21
- "root": "stackbite-0.5.98-linux-x86_64-4c23b408dfb8",
22
- "manifestSha256": "1542d61e5aaee722b99bfefe04274b11e6287caa6924dc4c5f2fc7157c5842d3",
23
- "metadataSha256": "5d0a1ec2ca0c26f3119bfc68e70bd102890313099e961ee7ba65f3da661eee8b"
17
+ "asset": "stackbite-0.5.100-linux-x86_64-22d4d67b4166.tar.gz",
18
+ "url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.100/stackbite-0.5.100-linux-x86_64-22d4d67b4166.tar.gz",
19
+ "sha256": "2487f3cdd8641cd773a57c5cdef678cc62e4e8fd3c5f1671a6800ff6776a8bc0",
20
+ "size": 76274251,
21
+ "root": "stackbite-0.5.100-linux-x86_64-22d4d67b4166",
22
+ "manifestSha256": "851174f3f59da84644bfcc6a95fc101a6d46a2ff9b15208b8b2f0b4bd70305cc",
23
+ "metadataSha256": "72199e271c9484163f23f676957e389a56c2becffd55488b8622772c843d24c3"
24
24
  },
25
25
  "darwin-arm64": {
26
- "asset": "stackbite-0.5.98-macos-arm64-4c23b408dfb8.tar.gz",
27
- "url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.98/stackbite-0.5.98-macos-arm64-4c23b408dfb8.tar.gz",
28
- "sha256": "56e4f20164b0163cde0be464349165944a7b333e223b1e4091ed60ba1476b1c2",
29
- "size": 31031692,
30
- "root": "stackbite-0.5.98-macos-arm64-4c23b408dfb8",
31
- "manifestSha256": "b833a1fbc7bbe6a8bdfa3aa69c9c2f8e35dc8be5a8bf9ed7d3fa2756e9316478",
32
- "metadataSha256": "93c06432dffb83f1afa72cda84e7f441482314fc67c2d511855b50ab4f014629"
26
+ "asset": "stackbite-0.5.100-macos-arm64-22d4d67b4166.tar.gz",
27
+ "url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.100/stackbite-0.5.100-macos-arm64-22d4d67b4166.tar.gz",
28
+ "sha256": "e517a93d50074c9d045471f5b6c5ac06ea85cb2ad26893374e7aed4a77b07de1",
29
+ "size": 31215045,
30
+ "root": "stackbite-0.5.100-macos-arm64-22d4d67b4166",
31
+ "manifestSha256": "0bf78ee881a3d868dddea0a864010f62c202bda38a1a0156ea6cf6240ac51da6",
32
+ "metadataSha256": "9d62c53e7a5a062469931fb49f7f40cf73d7cebb67c29d3e489fcc47516a3c9d"
33
33
  }
34
34
  }
35
35
  }