@homeflare/alchemy 0.8.0 → 0.10.1

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.
Files changed (247) hide show
  1. package/README.md +22 -11
  2. package/dist/caddy/config-lifecycle.d.ts +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +221 -53
  5. package/dist/caddy/index.js.map +13 -9
  6. package/dist/launchd/fake-runner.d.ts +1 -0
  7. package/dist/launchd/fake-runner.d.ts.map +1 -1
  8. package/dist/launchd/fake-sudo.d.ts +1 -0
  9. package/dist/launchd/fake-sudo.d.ts.map +1 -1
  10. package/dist/launchd/host-effect.d.ts +9 -18
  11. package/dist/launchd/host-effect.d.ts.map +1 -1
  12. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  13. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  14. package/dist/launchd/host-file.d.ts +2 -1
  15. package/dist/launchd/host-file.d.ts.map +1 -1
  16. package/dist/launchd/index.js +345 -86
  17. package/dist/launchd/index.js.map +18 -12
  18. package/dist/launchd/job-lifecycle.d.ts +8 -1
  19. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  20. package/dist/launchd/job-preflight.d.ts +13 -1
  21. package/dist/launchd/job-preflight.d.ts.map +1 -1
  22. package/dist/launchd/job.d.ts +2 -1
  23. package/dist/launchd/job.d.ts.map +1 -1
  24. package/dist/launchd/runner.d.ts +7 -0
  25. package/dist/launchd/runner.d.ts.map +1 -1
  26. package/dist/launchd/sudo-acl.d.ts +25 -0
  27. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  28. package/dist/launchd/sudo-guard.d.ts +31 -4
  29. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  30. package/dist/launchd/sudo-runner.d.ts +3 -3
  31. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-said.d.ts +10 -0
  33. package/dist/launchd/sudo-said.d.ts.map +1 -0
  34. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  35. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  36. package/dist/openbao/auth-method.d.ts.map +1 -1
  37. package/dist/openbao/auth-role.d.ts.map +1 -1
  38. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  39. package/dist/openbao/fake-bao.d.ts +18 -0
  40. package/dist/openbao/fake-bao.d.ts.map +1 -1
  41. package/dist/openbao/fake-core.d.ts +39 -0
  42. package/dist/openbao/fake-core.d.ts.map +1 -0
  43. package/dist/openbao/fake-families.d.ts +6 -3
  44. package/dist/openbao/fake-families.d.ts.map +1 -1
  45. package/dist/openbao/fake-mounts.d.ts +16 -0
  46. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  47. package/dist/openbao/fake-stack.d.ts +5 -1
  48. package/dist/openbao/fake-stack.d.ts.map +1 -1
  49. package/dist/openbao/index.js +541 -270
  50. package/dist/openbao/index.js.map +26 -21
  51. package/dist/openbao/jwt-config.d.ts.map +1 -1
  52. package/dist/openbao/jwt-role.d.ts.map +1 -1
  53. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  54. package/dist/openbao/mfa-enforcement.d.ts +6 -2
  55. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  56. package/dist/openbao/mfa-totp.d.ts +6 -2
  57. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  58. package/dist/openbao/mount-reconcile.d.ts +5 -1
  59. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  60. package/dist/openbao/mount.d.ts.map +1 -1
  61. package/dist/openbao/pki-role.d.ts.map +1 -1
  62. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  63. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  64. package/dist/openbao/plugin.d.ts.map +1 -1
  65. package/dist/openbao/policy.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  67. package/dist/openbao/role-reconcile.d.ts +11 -1
  68. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  69. package/dist/openbao/ssh-role.d.ts.map +1 -1
  70. package/dist/ownership/adopt.d.ts +52 -0
  71. package/dist/ownership/adopt.d.ts.map +1 -0
  72. package/dist/ownership/probe.d.ts +13 -0
  73. package/dist/ownership/probe.d.ts.map +1 -0
  74. package/dist/ownership/resume.d.ts +40 -0
  75. package/dist/ownership/resume.d.ts.map +1 -0
  76. package/dist/ownership/rows.d.ts +33 -0
  77. package/dist/ownership/rows.d.ts.map +1 -0
  78. package/dist/ownership/whole.d.ts +14 -0
  79. package/dist/ownership/whole.d.ts.map +1 -0
  80. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  81. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  82. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  83. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  84. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  85. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  86. package/dist/proxmox/fake-pve.d.ts +38 -0
  87. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  88. package/dist/proxmox/index.d.ts +6 -2
  89. package/dist/proxmox/index.d.ts.map +1 -1
  90. package/dist/proxmox/index.js +970 -186
  91. package/dist/proxmox/index.js.map +22 -9
  92. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  93. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  94. package/dist/proxmox/lxc-harness.d.ts +34 -0
  95. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  96. package/dist/proxmox/lxc-identity.d.ts +18 -0
  97. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  98. package/dist/proxmox/lxc-judge.d.ts +46 -0
  99. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  100. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  101. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  102. package/dist/proxmox/lxc-props.d.ts +139 -0
  103. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  104. package/dist/proxmox/lxc-volume.d.ts +29 -0
  105. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  106. package/dist/proxmox/lxc-wire.d.ts +51 -0
  107. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  108. package/dist/proxmox/lxc.d.ts +27 -47
  109. package/dist/proxmox/lxc.d.ts.map +1 -1
  110. package/dist/proxmox/network-apply-read.d.ts +1 -1
  111. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  112. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  113. package/dist/proxmox/resource.d.ts.map +1 -1
  114. package/dist/proxmox/update-guard.d.ts +27 -0
  115. package/dist/proxmox/update-guard.d.ts.map +1 -0
  116. package/dist/verify/args.d.ts +16 -0
  117. package/dist/verify/args.d.ts.map +1 -0
  118. package/dist/verify/cli.d.ts +3 -0
  119. package/dist/verify/cli.d.ts.map +1 -0
  120. package/dist/verify/cli.js +370 -0
  121. package/dist/verify/cli.js.map +17 -0
  122. package/dist/verify/fake-engine.d.ts +65 -0
  123. package/dist/verify/fake-engine.d.ts.map +1 -0
  124. package/dist/verify/index.d.ts +16 -0
  125. package/dist/verify/index.d.ts.map +1 -0
  126. package/dist/verify/index.js +347 -0
  127. package/dist/verify/index.js.map +16 -0
  128. package/dist/verify/plan-scope.d.ts +14 -0
  129. package/dist/verify/plan-scope.d.ts.map +1 -0
  130. package/dist/verify/recheck.d.ts +42 -0
  131. package/dist/verify/recheck.d.ts.map +1 -0
  132. package/dist/verify/report.d.ts +6 -0
  133. package/dist/verify/report.d.ts.map +1 -0
  134. package/dist/verify/rows.d.ts +67 -0
  135. package/dist/verify/rows.d.ts.map +1 -0
  136. package/dist/verify/spy.d.ts +43 -0
  137. package/dist/verify/spy.d.ts.map +1 -0
  138. package/dist/verify/verify.d.ts +43 -0
  139. package/dist/verify/verify.d.ts.map +1 -0
  140. package/docs/adopt-verify.md +119 -0
  141. package/docs/adopted-deploys.md +85 -0
  142. package/docs/credentials.md +15 -0
  143. package/docs/launchd-sudo.md +31 -6
  144. package/docs/launchd.md +7 -3
  145. package/docs/ownership.md +113 -0
  146. package/docs/proxmox-lxc.md +196 -0
  147. package/docs/proxmox.md +12 -0
  148. package/package.json +9 -2
  149. package/src/caddy/adopt-scope.test.ts +1 -1
  150. package/src/caddy/config-lifecycle.ts +1 -1
  151. package/src/caddy/config.ts +2 -1
  152. package/src/launchd/adopt-apply.test.ts +166 -0
  153. package/src/launchd/fake-runner.ts +26 -4
  154. package/src/launchd/host-effect.ts +1 -33
  155. package/src/launchd/host-file-lifecycle.ts +39 -14
  156. package/src/launchd/host-file.ts +13 -4
  157. package/src/launchd/job-lifecycle.ts +22 -14
  158. package/src/launchd/job-preflight.ts +18 -1
  159. package/src/launchd/job.ts +13 -4
  160. package/src/launchd/runner.ts +7 -0
  161. package/src/launchd/sudo-acl.ts +68 -0
  162. package/src/launchd/sudo-chain.test.ts +121 -0
  163. package/src/launchd/sudo-guard.ts +89 -10
  164. package/src/launchd/sudo-modes.test.ts +135 -0
  165. package/src/launchd/sudo-runner.ts +40 -59
  166. package/src/launchd/sudo-said.ts +58 -0
  167. package/src/openbao/README.md +5 -2
  168. package/src/openbao/REPLACE.md +13 -11
  169. package/src/openbao/adopt-core.test.ts +119 -0
  170. package/src/openbao/adopt-families.test.ts +97 -0
  171. package/src/openbao/adopt-holes.test.ts +168 -0
  172. package/src/openbao/adopt-recovery.test.ts +71 -0
  173. package/src/openbao/auth-method-reconcile.ts +15 -3
  174. package/src/openbao/auth-method.ts +15 -6
  175. package/src/openbao/auth-role.ts +14 -4
  176. package/src/openbao/cloudflare-role.ts +17 -4
  177. package/src/openbao/fake-bao.ts +52 -0
  178. package/src/openbao/fake-core.ts +141 -0
  179. package/src/openbao/fake-families.ts +11 -5
  180. package/src/openbao/fake-mounts.ts +74 -0
  181. package/src/openbao/fake-stack.ts +17 -4
  182. package/src/openbao/jwt-config.ts +10 -7
  183. package/src/openbao/jwt-role.ts +10 -7
  184. package/src/openbao/kubernetes-role.ts +13 -7
  185. package/src/openbao/mfa-enforcement.ts +20 -6
  186. package/src/openbao/mfa-totp.ts +22 -7
  187. package/src/openbao/mount-move.test.ts +2 -35
  188. package/src/openbao/mount-reconcile.ts +13 -2
  189. package/src/openbao/mount.ts +15 -6
  190. package/src/openbao/pki-role.ts +15 -4
  191. package/src/openbao/plugin-reconcile.ts +16 -5
  192. package/src/openbao/plugin.ts +17 -6
  193. package/src/openbao/policy.ts +17 -4
  194. package/src/openbao/proxmox-role.ts +13 -4
  195. package/src/openbao/rename-adoption.test.ts +32 -19
  196. package/src/openbao/role-reconcile.ts +23 -0
  197. package/src/openbao/ssh-role.ts +14 -4
  198. package/src/ownership/adopt.ts +122 -0
  199. package/src/ownership/ownership.test.ts +200 -0
  200. package/src/ownership/probe.ts +65 -0
  201. package/src/ownership/resume.ts +118 -0
  202. package/src/ownership/rows.ts +117 -0
  203. package/src/ownership/whole.test.ts +39 -0
  204. package/src/ownership/whole.ts +60 -0
  205. package/src/proxmox/adopt-noop.test.ts +112 -0
  206. package/src/proxmox/ceph-pool-adopt.test.ts +120 -0
  207. package/src/proxmox/ceph-pool-form.ts +3 -3
  208. package/src/proxmox/ceph-pool.ts +10 -3
  209. package/src/proxmox/fake-bao-env.ts +28 -0
  210. package/src/proxmox/fake-pve-lxc.ts +210 -0
  211. package/src/proxmox/fake-pve.ts +75 -0
  212. package/src/proxmox/index.test.ts +6 -6
  213. package/src/proxmox/index.ts +6 -2
  214. package/src/proxmox/lxc-adopt.test.ts +170 -0
  215. package/src/proxmox/lxc-create-form.ts +76 -0
  216. package/src/proxmox/lxc-create.test.ts +126 -0
  217. package/src/proxmox/lxc-guards.test.ts +158 -0
  218. package/src/proxmox/lxc-harness.ts +150 -0
  219. package/src/proxmox/lxc-identity.ts +43 -0
  220. package/src/proxmox/lxc-judge.test.ts +175 -0
  221. package/src/proxmox/lxc-judge.ts +196 -0
  222. package/src/proxmox/lxc-lifecycle.ts +200 -0
  223. package/src/proxmox/lxc-ownership.test.ts +159 -0
  224. package/src/proxmox/lxc-props.ts +215 -0
  225. package/src/proxmox/lxc-read.test.ts +60 -0
  226. package/src/proxmox/lxc-volume.test.ts +96 -0
  227. package/src/proxmox/lxc-volume.ts +153 -0
  228. package/src/proxmox/lxc-wire.ts +210 -0
  229. package/src/proxmox/lxc.ts +216 -93
  230. package/src/proxmox/network-apply-read.ts +3 -1
  231. package/src/proxmox/pbs-datastore.ts +4 -3
  232. package/src/proxmox/resource.ts +7 -7
  233. package/src/proxmox/update-guard.ts +31 -0
  234. package/src/verify/args.ts +82 -0
  235. package/src/verify/cli.test.ts +86 -0
  236. package/src/verify/cli.ts +49 -0
  237. package/src/verify/fake-engine.ts +182 -0
  238. package/src/verify/index.ts +37 -0
  239. package/src/verify/plan-scope.ts +34 -0
  240. package/src/verify/recheck.test.ts +56 -0
  241. package/src/verify/recheck.ts +71 -0
  242. package/src/verify/report.ts +52 -0
  243. package/src/verify/rows.ts +210 -0
  244. package/src/verify/spy.test.ts +141 -0
  245. package/src/verify/spy.ts +160 -0
  246. package/src/verify/verify.test.ts +146 -0
  247. package/src/verify/verify.ts +185 -0
