@homeflare/alchemy 0.20.0 → 0.21.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 (172) hide show
  1. package/README.md +25 -0
  2. package/dist/caddy/access-forward-auth.d.ts +56 -0
  3. package/dist/caddy/access-forward-auth.d.ts.map +1 -0
  4. package/dist/caddy/index.d.ts +6 -0
  5. package/dist/caddy/index.d.ts.map +1 -1
  6. package/dist/caddy/index.js +169 -75
  7. package/dist/caddy/index.js.map +10 -7
  8. package/dist/cloudflare/mcp-server-api.d.ts +135 -0
  9. package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
  10. package/dist/cloudflare/mcp-server-form.d.ts +116 -0
  11. package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/launchd/file-converge.d.ts +70 -0
  15. package/dist/launchd/file-converge.d.ts.map +1 -0
  16. package/dist/launchd/file-identity.d.ts +32 -0
  17. package/dist/launchd/file-identity.d.ts.map +1 -0
  18. package/dist/launchd/host-file-form.d.ts +2 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts +11 -24
  21. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  22. package/dist/launchd/host-file.d.ts.map +1 -1
  23. package/dist/launchd/index.js +173 -120
  24. package/dist/launchd/index.js.map +12 -10
  25. package/dist/launchd/local-runner.d.ts.map +1 -1
  26. package/dist/launchd/runner.d.ts +17 -2
  27. package/dist/launchd/runner.d.ts.map +1 -1
  28. package/dist/launchd/sudo-runner.d.ts +11 -0
  29. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  30. package/dist/linux/index.js.map +3 -3
  31. package/dist/linux/sudo-listing.d.ts +47 -0
  32. package/dist/linux/sudo-listing.d.ts.map +1 -0
  33. package/dist/proxmox/credential-literals.d.ts +1 -1
  34. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  35. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  36. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  37. package/dist/proxmox/generated/pbs.d.ts +7 -0
  38. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  39. package/dist/proxmox/generated/pve.d.ts +7 -0
  40. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  41. package/dist/proxmox/index.d.ts +1 -1
  42. package/dist/proxmox/index.d.ts.map +1 -1
  43. package/dist/proxmox/index.js +2 -2
  44. package/dist/proxmox/index.js.map +9 -9
  45. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  46. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  47. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  48. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  49. package/dist/release/archive.d.ts +11 -0
  50. package/dist/release/archive.d.ts.map +1 -0
  51. package/dist/release/binary-claim.d.ts +53 -0
  52. package/dist/release/binary-claim.d.ts.map +1 -0
  53. package/dist/release/binary-diff.d.ts +38 -0
  54. package/dist/release/binary-diff.d.ts.map +1 -0
  55. package/dist/release/binary-form.d.ts +118 -0
  56. package/dist/release/binary-form.d.ts.map +1 -0
  57. package/dist/release/binary-lifecycle.d.ts +55 -0
  58. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  59. package/dist/release/binary-read.d.ts +11 -0
  60. package/dist/release/binary-read.d.ts.map +1 -0
  61. package/dist/release/binary.d.ts +68 -0
  62. package/dist/release/binary.d.ts.map +1 -0
  63. package/dist/release/catalog.d.ts +76 -0
  64. package/dist/release/catalog.d.ts.map +1 -0
  65. package/dist/release/checksums.d.ts +22 -0
  66. package/dist/release/checksums.d.ts.map +1 -0
  67. package/dist/release/declared-pins.d.ts +4 -0
  68. package/dist/release/declared-pins.d.ts.map +1 -0
  69. package/dist/release/download.d.ts +44 -0
  70. package/dist/release/download.d.ts.map +1 -0
  71. package/dist/release/fake-deploy.d.ts +48 -0
  72. package/dist/release/fake-deploy.d.ts.map +1 -0
  73. package/dist/release/fake-engine.d.ts +8 -0
  74. package/dist/release/fake-engine.d.ts.map +1 -0
  75. package/dist/release/fake-install.d.ts +39 -0
  76. package/dist/release/fake-install.d.ts.map +1 -0
  77. package/dist/release/fake-provider.d.ts +58 -0
  78. package/dist/release/fake-provider.d.ts.map +1 -0
  79. package/dist/release/fake-release.d.ts +95 -0
  80. package/dist/release/fake-release.d.ts.map +1 -0
  81. package/dist/release/index.d.ts +24 -0
  82. package/dist/release/index.d.ts.map +1 -0
  83. package/dist/release/index.js +1365 -0
  84. package/dist/release/index.js.map +35 -0
  85. package/dist/release/providers.d.ts +19 -0
  86. package/dist/release/providers.d.ts.map +1 -0
  87. package/dist/release/refused.d.ts +39 -0
  88. package/dist/release/refused.d.ts.map +1 -0
  89. package/dist/release/tar.d.ts +14 -0
  90. package/dist/release/tar.d.ts.map +1 -0
  91. package/dist/release/victoria.d.ts +36 -0
  92. package/dist/release/victoria.d.ts.map +1 -0
  93. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  94. package/docs/release-binary-catalogs.md +113 -0
  95. package/docs/release-binary-measured.md +163 -0
  96. package/docs/release-binary-refusals.md +57 -0
  97. package/docs/release-binary-upstream.md +163 -0
  98. package/docs/release-binary.md +154 -0
  99. package/package.json +5 -1
  100. package/src/caddy/access-forward-auth.test.ts +63 -0
  101. package/src/caddy/access-forward-auth.ts +120 -0
  102. package/src/caddy/index.ts +10 -0
  103. package/src/caddy/providers.test.ts +4 -0
  104. package/src/cloudflare/mcp-server-api.ts +294 -0
  105. package/src/cloudflare/mcp-server-form.ts +219 -0
  106. package/src/index.ts +1 -0
  107. package/src/launchd/file-converge.ts +233 -0
  108. package/src/launchd/file-identity.ts +69 -0
  109. package/src/launchd/host-file-alias.test.ts +65 -0
  110. package/src/launchd/host-file-form.ts +2 -1
  111. package/src/launchd/host-file-lifecycle.ts +25 -144
  112. package/src/launchd/host-file.ts +8 -2
  113. package/src/launchd/local-runner.test.ts +12 -1
  114. package/src/launchd/local-runner.ts +2 -0
  115. package/src/launchd/runner.ts +17 -2
  116. package/src/launchd/sudo-runner.ts +11 -0
  117. package/src/linux/sudo-listing.ts +122 -0
  118. package/src/proxmox/credential-literals.ts +1 -1
  119. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  120. package/src/proxmox/generated/pbs.ts +7 -0
  121. package/src/proxmox/generated/pve.ts +7 -0
  122. package/src/proxmox/index.ts +1 -1
  123. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  124. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  125. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  126. package/src/proxmox/pbs-notification-target.ts +1 -1
  127. package/src/release/adopt-parity.test.ts +106 -0
  128. package/src/release/apply-pins.test.ts +128 -0
  129. package/src/release/archive.ts +94 -0
  130. package/src/release/binary-alias.test.ts +109 -0
  131. package/src/release/binary-claim.ts +80 -0
  132. package/src/release/binary-claims.test.ts +47 -0
  133. package/src/release/binary-diff.ts +73 -0
  134. package/src/release/binary-form.ts +229 -0
  135. package/src/release/binary-lifecycle.test.ts +90 -0
  136. package/src/release/binary-lifecycle.ts +202 -0
  137. package/src/release/binary-read.ts +55 -0
  138. package/src/release/binary-refusals.test.ts +192 -0
  139. package/src/release/binary-sudo.test.ts +93 -0
  140. package/src/release/binary.ts +181 -0
  141. package/src/release/catalog.test.ts +96 -0
  142. package/src/release/catalog.ts +175 -0
  143. package/src/release/checksums.test.ts +35 -0
  144. package/src/release/checksums.ts +43 -0
  145. package/src/release/declared-pins.ts +39 -0
  146. package/src/release/download.test.ts +138 -0
  147. package/src/release/download.ts +110 -0
  148. package/src/release/error-channel.test.ts +69 -0
  149. package/src/release/fake-deploy.ts +87 -0
  150. package/src/release/fake-engine.ts +50 -0
  151. package/src/release/fake-install.ts +59 -0
  152. package/src/release/fake-provider.ts +68 -0
  153. package/src/release/fake-release.ts +185 -0
  154. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  155. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  156. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  157. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  158. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  159. package/src/release/index.ts +41 -0
  160. package/src/release/plan.test.ts +189 -0
  161. package/src/release/provider-refusals.test.ts +48 -0
  162. package/src/release/providers.test.ts +174 -0
  163. package/src/release/providers.ts +24 -0
  164. package/src/release/recovery-read.test.ts +44 -0
  165. package/src/release/refused.ts +40 -0
  166. package/src/release/tar.test.ts +142 -0
  167. package/src/release/tar.ts +195 -0
  168. package/src/release/victoria.test.ts +108 -0
  169. package/src/release/victoria.ts +154 -0
  170. package/src/{proxmox → secrets}/write-only.ts +6 -0
  171. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  172. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Release.Binary's read / reconcile / delete as plain async functions over a HostRunner and a
