@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.
- package/README.md +15 -1
- package/dist/litellm/client.d.ts +76 -0
- package/dist/litellm/client.d.ts.map +1 -0
- package/dist/litellm/credentials.d.ts +48 -0
- package/dist/litellm/credentials.d.ts.map +1 -0
- package/dist/litellm/fake-litellm.d.ts +36 -0
- package/dist/litellm/fake-litellm.d.ts.map +1 -0
- package/dist/litellm/fake-stack.d.ts +30 -0
- package/dist/litellm/fake-stack.d.ts.map +1 -0
- package/dist/litellm/generated/pass-through.d.ts +71 -0
- package/dist/litellm/generated/pass-through.d.ts.map +1 -0
- package/dist/litellm/index.d.ts +13 -0
- package/dist/litellm/index.d.ts.map +1 -0
- package/dist/litellm/index.js +270 -0
- package/dist/litellm/index.js.map +14 -0
- package/dist/litellm/pass-through-endpoint.d.ts +94 -0
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +40 -0
- package/dist/litellm/pass-through-form.d.ts.map +1 -0
- package/dist/litellm/providers.d.ts +16 -0
- package/dist/litellm/providers.d.ts.map +1 -0
- package/dist/postgres/connection.d.ts +50 -0
- package/dist/postgres/connection.d.ts.map +1 -0
- package/dist/postgres/database-attrs.d.ts +79 -0
- package/dist/postgres/database-attrs.d.ts.map +1 -0
- package/dist/postgres/database-sql.d.ts +73 -0
- package/dist/postgres/database-sql.d.ts.map +1 -0
- package/dist/postgres/database.d.ts +63 -0
- package/dist/postgres/database.d.ts.map +1 -0
- package/dist/postgres/errors.d.ts +86 -0
- package/dist/postgres/errors.d.ts.map +1 -0
- package/dist/postgres/fake-sql.d.ts +22 -0
- package/dist/postgres/fake-sql.d.ts.map +1 -0
- package/dist/postgres/index.d.ts +16 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +238 -0
- package/dist/postgres/index.js.map +15 -0
- package/dist/postgres/providers.d.ts +14 -0
- package/dist/postgres/providers.d.ts.map +1 -0
- package/dist/proxmox/index.js.map +2 -2
- package/dist/proxmox/zfs-pool.d.ts +10 -2
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/release/archive.d.ts +6 -2
- package/dist/release/archive.d.ts.map +1 -1
- package/dist/release/binary-form.d.ts +14 -9
- package/dist/release/binary-form.d.ts.map +1 -1
- package/dist/release/binary-pins.d.ts +17 -0
- package/dist/release/binary-pins.d.ts.map +1 -0
- package/dist/release/catalog.d.ts.map +1 -1
- package/dist/release/index.js +90 -40
- package/dist/release/index.js.map +8 -7
- package/dist/release/tar.d.ts +9 -2
- package/dist/release/tar.d.ts.map +1 -1
- package/docs/litellm.md +110 -0
- package/docs/peers.md +8 -1
- package/docs/postgres.md +125 -0
- package/docs/release-binary-catalogs.md +40 -4
- package/docs/release-binary-refusals.md +22 -8
- package/docs/release-binary-upstream.md +33 -15
- package/package.json +10 -1
- package/src/litellm/client.ts +195 -0
- package/src/litellm/credentials.ts +73 -0
- package/src/litellm/fake-litellm.ts +114 -0
- package/src/litellm/fake-stack.ts +100 -0
- package/src/litellm/generated/pass-through.ts +73 -0
- package/src/litellm/index.ts +42 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
- package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
- package/src/litellm/pass-through-endpoint.test.ts +208 -0
- package/src/litellm/pass-through-endpoint.ts +258 -0
- package/src/litellm/pass-through-form.test.ts +128 -0
- package/src/litellm/pass-through-form.ts +136 -0
- package/src/litellm/providers.ts +19 -0
- package/src/postgres/connection.ts +62 -0
- package/src/postgres/database-attrs.ts +87 -0
- package/src/postgres/database-bigint-serialization.test.ts +140 -0
- package/src/postgres/database-lifecycle.test.ts +113 -0
- package/src/postgres/database-provenance.test.ts +118 -0
- package/src/postgres/database-refusals.test.ts +135 -0
- package/src/postgres/database-sql.ts +174 -0
- package/src/postgres/database.ts +193 -0
- package/src/postgres/errors.ts +131 -0
- package/src/postgres/fake-sql.ts +123 -0
- package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
- package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
- package/src/postgres/index.ts +30 -0
- package/src/postgres/providers.ts +16 -0
- package/src/proxmox/zfs-pool.ts +10 -2
- package/src/release/archive.ts +10 -4
- package/src/release/binary-form.ts +24 -76
- package/src/release/binary-pins.ts +95 -0
- package/src/release/binary-root.test.ts +122 -0
- package/src/release/catalog.ts +10 -2
- package/src/release/tar-root.test.ts +114 -0
- 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
|
+
});
|
package/src/release/catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/release/tar.ts
CHANGED
|
@@ -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.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
/**
|
|
105
|
-
|
|
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
|
-
|
|
138
|
-
const problem = nameProblem(
|
|
139
|
-
if (problem !== undefined) throw refuse(`entry ${JSON.stringify(
|
|
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 "${
|
|
206
|
+
throw refuse(`entry "${rawName}" is ${kind}; only regular files are accepted`);
|
|
144
207
|
}
|
|
145
|
-
if (seen.has(name)) throw refuse(`entry "${
|
|
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 "${
|
|
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
|
|