@homeflare/alchemy 0.5.0 → 0.6.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.
Files changed (180) hide show
  1. package/README.md +17 -48
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/launchd/fake-runner.d.ts +36 -0
  5. package/dist/launchd/fake-runner.d.ts.map +1 -0
  6. package/dist/launchd/host-effect.d.ts +19 -0
  7. package/dist/launchd/host-effect.d.ts.map +1 -0
  8. package/dist/launchd/host-file-form.d.ts +24 -0
  9. package/dist/launchd/host-file-form.d.ts.map +1 -0
  10. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  11. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  12. package/dist/launchd/host-file.d.ts +25 -0
  13. package/dist/launchd/host-file.d.ts.map +1 -0
  14. package/dist/launchd/host-lookup.d.ts +27 -0
  15. package/dist/launchd/host-lookup.d.ts.map +1 -0
  16. package/dist/launchd/index.d.ts +21 -0
  17. package/dist/launchd/index.d.ts.map +1 -0
  18. package/dist/launchd/index.js +969 -0
  19. package/dist/launchd/index.js.map +25 -0
  20. package/dist/launchd/job-form.d.ts +90 -0
  21. package/dist/launchd/job-form.d.ts.map +1 -0
  22. package/dist/launchd/job-lifecycle.d.ts +34 -0
  23. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  24. package/dist/launchd/job-preflight.d.ts +50 -0
  25. package/dist/launchd/job-preflight.d.ts.map +1 -0
  26. package/dist/launchd/job-validate.d.ts +25 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -0
  28. package/dist/launchd/job.d.ts +34 -0
  29. package/dist/launchd/job.d.ts.map +1 -0
  30. package/dist/launchd/launchctl.d.ts +62 -0
  31. package/dist/launchd/launchctl.d.ts.map +1 -0
  32. package/dist/launchd/local-runner.d.ts +7 -0
  33. package/dist/launchd/local-runner.d.ts.map +1 -0
  34. package/dist/launchd/plist.d.ts +41 -0
  35. package/dist/launchd/plist.d.ts.map +1 -0
  36. package/dist/launchd/providers.d.ts +15 -0
  37. package/dist/launchd/providers.d.ts.map +1 -0
  38. package/dist/launchd/runner.d.ts +91 -0
  39. package/dist/launchd/runner.d.ts.map +1 -0
  40. package/dist/launchd/secret-tripwire.d.ts +23 -0
  41. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  42. package/dist/openbao/auth-method-form.d.ts +7 -0
  43. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  45. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  46. package/dist/openbao/auth-method.d.ts +6 -0
  47. package/dist/openbao/auth-method.d.ts.map +1 -1
  48. package/dist/openbao/auth-role-form.d.ts +9 -0
  49. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-role.d.ts.map +1 -1
  51. package/dist/openbao/auth-token-form.d.ts +48 -0
  52. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  53. package/dist/openbao/bao-identity.d.ts +81 -0
  54. package/dist/openbao/bao-identity.d.ts.map +1 -0
  55. package/dist/openbao/bao-namespace.d.ts +20 -0
  56. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  57. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  58. package/dist/openbao/host-approles.d.ts +62 -0
  59. package/dist/openbao/host-approles.d.ts.map +1 -0
  60. package/dist/openbao/index.d.ts +19 -0
  61. package/dist/openbao/index.d.ts.map +1 -1
  62. package/dist/openbao/index.js +1433 -305
  63. package/dist/openbao/index.js.map +34 -13
  64. package/dist/openbao/jwt-config-form.d.ts +51 -0
  65. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  66. package/dist/openbao/jwt-config.d.ts +25 -0
  67. package/dist/openbao/jwt-config.d.ts.map +1 -0
  68. package/dist/openbao/jwt-role-form.d.ts +91 -0
  69. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  70. package/dist/openbao/jwt-role.d.ts +31 -0
  71. package/dist/openbao/jwt-role.d.ts.map +1 -0
  72. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  73. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  74. package/dist/openbao/kubernetes-role.d.ts +25 -0
  75. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  76. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  77. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  78. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  79. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  80. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  81. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  82. package/dist/openbao/mfa-totp.d.ts +33 -0
  83. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  84. package/dist/openbao/mfa-wire.d.ts +63 -0
  85. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  86. package/dist/openbao/mount-form.d.ts +7 -0
  87. package/dist/openbao/mount-form.d.ts.map +1 -1
  88. package/dist/openbao/mount-move.d.ts +49 -0
  89. package/dist/openbao/mount-move.d.ts.map +1 -0
  90. package/dist/openbao/mount-path.d.ts +8 -4
  91. package/dist/openbao/mount-path.d.ts.map +1 -1
  92. package/dist/openbao/mount-reconcile.d.ts +16 -0
  93. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/mount.d.ts +9 -0
  95. package/dist/openbao/mount.d.ts.map +1 -1
  96. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  97. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  98. package/dist/openbao/pki-role-form.d.ts +38 -30
  99. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  100. package/dist/openbao/pki-role.d.ts.map +1 -1
  101. package/dist/openbao/remount-wire.d.ts +44 -0
  102. package/dist/openbao/remount-wire.d.ts.map +1 -0
  103. package/dist/openbao/role-reconcile.d.ts +40 -0
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  105. package/docs/launchd.md +167 -0
  106. package/package.json +7 -2
  107. package/src/index.ts +1 -0
  108. package/src/launchd/fake-runner.ts +176 -0
  109. package/src/launchd/host-effect.ts +28 -0
  110. package/src/launchd/host-file-form.ts +71 -0
  111. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  112. package/src/launchd/host-file-lifecycle.ts +164 -0
  113. package/src/launchd/host-file.ts +64 -0
  114. package/src/launchd/host-lookup.test.ts +92 -0
  115. package/src/launchd/host-lookup.ts +94 -0
  116. package/src/launchd/index.ts +26 -0
  117. package/src/launchd/job-form.test.ts +171 -0
  118. package/src/launchd/job-form.ts +180 -0
  119. package/src/launchd/job-identity.test.ts +147 -0
  120. package/src/launchd/job-lifecycle.test.ts +220 -0
  121. package/src/launchd/job-lifecycle.ts +194 -0
  122. package/src/launchd/job-preflight.ts +152 -0
  123. package/src/launchd/job-validate.ts +134 -0
  124. package/src/launchd/job.ts +96 -0
  125. package/src/launchd/launchctl.test.ts +181 -0
  126. package/src/launchd/launchctl.ts +188 -0
  127. package/src/launchd/local-runner.test.ts +125 -0
  128. package/src/launchd/local-runner.ts +143 -0
  129. package/src/launchd/plist.test.ts +186 -0
  130. package/src/launchd/plist.ts +156 -0
  131. package/src/launchd/providers.test.ts +222 -0
  132. package/src/launchd/providers.ts +21 -0
  133. package/src/launchd/runner.ts +95 -0
  134. package/src/launchd/secret-tripwire.test.ts +110 -0
  135. package/src/launchd/secret-tripwire.ts +81 -0
  136. package/src/openbao/README.md +181 -0
  137. package/src/openbao/REPLACE.md +87 -0
  138. package/src/openbao/auth-method-form.ts +7 -0
  139. package/src/openbao/auth-method-reconcile.ts +74 -0
  140. package/src/openbao/auth-method.ts +20 -41
  141. package/src/openbao/auth-role-form.test.ts +16 -1
  142. package/src/openbao/auth-role-form.ts +11 -0
  143. package/src/openbao/auth-role.ts +12 -0
  144. package/src/openbao/auth-token-form.ts +161 -0
  145. package/src/openbao/bao-identity.test.ts +131 -0
  146. package/src/openbao/bao-identity.ts +188 -0
  147. package/src/openbao/bao-namespace.ts +32 -0
  148. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  149. package/src/openbao/host-approles.test.ts +121 -0
  150. package/src/openbao/host-approles.ts +126 -0
  151. package/src/openbao/index.ts +19 -0
  152. package/src/openbao/jwt-config-form.ts +161 -0
  153. package/src/openbao/jwt-config.ts +85 -0
  154. package/src/openbao/jwt-role-form.test.ts +130 -0
  155. package/src/openbao/jwt-role-form.ts +231 -0
  156. package/src/openbao/jwt-role.ts +102 -0
  157. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  158. package/src/openbao/kubernetes-role-form.ts +149 -0
  159. package/src/openbao/kubernetes-role.ts +94 -0
  160. package/src/openbao/mfa-enforcement-form.ts +147 -0
  161. package/src/openbao/mfa-enforcement.ts +143 -0
  162. package/src/openbao/mfa-totp-form.ts +155 -0
  163. package/src/openbao/mfa-totp.ts +118 -0
  164. package/src/openbao/mfa-wire.ts +117 -0
  165. package/src/openbao/mfa.test.ts +200 -0
  166. package/src/openbao/mount-form.ts +9 -1
  167. package/src/openbao/mount-move.test.ts +164 -0
  168. package/src/openbao/mount-move.ts +113 -0
  169. package/src/openbao/mount-path.test.ts +11 -1
  170. package/src/openbao/mount-path.ts +17 -11
  171. package/src/openbao/mount-reconcile.ts +75 -0
  172. package/src/openbao/mount.ts +26 -43
  173. package/src/openbao/pki-role-canonical.ts +163 -0
  174. package/src/openbao/pki-role-form.test.ts +112 -0
  175. package/src/openbao/pki-role-form.ts +76 -110
  176. package/src/openbao/pki-role.ts +12 -27
  177. package/src/openbao/remount-wire.test.ts +87 -0
  178. package/src/openbao/remount-wire.ts +112 -0
  179. package/src/openbao/role-reconcile.test.ts +104 -0
  180. package/src/openbao/role-reconcile.ts +84 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * An in-memory host for the tests beside it: a filesystem in a Map and a scripted launchd.
