@celilo/cli 1.12.0 → 1.14.0
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/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +21 -2
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +24 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +130 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +6 -0
- package/src/cli/index.ts +31 -1
- package/src/console/closure.test.ts +322 -0
- package/src/console/closure.ts +294 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +293 -0
- package/src/console/projection.ts +364 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +67 -10
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/schema.ts +35 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +126 -4
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/module/web-root.ts +35 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +26 -2
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +32 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +252 -0
- package/src/services/api-principal-enrolment.ts +158 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-create.ts +33 -7
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
- package/src/services/restore-from-file.ts +6 -5
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
5
|
import { moduleStateDir } from '@celilo/capabilities';
|
|
6
6
|
import { classifyModulePath } from '../module/packaging/package-rules';
|
|
7
7
|
import { invokeHook } from './executor';
|
|
@@ -25,9 +25,21 @@ const silent: HookLogger = {
|
|
|
25
25
|
success() {},
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
/** The workspace copy of the package every module bundles. */
|
|
29
|
+
const CAPABILITIES_PACKAGE = resolve(import.meta.dir, '../../../../packages/capabilities');
|
|
30
|
+
|
|
28
31
|
function scratchModule(script: string): string {
|
|
29
32
|
const root = mkdtempSync(join(tmpdir(), 'celilo-statedir-'));
|
|
30
33
|
Bun.write(join(root, 'hook.ts'), script);
|
|
34
|
+
// A real module in the store carries its OWN bundled @celilo/capabilities
|
|
35
|
+
// (modules run their bundled copy, celilo#173), and the hook jail binds the
|
|
36
|
+
// module's tree and nothing above it. This fixture used to resolve the
|
|
37
|
+
// import through the test runner's own tree, which worked only because hooks
|
|
38
|
+
// ran unjailed; `--chdir` into the module root removed that accident.
|
|
39
|
+
// Linking it here is what makes the fixture a module rather than a loose
|
|
40
|
+
// script.
|
|
41
|
+
mkdirSync(join(root, 'node_modules', '@celilo'), { recursive: true });
|
|
42
|
+
symlinkSync(CAPABILITIES_PACKAGE, join(root, 'node_modules', '@celilo', 'capabilities'));
|
|
31
43
|
return root;
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -74,8 +74,7 @@ describe('hook timeout is a kill, not a race', () => {
|
|
|
74
74
|
executeHookScript(
|
|
75
75
|
join(FIXTURES, 'runaway-hook.ts'),
|
|
76
76
|
contextFor({ sleep_ms: 1500, marker_path: marker }),
|
|
77
|
-
400,
|
|
78
|
-
400,
|
|
77
|
+
{ timeoutMs: 400, idleTimeoutMs: 400 },
|
|
79
78
|
),
|
|
80
79
|
).rejects.toThrow(/timeout/i);
|
|
81
80
|
|
|
@@ -92,8 +91,7 @@ describe('hook timeout is a kill, not a race', () => {
|
|
|
92
91
|
executeHookScript(
|
|
93
92
|
join(FIXTURES, 'sigterm-ignoring-hook.ts'),
|
|
94
93
|
contextFor({ sleep_ms: 6000, marker_path: marker }),
|
|
95
|
-
400,
|
|
96
|
-
400,
|
|
94
|
+
{ timeoutMs: 400, idleTimeoutMs: 400 },
|
|
97
95
|
),
|
|
98
96
|
).rejects.toThrow(/timeout/i);
|
|
99
97
|
|
|
@@ -22,9 +22,15 @@
|
|
|
22
22
|
|
|
23
23
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
24
24
|
import { execFileSync } from 'node:child_process';
|
|
25
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { tmpdir } from 'node:os';
|
|
27
27
|
import { dirname, join, resolve } from 'node:path';
|
|
28
|
+
import {
|
|
29
|
+
applyFallbacksToPackageJson,
|
|
30
|
+
isPublished,
|
|
31
|
+
listUnpublishedWorkspacePackages,
|
|
32
|
+
planFallbacks,
|
|
33
|
+
} from '../../../../scripts/workspace-fallback';
|
|
28
34
|
import { executeHookScript, hookChildEnv } from './executor';
|
|
29
35
|
import { createCapturingLogger } from './logger';
|
|
30
36
|
import type { HookContext } from './types';
|
|
@@ -75,9 +81,45 @@ let realHome: string | undefined;
|
|
|
75
81
|
function ensureFixtureInstalled(): void {
|
|
76
82
|
const scriptsDir = dirname(TRESPASS_SCRIPT);
|
|
77
83
|
if (existsSync(join(scriptsDir, 'node_modules', '@celilo', 'capabilities'))) return;
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
|
|
84
|
+
|
|
85
|
+
// On a version-packages PR the pin names a version that CANNOT be on the
|
|
86
|
+
// registry yet, because only merging that PR lets it publish. So a plain
|
|
87
|
+
// install here fails with `No version matching "^3.3.0" found`, and the
|
|
88
|
+
// version PR can never go green on its own.
|
|
89
|
+
//
|
|
90
|
+
// This is celilo#642 exactly, and check-modules.sh already solves it. Reuse
|
|
91
|
+
// that rather than resolving from the workspace unconditionally: doing it
|
|
92
|
+
// unconditionally would check the fixture against a copy it does not bundle,
|
|
93
|
+
// which is the celilo#173 property this module's scripts/ dir is kept out of
|
|
94
|
+
// the root workspace globs to preserve. The fallback fires only when the pin
|
|
95
|
+
// names a version the registry lacks AND the workspace holds exactly that
|
|
96
|
+
// version — where the workspace IS the artifact about to be published, same
|
|
97
|
+
// bytes by definition.
|
|
98
|
+
//
|
|
99
|
+
// The probe is scoped to this module's own dependencies: packages it does not
|
|
100
|
+
// depend on are reported published without asking the network, so this costs
|
|
101
|
+
// one registry call rather than one per publishable workspace package.
|
|
102
|
+
const pkgPath = join(scriptsDir, 'package.json');
|
|
103
|
+
const original = readFileSync(pkgPath, 'utf-8');
|
|
104
|
+
const dependencies =
|
|
105
|
+
(JSON.parse(original) as { dependencies?: Record<string, string> }).dependencies ?? {};
|
|
106
|
+
const unpublished = listUnpublishedWorkspacePackages((name, version) =>
|
|
107
|
+
name in dependencies ? isPublished(name, version) : true,
|
|
108
|
+
);
|
|
109
|
+
const fallbacks = planFallbacks(dependencies, unpublished);
|
|
110
|
+
if (fallbacks.length > 0)
|
|
111
|
+
writeFileSync(pkgPath, applyFallbacksToPackageJson(original, fallbacks));
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// `process.execPath`, not `bun` on PATH: this is the bun already running the
|
|
115
|
+
// suite, so it works wherever the suite does.
|
|
116
|
+
execFileSync(process.execPath, ['install'], { cwd: scriptsDir, stdio: 'pipe' });
|
|
117
|
+
} finally {
|
|
118
|
+
// Restore even when the install failed. A `file:` pin left in the tree
|
|
119
|
+
// points the fixture at one machine's checkout and would be swept up by the
|
|
120
|
+
// next `git add -A` — the same reason check-modules.sh restores from a trap.
|
|
121
|
+
if (fallbacks.length > 0) writeFileSync(pkgPath, original);
|
|
122
|
+
}
|
|
81
123
|
}
|
|
82
124
|
|
|
83
125
|
beforeAll(() => {
|
|
@@ -121,7 +163,10 @@ async function runTrespass(): Promise<{ report: TrespassReport; lines: string[]
|
|
|
121
163
|
capabilities: {},
|
|
122
164
|
};
|
|
123
165
|
|
|
124
|
-
const outputs = await executeHookScript(TRESPASS_SCRIPT, context,
|
|
166
|
+
const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
|
|
167
|
+
timeoutMs: 60_000,
|
|
168
|
+
idleTimeoutMs: 60_000,
|
|
169
|
+
});
|
|
125
170
|
return {
|
|
126
171
|
report: outputs as unknown as TrespassReport,
|
|
127
172
|
lines: messages.map((m) => m.message),
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermetic guards on the hook jail's CALLER (design D8, D9).
|
|
3
|
+
*
|
|
4
|
+
* `mount-set.test.ts` covers what the derivation computes. This covers what
|
|
5
|
+
* celilo does with it, and it is deliberately hermetic: the host running these
|
|
6
|
+
* tests usually has no jail at all (a Mac has none until task 4.8), so a suite
|
|
7
|
+
* that needed one would skip everywhere and prove nothing. `planJailedSpawn`
|
|
8
|
+
* is pure and takes the backend and the existence check as arguments for
|
|
9
|
+
* exactly that reason.
|
|
10
|
+
*
|
|
11
|
+
* The live half — a hook that actually cannot reach a path — is
|
|
12
|
+
* `hook-jail-unreachability.test.ts`, which skips loudly when there is no
|
|
13
|
+
* backend rather than passing quietly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import {
|
|
21
|
+
type JailAvailability,
|
|
22
|
+
type JailPlan,
|
|
23
|
+
jailModeStorePath,
|
|
24
|
+
jailPolicy,
|
|
25
|
+
planJailedSpawn,
|
|
26
|
+
readJailMode,
|
|
27
|
+
realpathRequest,
|
|
28
|
+
recordJailMode,
|
|
29
|
+
runtimeModulePathsFor,
|
|
30
|
+
} from './jail';
|
|
31
|
+
import { deriveMountSet } from './mount-set';
|
|
32
|
+
|
|
33
|
+
const AVAILABLE: JailAvailability = { backend: 'bubblewrap' };
|
|
34
|
+
const UNAVAILABLE: JailAvailability = {
|
|
35
|
+
backend: 'none',
|
|
36
|
+
reason: 'bubblewrap is not installed, so hooks run unjailed.',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const MODULE = '/var/celilo/modules/caddy';
|
|
40
|
+
const CMD = ['/usr/local/bin/bun', '/opt/celilo/src/hooks/hook-runner.ts'];
|
|
41
|
+
|
|
42
|
+
const REQUEST = {
|
|
43
|
+
modulePath: MODULE,
|
|
44
|
+
stateDir: `${MODULE}/state`,
|
|
45
|
+
socketDir: '/tmp/celilo-hook-a1b2c3',
|
|
46
|
+
runtimePath: CMD[0] as string,
|
|
47
|
+
runnerPath: CMD[1] as string,
|
|
48
|
+
pathInputs: [],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Every path exists, so nothing is dropped and the argv is the full set. */
|
|
52
|
+
const allPresent = () => true;
|
|
53
|
+
|
|
54
|
+
function plan(): JailPlan {
|
|
55
|
+
return planJailedSpawn(CMD, deriveMountSet(REQUEST), AVAILABLE, 'auto', allPresent);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('the shim is spawned under bwrap', () => {
|
|
59
|
+
test('bwrap leads, and the unjailed command follows the -- terminator', () => {
|
|
60
|
+
const { cmd, mode } = plan();
|
|
61
|
+
expect(mode).toBe('jailed');
|
|
62
|
+
expect(cmd[0]).toBe('bwrap');
|
|
63
|
+
expect(cmd.slice(cmd.indexOf('--') + 1)).toEqual(CMD);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the derived mount set reaches the argv, at the mode it derived', () => {
|
|
67
|
+
const { cmd } = plan();
|
|
68
|
+
const args = cmd.join(' ');
|
|
69
|
+
// The module's own tree read-only, then its writable directory carved on
|
|
70
|
+
// top of it. Order is what makes the carve-out work, so assert the order
|
|
71
|
+
// and not merely the presence.
|
|
72
|
+
expect(args).toContain(`--ro-bind ${MODULE} ${MODULE}`);
|
|
73
|
+
expect(args).toContain(`--bind ${MODULE}/state ${MODULE}/state`);
|
|
74
|
+
expect(args.indexOf(`--ro-bind ${MODULE} `)).toBeLessThan(
|
|
75
|
+
args.indexOf(`--bind ${MODULE}/state`),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the jail names its own working directory (task 4.2i)', () => {
|
|
80
|
+
// The spawn sets no `cwd`, so without this the child inherits celilo's —
|
|
81
|
+
// whatever directory the operator's shell was in, which need not exist
|
|
82
|
+
// inside the namespace. bubblewrap then fails on a path nobody chose.
|
|
83
|
+
const { cmd } = plan();
|
|
84
|
+
expect(cmd[cmd.indexOf('--chdir') + 1]).toBe(MODULE);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the network is NOT namespaced, which D9 says on purpose', () => {
|
|
88
|
+
// Reachability is scoped by withholding the credential (D12), not by
|
|
89
|
+
// filtering packets. An --unshare-net here would break every hook that
|
|
90
|
+
// reaches its own systems and would look like a DNS bug.
|
|
91
|
+
expect(plan().cmd).not.toContain('--unshare-net');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a fresh /proc comes with the pid namespace', () => {
|
|
95
|
+
// --unshare-pid without a fresh /proc leaves the host's process table
|
|
96
|
+
// visible, and /proc/1/root is a well-worn way to read out of a jail.
|
|
97
|
+
const { cmd } = plan();
|
|
98
|
+
expect(cmd).toContain('--unshare-pid');
|
|
99
|
+
expect(cmd[cmd.indexOf('--proc') + 1]).toBe('/proc');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('what the jailed argv must never name', () => {
|
|
104
|
+
// These are the acceptance criterion of the whole change, asserted at the
|
|
105
|
+
// caller rather than in the derivation: whatever else the wrapping does, it
|
|
106
|
+
// must not smuggle a path back in.
|
|
107
|
+
test("celilo's data directory, its master key and a sibling module are absent", () => {
|
|
108
|
+
const args = plan().cmd.join(' ');
|
|
109
|
+
expect(args).not.toContain('/var/celilo/master.key');
|
|
110
|
+
expect(args).not.toContain('/var/celilo/celilo.db');
|
|
111
|
+
expect(args).not.toContain('/var/celilo/modules ');
|
|
112
|
+
expect(args).not.toContain('/modules/technitium');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('bwrap binds no bwrap, so the jail cannot build a jail', () => {
|
|
116
|
+
// The AppArmor profile grants `userns` to /usr/bin/bwrap for anyone on the
|
|
117
|
+
// box. A jailed hook that could exec it would be uid 0 with CAP_SYS_ADMIN
|
|
118
|
+
// inside a namespace of its own. bwrap is the FIRST word of this command
|
|
119
|
+
// and must not be an argument of it.
|
|
120
|
+
expect(plan().cmd.slice(1).join(' ')).not.toContain('/bwrap');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('a mount whose source is missing is dropped, not fatal', () => {
|
|
125
|
+
// bubblewrap fails the whole jail on a bind with no source, and several rows
|
|
126
|
+
// are legitimately absent: /lib64 does not exist on arm64, and
|
|
127
|
+
// <module>/generated appears only once celilo has generated something.
|
|
128
|
+
const missing = `${MODULE}/generated`;
|
|
129
|
+
const exists = (path: string) => path !== missing;
|
|
130
|
+
|
|
131
|
+
test('it is filtered out of the argv and named in `skipped`', () => {
|
|
132
|
+
const { cmd, skipped } = planJailedSpawn(
|
|
133
|
+
CMD,
|
|
134
|
+
deriveMountSet(REQUEST),
|
|
135
|
+
AVAILABLE,
|
|
136
|
+
'auto',
|
|
137
|
+
exists,
|
|
138
|
+
);
|
|
139
|
+
expect(skipped).toContain(missing);
|
|
140
|
+
expect(cmd.join(' ')).not.toContain(missing);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a tmpfs is never dropped, because it has no source to be missing', () => {
|
|
144
|
+
const { cmd, skipped } = planJailedSpawn(
|
|
145
|
+
CMD,
|
|
146
|
+
deriveMountSet(REQUEST),
|
|
147
|
+
AVAILABLE,
|
|
148
|
+
'auto',
|
|
149
|
+
() => false,
|
|
150
|
+
);
|
|
151
|
+
expect(skipped).not.toContain('/tmp');
|
|
152
|
+
expect(cmd.join(' ')).toContain('--tmpfs /tmp');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("celilo's own dependencies reach the jail, and nothing else does", () => {
|
|
157
|
+
// The shim is not self-contained: it imports `isCompiledHook` from
|
|
158
|
+
// @celilo/capabilities and Zod through hook-protocol.ts, and both resolve
|
|
159
|
+
// ABOVE its own directory. Measured — the jail built correctly and the shim
|
|
160
|
+
// died on `Cannot find module '@celilo/capabilities'`.
|
|
161
|
+
const NPM_SHIM = '/var/celilo/node_modules/@celilo/cli/src/hooks/hook-runner.ts';
|
|
162
|
+
|
|
163
|
+
test('an npm install finds its node_modules, nearest first', () => {
|
|
164
|
+
const exists = (p: string) => p === '/var/celilo/node_modules';
|
|
165
|
+
expect(runtimeModulePathsFor(NPM_SHIM, exists)).toEqual(['/var/celilo/node_modules']);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('the data directory holding master.key is NEVER collected', () => {
|
|
169
|
+
// This is the whole safety property. `/var/celilo` is the ancestor that
|
|
170
|
+
// holds the shim's dependencies, and it also holds master.key and
|
|
171
|
+
// celilo.db. Only a path literally named `node_modules` is ever kept, so
|
|
172
|
+
// the ancestor itself cannot be bound however the walk is refactored.
|
|
173
|
+
const everythingExists = () => true;
|
|
174
|
+
const found = runtimeModulePathsFor(NPM_SHIM, everythingExists);
|
|
175
|
+
expect(found).not.toContain('/var/celilo');
|
|
176
|
+
expect(found.every((p) => p.endsWith('/node_modules'))).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('the walk stops at the filesystem root rather than looping', () => {
|
|
180
|
+
expect(runtimeModulePathsFor('/hook-runner.ts', () => false)).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('they are bound read-only, and the derivation puts them in the argv', () => {
|
|
184
|
+
const { cmd } = planJailedSpawn(
|
|
185
|
+
CMD,
|
|
186
|
+
deriveMountSet({ ...REQUEST, runtimeModulePaths: ['/var/celilo/node_modules'] }),
|
|
187
|
+
AVAILABLE,
|
|
188
|
+
'auto',
|
|
189
|
+
allPresent,
|
|
190
|
+
);
|
|
191
|
+
expect(cmd.join(' ')).toContain('--ro-bind /var/celilo/node_modules /var/celilo/node_modules');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('the three policies', () => {
|
|
196
|
+
test("'auto' with no backend runs the hook and records why", () => {
|
|
197
|
+
const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'auto', allPresent);
|
|
198
|
+
expect(p.mode).toBe('unjailed');
|
|
199
|
+
expect(p.cmd).toEqual(CMD);
|
|
200
|
+
expect(p.reason).toBe(UNAVAILABLE.reason);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("'off' declines a jail that IS available", () => {
|
|
204
|
+
const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), AVAILABLE, 'off', allPresent);
|
|
205
|
+
expect(p.mode).toBe('unjailed');
|
|
206
|
+
expect(p.cmd).toEqual(CMD);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("'required' turns an unavailable jail into a hard failure (task 4.5)", () => {
|
|
210
|
+
expect(() =>
|
|
211
|
+
planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'required', allPresent),
|
|
212
|
+
).toThrow(/CELILO_HOOK_JAIL=required/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("'required' names the reason, so the operator can act on it", () => {
|
|
216
|
+
expect(() =>
|
|
217
|
+
planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'required', allPresent),
|
|
218
|
+
).toThrow(/not installed/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('an invocation with no mount set runs unjailed rather than in an empty jail', () => {
|
|
222
|
+
// An empty mount set is a hook that cannot read its own script. Absent
|
|
223
|
+
// means "do not jail this one", never "jail it with nothing".
|
|
224
|
+
const p = planJailedSpawn(CMD, undefined, AVAILABLE, 'auto', allPresent);
|
|
225
|
+
expect(p.mode).toBe('unjailed');
|
|
226
|
+
expect(p.cmd).toEqual(CMD);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('jailPolicy reads the operator’s switch', () => {
|
|
231
|
+
const withEnv = <T>(value: string | undefined, fn: () => T): T => {
|
|
232
|
+
const saved = process.env.CELILO_HOOK_JAIL;
|
|
233
|
+
if (value === undefined) delete process.env.CELILO_HOOK_JAIL;
|
|
234
|
+
else process.env.CELILO_HOOK_JAIL = value;
|
|
235
|
+
try {
|
|
236
|
+
return fn();
|
|
237
|
+
} finally {
|
|
238
|
+
if (saved === undefined) delete process.env.CELILO_HOOK_JAIL;
|
|
239
|
+
else process.env.CELILO_HOOK_JAIL = saved;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
test('unset is auto', () => {
|
|
244
|
+
expect(withEnv(undefined, jailPolicy)).toBe('auto');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('a typo is refused rather than silently meaning auto', () => {
|
|
248
|
+
// `CELILO_HOOK_JAIL=requried` reading as `auto` would look exactly like
|
|
249
|
+
// the jail being enforced when it is not — the one mistake this variable
|
|
250
|
+
// exists to prevent (Rule 4.2).
|
|
251
|
+
expect(() => withEnv('requried', jailPolicy)).toThrow(/is not a hook jail policy/);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('realpath at the caller (task 4.2k)', () => {
|
|
256
|
+
test('a symlinked module root is resolved before it becomes a bind', () => {
|
|
257
|
+
// `mod.sourcePath` comes out of the database, and a database restored from
|
|
258
|
+
// another box carries that box's absolute paths (ISS-0052).
|
|
259
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-realpath-'));
|
|
260
|
+
try {
|
|
261
|
+
const real = join(scratch, 'real-module');
|
|
262
|
+
mkdirSync(join(real, 'state'), { recursive: true });
|
|
263
|
+
const link = join(scratch, 'linked-module');
|
|
264
|
+
symlinkSync(real, link);
|
|
265
|
+
|
|
266
|
+
const resolved = realpathRequest({
|
|
267
|
+
...REQUEST,
|
|
268
|
+
modulePath: link,
|
|
269
|
+
stateDir: join(link, 'state'),
|
|
270
|
+
});
|
|
271
|
+
expect(resolved.modulePath).not.toContain('linked-module');
|
|
272
|
+
expect(resolved.modulePath).toBe(resolved.stateDir.replace(/\/state$/, ''));
|
|
273
|
+
} finally {
|
|
274
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('a path that does not exist keeps its lexical form', () => {
|
|
279
|
+
// It cannot be resolved, and it is dropped by the existence filter anyway.
|
|
280
|
+
expect(realpathRequest(REQUEST).modulePath).toBe(MODULE);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('the mode is recorded state, not a log line (design D8)', () => {
|
|
285
|
+
const withStore = <T>(fn: (path: string) => T): T => {
|
|
286
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-mode-'));
|
|
287
|
+
const path = join(scratch, 'nested', 'hook-jail-mode.json');
|
|
288
|
+
const saved = process.env.CELILO_HOOK_JAIL_MODE_PATH;
|
|
289
|
+
process.env.CELILO_HOOK_JAIL_MODE_PATH = path;
|
|
290
|
+
try {
|
|
291
|
+
return fn(path);
|
|
292
|
+
} finally {
|
|
293
|
+
if (saved === undefined) delete process.env.CELILO_HOOK_JAIL_MODE_PATH;
|
|
294
|
+
else process.env.CELILO_HOOK_JAIL_MODE_PATH = saved;
|
|
295
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const jailed: JailPlan = { cmd: CMD, mode: 'jailed', backend: 'bubblewrap', skipped: [] };
|
|
300
|
+
const unjailed: JailPlan = {
|
|
301
|
+
cmd: CMD,
|
|
302
|
+
mode: 'unjailed',
|
|
303
|
+
backend: 'none',
|
|
304
|
+
reason: 'bubblewrap is not installed',
|
|
305
|
+
skipped: [],
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
test('the first run writes the mode and had nothing to replace', () => {
|
|
309
|
+
withStore(() => {
|
|
310
|
+
expect(recordJailMode(jailed).previous).toBeUndefined();
|
|
311
|
+
expect(readJailMode()?.mode).toBe('jailed');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('a jailed-to-unjailed transition is readable from the record (task 4.4)', () => {
|
|
316
|
+
withStore(() => {
|
|
317
|
+
recordJailMode(jailed);
|
|
318
|
+
const { previous } = recordJailMode(unjailed);
|
|
319
|
+
// This is the third of D8's three states, the only one that is an event:
|
|
320
|
+
// a host that used to jail and has stopped. The self-monitor is task
|
|
321
|
+
// 4.4; this is the state it reads.
|
|
322
|
+
expect(previous?.mode).toBe('jailed');
|
|
323
|
+
expect(readJailMode()?.mode).toBe('unjailed');
|
|
324
|
+
expect(readJailMode()?.reason).toContain('not installed');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('an unchanged mode keeps the timestamp of the last CHANGE', () => {
|
|
329
|
+
withStore(() => {
|
|
330
|
+
recordJailMode(jailed);
|
|
331
|
+
const first = readJailMode()?.recordedAt;
|
|
332
|
+
recordJailMode(jailed);
|
|
333
|
+
expect(readJailMode()?.recordedAt).toBe(first as string);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('the record names the host, because a transition is per host', () => {
|
|
338
|
+
withStore(() => {
|
|
339
|
+
recordJailMode(jailed);
|
|
340
|
+
expect(readJailMode()?.host).toBeTruthy();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('a corrupt file reads as absent rather than failing the hook', () => {
|
|
345
|
+
withStore((path) => {
|
|
346
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
347
|
+
writeFileSync(path, 'not json');
|
|
348
|
+
expect(readJailMode()).toBeUndefined();
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('an unwritable store does not fail the hook', () => {
|
|
353
|
+
// Diagnostic state. celilo must not refuse to run a hook because it could
|
|
354
|
+
// not write down how it ran it.
|
|
355
|
+
withStore(() => {
|
|
356
|
+
process.env.CELILO_HOOK_JAIL_MODE_PATH = '/proc/celilo-cannot-write-here/mode.json';
|
|
357
|
+
expect(() => recordJailMode(jailed)).not.toThrow();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('the store sits beside the rest of celilo’s per-machine state', () => {
|
|
362
|
+
const saved = process.env.CELILO_HOOK_JAIL_MODE_PATH;
|
|
363
|
+
delete process.env.CELILO_HOOK_JAIL_MODE_PATH;
|
|
364
|
+
try {
|
|
365
|
+
expect(jailModeStorePath()).toMatch(/hook-jail-mode\.json$/);
|
|
366
|
+
} finally {
|
|
367
|
+
if (saved !== undefined) process.env.CELILO_HOOK_JAIL_MODE_PATH = saved;
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|