node-command 2.0.8 → 2.0.9

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.
Files changed (3) hide show
  1. package/bin/nc.mjs +23 -9
  2. package/lib/setup.mjs +111 -23
  3. package/package.json +1 -1
package/bin/nc.mjs CHANGED
@@ -21,9 +21,11 @@ import {
21
21
  getNcRootForMode,
22
22
  installerEnvForMode,
23
23
  isElevated,
24
+ isPartialSystemRemovalCandidate,
24
25
  isPartialUserRemovalCandidate,
25
26
  isUserInstallRoot,
26
27
  probeInstallRoot,
28
+ removePartialSystemRoot,
27
29
  removePartialUserRoot,
28
30
  resolveExecutable,
29
31
  setupTarget,
@@ -2960,11 +2962,17 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2960
2962
  // A failed per-user install can leave its own tree (config plus partial
2961
2963
  // payload) without the packaged uninstaller. Only the canonical user root
2962
2964
  // is eligible, and the bounded removal revalidates ancestry, ownership and
2963
- // every top-level entry. System/protected roots never use this fallback.
2965
+ // every top-level entry. Real system installs never use this fallback;
2966
+ // the exact /opt/nc partial tree has its own narrower root-only exception.
2964
2967
  const scriptExists = pair || fs.existsSync(script);
2965
- const boundedUserTree = !scriptExists &&
2966
- (command === 'uninstall' || command === 'remove') && isPartialUserRemovalCandidate(root);
2967
- if (!scriptExists && !boundedUserTree) {
2968
+ const explicitRemoval = command === 'uninstall' || command === 'remove';
2969
+ const partialUserTree = !scriptExists && explicitRemoval && isPartialUserRemovalCandidate(root);
2970
+ // The narrow system exception is /opt/nc only, requires real+effective
2971
+ // root, and refuses app/, uninstall.sh, units, libexec/ and lifecycle
2972
+ // metadata inside removePartialSystemRoot.
2973
+ const partialSystemTree = !scriptExists && explicitRemoval && isPartialSystemRemovalCandidate(root);
2974
+ const boundedTree = partialUserTree || partialSystemTree;
2975
+ if (!scriptExists && !boundedTree) {
2968
2976
  console.error(`error: [UNINSTALLER_MISSING] uninstaller not found at ${script}; skipping ${root}.`);
2969
2977
  console.error(uninstallFailureSuggestion());
2970
2978
  failed += 1;
@@ -2979,13 +2987,19 @@ async function runRemoval(args, { command, emptyMessage, elevateOnWindows = fals
2979
2987
  continue;
2980
2988
  }
2981
2989
  try {
2982
- if (boundedUserTree) {
2990
+ if (boundedTree) {
2983
2991
  try {
2984
- removePartialUserRoot(root);
2985
- console.log(`Removed partial per-user Node Command installation at ${root} (no packaged uninstaller was present).`);
2992
+ if (partialSystemTree) removePartialSystemRoot(root);
2993
+ else removePartialUserRoot(root);
2994
+ console.log(partialSystemTree
2995
+ ? `Removed partial system Node Command installation at ${root} (no packaged uninstaller, unit or application payload).`
2996
+ : `Removed partial per-user Node Command installation at ${root} (no packaged uninstaller was present).`);
2986
2997
  } catch (err) {
2987
- console.error(`error: [USER_TREE_REMOVAL_REFUSED] ${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 300)}`);
2988
- console.error('suggested_action: The tree was left untouched. Repair it with `nodecommand --user`, or remove it yourself after confirming it is your own Node Command directory.');
2998
+ const code = typeof err?.code === 'string' && /_REFUSED$/.test(err.code) ? err.code : 'PARTIAL_ROOT_REMOVAL_REFUSED';
2999
+ console.error(`error: [${code}] ${sanitizeForDisplay(err instanceof Error ? err.message : String(err), 300)}`);
3000
+ console.error(partialSystemTree
3001
+ ? 'suggested_action: The system tree was left untouched. Inspect /opt/nc and complete or repair the failed system installation through explicit root maintenance; credentials/config were preserved.'
3002
+ : 'suggested_action: The tree was left untouched. Repair it with `nodecommand --user`, or remove it yourself after confirming it is your own Node Command directory.');
2989
3003
  failed += 1;
2990
3004
  }
2991
3005
  continue;
package/lib/setup.mjs CHANGED
@@ -450,6 +450,26 @@ const USER_INSTALL_ENTRIES = new Set([
450
450
  'uninstall.sh'
451
451
  ]);
452
452
 
453
+ /** Canonical system root eligible only for a narrow, root-only cleanup. */
454
+ export const PARTIAL_SYSTEM_ROOT = '/opt/nc';
455
+
456
+ /** A failed system install may leave only these payload/config directories. */
457
+ const PARTIAL_SYSTEM_ENTRIES = new Set(['config', 'tools', 'logs', 'tmp', 'releases', 'backups']);
458
+
459
+ /** Artifacts that prove a real system installation exists; never bypassed. */
460
+ const PARTIAL_SYSTEM_FORBIDDEN_PATHS = [
461
+ '/etc/systemd/system/nc-client.service',
462
+ '/etc/systemd/system/nc-client.service.d',
463
+ '/etc/systemd/user/nc-client.service',
464
+ '/etc/systemd/user/nc-client.service.d',
465
+ '/etc/systemd/system/multi-user.target.wants/nc-client.service',
466
+ '/etc/systemd/system/nc-privileged-helper.service',
467
+ '/etc/systemd/system/nc-privileged-helper.socket',
468
+ '/etc/tmpfiles.d/nc-privileged-helper.conf',
469
+ '/var/lib/nc-lifecycle',
470
+ '/var/lib/nodecommand/nc'
471
+ ];
472
+
453
473
  /**
454
474
  * True when `root` is the canonical per-user tree for the current account and
455
475
  * is not a protected system root. This is the cheap path/ownership gate; the
@@ -475,6 +495,52 @@ export function isPartialUserRemovalCandidate(root, { platform = os.platform(),
475
495
  );
476
496
  }
477
497
 
498
+ /**
499
+ * True only for the exact Linux system root when the process holds real AND
500
+ * effective root. The tree contents and every real-install artifact are
501
+ * checked again inside `removePartialSystemRoot`.
502
+ * @param {string} root
503
+ * @param {{ platform?: string, getuid?: (() => number)|null, geteuid?: (() => number)|null }} [options]
504
+ */
505
+ export function isPartialSystemRemovalCandidate(root, { platform = os.platform(), getuid = process.getuid, geteuid = process.geteuid } = {}) {
506
+ if (platform !== 'linux') return false;
507
+ if (typeof getuid !== 'function' || typeof geteuid !== 'function') return false;
508
+ try {
509
+ if (getuid() !== 0 || geteuid() !== 0) return false;
510
+ } catch {
511
+ return false;
512
+ }
513
+ return path.resolve(root) === PARTIAL_SYSTEM_ROOT;
514
+ }
515
+
516
+ /** Shared bounded removal of an already-authorized root. */
517
+ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
518
+ const resolved = path.resolve(root);
519
+ if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
520
+ const rootInfo = io.lstatSync(resolved);
521
+ if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw refused('the root is not a regular directory');
522
+ if (rootInfo.uid !== uid) throw refused(`the root is not owned by ${owner}`);
523
+ const entries = io.readdirSync(resolved);
524
+ for (const name of entries) {
525
+ if (!allowedEntries.has(name)) throw refused(`unexpected top-level entry "${name}"`);
526
+ const info = io.lstatSync(path.join(resolved, name));
527
+ if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
528
+ if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by ${owner}`);
529
+ }
530
+ for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
531
+ const info = io.lstatSync(path.join(resolved, file));
532
+ if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
533
+ throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
534
+ }
535
+ if (info.uid !== uid) throw refused(`the ${file} marker is not owned by ${owner}`);
536
+ }
537
+ io.accessSync(path.join(resolved, 'config', 'nc-client.env'), fs.constants.R_OK);
538
+ for (const name of entries) {
539
+ io.rmSync(path.join(resolved, name), { recursive: true, force: true });
540
+ }
541
+ io.rmdirSync(resolved);
542
+ }
543
+
478
544
  /**
479
545
  * Bounded removal of a per-user Node Command tree whose packaged `uninstall.sh`
480
546
  * is missing (a partial/failed install). Only the canonical per-user root is
@@ -494,33 +560,55 @@ export function removePartialUserRoot(root, { platform = os.platform(), env = pr
494
560
  if (!isPartialUserRemovalCandidate(root, { platform, env, homedir, uid })) {
495
561
  throw refused('it is not the canonical per-user Node Command root');
496
562
  }
497
- const resolved = path.resolve(root);
498
563
  try {
499
- if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
500
- const rootInfo = io.lstatSync(resolved);
501
- if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) throw refused('the root is not a regular directory');
502
- if (rootInfo.uid !== uid) throw refused('the root is not owned by the current user');
503
- const entries = io.readdirSync(resolved);
504
- for (const name of entries) {
505
- if (!USER_INSTALL_ENTRIES.has(name)) throw refused(`unexpected top-level entry "${name}"`);
506
- const info = io.lstatSync(path.join(resolved, name));
507
- if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
508
- if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by the current user`);
509
- }
510
- for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
511
- const info = io.lstatSync(path.join(resolved, file));
512
- if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
513
- throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
564
+ removeBoundedRoot(root, {
565
+ uid,
566
+ allowedEntries: USER_INSTALL_ENTRIES,
567
+ io,
568
+ refused,
569
+ owner: 'the current user'
570
+ });
571
+ } catch (err) {
572
+ if (err?.code === 'USER_TREE_REMOVAL_REFUSED') throw err;
573
+ throw refused(err instanceof Error ? err.message : 'the tree could not be verified');
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Root-only bounded removal of a failed SYSTEM install at /opt/nc. This is not
579
+ * a general system-root fallback: it requires real+effective root, the exact
580
+ * canonical root, no unit/helper/lifecycle artifact, and only the known partial
581
+ * payload directories. A real or unverifiable installation stays fail-closed.
582
+ * @param {string} root
583
+ * @param {{ platform?: string, getuid?: (() => number)|null, geteuid?: (() => number)|null, io?: typeof fs }} [options]
584
+ */
585
+ export function removePartialSystemRoot(root, { platform = os.platform(), getuid = process.getuid, geteuid = process.geteuid, io = fs } = {}) {
586
+ const refused = (reason) => Object.assign(
587
+ new Error(`Refusing bounded removal of ${root}: ${reason}.`),
588
+ { code: 'PARTIAL_SYSTEM_REMOVAL_REFUSED' }
589
+ );
590
+ if (!isPartialSystemRemovalCandidate(root, { platform, getuid, geteuid })) {
591
+ throw refused('it is not the canonical partial system root');
592
+ }
593
+ try {
594
+ for (const file of PARTIAL_SYSTEM_FORBIDDEN_PATHS) {
595
+ try {
596
+ io.lstatSync(file);
597
+ } catch (err) {
598
+ if (err?.code === 'ENOENT') continue;
599
+ throw refused(`existing installation state at ${file} cannot be verified`);
514
600
  }
515
- if (info.uid !== uid) throw refused(`the ${file} marker is not owned by the current user`);
601
+ throw refused(`${file} exists; a real installation requires its packaged maintenance`);
516
602
  }
517
- io.accessSync(path.join(resolved, 'config', 'nc-client.env'), fs.constants.R_OK);
518
- for (const name of entries) {
519
- io.rmSync(path.join(resolved, name), { recursive: true, force: true });
520
- }
521
- io.rmdirSync(resolved);
603
+ removeBoundedRoot(path.resolve(root), {
604
+ uid: 0,
605
+ allowedEntries: PARTIAL_SYSTEM_ENTRIES,
606
+ io,
607
+ refused,
608
+ owner: 'root'
609
+ });
522
610
  } catch (err) {
523
- if (err?.code === 'USER_TREE_REMOVAL_REFUSED') throw err;
611
+ if (err?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw err;
524
612
  throw refused(err instanceof Error ? err.message : 'the tree could not be verified');
525
613
  }
526
614
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.8",
3
+ "version": "2.0.9",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {