@homeflare/alchemy 0.23.0 → 0.24.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 (95) hide show
  1. package/README.md +15 -1
  2. package/dist/litellm/client.d.ts +76 -0
  3. package/dist/litellm/client.d.ts.map +1 -0
  4. package/dist/litellm/credentials.d.ts +48 -0
  5. package/dist/litellm/credentials.d.ts.map +1 -0
  6. package/dist/litellm/fake-litellm.d.ts +36 -0
  7. package/dist/litellm/fake-litellm.d.ts.map +1 -0
  8. package/dist/litellm/fake-stack.d.ts +30 -0
  9. package/dist/litellm/fake-stack.d.ts.map +1 -0
  10. package/dist/litellm/generated/pass-through.d.ts +71 -0
  11. package/dist/litellm/generated/pass-through.d.ts.map +1 -0
  12. package/dist/litellm/index.d.ts +13 -0
  13. package/dist/litellm/index.d.ts.map +1 -0
  14. package/dist/litellm/index.js +270 -0
  15. package/dist/litellm/index.js.map +14 -0
  16. package/dist/litellm/pass-through-endpoint.d.ts +94 -0
  17. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -0
  18. package/dist/litellm/pass-through-form.d.ts +40 -0
  19. package/dist/litellm/pass-through-form.d.ts.map +1 -0
  20. package/dist/litellm/providers.d.ts +16 -0
  21. package/dist/litellm/providers.d.ts.map +1 -0
  22. package/dist/postgres/connection.d.ts +50 -0
  23. package/dist/postgres/connection.d.ts.map +1 -0
  24. package/dist/postgres/database-attrs.d.ts +79 -0
  25. package/dist/postgres/database-attrs.d.ts.map +1 -0
  26. package/dist/postgres/database-sql.d.ts +73 -0
  27. package/dist/postgres/database-sql.d.ts.map +1 -0
  28. package/dist/postgres/database.d.ts +63 -0
  29. package/dist/postgres/database.d.ts.map +1 -0
  30. package/dist/postgres/errors.d.ts +86 -0
  31. package/dist/postgres/errors.d.ts.map +1 -0
  32. package/dist/postgres/fake-sql.d.ts +22 -0
  33. package/dist/postgres/fake-sql.d.ts.map +1 -0
  34. package/dist/postgres/index.d.ts +16 -0
  35. package/dist/postgres/index.d.ts.map +1 -0
  36. package/dist/postgres/index.js +238 -0
  37. package/dist/postgres/index.js.map +15 -0
  38. package/dist/postgres/providers.d.ts +14 -0
  39. package/dist/postgres/providers.d.ts.map +1 -0
  40. package/dist/proxmox/index.js.map +2 -2
  41. package/dist/proxmox/zfs-pool.d.ts +10 -2
  42. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  43. package/dist/release/archive.d.ts +6 -2
  44. package/dist/release/archive.d.ts.map +1 -1
  45. package/dist/release/binary-form.d.ts +14 -9
  46. package/dist/release/binary-form.d.ts.map +1 -1
  47. package/dist/release/binary-pins.d.ts +17 -0
  48. package/dist/release/binary-pins.d.ts.map +1 -0
  49. package/dist/release/catalog.d.ts.map +1 -1
  50. package/dist/release/index.js +90 -40
  51. package/dist/release/index.js.map +8 -7
  52. package/dist/release/tar.d.ts +9 -2
  53. package/dist/release/tar.d.ts.map +1 -1
  54. package/docs/litellm.md +110 -0
  55. package/docs/peers.md +8 -1
  56. package/docs/postgres.md +125 -0
  57. package/docs/release-binary-catalogs.md +40 -4
  58. package/docs/release-binary-refusals.md +22 -8
  59. package/docs/release-binary-upstream.md +33 -15
  60. package/package.json +10 -1
  61. package/src/litellm/client.ts +195 -0
  62. package/src/litellm/credentials.ts +73 -0
  63. package/src/litellm/fake-litellm.ts +114 -0
  64. package/src/litellm/fake-stack.ts +100 -0
  65. package/src/litellm/generated/pass-through.ts +73 -0
  66. package/src/litellm/index.ts +42 -0
  67. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
  68. package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
  69. package/src/litellm/pass-through-endpoint.test.ts +208 -0
  70. package/src/litellm/pass-through-endpoint.ts +258 -0
  71. package/src/litellm/pass-through-form.test.ts +128 -0
  72. package/src/litellm/pass-through-form.ts +136 -0
  73. package/src/litellm/providers.ts +19 -0
  74. package/src/postgres/connection.ts +62 -0
  75. package/src/postgres/database-attrs.ts +87 -0
  76. package/src/postgres/database-bigint-serialization.test.ts +140 -0
  77. package/src/postgres/database-lifecycle.test.ts +113 -0
  78. package/src/postgres/database-provenance.test.ts +118 -0
  79. package/src/postgres/database-refusals.test.ts +135 -0
  80. package/src/postgres/database-sql.ts +174 -0
  81. package/src/postgres/database.ts +193 -0
  82. package/src/postgres/errors.ts +131 -0
  83. package/src/postgres/fake-sql.ts +123 -0
  84. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
  85. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
  86. package/src/postgres/index.ts +30 -0
  87. package/src/postgres/providers.ts +16 -0
  88. package/src/proxmox/zfs-pool.ts +10 -2
  89. package/src/release/archive.ts +10 -4
  90. package/src/release/binary-form.ts +24 -76
  91. package/src/release/binary-pins.ts +95 -0
  92. package/src/release/binary-root.test.ts +122 -0
  93. package/src/release/catalog.ts +10 -2
  94. package/src/release/tar-root.test.ts +114 -0
  95. package/src/release/tar.ts +78 -12
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `pinProblems` — everything wrong with a declaration's pins alone, as plain strings, checked
3
+ * before a plan resolves a directory or a host is asked anything. Split out of binary-form.ts
4
+ * 2026-09-23 to keep that file under the house's 250-line cap (it was 229, heading for ~250 once
5
+ * `archive.root` needed its own validation and JSDoc). binary-form.ts re-exports `pinProblems` and
6
+ * `MAX_ARCHIVE`, so nothing that imported them moves.
7
+ */
8
+ import type { ReleaseArchive } from './binary-form.ts';
9
+
10
+ const HEX64 = /^[0-9a-f]{64}$/;
11
+ export const MAX_ARCHIVE = 1 << 30;
12
+ // ⛔ Strict on purpose: each is written into a URL or a path. GitHub's own owner and name alphabet.
13
+ const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
14
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
15
+ // ★ ONLY WHAT THE READER HANDLES: a gzipped tar. A `.zip` or a bare binary is a new capability
16
+ // with its own walk-down, not a name this should accept and then fail to unpack.
17
+ const TARBALL = /\.(tar\.gz|tgz)$/;
18
+
19
+ /** A single path segment that cannot climb, hide or name the directory itself. */
20
+ const segmentProblem = (what: string, value: string): string | undefined =>
21
+ !SEGMENT.test(value) || value === '.' || value === '..'
22
+ ? `${what} must be one path segment of letters, digits, '.', '_' or '-': ${JSON.stringify(value)}`
23
+ : undefined;
24
+
25
+ /** Everything wrong with the pins alone — plain strings in any declaration, resolved or not. */
26
+ export const pinProblems = (props: {
27
+ readonly archive?: Partial<ReleaseArchive>;
28
+ readonly member?: unknown;
29
+ readonly sha256?: unknown;
30
+ readonly name?: unknown;
31
+ }): string[] => {
32
+ const archive = props.archive ?? {};
33
+ const found: string[] = [];
34
+ const text = (what: string, value: unknown): string | undefined => {
35
+ if (typeof value === 'string') return value;
36
+ found.push(`${what} is required, as a plain string`);
37
+ return undefined;
38
+ };
39
+ const repo = text('archive.repo', archive.repo);
40
+ // ⚠️ `owner/..` passes the alphabet, and a URL parser normalises it away: the request would go
41
+ // to `github.com/releases/…`, somewhere nobody pinned. Refused by name.
42
+ const repoName = repo?.slice(repo.indexOf('/') + 1);
43
+ if (repo !== undefined && (!REPO.test(repo) || repoName === '.' || repoName === '..'))
44
+ found.push(`archive.repo must be "owner/name": ${JSON.stringify(repo)}`);
45
+ for (const [what, value] of [
46
+ ['archive.tag', archive.tag],
47
+ ['name', props.name],
48
+ ] as const) {
49
+ const given = text(what, value);
50
+ const problem = given === undefined ? undefined : segmentProblem(what, given);
51
+ if (problem !== undefined) found.push(problem);
52
+ }
53
+ const asset = text('archive.asset', archive.asset);
54
+ if (asset !== undefined) {
55
+ const problem = segmentProblem('archive.asset', asset);
56
+ if (problem !== undefined) found.push(problem);
57
+ else if (!TARBALL.test(asset))
58
+ found.push(`archive.asset must be a .tar.gz or .tgz: "${asset}"`);
59
+ }
60
+ // ★ ONE DECLARED DIRECTORY, NEVER DERIVED. Optional: most archives (Victoria, OpenBao) are flat.
61
+ // Where given, it is the vendor's own wrapper (measured, not guessed — tar.ts's header comment)
62
+ // and must be exactly one safe segment, the same alphabet as a tag or an asset name.
63
+ if (archive.root !== undefined) {
64
+ if (typeof archive.root !== 'string') {
65
+ found.push('archive.root must be a plain string when given');
66
+ } else {
67
+ const problem = segmentProblem('archive.root', archive.root);
68
+ if (problem !== undefined) found.push(problem);
69
+ }
70
+ }
71
+ // ⛔ THE SIZE IS ALLOCATED UP FRONT (download.ts fills one buffer of exactly this many bytes), so
72
+ // a typo'd size is a memory bomb in the deploying process. vmutils, the largest archive pinned,
73
+ // is 123.6 MB; a gibibyte is a ceiling nothing single-binary should reach.
74
+ const size = archive.size;
75
+ if (typeof size !== 'number' || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ARCHIVE) {
76
+ found.push(`archive.size must be the asset's byte count, 1 to ${String(MAX_ARCHIVE)}`);
77
+ }
78
+ for (const [what, value] of [
79
+ ['archive.sha256', archive.sha256],
80
+ ['sha256', props.sha256],
81
+ ] as const) {
82
+ const given = text(what, value);
83
+ if (given !== undefined && !HEX64.test(given))
84
+ found.push(`${what} must be 64 lower-case hex digits`);
85
+ }
86
+ const member = text('member', props.member);
87
+ // ⛔ The tar reader refuses these entries anyway; asking for one is a mistake to name at plan.
88
+ if (
89
+ member !== undefined &&
90
+ (member === '' || member.startsWith('/') || member.split('/').includes('..'))
91
+ ) {
92
+ found.push(`member must be a relative name inside the archive: ${JSON.stringify(member)}`);
93
+ }
94
+ return found;
95
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `Release.Binary` end to end, through Alchemy's own Plan and Apply (openbao/fake-stack.ts), for a
3
+ * synthetic archive shaped like the Prometheus family: every entry wrapped in one directory
4
+ * (tar.ts's header comment, measured 2026-09-23). `archive.root` installs it; the same declaration
5
+ * without `root` refuses, exactly as tar-root.test.ts shows at the reader alone.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Layer from 'effect/Layer';
10
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
11
+ import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
12
+ import { sha256Hex } from '../launchd/job-form.ts';
13
+ import { type HostRunner, hostRunnerLayer } from '../launchd/runner.ts';
14
+ import { HostDirectory, HostDirectoryProvider } from '../linux/directory.ts';
15
+ import { fakeLinuxHost } from '../linux/fake-linux-host.ts';
16
+ import { fakeStack } from '../openbao/fake-stack.ts';
17
+ import { releaseUrl } from './binary-form.ts';
18
+ import { ReleaseBinary, makeReleaseBinaryProvider } from './binary.ts';
19
+ import type { PinnedBinary } from './catalog.ts';
20
+ import { bytesOf, gzip, tarOf } from './fake-release.ts';
21
+
22
+ const ROOT = '/opt/example/bin';
23
+ const DIR = `${ROOT}/alertmanager-0.33.1`;
24
+ const WRAPPED = 'alertmanager-0.33.1.darwin-arm64';
25
+ const BYTES = bytesOf('#!fake alertmanager 0.33.1\n');
26
+ const ARCHIVE = gzip(
27
+ tarOf([
28
+ { name: `${WRAPPED}/`, type: '5' },
29
+ { bytes: BYTES, name: `${WRAPPED}/alertmanager` },
30
+ ]),
31
+ );
32
+ const ASSET = 'alertmanager-0.33.1.darwin-arm64.tar.gz';
33
+ const REPO = 'prometheus/alertmanager';
34
+ const TAG = 'v0.33.1';
35
+ const URL = releaseUrl(REPO, TAG, ASSET);
36
+ const PATH = `${DIR}/alertmanager`;
37
+
38
+ /** The pinned props, with or without the declared root — everything else identical. */
39
+ const pins = (root?: string): PinnedBinary => ({
40
+ archive: {
41
+ asset: ASSET,
42
+ repo: REPO,
43
+ sha256: sha256Hex(ARCHIVE),
44
+ size: ARCHIVE.length,
45
+ tag: TAG,
46
+ ...(root === undefined ? {} : { root }),
47
+ },
48
+ member: 'alertmanager',
49
+ name: 'alertmanager',
50
+ sha256: sha256Hex(BYTES),
51
+ });
52
+
53
+ /** One fake host and one fake server; `events` is every host write and request, in order. */
54
+ const harness = () => {
55
+ // ★ SEED THE PARENT, NOT `DIR` ITSELF: `HostDirectory` below creates `DIR` fresh, as
56
+ // plan.test.ts's harness does — a directory pre-seeded at the declared path would instead be
57
+ // an adoption question, which is not what this file is testing.
58
+ const fake = fakeLinuxHost({ dirs: { [ROOT]: 0 }, euid: 0 });
59
+ const events: string[] = [];
60
+ const runner: HostRunner = {
61
+ ...fake.runner,
62
+ writeFileAtomic: (path, bytes, options) => {
63
+ events.push(`write ${path}`);
64
+ return fake.runner.writeFileAtomic(path, bytes, options);
65
+ },
66
+ };
67
+ const client = HttpClient.make((request, url) => {
68
+ events.push(`GET ${url.toString()}`);
69
+ const body = url.toString() === URL ? new Blob([ARCHIVE.slice()]) : null;
70
+ return Effect.succeed(
71
+ HttpClientResponse.fromWeb(request, new Response(body, { status: body ? 200 : 404 })),
72
+ );
73
+ });
74
+ const providers = Layer.mergeAll(makeReleaseBinaryProvider(), HostDirectoryProvider()).pipe(
75
+ Layer.provide(
76
+ Layer.mergeAll(hostRunnerLayer(runner), Layer.succeed(HttpClient.HttpClient, client)),
77
+ ),
78
+ );
79
+ const stack = fakeStack(providers, {}, 'RootBinaryStack');
80
+ const declare = (binary: PinnedBinary) =>
81
+ Effect.gen(function* () {
82
+ const dir = yield* HostDirectory('alertmanager-dir', { mode: 0o755, path: DIR });
83
+ yield* ReleaseBinary('alertmanager', { ...binary, directory: dir.path });
84
+ });
85
+ return { declare, events, fake, stack };
86
+ };
87
+
88
+ describe('a directory-wrapped archive, declared with root', () => {
89
+ test('installs, reads back and re-hashes to the pinned member digest', async () => {
90
+ const h = harness();
91
+ const planned = await h.stack.deploy(h.declare(pins(WRAPPED)));
92
+ expect(planned).toEqual({ alertmanager: 'create', 'alertmanager-dir': 'create' });
93
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BYTES);
94
+ expect(sha256Hex(h.fake.files.get(PATH)?.bytes ?? new Uint8Array())).toBe(sha256Hex(BYTES));
95
+ });
96
+
97
+ test('a second deploy of the same declaration reads the file back; nothing downloaded again', async () => {
98
+ const h = harness();
99
+ await h.stack.deploy(h.declare(pins(WRAPPED)));
100
+ h.events.length = 0;
101
+ await h.stack.deploy(h.declare(pins(WRAPPED)));
102
+ expect(h.events).toEqual([]);
103
+ expect(h.fake.files.get(PATH)?.bytes).toEqual(BYTES);
104
+ });
105
+
106
+ test('the same declaration without root refuses, writes nothing, leaves no file at the path', async () => {
107
+ const h = harness();
108
+ await expect(h.stack.deploy(h.declare(pins()))).rejects.toThrow('is a directory');
109
+ expect(h.fake.files.has(PATH)).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('pinProblems refuses an unsafe root, before any host write or network call', () => {
114
+ test.each(['', '.', '..', 'a/b', './a'])('root %j', async (root) => {
115
+ const h = harness();
116
+ await expect(h.stack.deploy(h.declare(pins(root)))).rejects.toThrow('archive.root must be');
117
+ expect(h.fake.files.has(PATH)).toBe(false);
118
+ // ★ mkdir is not instrumented (it goes through `exec`, not `writeFileAtomic`), so HostDirectory
119
+ // creating DIR is expected here; nothing about ReleaseBinary's own pin is.
120
+ expect(h.events.filter((e) => e.startsWith('GET') || e.startsWith('write'))).toEqual([]);
121
+ });
122
+ });
@@ -191,9 +191,17 @@ export const catalogBinary = (catalog: ReleaseCatalog, request: CatalogRequest):
191
191
  message: `Release.Binary ${request.binary}: ${problems.join('; ')}. Nothing was fetched.`,
192
192
  });
193
193
  }
194
- const { asset, repo, sha256: archiveSha256, size, tag } = archive;
194
+ const { asset, repo, root, sha256: archiveSha256, size, tag } = archive;
195
195
  return {
196
- archive: { asset, repo, sha256: archiveSha256, size, tag },
196
+ // ⚠️ exactOptionalPropertyTypes: a conditional spread, never `root: undefined` spelled out.
197
+ archive: {
198
+ asset,
199
+ repo,
200
+ sha256: archiveSha256,
201
+ size,
202
+ tag,
203
+ ...(root === undefined ? {} : { root }),
204
+ },
197
205
  member,
198
206
  name: request.binary,
199
207
  sha256,
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `tarReader(wanted, root)` — the declared-root mode measured against the Prometheus-family shape
3
+ * (tar.ts's header comment, 2026-09-23): one directory entry wraps every other entry, stripped
4
+ * before a name is matched, listed or checked for a duplicate. Everything tar.test.ts already
5
+ * refuses keeps refusing; this file is only what `root` adds or changes.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { bytesOf, tarOf } from './fake-release.ts';
9
+ import { ArchiveRefused } from './refused.ts';
10
+ import { tarReader } from './tar.ts';
11
+
12
+ const ROOT = 'alertmanager-0.33.1.darwin-arm64';
13
+ const wrapper = { name: `${ROOT}/`, type: '5' } as const;
14
+ const binary = { bytes: bytesOf('#!binary\n'), name: `${ROOT}/alertmanager` } as const;
15
+
16
+ const read = (tar: Uint8Array, wanted: readonly string[], root?: string) => {
17
+ const reader = tarReader(new Set(wanted), root);
18
+ reader.push(tar);
19
+ return reader.finish();
20
+ };
21
+
22
+ const refusal = (run: () => unknown): string => {
23
+ try {
24
+ run();
25
+ } catch (cause) {
26
+ expect(cause).toBeInstanceOf(ArchiveRefused);
27
+ return (cause as ArchiveRefused).message;
28
+ }
29
+ throw new Error('expected a refusal');
30
+ };
31
+
32
+ describe('a declared root strips exactly one leading directory', () => {
33
+ test('the member comes back under its stripped name; the root entry is not one of the names', () => {
34
+ const tar = tarOf([wrapper, binary]);
35
+ const { members, names } = read(tar, ['alertmanager'], ROOT);
36
+ expect(new TextDecoder().decode(members.get('alertmanager'))).toBe('#!binary\n');
37
+ expect(names).toEqual(['alertmanager']);
38
+ });
39
+
40
+ test('a sibling regular file under root is listed, stripped, but not fetched unless asked for', () => {
41
+ const notice = { bytes: bytesOf('legal text'), name: `${ROOT}/NOTICE` };
42
+ const { members, names } = read(tarOf([wrapper, binary, notice]), ['alertmanager'], ROOT);
43
+ expect(names).toEqual(['alertmanager', 'NOTICE']);
44
+ expect(members.has('NOTICE')).toBe(false);
45
+ });
46
+
47
+ test('THE SAME ARCHIVE, root omitted: still refused "is a directory" — origin/main\'s behaviour', () => {
48
+ const tar = tarOf([wrapper, binary]);
49
+ expect(refusal(() => read(tar, ['alertmanager']))).toContain('is a directory');
50
+ });
51
+
52
+ test('a root declared but never present in the archive is refused', () => {
53
+ expect(refusal(() => read(tarOf([binary]), ['alertmanager'], ROOT))).toContain('never appears');
54
+ });
55
+
56
+ // 🔴 ADVERSARIAL REVIEW, 2026-09-23 (before merge): a stripped name was never re-checked by
57
+ // `nameProblem`, so `<root>//x` — not absolute as a whole raw name, but stripping to `/x` —
58
+ // parsed to completion as a second, distinct, un-refused entry `/x` beside `x`. Fixed in
59
+ // tar.ts by running `nameProblem` on the STRIPPED name too, not only the raw one.
60
+ test('stripping cannot uncover an absolute name the reader would otherwise refuse', () => {
61
+ // `<root>//alertmanager` is not absolute AS A WHOLE NAME, so it passes nameProblem before
62
+ // stripping; only after `<root>/` is removed does it become the absolute name `/alertmanager`.
63
+ const evil = { bytes: bytesOf('evil'), name: `${ROOT}//alertmanager` };
64
+ expect(refusal(() => read(tarOf([wrapper, binary, evil]), ['alertmanager'], ROOT))).toContain(
65
+ 'strips to "/alertmanager"',
66
+ );
67
+ });
68
+ });
69
+
70
+ describe('everything a declared root still refuses, whole archive', () => {
71
+ test.each([
72
+ ['a second directory entry (the root, twice)', [wrapper, wrapper, binary], 'appearing twice'],
73
+ [
74
+ 'an entry named <root>-evil/x: a segment match, not a string prefix',
75
+ [wrapper, binary, { name: `${ROOT}-evil/x` }],
76
+ 'outside the declared root',
77
+ ],
78
+ [
79
+ 'a regular file at archive top level beside root',
80
+ [wrapper, binary, { name: 'README' }],
81
+ 'outside the declared root',
82
+ ],
83
+ ['<root>/../x climbs out through the root', [wrapper, { name: `${ROOT}/../x` }], 'climbs out'],
84
+ [
85
+ 'a PAX x header inside root',
86
+ [wrapper, binary, { name: `${ROOT}/PaxHeader`, type: 'x' }],
87
+ 'PAX extended',
88
+ ],
89
+ [
90
+ 'a GNU L header inside root',
91
+ [wrapper, binary, { name: `${ROOT}/@LongLink`, type: 'L' }],
92
+ 'GNU long-name',
93
+ ],
94
+ ['a symlink inside root', [wrapper, binary, { name: `${ROOT}/link`, type: '2' }], 'a symlink'],
95
+ [
96
+ 'the root entry with size > 0',
97
+ [{ ...wrapper, bytes: bytesOf('x') }, binary],
98
+ 'not an empty directory',
99
+ ],
100
+ [
101
+ 'the root entry as typeflag 1 (a hard link), not a directory',
102
+ [{ name: `${ROOT}/`, type: '1' }],
103
+ 'not an empty directory',
104
+ ],
105
+ [
106
+ 'the root entry as typeflag 2 (a symlink), not a directory',
107
+ [{ name: `${ROOT}/`, type: '2' }],
108
+ 'not an empty directory',
109
+ ],
110
+ ['a duplicate name after stripping', [wrapper, binary, binary], 'appears twice'],
111
+ ] as const)('%s', (_, entries, message) => {
112
+ expect(refusal(() => read(tarOf(entries), ['alertmanager'], ROOT))).toContain(message);
113
+ });
114
+ });
@@ -15,10 +15,25 @@
15
15
  * non-zero bytes after the end marker; an archive that stops before it.
16
16
  * ★ GNU LONG NAMES ('L', 'K') AND PAX HEADERS ('x', 'g') ARE REFUSED, NOT INTERPRETED. Each renames
17
17
  * the entry after it: a reader that honours them must get them exactly right to match exactly,
18
- * and one that skips them matches under the wrong name. The four pinned archives use neither
19
- * (measured 2026-09-22 with `tar -tvzf` on each: every entry a root-level regular file with a
20
- * short name), so refusing costs nothing today, and a vendor format change becomes a refusal that
21
- * says what changed instead of a silent mis-read.
18
+ * and one that skips them matches under the wrong name. Refusing costs nothing for an archive
19
+ * that carries neither, and a vendor format change becomes a refusal that says what changed
20
+ * instead of a silent mis-read.
21
+ * ★ AN OPTIONAL `root`: ONE DECLARED LEADING DIRECTORY, STRIPPED, NEVER GUESSED. Measured
22
+ * 2026-09-23: `gh api repos/<o>/<r>/releases/tags/<tag>` for each asset URL and digest, the
23
+ * archive downloaded into a scratch directory (never executed), re-hashed against GitHub's
24
+ * `digest`, then read by THIS reader (a driver script importing `tarReader`) both before and
25
+ * after this change — refused whole either way without `root`, and, with it, parsed to
26
+ * completion: `alertmanager-0.33.1.darwin-arm64.tar.gz` (37,247,168 B),
27
+ * `blackbox_exporter-0.28.0.darwin-arm64.tar.gz` (15,705,022 B),
28
+ * `node_exporter-1.12.1.darwin-arm64.tar.gz` (5,368,643 B) and
29
+ * `postgres_exporter-0.20.1.darwin-arm64.tar.gz` (10,072,235 B) each recompute to GitHub's own
30
+ * `digest`, and each wraps every entry in exactly one directory (typeflag '5', size 0, first)
31
+ * named `<binary>-<version>.darwin-arm64/`, holding only root-level regular files (the binary,
32
+ * `LICENSE`, `NOTICE`, and for alertmanager also `amtool` and `alertmanager.yml`) — no PAX or
33
+ * GNU long-name entries, so this reader's refusal of both stays exactly as costly as before.
34
+ * Without `root`, EVERY refusal above still holds byte-for-byte: a directory entry — the wrapper
35
+ * included — is refused the same way it always was (docs/release-binary-catalogs.md,
36
+ * docs/release-binary-upstream.md gap 11).
22
37
  */
23
38
  import { ArchiveRefused } from './refused.ts';
24
39
 
@@ -101,8 +116,15 @@ export type TarContents = {
101
116
  readonly names: readonly string[];
102
117
  };
103
118
 
104
- /** A push parser: `push` each chunk of the unzipped stream, then `finish`. Either may throw. */
105
- export const tarReader = (wanted: ReadonlySet<string>) => {
119
+ /**
120
+ * A push parser: `push` each chunk of the unzipped stream, then `finish`. Either may throw.
121
+ *
122
+ * @param root When given, exactly one typeflag-'5' entry named `<root>/`, size 0, is accepted and
123
+ * dropped rather than refused as a directory; every OTHER entry name must begin with the SEGMENT
124
+ * `<root>/` (a `<root>-evil/x` sibling does not) and is reported, matched and kept under that
125
+ * prefix stripped off. Omitted, behaviour is byte-identical to a reader with no root at all.
126
+ */
127
+ export const tarReader = (wanted: ReadonlySet<string>, root?: string) => {
106
128
  const header = new Uint8Array(BLOCK);
107
129
  const members = new Map<string, Uint8Array>();
108
130
  const names: string[] = [];
@@ -114,6 +136,8 @@ export const tarReader = (wanted: ReadonlySet<string>) => {
114
136
  let zeros = 0;
115
137
  let name = '';
116
138
  let parts: Uint8Array[] | undefined;
139
+ let rootSeen = false;
140
+ const rootPrefix = root === undefined ? undefined : `${root}/`;
117
141
 
118
142
  const endEntry = () => {
119
143
  if (parts !== undefined) members.set(name, concatBytes(parts));
@@ -134,18 +158,57 @@ export const tarReader = (wanted: ReadonlySet<string>) => {
134
158
  if (!posix && magic !== 'ustar \u0000')
135
159
  throw refuse('an entry header is neither ustar nor GNU');
136
160
  const prefix = posix ? text(header, 345, 155) : '';
137
- name = prefix === '' ? text(header, 0, 100) : `${prefix}/${text(header, 0, 100)}`;
138
- const problem = nameProblem(name);
139
- if (problem !== undefined) throw refuse(`entry ${JSON.stringify(name)} ${problem}`);
161
+ const rawName = prefix === '' ? text(header, 0, 100) : `${prefix}/${text(header, 0, 100)}`;
162
+ const problem = nameProblem(rawName);
163
+ if (problem !== undefined) throw refuse(`entry ${JSON.stringify(rawName)} ${problem}`);
164
+ // ★ THE DECLARED ROOT IS RECOGNISED BEFORE THE GENERAL TYPE CHECK, and only by exact name: a
165
+ // directory entry under any other name — nested, or a second copy of the root itself — still
166
+ // falls through to that check below and is refused as a directory, same as with no root.
167
+ if (rootPrefix !== undefined && rawName === rootPrefix) {
168
+ if (rootSeen) {
169
+ // ⚠️ Whatever type THIS entry is, its name is already taken: refused for reusing the
170
+ // root's name, not necessarily for being a second directory (it need not be one).
171
+ throw refuse(`entry "${rawName}" reuses the declared root's own name, appearing twice`);
172
+ }
173
+ const type = String.fromCharCode(header[156] ?? 0);
174
+ const size = octal(header, 124, 12, `entry "${rawName}"'s size`);
175
+ if (type !== '5' || size !== 0) {
176
+ throw refuse(`entry "${rawName}" is the declared root but is not an empty directory`);
177
+ }
178
+ rootSeen = true;
179
+ phase = 'header';
180
+ return;
181
+ }
182
+ let stripped = rawName;
183
+ if (rootPrefix !== undefined) {
184
+ if (!rawName.startsWith(rootPrefix)) {
185
+ throw refuse(`entry "${rawName}" is outside the declared root "${rootPrefix}"`);
186
+ }
187
+ // ⛔ THE STRIPPED NAME GETS THE SAME SAFETY CHECK AS A RAW ONE. `rawName` already passed
188
+ // `nameProblem` above, but stripping a prefix can UNCOVER a `/` or `..` that was hiding
189
+ // past it: `<root>//x` is not absolute as a whole name, but strips to `/x`, which is —
190
+ // the exact thing this reader refuses a plain (rootless) archive for. MEASURED: without
191
+ // this check, `<root>//x` beside `<root>/x` parsed to completion as two distinct entries,
192
+ // `x` and `/x`, neither refused. `stripped` can never be empty here: the one raw name that
193
+ // strips to '' is `rootPrefix` itself, handled above.
194
+ stripped = rawName.slice(rootPrefix.length);
195
+ const strippedProblem = nameProblem(stripped);
196
+ if (strippedProblem !== undefined) {
197
+ throw refuse(
198
+ `entry "${rawName}" strips to ${JSON.stringify(stripped)}, which ${strippedProblem}`,
199
+ );
200
+ }
201
+ }
202
+ name = stripped;
140
203
  const type = String.fromCharCode(header[156] ?? 0);
141
204
  if (type !== '0' && type !== '\0') {
142
205
  const kind = KINDS[type] ?? `of unknown type ${JSON.stringify(type)}`;
143
- throw refuse(`entry "${name}" is ${kind}; only regular files are accepted`);
206
+ throw refuse(`entry "${rawName}" is ${kind}; only regular files are accepted`);
144
207
  }
145
- if (seen.has(name)) throw refuse(`entry "${name}" appears twice`);
208
+ if (seen.has(name)) throw refuse(`entry "${rawName}" appears twice`);
146
209
  seen.add(name);
147
210
  names.push(name);
148
- remaining = octal(header, 124, 12, `entry "${name}"'s size`);
211
+ remaining = octal(header, 124, 12, `entry "${rawName}"'s size`);
149
212
  padding = (BLOCK - (remaining % BLOCK)) % BLOCK;
150
213
  parts = wanted.has(name) ? [] : undefined;
151
214
  phase = 'data';
@@ -188,6 +251,9 @@ export const tarReader = (wanted: ReadonlySet<string>) => {
188
251
 
189
252
  const finish = (): TarContents => {
190
253
  if (phase !== 'end') throw refuse('the archive stops before its end-of-archive marker');
254
+ if (rootPrefix !== undefined && !rootSeen) {
255
+ throw refuse(`the declared root "${rootPrefix}" never appears in the archive`);
256
+ }
191
257
  return { members, names };
192
258
  };
193
259