@homeflare/alchemy 0.25.1 → 0.27.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/dist/github/declare-repo-baseline.d.ts +11 -0
- package/dist/github/declare-repo-baseline.d.ts.map +1 -0
- package/dist/github/index.d.ts +20 -11
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +715 -4
- package/dist/github/index.js.map +14 -4
- package/dist/github/repo-baseline-data.d.ts +75 -0
- package/dist/github/repo-baseline-data.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +4 -4
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-constraints.d.ts +105 -0
- package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
- package/dist/github/repository-ruleset-errors.d.ts +97 -0
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
- package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
- package/dist/github/repository-ruleset-form.d.ts +30 -0
- package/dist/github/repository-ruleset-form.d.ts.map +1 -0
- package/dist/github/repository-ruleset-guards.d.ts +70 -0
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-octokit.d.ts +5 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-probe.d.ts +87 -0
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
- package/dist/github/repository-ruleset.d.ts +89 -0
- package/dist/github/repository-ruleset.d.ts.map +1 -0
- package/dist/launchd/index.js +51 -47
- package/dist/launchd/index.js.map +5 -4
- package/dist/launchd/sudo-guard-mode.d.ts +62 -0
- package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +1 -38
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/linux/fake-sudo-root.d.ts +26 -0
- package/dist/linux/fake-sudo-root.d.ts.map +1 -0
- package/dist/linux/fake-sudo.d.ts +56 -0
- package/dist/linux/fake-sudo.d.ts.map +1 -0
- package/dist/linux/index.d.ts +2 -0
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +1134 -2
- package/dist/linux/index.js.map +22 -5
- package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
- package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
- package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist.d.ts +40 -0
- package/dist/linux/sudo-allowlist.d.ts.map +1 -0
- package/dist/linux/sudo-exec.d.ts +31 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -0
- package/dist/linux/sudo-guard.d.ts +36 -0
- package/dist/linux/sudo-guard.d.ts.map +1 -0
- package/dist/linux/sudo-runner.d.ts +37 -0
- package/dist/linux/sudo-runner.d.ts.map +1 -0
- package/dist/linux/sudo-stage.d.ts +22 -0
- package/dist/linux/sudo-stage.d.ts.map +1 -0
- package/dist/linux/sudo-write.d.ts +25 -0
- package/dist/linux/sudo-write.d.ts.map +1 -0
- package/dist/linux/unit-form.d.ts +2 -0
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/docs/launchd-sudo.md +12 -3
- package/docs/linux-host.md +3 -3
- package/docs/linux-sudo.md +187 -0
- package/docs/repo-policy-ruleset-hazards.md +8 -0
- package/docs/repo-policy.md +27 -12
- package/docs/repository-ruleset.md +114 -0
- package/docs/upstream-conformance.md +12 -1
- package/docs/upstream-pr-ruleset-fixes.md +77 -0
- package/package.json +1 -1
- package/src/github/declare-repo-baseline.ts +42 -0
- package/src/github/index.ts +43 -11
- package/src/github/repo-baseline-data.test.ts +76 -0
- package/src/github/repo-baseline-data.ts +86 -0
- package/src/github/repo-policy.ts +17 -17
- package/src/github/repository-ruleset-constraints.ts +136 -0
- package/src/github/repository-ruleset-coverage.test.ts +82 -0
- package/src/github/repository-ruleset-errors.ts +136 -0
- package/src/github/repository-ruleset-fake-octokit.ts +131 -0
- package/src/github/repository-ruleset-fixtures.ts +183 -0
- package/src/github/repository-ruleset-form.ts +212 -0
- package/src/github/repository-ruleset-guards.ts +194 -0
- package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
- package/src/github/repository-ruleset-octokit.ts +140 -0
- package/src/github/repository-ruleset-probe.ts +116 -0
- package/src/github/repository-ruleset-reconcile.ts +176 -0
- package/src/github/repository-ruleset-test-helpers.ts +102 -0
- package/src/github/repository-ruleset-wire.test.ts +66 -0
- package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
- package/src/github/repository-ruleset-write.test.ts +83 -0
- package/src/github/repository-ruleset.ts +219 -0
- package/src/launchd/sudo-guard-mode.ts +111 -0
- package/src/launchd/sudo-guard.ts +3 -79
- package/src/launchd/sudo-modes.test.ts +38 -1
- package/src/linux/fake-sudo-root.ts +157 -0
- package/src/linux/fake-sudo.ts +142 -0
- package/src/linux/index.ts +2 -0
- package/src/linux/sudo-allowlist-chown.ts +71 -0
- package/src/linux/sudo-allowlist-dir.ts +103 -0
- package/src/linux/sudo-allowlist.test.ts +252 -0
- package/src/linux/sudo-allowlist.ts +223 -0
- package/src/linux/sudo-exec.ts +118 -0
- package/src/linux/sudo-guard.test.ts +136 -0
- package/src/linux/sudo-guard.ts +142 -0
- package/src/linux/sudo-lifecycle.test.ts +151 -0
- package/src/linux/sudo-runner.test.ts +206 -0
- package/src/linux/sudo-runner.ts +164 -0
- package/src/linux/sudo-stage.ts +50 -0
- package/src/linux/sudo-write.ts +167 -0
- package/src/linux/unit-form.ts +2 -1
- package/src/litellm/fake-litellm.ts +2 -0
- package/src/proxmox/client.test.ts +17 -3
- package/src/proxmox/fake-pve-lxc.ts +10 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assertGuardedChain against the fake Linux host, unprivileged: a symlink below the prefix, a
|
|
3
|
+
* group-writable directory, a `+` ACL flag, a non-root owner above the prefix, and a directory
|
|
4
|
+
* sitting where a write expects `absent`. No sudo call anywhere in this file.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { fakeSudoHost } from './fake-sudo.ts';
|
|
8
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
9
|
+
import { assertGuardedChain } from './sudo-guard.ts';
|
|
10
|
+
|
|
11
|
+
describe('a clean prefix', () => {
|
|
12
|
+
test('a file-or-absent target that is absent is fine', async () => {
|
|
13
|
+
const { base } = fakeSudoHost();
|
|
14
|
+
const target = await assertGuardedChain(
|
|
15
|
+
base,
|
|
16
|
+
'/etc/systemd/system',
|
|
17
|
+
'/etc/systemd/system/thing.service',
|
|
18
|
+
'file-or-absent',
|
|
19
|
+
);
|
|
20
|
+
expect(target).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('mkdir\'s "absent" expectation refuses a directory already there', async () => {
|
|
24
|
+
const { base } = fakeSudoHost({ '/usr/local/bin/sub': 0 });
|
|
25
|
+
await expect(
|
|
26
|
+
assertGuardedChain(base, '/usr/local/bin', '/usr/local/bin/sub', 'absent'),
|
|
27
|
+
).rejects.toThrow('already exists');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('a symlink in the chain', () => {
|
|
32
|
+
test('below the prefix is refused, even root-owned', async () => {
|
|
33
|
+
const { base, fake } = fakeSudoHost();
|
|
34
|
+
fake.files.set('/etc/systemd/system/sub', {
|
|
35
|
+
bytes: new Uint8Array(),
|
|
36
|
+
gid: 0,
|
|
37
|
+
kind: 'symlink',
|
|
38
|
+
mode: 0o777,
|
|
39
|
+
uid: 0,
|
|
40
|
+
});
|
|
41
|
+
await expect(
|
|
42
|
+
assertGuardedChain(
|
|
43
|
+
base,
|
|
44
|
+
'/etc/systemd/system',
|
|
45
|
+
'/etc/systemd/system/sub/x.service',
|
|
46
|
+
'file-or-absent',
|
|
47
|
+
),
|
|
48
|
+
).rejects.toBeInstanceOf(SudoRefusedError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('above the prefix, root-owned, is followed like the merged-/usr symlinks it models', async () => {
|
|
52
|
+
const { base, fake } = fakeSudoHost();
|
|
53
|
+
fake.files.set('/etc', {
|
|
54
|
+
bytes: new Uint8Array(),
|
|
55
|
+
gid: 0,
|
|
56
|
+
kind: 'symlink',
|
|
57
|
+
mode: 0o777,
|
|
58
|
+
uid: 0,
|
|
59
|
+
});
|
|
60
|
+
const target = await assertGuardedChain(
|
|
61
|
+
base,
|
|
62
|
+
'/etc/systemd/system',
|
|
63
|
+
'/etc/systemd/system/thing.service',
|
|
64
|
+
'file-or-absent',
|
|
65
|
+
);
|
|
66
|
+
expect(target).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('above the prefix, owned by someone else, is refused (not treated as the system’s own)', async () => {
|
|
70
|
+
const { base, fake } = fakeSudoHost();
|
|
71
|
+
fake.files.set('/etc', {
|
|
72
|
+
bytes: new Uint8Array(),
|
|
73
|
+
gid: 0,
|
|
74
|
+
kind: 'symlink',
|
|
75
|
+
mode: 0o777,
|
|
76
|
+
uid: 900,
|
|
77
|
+
});
|
|
78
|
+
await expect(
|
|
79
|
+
assertGuardedChain(
|
|
80
|
+
base,
|
|
81
|
+
'/etc/systemd/system',
|
|
82
|
+
'/etc/systemd/system/thing.service',
|
|
83
|
+
'file-or-absent',
|
|
84
|
+
),
|
|
85
|
+
).rejects.toThrow('is a symlink, not a directory');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a group-writable directory in the chain is refused', async () => {
|
|
90
|
+
const { base, fake } = fakeSudoHost({ '/etc/systemd/system/sub': 0 });
|
|
91
|
+
const live = fake.modes.get('/etc/systemd/system/sub');
|
|
92
|
+
if (live !== undefined) live.mode = 0o775;
|
|
93
|
+
await expect(
|
|
94
|
+
assertGuardedChain(
|
|
95
|
+
base,
|
|
96
|
+
'/etc/systemd/system',
|
|
97
|
+
'/etc/systemd/system/sub/x.service',
|
|
98
|
+
'file-or-absent',
|
|
99
|
+
),
|
|
100
|
+
).rejects.toThrow('writable by group or other');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('an ACL `+` flag anywhere in the chain is refused, fail closed', async () => {
|
|
104
|
+
const { base } = fakeSudoHost({}, new Set(['/etc/systemd/system']));
|
|
105
|
+
await expect(
|
|
106
|
+
assertGuardedChain(
|
|
107
|
+
base,
|
|
108
|
+
'/etc/systemd/system',
|
|
109
|
+
'/etc/systemd/system/thing.service',
|
|
110
|
+
'file-or-absent',
|
|
111
|
+
),
|
|
112
|
+
).rejects.toThrow('POSIX ACL');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a non-root owner above the prefix is refused', async () => {
|
|
116
|
+
const { base, fake } = fakeSudoHost();
|
|
117
|
+
const live = fake.modes.get('/etc/systemd/system');
|
|
118
|
+
if (live !== undefined) live.uid = 0; // keep the prefix itself root-owned
|
|
119
|
+
fake.dirs.set('/etc', 900);
|
|
120
|
+
fake.modes.set('/etc', { gid: 0, mode: 0o755, uid: 900 });
|
|
121
|
+
await expect(
|
|
122
|
+
assertGuardedChain(
|
|
123
|
+
base,
|
|
124
|
+
'/etc/systemd/system',
|
|
125
|
+
'/etc/systemd/system/thing.service',
|
|
126
|
+
'file-or-absent',
|
|
127
|
+
),
|
|
128
|
+
).rejects.toThrow('not root');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a directory at a "file" destination is the trap install/rm must not fall into', async () => {
|
|
132
|
+
const { base } = fakeSudoHost({ '/etc/systemd/system/adir': 0 });
|
|
133
|
+
await expect(
|
|
134
|
+
assertGuardedChain(base, '/etc/systemd/system', '/etc/systemd/system/adir', 'file'),
|
|
135
|
+
).rejects.toThrow('is a directory, not a file');
|
|
136
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checks sudo-runner.ts makes against the host, as the operator, before it runs a file or
|
|
3
|
+
* directory operation as root. sudo-allowlist.ts checks the argv; this checks what the paths in
|
|
4
|
+
* it actually are — the Linux twin of `../launchd/sudo-guard.ts`, rebuilt on `sudo-listing.ts`'s
|
|
5
|
+
* ONE `ls -ldn` call instead of an lstat per directory, because ssh pays a handshake per call
|
|
6
|
+
* (ssh-runner.ts: no multiplexing unless the caller opts in) where a local syscall pays nothing.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ EVERY DIRECTORY FROM `/` DOWN TO THE FILE MUST BE ROOT'S ALONE, for the same reason the Mac
|
|
9
|
+
* guard insists on it: a directory anyone else may write lets them swap what lies under it — a
|
|
10
|
+
* symlink where the file was — between this read and the privileged call. A root-owned symlink
|
|
11
|
+
* ABOVE the prefix (Debian's merged-`/usr`, `/lib` -> `usr/lib`) is the system's own and is
|
|
12
|
+
* followed, exactly as `aboveDirs`/`betweenDirs` from the Mac guard already encode; a symlink AT
|
|
13
|
+
* or BELOW the prefix is refused outright — a write should never chase one there.
|
|
14
|
+
* ⛔ THE ACL SIGNAL IS THE `+` FLAG ALONE (sudo-listing.ts header): `ls`, not `getfacl`, which is
|
|
15
|
+
* absent on the estate's Debian hosts. A directory that carries one is refused whatever it
|
|
16
|
+
* grants — the guard cannot tell a read-only entry from a write one, so it fails closed.
|
|
17
|
+
*/
|
|
18
|
+
import type { FileStat, HostRunner, WriteOptions } from '../launchd/runner.ts';
|
|
19
|
+
import {
|
|
20
|
+
aboveDirs,
|
|
21
|
+
assertReadableBack,
|
|
22
|
+
betweenDirs,
|
|
23
|
+
canRead,
|
|
24
|
+
modeProblem,
|
|
25
|
+
operatorGroups,
|
|
26
|
+
} from '../launchd/sudo-guard.ts';
|
|
27
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
28
|
+
import { type Listed, readListing } from './sudo-listing.ts';
|
|
29
|
+
|
|
30
|
+
export { aboveDirs, assertReadableBack, betweenDirs, canRead, modeProblem, operatorGroups };
|
|
31
|
+
|
|
32
|
+
const refuse = (path: string, message: string): Error =>
|
|
33
|
+
new SudoRefusedError(`sshSudoRunner ${path}: ${message}`);
|
|
34
|
+
|
|
35
|
+
const rootOnlyProblem = (entry: Listed | undefined): string | undefined => {
|
|
36
|
+
if (entry === undefined) return 'is missing';
|
|
37
|
+
if (entry.kind !== 'directory') return `is a ${entry.kind}, not a directory`;
|
|
38
|
+
if (entry.uid !== 0) return `is owned by uid ${String(entry.uid)}, not root`;
|
|
39
|
+
if ((entry.mode & 0o022) !== 0) {
|
|
40
|
+
return `is writable by group or other (mode ${entry.mode.toString(8).padStart(4, '0')})`;
|
|
41
|
+
}
|
|
42
|
+
if (entry.acl) return 'carries a POSIX ACL this runner cannot verify the grants of (ls -ldn `+`)';
|
|
43
|
+
return undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** What must be at `path` itself, for the operation about to run there. */
|
|
47
|
+
export type ChainExpect = 'file' | 'file-or-absent' | 'directory' | 'absent';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* ⚠️ NOT PART OF THE `ls -ldn` BATCH, on purpose — mirroring the Mac guard's own
|
|
51
|
+
* `assertPlainPath`, whose ACL check also excludes `path` itself (`assertNoAclGrants(base,
|
|
52
|
+
* path, [...above, prefix, ...dirs])`, never `path` in that list). `readListing`'s
|
|
53
|
+
* `parseListing` requires every requested path to have a line (sudo-listing.ts), so a target
|
|
54
|
+
* that is legitimately ABSENT — the common case for a create — would make the whole batched
|
|
55
|
+
* read look unparseable if it were included. A plain `stat` handles "nothing there" correctly.
|
|
56
|
+
*/
|
|
57
|
+
const targetProblem = (entry: FileStat | undefined, expect: ChainExpect): string | undefined => {
|
|
58
|
+
if (expect === 'absent')
|
|
59
|
+
return entry === undefined ? undefined : `already exists (a ${entry.kind})`;
|
|
60
|
+
if (entry === undefined) return expect === 'file-or-absent' ? undefined : 'is missing';
|
|
61
|
+
if (expect === 'file-or-absent')
|
|
62
|
+
return entry.kind === 'file' ? undefined : `is a ${entry.kind}, not a file`;
|
|
63
|
+
return entry.kind === expect ? undefined : `is a ${entry.kind}, not a ${expect}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Refuse a prefix that is not root-only, a symlink or wrong kind between the prefix and `path`,
|
|
68
|
+
* an ACL anywhere in the chain, or a target that is not what `expect` says. ONE `ls -ldn` for the
|
|
69
|
+
* directory chain, plus one plain `stat` for the target (see `targetProblem`'s note on why they
|
|
70
|
+
* are separate calls). ⚠️ Reads only, as the operator — so `checkWrite` runs this at plan time too.
|
|
71
|
+
*/
|
|
72
|
+
export const assertGuardedChain = async (
|
|
73
|
+
base: HostRunner,
|
|
74
|
+
prefix: string,
|
|
75
|
+
path: string,
|
|
76
|
+
expect: ChainExpect,
|
|
77
|
+
): Promise<FileStat | undefined> => {
|
|
78
|
+
const above = aboveDirs(prefix);
|
|
79
|
+
const between = betweenDirs(prefix, path);
|
|
80
|
+
const chain = [...above, prefix, ...between];
|
|
81
|
+
const listed = await readListing(base, '/usr/bin/ls', path, chain);
|
|
82
|
+
for (const dir of above) {
|
|
83
|
+
const entry = listed.get(dir);
|
|
84
|
+
// ★ A root-owned symlink ABOVE the prefix is the system's own and is followed, as every path
|
|
85
|
+
// lookup does; a symlink anywhere else in the chain is refused by rootOnlyProblem below.
|
|
86
|
+
const problem =
|
|
87
|
+
entry?.kind === 'symlink' && entry.uid === 0 ? undefined : rootOnlyProblem(entry);
|
|
88
|
+
if (problem !== undefined) {
|
|
89
|
+
throw refuse(
|
|
90
|
+
path,
|
|
91
|
+
`${dir}, above the declared prefix ${prefix}, ${problem}. Whoever may change it may swap ` +
|
|
92
|
+
'the prefix itself; every directory from / down must be root-only. Nothing ran as root.',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const prefixProblem = rootOnlyProblem(listed.get(prefix));
|
|
97
|
+
if (prefixProblem !== undefined) {
|
|
98
|
+
throw refuse(
|
|
99
|
+
path,
|
|
100
|
+
`the declared prefix ${prefix} ${prefixProblem}. Declare only a real directory that root ` +
|
|
101
|
+
'owns and only root may write. Nothing ran as root.',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
for (const dir of between) {
|
|
105
|
+
const problem = rootOnlyProblem(listed.get(dir));
|
|
106
|
+
if (problem !== undefined) {
|
|
107
|
+
throw refuse(
|
|
108
|
+
path,
|
|
109
|
+
`${dir} ${problem}. Every directory from the prefix ${prefix} down to the file must be ` +
|
|
110
|
+
'one that root owns and only root may write. Nothing ran as root.',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const target = await base.stat(path);
|
|
115
|
+
const problem = targetProblem(target, expect);
|
|
116
|
+
if (problem !== undefined) throw refuse(path, `${problem}. Nothing ran as root.`);
|
|
117
|
+
return target;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The mode/owner and read-back checks for a file write under `prefix`, plus the path chain.
|
|
122
|
+
* ★ Reads only, as the operator — the same checks run at plan time (`checkWrite`) and before the
|
|
123
|
+
* install/mv pair. `expect` is `'file-or-absent'`: a write may create or replace.
|
|
124
|
+
*/
|
|
125
|
+
export const assertInstallable = async (
|
|
126
|
+
base: HostRunner,
|
|
127
|
+
prefix: string,
|
|
128
|
+
path: string,
|
|
129
|
+
options: WriteOptions,
|
|
130
|
+
groups: readonly number[] | undefined,
|
|
131
|
+
): Promise<void> => {
|
|
132
|
+
const mode = modeProblem(options);
|
|
133
|
+
if (mode !== undefined) {
|
|
134
|
+
throw refuse(
|
|
135
|
+
path,
|
|
136
|
+
`${mode}. Declare a mode without those bits (0644, 0640, 0755), or give the file to its ` +
|
|
137
|
+
'real owner. Nothing ran as root.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
await assertGuardedChain(base, prefix, path, 'file-or-absent');
|
|
141
|
+
await assertReadableBack(base, path, options, groups);
|
|
142
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Systemd.Unit` and `Remote.File`'s own lifecycle functions, driven through sshSudoRunner over
|
|
3
|
+
* the fake Linux host — proving the two families this runner exists for actually work end to end,
|
|
4
|
+
* not just that the runner's own primitives do. No sudo runs anywhere but through the fake.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { fakeSudoHost } from './fake-sudo.ts';
|
|
8
|
+
import { SudoRefusedError } from './sudo-runner.ts';
|
|
9
|
+
import { deleteFile, reconcileFile } from './remote-file-lifecycle.ts';
|
|
10
|
+
import type { RemoteFileProps } from './remote-file-form.ts';
|
|
11
|
+
import { deleteUnit, diffUnit, reconcileUnit } from './unit-lifecycle.ts';
|
|
12
|
+
import type { SystemdUnitProps } from './unit-form.ts';
|
|
13
|
+
|
|
14
|
+
const UNIT: SystemdUnitProps = {
|
|
15
|
+
name: 'hf-thing.service',
|
|
16
|
+
sections: [
|
|
17
|
+
{ lines: [['Description', 'a thing']], name: 'Unit' },
|
|
18
|
+
{ lines: [['ExecStart', '/usr/local/bin/hf-thing']], name: 'Service' },
|
|
19
|
+
{ lines: [['WantedBy', 'multi-user.target']], name: 'Install' },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('Systemd.Unit through sshSudoRunner', () => {
|
|
24
|
+
test('create writes, reloads, enables and starts — all elevated', async () => {
|
|
25
|
+
const { privileged, runner } = fakeSudoHost();
|
|
26
|
+
const attrs = await reconcileUnit(runner, UNIT, undefined);
|
|
27
|
+
expect(attrs.active).toBe(true);
|
|
28
|
+
expect(attrs.enabled).toBe(true);
|
|
29
|
+
const verbs = privileged().map((call) => call[0]);
|
|
30
|
+
expect(verbs.filter((v) => v === 'systemctl')).toEqual([]); // canonicalised to the absolute path
|
|
31
|
+
expect(verbs).toContain('/usr/bin/systemctl');
|
|
32
|
+
// write (install+mv), daemon-reload, enable, start.
|
|
33
|
+
expect(privileged().filter((c) => c[0] === '/usr/bin/install')).toHaveLength(1);
|
|
34
|
+
expect(
|
|
35
|
+
privileged().some((c) => c[0] === '/usr/bin/systemctl' && c[1] === 'daemon-reload'),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
expect(privileged().some((c) => c[1] === 'enable')).toBe(true);
|
|
38
|
+
expect(privileged().some((c) => c[1] === 'start')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('an unchanged unit is reconciled again with zero privileged calls', async () => {
|
|
42
|
+
const { privileged, runner } = fakeSudoHost();
|
|
43
|
+
const created = await reconcileUnit(runner, UNIT, undefined);
|
|
44
|
+
expect(await diffUnit(runner, UNIT, created)).toEqual({ action: 'noop' });
|
|
45
|
+
const before = privileged().length;
|
|
46
|
+
const again = await reconcileUnit(runner, UNIT, created);
|
|
47
|
+
expect(privileged().length).toBe(before); // ★ no restart, no reload — the whole point.
|
|
48
|
+
expect(again.active).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a restartOn digest change updates and restarts, via elevated calls only', async () => {
|
|
52
|
+
const { privileged, runner } = fakeSudoHost();
|
|
53
|
+
const created = await reconcileUnit(runner, UNIT, undefined);
|
|
54
|
+
const changed: SystemdUnitProps = { ...UNIT, restartOn: ['new-digest'] };
|
|
55
|
+
expect((await diffUnit(runner, changed, created))?.action).toBe('update');
|
|
56
|
+
const before = privileged().length;
|
|
57
|
+
const updated = await reconcileUnit(runner, changed, created);
|
|
58
|
+
expect(privileged().length).toBeGreaterThan(before);
|
|
59
|
+
expect(updated.configSha256).not.toBe(created.configSha256);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('delete stops, disables, removes the file, reloads — all elevated', async () => {
|
|
63
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
64
|
+
const created = await reconcileUnit(runner, UNIT, undefined);
|
|
65
|
+
await deleteUnit(runner, created);
|
|
66
|
+
expect(fake.files.has(created.unitPath)).toBe(false);
|
|
67
|
+
const verbs = privileged()
|
|
68
|
+
.filter((c) => c[0] === '/usr/bin/systemctl')
|
|
69
|
+
.map((c) => c[1]);
|
|
70
|
+
expect(verbs).toContain('stop');
|
|
71
|
+
expect(verbs).toContain('disable');
|
|
72
|
+
expect(verbs).toContain('daemon-reload');
|
|
73
|
+
expect(privileged().some((c) => c[0] === '/usr/bin/rm')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a vendor unit outside every prefix is unreachable, refused before sudo runs', async () => {
|
|
77
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
78
|
+
fake.placeUnit('/usr/lib/systemd/system/pveproxy.service', '[Service]\nExecStart=/x\n', {
|
|
79
|
+
active: true,
|
|
80
|
+
enabled: true,
|
|
81
|
+
});
|
|
82
|
+
await expect(runner.exec(['systemctl', 'restart', '--', 'pveproxy.service'])).rejects.toThrow(
|
|
83
|
+
SudoRefusedError,
|
|
84
|
+
);
|
|
85
|
+
await expect(runner.exec(['systemctl', 'restart', '--', 'pveproxy.service'])).rejects.toThrow(
|
|
86
|
+
'FragmentPath',
|
|
87
|
+
);
|
|
88
|
+
expect(privileged()).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 🔴 Adversarial review, round 2: a masked vendor unit also reads FragmentPath empty, which
|
|
92
|
+
// used to fall into the "no file, elevate anyway" exemption regardless of verb.
|
|
93
|
+
test('a masked unit is refused, even one this runner’s own prefix could otherwise reach', async () => {
|
|
94
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
95
|
+
fake.placeUnit('/etc/systemd/system/pveproxy.service', '[Service]\nExecStart=/x\n', {
|
|
96
|
+
active: false,
|
|
97
|
+
enabled: false,
|
|
98
|
+
masked: true,
|
|
99
|
+
});
|
|
100
|
+
for (const verb of ['enable', 'start', 'stop', 'disable', 'restart']) {
|
|
101
|
+
await expect(runner.exec(['systemctl', verb, '--', 'pveproxy.service'])).rejects.toThrow(
|
|
102
|
+
'is masked',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
expect(privileged()).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 🔴 Adversarial review, round 2: the empty-FragmentPath exemption used to admit every write
|
|
109
|
+
// verb, not just the two `deleteUnit` actually needs — so a kernel-generated pseudo-unit like
|
|
110
|
+
// `init.scope` (empty FragmentPath, never file-backed) could still elevate enable/start/restart.
|
|
111
|
+
test('a unit with no file at all elevates stop/disable but refuses enable/start/restart', async () => {
|
|
112
|
+
const { privileged, runner } = fakeSudoHost();
|
|
113
|
+
for (const verb of ['enable', 'start', 'restart']) {
|
|
114
|
+
await expect(runner.exec(['systemctl', verb, '--', 'init.scope'])).rejects.toThrow(
|
|
115
|
+
'is not stop/disable',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
expect(privileged()).toEqual([]);
|
|
119
|
+
// stop/disable of a genuinely unknown unit still REACHES systemctl as root (which itself
|
|
120
|
+
// then judges it, exit 0 or not) — this runner's job is only to decide whether to ASK as
|
|
121
|
+
// root, not to pre-judge every unit name that might exist.
|
|
122
|
+
await runner.exec(['systemctl', 'stop', '--', 'init.scope']);
|
|
123
|
+
expect(privileged().some((c) => c[0] === '/usr/bin/systemctl' && c[1] === 'stop')).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const SCRIPT: RemoteFileProps = {
|
|
128
|
+
content: '#!/bin/sh\necho hi\n',
|
|
129
|
+
mode: 0o755,
|
|
130
|
+
path: '/usr/local/bin/hf-thing',
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
describe('Remote.File through sshSudoRunner', () => {
|
|
134
|
+
test('create, update and delete, all through install/mv/rm', async () => {
|
|
135
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
136
|
+
const created = await reconcileFile(runner, SCRIPT);
|
|
137
|
+
expect(fake.files.get(SCRIPT.path)?.mode).toBe(0o755);
|
|
138
|
+
expect(privileged().map((c) => c[0])).toEqual(['/usr/bin/install', '/usr/bin/mv']);
|
|
139
|
+
|
|
140
|
+
const updated = await reconcileFile(
|
|
141
|
+
runner,
|
|
142
|
+
{ ...SCRIPT, content: '#!/bin/sh\necho bye\n' },
|
|
143
|
+
created,
|
|
144
|
+
);
|
|
145
|
+
expect(new TextDecoder().decode(fake.files.get(SCRIPT.path)?.bytes)).toContain('bye');
|
|
146
|
+
|
|
147
|
+
await deleteFile(runner, updated);
|
|
148
|
+
expect(fake.files.has(SCRIPT.path)).toBe(false);
|
|
149
|
+
expect(privileged().some((c) => c[0] === '/usr/bin/rm' && c.at(-1) === SCRIPT.path)).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshSudoRunner over the fake Linux host end to end: writeFileAtomic's exact argv sequence and
|
|
3
|
+
* final bytes/mode/owner, a failed `mv` cleaning up the temp, `removeFile` of an absent path never
|
|
4
|
+
* calling sudo, `checkWrite` never calling sudo, and every logged line carrying no file content.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { CHOWN, INSTALL, MKDIR, MV, RM, RMDIR } from './sudo-allowlist.ts';
|
|
8
|
+
import { fakeSudoHost } from './fake-sudo.ts';
|
|
9
|
+
import { SudoRefusedError } from './sudo-runner.ts';
|
|
10
|
+
|
|
11
|
+
const SECRET = 'super-secret-config-value=hunter2\n';
|
|
12
|
+
const DEST = '/etc/systemd/system/thing.service';
|
|
13
|
+
|
|
14
|
+
describe('what it claims', () => {
|
|
15
|
+
test('privileged, as the operator: it elevates calls, it is not root', () => {
|
|
16
|
+
const { runner } = fakeSudoHost();
|
|
17
|
+
expect(runner.privileged).toBe(true);
|
|
18
|
+
expect(runner.effectiveUid()).toBe(1000);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('writeFileAtomic under a prefix', () => {
|
|
23
|
+
test('install into a derived temp, then mv onto the path — exact argv, and the bytes land', async () => {
|
|
24
|
+
const { fake, logs, privileged, runner } = fakeSudoHost();
|
|
25
|
+
await runner.writeFileAtomic(DEST, new TextEncoder().encode(SECRET), {
|
|
26
|
+
gid: 0,
|
|
27
|
+
mode: 0o644,
|
|
28
|
+
uid: 0,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const calls = privileged();
|
|
32
|
+
expect(calls).toHaveLength(2);
|
|
33
|
+
const [install, mv] = calls;
|
|
34
|
+
expect(install?.[0]).toBe(INSTALL);
|
|
35
|
+
expect(install?.slice(1, 3)).toEqual(['-m', '0644']);
|
|
36
|
+
// install: [-m, mode, -T, --, staged, temp] — the temp is derived FROM dest, in its directory.
|
|
37
|
+
expect(install?.at(-1)).toMatch(
|
|
38
|
+
/^\/etc\/systemd\/system\/\.thing\.service\.hf-[0-9a-f]{12}\.tmp$/,
|
|
39
|
+
);
|
|
40
|
+
expect(mv?.[0]).toBe(MV);
|
|
41
|
+
expect(mv?.slice(1, 3)).toEqual(['-f', '-T']);
|
|
42
|
+
// mv: [-f, -T, --, temp, dest] — its source is exactly install's target.
|
|
43
|
+
expect(mv?.at(-2)).toBe(install?.at(-1));
|
|
44
|
+
expect(mv?.at(-1)).toBe(DEST);
|
|
45
|
+
|
|
46
|
+
const file = fake.files.get(DEST);
|
|
47
|
+
expect(file?.mode).toBe(0o644);
|
|
48
|
+
expect(file?.uid).toBe(0);
|
|
49
|
+
expect(new TextDecoder().decode(file?.bytes)).toBe(SECRET);
|
|
50
|
+
// ⛔ Bytes never travel through argv or the log.
|
|
51
|
+
expect(logs.some((line) => line.includes('hunter2'))).toBe(false);
|
|
52
|
+
for (const line of logs) expect(line.startsWith('homeflare/linux sudo -n ')).toBe(true);
|
|
53
|
+
// No temp file left behind, and the staging directory is gone too.
|
|
54
|
+
expect([...fake.files.keys()].some((path) => path.includes('.hf-'))).toBe(false);
|
|
55
|
+
expect([...fake.dirs.keys()].some((path) => path.startsWith('/tmp/hf-sudo-'))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a failed mv removes the temp it just installed, then still throws', async () => {
|
|
59
|
+
const { fake, privileged, runner, state } = fakeSudoHost();
|
|
60
|
+
state.mvFailure = { exitCode: 1, stderr: 'mv: simulated failure', stdout: '' };
|
|
61
|
+
await expect(
|
|
62
|
+
runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 }),
|
|
63
|
+
).rejects.toThrow('simulated failure');
|
|
64
|
+
|
|
65
|
+
const calls = privileged();
|
|
66
|
+
expect(calls.map((c) => c[0])).toEqual([INSTALL, MV, RM]);
|
|
67
|
+
// ★ rm's argument is install's own temp target — the exact path mv failed to move.
|
|
68
|
+
expect(calls[2]?.at(-1)).toBe(calls[0]?.at(-1));
|
|
69
|
+
expect(fake.files.has(DEST)).toBe(false);
|
|
70
|
+
expect([...fake.files.keys()].some((path) => path.includes('.hf-'))).toBe(false);
|
|
71
|
+
expect([...fake.dirs.keys()].some((path) => path.startsWith('/tmp/hf-sudo-'))).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// 🔴 Adversarial review, round 2: a failed CLEANUP rm after a failed mv used to be swallowed
|
|
75
|
+
// silently — the operator learned only about the mv failure, never that a root-owned temp
|
|
76
|
+
// was now littered on the host. The thrown error must say so and name the path.
|
|
77
|
+
test('a cleanup rm that ALSO fails is reported, not swallowed — names the leftover temp', async () => {
|
|
78
|
+
const { privileged, runner, state } = fakeSudoHost();
|
|
79
|
+
state.mvFailure = { exitCode: 1, stderr: 'mv: simulated failure', stdout: '' };
|
|
80
|
+
state.rmFailure = { exitCode: 1, stderr: 'rm: simulated cleanup failure', stdout: '' };
|
|
81
|
+
const write = runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 });
|
|
82
|
+
await expect(write).rejects.toThrow('simulated failure');
|
|
83
|
+
await expect(write).rejects.toThrow('also failed');
|
|
84
|
+
// 🔴 Adversarial review, round 3: `String(cleaned)` on the raw ExecResult printed
|
|
85
|
+
// `[object Object]`, silently dropping rm's own stderr — assert the actual text survives.
|
|
86
|
+
await expect(write).rejects.toThrow('rm: simulated cleanup failure');
|
|
87
|
+
const calls = privileged();
|
|
88
|
+
expect(calls.map((c) => c[0])).toEqual([INSTALL, MV, RM]);
|
|
89
|
+
await expect(write).rejects.toThrow(calls[0]?.at(-1) ?? 'unreachable');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 🔴 Adversarial review, round 2: install never carries -o/-g (coreutils has no way to force
|
|
93
|
+
// numeric there); a non-root owner or group is set by a separate, +-forced chown on the same
|
|
94
|
+
// temp, before mv — and only when one is actually declared, never for a plain root write.
|
|
95
|
+
test('a non-root owner is set by a +-forced chown on the temp, between install and mv', async () => {
|
|
96
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
97
|
+
const path = '/usr/local/bin/hf-thing';
|
|
98
|
+
await runner.writeFileAtomic(path, new TextEncoder().encode('x'), {
|
|
99
|
+
gid: 60,
|
|
100
|
+
mode: 0o644,
|
|
101
|
+
uid: 900,
|
|
102
|
+
});
|
|
103
|
+
const calls = privileged();
|
|
104
|
+
expect(calls.map((c) => c[0])).toEqual([INSTALL, CHOWN, MV]);
|
|
105
|
+
expect(calls[0]?.includes('-o')).toBe(false);
|
|
106
|
+
expect(calls[0]?.includes('-g')).toBe(false);
|
|
107
|
+
expect(calls[1]).toEqual([CHOWN, '+900:+60', '--', calls[0]?.at(-1) ?? '']);
|
|
108
|
+
expect(fake.files.get(path)).toMatchObject({ gid: 60, uid: 900 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a plain root write never chowns — install’s own default already matches', async () => {
|
|
112
|
+
const { privileged, runner } = fakeSudoHost();
|
|
113
|
+
await runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 });
|
|
114
|
+
expect(privileged().map((c) => c[0])).toEqual([INSTALL, MV]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the derived temp is verified absent before install runs', async () => {
|
|
118
|
+
const { base, privileged, runner } = fakeSudoHost();
|
|
119
|
+
// The temp's 12-hex suffix is random, so simulate a collision by making `stat` claim
|
|
120
|
+
// something is already at any `.hf-….tmp` path — `dest` itself is unaffected, so vetWrite's
|
|
121
|
+
// own read of it still sees the real (absent) state.
|
|
122
|
+
const originalStat = base.stat;
|
|
123
|
+
base.stat = async (path) =>
|
|
124
|
+
/\.hf-[0-9a-f]{12}\.tmp$/.test(path)
|
|
125
|
+
? { gid: 0, kind: 'file', mode: 0o600, size: 0, uid: 0 }
|
|
126
|
+
: originalStat(path);
|
|
127
|
+
await expect(
|
|
128
|
+
runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 }),
|
|
129
|
+
).rejects.toBeInstanceOf(SudoRefusedError);
|
|
130
|
+
await expect(
|
|
131
|
+
runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 }),
|
|
132
|
+
).rejects.toThrow('already exists');
|
|
133
|
+
// ★ Refused before `install` ever ran — nothing was staged as root over a collision.
|
|
134
|
+
expect(privileged()).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('checkWrite', () => {
|
|
139
|
+
test('never calls sudo, under a prefix or outside one', async () => {
|
|
140
|
+
const { runner, logs, privileged } = fakeSudoHost();
|
|
141
|
+
await runner.checkWrite?.(DEST, { mode: 0o644, uid: 0, gid: 0 });
|
|
142
|
+
await expect(runner.checkWrite?.('/opt/elsewhere/x', { mode: 0o644 })).resolves.toBeUndefined();
|
|
143
|
+
expect(privileged()).toEqual([]);
|
|
144
|
+
expect(logs).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('removeFile', () => {
|
|
149
|
+
test('an absent path under a prefix is a no-op, no sudo', async () => {
|
|
150
|
+
const { privileged, runner } = fakeSudoHost();
|
|
151
|
+
await runner.removeFile('/etc/systemd/system/absent.service');
|
|
152
|
+
expect(privileged()).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a present file under a prefix is removed with rm -f --', async () => {
|
|
156
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
157
|
+
fake.files.set(DEST, { bytes: new Uint8Array(), gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
158
|
+
await runner.removeFile(DEST);
|
|
159
|
+
expect(privileged()).toEqual([[RM, '-f', '--', DEST]]);
|
|
160
|
+
expect(fake.files.has(DEST)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('exec routing', () => {
|
|
165
|
+
test('mkdir/chown under a prefix run as root with absolute argv, chown +-forced', async () => {
|
|
166
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
167
|
+
await runner.exec(['mkdir', '-m', '750', '--', '/usr/local/bin/sub']);
|
|
168
|
+
await runner.exec(['chown', '0:0', '--', '/usr/local/bin/sub']);
|
|
169
|
+
expect(privileged()).toEqual([
|
|
170
|
+
[MKDIR, '-m', '750', '--', '/usr/local/bin/sub'],
|
|
171
|
+
[CHOWN, '+0:+0', '--', '/usr/local/bin/sub'],
|
|
172
|
+
]);
|
|
173
|
+
expect(fake.modes.get('/usr/local/bin/sub')).toEqual({ gid: 0, mode: 0o750, uid: 0 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('mkdir/chmod/chown with a dangerous mode or a non-root owner are refused before sudo', async () => {
|
|
177
|
+
const { privileged, runner } = fakeSudoHost();
|
|
178
|
+
await expect(
|
|
179
|
+
runner.exec(['mkdir', '-m', '777', '--', '/usr/local/bin/world']),
|
|
180
|
+
).rejects.toBeInstanceOf(SudoRefusedError);
|
|
181
|
+
await expect(
|
|
182
|
+
runner.exec(['chown', '900:60', '--', '/usr/local/bin/sub']),
|
|
183
|
+
).rejects.toBeInstanceOf(SudoRefusedError);
|
|
184
|
+
expect(privileged()).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('rmdir under a prefix runs as root', async () => {
|
|
188
|
+
const { fake, privileged, runner } = fakeSudoHost({ '/usr/local/bin/sub': 0 });
|
|
189
|
+
await runner.exec(['rmdir', '--', '/usr/local/bin/sub']);
|
|
190
|
+
expect(privileged()).toEqual([[RMDIR, '--', '/usr/local/bin/sub']]);
|
|
191
|
+
expect(fake.dirs.has('/usr/local/bin/sub')).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('mkdir outside every prefix runs as the operator, no sudo', async () => {
|
|
195
|
+
const { privileged, runner } = fakeSudoHost({ '/opt': 0 });
|
|
196
|
+
const result = await runner.exec(['mkdir', '-m', '750', '--', '/opt/elsewhere']);
|
|
197
|
+
expect(result.exitCode).toBe(0);
|
|
198
|
+
expect(privileged()).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('a sudo argv is refused before anything runs', async () => {
|
|
202
|
+
await expect(fakeSudoHost().runner.exec(['sudo', 'true'])).rejects.toBeInstanceOf(
|
|
203
|
+
SudoRefusedError,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|