@homeflare/alchemy 0.23.0 → 0.25.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 +25 -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/paperless/client.d.ts +37 -0
- package/dist/paperless/client.d.ts.map +1 -0
- package/dist/paperless/constraint-guard.d.ts +19 -0
- package/dist/paperless/constraint-guard.d.ts.map +1 -0
- package/dist/paperless/constraints.d.ts +50 -0
- package/dist/paperless/constraints.d.ts.map +1 -0
- package/dist/paperless/credentials.d.ts +40 -0
- package/dist/paperless/credentials.d.ts.map +1 -0
- package/dist/paperless/custom-field.d.ts +75 -0
- package/dist/paperless/custom-field.d.ts.map +1 -0
- package/dist/paperless/document-type.d.ts +30 -0
- package/dist/paperless/document-type.d.ts.map +1 -0
- package/dist/paperless/errors.d.ts +49 -0
- package/dist/paperless/errors.d.ts.map +1 -0
- package/dist/paperless/fake-paperless.d.ts +39 -0
- package/dist/paperless/fake-paperless.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/index.d.ts +13 -0
- package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
- package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
- package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
- package/dist/paperless/generated/types/document-types.d.ts +39 -0
- package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
- package/dist/paperless/generated/types/index.d.ts +10 -0
- package/dist/paperless/generated/types/index.d.ts.map +1 -0
- package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
- package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
- package/dist/paperless/generated/types/tags.d.ts +37 -0
- package/dist/paperless/generated/types/tags.d.ts.map +1 -0
- package/dist/paperless/index.d.ts +19 -0
- package/dist/paperless/index.d.ts.map +1 -0
- package/dist/paperless/index.js +524 -0
- package/dist/paperless/index.js.map +26 -0
- package/dist/paperless/matching-locate.d.ts +33 -0
- package/dist/paperless/matching-locate.d.ts.map +1 -0
- package/dist/paperless/matching-types.d.ts +60 -0
- package/dist/paperless/matching-types.d.ts.map +1 -0
- package/dist/paperless/matching.d.ts +61 -0
- package/dist/paperless/matching.d.ts.map +1 -0
- package/dist/paperless/providers.d.ts +15 -0
- package/dist/paperless/providers.d.ts.map +1 -0
- package/dist/paperless/storage-path.d.ts +32 -0
- package/dist/paperless/storage-path.d.ts.map +1 -0
- package/dist/paperless/tag.d.ts +47 -0
- package/dist/paperless/tag.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/paperless.md +115 -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 +14 -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/paperless/client.ts +88 -0
- package/src/paperless/constraint-guard.ts +50 -0
- package/src/paperless/constraints.test.ts +74 -0
- package/src/paperless/constraints.ts +111 -0
- package/src/paperless/credentials.ts +69 -0
- package/src/paperless/custom-field.test.ts +74 -0
- package/src/paperless/custom-field.ts +95 -0
- package/src/paperless/document-type.ts +74 -0
- package/src/paperless/errors.ts +62 -0
- package/src/paperless/fake-paperless.ts +156 -0
- package/src/paperless/generated/constraints/index.ts +23 -0
- package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
- package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
- package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
- package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
- package/src/paperless/generated/types/custom-fields.ts +23 -0
- package/src/paperless/generated/types/document-types.ts +30 -0
- package/src/paperless/generated/types/index.ts +9 -0
- package/src/paperless/generated/types/storage-paths.ts +31 -0
- package/src/paperless/generated/types/tags.ts +37 -0
- package/src/paperless/index.ts +56 -0
- package/src/paperless/matching-identity.test.ts +194 -0
- package/src/paperless/matching-locate.ts +104 -0
- package/src/paperless/matching-types.ts +61 -0
- package/src/paperless/matching.test.ts +128 -0
- package/src/paperless/matching.ts +239 -0
- package/src/paperless/providers.ts +27 -0
- package/src/paperless/storage-path.ts +78 -0
- package/src/paperless/tag.ts +91 -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,67 @@
|
|
|
1
|
+
# path: src/include/catalog/pg_database.h
|
|
2
|
+
# blob: 54f0d38c9c9e1cd4d419b05b296b8f84875e9768
|
|
3
|
+
# tag: REL_18_6 (commit 724edf9bde9d356724ad384a2e196edc3c9f80f7)
|
|
4
|
+
# fetched: 2026-09-23 via gh api repos/postgres/postgres/contents/...?ref=724edf9bde9d356724ad384a2e196edc3c9f80f7
|
|
5
|
+
# excerpt: the CATALOG(pg_database...) { ... } FormData_pg_database struct only (lines 29-89 of the full file)
|
|
6
|
+
#
|
|
7
|
+
CATALOG(pg_database,1262,DatabaseRelationId) BKI_SHARED_RELATION BKI_ROWTYPE_OID(1248,DatabaseRelation_Rowtype_Id) BKI_SCHEMA_MACRO
|
|
8
|
+
{
|
|
9
|
+
/* oid */
|
|
10
|
+
Oid oid;
|
|
11
|
+
|
|
12
|
+
/* database name */
|
|
13
|
+
NameData datname;
|
|
14
|
+
|
|
15
|
+
/* owner of database */
|
|
16
|
+
Oid datdba BKI_DEFAULT(POSTGRES) BKI_LOOKUP(pg_authid);
|
|
17
|
+
|
|
18
|
+
/* character encoding */
|
|
19
|
+
int32 encoding;
|
|
20
|
+
|
|
21
|
+
/* locale provider, see pg_collation.collprovider */
|
|
22
|
+
char datlocprovider;
|
|
23
|
+
|
|
24
|
+
/* allowed as CREATE DATABASE template? */
|
|
25
|
+
bool datistemplate;
|
|
26
|
+
|
|
27
|
+
/* new connections allowed? */
|
|
28
|
+
bool datallowconn;
|
|
29
|
+
|
|
30
|
+
/* database has login event triggers? */
|
|
31
|
+
bool dathasloginevt;
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
* Max connections allowed. Negative values have special meaning, see
|
|
35
|
+
* DATCONNLIMIT_* defines below.
|
|
36
|
+
*/
|
|
37
|
+
int32 datconnlimit;
|
|
38
|
+
|
|
39
|
+
/* all Xids < this are frozen in this DB */
|
|
40
|
+
TransactionId datfrozenxid;
|
|
41
|
+
|
|
42
|
+
/* all multixacts in the DB are >= this */
|
|
43
|
+
TransactionId datminmxid;
|
|
44
|
+
|
|
45
|
+
/* default table space for this DB */
|
|
46
|
+
Oid dattablespace BKI_LOOKUP(pg_tablespace);
|
|
47
|
+
|
|
48
|
+
#ifdef CATALOG_VARLEN /* variable-length fields start here */
|
|
49
|
+
/* LC_COLLATE setting */
|
|
50
|
+
text datcollate BKI_FORCE_NOT_NULL;
|
|
51
|
+
|
|
52
|
+
/* LC_CTYPE setting */
|
|
53
|
+
text datctype BKI_FORCE_NOT_NULL;
|
|
54
|
+
|
|
55
|
+
/* ICU locale ID */
|
|
56
|
+
text datlocale;
|
|
57
|
+
|
|
58
|
+
/* ICU collation rules */
|
|
59
|
+
text daticurules;
|
|
60
|
+
|
|
61
|
+
/* provider-dependent version of collation data */
|
|
62
|
+
text datcollversion BKI_DEFAULT(_null_);
|
|
63
|
+
|
|
64
|
+
/* access permissions */
|
|
65
|
+
aclitem datacl[1];
|
|
66
|
+
#endif
|
|
67
|
+
} FormData_pg_database;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL providers for Alchemy — `Postgres.Database`, create-and-assert over a self-hosted
|
|
3
|
+
* cluster, walked against PostgreSQL 18.6. See `docs/postgres.md`.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THIS BARREL IS THE PUBLIC API, deliberately smaller than the directory: `fake-sql.ts` is a
|
|
6
|
+
* test double, not something a consuming stack should import.
|
|
7
|
+
*/
|
|
8
|
+
export type { PostgresConnectionConfig } from './connection.ts';
|
|
9
|
+
export { PostgresConnection, postgresConnection, withPg } from './connection.ts';
|
|
10
|
+
export type { PostgresDatabaseAttributes, PostgresDatabaseProps } from './database-attrs.ts';
|
|
11
|
+
export { POSTGRES_NAME_MAX_BYTES, nameByteRefusal, utf8ByteLength } from './database-attrs.ts';
|
|
12
|
+
export {
|
|
13
|
+
buildCreateDatabaseSql,
|
|
14
|
+
firstDrift,
|
|
15
|
+
isDuplicateDatabaseRace,
|
|
16
|
+
quoteIdent,
|
|
17
|
+
quoteStringLiteral,
|
|
18
|
+
roleExists,
|
|
19
|
+
selectDatabase,
|
|
20
|
+
} from './database-sql.ts';
|
|
21
|
+
export { PostgresDatabase, PostgresDatabaseProvider, isPostgresDatabase } from './database.ts';
|
|
22
|
+
export {
|
|
23
|
+
PostgresDatabaseDrift,
|
|
24
|
+
PostgresDatabaseDropRefused,
|
|
25
|
+
PostgresDatabaseNameRefused,
|
|
26
|
+
PostgresDatabaseOwnerMissing,
|
|
27
|
+
PostgresDatabaseRenameRefused,
|
|
28
|
+
type PostgresDatabaseError,
|
|
29
|
+
} from './errors.ts';
|
|
30
|
+
export { postgresProviders } from './providers.ts';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Postgres.Database`'s provider, pre-wired to one cluster:
|
|
3
|
+
*
|
|
4
|
+
* const providers = postgresProviders({ host: '/opt/homeflare/postgres/sockets', database: 'postgres', username: 'tim' });
|
|
5
|
+
*
|
|
6
|
+
* ★ THE CLUSTER IS A PARAMETER, NOT A DEFAULT (S15, and `docs/postgres.md#path`). This kit is
|
|
7
|
+
* PUBLIC; the mini's socket directory, its user and its port are the CONSUMING stack's values,
|
|
8
|
+
* never baked in here. A stack with more than one cluster calls this more than once and merges
|
|
9
|
+
* the results under whatever logical grouping it wants.
|
|
10
|
+
*/
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { type PostgresConnectionConfig, postgresConnection } from './connection.ts';
|
|
13
|
+
import { PostgresDatabaseProvider } from './database.ts';
|
|
14
|
+
|
|
15
|
+
export const postgresProviders = (config: PostgresConnectionConfig) =>
|
|
16
|
+
PostgresDatabaseProvider().pipe(Layer.provide(postgresConnection(config)));
|
package/src/proxmox/zfs-pool.ts
CHANGED
|
@@ -54,8 +54,16 @@
|
|
|
54
54
|
*
|
|
55
55
|
* ⚠️ PRIVILEGES, FROM THE SCHEMA. Read and diff need `Sys.Audit` on `/` — both GETs check it.
|
|
56
56
|
* Reconcile needs `Sys.Modify` on `/` for the POST. `Datastore.Allocate` on `/storage` is NOT
|
|
57
|
-
* needed, because `add_storage` is not offered — see the ⛔ on `ZfsPoolProps`.
|
|
58
|
-
*
|
|
57
|
+
* needed, because `add_storage` is not offered — see the ⛔ on `ZfsPoolProps`.
|
|
58
|
+
* ★ CORRECTED 2026-09-23, ALONGSIDE THE HEADER ABOVE: `delete` needs `Sys.Modify` on `/` too,
|
|
59
|
+
* for the DELETE it now sends under `.pipe(RemovalPolicy.destroy())` — the whole `disks/zfs`
|
|
60
|
+
* family checks it, not only the POST. MEASURED the same day as the rest of this paragraph:
|
|
61
|
+
* `ceph-osd.ts`'s own privilege comparison names `disks/zfs` as one of the sibling families
|
|
62
|
+
* whose write verbs DO carry a `Sys.Modify` check, unlike `ceph/osd`'s create and delete, which
|
|
63
|
+
* carry no permissions block at all and 403 for any identity but `root@pam`. This line
|
|
64
|
+
* previously said delete "needs nothing at all, since it calls nothing", which repeated the same
|
|
65
|
+
* mistake the header above corrects: `delete` calls the DELETE endpoint, so it needs whatever
|
|
66
|
+
* that endpoint checks.
|
|
59
67
|
*/
|
|
60
68
|
import { Resource } from 'alchemy';
|
|
61
69
|
import * as Provider from 'alchemy/Provider';
|
package/src/release/archive.ts
CHANGED
|
@@ -24,12 +24,17 @@ import { type TarContents, tarReader } from './tar.ts';
|
|
|
24
24
|
/** How much gzip is handed to the inflater at a time. */
|
|
25
25
|
const SLICE = 1 << 20;
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Gunzip and read a tar, keeping only `wanted`. Throws ArchiveRefused.
|
|
29
|
+
* @param root The one declared leading directory to strip (tar.ts) — a directory-wrapped vendor
|
|
30
|
+
* archive (the Prometheus family, measured 2026-09-23) without it, or one that carries no root.
|
|
31
|
+
*/
|
|
28
32
|
export const extractMembers = async (
|
|
29
33
|
gzipped: Uint8Array,
|
|
30
34
|
wanted: ReadonlySet<string>,
|
|
35
|
+
root?: string,
|
|
31
36
|
): Promise<TarContents> => {
|
|
32
|
-
const tar = tarReader(wanted);
|
|
37
|
+
const tar = tarReader(wanted, root);
|
|
33
38
|
// ⚠️ FED IN SLICES, ON DEMAND. Enqueued whole, the 123.6 MB vmutils archive is inflated in one
|
|
34
39
|
// transform step and all 264 MB of output queue up before the first read: MEASURED 2026-09-22,
|
|
35
40
|
// a peak RSS of 1116 MB for vmalert alone under bun 1.4.0. `pull` hands over the next slice only
|
|
@@ -79,11 +84,12 @@ export const verifiedMember = async (
|
|
|
79
84
|
): Promise<Uint8Array> => {
|
|
80
85
|
const archiveSha = sha256Hex(archive);
|
|
81
86
|
if (archiveSha !== release.sha256) throw mismatch(release.url, release.sha256, archiveSha);
|
|
82
|
-
const { members, names } = await extractMembers(archive, new Set([release.member]));
|
|
87
|
+
const { members, names } = await extractMembers(archive, new Set([release.member]), release.root);
|
|
83
88
|
const bytes = members.get(release.member);
|
|
84
89
|
if (bytes === undefined) {
|
|
90
|
+
const under = release.root === undefined ? '' : ` under root "${release.root}/"`;
|
|
85
91
|
throw new ArchiveRefused({
|
|
86
|
-
message: `${release.url} has no member named exactly "${release.member}" (it has ${names.map((n) => `"${n}"`).join(', ') || 'none'})`,
|
|
92
|
+
message: `${release.url} has no member named exactly "${release.member}"${under} (it has ${names.map((n) => `"${n}"`).join(', ') || 'none'})`,
|
|
87
93
|
});
|
|
88
94
|
}
|
|
89
95
|
const memberSha = sha256Hex(bytes);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { HostFileAttributes } from '../launchd/host-file-form.ts';
|
|
16
16
|
import { identityProblems, pathProblems } from '../launchd/host-file-form.ts';
|
|
17
|
+
import { pinProblems } from './binary-pins.ts';
|
|
17
18
|
|
|
18
19
|
/** One release archive, pinned: where it is published, and the bytes it must be. */
|
|
19
20
|
export interface ReleaseArchive {
|
|
@@ -30,6 +31,17 @@ export interface ReleaseArchive {
|
|
|
30
31
|
readonly size: number;
|
|
31
32
|
/** The archive's SHA-256, lower-case hex, as the vendor's checksum file lists it. */
|
|
32
33
|
readonly sha256: string;
|
|
34
|
+
/**
|
|
35
|
+
* The one directory the vendor wraps every entry in, exactly as its own archive names it —
|
|
36
|
+
* `'alertmanager-0.33.1.darwin-arm64'`, never `'alertmanager-0.33.1.darwin-arm64/'` (no trailing
|
|
37
|
+
* slash). Pinned from the walk-down, never derived from `asset` or `tag`: the vendor spells its
|
|
38
|
+
* wrapper however it likes (`darwin_arm64`, an `-enterprise` suffix, no version at all), so a
|
|
39
|
+
* computed name would drift onto a sibling the moment one vendor's naming changes. Omit it for a
|
|
40
|
+
* flat archive (Victoria, OpenBao); the tar reader then refuses a directory entry exactly as it
|
|
41
|
+
* always has (tar.ts).
|
|
42
|
+
* @default undefined — no wrapping directory; the archive is flat.
|
|
43
|
+
*/
|
|
44
|
+
readonly root?: string;
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
export interface ReleaseBinaryProps {
|
|
@@ -82,6 +94,8 @@ export type PinnedDownload = {
|
|
|
82
94
|
readonly member: string;
|
|
83
95
|
/** The member's pinned SHA-256. */
|
|
84
96
|
readonly memberSha256: string;
|
|
97
|
+
/** The one declared leading directory to strip, if the archive has one — `archive.root`. */
|
|
98
|
+
readonly root?: string;
|
|
85
99
|
};
|
|
86
100
|
|
|
87
101
|
export const DEFAULT_BINARY_MODE = 0o755;
|
|
@@ -109,6 +123,8 @@ export const pinnedDownload = (props: ReleaseBinaryProps): PinnedDownload => ({
|
|
|
109
123
|
sha256: props.archive.sha256,
|
|
110
124
|
size: props.archive.size,
|
|
111
125
|
url: releaseUrl(props.archive.repo, props.archive.tag, props.archive.asset),
|
|
126
|
+
// ⚠️ exactOptionalPropertyTypes: never spell out `root: undefined`; leave the key off entirely.
|
|
127
|
+
...(props.archive.root === undefined ? {} : { root: props.archive.root }),
|
|
112
128
|
});
|
|
113
129
|
|
|
114
130
|
export const modeProblems = (mode: number): string[] => {
|
|
@@ -123,83 +139,15 @@ export const modeProblems = (mode: number): string[] => {
|
|
|
123
139
|
return found;
|
|
124
140
|
};
|
|
125
141
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
const SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
131
|
-
// ★ ONLY WHAT THE READER HANDLES: a gzipped tar. A `.zip` or a bare binary is a new capability
|
|
132
|
-
// with its own walk-down, not a name this should accept and then fail to unpack.
|
|
133
|
-
const TARBALL = /\.(tar\.gz|tgz)$/;
|
|
134
|
-
|
|
135
|
-
/** A single path segment that cannot climb, hide or name the directory itself. */
|
|
136
|
-
const segmentProblem = (what: string, value: string): string | undefined =>
|
|
137
|
-
!SEGMENT.test(value) || value === '.' || value === '..'
|
|
138
|
-
? `${what} must be one path segment of letters, digits, '.', '_' or '-': ${JSON.stringify(value)}`
|
|
139
|
-
: undefined;
|
|
140
|
-
|
|
141
|
-
/** Everything wrong with the pins alone — plain strings in any declaration, resolved or not. */
|
|
142
|
-
export const pinProblems = (props: {
|
|
143
|
-
readonly archive?: Partial<ReleaseArchive>;
|
|
144
|
-
readonly member?: unknown;
|
|
145
|
-
readonly sha256?: unknown;
|
|
146
|
-
readonly name?: unknown;
|
|
147
|
-
}): string[] => {
|
|
148
|
-
const archive = props.archive ?? {};
|
|
149
|
-
const found: string[] = [];
|
|
150
|
-
const text = (what: string, value: unknown): string | undefined => {
|
|
151
|
-
if (typeof value === 'string') return value;
|
|
152
|
-
found.push(`${what} is required, as a plain string`);
|
|
153
|
-
return undefined;
|
|
154
|
-
};
|
|
155
|
-
const repo = text('archive.repo', archive.repo);
|
|
156
|
-
// ⚠️ `owner/..` passes the alphabet, and a URL parser normalises it away: the request would go
|
|
157
|
-
// to `github.com/releases/…`, somewhere nobody pinned. Refused by name.
|
|
158
|
-
const repoName = repo?.slice(repo.indexOf('/') + 1);
|
|
159
|
-
if (repo !== undefined && (!REPO.test(repo) || repoName === '.' || repoName === '..'))
|
|
160
|
-
found.push(`archive.repo must be "owner/name": ${JSON.stringify(repo)}`);
|
|
161
|
-
for (const [what, value] of [
|
|
162
|
-
['archive.tag', archive.tag],
|
|
163
|
-
['name', props.name],
|
|
164
|
-
] as const) {
|
|
165
|
-
const given = text(what, value);
|
|
166
|
-
const problem = given === undefined ? undefined : segmentProblem(what, given);
|
|
167
|
-
if (problem !== undefined) found.push(problem);
|
|
168
|
-
}
|
|
169
|
-
const asset = text('archive.asset', archive.asset);
|
|
170
|
-
if (asset !== undefined) {
|
|
171
|
-
const problem = segmentProblem('archive.asset', asset);
|
|
172
|
-
if (problem !== undefined) found.push(problem);
|
|
173
|
-
else if (!TARBALL.test(asset))
|
|
174
|
-
found.push(`archive.asset must be a .tar.gz or .tgz: "${asset}"`);
|
|
175
|
-
}
|
|
176
|
-
// ⛔ THE SIZE IS ALLOCATED UP FRONT (download.ts fills one buffer of exactly this many bytes), so
|
|
177
|
-
// a typo'd size is a memory bomb in the deploying process. vmutils, the largest archive pinned,
|
|
178
|
-
// is 123.6 MB; a gibibyte is a ceiling nothing single-binary should reach.
|
|
179
|
-
const size = archive.size;
|
|
180
|
-
if (typeof size !== 'number' || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ARCHIVE) {
|
|
181
|
-
found.push(`archive.size must be the asset's byte count, 1 to ${String(MAX_ARCHIVE)}`);
|
|
182
|
-
}
|
|
183
|
-
for (const [what, value] of [
|
|
184
|
-
['archive.sha256', archive.sha256],
|
|
185
|
-
['sha256', props.sha256],
|
|
186
|
-
] as const) {
|
|
187
|
-
const given = text(what, value);
|
|
188
|
-
if (given !== undefined && !HEX64.test(given))
|
|
189
|
-
found.push(`${what} must be 64 lower-case hex digits`);
|
|
190
|
-
}
|
|
191
|
-
const member = text('member', props.member);
|
|
192
|
-
// ⛔ The tar reader refuses these entries anyway; asking for one is a mistake to name at plan.
|
|
193
|
-
if (
|
|
194
|
-
member !== undefined &&
|
|
195
|
-
(member === '' || member.startsWith('/') || member.split('/').includes('..'))
|
|
196
|
-
) {
|
|
197
|
-
found.push(`member must be a relative name inside the archive: ${JSON.stringify(member)}`);
|
|
198
|
-
}
|
|
199
|
-
return found;
|
|
200
|
-
};
|
|
142
|
+
// `pinProblems` (imported above, for `binaryProblems` below) and the constant it needs,
|
|
143
|
+
// `MAX_ARCHIVE`, moved to binary-pins.ts, split out to stay under the file-size cap; re-exported
|
|
144
|
+
// here so every importer of './binary-form.ts' is unchanged.
|
|
145
|
+
export { MAX_ARCHIVE, pinProblems } from './binary-pins.ts';
|
|
201
146
|
|
|
202
|
-
|
|
147
|
+
// ★ 'root' JOINED THIS LIST 2026-09-23. A state row from before this unit has no `root` at all
|
|
148
|
+
// (`undefined`), and `undefined !== undefined` is false, so an old row with no root and a new
|
|
149
|
+
// declaration that still has none does not move; declaring — or changing — a root does.
|
|
150
|
+
const ARCHIVE_KEYS = ['repo', 'tag', 'asset', 'size', 'sha256', 'root'] as const;
|
|
203
151
|
|
|
204
152
|
type Pins = Pick<ReleaseBinaryProps, 'archive' | 'member' | 'sha256'>;
|
|
205
153
|
|
|
@@ -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
|
+
});
|