@homeflare/alchemy 0.25.1 → 0.26.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/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/package.json +1 -1
- 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,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mode/owner and read-back half of sudo-guard.ts, split out to keep that file under the
|
|
3
|
+
* house's 250-line cap. `assertPlainPath` (path chain) stays there; this is "is the mode/owner
|
|
4
|
+
* safe, and can the deploying user read the result back" — re-exported from sudo-guard.ts so
|
|
5
|
+
* nothing importing from there has to know this file exists.
|
|
6
|
+
*/
|
|
7
|
+
import type { HostRunner, WriteOptions } from './runner.ts';
|
|
8
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
9
|
+
|
|
10
|
+
const refuse = (path: string, message: string): Error =>
|
|
11
|
+
new SudoRefusedError(`sudoRunner ${path}: ${message}`);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ⛔ A ROOT-OWNED FILE UNDER A PREFIX IS ROOT'S TO CHANGE, AND ONLY ROOT'S (decided 2026-09-21).
|
|
15
|
+
* Group- or world-writable, anyone in that class rewrites a file root installed — a daemon's
|
|
16
|
+
* config, a script a LaunchDaemon runs as root — which is root by another name. Setuid or setgid,
|
|
17
|
+
* it runs as root (or its group) for whoever executes it. install(1) would apply either, as root.
|
|
18
|
+
* ★ Root-owned is an omitted owner (the runner installs as root under a prefix) or uid 0. A file
|
|
19
|
+
* handed to another user is that user's to change; the sticky bit changes nothing on a file.
|
|
20
|
+
* 🔴 MEASURED (adversarial review, 2026-09-23, round 1): "handed to another user" checked only
|
|
21
|
+
* `uid`, never `gid`. `{ uid: 501, gid: 0, mode: 0o2775 }` — a setgid file owned by an
|
|
22
|
+
* unprivileged uid but GROUPED to root — passed untouched, because a non-zero uid alone skipped
|
|
23
|
+
* every check below. Fixed by also treating `gid: 0` as root-adjacent.
|
|
24
|
+
* 🔴 MEASURED (adversarial review, round 2, same day): the round-1 fix still missed an OMITTED
|
|
25
|
+
* `gid`. `remote-file-plan.ts`'s `identity()` returns no `gid` key at all when a `RemoteFile`
|
|
26
|
+
* declares no `group`, so `{ uid: 501, mode: 0o2775 }` reaches this check with `gid: undefined`
|
|
27
|
+
* — neither `0` nor genuinely someone else's. `sudo-write.ts` then omits `-g` from `install`
|
|
28
|
+
* entirely, and GNU `install`, run as root via `sudo -n`, defaults an omitted `-g` to the
|
|
29
|
+
* INVOKING PROCESS's group — root's own — whenever the destination directory is not itself
|
|
30
|
+
* setgid (which none of this runner's declared prefixes are, measured in linux-host-measured.md).
|
|
31
|
+
* The file lands exactly as `{ uid: 501, gid: 0, mode: 0o2775 }` — the identical escalation the
|
|
32
|
+
* round-1 fix closed, reached by OMISSION instead of an explicit `group: 0`. So "root-adjacent"
|
|
33
|
+
* now also means an omitted `gid`: only a file with a genuinely non-root, EXPLICITLY GIVEN owner
|
|
34
|
+
* AND group is someone else's alone to make setuid/setgid or writable — an omission is never
|
|
35
|
+
* read as a safe default on either side, uid or gid.
|
|
36
|
+
*/
|
|
37
|
+
export const modeProblem = (options: WriteOptions): string | undefined => {
|
|
38
|
+
const rootAdjacent =
|
|
39
|
+
options.uid === undefined ||
|
|
40
|
+
options.uid === 0 ||
|
|
41
|
+
options.gid === undefined ||
|
|
42
|
+
options.gid === 0;
|
|
43
|
+
if (!rootAdjacent) return undefined;
|
|
44
|
+
const found: string[] = [];
|
|
45
|
+
if ((options.mode & 0o6000) !== 0) found.push('setuid/setgid');
|
|
46
|
+
if ((options.mode & 0o022) !== 0) found.push('writable by group or other');
|
|
47
|
+
if (found.length === 0) return undefined;
|
|
48
|
+
return `mode ${options.mode.toString(8).padStart(4, '0')} is ${found.join(' and ')} on a root-owned file`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* POSIX read permission for one uid: owner class, else group class, else other. `undefined` when
|
|
53
|
+
* only group membership could decide and the groups are unknown.
|
|
54
|
+
* ⚠️ EXACTLY ONE CLASS APPLIES. A group member gets the group bits even when the other bits are
|
|
55
|
+
* wider (`0604` hides a file from its group), so "other can read" is not enough on its own.
|
|
56
|
+
*/
|
|
57
|
+
export const canRead = (
|
|
58
|
+
file: { readonly uid: number; readonly gid: number; readonly mode: number },
|
|
59
|
+
uid: number,
|
|
60
|
+
groups: readonly number[] | undefined,
|
|
61
|
+
): boolean | undefined => {
|
|
62
|
+
if (uid === 0) return true;
|
|
63
|
+
if (file.uid === uid) return (file.mode & 0o400) !== 0;
|
|
64
|
+
const groupRead = (file.mode & 0o040) !== 0;
|
|
65
|
+
const otherRead = (file.mode & 0o004) !== 0;
|
|
66
|
+
// Membership cannot change the answer when both classes agree.
|
|
67
|
+
if (groupRead === otherRead) return groupRead;
|
|
68
|
+
if (groups === undefined) return undefined;
|
|
69
|
+
return groups.includes(file.gid) ? groupRead : otherRead;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ⛔ REFUSE A FILE THE DEPLOYING USER COULD NOT READ BACK, before writing it. Both providers read
|
|
74
|
+
* every write back to verify it (host-file-lifecycle.ts, job-lifecycle.ts) and read it again at
|
|
75
|
+
* every plan, as the deploying user; this runner elevates writes, never reads. A root-only
|
|
76
|
+
* `0600` would be installed, then fail the read-back with EACCES and stay on disk with no state,
|
|
77
|
+
* so every later plan would fail too.
|
|
78
|
+
* ★ An omitted owner is root (under a prefix the runner writes as root). An omitted group is the
|
|
79
|
+
* parent directory's, which is the group a new file gets on macOS.
|
|
80
|
+
* ⚠️ Unknown groups never refuse: the read-back after the write stays the final judge.
|
|
81
|
+
*/
|
|
82
|
+
export const assertReadableBack = async (
|
|
83
|
+
base: HostRunner,
|
|
84
|
+
path: string,
|
|
85
|
+
options: WriteOptions,
|
|
86
|
+
groups: readonly number[] | undefined,
|
|
87
|
+
): Promise<void> => {
|
|
88
|
+
const operator = base.effectiveUid();
|
|
89
|
+
const parent = path.slice(0, path.lastIndexOf('/')) || '/';
|
|
90
|
+
const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
|
|
91
|
+
const file = { gid, mode: options.mode, uid: options.uid ?? 0 };
|
|
92
|
+
if (canRead(file, operator, groups) !== false) return;
|
|
93
|
+
throw refuse(
|
|
94
|
+
path,
|
|
95
|
+
`owner ${String(file.uid)}, group ${String(gid)}, mode ${file.mode.toString(8)} would hide it ` +
|
|
96
|
+
`from the deploying user (uid ${String(operator)}), who reads every file back. Declare a ` +
|
|
97
|
+
'mode that user can read, or deploy as root. Nothing was written.',
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The deploying user's groups, from `id -G`; `undefined` when that fails.
|
|
103
|
+
* ⚠️ NOT process.getgroups(). MEASURED 2026-09-21 on macOS 27.2: getgroups() returned 16 groups
|
|
104
|
+
* where `id -G` returned 18, because macOS caps a process's own list at 16. A group past the
|
|
105
|
+
* 16th would read as "not a member" and refuse a file the user can in fact read.
|
|
106
|
+
*/
|
|
107
|
+
export const operatorGroups = async (base: HostRunner): Promise<readonly number[] | undefined> => {
|
|
108
|
+
const result = await base.exec(['/usr/bin/id', '-G']);
|
|
109
|
+
const ids = result.stdout.trim().split(/\s+/);
|
|
110
|
+
return result.exitCode === 0 && ids.every((id) => /^\d+$/.test(id)) ? ids.map(Number) : undefined;
|
|
111
|
+
};
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
import type { FileStat, HostRunner, WriteOptions } from './runner.ts';
|
|
24
24
|
import { assertNoAclGrants } from './sudo-acl.ts';
|
|
25
25
|
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
26
|
+
import { assertReadableBack, modeProblem } from './sudo-guard-mode.ts';
|
|
27
|
+
|
|
28
|
+
export { assertReadableBack, canRead, modeProblem, operatorGroups } from './sudo-guard-mode.ts';
|
|
26
29
|
|
|
27
30
|
const refuse = (path: string, message: string): Error =>
|
|
28
31
|
new SudoRefusedError(`sudoRunner ${path}: ${message}`);
|
|
@@ -126,23 +129,6 @@ export const assertPlainPath = async (
|
|
|
126
129
|
throw refuse(path, `is ${target === undefined ? 'missing' : `a ${target.kind}`}, not a file`);
|
|
127
130
|
};
|
|
128
131
|
|
|
129
|
-
/**
|
|
130
|
-
* ⛔ A ROOT-OWNED FILE UNDER A PREFIX IS ROOT'S TO CHANGE, AND ONLY ROOT'S (decided 2026-09-21).
|
|
131
|
-
* Group- or world-writable, anyone in that class rewrites a file root installed — a daemon's
|
|
132
|
-
* config, a script a LaunchDaemon runs as root — which is root by another name. Setuid or setgid,
|
|
133
|
-
* it runs as root (or its group) for whoever executes it. install(1) would apply either, as root.
|
|
134
|
-
* ★ Root-owned is an omitted owner (the runner installs as root under a prefix) or uid 0. A file
|
|
135
|
-
* handed to another user is that user's to change; the sticky bit changes nothing on a file.
|
|
136
|
-
*/
|
|
137
|
-
export const modeProblem = (options: WriteOptions): string | undefined => {
|
|
138
|
-
if (options.uid !== undefined && options.uid !== 0) return undefined;
|
|
139
|
-
const found: string[] = [];
|
|
140
|
-
if ((options.mode & 0o6000) !== 0) found.push('setuid/setgid');
|
|
141
|
-
if ((options.mode & 0o022) !== 0) found.push('writable by group or other');
|
|
142
|
-
if (found.length === 0) return undefined;
|
|
143
|
-
return `mode ${options.mode.toString(8).padStart(4, '0')} is ${found.join(' and ')} on a root-owned file`;
|
|
144
|
-
};
|
|
145
|
-
|
|
146
132
|
/**
|
|
147
133
|
* Every check a write under `prefix` meets before anything is staged or run as root: the mode, the
|
|
148
134
|
* path (assertPlainPath) and the read-back (assertReadableBack). ★ Reads only, as the deploying
|
|
@@ -166,65 +152,3 @@ export const assertInstallable = async (
|
|
|
166
152
|
await assertPlainPath(base, prefix, path, 'file-or-absent');
|
|
167
153
|
await assertReadableBack(base, path, options, groups);
|
|
168
154
|
};
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* POSIX read permission for one uid: owner class, else group class, else other. `undefined` when
|
|
172
|
-
* only group membership could decide and the groups are unknown.
|
|
173
|
-
* ⚠️ EXACTLY ONE CLASS APPLIES. A group member gets the group bits even when the other bits are
|
|
174
|
-
* wider (`0604` hides a file from its group), so "other can read" is not enough on its own.
|
|
175
|
-
*/
|
|
176
|
-
export const canRead = (
|
|
177
|
-
file: { readonly uid: number; readonly gid: number; readonly mode: number },
|
|
178
|
-
uid: number,
|
|
179
|
-
groups: readonly number[] | undefined,
|
|
180
|
-
): boolean | undefined => {
|
|
181
|
-
if (uid === 0) return true;
|
|
182
|
-
if (file.uid === uid) return (file.mode & 0o400) !== 0;
|
|
183
|
-
const groupRead = (file.mode & 0o040) !== 0;
|
|
184
|
-
const otherRead = (file.mode & 0o004) !== 0;
|
|
185
|
-
// Membership cannot change the answer when both classes agree.
|
|
186
|
-
if (groupRead === otherRead) return groupRead;
|
|
187
|
-
if (groups === undefined) return undefined;
|
|
188
|
-
return groups.includes(file.gid) ? groupRead : otherRead;
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* ⛔ REFUSE A FILE THE DEPLOYING USER COULD NOT READ BACK, before writing it. Both providers read
|
|
193
|
-
* every write back to verify it (host-file-lifecycle.ts, job-lifecycle.ts) and read it again at
|
|
194
|
-
* every plan, as the deploying user; this runner elevates writes, never reads. A root-only
|
|
195
|
-
* `0600` would be installed, then fail the read-back with EACCES and stay on disk with no state,
|
|
196
|
-
* so every later plan would fail too.
|
|
197
|
-
* ★ An omitted owner is root (under a prefix the runner writes as root). An omitted group is the
|
|
198
|
-
* parent directory's, which is the group a new file gets on macOS.
|
|
199
|
-
* ⚠️ Unknown groups never refuse: the read-back after the write stays the final judge.
|
|
200
|
-
*/
|
|
201
|
-
export const assertReadableBack = async (
|
|
202
|
-
base: HostRunner,
|
|
203
|
-
path: string,
|
|
204
|
-
options: WriteOptions,
|
|
205
|
-
groups: readonly number[] | undefined,
|
|
206
|
-
): Promise<void> => {
|
|
207
|
-
const operator = base.effectiveUid();
|
|
208
|
-
const parent = path.slice(0, path.lastIndexOf('/')) || '/';
|
|
209
|
-
const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
|
|
210
|
-
const file = { gid, mode: options.mode, uid: options.uid ?? 0 };
|
|
211
|
-
if (canRead(file, operator, groups) !== false) return;
|
|
212
|
-
throw refuse(
|
|
213
|
-
path,
|
|
214
|
-
`owner ${String(file.uid)}, group ${String(gid)}, mode ${file.mode.toString(8)} would hide it ` +
|
|
215
|
-
`from the deploying user (uid ${String(operator)}), who reads every file back. Declare a ` +
|
|
216
|
-
'mode that user can read, or deploy as root. Nothing was written.',
|
|
217
|
-
);
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* The deploying user's groups, from `id -G`; `undefined` when that fails.
|
|
222
|
-
* ⚠️ NOT process.getgroups(). MEASURED 2026-09-21 on macOS 27.2: getgroups() returned 16 groups
|
|
223
|
-
* where `id -G` returned 18, because macOS caps a process's own list at 16. A group past the
|
|
224
|
-
* 16th would read as "not a member" and refuse a file the user can in fact read.
|
|
225
|
-
*/
|
|
226
|
-
export const operatorGroups = async (base: HostRunner): Promise<readonly number[] | undefined> => {
|
|
227
|
-
const result = await base.exec(['/usr/bin/id', '-G']);
|
|
228
|
-
const ids = result.stdout.trim().split(/\s+/);
|
|
229
|
-
return result.exitCode === 0 && ids.every((id) => /^\d+$/.test(id)) ? ids.map(Number) : undefined;
|
|
230
|
-
};
|
|
@@ -34,7 +34,44 @@ describe('modeProblem', () => {
|
|
|
34
34
|
['4755 root is setuid', { mode: 0o4755 }, 'setuid/setgid'],
|
|
35
35
|
['2755 root is setgid', { mode: 0o2755, uid: 0 }, 'setuid/setgid'],
|
|
36
36
|
['6777 root is both', { mode: 0o6777 }, 'setuid/setgid and writable by group or other'],
|
|
37
|
-
[
|
|
37
|
+
[
|
|
38
|
+
'0664 handed to another user AND an explicitly non-root group is theirs to change',
|
|
39
|
+
{ gid: 80, mode: 0o664, uid: OPERATOR },
|
|
40
|
+
undefined,
|
|
41
|
+
],
|
|
42
|
+
// 🔴 Adversarial review, 2026-09-23, round 1: the non-root-uid exemption alone let a
|
|
43
|
+
// setgid-root file through untouched as long as its OWNER was merely non-zero — the group is
|
|
44
|
+
// root's just as much as the owner being uid 0 would be. gid 0 must keep every check active.
|
|
45
|
+
[
|
|
46
|
+
'setgid to root’s own group, handed to another OWNER, is still root’s file to protect',
|
|
47
|
+
{ gid: 0, mode: 0o2775, uid: OPERATOR },
|
|
48
|
+
'setuid/setgid',
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
'group-writable with root’s own group, handed to another owner',
|
|
52
|
+
{ gid: 0, mode: 0o664, uid: OPERATOR },
|
|
53
|
+
'writable by group or other',
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
'handed to another owner AND another group is genuinely theirs, setgid included',
|
|
57
|
+
{ gid: 80, mode: 0o2775, uid: OPERATOR },
|
|
58
|
+
undefined,
|
|
59
|
+
],
|
|
60
|
+
// 🔴 Adversarial review, round 2: an OMITTED gid is not "someone else's" either — GNU install,
|
|
61
|
+
// run as root via sudo, defaults a missing -g to root's OWN group whenever the destination
|
|
62
|
+
// directory is not itself setgid (none of this runner's prefixes are), so a `RemoteFile`
|
|
63
|
+
// with an owner but no declared `group` reaches this check exactly as `gid: 0` would, and
|
|
64
|
+
// must be refused exactly as `gid: 0` is above.
|
|
65
|
+
[
|
|
66
|
+
'setgid, handed to another owner, with the group simply never declared',
|
|
67
|
+
{ mode: 0o2775, uid: OPERATOR },
|
|
68
|
+
'setuid/setgid',
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
'group-writable, handed to another owner, with the group simply never declared',
|
|
72
|
+
{ mode: 0o664, uid: OPERATOR },
|
|
73
|
+
'writable by group or other',
|
|
74
|
+
],
|
|
38
75
|
] as const)('%s', (_name, options, found) => {
|
|
39
76
|
const problem = modeProblem(options);
|
|
40
77
|
if (found === undefined) expect(problem).toBeUndefined();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "as root" half of fake-sudo.ts: what each allowlisted program does against the SAME
|
|
3
|
+
* in-memory host `fake-linux-host.ts` builds, once `/usr/bin/sudo` has decided to run it.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ TEST-ONLY, like fake-linux-host.ts: no provider imports it, it is not on the barrel.
|
|
6
|
+
* ★ CHOWN AND MKDIR ARE HANDLED HERE, NOT BY DELEGATING TO `fake.runner.exec()` — the fake's own
|
|
7
|
+
* `chown` refuses when its FIXED `root` flag is false (which it is: the base host models an
|
|
8
|
+
* unprivileged operator), and `mkdir`/`chmod`/`rmdir` do not check ownership at all. Only a
|
|
9
|
+
* handler that writes `fake.dirs` / `fake.modes` / `fake.files` directly proves the runner's
|
|
10
|
+
* OWN allowlist and guard are what gate root, not a permission check the fake happens to have.
|
|
11
|
+
* ★ INSTALL AND MV READ AND WRITE `fake.files` DIRECTLY TOO, because the staged file the operator
|
|
12
|
+
* wrote (via `stageRemote`'s ordinary `writeFileAtomic`) already lives there — there is no
|
|
13
|
+
* separate "staged bytes" side-channel to model, unlike the Mac fake's local temp files.
|
|
14
|
+
*/
|
|
15
|
+
import type { ExecResult, FileStat } from '../launchd/runner.ts';
|
|
16
|
+
import { CHMOD, CHOWN, INSTALL, MKDIR, MV, RM, RMDIR, SYSTEMCTL_ABS } from './sudo-allowlist.ts';
|
|
17
|
+
import type { fakeLinuxHost } from './fake-linux-host.ts';
|
|
18
|
+
|
|
19
|
+
type Fake = ReturnType<typeof fakeLinuxHost>;
|
|
20
|
+
|
|
21
|
+
/** What a test can force `sudo`'s next `mv` / `rm` to answer instead of actually running it. */
|
|
22
|
+
export type RootState = { mvFailure?: ExecResult; rmFailure?: ExecResult };
|
|
23
|
+
|
|
24
|
+
const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
|
|
25
|
+
const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
|
|
26
|
+
const parent = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/';
|
|
27
|
+
|
|
28
|
+
const applyOwner = (live: { uid: number; gid: number }, owner: string): void => {
|
|
29
|
+
const [uidPart, gidPart] = owner.includes(':') ? owner.split(':') : [owner, undefined];
|
|
30
|
+
if (uidPart !== undefined && uidPart !== '') live.uid = Number(uidPart);
|
|
31
|
+
if (gidPart !== undefined && gidPart !== '') live.gid = Number(gidPart);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const dirRoot = (fake: Fake, argv: readonly string[]): ExecResult | undefined => {
|
|
35
|
+
const [program, ...args] = argv;
|
|
36
|
+
if (program === MKDIR) {
|
|
37
|
+
const [, mode, , path] = args;
|
|
38
|
+
if (path === undefined) return fail(1, 'mkdir: missing operand');
|
|
39
|
+
fake.dirs.set(path, 0);
|
|
40
|
+
fake.modes.set(path, { gid: 0, mode: Number.parseInt(mode ?? '755', 8), uid: 0 });
|
|
41
|
+
return ok();
|
|
42
|
+
}
|
|
43
|
+
if (program === CHMOD) {
|
|
44
|
+
const [mode, , path] = args;
|
|
45
|
+
const live = path === undefined ? undefined : fake.modes.get(path);
|
|
46
|
+
if (live === undefined) return fail(1, `chmod: ${String(path)}: No such file or directory`);
|
|
47
|
+
live.mode = Number.parseInt(mode ?? '755', 8);
|
|
48
|
+
return ok();
|
|
49
|
+
}
|
|
50
|
+
if (program === RMDIR) {
|
|
51
|
+
const [, path] = args;
|
|
52
|
+
if (path === undefined || !fake.dirs.has(path)) {
|
|
53
|
+
return fail(1, `rmdir: '${String(path)}': No such file or directory`);
|
|
54
|
+
}
|
|
55
|
+
if ([...fake.files.keys()].some((file) => parent(file) === path)) {
|
|
56
|
+
return fail(1, `rmdir: failed to remove '${path}': Directory not empty`);
|
|
57
|
+
}
|
|
58
|
+
fake.dirs.delete(path);
|
|
59
|
+
fake.modes.delete(path);
|
|
60
|
+
return ok();
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const fileRoot = (
|
|
66
|
+
fake: Fake,
|
|
67
|
+
argv: readonly string[],
|
|
68
|
+
state: RootState,
|
|
69
|
+
): ExecResult | undefined => {
|
|
70
|
+
const [program, ...args] = argv;
|
|
71
|
+
if (program === INSTALL) {
|
|
72
|
+
const tIndex = args.indexOf('-T');
|
|
73
|
+
const [source, dest] = args.slice(-2);
|
|
74
|
+
const ids = args.slice(2, tIndex);
|
|
75
|
+
const owned = ids.includes('-o') ? Number(ids[ids.indexOf('-o') + 1]) : 0;
|
|
76
|
+
const grouped = ids.includes('-g') ? Number(ids[ids.indexOf('-g') + 1]) : 0;
|
|
77
|
+
const bytes = source === undefined ? undefined : fake.files.get(source)?.bytes;
|
|
78
|
+
if (bytes === undefined || dest === undefined) {
|
|
79
|
+
return fail(1, `install: cannot stat '${String(source)}': No such file or directory`);
|
|
80
|
+
}
|
|
81
|
+
const kind: FileStat['kind'] = 'file';
|
|
82
|
+
fake.files.set(dest, {
|
|
83
|
+
bytes,
|
|
84
|
+
gid: grouped,
|
|
85
|
+
kind,
|
|
86
|
+
mode: Number.parseInt(args[1] ?? '644', 8),
|
|
87
|
+
uid: owned,
|
|
88
|
+
});
|
|
89
|
+
return ok();
|
|
90
|
+
}
|
|
91
|
+
if (program === MV) {
|
|
92
|
+
if (state.mvFailure !== undefined) {
|
|
93
|
+
const failure = state.mvFailure;
|
|
94
|
+
delete state.mvFailure; // ★ once, like a real transient failure — not every mv after it
|
|
95
|
+
return failure;
|
|
96
|
+
}
|
|
97
|
+
const [source, dest] = args.slice(-2);
|
|
98
|
+
const entry = source === undefined ? undefined : fake.files.get(source);
|
|
99
|
+
if (entry === undefined || dest === undefined) {
|
|
100
|
+
return fail(1, `mv: cannot stat '${String(source)}': No such file or directory`);
|
|
101
|
+
}
|
|
102
|
+
fake.files.delete(source ?? '');
|
|
103
|
+
fake.files.set(dest, entry);
|
|
104
|
+
return ok();
|
|
105
|
+
}
|
|
106
|
+
if (program === RM) {
|
|
107
|
+
if (state.rmFailure !== undefined) {
|
|
108
|
+
const failure = state.rmFailure;
|
|
109
|
+
delete state.rmFailure; // ★ once, like a real transient failure
|
|
110
|
+
return failure;
|
|
111
|
+
}
|
|
112
|
+
const [, , path] = args;
|
|
113
|
+
if (path !== undefined) fake.files.delete(path);
|
|
114
|
+
return ok();
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `chown` on either a directory (`fake.modes`) or a file (`fake.files` — the derived temp,
|
|
121
|
+
* `sudo-write.ts`'s own non-root-owner step). `applyOwner` accepts the `+`-forced spec
|
|
122
|
+
* unchanged: `Number('+501')` parses to `501` in JS, same as a bare digit string.
|
|
123
|
+
*/
|
|
124
|
+
const chownRoot = (fake: Fake, argv: readonly string[]): ExecResult | undefined => {
|
|
125
|
+
if (argv[0] !== CHOWN) return undefined;
|
|
126
|
+
const [owner, , path] = argv.slice(1);
|
|
127
|
+
if (owner === undefined || path === undefined) {
|
|
128
|
+
return fail(1, `chown: ${String(path)}: No such file or directory`);
|
|
129
|
+
}
|
|
130
|
+
const dir = fake.modes.get(path);
|
|
131
|
+
if (dir !== undefined) {
|
|
132
|
+
applyOwner(dir, owner);
|
|
133
|
+
return ok();
|
|
134
|
+
}
|
|
135
|
+
const file = fake.files.get(path);
|
|
136
|
+
if (file !== undefined) {
|
|
137
|
+
applyOwner(file, owner);
|
|
138
|
+
return ok();
|
|
139
|
+
}
|
|
140
|
+
return fail(1, `chown: ${path}: No such file or directory`);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Everything `/usr/bin/sudo -n --` may run, dispatched as root against the same fake host. */
|
|
144
|
+
export const asRoot = async (
|
|
145
|
+
fake: Fake,
|
|
146
|
+
argv: readonly string[],
|
|
147
|
+
state: RootState = {},
|
|
148
|
+
): Promise<ExecResult> => {
|
|
149
|
+
const chown = chownRoot(fake, argv);
|
|
150
|
+
if (chown !== undefined) return chown;
|
|
151
|
+
const dir = dirRoot(fake, argv);
|
|
152
|
+
if (dir !== undefined) return dir;
|
|
153
|
+
const file = fileRoot(fake, argv, state);
|
|
154
|
+
if (file !== undefined) return file;
|
|
155
|
+
if (argv[0] === SYSTEMCTL_ABS) return fake.runner.exec(['systemctl', ...argv.slice(1)]);
|
|
156
|
+
return fail(1, `sudo: ${String(argv[0])}: command not found`);
|
|
157
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshSudoRunner over fake-linux-host.ts, for the tests beside it: the operator is uid 1000 and
|
|
3
|
+
* unprivileged, and `/usr/bin/sudo` is a tiny interpreter (fake-sudo-root.ts) that runs the
|
|
4
|
+
* allowlisted programs against the SAME in-memory host, as root. No test anywhere runs ssh or a
|
|
5
|
+
* real sudo.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY, like fake-linux-host.ts: no provider imports it, it is not on the barrel.
|
|
8
|
+
* ★ `ls -ldn` AND `mktemp -d` ARE MODELLED HERE, AS THE OPERATOR — sudo-guard.ts's chain read and
|
|
9
|
+
* sudo-stage.ts's staging both run unprivileged, so both need a host that answers them without
|
|
10
|
+
* sudo. `ls` reads through `fake.runner.stat()` (already correct for declared dirs, their
|
|
11
|
+
* implied root-owned ancestors, and files) and formats one `ls -ldn`-shaped line per path;
|
|
12
|
+
* `mktemp -d` registers a fresh 0700 directory the operator owns.
|
|
13
|
+
* ★ SUDO REFUSES A CALL THAT WAS NOT LOGGED FIRST, so every test holds the runner to "logged
|
|
14
|
+
* before it runs", exactly as the Mac fake does.
|
|
15
|
+
*/
|
|
16
|
+
import type { ExecResult, FileStat, HostRunner } from '../launchd/runner.ts';
|
|
17
|
+
import { SUDO_SAYS } from '../launchd/fake-sudo.ts';
|
|
18
|
+
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
19
|
+
import { type RootState, asRoot } from './fake-sudo-root.ts';
|
|
20
|
+
import { SUDO } from './sudo-allowlist.ts';
|
|
21
|
+
import { makeSshSudoRunner } from './sudo-runner.ts';
|
|
22
|
+
import { stageRemote } from './sudo-stage.ts';
|
|
23
|
+
|
|
24
|
+
export { SUDO_SAYS } from '../launchd/fake-sudo.ts';
|
|
25
|
+
|
|
26
|
+
export const OPERATOR = 1000;
|
|
27
|
+
export const PREFIXES = ['/etc/systemd/system', '/usr/local/bin'];
|
|
28
|
+
|
|
29
|
+
const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
|
|
30
|
+
const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
|
|
31
|
+
|
|
32
|
+
const permString = (mode: number): string => {
|
|
33
|
+
const triplet = (shift: number, special: number, ch: string, upper: string) => {
|
|
34
|
+
const r = (mode & (0o4 << shift)) === 0 ? '-' : 'r';
|
|
35
|
+
const w = (mode & (0o2 << shift)) === 0 ? '-' : 'w';
|
|
36
|
+
const exec = (mode & (0o1 << shift)) !== 0;
|
|
37
|
+
const marked = (mode & special) !== 0;
|
|
38
|
+
return `${r}${w}${marked ? (exec ? ch : upper) : exec ? 'x' : '-'}`;
|
|
39
|
+
};
|
|
40
|
+
return `${triplet(6, 0o4000, 's', 'S')}${triplet(3, 0o2000, 's', 'S')}${triplet(0, 0o1000, 't', 'T')}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const lsLine = (path: string, stat: FileStat, acl: boolean): string => {
|
|
44
|
+
const kind = stat.kind === 'directory' ? 'd' : stat.kind === 'symlink' ? 'l' : '-';
|
|
45
|
+
const target = stat.kind === 'symlink' ? ` -> ${path}.target` : '';
|
|
46
|
+
return (
|
|
47
|
+
`${kind}${permString(stat.mode)}${acl ? '+' : ''} 1 ${String(stat.uid)} ${String(stat.gid)} ` +
|
|
48
|
+
`${String(stat.size)} Sep 22 10:12 ${path}${target}`
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `extraDirs` adds more directories to the fake filesystem (a pre-existing tree outside every
|
|
54
|
+
* prefix, say); it never changes the runner's OWN prefixes, which stay `PREFIXES` throughout.
|
|
55
|
+
* `acl` is the set of directory paths a test wants to carry the `+` (POSIX ACL) flag.
|
|
56
|
+
*/
|
|
57
|
+
export const fakeSudoHost = (
|
|
58
|
+
extraDirs: Record<string, number> = {},
|
|
59
|
+
acl: ReadonlySet<string> = new Set(),
|
|
60
|
+
) => {
|
|
61
|
+
const fake = fakeLinuxHost({
|
|
62
|
+
dirs: { '/etc/systemd/system': 0, '/usr/local/bin': 0, ...extraDirs },
|
|
63
|
+
euid: OPERATOR,
|
|
64
|
+
users: { app: { gid: 60, home: '/opt/app', uid: 900 } },
|
|
65
|
+
});
|
|
66
|
+
let mktempCount = 0;
|
|
67
|
+
const logs: string[] = [];
|
|
68
|
+
const sudoCalls: string[][] = [];
|
|
69
|
+
const state: RootState & { sudo: 'ok' | keyof typeof SUDO_SAYS } = { sudo: 'ok' };
|
|
70
|
+
|
|
71
|
+
const handleLs = async (paths: readonly string[]): Promise<ExecResult> => {
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
for (const path of paths) {
|
|
74
|
+
const stat = await fake.runner.stat(path);
|
|
75
|
+
if (stat === undefined)
|
|
76
|
+
return fail(2, `ls: cannot access '${path}': No such file or directory`);
|
|
77
|
+
lines.push(lsLine(path, stat, acl.has(path)));
|
|
78
|
+
}
|
|
79
|
+
return ok(`${lines.join('\n')}\n`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleMktemp = (): ExecResult => {
|
|
83
|
+
mktempCount += 1;
|
|
84
|
+
const dir = `/tmp/hf-sudo-${String(mktempCount)}`;
|
|
85
|
+
fake.dirs.set(dir, OPERATOR);
|
|
86
|
+
fake.modes.set(dir, { gid: 0, mode: 0o700, uid: OPERATOR });
|
|
87
|
+
return ok(`${dir}\n`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** `rm -rf -- <dir>`, as the operator — `sudo-stage.ts`'s own cleanup of its staging directory. */
|
|
91
|
+
const handleRm = (argv: readonly string[]): ExecResult => {
|
|
92
|
+
const target = argv.at(-1);
|
|
93
|
+
if (target === undefined) return fail(1, 'rm: missing operand');
|
|
94
|
+
const under = (path: string) => path === target || path.startsWith(`${target}/`);
|
|
95
|
+
for (const path of [...fake.files.keys()].filter(under)) fake.files.delete(path);
|
|
96
|
+
for (const path of [...fake.dirs.keys()].filter(under)) {
|
|
97
|
+
fake.dirs.delete(path);
|
|
98
|
+
fake.modes.delete(path);
|
|
99
|
+
}
|
|
100
|
+
return ok();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const base: HostRunner = {
|
|
104
|
+
...fake.runner,
|
|
105
|
+
exec: async (argv) => {
|
|
106
|
+
// ★ Read-only, operator-level calls now go by absolute path too (cosmetic hardening from
|
|
107
|
+
// the 2026-09-23 adversarial review — see the sources' own headers); fakeLinuxHost's own
|
|
108
|
+
// dispatcher only recognises the bare names it was built against, so both spellings are
|
|
109
|
+
// handled here and `systemctl`'s absolute form is translated back to bare before it is
|
|
110
|
+
// handed off, rather than teaching the shared fixture a second spelling.
|
|
111
|
+
if (argv[0] === 'ls' || argv[0] === '/usr/bin/ls') {
|
|
112
|
+
if (argv[1] === '-ldn') return handleLs(argv.slice(3));
|
|
113
|
+
}
|
|
114
|
+
if ((argv[0] === 'mktemp' || argv[0] === '/usr/bin/mktemp') && argv[1] === '-d') {
|
|
115
|
+
return handleMktemp();
|
|
116
|
+
}
|
|
117
|
+
if (argv[0] === 'rm' || argv[0] === '/usr/bin/rm') return handleRm(argv);
|
|
118
|
+
if (argv[0] === '/usr/bin/systemctl')
|
|
119
|
+
return fake.runner.exec(['systemctl', ...argv.slice(1)]);
|
|
120
|
+
if (argv[0] !== SUDO) return fake.runner.exec(argv);
|
|
121
|
+
sudoCalls.push([...argv]);
|
|
122
|
+
if (logs[logs.length - 1] !== `homeflare/linux sudo -n ${JSON.stringify(argv.slice(3))}`) {
|
|
123
|
+
throw new Error(`fake sudo: ${JSON.stringify(argv)} was not logged before it ran`);
|
|
124
|
+
}
|
|
125
|
+
if (state.sudo !== 'ok') return fail(1, SUDO_SAYS[state.sudo]);
|
|
126
|
+
return asRoot(fake, argv.slice(3), state);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const runner = makeSshSudoRunner(PREFIXES, {
|
|
131
|
+
base,
|
|
132
|
+
log: (line) => logs.push(line),
|
|
133
|
+
// ★ The real stager, unmodified: it only ever calls `base.exec`/`base.writeFileAtomic`, both
|
|
134
|
+
// of which this fake's `mktemp`/`ls`/operator-level paths already answer.
|
|
135
|
+
stage: stageRemote(base),
|
|
136
|
+
});
|
|
137
|
+
/** The privileged commands run, without the `sudo -n --` in front. */
|
|
138
|
+
const privileged = () => sudoCalls.map((call) => call.slice(3));
|
|
139
|
+
// ★ `base` (unprivileged, but with `ls`/`mktemp` support) is exposed for sudo-guard.test.ts,
|
|
140
|
+
// which exercises assertGuardedChain directly, below the runner it is a building block of.
|
|
141
|
+
return { base, fake, logs, privileged, runner, state };
|
|
142
|
+
};
|
package/src/linux/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type { RemoteFileAttributes, RemoteFileProps } from './remote-file-form.t
|
|
|
19
19
|
export { RemoteFile, RemoteFileProvider } from './remote-file.ts';
|
|
20
20
|
export type { SshRunnerOptions } from './ssh-runner.ts';
|
|
21
21
|
export { sshRunner } from './ssh-runner.ts';
|
|
22
|
+
export type { SshSudoRunnerOptions } from './sudo-runner.ts';
|
|
23
|
+
export { sshSudoRunner } from './sudo-runner.ts';
|
|
22
24
|
export type {
|
|
23
25
|
ExecResult,
|
|
24
26
|
FileStat,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chown`'s two privileged shapes, split out of sudo-allowlist.ts to keep it under the house's
|
|
3
|
+
* 250-line cap: the ROOT-ONLY directory form (sudo-allowlist-dir.ts owns that check) and the
|
|
4
|
+
* FILE-ownership form used only on the derived temp file `sudo-write.ts` `install`s into, for a
|
|
5
|
+
* `RemoteFile` whose declared owner or group is not root.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ `install`'S OWN `-o`/`-g` ARE NEVER USED, ON EITHER PLATFORM'S ALLOWLIST NOW. 🔴 MEASURED
|
|
8
|
+
* (adversarial review, round 2, 2026-09-23, against coreutils 9.7 `src/install.c` `get_ids()`):
|
|
9
|
+
* unlike `chown`, `install` does NOT go through gnulib's `+`-aware `userspec.c` at all — its own
|
|
10
|
+
* `get_ids()` calls `getpwnam(owner_name)` / `getgrnam(group_name)` FIRST for ANY string,
|
|
11
|
+
* falling back to `xstrtoumax` only when no such account exists, with no `+`-prefix escape of
|
|
12
|
+
* any kind. So `install -o 0` on a host with a user or group literally named `"0"` installs
|
|
13
|
+
* owned by THAT account, not uid 0 — and there is no argv shape that closes this for `install`
|
|
14
|
+
* itself. The fix is to never let `install` set ownership: it always runs with no `-o`/`-g` (so
|
|
15
|
+
* the fresh temp file lands owned by whoever `sudo` runs it as — root:root, with no name lookup
|
|
16
|
+
* at all, since omitting the flags skips `get_ids` entirely), and this file's `chown` shape sets
|
|
17
|
+
* the FINAL ownership afterward, on the same temp file, before `mv`. `chown` DOES support `+`
|
|
18
|
+
* (gnulib `userspec.c`: `*u == '+' ? NULL : getpwnam(u)`, "if it starts with '+', skip the
|
|
19
|
+
* look-up") — repeated on each side of a `user:group` spec independently — so this is where the
|
|
20
|
+
* fix actually lands: `+<uid>`, `:+<gid>`, or `+<uid>:+<gid>`, never a bare digit string.
|
|
21
|
+
*/
|
|
22
|
+
import { CHOWN, dirProgramProblem } from './sudo-allowlist-dir.ts';
|
|
23
|
+
|
|
24
|
+
/** Bare `directory-lifecycle.ts` argv (`0`, `:0`, `0:0`, …) → the `+`-forced form sudo may run. */
|
|
25
|
+
export const forceNumericOwner = (spec: string): string => {
|
|
26
|
+
const [uidPart, gidPart] = spec.includes(':') ? spec.split(':') : [spec, undefined];
|
|
27
|
+
const uid = uidPart === undefined || uidPart === '' ? '' : `+${uidPart}`;
|
|
28
|
+
const gid = gidPart === undefined ? '' : `:+${gidPart}`;
|
|
29
|
+
return `${uid}${gid}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** `+<uid>`, `:+<gid>`, or `+<uid>:+<gid>` — numeric, `+`-forced, either or both sides. */
|
|
33
|
+
const SAFE_OWNER = /^(\+\d+(:\+\d+)?|:\+\d+)$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `chown <safe spec> -- <temp>`, bound to the exact temp `install` just wrote — the file-ownership
|
|
37
|
+
* half of `writeUnderPrefix`'s sequence. Any owner/group is allowed (a `RemoteFile` may declare
|
|
38
|
+
* one), as long as it is numeric and forced; the temp binding is what stops it reaching anywhere
|
|
39
|
+
* else, the same way `mv`'s own binding does.
|
|
40
|
+
*/
|
|
41
|
+
const fileChownProblem = (args: readonly string[], temp: string): string | undefined => {
|
|
42
|
+
const [owner, end, path, ...extra] = args;
|
|
43
|
+
if (
|
|
44
|
+
owner !== undefined &&
|
|
45
|
+
SAFE_OWNER.test(owner) &&
|
|
46
|
+
end === '--' &&
|
|
47
|
+
extra.length === 0 &&
|
|
48
|
+
path === temp
|
|
49
|
+
) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return (
|
|
53
|
+
'chown on the staged temp takes exactly `+<uid>`, `:+<gid>` or `+<uid>:+<gid>` -- ' +
|
|
54
|
+
'<the exact temp install just wrote>, numeric and +-forced'
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `chown`'s dispatch: a call bound to `context.temp` (its last argument equals it) is the
|
|
60
|
+
* file-ownership form above; everything else is the directory form, root-only
|
|
61
|
+
* (`sudo-allowlist-dir.ts`'s own `ROOT_OWNER`, `+`-forced the same way).
|
|
62
|
+
*/
|
|
63
|
+
export const chownProblem = (
|
|
64
|
+
args: readonly string[],
|
|
65
|
+
context: { readonly prefixes: readonly string[]; readonly temp?: string },
|
|
66
|
+
): string | undefined => {
|
|
67
|
+
const path = args[args.length - 1];
|
|
68
|
+
if (context.temp !== undefined && path === context.temp)
|
|
69
|
+
return fileChownProblem(args, context.temp);
|
|
70
|
+
return dirProgramProblem(CHOWN, args, context.prefixes);
|
|
71
|
+
};
|