node-command 2.0.10 → 2.0.12

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 (2) hide show
  1. package/lib/setup.mjs +97 -12
  2. package/package.json +5 -12
package/lib/setup.mjs CHANGED
@@ -296,7 +296,16 @@ function linuxPairedInstallation(io = fs) {
296
296
  } finally { io.closeSync(fd); }
297
297
  };
298
298
  check(app, true);
299
- if (io.readdirSync(helperRoot + '/libexec').sort().join('\n') !== 'nc-linux-peercred\nprivileged-helper') fail();
299
+ const libexec = helperRoot + '/libexec';
300
+ const requiredLibexecEntries = ['nc-linux-peercred', 'privileged-helper'];
301
+ const managedSealedSnapshot = name => {
302
+ if (!/^privileged-helper-sealed-[0-9a-f]{64}$/.test(name)) return false;
303
+ const st = lstat(libexec + '/' + name);
304
+ return st.isDirectory() && !st.isSymbolicLink() && st.uid === 0 && st.gid === 0 && (st.mode & 0o7777) === 0o700;
305
+ };
306
+ const libexecEntries = io.readdirSync(libexec).sort();
307
+ if (!requiredLibexecEntries.every(name => libexecEntries.includes(name)) ||
308
+ !libexecEntries.every(name => requiredLibexecEntries.includes(name) || managedSealedSnapshot(name))) fail();
300
309
  check(helperRoot + '/libexec/nc-linux-peercred');
301
310
  const bootstrap = JSON.parse(read(app + '/helper-bootstrap.json'));
302
311
  const selection = JSON.parse(read('/var/lib/nc-lifecycle/selected.json'));
@@ -470,6 +479,42 @@ const PARTIAL_SYSTEM_FORBIDDEN_PATHS = [
470
479
  '/var/lib/nodecommand/nc'
471
480
  ];
472
481
 
482
+ /** Files a rejected/expired enrollment may leave in the system config tree. */
483
+ const PARTIAL_ENROLLMENT_CONFIG_ENTRIES = new Set(['.nc-cli.lock', 'nc-device-key.pem', 'nc-enroll-state.json']);
484
+
485
+ /** The exact residue that identifies the narrow recoverable enrollment shape. */
486
+ const PARTIAL_ENROLLMENT_REQUIRED = ['nc-device-key.pem', 'nc-enroll-state.json'];
487
+
488
+ /**
489
+ * True only for the exact canonical system root carrying the residue of a
490
+ * rejected/expired enrollment: a root-owned config directory with the device
491
+ * key and enrollment state, no host environment, and no application payload.
492
+ * Discovery uses this so the abandoned tree is a removal target; removal
493
+ * revalidates every entry before deleting anything.
494
+ * @param {string} root
495
+ * @param {typeof fs} [io]
496
+ */
497
+ function partialEnrollmentSystemRoot(root, io = fs) {
498
+ if (path.resolve(root) !== PARTIAL_SYSTEM_ROOT) return false;
499
+ const lstat = io.lstatSync || fs.lstatSync;
500
+ const readdir = io.readdirSync || fs.readdirSync;
501
+ try {
502
+ if (!readdir(PARTIAL_SYSTEM_ROOT).every((name) => PARTIAL_SYSTEM_ENTRIES.has(name))) return false;
503
+ const config = path.join(PARTIAL_SYSTEM_ROOT, 'config');
504
+ const configInfo = lstat(config);
505
+ if (configInfo.isSymbolicLink() || !configInfo.isDirectory()) return false;
506
+ const names = readdir(config);
507
+ if (!names.every((name) => PARTIAL_ENROLLMENT_CONFIG_ENTRIES.has(name))) return false;
508
+ if (!PARTIAL_ENROLLMENT_REQUIRED.every((name) => names.includes(name))) return false;
509
+ return PARTIAL_ENROLLMENT_REQUIRED.every((name) => {
510
+ const entry = lstat(path.join(config, name));
511
+ return !entry.isSymbolicLink() && entry.isFile();
512
+ });
513
+ } catch {
514
+ return false;
515
+ }
516
+ }
517
+
473
518
  /**
474
519
  * True when `root` is the canonical per-user tree for the current account and
475
520
  * is not a protected system root. This is the cheap path/ownership gate; the
@@ -513,8 +558,43 @@ export function isPartialSystemRemovalCandidate(root, { platform = os.platform()
513
558
  return path.resolve(root) === PARTIAL_SYSTEM_ROOT;
514
559
  }
515
560
 
561
+ /** A real installation is proven by a readable, owner-matching host env. */
562
+ function requireHostEnvironment(root, { uid, io, refused, owner }) {
563
+ for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
564
+ const info = io.lstatSync(path.join(root, file));
565
+ if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
566
+ throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
567
+ }
568
+ if (info.uid !== uid) throw refused(`the ${file} marker is not owned by ${owner}`);
569
+ }
570
+ io.accessSync(path.join(root, 'config', 'nc-client.env'), fs.constants.R_OK);
571
+ }
572
+
573
+ /**
574
+ * A rejected/expired enrollment leaves only the device key and enrollment
575
+ * state (plus this process's own CLI lock) in config/. A host environment, an
576
+ * app payload or any unknown file means this is not the narrow recoverable
577
+ * shape, so removal fails closed.
578
+ */
579
+ function requirePartialEnrollment(root, { uid, io, refused, owner }) {
580
+ const config = path.join(root, 'config');
581
+ const info = io.lstatSync(config);
582
+ if (info.isSymbolicLink() || !info.isDirectory()) throw refused('the config marker is missing or not a regular directory');
583
+ if (info.uid !== uid) throw refused(`the config marker is not owned by ${owner}`);
584
+ const names = io.readdirSync(config);
585
+ for (const name of names) {
586
+ if (!PARTIAL_ENROLLMENT_CONFIG_ENTRIES.has(name)) throw refused(`unexpected config entry "${name}"`);
587
+ const entry = io.lstatSync(path.join(config, name));
588
+ if (entry.isSymbolicLink() || !entry.isFile()) throw refused(`config entry "${name}" is not a regular file`);
589
+ if (entry.uid !== uid) throw refused(`config entry "${name}" is not owned by ${owner}`);
590
+ }
591
+ for (const name of PARTIAL_ENROLLMENT_REQUIRED) {
592
+ if (!names.includes(name)) throw refused(`the partial-enrollment marker "${name}" is missing`);
593
+ }
594
+ }
595
+
516
596
  /** Shared bounded removal of an already-authorized root. */