@@ -9,7 +9,8 @@
9
9
  * - delete — removes the file.
10
10
  *
11
11
  * ⛔ NEVER A SECRET — see host-file-form.ts. Secret files stay rendered by openbao-agent.
12
- * ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`: a file already at the path reads as `Unowned`.
12
+ * ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`: a file already at the path reads as `Unowned`, and where
13
+ * the plan never asked, reconcile refuses it unless adoption is on (docs/ownership.md).
13
14
  * ★ IT LIVES IN THE launchd SUBPATH because the two travel together: a Mac host stack declares a
14
15
  * daemon's config file and the daemon that reads it, through the same HostRunner.
15
16
  */
@@ -18,6 +19,8 @@ import { Unowned } from 'alchemy/AdoptPolicy';
18
19
  import { isResolved } from 'alchemy/Diff';
19
20
  import * as Provider from 'alchemy/Provider';
20
21
  import * as Effect from 'effect/Effect';
22
+ import { adoptsAtApply } from '../ownership/adopt.ts';
23
+ import { noteUnfinished } from '../ownership/resume.ts';
21
24
  import { lift, resolvedString } from './host-effect.ts';
22
25
  import type { HostFileAttributes, HostFileProps } from './host-file-form.ts';
23
26
  import { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';
@@ -44,8 +47,9 @@ export const HostFileProvider = () =>
44
47
  return output === undefined ? Unowned(found) : found;
45
48
  }),