3
+ * FetchArchive, so the lifecycle runs against fakes of both in tests. (diff: binary-diff.ts.)
4
+ *
5
+ * ⛔ ONE WRITER. The verified bytes reach the host through file-converge.ts — the path Host.File's
6
+ * bytes take — and so through `HostRunner.writeFileAtomic` alone: a temp file in the same
7
+ * directory, mode and owner set, rename. Under sudoRunner that is its staged `install -S`.
8
+ * Nothing here opens a file, spawns `tar`, or stages a download on disk.
9
+ * ★ REFUSE BEFORE TOUCHING ANYTHING. Validation runs before the first host call, the host checks
10
+ * run before the first download, and the digest checks run before the one write — so every
11
+ * refusal but a failed read-back can say "Nothing was written."
12
+ * ★ AN INSTALLED BINARY IS RECOGNISED BY ITS DIGEST. A file whose SHA-256 is the pin needs no
13
+ * download to confirm, and a mode or owner fix re-writes the bytes already on disk.
14
+ */
15
+ import {
16
+ type FileTarget,
17
+ convergeFile,
18
+ readFileAttributes,
19
+ removeWholeFile,
20
+ resolveIds,
21
+ } from '../launchd/file-converge.ts';
22
+ import type { HostFileAttributes } from '../launchd/host-file-form.ts';
23
+ import { sha256Hex } from '../launchd/job-form.ts';
24
+ import type { HostRunner } from '../launchd/runner.ts';
25
+ import { verifiedMember } from './archive.ts';
26
+ import { claimProblem } from './binary-claim.ts';
27
+ import {
28
+ DEFAULT_BINARY_MODE,
29
+ type PinnedDownload,
30
+ type ReleaseBinaryAttributes,
31
+ type ReleaseBinaryProps,
32
+ binaryProblems,
33
+ inPlaceRefusal,
34
+ pinnedDownload,
35
+ pinsMoved,
36
+ releaseBinaryPath,
37
+ } from './binary-form.ts';
38
+ import type { FetchArchive } from './download.ts';
39
+ import { ArchiveRefused, BinaryRefused, ChecksumMismatch, DownloadFailed } from './refused.ts';
40
+
41
+ export const NOTHING = 'Nothing was written.';
42
+
43
+ export const refuse = (path: string, message: string): BinaryRefused =>
44
+ new BinaryRefused({ message: `Release.Binary ${path}: ${message}` });
45
+
46
+ /** Re-say a download or archive failure as this resource's, keeping its tag. */
47
+ const restated = (path: string, cause: unknown): unknown => {
48
+ const say = (message: string) => `Release.Binary ${path}: ${message}. ${NOTHING}`;
49
+ if (cause instanceof ChecksumMismatch) {
50
+ const { actual, expected, subject } = cause;
51
+ return new ChecksumMismatch({ actual, expected, message: say(cause.message), subject });
52
+ }
53
+ if (cause instanceof ArchiveRefused) return new ArchiveRefused({ message: say(cause.message) });
54
+ if (cause instanceof DownloadFailed) {
55
+ return new DownloadFailed({ message: say(cause.message), retryable: cause.retryable });
56
+ }
57
+ return cause;
58
+ };
59
+
60
+ export type Desired = { readonly want: FileTarget; readonly download: PinnedDownload };
61
+
62
+ /** Validate every prop, then resolve owner and group through the runner. */
63
+ export const desiredBinary = async (
64
+ runner: HostRunner,
65
+ props: ReleaseBinaryProps,
66
+ ): Promise<Desired> => {
67
+ const path = releaseBinaryPath(props);
68
+ const problems = binaryProblems(props);
69
+ if (problems.length > 0) throw refuse(path, `${problems.join('; ')}. ${NOTHING}`);
70
+ const ids = await resolveIds(runner, props, path, refuse);
71
+ const mode = props.mode ?? DEFAULT_BINARY_MODE;
72
+ return { download: pinnedDownload(props), want: { mode, path, sha256: props.sha256, ...ids } };
73
+ };
74
+
75
+ const withSource = (
76
+ found: HostFileAttributes,
77
+ download: PinnedDownload,
78
+ ): ReleaseBinaryAttributes => ({ ...found, member: download.member, url: download.url });
79
+
80
+ /**
81
+ * The file at the declaration's path, or undefined when absent — the adoption probe's answer.
82
+ * ★ Its `sha256` is the digest of the bytes on disk, so a caller sees at once whether it is the
83
+ * pinned binary (equal to `props.sha256`) without anything being executed.
84
+ */
85
+ export const readBinary = async (
86
+ runner: HostRunner,
87
+ props: ReleaseBinaryProps,
88
+ ): Promise<ReleaseBinaryAttributes | undefined> => {
89
+ const { download, want } = await desiredBinary(runner, props);
90
+ const found = await readFileAttributes(runner, want.path);
91
+ return found === undefined ? undefined : withSource(found, download);
92
+ };
93
+
94
+ /** An owned binary as it is now, at the path state recorded — no pins needed to find it. */
95
+ export const refreshBinary = async (
96
+ runner: HostRunner,
97
+ output: ReleaseBinaryAttributes,
98
+ ): Promise<ReleaseBinaryAttributes | undefined> => {
99
+ const found = await readFileAttributes(runner, output.path);
100
+ return found === undefined ? undefined : { ...found, member: output.member, url: output.url };
101
+ };
102
+
103
+ /**
104
+ * ⛔ THE DIRECTORY IS DECLARED, NEVER INVENTED. Checked before any download: a missing directory
105
+ * would otherwise cost 123 MB and then fail at the write. A symlink is refused as well — the
106
+ * write would land wherever it points, under an owner nobody declared.
107
+ */
108
+ const directoryReady = async (runner: HostRunner, props: ReleaseBinaryProps): Promise<void> => {
109
+ const stat = await runner.stat(props.directory);
110
+ /**
111
+ * ⛔ AND ONE ONLY ITS OWNER MAY WRITE. The mode rule (binary-form.ts modeProblems) refuses a
112
+ * group- or world-writable binary because its writers could change the code a daemon runs. A
113
+ * directory they may write gives them the same power by another call: rename(2) their own file
114
+ * over the binary, and the next launchd start runs theirs. The sticky bit is no excuse — a
115
+ * binary directory has no business being shared.
116
+ */
117
+ if (stat?.kind === 'directory' && (stat.mode & 0o022) !== 0) {
118
+ throw refuse(
119
+ releaseBinaryPath(props),
120
+ `directory ${props.directory} is writable by group or other (mode ` +
121
+ `${stat.mode.toString(8).padStart(4, '0')}), so whoever may write it may replace the ` +
122
+ `binary; declare it 0755 or narrower. ${NOTHING}`,
123
+ );
124
+ }
125
+ if (stat?.kind === 'directory') return;
126
+ const why = stat === undefined ? 'does not exist' : `is a ${stat.kind}, not a directory`;
127
+ throw refuse(
128
+ releaseBinaryPath(props),
129
+ `directory ${props.directory} ${why}; declare it (HostDirectory) and pass its path. ${NOTHING}`,
130
+ );
131
+ };
132
+
133
+ export type ReconcileOptions = {
134
+ /** The attributes state holds; undefined on a create. */
135
+ readonly output?: ReleaseBinaryAttributes | undefined;
136
+ /** The props state holds; undefined on a create and on an adoption. */
137
+ readonly olds?: ReleaseBinaryProps | undefined;
138
+ /** adoptsAtApply's answer. What it may take over is convergeFile's ⛔. @default false */
139
+ readonly adopt?: boolean;
140
+ /** Progress for the deploy log — the engine's `session.note`. */
141
+ readonly note?: (message: string) => Promise<void>;
142
+ /**
143
+ * What is wrong with the pins as the stack program DECLARED them (declared-pins.ts) — an Output
144
+ * there resolves to a plausible plain string by the time it reaches `props`. @default []
145
+ */
146
+ readonly declared?: readonly string[];
147
+ };
148
+
149
+ /** Install the declared binary unless it is already there, and return it as read back. */
150
+ export const reconcileBinary = async (
151
+ runner: HostRunner,
152
+ fetch: FetchArchive,
153
+ props: ReleaseBinaryProps,
154
+ options: ReconcileOptions = {},
155
+ ): Promise<ReleaseBinaryAttributes> => {
156
+ const { adopt = false, declared = [], note = async () => undefined } = options;
157
+ // ⛔ FIRST, before any host call: a digest the deploy computed is a fetch by another name.
158
+ if (declared.length > 0) {
159
+ throw refuse(
160
+ releaseBinaryPath(props),
161
+ `${declared.join('; ')}. A pin is copied into reviewed code, never computed during the ` +
162
+ `deploy (declared-pins.ts). ${NOTHING}`,
163
+ );
164
+ }
165
+ const { download, want } = await desiredBinary(runner, props);
166
+ // ⛔ The apply-time half of binary-diff.ts's in-place refusal, for an update whose `olds` say so.
167
+ const olds = options.olds;
168
+ if (olds !== undefined && releaseBinaryPath(olds) === want.path && pinsMoved(olds, props)) {
169
+ throw refuse(want.path, `${inPlaceRefusal(olds, props)}. ${NOTHING}`);
170
+ }
171
+ await directoryReady(runner, props);
172
+ // ⛔ WITH NO STATE, ONLY THE PINNED BINARY UNDER --adopt (binary-claim.ts).
173
+ // ⚠️ NOT an update across a move (`output` at another path): --adopt can never speak for one, so
174
+ // refusing identical bytes there would strand the resume of our own interrupted move with no
175
+ // remedy but a hand removal. Other bytes there stay refused (file-converge.ts).
176
+ if (options.output === undefined) {
177
+ const problem = await claimProblem(runner, want, adopt);
178
+ if (problem !== undefined) throw refuse(want.path, `${problem}. ${NOTHING}`);
179
+ }
180
+ const bytesFor = async (before: HostFileAttributes | undefined): Promise<Uint8Array> => {
181
+ // ★ The right bytes with the wrong mode or owner: re-write what is there, re-hashed, rather
182
+ // than download 123 MB to get the same bytes back.
183
+ if (before?.sha256 === want.sha256) {
184
+ const local = await runner.readFile(want.path);
185
+ if (local !== undefined && sha256Hex(local) === want.sha256) return local;
186
+ }
187
+ await note(`downloading ${download.url} (${String(download.size)} bytes)`);
188
+ try {
189
+ const bytes = await verifiedMember(await fetch(download.url, download.size), download);
190
+ await note(`verified ${download.member} ${download.memberSha256}`);
191
+ return bytes;
192
+ } catch (cause) {
193
+ throw restated(want.path, cause);
194
+ }
195
+ };
196
+ const spec = { bytesFor, owner: props.owner, refuse, want };
197
+ return withSource(await convergeFile(runner, spec, options.output, adopt), download);
198
+ };
199
+
200
+ /** Remove the binary. Idempotent. ⛔ Refuses a path that has since become a symlink or directory. */
201
+ export const deleteBinary = (runner: HostRunner, output: ReleaseBinaryAttributes): Promise<void> =>
202
+ removeWholeFile(runner, output, refuse);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Release.Binary's `read` with no attributes. Alchemy asks it twice over (ownership/probe.ts): as
3
+ * the ADOPTION PROBE (no row; `olds` is the declaration, `instanceId` fresh) and as the RECOVERY
4
+ * READ of a row whose create never recorded attributes (`olds` and `instanceId` are the row's —
5
+ * Plan.ts for a `creating` row, Apply.ts for such a row it must delete).
6
+ *
7
+ * ⛔ THE PROBE VALIDATES; THE RECOVERY READ NEVER FAILS ITS CALLER. The probe's `olds` is today's
8
+ * declaration, so a malformed prop fails the plan there, before anything is touched. The recovery
9
+ * read's `olds` is the FAILED deploy's props, and with `directory: dir.path` — the documented
10
+ * usage — a first deploy commits its `creating` row while the directory is still an Output, so
11
+ * the row has no `directory` at all. 🔴 MEASURED 2026-09-22 (adopt-parity.test.ts): after ANY
12
+ * failed first install — a 404, a checksum mismatch, a mode refused at apply — every later plan
13
+ * died with `TypeError: undefined is not an object (evaluating 'path.startsWith')`, the fixed
14
+ * declaration included, and removing the declaration failed too (DestroyError: the orphan's
15
+ * recovery read crashed the same way). The stage was bricked until a hand `alchemy state delete`.
16
+ * ownership/probe.ts states the rule this broke: the recovery read "NEVER FAILS THE CALLER".
17
+ * ★ "NOTHING RECOVERED" IS THE SAFE ANSWER, NOT `Unowned`. The plan then re-drives the create, and
18
+ * reconcile looks at the path itself with every prop resolved: a file there is refused unless
19
+ * `--adopt` (binary-claim.ts), exactly as ownership.md says a create interrupted while a prop was
20
+ * an Output resumes. An orphan's row is dropped and its path left alone — a leak at worst, never
21
+ * a delete of something unproven.
22
+ * ★ THE ROW, NOT THE SHAPE OF `olds`, SAYS WHICH READ THIS IS: a probe's instance id is minted for
23
+ * the probe, so no row holds it (ownership/rows.ts). Without a Stack or State in context a row
24
+ * reads as absent, and this validates as the probe does — the behaviour before this file.
25
+ */
26
+ import { Unowned } from 'alchemy/AdoptPolicy';
27
+ import * as Effect from 'effect/Effect';
28
+ import { lift } from '../launchd/host-effect.ts';
29
+ import type { HostRunner } from '../launchd/runner.ts';
30
+ import { recordedGeneration } from '../ownership/rows.ts';
31
+ import type { ReleaseBinaryProps } from './binary-form.ts';
32
+ import { readBinary } from './binary-lifecycle.ts';
33
+
34
+ type Ask = { readonly fqn: string; readonly instanceId: string; readonly olds: unknown };
35
+
36
+ /** The file a declaration (or a row) names, as `Unowned` — or undefined when there is none. */
37
+ export const readWithoutState = (runner: HostRunner, ask: Ask) =>
38
+ Effect.gen(function* () {
39
+ const recovering = (yield* recordedGeneration(ask.fqn, ask.instanceId)) !== undefined;
40
+ const read = lift(() => readBinary(runner, ask.olds as ReleaseBinaryProps));
41
+ const found = recovering
42
+ ? yield* read.pipe(
43
+ Effect.catch((cause) =>
44
+ Effect.as(
45
+ Effect.logWarning(
46
+ `${ask.fqn}: the interrupted create's row cannot locate its binary (${cause.message}); ` +
47
+ 'nothing is recovered, and the create is re-driven with the declaration as it is now',
48
+ ),
49
+ undefined,
50
+ ),
51
+ ),
52
+ )
53
+ : yield* read;
54
+ return found === undefined ? undefined : Unowned(found);
55
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Every refusal of the install lifecycle, each asserted by what it LEFT (fake-install.ts): the
3
+ * transport's request log, the host's write log, and the host's paths before and after.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { sha256Hex } from '../launchd/job-form.ts';
7
+ import type { ReleaseBinaryProps } from './binary-form.ts';
8
+ import { reconcileBinary } from './binary-lifecycle.ts';
9
+ import {
10
+ BINARY,
11
+ VMUTILS_DIR,
12
+ VMUTILS_ENTRIES,
13
+ VMUTILS_URL,
14
+ bytesOf,
15
+ fakeTransport,
16
+ gzip,
17
+ pathsOn,
18
+ tarOf,
19
+ } from './fake-release.ts';
20
+ import { PATH, refused, setup } from './fake-install.ts';
21
+ import { ArchiveRefused, BinaryRefused, ChecksumMismatch, DownloadFailed } from './refused.ts';
22
+
23
+ describe('refusals leave the host exactly as it was', () => {
24
+ test('a wrong archive hash', async () => {
25
+ const s = setup();
26
+ const tampered = gzip(tarOf(VMUTILS_ENTRIES.map((e) => ({ ...e, bytes: bytesOf('evil') }))));
27
+ const transport = fakeTransport({ [VMUTILS_URL]: tampered });
28
+ const error = await refused(s, () =>
29
+ reconcileBinary(s.fake.runner, transport.fetch, s.props()),
30
+ );
31
+ expect(error).toBeInstanceOf(ChecksumMismatch);
32
+ expect(error.message).toContain(`Release.Binary ${PATH}: ${VMUTILS_URL} hashes to`);
33
+ expect(error.message).toContain('Nothing was written.');
34
+ });
35
+
36
+ test('the right archive with a wrong binary pin', async () => {
37
+ const s = setup();
38
+ const error = await refused(s, () => s.install(s.props({ sha256: 'f'.repeat(64) })));
39
+ expect(error).toBeInstanceOf(ChecksumMismatch);
40
+ expect(error.message).toContain(`member "vmalert-prod" hashes to ${sha256Hex(BINARY.vmalert)}`);
41
+ });
42
+
43
+ test('a tar-slip entry beside the declared member, in a correctly pinned archive', async () => {
44
+ const s = setup([
45
+ ...VMUTILS_ENTRIES,
46
+ { bytes: bytesOf('pwn'), name: '../../../etc/sudoers.d/x' },
47
+ ]);
48
+ const error = await refused(s, () => s.install());
49
+ expect(error).toBeInstanceOf(ArchiveRefused);
50
+ expect(error.message).toContain('climbs out');
51
+ });
52
+
53
+ test('the declared member as a symlink', async () => {
54
+ const s = setup([{ name: 'vmalert-prod', type: '2' }]);
55
+ expect(await refused(s, () => s.install())).toBeInstanceOf(ArchiveRefused);
56
+ });
57
+
58
+ test('the declared member missing from a correctly pinned archive', async () => {
59
+ const s = setup([{ bytes: BINARY.vmagent, name: 'vmagent-prod' }]);
60
+ const error = await refused(s, () => s.install());
61
+ expect(error.message).toContain(
62
+ 'no member named exactly "vmalert-prod" (it has "vmagent-prod")',
63
+ );
64
+ });
65
+
66
+ test('a failed download', async () => {
67
+ const s = setup();
68
+ const error = await refused(s, () =>
69
+ reconcileBinary(s.fake.runner, fakeTransport({}).fetch, s.props()),
70
+ );
71
+ expect(error).toBeInstanceOf(DownloadFailed);
72
+ expect(error.message).toContain(`Release.Binary ${PATH}:`);
73
+ });
74
+
75
+ const archive = (more: object) => (p: ReleaseBinaryProps) => ({
76
+ archive: { ...p.archive, ...more },
77
+ });
78
+ test.each([
79
+ ['an archive digest that is not hex', archive({ sha256: 'abc' })],
80
+ ['an asset given as a glob', archive({ asset: 'vmutils-darwin-arm64-*.tar.gz' })],
81
+ ['an asset that is not a tarball', archive({ asset: 'vmutils-darwin-arm64-v1.151.0.zip' })],
82
+ ['a repo that climbs', archive({ repo: '../VictoriaMetrics' })],
83
+ ['a tag with a slash', archive({ tag: 'v1.151.0/../x' })],
84
+ ['no size', archive({ size: 0 })],
85
+ ['a size past the one-gibibyte ceiling', archive({ size: 2 ** 30 + 1 })],
86
+ ['a repo named ..', archive({ repo: 'VictoriaMetrics/..' })],
87
+ ['an upper-case member digest', () => ({ sha256: 'A'.repeat(64) })],
88
+ ['an absolute member', () => ({ member: '/vmalert-prod' })],
89
+ ['a member that climbs', () => ({ member: '../vmalert-prod' })],
90
+ ['a name with a slash', () => ({ name: 'bin/vmalert' })],
91
+ ['a relative directory', () => ({ directory: 'bin/vmutils-1.151.0' })],
92
+ ['a setuid mode', () => ({ mode: 0o4755 })],
93
+ ['a group-writable mode', () => ({ mode: 0o775 })],
94
+ // Measured 2026-09-22 before the fix: written, then EACCES on the read-back, left with no state.
95
+ ['an execute-only mode its owner cannot re-hash', () => ({ mode: 0o111 })],
96
+ ])('%s: refused before any host call or download', async (_, change) => {
97
+ const s = setup();
98
+ s.fake.calls.length = 0;
99
+ const base = s.props();
100
+ const error = await refused(s, () =>
101
+ s.install({ ...base, ...change(base) } as ReleaseBinaryProps),
102
+ );
103
+ expect(error).toBeInstanceOf(BinaryRefused);
104
+ expect(s.transport.requests).toEqual([]);
105
+ expect(s.fake.calls).toEqual([]);
106
+ });
107
+
108
+ test('a new pin at the same path: refused, the installed binary untouched', async () => {
109
+ const s = setup();
110
+ const olds = s.props();
111
+ const output = await s.install(olds);
112
+ s.transport.requests.length = 0;
113
+ s.fake.calls.length = 0;
114
+ const bumped = s.props({
115
+ archive: { ...olds.archive, tag: 'v1.152.0' },
116
+ sha256: 'e'.repeat(64),
117
+ });
118
+ const error = await refused(s, () => s.install(bumped, { olds, output }));
119
+ expect(error.message).toContain('would overwrite it in place');
120
+ expect(s.transport.requests).toEqual([]);
121
+ expect(s.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
122
+ });
123
+
124
+ test('a missing directory: refused before the download', async () => {
125
+ const s = setup();
126
+ s.fake.dirs.delete(VMUTILS_DIR);
127
+ const error = await refused(s, () => s.install());
128
+ expect(error.message).toContain(`directory ${VMUTILS_DIR} does not exist`);
129
+ expect(s.transport.requests).toEqual([]);
130
+ });
131
+
132
+ test('⛔ a directory others may write: refused before the download', async () => {
133
+ const s = setup();
134
+ const stat = s.fake.runner.stat;
135
+ s.fake.runner.stat = async (path) => {
136
+ const found = await stat(path);
137
+ return path === VMUTILS_DIR && found !== undefined ? { ...found, mode: 0o777 } : found;
138
+ };
139
+ const error = await refused(s, () => s.install());
140
+ expect(error.message).toContain('is writable by group or other (mode 0777)');
141
+ expect(s.transport.requests).toEqual([]);
142
+ });
143
+
144
+ test('a file this resource does not own, without --adopt: refused before the download', async () => {
145
+ const s = setup();
146
+ s.fake.files.set(PATH, { bytes: bytesOf('theirs'), gid: 0, kind: 'file', mode: 0o755, uid: 0 });
147
+ const error = await refused(s, () => s.install());
148
+ expect(error.message).toContain('already exists and is not this resource');
149
+ expect(s.transport.requests).toEqual([]);
150
+ });
151
+
152
+ // ⛔ binary-claim.ts: with no state, only the pinned binary, and only under --adopt.
153
+ test('the pinned bytes at the path with no state, without --adopt: refused, not claimed', async () => {
154
+ const s = setup();
155
+ s.fake.files.set(PATH, { bytes: BINARY.vmalert, gid: 0, kind: 'file', mode: 0o755, uid: 0 });
156
+ const error = await refused(s, () => s.install());
157
+ expect(error.message).toContain('it holds the pinned binary, but this stack holds no state');
158
+ expect(s.transport.requests).toEqual([]);
159
+ });
160
+
161
+ test('other bytes at the path under --adopt: refused before the download, their file kept', async () => {
162
+ const s = setup();
163
+ const theirs = bytesOf('theirs');
164
+ s.fake.files.set(PATH, { bytes: theirs, gid: 0, kind: 'file', mode: 0o755, uid: 0 });
165
+ const error = await refused(s, () => s.install(s.props(), { adopt: true }));
166
+ expect(error.message).toContain(`a file hashing to ${sha256Hex(theirs)} is there`);
167
+ expect(s.transport.requests).toEqual([]);
168
+ expect(s.fake.files.get(PATH)?.bytes).toEqual(theirs);
169
+ });
170
+
171
+ test('a write that does not read back as declared: the create is rolled back', async () => {
172
+ const s = setup();
173
+ const write = s.fake.runner.writeFileAtomic;
174
+ s.fake.runner.writeFileAtomic = (path, bytes, options) =>
175
+ write(path, bytes, { ...options, mode: 0o700 });
176
+ const before = pathsOn(s.fake);
177
+ await expect(s.install()).rejects.toThrow('does not match the declaration');
178
+ expect(pathsOn(s.fake)).toEqual(before);
179
+ });
180
+
181
+ test('⛔ a read-back that THROWS rolls the create back too, as a refusal', async () => {
182
+ const s = setup();
183
+ const read = s.fake.runner.readFile;
184
+ s.fake.runner.readFile = async (path) => {
185
+ if (s.fake.files.has(path)) throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
186
+ return read(path);
187
+ };
188
+ const before = pathsOn(s.fake);
189
+ await expect(s.install()).rejects.toThrow(`Release.Binary ${PATH}: the write returned but`);
190
+ expect(pathsOn(s.fake)).toEqual(before);
191
+ });
192
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The install through the kit's REAL sudoRunner over its fake host (launchd/fake-sudo.ts), so what
3
+ * is asserted is the runner's own staging, not a fake's: the verified bytes reach root's directory
4
+ * as one `install -S` of one staged file, and no failure — before the write or during it — leaves a
5
+ * staged file behind.
6
+ *
7
+ * ★ WHY THIS IS THE STAGING TEST. Release.Binary stages nothing itself: the archive and the member
8
+ * live in memory (archive.ts). The only staged file in an install is the runner's — local-runner's
9
+ * same-directory temp file (local-runner.test.ts: "leaves no temp file behind") or, here,
10
+ * sudoRunner's 0600 file in a 0700 directory.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { fakeSudoHost } from '../launchd/fake-sudo.ts';
14
+ import { INSTALL } from '../launchd/sudo-allowlist.ts';
15
+ import type { ReleaseBinaryProps } from './binary-form.ts';
16
+ import { reconcileBinary } from './binary-lifecycle.ts';
17
+ import {
18
+ BINARY,
19
+ type TarEntry,
20
+ VMUTILS_ENTRIES,
21
+ VMUTILS_URL,
22
+ bytesOf,
23
+ fakeTransport,
24
+ gzip,
25
+ syntheticRelease,
26
+ tarOf,
27
+ vmalertProps,
28
+ } from './fake-release.ts';
29
+
30
+ const DIR = '/opt/example/app/vmutils-1.151.0';
31
+ const PATH = `${DIR}/vmalert`;
32
+
33
+ type Setup = {
34
+ /** What the transport serves at the vmutils URL; undefined serves nothing (a 404). */
35
+ readonly served?: (release: ReturnType<typeof syntheticRelease>) => Uint8Array | undefined;
36
+ readonly entries?: readonly TarEntry[];
37
+ readonly declare?: Partial<ReleaseBinaryProps>;
38
+ };
39
+
40
+ const setup = ({ declare = {}, entries, served = (r) => r.vmutils }: Setup = {}) => {
41
+ const release = syntheticRelease(entries);
42
+ const host = fakeSudoHost();
43
+ host.fake.dirs.set(DIR, 0);
44
+ const bytes = served(release);
45
+ const transport = fakeTransport(bytes === undefined ? {} : { [VMUTILS_URL]: bytes });
46
+ const props = vmalertProps(release.catalog, { directory: DIR, group: 0, owner: 0, ...declare });
47
+ const install = () => reconcileBinary(host.runner, transport.fetch, props);
48
+ return { host, install, transport };
49
+ };
50
+
51
+ describe('Release.Binary through sudoRunner', () => {
52
+ test('one staged file, one exact install, and the staged file removed', async () => {
53
+ const { host, install } = setup();
54
+ const attrs = await install();
55
+ expect(host.privileged()).toEqual([
56
+ [INSTALL, '-S', '-m', '0755', '-o', '0', '-g', '0', expect.any(String), PATH],
57
+ ]);
58
+ expect(host.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
59
+ expect(attrs).toMatchObject({ gid: 0, mode: 0o755, uid: 0 });
60
+ expect(host.state.stagedCount).toBe(1);
61
+ expect(host.staged.size).toBe(0);
62
+ });
63
+
64
+ test('a failed install throws and leaves no staged file and no binary', async () => {
65
+ const { host, install } = setup();
66
+ host.state.installFailure = { exitCode: 71, stderr: 'install: disk full', stdout: '' };
67
+ await expect(install()).rejects.toThrow('disk full');
68
+ expect(host.state.stagedCount).toBe(1);
69
+ expect(host.staged.size).toBe(0);
70
+ expect(host.fake.files.has(PATH)).toBe(false);
71
+ });
72
+
73
+ // ⛔ EVERY failure before the write: nothing is staged at all, sudo is never asked, no binary.
74
+ const tampered = () =>
75
+ gzip(tarOf(VMUTILS_ENTRIES.map((e) => ({ ...e, bytes: bytesOf('evil') }))));
76
+ test.each<[string, Setup]>([
77
+ ['a wrong archive hash', { served: tampered }],
78
+ ['a wrong binary hash', { declare: { sha256: 'f'.repeat(64) } }],
79
+ ['a tar-slip entry', { entries: [...VMUTILS_ENTRIES, { bytes: bytesOf('x'), name: '../x' }] }],
80
+ ['the member as a symlink', { entries: [{ name: 'vmalert-prod', type: '2' }] }],
81
+ ['the member missing', { entries: [{ bytes: BINARY.vmagent, name: 'vmagent-prod' }] }],
82
+ ['a failed download', { served: () => undefined }],
83
+ ['a malformed pin', { declare: { sha256: 'not-a-digest' } }],
84
+ ['an unwritable mode', { declare: { mode: 0o777 } }],
85
+ ])('%s stages nothing, asks sudo nothing, writes nothing', async (_, how) => {
86
+ const { host, install } = setup(how);
87
+ await expect(install()).rejects.toThrow();
88
+ expect(host.state.stagedCount).toBe(0);
89
+ expect(host.staged.size).toBe(0);
90
+ expect(host.sudoCalls).toEqual([]);
91
+ expect(host.fake.files.has(PATH)).toBe(false);
92
+ });
93
+ });