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.
- package/bin/nc.mjs +23 -9
- package/lib/setup.mjs +111 -23
- 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.
|
|
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
|
|
2966
|
-
|
|
2967
|
-
|
|
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 (
|
|
2990
|
+
if (boundedTree) {
|
|
2983
2991
|
try {
|
|
2984
|
-
|
|
2985
|
-
|
|
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
|
-
|
|
2988
|
-
console.error(
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
601
|
+
throw refused(`${file} exists; a real installation requires its packaged maintenance`);
|
|
516
602
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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 === '
|
|
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
|
}
|