46
49
 
47
- diff: ({ news, output }) => {
48
- if (output === undefined) return Effect.succeed(undefined);
50
+ diff: ({ instanceId, news, output }) => {
51
+ // ★ An unfinished generation of our own: `--adopt` may resume it (ownership/resume.ts).
52
+ if (output === undefined) return noteUnfinished(instanceId);
49
53
  if (isResolved(news)) return lift(() => diffFile(runner, news, output));
50
54
  // ⛔ A new path is a replace even while `content` is unresolved (an Output templated in):
51
55
  // the engine's default would be an update, which writes the new path and never
@@ -56,7 +60,12 @@ export const HostFileProvider = () =>
56
60
  );
57
61
  },
58
62
 
59
- reconcile: ({ news, output }) => lift(() => reconcileFile(runner, news, output)),
63
+ // ★ `--adopt` reaches the apply too: the probe never ran for a create with an Output prop.
64
+ // ⛔ A create or an unfinished generation — never a fresh replace's, never offered it.
65
+ reconcile: ({ fqn, instanceId, news, output }) =>
66
+ Effect.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) =>
67
+ lift(() => reconcileFile(runner, news, output, adopt)),
68
+ ),
60
69
 
61
70
  delete: ({ output }) => lift(() => deleteFile(runner, output)),
62
71
  });
@@ -28,7 +28,9 @@ import {
28
28
  assertReplaceable,
29
29
  assertUnclaimed,
30
30
  assertValid,
31
+ checkPlistWrite,
31
32
  locate,
33
+ plistWriteOptions,
32
34
  preflight,
33
35
  refuse,
34
36
  } from './job-preflight.ts';
@@ -94,19 +96,31 @@ export const diffJob = async (
94
96
  }
95
97
  assertValid(news);
96
98
  const desired = renderJob(news).sha256;
99
+ // ★ A plan that will write the plist asks the runner first (checkPlistWrite, HostRunner.checkWrite).
100
+ const update = async (): Promise<Diff> => {
101
+ await checkPlistWrite(runner, await locate(runner, news));
102
+ return { action: 'update' };
103
+ };
97
104
  // ⛔ BOTH digests: the stored one catches a bootstrap that failed after the write landed, the live
