@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
@@ -33,10 +33,23 @@ export type FileStat = {
33
33
  readonly uid: number;
34
34
  readonly gid: number;
35
35
  readonly size: number;
36
+ /**
37
+ * The entry's identity, device and inode, when the runner can say. ★ Two spellings of ONE file
38
+ * (`/tmp/x` and `/private/tmp/x`; `Bin` and `bin` on case-insensitive APFS) differ as strings
39
+ * and match here, and a move must not delete its old path when that path IS the new file
40
+ * (file-identity.ts). Optional: a runner that leaves them out gets a check after the fact.
41
+ */
42
+ readonly dev?: number;
43
+ readonly ino?: number;
36
44
  };
37
45
 
38
46
  export type WriteOptions = {
39
- /** Permission bits for the final file, applied BEFORE it becomes visible at `path`. */
47
+ /**
48
+ * Permission bits for the final file. localRunner applies them BEFORE the file is visible at
49
+ * `path`. ⚠️ sudoRunner's `install -S` does NOT: MEASURED 2026-09-22 on macOS 27.2 (an lstat
50
+ * poller racing /usr/bin/install), the new inode appears at `path` as 0600 and is chmodded
51
+ * 0.2–0.4 ms later. Never torn, never wider than declared, but briefly not executable.
52
+ */
40
53
  readonly mode: number;
41
54
  /** Numeric owner; omitted means whoever the runner writes as. */
42
55
  readonly uid?: number;
@@ -69,7 +82,9 @@ export interface HostRunner {
69
82
  stat(path: string): Promise<FileStat | undefined>;
70
83
  /**
71
84
  * Write `bytes` so that `path` holds either the old file or the new one, never a torn mix:
72
- * a temporary file in the same directory, mode and owner set on it, then rename(2) over `path`.
85
+ * a temporary file in the same directory, mode and owner set on it, then rename(2) over `path`
86
+ * (sudoRunner: the mode just after the rename, measured — see WriteOptions.mode; the owner too,
87
+ * ⚠️ REASONED NOT MEASURED, since measuring it needs root).
73
88
  * ⚠️ The parent directory must exist; creating it would invent an owner and mode nobody declared.
74
89
  */
75
90
  writeFileAtomic(path: string, bytes: Uint8Array, options: WriteOptions): Promise<void>;
@@ -21,6 +21,17 @@
21
21
  * ★ `install -S`: install(1) on macOS 27.2 (read 2026-09-21) always writes a temp file in the
22
22
  * target directory and renames it, so the file is never torn, and -S adds the fsync that
23
23
  * localRunner's `handle.sync()` does.
24
+ * ⚠️ BUT IT CHMODS AFTER THE RENAME. MEASURED 2026-09-22 (an lstat poller racing
25
+ * `/usr/bin/install -S -m 0755`, 300 MB, six runs, target absent and present): the new inode
26
+ * is visible at the path as 0600, then 0755 0.2–0.4 ms later. Only ever narrower than declared.
27
+ * ⚠️ REASONED NOT MEASURED: a launchd start inside that window gets EACCES and retries after its
28
+ * throttle, and an install killed inside it leaves a root-owned 0600 file the operator cannot
29
+ * read back. Closing it needs a second privileged step (install to a temp name, then rename),
30
+ * which is a change to this allowlist and wants its own red team.
31
+ * ⚠️ AND IT COPIES XATTRS, com.apple.quarantine included (MEASURED 2026-09-22: a quarantined
32
+ * source installed as a quarantined target). The staged file is written by this process, and
33
+ * node:fs writes here carried only com.apple.provenance (measured the same day) — so nothing
34
+ * is quarantined today, but a quarantined staging file would be installed quarantined.
24
35
  * ⚠️ REASONED, NOT MEASURED: sudo's failure text (sudo-said.ts has the strings and their sources).
25
36
  * ⛔ EVERY CHECK BEFORE SUDO ALSO RUNS AT PLAN TIME (`checkWrite`, 2026-09-21): the mode, the
26
37
  * directories from the prefix down, the read-back. Reads only, as the operator — never sudo.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * ONE READ OF `ls -ldn --` FOR A WHOLE LIST OF PATHS, parsed into the same FileStat the seam uses
3
+ * plus the one thing `stat` cannot say on Linux: whether the path carries a POSIX ACL.
4
+ *
5
+ * ★ WHY `ls` AND NOT `stat`, AND WHY ONE CALL. The Mac guard lstats every directory from `/` down
6
+ * with a local syscall each; over ssh each of those is a TCP connection with no multiplexing
7
+ * (ssh-runner.ts), so a five-deep chain would be five handshakes per check and per plan. `ls -ldn`
8
+ * takes every path as an operand and answers in one connection, and its mode column carries the
9
+ * ACL flag the chain check needs anyway.
10
+ * ★ MEASURED 2026-09-22, read-only over ssh, on two host classes — an unprivileged LXC container
11
+ * and a Proxmox VE node, both Debian 13 / coreutils 9.7, LANG=C, ssh user uid 1001 and 1000:
12
+ * $ ls -ldn / /etc /etc/systemd /etc/systemd/system /usr /usr/lib /usr/lib/systemd …
13
+ * drwxr-xr-x 18 0 0 4096 Sep 22 10:12 /etc (all ten root:root 0755, no flag)
14
+ * $ ls -ld /var/log/journal
15
+ * drwxr-sr-x+ 3 root systemd-journal … (the `+` IS the POSIX ACL flag here)
16
+ * $ ls -lden … → ls: invalid option -- 'e' (the Mac's spelling does not exist)
17
+ * $ command -v getfacl → absent on both Debian classes (present on the NixOS class)
18
+ * ⛔ SO THE ACL SIGNAL IS THE `+` FLAG, NOT `getfacl`: the tool that would enumerate the entries
19
+ * is not installed on the hosts this runner is for, and a guard that shells out to a missing
20
+ * program and fails closed would refuse every write on them. `ls` is coreutils, always there.
21
+ * ⚠️ THE PRICE, STATED: we learn THAT a directory has an ACL, never WHAT it grants, so any ACL on
22
+ * the chain is a refusal — including a read-only one the Mac guard would allow. Fail closed and
23
+ * say so; the remedy is a prefix without one.
24
+ * ⚠️ `.` in that column is an SELinux context, not an ACL, and is not a grant. `@` is macOS's.
25
+ * ⛔ FAIL CLOSED ON ANYTHING WE DO NOT UNDERSTAND. A line that does not parse, a path that no line
26
+ * named, two lines for one path, or a non-zero exit is an error — never "nothing is there".
27
+ */
28
+ import type { FileStat, HostRunner } from '../launchd/runner.ts';
29
+ import { SudoRefusedError } from '../launchd/sudo-allowlist.ts';
30
+
31
+ /** What one `ls -ldn` line says: the lstat fields, plus whether the path carries an ACL. */
32
+ export type Listed = FileStat & { readonly acl: boolean };
33
+
34
+ const KINDS = new Map<string, FileStat['kind']>([
35
+ ['-', 'file'],
36
+ ['d', 'directory'],
37
+ ['l', 'symlink'],
38
+ ]);
39
+
40
+ /**
41
+ * `rwxr-x---` → 0o750, with the three special bits `ls` hides in the execute columns:
42
+ * `s`/`S` = setuid (user) or setgid (group), `t`/`T` = sticky; the capital is the bit without
43
+ * execute. ⚠️ Anything else in a column is a shape we do not know, and returns undefined.
44
+ */
45
+ export const permBits = (perms: string): number | undefined => {
46
+ if (perms.length !== 9) return undefined;
47
+ let mode = 0;
48
+ for (const index of [0, 1, 2]) {
49
+ const at = index * 3;
50
+ const shift = 6 - at;
51
+ const [read, write, exec] = [perms[at], perms[at + 1], perms[at + 2]];
52
+ if ((read !== 'r' && read !== '-') || (write !== 'w' && write !== '-')) return undefined;
53
+ if (read === 'r') mode |= 0o4 << shift;
54
+ if (write === 'w') mode |= 0o2 << shift;
55
+ const [set, unset] = index === 2 ? ['t', 'T'] : ['s', 'S'];
56
+ if (exec === 'x' || exec === set) mode |= 0o1 << shift;
57
+ else if (exec !== '-' && exec !== unset) return undefined;
58
+ if (exec === set || exec === unset) mode |= 0o4000 >> index;
59
+ }
60
+ return mode;
61
+ };
62
+
63
+ /** `drwxr-xr-x+ 4 0 0 4096 Sep 22 10:12 /etc/systemd` — the tail holds the date and the name. */
64
+ const LINE = /^([-dlbcps])(\S{9})([+.@]?)\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/;
65
+
66
+ /**
67
+ * Match each line to the path that asked for it, by NAME rather than by position.
68
+ * ⚠️ `ls` SORTS ITS OPERANDS, so the answers do not come back in the order they were asked; and a
69
+ * symlink's line ends `<path> -> <target>`, so it is not a suffix match either. The longest
70
+ * candidate wins, so a path that is a tail of another cannot steal its line.
71
+ */
72
+ const nameOf = (tail: string, paths: readonly string[]): string | undefined =>
73
+ [...paths]
74
+ .sort((left, right) => right.length - left.length)
75
+ .find((path) => tail.endsWith(` ${path}`) || tail.includes(` ${path} -> `));
76
+
77
+ /** Every requested path's entry, or `undefined` when the listing is not exactly that. */
78
+ export const parseListing = (
79
+ stdout: string,
80
+ paths: readonly string[],
81
+ ): Map<string, Listed> | undefined => {
82
+ const entries = new Map<string, Listed>();
83
+ for (const line of stdout.split('\n').filter((text) => text.trim() !== '')) {
84
+ const match = LINE.exec(line);
85
+ if (match === null) return undefined;
86
+ const [, type, perms, flag, uid, gid, size, tail] = match;
87
+ const mode = permBits(perms ?? '');
88
+ const path = nameOf(tail ?? '', paths);
89
+ if (mode === undefined || path === undefined || entries.has(path)) return undefined;
90
+ entries.set(path, {
91
+ acl: flag === '+',
92
+ gid: Number(gid),
93
+ kind: KINDS.get(type ?? '') ?? 'other',
94
+ mode,
95
+ size: Number(size),
96
+ uid: Number(uid),
97
+ });
98
+ }
99
+ return entries.size === paths.length ? entries : undefined;
100
+ };
101
+
102
+ /**
103
+ * Read every path in one call, as the deploying user — no sudo, the same read at plan time and
104
+ * before the write. ⛔ Anything but a complete, understood listing is a SudoRefusedError: a chain
105
+ * we could not read is a chain we cannot say is root's alone.
106
+ */
107
+ export const readListing = async (
108
+ base: HostRunner,
109
+ ls: string,
110
+ what: string,
111
+ paths: readonly string[],
112
+ ): Promise<Map<string, Listed>> => {
113
+ const result = await base.exec([ls, '-ldn', '--', ...paths]);
114
+ const entries = result.exitCode === 0 ? parseListing(result.stdout, paths) : undefined;
115
+ if (entries === undefined) {
116
+ throw new SudoRefusedError(
117
+ `sudoRunner ${what}: could not read ${paths.join(', ')} (${ls} -ldn exit ` +
118
+ `${String(result.exitCode)}: ${result.stderr.trim().slice(0, 200)}). Nothing ran as root.`,
119
+ );
120
+ }
121
+ return entries;
122
+ };
@@ -23,7 +23,7 @@
23
23
  * it from the template. docs/pbs-notifications.md says the same to the reader.
24
24
  * ⛔ A REFUSAL NAMES THE FIELD, NEVER THE VALUE — the message lands in a terminal and a CI log.
25
25
  */
26
- import type { FromEnv } from './write-only.ts';
26
+ import type { FromEnv } from '../secrets/write-only.ts';
27
27
 
28
28
  /** `{{ secrets.x }}`, `{{secrets.x}}`, `{{ url-encode secrets.x }}` — a reference, not a value. */
29
29
  const READS_SECRET = /\{\{[^}]*\bsecrets\./;
@@ -241,4 +241,4 @@ export type NodesNodeDisksZfsPostParams = {
241
241
  export type NodesNodeDisksZfsPostReturn = string;
242
242
 
243
243
  /** GET /nodes/{node}/disks/zfs/{name} — `data` payload after client unwrap. */
244
- export type NodesNodeDisksZfsNameGetReturn = {};
244
+ export type NodesNodeDisksZfsNameGetReturn = Record<string, unknown>;
@@ -13,6 +13,13 @@
13
13
  * ⚠️ 151 request parameters are typed `\`${number}\`` because the vendor calls them
14
14
  * integer or number. They were `string` before, which accepted 'banana'. A caller holding a
15
15
  * number writes \`${n}\` — `String(n)` is a plain `string` and will not typecheck, deliberately.
16
+ *
17
+ * ⛔ 135 OF THESE NAMES ARE ALSO EXPORTED BY `pve.ts`, MEANING SOMETHING ELSE. Both
18
+ * products document a `/nodes/{node}` subtree, so a name like `NodesNodeCertificatesGetReturn`
19
+ * exists on each side — here an array of objects, there `null`. Nothing stops a pve call
20
+ * importing the pbs spelling: it compiles, and the type is simply wrong about the payload.
21
+ * Import from the barrel that names your product. tests/schema-types.test.ts pins this count, so
22
+ * a vendor upgrade that adds a collision fails there rather than at runtime.
16
23
  */
17
24
  export * from './pbs/access.ts';
18
25
  export * from './pbs/access-users.ts';
@@ -13,6 +13,13 @@
13
13
  * ⚠️ 480 request parameters are typed `\`${number}\`` because the vendor calls them
14
14
  * integer or number. They were `string` before, which accepted 'banana'. A caller holding a
15
15
  * number writes \`${n}\` — `String(n)` is a plain `string` and will not typecheck, deliberately.
16
+ *
17
+ * ⛔ 135 OF THESE NAMES ARE ALSO EXPORTED BY `pbs.ts`, MEANING SOMETHING ELSE. Both
18
+ * products document a `/nodes/{node}` subtree, so a name like `NodesNodeCertificatesGetReturn`
19
+ * exists on each side — here an array of objects, there `null`. Nothing stops a pbs call
20
+ * importing the pve spelling: it compiles, and the type is simply wrong about the payload.
21
+ * Import from the barrel that names your product. tests/schema-types.test.ts pins this count, so
22
+ * a vendor upgrade that adds a collision fails there rather than at runtime.
16
23
  */
17
24
  export * from './pve/access.ts';
18
25
  export * from './pve/access-roles.ts';
@@ -104,5 +104,5 @@ export { ProxmoxSdnVnet, ProxmoxSdnVnetProvider } from './sdn-vnet.ts';
104
104
  export { ProxmoxSdnZone, ProxmoxSdnZoneProvider } from './sdn-zone.ts';
105
105
  export { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
106
106
  export { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
107
- export { type FromEnv } from './write-only.ts';
107
+ export { type FromEnv } from '../secrets/write-only.ts';
108
108
  export { ProxmoxZfsPoolProvider } from './zfs-pool.ts';
@@ -25,7 +25,7 @@ import type {
25
25
  PbsNotificationTargetProps,
26
26
  } from './pbs-notification-target.ts';
27
27
  import { base64, headerDigest, names, wireItem } from './pbs-notification-target-wire.ts';
28
- import { type Environment, type FromEnv, resolveAll, sealMatches } from './write-only.ts';
28
+ import { type Environment, type FromEnv, resolveAll, sealMatches } from '../secrets/write-only.ts';
29
29
 
30
30
  type Props = PbsNotificationTargetProps;
31
31
  type Field = keyof Props;
@@ -32,7 +32,7 @@ import {
32
32
  targetForm,
33
33
  } from './pbs-notification-target-form.ts';
34
34
  import { targetAttributes } from './pbs-notification-target-wire.ts';
35
- import { seal } from './write-only.ts';
35
+ import { seal } from '../secrets/write-only.ts';
36
36
 
37
37
  type Props = PbsNotificationTargetProps;
38
38
  type Attributes = PbsNotificationTargetAttributes;
@@ -23,7 +23,7 @@ import type {
23
23
  PbsNotificationTargetProps,
24
24
  } from './pbs-notification-target.ts';
25
25
  import { bool, text } from './values.ts';
26
- import { seal } from './write-only.ts';
26
+ import { seal } from '../secrets/write-only.ts';
27
27
 
28
28
  export const base64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64');
29
29
 
@@ -31,7 +31,7 @@ import * as Provider from 'alchemy/Provider';
31
31
  import * as Effect from 'effect/Effect';
32
32
  import { handlers } from './pbs-notification-target-lifecycle.ts';
33
33
  import type { PveRequirements, WithPbsTarget } from './resource.ts';
34
- import type { FromEnv } from './write-only.ts';
34
+ import type { FromEnv } from '../secrets/write-only.ts';
35
35
 
36
36
  export type PbsNotificationTargetType = 'sendmail' | 'smtp' | 'webhook';
37
37
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Adoption through Alchemy's OWN Plan and Apply (openbao/fake-stack.ts): a file already at the path,
3
+ * and a stack holding no state for it. What binary-claim.ts decides, measured end to end — the plan
4
+ * and the apply must give the same answer, and `adopted` must never stand for other bytes.
5
+ *
6
+ * 🔴 EVERY REFUSAL BELOW WAS A SILENT WRITE OR LOSS BEFORE 2026-09-22 (adversarial review of PR 138,
7
+ * lens: adoption and plan parity), each measured with this harness against the code before the
8
+ * fix: other bytes under `--adopt` planned `adopted` (resolved) or `create` (directory an Output)
9
+ * and were downloaded over in place; a symlink planned `adopted` and was refused only at apply;
10
+ * a renamed declaration claimed the identical file at apply and its old name's delete removed it.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { BINARY } from './fake-release.ts';
14
+ import { DIR, PATH, THEIRS, deployHarness as harness } from './fake-deploy.ts';
15
+
16
+ describe('a file already at the path, with no state', () => {
17
+ test('OTHER bytes under --adopt, probed: the plan refuses; nothing fetched, their file kept', async () => {
18
+ const h = harness(true);
19
+ h.place(THEIRS);
20
+ await expect(h.stack.deploy(h.literal, { adopt: true })).rejects.toThrow(
21
+ '--adopt takes over only the pinned binary',
22
+ );
23
+ expect(h.events).toEqual([]);
24
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(THEIRS);
25
+ });
26
+
27
+ test('OTHER bytes under --adopt, the directory an Output: refused at apply, before the download', async () => {
28
+ const h = harness(true);
29
+ h.place(THEIRS);
30
+ await expect(h.stack.deploy(h.wired(), { adopt: true })).rejects.toThrow(
31
+ '--adopt takes over only the pinned binary',
32
+ );
33
+ expect(h.events.filter((e) => e.startsWith('GET ') || e.startsWith('write '))).toEqual([]);
34
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(THEIRS);
35
+ });
36
+
37
+ test('a symlink under --adopt: refused by the PLAN, not first by the apply', async () => {
38
+ const h = harness(true);
39
+ h.place(new Uint8Array(), 0o755, 'symlink');
40
+ // ★ The plan-side wording; the apply's own would be "is a symlink".
41
+ await expect(h.stack.deploy(h.literal, { adopt: true })).rejects.toThrow(
42
+ 'something that is not a regular file',
43
+ );
44
+ expect(h.events).toEqual([]);
45
+ });
46
+
47
+ test('the pinned bytes with a wrong mode, under --adopt: adopted, re-written from disk, never fetched', async () => {
48
+ const h = harness(true);
49
+ h.place(BINARY.vmalert, 0o775);
50
+ expect(await h.stack.deploy(h.literal, { adopt: true })).toEqual({ vmalert: 'adopted' });
51
+ expect(h.events).toEqual([`write ${PATH}`]);
52
+ expect(h.fake.files.get(PATH)?.mode).toBe(0o755);
53
+ });
54
+
55
+ test('the pinned bytes, the directory an Output, under --adopt: kept, never fetched', async () => {
56
+ const h = harness(true);
57
+ h.place(BINARY.vmalert);
58
+ const planned = await h.stack.deploy(h.wired(), { adopt: true });
59
+ expect(planned).toEqual({ dir: 'adopted', vmalert: 'create' });
60
+ expect(h.events).toEqual([]);
61
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
62
+ });
63
+
64
+ test('a renamed declaration while its directory changes: refused at apply, the binary kept', async () => {
65
+ const h = harness(false);
66
+ await h.stack.deploy(h.wired('vmalert'));
67
+ h.events.length = 0;
68
+ // The directory's mode change leaves `dir.path` an Output at plan, so nothing probes the path.
69
+ await expect(h.stack.deploy(h.wired('vmalert-rules', 0o750))).rejects.toThrow(
70
+ 'it holds the pinned binary, but this stack holds no state for it',
71
+ );
72
+ // 🔴 Before: planned {dir: update, vmalert-rules: create, vmalert: delete}; the new name took
73
+ // the identical file as its own, the old name's delete removed it, and the deploy succeeded.
74
+ expect(h.events).not.toContain(`remove ${PATH}`);
75
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
76
+ });
77
+
78
+ test('the same rename declared with renamedFrom (the remedy the refusal names): the binary kept', async () => {
79
+ const h = harness(false);
80
+ await h.stack.deploy(h.wired('vmalert'));
81
+ h.events.length = 0;
82
+ const planned = await h.stack.deploy(h.wired('vmalert-rules', 0o750, 'vmalert'));
83
+ expect(planned).toEqual({ dir: 'update', 'vmalert-rules': 'update' });
84
+ expect(h.events).toEqual([`chmod 750 ${DIR}`]);
85
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
86
+ });
87
+
88
+ test('the same rename under --adopt: the new name takes the file, the old name leaves it', async () => {
89
+ const h = harness(false);
90
+ await h.stack.deploy(h.wired('vmalert'));
91
+ h.events.length = 0;
92
+ const planned = await h.stack.deploy(h.wired('vmalert-rules', 0o750), { adopt: true });
93
+ expect(planned).toEqual({ dir: 'update', vmalert: 'delete', 'vmalert-rules': 'create' });
94
+ // 🔴 Before 2026-09-22 (release gate): the old name's orphan delete ran `remove ${PATH}` after
95
+ // the new name had claimed it, and the deploy succeeded with the binary gone.
96
+ expect(h.events).toEqual([`chmod 750 ${DIR}`]);
97
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
98
+ // The new name owns it now: the next deploy of the same declaration changes nothing.
99
+ h.events.length = 0;
100
+ expect(await h.stack.deploy(h.wired('vmalert-rules', 0o750))).toEqual({
101
+ dir: 'noop',
102
+ 'vmalert-rules': 'noop',
103
+ });
104
+ expect(h.events).toEqual([]);
105
+ });
106
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * ⛔ A PIN IS NEVER COMPUTED DURING THE DEPLOY — on a FIRST deploy too. Release.Binary through
3
+ * Alchemy's own Plan and Apply (openbao/fake-stack.ts), with a pin wired from another resource's
4
+ * Output: the shape of "fetch the checksum file at apply and trust what it says".
5
+ *
6
+ * 🔴 MEASURED 2026-09-22 (supply-chain review of PR 138): before the apply-time check, the engine
7
+ * never diffed this create and skipped its probe (an Output in `news`), so reconcile saw only the
8
+ * RESOLVED digests. The release asset and its checksum file were both swapped (the releases are
9
+ * mutable, victoria.ts), the Output carried the swapped digests, and the deploy installed the
10
+ * swapped bytes and reported them verified. binary-diff.ts refused the same declaration only
11
+ * from the SECOND plan on — after the wrong binary was already on the host.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import { Resource } from 'alchemy';
15
+ import * as Provider from 'alchemy/Provider';
16
+ import * as Effect from 'effect/Effect';
17
+ import * as Layer from 'effect/Layer';
18
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
19
+ import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
20
+ import { sha256Hex } from '../launchd/job-form.ts';
21
+ import { type HostRunner, hostRunnerLayer } from '../launchd/runner.ts';
22
+ import { HostDirectory, HostDirectoryProvider } from '../linux/directory.ts';
23
+ import { fakeLinuxHost } from '../linux/fake-linux-host.ts';
24
+ import { fakeStack } from '../openbao/fake-stack.ts';
25
+ import { ReleaseBinary, makeReleaseBinaryProvider } from './binary.ts';
26
+ import { catalogBinary } from './catalog.ts';
27
+ import {
28
+ VMALERT_REQUEST,
29
+ VMUTILS_URL,
30
+ bytesOf,
31
+ gzip,
32
+ syntheticRelease,
33
+ tarOf,
34
+ } from './fake-release.ts';
35
+
36
+ const ROOT = '/opt/example/bin';
37
+ const DIR = `${ROOT}/vmutils-1.151.0`;
38
+ const EVIL = bytesOf('#!swapped vmalert\n');
39
+ const SWAPPED = gzip(tarOf([{ bytes: EVIL, name: 'vmalert-prod' }]));
40
+
41
+ /** What a stack might write to "stay current": digests read from the release at apply. */
42
+ type Sums = { readonly archive: string; readonly member: string; readonly size: number };
43
+ interface FetchedSums extends Resource<'Test.FetchedSums', { url: string }, Sums> {}
44
+ const FetchedSums = Resource<FetchedSums>('Test.FetchedSums');
45
+ /** ★ It answers with the SWAPPED asset's digests — what the swapped checksum file would say. */
46
+ const FetchedSumsProvider = () =>
47
+ Provider.effect(
48
+ FetchedSums,
49
+ Effect.succeed(
50
+ FetchedSums.Provider.of({
51
+ delete: () => Effect.void,
52
+ list: () => Effect.succeed([]),
53
+ reconcile: () =>
54
+ Effect.succeed({
55
+ archive: sha256Hex(SWAPPED),
56
+ member: sha256Hex(EVIL),
57
+ size: SWAPPED.length,
58
+ }),
59
+ }),
60
+ ),
61
+ );
62
+
63
+ const harness = () => {
64
+ const pins = catalogBinary(syntheticRelease().catalog, VMALERT_REQUEST);
65
+ const fake = fakeLinuxHost({ dirs: { [ROOT]: 0 }, euid: 0 });
66
+ const events: string[] = [];
67
+ const runner: HostRunner = {
68
+ ...fake.runner,
69
+ writeFileAtomic: (path, bytes, options) => {
70
+ events.push(`write ${path}`);
71
+ return fake.runner.writeFileAtomic(path, bytes, options);
72
+ },
73
+ };
74
+ // ★ The attacker's release: the swapped archive at the REAL asset URL.
75
+ const client = HttpClient.make((request, url) => {
76
+ events.push(`GET ${url.toString()}`);
77
+ const body = url.toString() === VMUTILS_URL ? new Blob([SWAPPED.slice()]) : null;
78
+ return Effect.succeed(
79
+ HttpClientResponse.fromWeb(request, new Response(body, { status: body ? 200 : 404 })),
80
+ );
81
+ });
82
+ const providers = Layer.mergeAll(
83
+ makeReleaseBinaryProvider({ download: { retries: 0 } }),
84
+ HostDirectoryProvider(),
85
+ FetchedSumsProvider(),
86
+ ).pipe(
87
+ Layer.provide(
88
+ Layer.mergeAll(hostRunnerLayer(runner), Layer.succeed(HttpClient.HttpClient, client)),
89
+ ),
90
+ );
91
+ return { events, fake, pins, stack: fakeStack(providers, {}, 'ApplyPinStack') };
92
+ };
93
+
94
+ type Wiring = 'every digest and the size' | 'the member digest' | 'the archive digest and size';
95
+
96
+ describe('a pin wired from an Output, on a first deploy', () => {
97
+ test.each<[Wiring, string]>([
98
+ ['every digest and the size', 'archive.sha256 is required, as a plain string'],
99
+ ['the member digest', 'sha256 is required, as a plain string'],
100
+ ['the archive digest and size', 'archive.size must be'],
101
+ ])('%s: refused at apply, nothing fetched, nothing written', async (wiring, refusal) => {
102
+ const h = harness();
103
+ const body = Effect.gen(function* () {
104
+ const dir = yield* HostDirectory('vmutils-1.151.0', { mode: 0o755, path: DIR });
105
+ const sums = yield* FetchedSums('sums', { url: `${VMUTILS_URL}_checksums.txt` });
106
+ const archive =
107
+ wiring === 'the member digest'
108
+ ? h.pins.archive
109
+ : { ...h.pins.archive, sha256: sums.archive, size: sums.size };
110
+ const sha256 = wiring === 'the archive digest and size' ? h.pins.sha256 : sums.member;
111
+ yield* ReleaseBinary('vmalert', { ...h.pins, archive, directory: dir.path, sha256 });
112
+ });
113
+ await expect(h.stack.deploy(body)).rejects.toThrow(refusal);
114
+ expect(h.events).toEqual([]);
115
+ expect(h.fake.files.has(`${DIR}/vmalert`)).toBe(false);
116
+ });
117
+
118
+ test('control: the same swap against plain pins is refused by the download it makes', async () => {
119
+ const h = harness();
120
+ const body = Effect.gen(function* () {
121
+ const dir = yield* HostDirectory('vmutils-1.151.0', { mode: 0o755, path: DIR });
122
+ yield* ReleaseBinary('vmalert', { ...h.pins, directory: dir.path });
123
+ });
124
+ await expect(h.stack.deploy(body)).rejects.toThrow('the pin says');
125
+ expect(h.events).toEqual([`GET ${VMUTILS_URL}`]);
126
+ expect(h.fake.files.has(`${DIR}/vmalert`)).toBe(false);
127
+ });
128
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A pinned archive, verified, to the one member a declaration names, verified — the order the
3
+ * checks run in is the point of the file.
4
+ *
5
+ * 1. the archive's SHA-256 against its pin — BEFORE a single byte is unpacked;
6
+ * 2. gunzip and tar-read as one stream, keeping only the declared member (tar.ts refuses anything
7
+ * unsafe in the archive, asked-for or not);
8
+ * 3. that member's SHA-256 against its own pin.
9
+ *
10
+ * ★ BOTH LAYERS, BECAUSE THE VENDOR HASHES BOTH. Each checksum file lists the archive AND every
11
+ * binary inside it (measured 2026-09-22, all four). The archive check says the download is the
12
+ * published file; the member check says the bytes about to be written are the published binary —
13
+ * and it is the only check that still holds if the tar reader were ever wrong about a boundary.
14
+ * ★ DecompressionStream, THE WEB STANDARD: node and bun both ship it, so dist behaves the same
15
+ * under either runtime — no zlib binding, no dependency.
16
+ * ⛔ IN MEMORY ONLY. Nothing here touches a disk, so no failure below can leave a staged download
17
+ * behind; the bytes reach a host only through HostRunner.writeFileAtomic (file-converge.ts).
18
+ */
19
+ import { sha256Hex } from '../launchd/job-form.ts';
20
+ import type { PinnedDownload } from './binary-form.ts';
21
+ import { ArchiveRefused, ChecksumMismatch } from './refused.ts';
22
+ import { type TarContents, tarReader } from './tar.ts';
23
+
24
+ /** How much gzip is handed to the inflater at a time. */
25
+ const SLICE = 1 << 20;
26
+
27
+ /** Gunzip and read a tar, keeping only `wanted`. Throws ArchiveRefused. */
28
+ export const extractMembers = async (
29
+ gzipped: Uint8Array,
30
+ wanted: ReadonlySet<string>,
31
+ ): Promise<TarContents> => {
32
+ const tar = tarReader(wanted);
33
+ // ⚠️ FED IN SLICES, ON DEMAND. Enqueued whole, the 123.6 MB vmutils archive is inflated in one
34
+ // transform step and all 264 MB of output queue up before the first read: MEASURED 2026-09-22,
35
+ // a peak RSS of 1116 MB for vmalert alone under bun 1.4.0. `pull` hands over the next slice only
36
+ // when the stream asks, so backpressure bounds what is inflated ahead of the tar reader.
37
+ let offset = 0;
38
+ const source = new ReadableStream<Uint8Array>({
39
+ pull(controller) {
40
+ if (offset >= gzipped.length) {
41
+ controller.close();
42
+ return;
43
+ }
44
+ controller.enqueue(gzipped.slice(offset, offset + SLICE));
45
+ offset += SLICE;
46
+ },
47
+ });
48
+ const unzipped = source.pipeThrough(new DecompressionStream('gzip')).getReader();
49
+ try {
50
+ for (;;) {
51
+ const { done, value } = await unzipped.read();
52
+ if (done) break;
53
+ tar.push(value);
54
+ }
55
+ } catch (cause) {
56
+ await unzipped.cancel().catch(() => undefined);
57
+ if (cause instanceof ArchiveRefused) throw cause;
58
+ throw new ArchiveRefused({ message: `not a gzip stream (${String(cause)})` });
59
+ }
60
+ return tar.finish();
61
+ };
62
+
63
+ const mismatch = (subject: string, expected: string, actual: string): ChecksumMismatch =>
64
+ new ChecksumMismatch({
65
+ actual,
66
+ expected,
67
+ message: `${subject} hashes to ${actual}, not the pinned ${expected}`,
68
+ subject,
69
+ });
70
+
71
+ /**
72
+ * The declared member's bytes out of a downloaded archive, both layers checked.
73
+ * ⚠️ `archive` may be shared with another resource's extraction (download.ts sharingInFlight):
74
+ * this only reads it.
75
+ */
76
+ export const verifiedMember = async (
77
+ archive: Uint8Array,
78
+ release: PinnedDownload,
79
+ ): Promise<Uint8Array> => {
80
+ const archiveSha = sha256Hex(archive);
81
+ if (archiveSha !== release.sha256) throw mismatch(release.url, release.sha256, archiveSha);
82
+ const { members, names } = await extractMembers(archive, new Set([release.member]));
83
+ const bytes = members.get(release.member);
84
+ if (bytes === undefined) {
85
+ throw new ArchiveRefused({
86
+ message: `${release.url} has no member named exactly "${release.member}" (it has ${names.map((n) => `"${n}"`).join(', ') || 'none'})`,
87
+ });
88
+ }
89
+ const memberSha = sha256Hex(bytes);
90
+ if (memberSha !== release.memberSha256) {
91
+ throw mismatch(`member "${release.member}"`, release.memberSha256, memberSha);
92
+ }
93
+ return bytes;
94
+ };