3
+ *
4
+ * ⛔ TEST-ONLY. No provider imports this file, it is not on the barrel, and it never touches the
5
+ * real filesystem or runs a real program — which is the only way the lifecycle tests can prove
6
+ * "bootout before bootstrap" without writing /Library or loading a job.
7
+ * ★ IT MODELS THE BEHAVIOURS THE PROVIDER DEPENDS ON, as launchctl.ts documents them: `print` of an
8
+ * unknown target exits 113; bootstrap of a loaded or disabled label fails; bootout of an absent
9
+ * job exits non-zero; a job can linger after bootout (`lingerPrints`); a gui uid with no login
10
+ * session answers 112 (`loggedOut`); writes into a root-owned directory, or a chown to another
11
+ * uid, need root.
12
+ */
13
+ import { LAUNCHCTL, NOT_FOUND, NO_DOMAIN } from './launchctl.ts';
14
+ import type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
15
+
16
+ type Entry = { bytes: Uint8Array; mode: number; uid: number; gid: number; kind: FileStat['kind'] };
17
+
18
+ export type FakeOptions = {
19
+ euid?: number;
20
+ privileged?: boolean;
21
+ /** Directories that exist, and their owner uid. */
22
+ dirs?: Record<string, number>;
23
+ users?: Record<string, HostUser>;
24
+ groups?: Record<string, number>;
25
+ };
26
+
27
+ const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
28
+ const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
29
+ const errno = (code: string, path: string) =>
30
+ Object.assign(new Error(`${code}: ${path}`), { code });
31
+ const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
32
+
33
+ /** The shape `launchctl print` produced on macOS 27.2 (see launchctl.ts), trimmed. */
34
+ const printBlock = (target: string, pid: number | undefined, lastExit: string) =>
35
+ [
36
+ `${target} = {`,
37
+ '\tactive count = 1',
38
+ `\tstate = ${pid === undefined ? 'not running' : 'running'}`,
39
+ '\targuments = {',
40
+ '\t\t/bin/sh',
41
+ '}',
42
+ '\t}',
43
+ '\tresource coalition = {',
44
+ '\t\tstate = active',
45
+ '\t}',
46
+ ...(pid === undefined ? [] : [`\tpid = ${String(pid)}`]),
47
+ `\tlast exit code = ${lastExit}`,
48
+ '}',
49
+ ].join('\n');
50
+
51
+ export const fakeRunner = (options: FakeOptions = {}) => {
52
+ const euid = options.euid ?? 501;
53
+ const privileged = options.privileged ?? false;
54
+ const root = privileged || euid === 0;
55
+ const dirs = new Map(Object.entries(options.dirs ?? {}));
56
+ const files = new Map<string, Entry>();
57
+ const calls: string[][] = [];
58
+ /** target → pid (undefined = loaded, not running). */
59
+ const loaded = new Map<string, number | undefined>();
60
+ const disabled = new Set<string>();
61
+ const state = {
62
+ lingerPrints: 0,
63
+ nextPid: 4000,
64
+ bootstrapFailure: undefined as ExecResult | undefined,
65
+ /** Returned by bootout in place of success. The job still goes, unless `bootoutKeepsJob`. */
66
+ bootoutFailure: undefined as ExecResult | undefined,
67
+ bootoutKeepsJob: false,
68
+ /** gui uids with no login session: every launchctl call into `gui/<uid>` exits 112. */
69
+ loggedOut: new Set<number>(),
70
+ };
71
+ /** Booted-out targets launchd has not let go of yet → prints left before they vanish. */
72
+ const lingering = new Map<string, number>();
73
+
74
+ const labelIn = (path: string) =>
75
+ /<key>Label<\/key>\s*<string>([^<]*)<\/string>/.exec(
76
+ new TextDecoder().decode(files.get(path)?.bytes),
77
+ )?.[1];
78
+
79
+ const launchctl = (args: readonly string[]): ExecResult => {
80
+ const [sub, first, second] = args;
81
+ const gui = /^gui\/(\d+)(?:\/|$)/.exec(first ?? '')?.[1];
82
+ if (gui !== undefined && state.loggedOut.has(Number(gui))) {
83
+ // The measured shape (launchctl.ts): 112, "Bad request." then the domain line.
84
+ return fail(NO_DOMAIN, `Bad request.\nCould not find domain for user gui: ${gui}`);
85
+ }
86
+ if (sub === 'print' && first !== undefined) {
87
+ const left = lingering.get(first);
88
+ if (left !== undefined) {
89
+ if (left <= 0) {
90
+ lingering.delete(first);
91
+ loaded.delete(first);
92
+ } else lingering.set(first, left - 1);
93
+ }
94
+ if (!loaded.has(first)) return fail(NOT_FOUND, `Could not find service "${first}"`);
95
+ return ok(printBlock(first, loaded.get(first), '(never exited)'));
96
+ }
97
+ if (sub === 'print-disabled' && first !== undefined) {
98
+ const lines = [...disabled]
99
+ .filter((target) => target.startsWith(`${first}/`))
100
+ .map((target) => `\t\t"${target.slice(first.length + 1)}" => disabled`);
101
+ return ok(['\tdisabled services = {', ...lines, '\t}'].join('\n'));
102
+ }
103
+ if (sub === 'bootstrap' && first !== undefined && second !== undefined) {
104
+ if (state.bootstrapFailure !== undefined) return state.bootstrapFailure;
105
+ const label = labelIn(second);
106
+ if (label === undefined) return fail(5, 'Bootstrap failed: 5: Input/output error');
107
+ const target = `${first}/${label}`;
108
+ if (loaded.has(target) || disabled.has(target))
109
+ return fail(5, 'Bootstrap failed: 5: Input/output error');
110
+ state.nextPid += 1;
111
+ loaded.set(target, state.nextPid);
112
+ return ok();
113
+ }
114
+ if (sub === 'bootout' && first !== undefined) {
115
+ if (!loaded.has(first) || lingering.has(first))
116
+ return fail(3, 'Boot-out failed: 3: No such process');
117
+ if (state.bootoutKeepsJob) return state.bootoutFailure ?? fail(5, 'Boot-out failed: 5');
118
+ // ⚠️ Model launchd letting go late: the job stays visible for `lingerPrints` more prints.
119
+ if (state.lingerPrints === 0) loaded.delete(first);
120
+ else lingering.set(first, state.lingerPrints);
121
+ return state.bootoutFailure ?? ok();
122
+ }
123
+ return fail(64, `fake launchctl: unsupported ${args.join(' ')}`);
124
+ };
125
+
126
+ const runner: HostRunner = {
127
+ effectiveUid: () => euid,
128
+ exec: async (argv) => {
129
+ calls.push([...argv]);
130
+ if (argv[0] === LAUNCHCTL) return launchctl(argv.slice(1));
131
+ return fail(127, `fake: no program ${String(argv[0])}`);
132
+ },
133
+ lookupGroup: async (nameOrId) => options.groups?.[nameOrId],
134
+ lookupUser: async (nameOrId) =>
135
+ options.users?.[nameOrId] ??
136
+ Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
137
+ privileged,
138
+ readFile: async (path) => files.get(path)?.bytes,
139
+ removeFile: async (path) => {
140
+ if (files.has(path) && (dirs.get(parent(path)) ?? 0) !== euid && !root)
141
+ throw errno('EACCES', path);
142
+ files.delete(path);
143
+ },
144
+ // ★ No real waiting: a lingering job drains one `print` per poll instead.
145
+ sleep: async () => undefined,
146
+ stat: async (path) => {
147
+ const entry = files.get(path);
148
+ if (entry !== undefined)
149
+ return {
150
+ gid: entry.gid,
151
+ kind: entry.kind,
152
+ mode: entry.mode,
153
+ size: entry.bytes.length,
154
+ uid: entry.uid,
155
+ };
156
+ return dirs.has(path)
157
+ ? { gid: 0, kind: 'directory', mode: 0o755, size: 0, uid: dirs.get(path) ?? 0 }
158
+ : undefined;
159
+ },
160
+ writeFileAtomic: async (path: string, bytes: Uint8Array, write: WriteOptions) => {
161
+ calls.push(['write', path]);
162
+ const owner = dirs.get(parent(path));
163
+ if (owner === undefined) throw errno('ENOENT', parent(path));
164
+ if (owner !== euid && !root) throw errno('EACCES', path);
165
+ if (write.uid !== undefined && write.uid !== euid && !root) throw errno('EPERM', path);
166
+ files.set(path, {
167
+ bytes,
168
+ gid: write.gid ?? 20,
169
+ kind: 'file',
170
+ mode: write.mode,
171
+ uid: write.uid ?? euid,
172
+ });
173
+ },
174
+ };
175
+ return { calls, disabled, dirs, files, loaded, runner, state };
176
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
3
+ * `news` both providers' diffs share.
4
+ *
5
+ * ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
6
+ * runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
7
+ * ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
8
+ * thrown is wrapped, so the failure channel is always an Error with a readable message.
9
+ */
10
+ import * as Effect from 'effect/Effect';
11
+
12
+ export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
13
+ Effect.tryPromise({
14
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
15
+ try: step,
16
+ });
17
+
18
+ /**
19
+ * A string prop of a diff's `news`, when that one prop is already resolved; `undefined` while it
20
+ * is still an Output (a function-typed proxy), an Effect or a Config.
21
+ * ★ For the identity props a diff must see even when the rest of `news` is unresolved — a plain
22
+ * string is resolved by definition, so `typeof` is the whole test.
23
+ */
24
+ export const resolvedString = (news: unknown, key: string): string | undefined => {
25
+ if (typeof news !== 'object' || news === null) return undefined;
26
+ const value = (news as Record<string, unknown>)[key];
27
+ return typeof value === 'string' ? value : undefined;
28
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * HostFile's props, attributes and validation — the pure half of the provider.
3
+ *
4
+ * ⛔ `content` IS NEVER A SECRET. Alchemy stores props unencrypted, so the content of every HostFile
5
+ * sits in the state store and in every plan diff. A secret file stays RENDERED BY A SECRET
6
+ * RENDERER (openbao-agent templates, on this estate) that fetches it at run time; the stack
7
+ * declares only non-secret config, and at most the PATH the renderer writes to.
8
+ * secret-tripwire.ts refuses the obvious case (a PEM private key); nothing can catch all of them.
9
+ */
10
+ import { fileSecretProblems } from './secret-tripwire.ts';
11
+
12
+ export interface HostFileProps {
13
+ /** Absolute path. The parent directory must already exist. */
14
+ path: string;
15
+ /** UTF-8 text. ⛔ Never a secret — see the file header. */
16
+ content: string;
17
+ /** Permission bits. @default 0o644 */
18
+ mode?: number;
19
+ /** User name or numeric uid. Omitted: whoever the runner writes as (a rewrite keeps no owner). */
20
+ owner?: string | number;
21
+ /** Group name or numeric gid. Omitted: the directory's default group. */
22
+ group?: string | number;
23
+ }
24
+
25
+ export interface HostFileAttributes {
26
+ path: string;
27
+ /** SHA-256 of the bytes on disk. */
28
+ sha256: string;
29
+ mode: number;
30
+ uid: number;
31
+ gid: number;
32
+ size: number;
33
+ }
34
+
35
+ export const DEFAULT_MODE = 0o644;
36
+
37
+ const identityProblems = (name: string, value: string | number | undefined): string[] => {
38
+ if (value === undefined) return [];
39
+ if (typeof value === 'number') {
40
+ return Number.isSafeInteger(value) && value >= 0
41
+ ? []
42
+ : [`${name} must be a non-negative integer id`];
43
+ }
44
+ return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value) || /^\d+$/.test(value)
45
+ ? []
46
+ : [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
47
+ };
48
+
49
+ export const fileProblems = (props: HostFileProps): string[] => {
50
+ const found: string[] = [];
51
+ const { path } = props;
52
+ if (!path.startsWith('/')) found.push('path must be absolute');
53
+ // ★ One spelling per file: `a/./b`, `a//b` and `a/../b` would each be a second resource for the
54
+ // same inode, and `..` is how a derived path walks out of the directory it was meant for.
55
+ if (
56
+ path.endsWith('/') ||
57
+ path
58
+ .split('/')
59
+ .slice(1)
60
+ .some((part) => part === '' || part === '.' || part === '..')
61
+ ) {
62
+ found.push('path must be normalised (no trailing slash, no empty, "." or ".." segments)');
63
+ }
64
+ if (path.includes('\x00')) found.push('path contains NUL');
65
+ const mode = props.mode ?? DEFAULT_MODE;
66
+ if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) found.push('mode must be 0–0o7777');
67
+ found.push(...identityProblems('owner', props.owner));
68
+ found.push(...identityProblems('group', props.group));
69
+ found.push(...fileSecretProblems(props.content));
70
+ return found;
71
+ };
@@ -0,0 +1,198 @@
1
+ /**
2
+ * HostFile's lifecycle against the fake host (no real writes), plus its validation.
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import { fakeRunner } from './fake-runner.ts';
6
+ import { type HostFileProps, fileProblems } from './host-file-form.ts';
7
+ import { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';
8
+
9
+ const PATH = '/etc/example/app.conf';
10
+ const file: HostFileProps = { content: 'listen = 127.0.0.1:9000\n', path: PATH };
11
+
12
+ const host = (euid = 0) =>
13
+ fakeRunner({
14
+ dirs: { '/etc/example': 0, '/Users/someone/cfg': 501 },
15
+ euid,
16
+ groups: { staff: 20, wheel: 0 },
17
+ users: {
18
+ root: { gid: 0, home: '/var/root', uid: 0 },
19
+ someone: { gid: 20, home: '/Users/someone', uid: 501 },
20
+ },
21
+ });
22
+
23
+ const writeCount = (calls: string[][]) => calls.filter((call) => call[0] === 'write').length;
24
+
25
+ describe('create and read back', () => {
26
+ test('writes the content with the default mode 0644 and returns what is on disk', async () => {
27
+ const fake = host();
28
+ const attrs = await reconcileFile(fake.runner, file);
29
+ expect(attrs).toMatchObject({ mode: 0o644, path: PATH, size: file.content.length, uid: 0 });
30
+ expect(new TextDecoder().decode(fake.files.get(PATH)?.bytes)).toBe(file.content);
31
+ expect(await readFileAttributes(fake.runner, PATH)).toEqual(attrs);
32
+ });
33
+
34
+ test('owner and group resolve by name or by id', async () => {
35
+ const fake = host();
36
+ const attrs = await reconcileFile(fake.runner, {
37
+ ...file,
38
+ group: 'staff',
39
+ mode: 0o640,
40
+ owner: 'someone',
41
+ });
42
+ expect(attrs).toMatchObject({ gid: 20, mode: 0o640, uid: 501 });
43
+ const byId = await reconcileFile(fake.runner, { ...file, group: 0, owner: '0' }, attrs);
44
+ expect(byId).toMatchObject({ gid: 0, uid: 0 });
45
+ });
46
+
47
+ test('an already-matching file is not rewritten', async () => {
48
+ const fake = host();
49
+ await reconcileFile(fake.runner, file);
50
+ await reconcileFile(fake.runner, file);
51
+ expect(writeCount(fake.calls)).toBe(1);
52
+ });
53
+ });
54
+
55
+ describe('diff', () => {
56
+ test('noop, then update on content, mode or owner drift', async () => {
57
+ const fake = host();
58
+ const output = await reconcileFile(fake.runner, { ...file, owner: 'root' });
59
+ expect(await diffFile(fake.runner, { ...file, owner: 'root' }, output)).toEqual({
60
+ action: 'noop',
61
+ });
62
+ expect(await diffFile(fake.runner, { ...file, content: 'x\n' }, output)).toEqual({
63
+ action: 'update',
64
+ });
65
+ expect(await diffFile(fake.runner, { ...file, mode: 0o600 }, output)).toEqual({
66
+ action: 'update',
67
+ });
68
+ expect(await diffFile(fake.runner, { ...file, owner: 'someone' }, output)).toEqual({
69
+ action: 'update',
70
+ });
71
+ const entry = fake.files.get(PATH);
72
+ if (entry === undefined) throw new Error('no file');
73
+ fake.files.set(PATH, { ...entry, mode: 0o666 });
74
+ expect(await diffFile(fake.runner, { ...file, owner: 'root' }, output)).toEqual({
75
+ action: 'update',
76
+ });
77
+ });
78
+
79
+ test('a file deleted behind our back is an update', async () => {
80
+ const fake = host();
81
+ const output = await reconcileFile(fake.runner, file);
82
+ fake.files.delete(PATH);
83
+ expect(await diffFile(fake.runner, file, output)).toEqual({ action: 'update' });
84
+ });
85
+
86
+ test('a new path is a create-before-delete replace', async () => {
87
+ const fake = host();
88
+ const output = await reconcileFile(fake.runner, file);
89
+ expect(
90
+ await diffFile(fake.runner, { ...file, path: '/etc/example/other.conf' }, output),
91
+ ).toEqual({ action: 'replace' });
92
+ });
93
+ });
94
+
95
+ describe('refusals', () => {
96
+ test('chown to another user as a non-root deployer', async () => {
97
+ const fake = host(501);
98
+ await expect(
99
+ reconcileFile(fake.runner, { ...file, owner: 'root', path: '/Users/someone/cfg/a' }),
100
+ ).rejects.toThrow('only root may chown');
101
+ });
102
+
103
+ test('a symlink or a directory at the path is never replaced', async () => {
104
+ const fake = host();
105
+ fake.files.set(PATH, { bytes: new Uint8Array(), gid: 0, kind: 'symlink', mode: 0o755, uid: 0 });
106
+ await expect(reconcileFile(fake.runner, file)).rejects.toThrow('is a symlink');
107
+ await expect(reconcileFile(fake.runner, { ...file, path: '/etc/example' })).rejects.toThrow(
108
+ 'is a directory',
109
+ );
110
+ });
111
+
112
+ test('an unknown owner or group', async () => {
113
+ const fake = host();
114
+ await expect(reconcileFile(fake.runner, { ...file, owner: 'nobody-here' })).rejects.toThrow(
115
+ 'no user',
116
+ );
117
+ await expect(reconcileFile(fake.runner, { ...file, group: 'nogroup' })).rejects.toThrow(
118
+ 'no group',
119
+ );
120
+ });
121
+
122
+ test('a missing parent directory surfaces as the error it is', async () => {
123
+ await expect(reconcileFile(host().runner, { ...file, path: '/nowhere/x' })).rejects.toThrow(
124
+ 'ENOENT',
125
+ );
126
+ });
127
+
128
+ test.each([
129
+ ['a relative path', { path: 'etc/x' }, 'absolute'],
130
+ ['a dot-dot segment', { path: '/etc/../x' }, 'normalised'],
131
+ ['a trailing slash', { path: '/etc/x/' }, 'normalised'],
132
+ ['a double slash', { path: '/etc//x' }, 'normalised'],
133
+ ['a mode above 0o7777', { mode: 0o10000 }, 'mode'],
134
+ ['a bad owner name', { owner: 'a b' }, 'not a valid user'],
135
+ ['a negative gid', { group: -1 }, 'non-negative'],
136
+ // ⚠️ Split so gitleaks' private-key rule does not flag the fixture (secret-tripwire.test.ts).
137
+ ['a private key', { content: `-----BEGIN EC ${'PRIVATE'} KEY-----\n` }, 'openbao-agent'],
138
+ ])('%s', (_name, patch, message) => {
139
+ expect(fileProblems({ ...file, ...patch } as HostFileProps).join('\n')).toContain(message);
140
+ });
141
+ });
142
+
143
+ describe('files this resource does not own, and moves', () => {
144
+ const OTHER = '/etc/example/other.conf';
145
+
146
+ test('a different file at a path with no prior state is refused and left alone', async () => {
147
+ // ★ The new path of a replace: the engine's adoption probe never looked there.
148
+ const fake = host();
149
+ fake.files.set(OTHER, {
150
+ bytes: new Uint8Array([7]),
151
+ gid: 0,
152
+ kind: 'file',
153
+ mode: 0o600,
154
+ uid: 0,
155
+ });
156
+ await expect(reconcileFile(fake.runner, { ...file, path: OTHER })).rejects.toThrow(
157
+ 'not this resource',
158
+ );
159
+ expect(fake.files.get(OTHER)?.bytes).toEqual(new Uint8Array([7]));
160
+ });
161
+
162
+ test('a move that reaches reconcile as an update writes the new path, then removes the old', async () => {
163
+ const fake = host();
164
+ const output = await reconcileFile(fake.runner, file);
165
+ const moved = await reconcileFile(fake.runner, { ...file, path: OTHER }, output);
166
+ expect(moved.path).toBe(OTHER);
167
+ expect(fake.files.has(OTHER)).toBe(true);
168
+ expect(fake.files.has(PATH)).toBe(false);
169
+ });
170
+
171
+ test('a create whose read-back fails is rolled back, so the next plan finds nothing', async () => {
172
+ const fake = host();
173
+ const runner = {
174
+ ...fake.runner,
175
+ writeFileAtomic: (path: string, _bytes: Uint8Array, options: { mode: number }) =>
176
+ fake.runner.writeFileAtomic(path, new Uint8Array([0]), options),
177
+ };
178
+ await expect(reconcileFile(runner, file)).rejects.toThrow('does not match');
179
+ expect(fake.files.has(PATH)).toBe(false);
180
+ });
181
+ });
182
+
183
+ describe('delete', () => {
184
+ test('removes the file and is idempotent', async () => {
185
+ const fake = host();
186
+ const output = await reconcileFile(fake.runner, file);
187
+ await deleteFile(fake.runner, output);
188
+ expect(fake.files.has(PATH)).toBe(false);
189
+ await deleteFile(fake.runner, output);
190
+ });
191
+
192
+ test('refuses once the path has become something other than a file', async () => {
193
+ const fake = host();
194
+ const output = await reconcileFile(fake.runner, file);
195
+ fake.files.set(PATH, { bytes: new Uint8Array(), gid: 0, kind: 'symlink', mode: 0o755, uid: 0 });
196
+ await expect(deleteFile(fake.runner, output)).rejects.toThrow('not removing');
197
+ });
198
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * HostFile's read / diff / reconcile / delete as plain async functions over a HostRunner, so the
3
+ * lifecycle runs against fake-runner.ts in tests.
4
+ *
5
+ * ⛔ SYMLINKS AND DIRECTORIES ARE REFUSED, NOT REPLACED. rename(2) over a symlink replaces the LINK,
6
+ * so a path that is a symlink (nix-darwin's /etc entries point into /nix/store) would be quietly
7
+ * taken from the tool that owns it — which puts it back, or refuses its next activation. Remove
8
+ * the path from its owner first, then declare it here.
9
+ */
10
+ import type { Diff } from 'alchemy/Diff';
11
+ import {
12
+ DEFAULT_MODE,
13
+ type HostFileAttributes,
14
+ type HostFileProps,
15
+ fileProblems,
16
+ } from './host-file-form.ts';
17
+ import { sha256Hex } from './job-form.ts';
18
+ import { type HostRunner, canActAsRoot } from './runner.ts';
19
+
20
+ type Desired = {
21
+ readonly bytes: Uint8Array;
22
+ readonly sha256: string;
23
+ readonly mode: number;
24
+ readonly uid?: number;
25
+ readonly gid?: number;
26
+ };
27
+
28
+ const refuse = (path: string, message: string): Error => new Error(`Host.File ${path}: ${message}`);
29
+
30
+ const numericId = (value: string | number): number | undefined =>
31
+ typeof value === 'number' ? value : /^\d+$/.test(value) ? Number(value) : undefined;
32
+
33
+ /** Validate, then resolve owner/group names to ids through the runner. */
34
+ export const desiredFile = async (runner: HostRunner, props: HostFileProps): Promise<Desired> => {
35
+ const found = fileProblems(props);
36
+ if (found.length > 0) throw refuse(props.path, found.join('; '));
37
+ const bytes = new TextEncoder().encode(props.content);
38
+ let uid: number | undefined;
39
+ let gid: number | undefined;
40
+ if (props.owner !== undefined) {
41
+ uid = numericId(props.owner) ?? (await runner.lookupUser(String(props.owner)))?.uid;
42
+ if (uid === undefined) throw refuse(props.path, `no user ${String(props.owner)} on this host`);
43
+ }
44
+ if (props.group !== undefined) {
45
+ gid = numericId(props.group) ?? (await runner.lookupGroup(String(props.group)));
46
+ if (gid === undefined) throw refuse(props.path, `no group ${String(props.group)} on this host`);
47
+ }
48
+ return {
49
+ bytes,
50
+ mode: props.mode ?? DEFAULT_MODE,
51
+ sha256: sha256Hex(bytes),
52
+ ...(uid === undefined ? {} : { uid }),
53
+ ...(gid === undefined ? {} : { gid }),
54
+ };
55
+ };
56
+
57
+ /** The file as it is now, or `undefined` when nothing is at the path. Non-files read with no digest. */
58
+ export const readFileAttributes = async (
59
+ runner: HostRunner,
60
+ path: string,
61
+ ): Promise<HostFileAttributes | undefined> => {
62
+ const stat = await runner.stat(path);
63
+ if (stat === undefined) return undefined;
64
+ const bytes = stat.kind === 'file' ? await runner.readFile(path) : undefined;
65
+ return {
66
+ gid: stat.gid,
67
+ mode: stat.mode,
68
+ path,
69
+ sha256: bytes === undefined ? '' : sha256Hex(bytes),
70
+ size: stat.size,
71
+ uid: stat.uid,
72
+ };
73
+ };
74
+
75
+ const matches = (live: HostFileAttributes, want: Desired): boolean =>
76
+ live.sha256 === want.sha256 &&
77
+ live.mode === want.mode &&
78
+ (want.uid === undefined || live.uid === want.uid) &&
79
+ (want.gid === undefined || live.gid === want.gid);
80
+
81
+ export const diffFile = async (
82
+ runner: HostRunner,
83
+ news: HostFileProps,
84
+ output: HostFileAttributes,
85
+ ): Promise<Diff> => {
86
+ // ★ Create-before-delete: two paths can hold two files at once, so nothing forces deleteFirst.
87
+ if (news.path !== output.path) return { action: 'replace' };
88
+ const want = await desiredFile(runner, news);
89
+ if (want.sha256 !== output.sha256) return { action: 'update' };
90
+ const live = await readFileAttributes(runner, news.path);
91
+ return live !== undefined && matches(live, want) ? { action: 'noop' } : { action: 'update' };
92
+ };
93
+
94
+ export const reconcileFile = async (
95
+ runner: HostRunner,
96
+ props: HostFileProps,
97
+ output?: HostFileAttributes,
98
+ ): Promise<HostFileAttributes> => {
99
+ const want = await desiredFile(runner, props);
100
+ // ⛔ NO SILENT SUDO: handing a file to another user is root's call (chown(2)), so say so up front.
101
+ if (want.uid !== undefined && want.uid !== runner.effectiveUid() && !canActAsRoot(runner)) {
102
+ throw refuse(
103
+ props.path,
104
+ `owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` +
105
+ 'or through a privileged HostRunner (privileged: true). This provider never calls sudo.',
106
+ );
107
+ }
108
+ const stat = await runner.stat(props.path);
109
+ if (stat !== undefined && stat.kind !== 'file') {
110
+ throw refuse(
111
+ props.path,
112
+ `is a ${stat.kind}; remove it from whatever owns it before declaring it here`,
113
+ );
114
+ }
115
+ const before = stat === undefined ? undefined : await readFileAttributes(runner, props.path);
116
+ // ⚠️ An `output` at another path: the engine planned an UPDATE across a move, which it does
117
+ // when diff could not see the new path (host-file.ts). Finish it the way a replace would.
118
+ const moved = output !== undefined && output.path !== props.path;
119
+ const prior = moved ? undefined : output;
120
+ /**
121
+ * ⛔ A FILE THIS RESOURCE DOES NOT OWN IS NEVER OVERWRITTEN. With no prior state for this path,
122
+ * the engine's adoption probe has already refused anything it found — except at the new path of
123
+ * a replace, where it reads nothing. A path typo there would otherwise overwrite, say, a system
124
+ * file that a fresh declaration of the same path would have been refused as `Unowned`.
125
+ */
126
+ if (prior === undefined && before !== undefined && !matches(before, want)) {
127
+ throw refuse(
128
+ props.path,
129
+ 'already exists and is not this resource. Remove it, or declare it as a new resource and ' +
130
+ 'deploy with --adopt.',
131
+ );
132
+ }
133
+ if (before === undefined || !matches(before, want)) {
134
+ await runner.writeFileAtomic(props.path, want.bytes, {
135
+ mode: want.mode,
136
+ ...(want.uid === undefined ? {} : { uid: want.uid }),
137
+ ...(want.gid === undefined ? {} : { gid: want.gid }),
138
+ });
139
+ }
140
+ // ⚠️ READ BACK, never echo the declaration: a umask, an ACL or a runner that ignored `uid` shows
141
+ // up here as a refusal instead of as a forever-`update`.
142
+ const after = await readFileAttributes(runner, props.path);
143
+ if (after === undefined || !matches(after, want)) {
144
+ // ⚠️ Roll back a CREATE: left behind, the next plan's recovery `read` finds a file with no
145
+ // state, reports it `Unowned`, and every later deploy demands --adopt for our own file.
146
+ if (before === undefined) await runner.removeFile(props.path).catch(() => undefined);
147
+ throw refuse(
148
+ props.path,
149
+ 'the write returned but the file on disk does not match the declaration',
150
+ );
151
+ }
152
+ // ★ Create-before-delete, as the replace would have been: the old path goes only once the new
153
+ // one is written and verified.
154
+ if (moved) await deleteFile(runner, output);
155
+ return after;
156
+ };
157
+
158
+ /** Remove the file. Idempotent. ⛔ Refuses a path that has since become a symlink or directory. */
159
+ export const deleteFile = async (runner: HostRunner, output: HostFileAttributes): Promise<void> => {
160
+ const stat = await runner.stat(output.path);
161
+ if (stat === undefined) return;
162
+ if (stat.kind !== 'file') throw refuse(output.path, `is now a ${stat.kind}; not removing it`);
163
+ await runner.removeFile(output.path);
164
+ };