98
105
  // one catches a hand edit. Either alone would report `noop` over a job that is not what we say.
99
- if (desired !== output.plistSha256) return { action: 'update' };
106
+ if (desired !== output.plistSha256) return update();
100
107
  const live = await readJob(runner, news);
101
- if (live === undefined || !live.loaded || live.plistSha256 !== desired)
102
- return { action: 'update' };
108
+ if (live === undefined || !live.loaded || live.plistSha256 !== desired) return update();
103
109
  return { action: 'noop' };
104
110
  };
105
111
 
112
+ /**
113
+ * `adopt` is whether adoption is on AND this apply is a create or an unfinished generation of our
114
+ * own (ownership/adopt.ts adoptsAtApply): it lets such a generation take over a job already on the
115
+ * host, as the plan's probe would have. A rename onto an occupied label stays refused, as it is at
116
+ * plan time (assertReplaceable) — including a label taken between that plan and a fresh replace's
117
+ * new generation, which the caller never marks adoptable.
118
+ */
106
119
  export const reconcileJob = async (
107
120
  runner: HostRunner,
108
121
  props: LaunchdJobProps,
109
122
  output: LaunchdJobAttributes | undefined,
123
+ adopt = false,
110
124
  ): Promise<LaunchdJobAttributes> => {
111
125
  assertValid(props);
112
126
  const { location, status } = await preflight(runner, props);
@@ -121,8 +135,9 @@ export const reconcileJob = async (
121
135
  const renamed =
122
136
  output !== undefined && (output.label !== props.label || output.domain !== props.domain);
123
137
  const prior = renamed ? undefined : output;
124
- // ⛔ No prior state for THIS identity: whatever is there already belongs to someone else.
125
- if (prior === undefined) {
138
+ // ⛔ No prior state for THIS identity: whatever is there already belongs to someone else —
139
+ // unless adoption is on for a create, where the probe was skipped (a prop still an Output).
140
+ if (prior === undefined && !(adopt && output === undefined)) {
126
141
  await assertUnclaimed(runner, props.label, location, status, rendered.sha256);
127
142
  }
128
143
  if (renamed) await deleteJob(runner, output);
@@ -138,18 +153,11 @@ export const reconcileJob = async (
138
153
  ) {
139
154
  return attributesOf(props, location, rendered.sha256, status);
140
155
  }
141
- /**
142
- * ⚠️ launchd refuses a daemon plist that is not root:wheel or is group/world-writable ("bad
143
- * ownership/permissions"), so the system domain always writes 0644 root:wheel. An agent's plist
144
- * belongs to its user. REASONED from launchd's documented behaviour, not measured here (no
145
- * bootstrap was run to write this); the nix-darwin daemons on the reference host are 0644 root.
146
- */
156
+ // ⚠️ Owner and mode are launchd's rule, not a choice: see plistWriteOptions (job-preflight.ts).
147
157
  await runner.writeFileAtomic(
148
158
  location.plistPath,
149
159
  rendered.bytes,
150
- location.domain.kind === 'system'
151
- ? { gid: 0, mode: 0o644, uid: 0 }
152
- : { mode: 0o644, uid: location.domain.uid },
160
+ plistWriteOptions(location.domain),
153
161
  );
154
162
  await bootoutIfLoaded(runner, location.target);
155
163
  const failed = async (detail: string): Promise<never> => {
@@ -21,7 +21,7 @@ import {
21
21
  } from './job-form.ts';
22
22
  import { identityProblems, jobProblems } from './job-validate.ts';
23
23
  import { type ServiceStatus, isDisabled, printService } from './launchctl.ts';
24
- import { type HostRunner, canActAsRoot } from './runner.ts';
24
+ import { type HostRunner, type WriteOptions, canActAsRoot } from './runner.ts';
25
25
 
26
26
  export type Location = {
27
27
  readonly domain: ParsedDomain;
@@ -31,6 +31,22 @@ export type Location = {
31
31
 
32
32
  export type Identity = Pick<LaunchdJobProps, 'label' | 'domain'>;
33
33
 
34
+ /**
35
+ * ⚠️ launchd refuses a daemon plist that is not root:wheel or is group/world-writable ("bad
36
+ * ownership/permissions"), so the system domain always writes 0644 root:wheel. An agent's plist
37
+ * belongs to its user. REASONED from launchd's documented behaviour, not measured here (no
38
+ * bootstrap was run to write this); the nix-darwin daemons on the reference host are 0644 root.
39
+ */
40
+ export const plistWriteOptions = (domain: ParsedDomain): WriteOptions =>
41
+ domain.kind === 'system' ? { gid: 0, mode: 0o644, uid: 0 } : { mode: 0o644, uid: domain.uid };
42
+
43
+ /**
44
+ * PLAN TIME, for a plan that will write the plist: the runner's own pre-write refusals
45
+ * (HostRunner.checkWrite — sudoRunner's directory and mode checks), reading only.
46
+ */
47
+ export const checkPlistWrite = async (runner: HostRunner, location: Location): Promise<void> =>
48
+ runner.checkWrite?.(location.plistPath, plistWriteOptions(location.domain));
49
+
34
50
  export const refuse = (label: string, message: string): Error =>
35
51
  new Error(`Launchd.Job ${label}: ${message}`);
36
52
 
@@ -150,4 +166,5 @@ export const assertReplaceable = async (
150
166
  assertMayWrite(runner, old.label, oldDomain);
151
167
  const { location, status } = await preflight(runner, next);
152
168
  await assertUnclaimed(runner, next.label, location, status, renderedSha256);
169
+ await checkPlistWrite(runner, location);
153
170
  };
@@ -18,7 +18,8 @@
18
18
  * ⛔ ENVIRONMENT IS NON-SECRET ONLY — see the ⛔ on LaunchdJobProps.environment.
19
19
  * ⛔ NO SILENT SUDO — see assertMayWrite in job-preflight.ts.
20
20
  * ⚠️ NOTHING IS ADOPTED WITHOUT `--adopt`. A label already loaded, or a plist already on disk, reads
21
- * as `Unowned`, so Alchemy refuses to take it over unless asked. Labels under `org.nixos.`,
21
+ * as `Unowned`, so Alchemy refuses to take it over unless asked — and reconcile, where the plan
22
+ * never asked, honours the same `--adopt` / `adopt(…)` (docs/ownership.md). Labels under `org.nixos.`,
22
23
  * `com.apple.` and `homebrew.mxcl.` are refused outright (job-validate.ts) — declare a new label
23
24
  * and cut over instead (docs/launchd.md).
24
25
  */
@@ -27,6 +28,8 @@ import { Unowned } from 'alchemy/AdoptPolicy';
27
28
  import { isResolved } from 'alchemy/Diff';
28
29
  import * as Provider from 'alchemy/Provider';
29
30
  import * as Effect from 'effect/Effect';
31
+ import { adoptsAtApply } from '../ownership/adopt.ts';
32
+ import { noteUnfinished } from '../ownership/resume.ts';
30
33
  import { lift, resolvedString } from './host-effect.ts';
31
34
  import type { LaunchdJobAttributes, LaunchdJobProps } from './job-form.ts';
32
35
  import { deleteJob, diffJob, readJob, reconcileJob, replaceDiff } from './job-lifecycle.ts';
@@ -67,8 +70,9 @@ export const LaunchdJobProvider = () =>
67
70
  return output === undefined ? Unowned(found) : found;
68
71
  }),
69
72
 
70
- diff: ({ news, output }) => {
71
- if (output === undefined) return Effect.succeed(undefined);
73
+ diff: ({ instanceId, news, output }) => {
74
+ // ★ An unfinished generation of our own: `--adopt` may resume it (ownership/resume.ts).
75
+ if (output === undefined) return noteUnfinished(instanceId);
72
76
  if (isResolved(news)) return lift(() => diffJob(runner, news, output));
73
77
  /**
74
78
  * ⛔ A RENAME MUST BE SEEN EVEN WHILE OTHER PROPS ARE UNRESOLVED. Returning undefined
@@ -88,7 +92,12 @@ export const LaunchdJobProvider = () =>
88
92
  : Effect.succeed(undefined);
89
93
  },
90
94
 
91
- reconcile: ({ news, output }) => lift(() => reconcileJob(runner, news, output)),
95
+ // ★ `--adopt` reaches the apply too: the probe never ran for a create with an Output prop.
96
+ // ⛔ A create or an unfinished generation — never a fresh replace's, never offered it.
97
+ reconcile: ({ fqn, instanceId, news, output }) =>
98
+ Effect.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) =>
99
+ lift(() => reconcileJob(runner, news, output, adopt)),
100
+ ),
92
101
 
93
102
  delete: ({ output }) => lift(() => deleteJob(runner, output)),
94
103
  });
@@ -73,6 +73,13 @@ export interface HostRunner {
73
73
  * ⚠️ The parent directory must exist; creating it would invent an owner and mode nobody declared.
74
74
  */
75
75
  writeFileAtomic(path: string, bytes: Uint8Array, options: WriteOptions): Promise<void>;
76
+ /**
77
+ * PLAN TIME: refuse, reading only and writing nothing, a write this runner would refuse at
78
+ * apply — so the plan shows it before any resource is applied. Optional: a runner without it
79
+ * refuses at the write. ★ sudoRunner implements it with the checks it makes before `sudo -n`;
80
+ * ⛔ it never elevates, so a plan still never calls sudo.
81
+ */
82
+ checkWrite?(path: string, options: WriteOptions): Promise<void>;
76
83
  /** Remove a file. Idempotent: nothing at `path` is success. */
77
84
  removeFile(path: string): Promise<void>;
78
85
  /** A user by name or numeric id, or `undefined` when the host has none. */
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The ACL half of sudo-guard.ts: mode bits say a directory is root's alone, and an ACL can say
3
+ * otherwise. On macOS an `allow add_file,add_subdirectory,delete_child` entry lets its holder swap
4
+ * what lies under a root-owned `0755` directory — a symlink where the file was — between the
5
+ * guard's lstat checks and the privileged call, exactly as group write would. An inheritable
6
+ * `allow write` hands every file root installs there to its holder.
7
+ *
8
+ * ★ READ WITH `ls -lden`, AS THE OPERATOR — no sudo, and the same read at plan time and before the
9
+ * write. MEASURED 2026-09-21 on macOS 27.2: each entry prints on its own line after the path's,
10
+ * as ` 0: <qualifier> [inherited ]allow|deny <perms>`; `-n` makes the qualifier a UUID, never a
11
+ * name with a space in it. The `+` flag after the mode is NOT reliable — `@` (extended
12
+ * attributes) replaces it in the listing — so the entries are what is read.
13
+ * ⚠️ A DENY ENTRY GRANTS NOTHING (macOS puts `everyone deny delete` on home folders), and an allow
14
+ * of read-only rights (`list`, `search`, `readattr` …) swaps nothing, so neither refuses.
15
+ */
16
+ import type { HostRunner } from './runner.ts';
17
+ import { SudoRefusedError } from './sudo-allowlist.ts';
18
+
19
+ export const LS = '/bin/ls';
20
+
21
+ /** Rights that let the holder add, remove, rename or re-permission what a directory holds. */
22
+ const WRITE_RIGHTS: ReadonlySet<string> = new Set([
23
+ 'add_file',
24
+ 'add_subdirectory',
25
+ 'append',
26
+ 'chown',
27
+ 'delete',
28
+ 'delete_child',
29
+ 'write',
30
+ 'writesecurity',
31
+ ]);
32
+
33
+ const ENTRY = /^\s*\d+:\s+\S+\s+(?:inherited\s+)?allow\s+(\S+)/;
34
+
35
+ /** The allow entries in `ls -lden` output that grant a write right, as printed. */
36
+ export const aclGrants = (listing: string): string[] =>
37
+ listing
38
+ .split('\n')
39
+ .filter((line) => {
40
+ const rights = ENTRY.exec(line)?.[1];
41
+ return rights !== undefined && rights.split(',').some((right) => WRITE_RIGHTS.has(right));
42
+ })
43
+ .map((line) => line.trim());
44
+
45
+ /**
46
+ * ⛔ Refuse when any directory in `chain` carries an ACL entry that lets someone write it, or when
47
+ * the ACLs cannot be read at all (fail closed). `path` names the file in the refusal.
48
+ */
49
+ export const assertNoAclGrants = async (
50
+ base: HostRunner,
51
+ path: string,
52
+ chain: readonly string[],
53
+ ): Promise<void> => {
54
+ const listed = await base.exec([LS, '-lden', '--', ...chain]);
55
+ if (listed.exitCode !== 0) {
56
+ throw new SudoRefusedError(
57
+ `sudoRunner ${path}: could not read the ACLs of the directories from / down to it ` +
58
+ `(${LS} -lden exit ${String(listed.exitCode)}). Nothing ran as root.`,
59
+ );
60
+ }
61
+ const grants = aclGrants(listed.stdout);
62
+ if (grants.length === 0) return;
63
+ throw new SudoRefusedError(
64
+ `sudoRunner ${path}: a directory from / down to it carries an ACL that lets someone other ` +
65
+ `than root change what it holds (${grants.join('; ')}). Remove the entry (chmod -a), or ` +
66
+ 'declare a prefix without one. Nothing ran as root.',
67
+ );
68
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * sudoRunner's red-team refusals (2026-09-21), over the fake host (fake-sudo.ts): a directory ABOVE
3
+ * the prefix that someone other than root may change, and an ACL anywhere from / down that grants a
4
+ * write right. Either lets that someone swap the path between the guard and `sudo -n`. Each is
5
+ * refused before anything is staged or run as root, and at plan time through `checkWrite`.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { OPERATOR, fakeSudoHost } from './fake-sudo.ts';
9
+ import { diffFile, reconcileFile } from './host-file-lifecycle.ts';
10
+ import type { HostRunner } from './runner.ts';
11
+ import { aclGrants, assertNoAclGrants } from './sudo-acl.ts';
12
+ import { SudoRefusedError } from './sudo-allowlist.ts';
13
+ import { aboveDirs } from './sudo-guard.ts';
14
+
15
+ const CONF = '/opt/example/app/a.conf';
16
+ const bytes = (text: string) => new TextEncoder().encode(text);
17
+ type Host = ReturnType<typeof fakeSudoHost>;
18
+ const entry = (kind: 'directory' | 'symlink', mode: number, uid = 0) => ({
19
+ bytes: bytes(''),
20
+ gid: 0,
21
+ kind,
22
+ mode,
23
+ uid,
24
+ });
25
+
26
+ /** As `ls -lden` printed them on macOS 27.2 (sudo-acl.ts): a UUID qualifier, one entry a line. */
27
+ const UUID = 'ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000014';
28
+
29
+ test('aboveDirs is / down to the parent of the prefix', () => {
30
+ expect(aboveDirs('/Library/LaunchDaemons')).toEqual(['/', '/Library']);
31
+ expect(aboveDirs('/opt/example')).toEqual(['/', '/opt']);
32
+ expect(aboveDirs('/opt')).toEqual(['/']);
33
+ });
34
+
35
+ describe('aclGrants', () => {
36
+ test('an allow of a write right counts; deny, and read-only rights, do not', () => {
37
+ const listing = [
38
+ `drwxr-xr-x@ 3 0 0 96 Sep 21 19:36 /opt/example`,
39
+ ` 0: ${UUID} allow add_file,add_subdirectory,delete_child`,
40
+ ` 1: ${UUID} inherited allow write,file_inherit`,
41
+ ` 2: ${UUID} deny delete`,
42
+ ` 3: ${UUID} allow list,search,readattr`,
43
+ ].join('\n');
44
+ expect(aclGrants(listing)).toEqual([
45
+ `0: ${UUID} allow add_file,add_subdirectory,delete_child`,
46
+ `1: ${UUID} inherited allow write,file_inherit`,
47
+ ]);
48
+ });
49
+
50
+ test('fails closed when the ACLs cannot be read', async () => {
51
+ const base = { exec: async () => ({ exitCode: 1, stderr: 'ls: x', stdout: '' }) };
52
+ const read = assertNoAclGrants(base as unknown as HostRunner, CONF, ['/']);
53
+ await expect(read).rejects.toThrow('could not read the ACLs');
54
+ });
55
+ });
56
+
57
+ const refusedBeforeSudo = async (host: Host, says: string | RegExp) => {
58
+ const write = host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
59
+ await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
60
+ await expect(write).rejects.toThrow(says);
61
+ expect(host.state.stagedCount).toBe(0);
62
+ expect(host.sudoCalls).toEqual([]);
63
+ };
64
+
65
+ describe('every directory above the prefix', () => {
66
+ test.each([
67
+ ['owned by the operator', (h: Host) => h.fake.dirs.set('/opt', OPERATOR), 'uid 501'],
68
+ ['group-writable', (h: Host) => h.fake.files.set('/opt', entry('directory', 0o775)), '0775'],
69
+ [
70
+ 'a symlink the operator owns',
71
+ (h: Host) => h.fake.files.set('/opt', entry('symlink', 0o755, OPERATOR)),
72
+ 'symlink',
73
+ ],
74
+ ] as const)('%s is refused before staging or sudo', async (_n, setup, says) => {
75
+ const host = fakeSudoHost();
76
+ setup(host);
77
+ await refusedBeforeSudo(host, says);
78
+ await expect(host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 })).rejects.toThrow(
79
+ /\/opt, above the declared prefix \/opt\/example/,
80
+ );
81
+ });
82
+
83
+ test("a root-owned symlink (/etc -> private/etc) is root's own, and followed", async () => {
84
+ const host = fakeSudoHost();
85
+ host.fake.files.set('/opt', entry('symlink', 0o755));
86
+ await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
87
+ expect(host.fake.files.get(CONF)).toMatchObject({ mode: 0o644, uid: 0 });
88
+ });
89
+
90
+ test('a HostFile plan that would write under it fails, still without sudo', async () => {
91
+ const host = fakeSudoHost();
92
+ const output = await reconcileFile(host.runner, { content: 'x', path: CONF });
93
+ host.fake.dirs.set('/opt', OPERATOR);
94
+ const before = host.sudoCalls.length;
95
+ await expect(diffFile(host.runner, { content: 'y', path: CONF }, output)).rejects.toThrow(
96
+ 'uid 501',
97
+ );
98
+ expect(host.sudoCalls).toHaveLength(before);
99
+ });
100
+ });
101
+
102
+ describe('an ACL from / down to the file', () => {
103
+ test.each(['/', '/opt', '/opt/example', '/opt/example/app'])(
104
+ 'that grants a write right on %s is refused before staging or sudo',
105
+ async (dir) => {
106
+ const host = fakeSudoHost();
107
+ host.fake.acls.set(dir, [` 0: ${UUID} allow add_file,delete_child`]);
108
+ await refusedBeforeSudo(host, 'carries an ACL');
109
+ host.fake.files.set(CONF, { bytes: bytes('x'), gid: 0, kind: 'file', mode: 0o644, uid: 0 });
110
+ await expect(host.runner.removeFile(CONF)).rejects.toThrow('carries an ACL');
111
+ expect(host.sudoCalls).toEqual([]);
112
+ },
113
+ );
114
+
115
+ test('a deny-only ACL (as on a home folder) is no refusal', async () => {
116
+ const host = fakeSudoHost();
117
+ host.fake.acls.set('/opt/example/app', [` 0: ${UUID} deny delete`]);
118
+ await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
119
+ expect(host.fake.files.get(CONF)).toMatchObject({ uid: 0 });
120
+ });
121
+ });
@@ -5,12 +5,23 @@
5
5
  * ⛔ WHAT IS LOGGED IS WHAT IS WRITTEN. A symlinked directory under a prefix would make
6
6
  * `install` or `rm`, running as root, act on a path the log never named. So every directory
7
7
  * between the prefix and the file must be a real directory.
8
- * ⚠️ These checks run before the privileged call, so they guard against a mistaken declaration, not
9
- * against someone who can already write inside a prefix and swap a path in between. The prefix
10
- * itself is checked to be root-only (prefixProblem); a directory BELOW it that another user owns
11
- * is not, and that user could swap what lies under it in that window.
8
+ * ⛔ EVERY DIRECTORY FROM THE PREFIX DOWN IS ROOT'S ALONE (decided 2026-09-21). A directory below
9
+ * the prefix that another user owns, or that group or other may write, lets that user swap what
10
+ * lies under it — a symlink where the file was — between these checks and the privileged call.
11
+ * So each one must be owned by root and not group/other-writable (rootOnlyProblem), exactly as
12
+ * the prefix itself must.
13
+ * ⛔ AND EVERY DIRECTORY ABOVE THE PREFIX (red team, 2026-09-21). Whoever may write the prefix's
14
+ * parent may rename the prefix away and put a symlink in its place between these checks and the
15
+ * privileged call — the same swap one level up. So `/` down to the prefix's parent must be
16
+ * root-only too (a root-owned symlink among them, `/etc` -> `private/etc`, is root's own and is
17
+ * followed; its target is root's choice, and not walked).
18
+ * ⛔ AND NO ACL THAT SAYS OTHERWISE (sudo-acl.ts): mode bits are not the whole answer on macOS.
19
+ * ⚠️ These checks run before the privileged call, as the deploying user. They guard against a
20
+ * mistaken declaration and against every user but root; root itself could still change a path
21
+ * in that window.
12
22
  */
13
23
  import type { FileStat, HostRunner, WriteOptions } from './runner.ts';
24
+ import { assertNoAclGrants } from './sudo-acl.ts';
14
25
  import { SudoRefusedError } from './sudo-allowlist.ts';
15
26
 
16
27
  const refuse = (path: string, message: string): Error =>
@@ -25,6 +36,12 @@ export const betweenDirs = (prefix: string, path: string): string[] => {
25
36
  return parts.map((_, index) => `${prefix}/${parts.slice(0, index + 1).join('/')}`);
26
37
  };
27
38
 
39
+ /** The directories strictly above `prefix`: `/`, then each one down to the prefix's parent. */
40
+ export const aboveDirs = (prefix: string): string[] => {
41
+ const parts = prefix.split('/').slice(1, -1);
42
+ return ['/', ...parts.map((_, index) => `/${parts.slice(0, index + 1).join('/')}`)];
43
+ };
44
+
28
45
  /**
29
46
  * What must be at `path` itself: a regular file, or (for a write or a remove) nothing yet.
30
47
  * ⚠️ A DIRECTORY AT THE DESTINATION IS THE TRAP: `install src dir` copies INTO the directory
@@ -33,17 +50,17 @@ export const betweenDirs = (prefix: string, path: string): string[] => {
33
50
  export type Expect = 'file' | 'file-or-absent';
34
51
 
35
52
  /**
36
- * ⛔ THE PREFIX ITSELF MUST BE A DIRECTORY ONLY ROOT CAN CHANGE, checked at every call rather than
37
- * trusted from the declaration. A symlinked prefix makes root write wherever it points, which the
53
+ * ⛔ THE PREFIX, AND EVERY DIRECTORY BELOW IT ON THE WAY TO THE FILE, MUST BE A DIRECTORY ONLY ROOT
54
+ * CAN CHANGE, checked at every call rather than trusted from the declaration. A symlinked prefix makes root write wherever it points, which the
38
55
  * log never names (`/opt/example` -> `/etc` turns a HostFile at `/opt/example/sudoers` into
39
56
  * `/etc/sudoers`). A prefix another user owns or may write lets that user swap a directory below
40
57
  * it for a symlink between this check and the privileged call.
41
58
  * ⚠️ lstat of the prefix itself: a symlink ABOVE it (`/etc` -> `/private/etc` on macOS) is the
42
- * system's and is followed, as every path lookup does. ACLs are not read; mode bits only.
59
+ * system's and is followed, as every path lookup does — when root owns it (aboveDirs).
43
60
  * ★ MEASURED 2026-09-21 on macOS 27.2 (`ls -ldn`): /Library/LaunchDaemons, /private/etc, /opt and
44
61
  * /usr/local are uid 0, mode 0755, so the prefixes a host stack declares pass as they are.
45
62
  */
46
- const prefixProblem = (stat: FileStat | undefined): string | undefined => {
63
+ const rootOnlyProblem = (stat: FileStat | undefined): string | undefined => {
47
64
  if (stat === undefined) return 'is missing';
48
65
  if (stat.kind !== 'directory') return `is a ${stat.kind}, not a directory`;
49
66
  if (stat.uid !== 0) return `is owned by uid ${String(stat.uid)}, not root`;
@@ -63,9 +80,20 @@ export const assertPlainPath = async (
63
80
  path: string,
64
81
  expect: Expect,
65
82
  ): Promise<'file' | 'absent'> => {
83
+ const above = aboveDirs(prefix);
84
+ for (const [index, stat] of (await Promise.all(above.map((dir) => base.stat(dir)))).entries()) {
85
+ const up = stat?.kind === 'symlink' && stat.uid === 0 ? undefined : rootOnlyProblem(stat);
86
+ if (up !== undefined) {
87
+ throw refuse(
88
+ path,
89
+ `${String(above[index])}, above the declared prefix ${prefix}, ${up}. Whoever may change it ` +
90
+ 'may swap the prefix itself; every directory from / down must be root-only. Nothing ran as root.',
91
+ );
92
+ }
93
+ }
66
94
  const dirs = betweenDirs(prefix, path);
67
95
  const [prefixStat, ...stats] = await Promise.all([prefix, ...dirs].map((dir) => base.stat(dir)));
68
- const bad = prefixProblem(prefixStat);
96
+ const bad = rootOnlyProblem(prefixStat);
69
97
  if (bad !== undefined) {
70
98
  throw refuse(
71
99
  path,
@@ -74,20 +102,71 @@ export const assertPlainPath = async (
74
102
  );
75
103
  }
76
104
  for (const [index, stat] of stats.entries()) {
105
+ const dir = String(dirs[index]);
77
106
  if (stat?.kind !== 'directory') {
78
107
  throw refuse(
79
108
  path,
80
- `${String(dirs[index])} is ${stat === undefined ? 'missing' : `a ${stat.kind}`}; root ` +
109
+ `${dir} is ${stat === undefined ? 'missing' : `a ${stat.kind}`}; root ` +
81
110
  'writes only through real directories under a declared prefix.',
82
111
  );
83
112
  }
113
+ const below = rootOnlyProblem(stat);
114
+ if (below !== undefined) {
115
+ throw refuse(
116
+ path,
117
+ `${dir} ${below}. Every directory from the prefix ${prefix} down to the file must be one ` +
118
+ 'that root owns and only root may write. Nothing ran as root.',
119
+ );
120
+ }
84
121
  }
122
+ await assertNoAclGrants(base, path, [...above, prefix, ...dirs]);
85
123
  const target = await base.stat(path);
86
124
  if (target === undefined && expect === 'file-or-absent') return 'absent';
87
125
  if (target?.kind === 'file') return 'file';
88
126
  throw refuse(path, `is ${target === undefined ? 'missing' : `a ${target.kind}`}, not a file`);
89
127
  };
90
128
 
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
+ /**
147
+ * Every check a write under `prefix` meets before anything is staged or run as root: the mode, the
148
+ * path (assertPlainPath) and the read-back (assertReadableBack). ★ Reads only, as the deploying
149
+ * user — so sudoRunner's `checkWrite` runs it at PLAN time too, and a plan still never calls sudo.
150
+ */
151
+ export const assertInstallable = async (
152
+ base: HostRunner,
153
+ prefix: string,
154
+ path: string,
155
+ options: WriteOptions,
156
+ groups: readonly number[] | undefined,
157
+ ): Promise<void> => {
158
+ const mode = modeProblem(options);
159
+ if (mode !== undefined) {
160
+ throw refuse(
161
+ path,
162
+ `${mode}. Declare a mode without those bits (0644, 0640, 0755), or give the file to its ` +
163
+ 'real owner. Nothing ran as root.',
164
+ );
165
+ }
166
+ await assertPlainPath(base, prefix, path, 'file-or-absent');
167
+ await assertReadableBack(base, path, options, groups);
168
+ };
169
+
91
170
  /**
92
171
  * POSIX read permission for one uid: owner class, else group class, else other. `undefined` when
93
172
  * only group membership could decide and the groups are unknown.