unbound-cli 1.6.3 → 1.6.4
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 +1 -1
- package/scripts/verify-nuke-ubuntu.sh +92 -0
- package/src/commands/setup.js +95 -2
- package/test/setup-args.test.js +178 -1
package/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# WEB-4922 end-to-end verification: `sudo unbound nuke` removes /opt/unbound
|
|
3
|
+
# (plus newsyslog conf + log dir) on Linux when invoked as root.
|
|
4
|
+
#
|
|
5
|
+
# Runs ENTIRELY inside a fresh ubuntu:24.04 container — the host's filesystem
|
|
6
|
+
# is mounted read-only and the repo is copied into the container before
|
|
7
|
+
# `npm link`, so the host's real /opt/unbound (if any) is never touched.
|
|
8
|
+
#
|
|
9
|
+
# Usage: ./scripts/verify-nuke-ubuntu.sh
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
13
|
+
|
|
14
|
+
if ! command -v docker >/dev/null 2>&1; then
|
|
15
|
+
echo "FAIL: docker not on PATH" >&2
|
|
16
|
+
exit 2
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
echo "Running WEB-4922 nuke verification inside ubuntu:24.04..."
|
|
20
|
+
echo "Host repo (mounted read-only): $REPO_ROOT"
|
|
21
|
+
echo
|
|
22
|
+
|
|
23
|
+
docker run --rm \
|
|
24
|
+
-v "$REPO_ROOT:/repo:ro" \
|
|
25
|
+
ubuntu:24.04 bash -c '
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
# Refuse to run on a host (e.g. if someone copy-pastes this heredoc into a
|
|
29
|
+
# terminal): /.dockerenv is created by the docker engine in every container
|
|
30
|
+
# but never exists on a real host.
|
|
31
|
+
[ -f /.dockerenv ] || { echo "FAIL: refusing to run outside a container" >&2; exit 99; }
|
|
32
|
+
|
|
33
|
+
echo "[setup] installing node + curl..."
|
|
34
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
35
|
+
apt-get update -qq >/dev/null
|
|
36
|
+
apt-get install -y -qq curl ca-certificates >/dev/null
|
|
37
|
+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1
|
|
38
|
+
apt-get install -y -qq nodejs >/dev/null
|
|
39
|
+
|
|
40
|
+
# Copy repo into a writable spot — host mount is read-only.
|
|
41
|
+
cp -R /repo /work
|
|
42
|
+
cd /work
|
|
43
|
+
echo "[setup] npm link..."
|
|
44
|
+
npm link --silent >/dev/null 2>&1
|
|
45
|
+
|
|
46
|
+
# Fake the pkg layout from setup/packaging/pkg/postinstall.
|
|
47
|
+
echo "[setup] faking /opt/unbound + newsyslog + log dir..."
|
|
48
|
+
mkdir -p /opt/unbound/0.1.5/unbound-hook
|
|
49
|
+
mkdir -p /opt/unbound/0.1.5/unbound-discovery
|
|
50
|
+
mkdir -p /opt/unbound/etc
|
|
51
|
+
echo "#!fake binary" > /opt/unbound/0.1.5/unbound-hook/unbound-hook
|
|
52
|
+
echo "#!fake binary" > /opt/unbound/0.1.5/unbound-discovery/unbound-discovery
|
|
53
|
+
echo "{}" > /opt/unbound/etc/discovery.json
|
|
54
|
+
ln -sfn /opt/unbound/0.1.5 /opt/unbound/current
|
|
55
|
+
mkdir -p /etc/newsyslog.d
|
|
56
|
+
echo "# rotate" > /etc/newsyslog.d/ai.getunbound.conf
|
|
57
|
+
mkdir -p /var/log/unbound
|
|
58
|
+
echo "old log" > /var/log/unbound/discovery.log
|
|
59
|
+
|
|
60
|
+
echo
|
|
61
|
+
echo "===== BEFORE ====="
|
|
62
|
+
ls -la /opt/unbound
|
|
63
|
+
ls -la /etc/newsyslog.d/ai.getunbound.conf
|
|
64
|
+
ls -la /var/log/unbound
|
|
65
|
+
echo
|
|
66
|
+
|
|
67
|
+
# EUID 0 inside the container, so hasRootPrivileges() returns true and the
|
|
68
|
+
# includeMdm branch (where removeBinaryInstall is called) runs.
|
|
69
|
+
# Per-tool MDM clears WILL fail (no real tool installs) — that is best-effort
|
|
70
|
+
# and must not abort the binary install removal.
|
|
71
|
+
echo "[run] unbound nuke --yes (per-tool clears may noisily fail; that is fine)"
|
|
72
|
+
unbound nuke --yes || true
|
|
73
|
+
|
|
74
|
+
echo
|
|
75
|
+
echo "===== AFTER ====="
|
|
76
|
+
ls -la /opt/unbound 2>/dev/null || echo "(gone) /opt/unbound"
|
|
77
|
+
ls -la /etc/newsyslog.d/ai.getunbound.conf 2>/dev/null || echo "(gone) /etc/newsyslog.d/ai.getunbound.conf"
|
|
78
|
+
ls -la /var/log/unbound 2>/dev/null || echo "(gone) /var/log/unbound"
|
|
79
|
+
echo
|
|
80
|
+
|
|
81
|
+
FAIL=0
|
|
82
|
+
[ ! -e /opt/unbound ] || { echo "FAIL: /opt/unbound survived"; FAIL=1; }
|
|
83
|
+
[ ! -e /etc/newsyslog.d/ai.getunbound.conf ] || { echo "FAIL: newsyslog conf survived"; FAIL=1; }
|
|
84
|
+
[ ! -e /var/log/unbound ] || { echo "FAIL: log dir survived"; FAIL=1; }
|
|
85
|
+
if [ "$FAIL" = "0" ]; then
|
|
86
|
+
echo "PASS: WEB-4922 Ubuntu container — all binary install artifacts removed"
|
|
87
|
+
exit 0
|
|
88
|
+
else
|
|
89
|
+
echo "FAIL: at least one artifact survived"
|
|
90
|
+
exit 1
|
|
91
|
+
fi
|
|
92
|
+
'
|
package/src/commands/setup.js
CHANGED
|
@@ -390,6 +390,85 @@ function clearUnboundEnvsEverywhere() {
|
|
|
390
390
|
return cleared;
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
// Layout written by the setup pkg's postinstall (setup repo @ 41ca7b2 —
|
|
394
|
+
// packaging/pkg/postinstall + pkg/ai.getunbound.discovery.plist). Hard-coded;
|
|
395
|
+
// never accept user input here. Frozen so a misbehaving test can't mutate the
|
|
396
|
+
// production paths for the rest of the process.
|
|
397
|
+
const BINARY_INSTALL_PATHS = Object.freeze({
|
|
398
|
+
optDir: '/opt/unbound',
|
|
399
|
+
daemonPlist: '/Library/LaunchDaemons/ai.getunbound.discovery.plist',
|
|
400
|
+
daemonLabel: 'ai.getunbound.discovery',
|
|
401
|
+
newsyslogConf: '/etc/newsyslog.d/ai.getunbound.conf',
|
|
402
|
+
logDir: '/var/log/unbound',
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Tears down the privileged binary install. Best-effort: a failure on any
|
|
406
|
+
// single path is logged via output.warn AND pushed onto the returned
|
|
407
|
+
// `failed` array, but never aborts the rest. Bootout the daemon FIRST so
|
|
408
|
+
// its supervisor doesn't relaunch mid-rm. Callers MUST gate this on root.
|
|
409
|
+
// Returns { removed, failed } — both labelled lists; nuke folds `failed`
|
|
410
|
+
// into its closing summary so a partial wipe doesn't print "success".
|
|
411
|
+
// Path overrides + `runLaunchctl` exist so tests stay hermetic — see
|
|
412
|
+
// test/setup-args.test.js. Safety net: any production-default path (or
|
|
413
|
+
// daemonLabel, when runLaunchctl is true) requires the caller to explicitly
|
|
414
|
+
// pass `_confirmProductionPaths:true` — dev machines often have a real
|
|
415
|
+
// install, and a forgotten override would wipe it. Production sets the
|
|
416
|
+
// flag; tests omit it and rely on the throw.
|
|
417
|
+
function removeBinaryInstall({
|
|
418
|
+
optDir = BINARY_INSTALL_PATHS.optDir,
|
|
419
|
+
daemonPlist = BINARY_INSTALL_PATHS.daemonPlist,
|
|
420
|
+
daemonLabel = BINARY_INSTALL_PATHS.daemonLabel,
|
|
421
|
+
newsyslogConf = BINARY_INSTALL_PATHS.newsyslogConf,
|
|
422
|
+
logDir = BINARY_INSTALL_PATHS.logDir,
|
|
423
|
+
runLaunchctl = process.platform === 'darwin',
|
|
424
|
+
_confirmProductionPaths = false,
|
|
425
|
+
} = {}) {
|
|
426
|
+
const usesProdDefaults =
|
|
427
|
+
optDir === BINARY_INSTALL_PATHS.optDir ||
|
|
428
|
+
daemonPlist === BINARY_INSTALL_PATHS.daemonPlist ||
|
|
429
|
+
newsyslogConf === BINARY_INSTALL_PATHS.newsyslogConf ||
|
|
430
|
+
logDir === BINARY_INSTALL_PATHS.logDir ||
|
|
431
|
+
(runLaunchctl && daemonLabel === BINARY_INSTALL_PATHS.daemonLabel);
|
|
432
|
+
if (usesProdDefaults && !_confirmProductionPaths) {
|
|
433
|
+
throw new Error('removeBinaryInstall: production-default path(s) used without _confirmProductionPaths:true — pass tmp overrides or set the flag explicitly');
|
|
434
|
+
}
|
|
435
|
+
const removed = [];
|
|
436
|
+
const failed = [];
|
|
437
|
+
if (runLaunchctl) {
|
|
438
|
+
// spawnSync does NOT throw on non-zero exit — it returns { status, error,
|
|
439
|
+
// stderr }. Treat "Could not find specified service" / "No such process"
|
|
440
|
+
// as the legitimate "daemon not loaded" case (first install, already
|
|
441
|
+
// booted out) and swallow it. Surface anything else so the operator knows
|
|
442
|
+
// the bootout-then-rm ordering may have lost the race.
|
|
443
|
+
const r = spawnSync('launchctl', ['bootout', `system/${daemonLabel}`], { stdio: 'pipe' });
|
|
444
|
+
const stderr = r.stderr ? r.stderr.toString().trim() : '';
|
|
445
|
+
if (r.error) {
|
|
446
|
+
output.warn(`nuke: launchctl bootout failed to spawn: ${r.error.message}`);
|
|
447
|
+
failed.push(`launchctl bootout ${daemonLabel}`);
|
|
448
|
+
} else if (r.status !== 0 && !/Could not find specified service|No such process/i.test(stderr)) {
|
|
449
|
+
output.warn(`nuke: launchctl bootout exited ${r.status}: ${stderr || '(no stderr)'}`);
|
|
450
|
+
failed.push(`launchctl bootout ${daemonLabel}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const targets = [
|
|
454
|
+
['LaunchDaemon plist', daemonPlist],
|
|
455
|
+
['newsyslog conf', newsyslogConf],
|
|
456
|
+
['log dir', logDir],
|
|
457
|
+
['install tree', optDir],
|
|
458
|
+
];
|
|
459
|
+
for (const [label, p] of targets) {
|
|
460
|
+
try {
|
|
461
|
+
if (!fs.existsSync(p)) continue;
|
|
462
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
463
|
+
removed.push(`${label} (${p})`);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
output.warn(`nuke: failed to remove ${label} (${p}): ${err.message}`);
|
|
466
|
+
failed.push(`${label} (${p})`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return { removed, failed };
|
|
470
|
+
}
|
|
471
|
+
|
|
393
472
|
/**
|
|
394
473
|
* Returns true when the process has the privileges needed to touch system-level
|
|
395
474
|
* (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
|
|
@@ -956,9 +1035,21 @@ Examples:
|
|
|
956
1035
|
|
|
957
1036
|
// MDM clears need root and touch all users — run them only when we have it.
|
|
958
1037
|
let mdmFailed = [];
|
|
1038
|
+
let binaryFailed = [];
|
|
959
1039
|
if (includeMdm) {
|
|
960
1040
|
const mdmTools = Object.keys(MDM_TOOLS).map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
961
1041
|
mdmFailed = await clearToolsBestEffort('mdm', mdmTools, { mdm: true, backendUrl, gatewayUrl });
|
|
1042
|
+
|
|
1043
|
+
// Root-only by construction (gated on includeMdm). No-op when no binary
|
|
1044
|
+
// install is present (python-only setups) and on Windows.
|
|
1045
|
+
// `_confirmProductionPaths` is the explicit "this is the real call site"
|
|
1046
|
+
// ack — the function refuses production defaults without it as a guard
|
|
1047
|
+
// against tests forgetting to override paths.
|
|
1048
|
+
const { removed: binaryRemoved, failed: bf } = removeBinaryInstall({ _confirmProductionPaths: true });
|
|
1049
|
+
binaryFailed = bf;
|
|
1050
|
+
if (binaryRemoved.length) {
|
|
1051
|
+
output.info(`Removed binary install: ${binaryRemoved.join('; ')}.`);
|
|
1052
|
+
}
|
|
962
1053
|
} else {
|
|
963
1054
|
output.info('Skipped MDM (system-level) config — that needs root. Re-run with sudo to remove it too.');
|
|
964
1055
|
}
|
|
@@ -975,12 +1066,12 @@ Examples:
|
|
|
975
1066
|
output.success('Stored credentials and settings removed.');
|
|
976
1067
|
|
|
977
1068
|
console.log('');
|
|
978
|
-
const failed = [...userFailed, ...mdmFailed];
|
|
1069
|
+
const failed = [...userFailed, ...mdmFailed, ...binaryFailed];
|
|
979
1070
|
const scope = includeMdm ? 'on this device' : 'for your user';
|
|
980
1071
|
if (failed.length === 0) {
|
|
981
1072
|
output.success(`Unbound removed ${scope}. The CLI is back to a fresh state — run "unbound login" to start over.`);
|
|
982
1073
|
} else {
|
|
983
|
-
output.warn(`Done ${scope}, but ${failed.length}
|
|
1074
|
+
output.warn(`Done ${scope}, but ${failed.length} step(s) reported issues: ${failed.join(', ')}. Credentials were still removed.`);
|
|
984
1075
|
}
|
|
985
1076
|
} catch (err) {
|
|
986
1077
|
output.error(err.message);
|
|
@@ -1049,4 +1140,6 @@ module.exports = {
|
|
|
1049
1140
|
resolveSetupAllTools,
|
|
1050
1141
|
clearUnboundEnvsEverywhere,
|
|
1051
1142
|
NUKE_ENV_VARS,
|
|
1143
|
+
removeBinaryInstall,
|
|
1144
|
+
BINARY_INSTALL_PATHS,
|
|
1052
1145
|
};
|
package/test/setup-args.test.js
CHANGED
|
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS } = require('../src/commands/setup');
|
|
6
|
+
const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS, removeBinaryInstall, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
|
|
7
7
|
|
|
8
8
|
// shellEscape single-quotes every value, so a real key surfaces as
|
|
9
9
|
// --api-key '<key>' at the head of the argv tail.
|
|
@@ -219,3 +219,180 @@ if (process.platform !== 'win32') {
|
|
|
219
219
|
}
|
|
220
220
|
});
|
|
221
221
|
}
|
|
222
|
+
|
|
223
|
+
// All assertions below route through path overrides into a tmp dir and force
|
|
224
|
+
// runLaunchctl:false — the real /opt/unbound + /Library/LaunchDaemons/... are
|
|
225
|
+
// never touched (the dev host has a real binary install).
|
|
226
|
+
function makeFakeBinaryInstall() {
|
|
227
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-'));
|
|
228
|
+
const optDir = path.join(tmp, 'opt', 'unbound');
|
|
229
|
+
const versionDir = path.join(optDir, '0.1.5');
|
|
230
|
+
fs.mkdirSync(path.join(versionDir, 'unbound-hook'), { recursive: true });
|
|
231
|
+
fs.mkdirSync(path.join(versionDir, 'unbound-discovery'), { recursive: true });
|
|
232
|
+
fs.writeFileSync(path.join(versionDir, 'unbound-hook', 'unbound-hook'), '#!fake binary\n');
|
|
233
|
+
fs.writeFileSync(path.join(versionDir, 'unbound-discovery', 'unbound-discovery'), '#!fake binary\n');
|
|
234
|
+
fs.mkdirSync(path.join(optDir, 'etc'), { recursive: true });
|
|
235
|
+
fs.writeFileSync(path.join(optDir, 'etc', 'discovery.json'), '{}');
|
|
236
|
+
fs.symlinkSync(versionDir, path.join(optDir, 'current'));
|
|
237
|
+
const daemonPlist = path.join(tmp, 'Library', 'LaunchDaemons', 'ai.getunbound.discovery.plist');
|
|
238
|
+
fs.mkdirSync(path.dirname(daemonPlist), { recursive: true });
|
|
239
|
+
fs.writeFileSync(daemonPlist, '<plist/>');
|
|
240
|
+
const newsyslogConf = path.join(tmp, 'etc', 'newsyslog.d', 'ai.getunbound.conf');
|
|
241
|
+
fs.mkdirSync(path.dirname(newsyslogConf), { recursive: true });
|
|
242
|
+
fs.writeFileSync(newsyslogConf, '# rotate\n');
|
|
243
|
+
const logDir = path.join(tmp, 'var', 'log', 'unbound');
|
|
244
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
245
|
+
fs.writeFileSync(path.join(logDir, 'discovery.log'), 'old logs\n');
|
|
246
|
+
return { tmp, optDir, daemonPlist, newsyslogConf, logDir };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
test('removeBinaryInstall: full install layout — wipes plist + newsyslog + logs + /opt/unbound', () => {
|
|
250
|
+
const fx = makeFakeBinaryInstall();
|
|
251
|
+
try {
|
|
252
|
+
const { removed, failed } = removeBinaryInstall({
|
|
253
|
+
optDir: fx.optDir,
|
|
254
|
+
daemonPlist: fx.daemonPlist,
|
|
255
|
+
newsyslogConf: fx.newsyslogConf,
|
|
256
|
+
logDir: fx.logDir,
|
|
257
|
+
runLaunchctl: false,
|
|
258
|
+
});
|
|
259
|
+
assert.equal(removed.length, 4, `expected 4 removed, got ${JSON.stringify(removed)}`);
|
|
260
|
+
assert.deepEqual(failed, [], `unexpected failures: ${JSON.stringify(failed)}`);
|
|
261
|
+
assert.ok(!fs.existsSync(fx.optDir), 'optDir survived');
|
|
262
|
+
assert.ok(!fs.existsSync(fx.daemonPlist), 'plist survived');
|
|
263
|
+
assert.ok(!fs.existsSync(fx.newsyslogConf), 'newsyslog survived');
|
|
264
|
+
assert.ok(!fs.existsSync(fx.logDir), 'logDir survived');
|
|
265
|
+
// Each label appears in the summary so `unbound nuke` output names what went.
|
|
266
|
+
for (const label of ['LaunchDaemon plist', 'newsyslog conf', 'log dir', 'install tree']) {
|
|
267
|
+
assert.ok(removed.some(s => s.startsWith(label)), `summary missing ${label}: ${removed.join('; ')}`);
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
fs.rmSync(fx.tmp, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('removeBinaryInstall: nothing present (python-only install) — returns empty lists silently', () => {
|
|
275
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-empty-'));
|
|
276
|
+
try {
|
|
277
|
+
const { removed, failed } = removeBinaryInstall({
|
|
278
|
+
optDir: path.join(tmp, 'opt', 'unbound'),
|
|
279
|
+
daemonPlist: path.join(tmp, 'Library', 'LaunchDaemons', 'ai.getunbound.discovery.plist'),
|
|
280
|
+
newsyslogConf: path.join(tmp, 'etc', 'newsyslog.d', 'ai.getunbound.conf'),
|
|
281
|
+
logDir: path.join(tmp, 'var', 'log', 'unbound'),
|
|
282
|
+
runLaunchctl: false,
|
|
283
|
+
});
|
|
284
|
+
assert.deepEqual(removed, []);
|
|
285
|
+
assert.deepEqual(failed, []);
|
|
286
|
+
} finally {
|
|
287
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('removeBinaryInstall: partial install (only /opt/unbound, no daemon/log) — removes what exists', () => {
|
|
292
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-partial-'));
|
|
293
|
+
const optDir = path.join(tmp, 'opt', 'unbound');
|
|
294
|
+
fs.mkdirSync(optDir, { recursive: true });
|
|
295
|
+
fs.writeFileSync(path.join(optDir, 'marker'), 'x');
|
|
296
|
+
try {
|
|
297
|
+
const { removed, failed } = removeBinaryInstall({
|
|
298
|
+
optDir,
|
|
299
|
+
daemonPlist: path.join(tmp, 'Library', 'LaunchDaemons', 'ai.getunbound.discovery.plist'),
|
|
300
|
+
newsyslogConf: path.join(tmp, 'etc', 'newsyslog.d', 'ai.getunbound.conf'),
|
|
301
|
+
logDir: path.join(tmp, 'var', 'log', 'unbound'),
|
|
302
|
+
runLaunchctl: false,
|
|
303
|
+
});
|
|
304
|
+
assert.equal(removed.length, 1);
|
|
305
|
+
assert.ok(removed[0].startsWith('install tree'), removed[0]);
|
|
306
|
+
assert.deepEqual(failed, []);
|
|
307
|
+
assert.ok(!fs.existsSync(optDir));
|
|
308
|
+
} finally {
|
|
309
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Bugbot: when a removal step fails (e.g. plist locked by launchd), it must
|
|
314
|
+
// land in the returned `failed` list AND not appear in `removed`, so the
|
|
315
|
+
// outer nuke summary surfaces a partial wipe instead of printing "success".
|
|
316
|
+
test('removeBinaryInstall: rm failure surfaces in `failed`, not `removed`', () => {
|
|
317
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-rmfail-'));
|
|
318
|
+
// Pass an existsSync-true path that fs.rmSync can't remove. The simplest
|
|
319
|
+
// way to force rmSync to throw is to point optDir at a regular file owned
|
|
320
|
+
// by another process — but cross-platform that's flaky. Instead, monkey-
|
|
321
|
+
// patch fs.rmSync just for this call to simulate the real-world EBUSY.
|
|
322
|
+
const optDir = path.join(tmp, 'opt', 'unbound');
|
|
323
|
+
fs.mkdirSync(optDir, { recursive: true });
|
|
324
|
+
const realRmSync = fs.rmSync;
|
|
325
|
+
fs.rmSync = (p, opts) => {
|
|
326
|
+
if (p === optDir) throw new Error('EBUSY: resource busy or locked');
|
|
327
|
+
return realRmSync(p, opts);
|
|
328
|
+
};
|
|
329
|
+
try {
|
|
330
|
+
const { removed, failed } = removeBinaryInstall({
|
|
331
|
+
optDir,
|
|
332
|
+
daemonPlist: path.join(tmp, 'plist'),
|
|
333
|
+
newsyslogConf: path.join(tmp, 'newsyslog'),
|
|
334
|
+
logDir: path.join(tmp, 'log'),
|
|
335
|
+
runLaunchctl: false,
|
|
336
|
+
});
|
|
337
|
+
assert.deepEqual(removed, [], 'install tree must not be in removed when rmSync threw');
|
|
338
|
+
assert.equal(failed.length, 1, `expected 1 failure, got ${JSON.stringify(failed)}`);
|
|
339
|
+
assert.ok(failed[0].startsWith('install tree'), failed[0]);
|
|
340
|
+
} finally {
|
|
341
|
+
fs.rmSync = realRmSync;
|
|
342
|
+
realRmSync(tmp, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Guards against accidental drift of the hard-coded paths.
|
|
347
|
+
test('BINARY_INSTALL_PATHS: defaults match setup/packaging/pkg/postinstall layout', () => {
|
|
348
|
+
assert.equal(BINARY_INSTALL_PATHS.optDir, '/opt/unbound');
|
|
349
|
+
assert.equal(BINARY_INSTALL_PATHS.daemonPlist, '/Library/LaunchDaemons/ai.getunbound.discovery.plist');
|
|
350
|
+
assert.equal(BINARY_INSTALL_PATHS.daemonLabel, 'ai.getunbound.discovery');
|
|
351
|
+
assert.equal(BINARY_INSTALL_PATHS.newsyslogConf, '/etc/newsyslog.d/ai.getunbound.conf');
|
|
352
|
+
assert.equal(BINARY_INSTALL_PATHS.logDir, '/var/log/unbound');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Safety net: dev machines often have a real /opt/unbound install. Calling
|
|
356
|
+
// removeBinaryInstall with ANY production-default path requires the explicit
|
|
357
|
+
// `_confirmProductionPaths:true` ack, so a no-override test (or refactor
|
|
358
|
+
// drift) fails loudly instead of wiping the dev's real install. This works
|
|
359
|
+
// regardless of NODE_ENV — production sets the ack, tests don't.
|
|
360
|
+
test('removeBinaryInstall: refuses production-default paths without _confirmProductionPaths', () => {
|
|
361
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-guard-'));
|
|
362
|
+
try {
|
|
363
|
+
assert.throws(() => removeBinaryInstall(),
|
|
364
|
+
/_confirmProductionPaths/, 'no-arg call must throw');
|
|
365
|
+
// Partial override (logDir left at production) must throw.
|
|
366
|
+
assert.throws(() => removeBinaryInstall({
|
|
367
|
+
optDir: path.join(tmp, 'opt', 'unbound'),
|
|
368
|
+
daemonPlist: path.join(tmp, 'plist'),
|
|
369
|
+
newsyslogConf: path.join(tmp, 'newsyslog'),
|
|
370
|
+
runLaunchctl: false,
|
|
371
|
+
}), /_confirmProductionPaths/, 'partial override leaving logDir defaulted must throw');
|
|
372
|
+
// Greptile P2: daemonLabel must be checked when runLaunchctl is true.
|
|
373
|
+
// All four paths overridden, but default daemonLabel + runLaunchctl:true
|
|
374
|
+
// → would call `launchctl bootout system/ai.getunbound.discovery` on the
|
|
375
|
+
// real host. Must throw.
|
|
376
|
+
assert.throws(() => removeBinaryInstall({
|
|
377
|
+
optDir: path.join(tmp, 'opt', 'unbound'),
|
|
378
|
+
daemonPlist: path.join(tmp, 'plist'),
|
|
379
|
+
newsyslogConf: path.join(tmp, 'newsyslog'),
|
|
380
|
+
logDir: path.join(tmp, 'log'),
|
|
381
|
+
runLaunchctl: true,
|
|
382
|
+
}), /_confirmProductionPaths/, 'default daemonLabel with runLaunchctl:true must throw');
|
|
383
|
+
// Full override with runLaunchctl:false does NOT throw (no prod surface).
|
|
384
|
+
assert.doesNotThrow(() => removeBinaryInstall({
|
|
385
|
+
optDir: path.join(tmp, 'opt', 'unbound'),
|
|
386
|
+
daemonPlist: path.join(tmp, 'plist'),
|
|
387
|
+
newsyslogConf: path.join(tmp, 'newsyslog'),
|
|
388
|
+
logDir: path.join(tmp, 'log'),
|
|
389
|
+
runLaunchctl: false,
|
|
390
|
+
}));
|
|
391
|
+
} finally {
|
|
392
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('BINARY_INSTALL_PATHS: is frozen', () => {
|
|
397
|
+
assert.ok(Object.isFrozen(BINARY_INSTALL_PATHS));
|
|
398
|
+
});
|