517
- function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
597
+ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner, verifyConfig = requireHostEnvironment }) {
518
598
  const resolved = path.resolve(root);
519
599
  if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
520
600
  const rootInfo = io.lstatSync(resolved);
@@ -527,14 +607,7 @@ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
527
607
  if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
528
608
  if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by ${owner}`);
529
609
  }
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);
610
+ verifyConfig(resolved, { uid, io, refused, owner });
538
611
  for (const name of entries) {
539
612
  io.rmSync(path.join(resolved, name), { recursive: true, force: true });
540
613
  }
@@ -600,12 +673,22 @@ export function removePartialSystemRoot(root, { platform = os.platform(), getuid
600
673
  }
601
674
  throw refused(`${file} exists; a real installation requires its packaged maintenance`);
602
675
  }
603
- removeBoundedRoot(path.resolve(root), {
676
+ const resolved = path.resolve(root);
677
+ const hostEnvMarker = path.join(resolved, 'config', 'nc-client.env');
678
+ let verifyConfig = requireHostEnvironment;
679
+ try {
680
+ io.lstatSync(hostEnvMarker);
681
+ } catch (err) {
682
+ if (err?.code !== 'ENOENT') throw refused(`the host environment marker at ${hostEnvMarker} cannot be verified`);
683
+ verifyConfig = requirePartialEnrollment;
684
+ }
685
+ removeBoundedRoot(resolved, {
604
686
  uid: 0,
605
687
  allowedEntries: PARTIAL_SYSTEM_ENTRIES,
606
688
  io,
607
689
  refused,
608
- owner: 'root'
690
+ owner: 'root',
691
+ verifyConfig
609
692
  });
610
693
  } catch (err) {
611
694
  if (err?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw err;
@@ -710,6 +793,8 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
710
793
  const status = probeRemovalRoot(root, io);
711
794
  if (status === 'install' && !install.includes(root)) install.push(root);
712
795
  else if (status === 'unreadable' && !unreadable.includes(root)) unreadable.push(root);
796
+ else if (status === 'foreign' && !install.includes(root) && isPartialSystemRemovalCandidate(root, rest) &&
797
+ partialEnrollmentSystemRoot(root, io)) install.push(root);
713
798
  }
714
799
  // The paired relation is only actionable when its root is itself readable.
715
800
  // An unverifiable/foreign paired root is reported through `unreadable`.
package/package.json CHANGED
@@ -1,24 +1,17 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.10",
4
- "description": "Node Command CLI installer for the Node Command agent",
3
+ "version": "2.0.12",
5
4
  "type": "module",
6
5
  "license": "Apache-2.0",
7
6
  "engines": {
8
7
  "node": ">=24"
9
8
  },
10
9
  "bin": {
11
- "nc": "bin/nc.mjs",
12
- "nc-agent": "bin/nc.mjs",
13
- "nodecommand": "bin/nc.mjs",
14
- "node-command": "bin/nc.mjs"
10
+ "nc": "./bin/nc.mjs",
11
+ "nodecommand": "./bin/nc.mjs"
15
12
  },
16
13
  "files": [
17
14
  "bin/",
18
- "lib/",
19
- "package.json"
20
- ],
21
- "publishConfig": {
22
- "access": "public"
23
- }
15
+ "lib/"
16
+ ]
24
17
  }