node-command 2.0.11 → 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 +87 -11
  2. package/package.json +1 -1
package/lib/setup.mjs CHANGED
@@ -479,6 +479,42 @@ const PARTIAL_SYSTEM_FORBIDDEN_PATHS = [
479
479
  '/var/lib/nodecommand/nc'
480
480
  ];
481
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
+
482
518
  /**
483
519
  * True when `root` is the canonical per-user tree for the current account and
484
520
  * is not a protected system root. This is the cheap path/ownership gate; the
@@ -522,8 +558,43 @@ export function isPartialSystemRemovalCandidate(root, { platform = os.platform()
522
558
  return path.resolve(root) === PARTIAL_SYSTEM_ROOT;
523
559
  }
524
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
+
525
596
  /** Shared bounded removal of an already-authorized root. */
526
- function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
597
+ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner, verifyConfig = requireHostEnvironment }) {
527
598
  const resolved = path.resolve(root);
528
599
  if (io.realpathSync(resolved) !== resolved) throw refused('a symlinked ancestor is not removable');
529
600
  const rootInfo = io.lstatSync(resolved);
@@ -536,14 +607,7 @@ function removeBoundedRoot(root, { uid, allowedEntries, io, refused, owner }) {
536
607
  if (info.isSymbolicLink()) throw refused(`top-level entry "${name}" is a symlink`);
537
608
  if (info.uid !== uid) throw refused(`top-level entry "${name}" is not owned by ${owner}`);
538
609
  }
539
- for (const [file, directory] of [['config', true], [path.join('config', 'nc-client.env'), false]]) {
540
- const info = io.lstatSync(path.join(resolved, file));
541
- if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
542
- throw refused(`the ${file} marker is missing or not a regular ${directory ? 'directory' : 'file'}`);
543
- }
544
- if (info.uid !== uid) throw refused(`the ${file} marker is not owned by ${owner}`);
545
- }
546
- io.accessSync(path.join(resolved, 'config', 'nc-client.env'), fs.constants.R_OK);
610
+ verifyConfig(resolved, { uid, io, refused, owner });
547
611
  for (const name of entries) {
548
612
  io.rmSync(path.join(resolved, name), { recursive: true, force: true });
549
613
  }
@@ -609,12 +673,22 @@ export function removePartialSystemRoot(root, { platform = os.platform(), getuid
609
673
  }
610
674
  throw refused(`${file} exists; a real installation requires its packaged maintenance`);
611
675
  }
612
- 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, {
613
686
  uid: 0,
614
687
  allowedEntries: PARTIAL_SYSTEM_ENTRIES,
615
688
  io,
616
689
  refused,
617
- owner: 'root'
690
+ owner: 'root',
691
+ verifyConfig
618
692
  });
619
693
  } catch (err) {
620
694
  if (err?.code === 'PARTIAL_SYSTEM_REMOVAL_REFUSED') throw err;
@@ -719,6 +793,8 @@ export function findRemovalTargets({ io = {}, ...rest } = {}) {
719
793
  const status = probeRemovalRoot(root, io);
720
794
  if (status === 'install' && !install.includes(root)) install.push(root);
721
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);
722
798
  }
723
799
  // The paired relation is only actionable when its root is itself readable.
724
800
  // An unverifiable/foreign paired root is reported through `unreadable`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-command",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {