@homeflare/alchemy 0.20.0 → 0.22.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 +27 -0
- package/dist/caddy/access-forward-auth.d.ts +56 -0
- package/dist/caddy/access-forward-auth.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +6 -0
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +169 -75
- package/dist/caddy/index.js.map +10 -7
- package/dist/cloudflare/mcp-server-api.d.ts +135 -0
- package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
- package/dist/cloudflare/mcp-server-form.d.ts +116 -0
- package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/file-converge.d.ts +70 -0
- package/dist/launchd/file-converge.d.ts.map +1 -0
- package/dist/launchd/file-identity.d.ts +32 -0
- package/dist/launchd/file-identity.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +2 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +11 -24
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +225 -120
- package/dist/launchd/index.js.map +13 -10
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/port-claims.d.ts +31 -0
- package/dist/launchd/port-claims.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +17 -2
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +11 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/index.js.map +3 -3
- package/dist/linux/sudo-listing.d.ts +47 -0
- package/dist/linux/sudo-listing.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +1 -1
- package/dist/proxmox/credential-literals.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +7 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +7 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +1 -1
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +2 -2
- package/dist/proxmox/index.js.map +9 -9
- package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
- package/dist/release/archive.d.ts +11 -0
- package/dist/release/archive.d.ts.map +1 -0
- package/dist/release/binary-claim.d.ts +53 -0
- package/dist/release/binary-claim.d.ts.map +1 -0
- package/dist/release/binary-diff.d.ts +38 -0
- package/dist/release/binary-diff.d.ts.map +1 -0
- package/dist/release/binary-form.d.ts +118 -0
- package/dist/release/binary-form.d.ts.map +1 -0
- package/dist/release/binary-lifecycle.d.ts +55 -0
- package/dist/release/binary-lifecycle.d.ts.map +1 -0
- package/dist/release/binary-read.d.ts +11 -0
- package/dist/release/binary-read.d.ts.map +1 -0
- package/dist/release/binary.d.ts +68 -0
- package/dist/release/binary.d.ts.map +1 -0
- package/dist/release/catalog.d.ts +113 -0
- package/dist/release/catalog.d.ts.map +1 -0
- package/dist/release/checksums.d.ts +22 -0
- package/dist/release/checksums.d.ts.map +1 -0
- package/dist/release/declared-pins.d.ts +4 -0
- package/dist/release/declared-pins.d.ts.map +1 -0
- package/dist/release/download.d.ts +44 -0
- package/dist/release/download.d.ts.map +1 -0
- package/dist/release/fake-deploy.d.ts +48 -0
- package/dist/release/fake-deploy.d.ts.map +1 -0
- package/dist/release/fake-engine.d.ts +8 -0
- package/dist/release/fake-engine.d.ts.map +1 -0
- package/dist/release/fake-install.d.ts +39 -0
- package/dist/release/fake-install.d.ts.map +1 -0
- package/dist/release/fake-provider.d.ts +58 -0
- package/dist/release/fake-provider.d.ts.map +1 -0
- package/dist/release/fake-release.d.ts +95 -0
- package/dist/release/fake-release.d.ts.map +1 -0
- package/dist/release/index.d.ts +25 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +1425 -0
- package/dist/release/index.js.map +36 -0
- package/dist/release/openbao.d.ts +35 -0
- package/dist/release/openbao.d.ts.map +1 -0
- package/dist/release/providers.d.ts +19 -0
- package/dist/release/providers.d.ts.map +1 -0
- package/dist/release/refused.d.ts +39 -0
- package/dist/release/refused.d.ts.map +1 -0
- package/dist/release/tar.d.ts +14 -0
- package/dist/release/tar.d.ts.map +1 -0
- package/dist/release/victoria.d.ts +36 -0
- package/dist/release/victoria.d.ts.map +1 -0
- package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
- package/docs/launchd-ports.md +109 -0
- package/docs/launchd.md +5 -0
- package/docs/release-binary-catalogs.md +138 -0
- package/docs/release-binary-measured.md +163 -0
- package/docs/release-binary-openbao.md +151 -0
- package/docs/release-binary-refusals.md +57 -0
- package/docs/release-binary-upstream.md +163 -0
- package/docs/release-binary.md +165 -0
- package/package.json +5 -1
- package/src/caddy/access-forward-auth.test.ts +63 -0
- package/src/caddy/access-forward-auth.ts +120 -0
- package/src/caddy/index.ts +10 -0
- package/src/caddy/providers.test.ts +4 -0
- package/src/cloudflare/mcp-server-api.ts +294 -0
- package/src/cloudflare/mcp-server-form.ts +219 -0
- package/src/index.ts +1 -0
- package/src/launchd/file-converge.ts +233 -0
- package/src/launchd/file-identity.ts +69 -0
- package/src/launchd/host-file-alias.test.ts +65 -0
- package/src/launchd/host-file-form.ts +2 -1
- package/src/launchd/host-file-lifecycle.ts +25 -144
- package/src/launchd/host-file.ts +8 -2
- package/src/launchd/index.ts +2 -0
- package/src/launchd/local-runner.test.ts +12 -1
- package/src/launchd/local-runner.ts +2 -0
- package/src/launchd/port-claims.test.ts +124 -0
- package/src/launchd/port-claims.ts +102 -0
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/runner.ts +17 -2
- package/src/launchd/sudo-runner.ts +11 -0
- package/src/linux/sudo-listing.ts +122 -0
- package/src/proxmox/credential-literals.ts +1 -1
- package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
- package/src/proxmox/generated/pbs.ts +7 -0
- package/src/proxmox/generated/pve.ts +7 -0
- package/src/proxmox/index.ts +1 -1
- package/src/proxmox/pbs-notification-target-form.ts +1 -1
- package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
- package/src/proxmox/pbs-notification-target-wire.ts +1 -1
- package/src/proxmox/pbs-notification-target.ts +1 -1
- package/src/release/adopt-parity.test.ts +106 -0
- package/src/release/apply-pins.test.ts +128 -0
- package/src/release/archive.ts +94 -0
- package/src/release/binary-alias.test.ts +109 -0
- package/src/release/binary-claim.ts +80 -0
- package/src/release/binary-claims.test.ts +47 -0
- package/src/release/binary-diff.ts +73 -0
- package/src/release/binary-form.ts +229 -0
- package/src/release/binary-lifecycle.test.ts +90 -0
- package/src/release/binary-lifecycle.ts +202 -0
- package/src/release/binary-read.ts +55 -0
- package/src/release/binary-refusals.test.ts +192 -0
- package/src/release/binary-sudo.test.ts +93 -0
- package/src/release/binary.ts +181 -0
- package/src/release/catalog.test.ts +167 -0
- package/src/release/catalog.ts +238 -0
- package/src/release/checksums.test.ts +35 -0
- package/src/release/checksums.ts +43 -0
- package/src/release/declared-pins.ts +39 -0
- package/src/release/download.test.ts +138 -0
- package/src/release/download.ts +110 -0
- package/src/release/error-channel.test.ts +69 -0
- package/src/release/fake-deploy.ts +87 -0
- package/src/release/fake-engine.ts +50 -0
- package/src/release/fake-install.ts +59 -0
- package/src/release/fake-provider.ts +68 -0
- package/src/release/fake-release.ts +185 -0
- package/src/release/fixtures/openbao/checksums.txt +83 -0
- package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
- package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
- package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
- package/src/release/index.ts +44 -0
- package/src/release/openbao.test.ts +120 -0
- package/src/release/openbao.ts +86 -0
- package/src/release/plan.test.ts +189 -0
- package/src/release/provider-refusals.test.ts +48 -0
- package/src/release/providers.test.ts +175 -0
- package/src/release/providers.ts +24 -0
- package/src/release/recovery-read.test.ts +44 -0
- package/src/release/refused.ts +40 -0
- package/src/release/tar.test.ts +142 -0
- package/src/release/tar.ts +195 -0
- package/src/release/victoria.test.ts +108 -0
- package/src/release/victoria.ts +154 -0
- package/src/{proxmox → secrets}/write-only.ts +6 -0
- /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
- /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Alchemy wiring: the provider built as a Layer over a fake host and a fake `HttpClient`, its
|
|
3
|
+
* handlers called the way the engine calls them — plus the barrel, the subpath's public API.
|
|
4
|
+
*
|
|
5
|
+
* ★ `read` with no prior state is the adoption probe. A binary already at the path comes back
|
|
6
|
+
* `Unowned`, recognised by its digest; after `--adopt` the forced update finds it matching and
|
|
7
|
+
* neither downloads nor writes. The plan-time refusals: provider-refusals.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { Unowned, stripUnowned } from 'alchemy/AdoptPolicy';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import { ReleaseBinary } from './binary.ts';
|
|
13
|
+
import {
|
|
14
|
+
BINARY,
|
|
15
|
+
VMUTILS_DIR,
|
|
16
|
+
VMUTILS_URL,
|
|
17
|
+
pathsOn,
|
|
18
|
+
releaseHost,
|
|
19
|
+
syntheticRelease,
|
|
20
|
+
vmalertProps,
|
|
21
|
+
} from './fake-release.ts';
|
|
22
|
+
import { PATH, call, harness, ids, must } from './fake-provider.ts';
|
|
23
|
+
import * as barrel from './index.ts';
|
|
24
|
+
import { releaseProviders } from './providers.ts';
|
|
25
|
+
|
|
26
|
+
describe('the lifecycle through the engine-facing handlers', () => {
|
|
27
|
+
test('reconcile installs, noting progress; diff is then a noop, a mode change an update', async () => {
|
|
28
|
+
const h = harness();
|
|
29
|
+
const [output, same, mode] = await h.run((p) =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const output = yield* h.reconcile(p, h.VMALERT);
|
|
32
|
+
const diff = must(p.diff);
|
|
33
|
+
const base = { ...ids, ...call, olds: h.VMALERT as never, output };
|
|
34
|
+
return [
|
|
35
|
+
output,
|
|
36
|
+
yield* diff({ ...base, news: h.VMALERT as never }),
|
|
37
|
+
yield* diff({ ...base, news: { ...h.VMALERT, mode: 0o555 } as never }),
|
|
38
|
+
] as const;
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
expect(output.sha256).toBe(new Bun.CryptoHasher('sha256').update(BINARY.vmalert).digest('hex'));
|
|
42
|
+
expect([same, mode]).toEqual([{ action: 'noop' }, { action: 'update' }]);
|
|
43
|
+
expect(h.notes.some((n) => n.startsWith('downloading '))).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('while the directory is an Output: other bytes are a replace, the same bytes an update', async () => {
|
|
47
|
+
const h = harness();
|
|
48
|
+
const diffs = await h.run((p) =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const output = yield* h.reconcile(p, h.VMALERT);
|
|
51
|
+
const base = { ...ids, ...call, olds: h.VMALERT as never, output };
|
|
52
|
+
const pending = { ...h.VMALERT, directory: Effect.succeed(`${VMUTILS_DIR}-next`) };
|
|
53
|
+
const archive = { ...h.VMALERT.archive, tag: 'v1.152.0' };
|
|
54
|
+
const diff = (news: object) => must(p.diff)({ ...base, news: news as never });
|
|
55
|
+
return [
|
|
56
|
+
yield* diff({ ...pending, archive, sha256: 'e'.repeat(64) }),
|
|
57
|
+
yield* diff({ ...pending, archive }),
|
|
58
|
+
yield* diff(pending),
|
|
59
|
+
];
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
// ★ Never `replace` for the same bytes: onto an unmoved path, the new generation would accept
|
|
63
|
+
// the old file as its own and Phase 2 would then delete it (binary-diff.ts).
|
|
64
|
+
expect(diffs).toEqual([{ action: 'replace' }, { action: 'update' }, undefined]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('that update, if the path did not move after all, is refused at apply', async () => {
|
|
68
|
+
const h = harness();
|
|
69
|
+
const output = await h.run((p) => h.reconcile(p, h.VMALERT));
|
|
70
|
+
h.requests.length = 0;
|
|
71
|
+
const repinned = { ...h.VMALERT, archive: { ...h.VMALERT.archive, tag: 'v1.152.0' } };
|
|
72
|
+
const applying = h.run((p) => h.reconcile(p, repinned, output, h.VMALERT));
|
|
73
|
+
await expect(applying).rejects.toThrow('would overwrite it in place');
|
|
74
|
+
expect(h.requests).toEqual([]);
|
|
75
|
+
expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a binary someone already placed: Unowned by digest, then adopted with no download', async () => {
|
|
79
|
+
const h = harness();
|
|
80
|
+
h.fake.files.set(PATH, { bytes: BINARY.vmalert, gid: 0, kind: 'file', mode: 0o755, uid: 0 });
|
|
81
|
+
const before = pathsOn(h.fake);
|
|
82
|
+
const [probe, adopted] = await h.run((p) =>
|
|
83
|
+
Effect.gen(function* () {
|
|
84
|
+
const probe = yield* must(p.read)({ ...ids, olds: h.VMALERT as never, output: undefined });
|
|
85
|
+
if (probe === undefined) throw new Error('nothing found');
|
|
86
|
+
// What the engine does after `--adopt`: strip the brand, then force an update.
|
|
87
|
+
const adopted = yield* h.reconcile(p, h.VMALERT, stripUnowned(probe));
|
|
88
|
+
return [probe, adopted] as const;
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
expect(Unowned.is(probe)).toBe(true);
|
|
92
|
+
// ★ Recognised by its digest alone: the probe's sha256 IS the pin; nothing was executed.
|
|
93
|
+
expect(probe.sha256).toBe(h.VMALERT.sha256);
|
|
94
|
+
expect(adopted).toMatchObject({
|
|
95
|
+
member: 'vmalert-prod',
|
|
96
|
+
path: PATH,
|
|
97
|
+
sha256: h.VMALERT.sha256,
|
|
98
|
+
url: VMUTILS_URL,
|
|
99
|
+
});
|
|
100
|
+
expect(h.requests).toEqual([]);
|
|
101
|
+
expect(h.fake.calls.filter((c) => c[0] === 'write')).toEqual([]);
|
|
102
|
+
expect(pathsOn(h.fake)).toEqual(before);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('vmagent and vmalert at once: ONE download of the shared archive', async () => {
|
|
106
|
+
const h = harness();
|
|
107
|
+
const vmagent = {
|
|
108
|
+
...h.VMALERT,
|
|
109
|
+
member: 'vmagent-prod',
|
|
110
|
+
name: 'vmagent',
|
|
111
|
+
sha256: new Bun.CryptoHasher('sha256').update(BINARY.vmagent).digest('hex'),
|
|
112
|
+
};
|
|
113
|
+
await h.run((p) =>
|
|
114
|
+
Effect.all([h.reconcile(p, h.VMALERT), h.reconcile(p, vmagent)], {
|
|
115
|
+
concurrency: 'unbounded',
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
expect(h.requests).toEqual([VMUTILS_URL]);
|
|
119
|
+
expect(pathsOn(h.fake)).toEqual([`${VMUTILS_DIR}/vmagent`, PATH]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('delete removes the binary and never the directory', async () => {
|
|
123
|
+
const h = harness();
|
|
124
|
+
await h.run((p) =>
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const output = yield* h.reconcile(p, h.VMALERT);
|
|
127
|
+
yield* p.delete({
|
|
128
|
+
...ids,
|
|
129
|
+
bindings: [] as never,
|
|
130
|
+
olds: h.VMALERT as never,
|
|
131
|
+
output,
|
|
132
|
+
session: undefined as never,
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
expect(h.fake.files.has(PATH)).toBe(false);
|
|
137
|
+
expect(h.fake.dirs.has(VMUTILS_DIR)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('the subpath', () => {
|
|
142
|
+
test('releaseProviders builds with its own HTTP client, and a probe of nothing is nothing', async () => {
|
|
143
|
+
const fake = releaseHost();
|
|
144
|
+
const VMALERT = vmalertProps(syntheticRelease().catalog);
|
|
145
|
+
const found = await Effect.runPromise(
|
|
146
|
+
Effect.gen(function* () {
|
|
147
|
+
const p = yield* ReleaseBinary.Provider;
|
|
148
|
+
return yield* must(p.read)({ ...ids, olds: VMALERT as never, output: undefined });
|
|
149
|
+
}).pipe(Effect.provide(releaseProviders(fake.runner))),
|
|
150
|
+
);
|
|
151
|
+
expect(found).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the barrel is exactly the public API', () => {
|
|
155
|
+
expect(Object.keys(barrel).sort()).toEqual([
|
|
156
|
+
'ArchiveRefused',
|
|
157
|
+
'BinaryRefused',
|
|
158
|
+
'ChecksumMismatch',
|
|
159
|
+
'DownloadFailed',
|
|
160
|
+
'HostRunnerService',
|
|
161
|
+
'OPENBAO_RELEASES',
|
|
162
|
+
'ReleaseBinary',
|
|
163
|
+
'ReleaseBinaryProvider',
|
|
164
|
+
'VICTORIA_RELEASES',
|
|
165
|
+
'catalogBinary',
|
|
166
|
+
'catalogDirectory',
|
|
167
|
+
'catalogProblems',
|
|
168
|
+
'hostRunnerLayer',
|
|
169
|
+
'identifyBinary',
|
|
170
|
+
'releaseBinaryPath',
|
|
171
|
+
'releaseProviders',
|
|
172
|
+
'releaseUrl',
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release-binary provider with ONE HostRunner and its own HTTP client, as a single layer for a
|
|
3
|
+
* host stack:
|
|
4
|
+
*
|
|
5
|
+
* const runner = localRunner();
|
|
6
|
+
* Layer.mergeAll(launchdProviders(runner), releaseProviders(runner), …the stack's others)
|
|
7
|
+
*
|
|
8
|
+
* ★ PASS THE SAME RUNNER THE JOBS USE, so the binary and the daemon that runs it are written by one
|
|
9
|
+
* identity on one host — a stack cannot install as one user and bootstrap as another by accident.
|
|
10
|
+
* ⛔ THERE IS NO DEFAULT RUNNER. A binary is written wherever the runner reaches; a silent default to
|
|
11
|
+
* "this machine" would install a Mac's daemons onto whichever laptop ran the deploy (the same
|
|
12
|
+
* reasoning as linuxProviders()).
|
|
13
|
+
* ★ `FetchHttpClient.layer` IS PROVIDED HERE, not left to the stack: the Mac host stack this exists
|
|
14
|
+
* for provides no HTTP layer, and a missing one fails only at the first install.
|
|
15
|
+
*/
|
|
16
|
+
import * as Layer from 'effect/Layer';
|
|
17
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
18
|
+
import { type HostRunner, hostRunnerLayer } from '../launchd/runner.ts';
|
|
19
|
+
import { ReleaseBinaryProvider } from './binary.ts';
|
|
20
|
+
|
|
21
|
+
export const releaseProviders = (runner: HostRunner) =>
|
|
22
|
+
ReleaseBinaryProvider().pipe(
|
|
23
|
+
Layer.provide(Layer.mergeAll(hostRunnerLayer(runner), FetchHttpClient.layer)),
|
|
24
|
+
);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A FAILED FIRST INSTALL MUST NOT BRICK THE STAGE (binary-read.ts), through Alchemy's own Plan and
|
|
3
|
+
* Apply. With `directory: dir.path`, the documented shape, Apply commits the `creating` row while
|
|
4
|
+
* the directory is still an Output, so the row carries no `directory`; the next plan's recovery
|
|
5
|
+
* read — and, once the declaration is gone, Apply's orphan delete — is asked with that row.
|
|
6
|
+
*
|
|
7
|
+
* 🔴 MEASURED 2026-09-22 against the code before binary-read.ts, every case below: the next deploy
|
|
8
|
+
* died with `TypeError: undefined is not an object (evaluating 'path.startsWith')`, the fixed
|
|
9
|
+
* declaration included, and removing the declaration failed with `DestroyError`.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { BINARY, VMUTILS_URL } from './fake-release.ts';
|
|
13
|
+
import { DIR, PATH, deployHarness } from './fake-deploy.ts';
|
|
14
|
+
|
|
15
|
+
describe('after a failed first install', () => {
|
|
16
|
+
test('a download that failed: the next deploy installs, then is a noop', async () => {
|
|
17
|
+
const h = deployHarness(false);
|
|
18
|
+
h.server.serving = false;
|
|
19
|
+
await expect(h.stack.deploy(h.wired())).rejects.toThrow('HTTP 404');
|
|
20
|
+
h.server.serving = true;
|
|
21
|
+
h.events.length = 0;
|
|
22
|
+
expect(await h.stack.deploy(h.wired())).toEqual({ dir: 'noop', vmalert: 'create' });
|
|
23
|
+
expect(h.events).toEqual([`GET ${VMUTILS_URL}`, `write ${PATH}`]);
|
|
24
|
+
expect(h.fake.files.get(PATH)?.bytes).toEqual(BINARY.vmalert);
|
|
25
|
+
expect(await h.stack.deploy(h.wired())).toEqual({ dir: 'noop', vmalert: 'noop' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a declaration refused at apply (a group-writable mode), then fixed: it installs', async () => {
|
|
29
|
+
const h = deployHarness(false);
|
|
30
|
+
await expect(
|
|
31
|
+
h.stack.deploy(h.wired('vmalert', 0o755, undefined, { mode: 0o775 })),
|
|
32
|
+
).rejects.toThrow('mode must not be group- or world-writable');
|
|
33
|
+
expect(await h.stack.deploy(h.wired())).toEqual({ dir: 'noop', vmalert: 'create' });
|
|
34
|
+
expect(h.fake.files.get(PATH)?.mode).toBe(0o755);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the failed declaration removed instead: its row is dropped, the directory kept', async () => {
|
|
38
|
+
const h = deployHarness(false);
|
|
39
|
+
h.server.serving = false;
|
|
40
|
+
await expect(h.stack.deploy(h.wired())).rejects.toThrow('HTTP 404');
|
|
41
|
+
expect(await h.stack.deploy(h.dirOnly)).toEqual({ dir: 'noop', vmalert: 'delete' });
|
|
42
|
+
expect(h.fake.dirs.has(DIR)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release.Binary's failures as tagged errors — each carrying the house refusal sentence
|
|
3
|
+
* (`Release.Binary <path>: …`) as its `message`.
|
|
4
|
+
*
|
|
5
|
+
* ★ TAGGED BECAUSE ALCHEMY UPSTREAM REQUIRES IT (its AGENTS.md at v2.0.0-beta.79: Data.TaggedError,
|
|
6
|
+
* `catchTag`, never a cast on `_tag`), so the classes need no rewrite on the way there. The
|
|
7
|
+
* MESSAGE stays the kit's wording, so a deploy log reads the same as every other house family's.
|
|
8
|
+
* ⚠️ TAGGED IS NOT YET TYPED END TO END — measured in error-channel.test.ts. catalogBinary() throws,
|
|
9
|
+
* so in a stack program its BinaryRefused is a DEFECT that `catchTag` never sees; and the provider
|
|
10
|
+
* runs every lifecycle call through the house `lift()`, which keeps the instance at runtime but
|
|
11
|
+
* types the channel as `Error`. No caller can name one of these tags today without a cast.
|
|
12
|
+
* ★ THE TAGS NAME NO VENDOR. Downloading a pinned archive, refusing an unsafe one and comparing
|
|
13
|
+
* digests are the same for every catalog (docs/release-binary-upstream.md).
|
|
14
|
+
*/
|
|
15
|
+
import * as Data from 'effect/Data';
|
|
16
|
+
|
|
17
|
+
/** The declaration itself: a version no catalog pins, a malformed pin, a bad path or mode. */
|
|
18
|
+
export class BinaryRefused extends Data.TaggedError('BinaryRefused')<{
|
|
19
|
+
readonly message: string;
|
|
20
|
+
}> {}
|
|
21
|
+
|
|
22
|
+
/** The download: a status that is not 200, a body that is not the pinned size, a dead transport. */
|
|
23
|
+
export class DownloadFailed extends Data.TaggedError('DownloadFailed')<{
|
|
24
|
+
readonly message: string;
|
|
25
|
+
/** Worth another attempt: a transport failure or a 5xx, never a wrong size or a 404. */
|
|
26
|
+
readonly retryable: boolean;
|
|
27
|
+
}> {}
|
|
28
|
+
|
|
29
|
+
/** Bytes that do not hash to their pin — the archive, or a member extracted from it. */
|
|
30
|
+
export class ChecksumMismatch extends Data.TaggedError('ChecksumMismatch')<{
|
|
31
|
+
readonly message: string;
|
|
32
|
+
readonly subject: string;
|
|
33
|
+
readonly expected: string;
|
|
34
|
+
readonly actual: string;
|
|
35
|
+
}> {}
|
|
36
|
+
|
|
37
|
+
/** An archive this reader will not trust: an unsafe or unexpected entry, a missing member. */
|
|
38
|
+
export class ArchiveRefused extends Data.TaggedError('ArchiveRefused')<{
|
|
39
|
+
readonly message: string;
|
|
40
|
+
}> {}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tar reader against archives built here, byte by byte: it hands back only what was asked for,
|
|
3
|
+
* by exact name, and refuses the WHOLE archive for any entry that could make a name lie — whether
|
|
4
|
+
* or not that entry was the one asked for.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { extractMembers } from './archive.ts';
|
|
8
|
+
import { type TarEntry, bytesOf, gzip, tarHeader, tarOf } from './fake-release.ts';
|
|
9
|
+
import { ArchiveRefused } from './refused.ts';
|
|
10
|
+
import { tarReader } from './tar.ts';
|
|
11
|
+
|
|
12
|
+
const binary = { bytes: bytesOf('#!binary\n'), name: 'vmalert-prod' } as const;
|
|
13
|
+
const read = (tar: Uint8Array, wanted = ['vmalert-prod'], chunk = tar.length) => {
|
|
14
|
+
const reader = tarReader(new Set(wanted));
|
|
15
|
+
for (let at = 0; at < tar.length; at += chunk) reader.push(tar.subarray(at, at + chunk));
|
|
16
|
+
return reader.finish();
|
|
17
|
+
};
|
|
18
|
+
const refusal = (run: () => unknown): string => {
|
|
19
|
+
try {
|
|
20
|
+
run();
|
|
21
|
+
} catch (cause) {
|
|
22
|
+
expect(cause).toBeInstanceOf(ArchiveRefused);
|
|
23
|
+
return (cause as ArchiveRefused).message;
|
|
24
|
+
}
|
|
25
|
+
throw new Error('expected a refusal');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('reading', () => {
|
|
29
|
+
test('only the asked-for member comes back; every name is listed', () => {
|
|
30
|
+
const tar = tarOf([{ bytes: bytesOf('x'.repeat(700)), name: 'vmagent-prod' }, binary]);
|
|
31
|
+
const { members, names } = read(tar);
|
|
32
|
+
expect([...members.keys()]).toEqual(['vmalert-prod']);
|
|
33
|
+
expect(new TextDecoder().decode(members.get('vmalert-prod'))).toBe('#!binary\n');
|
|
34
|
+
expect(names).toEqual(['vmagent-prod', 'vmalert-prod']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('one byte at a time reads the same as all at once', () => {
|
|
38
|
+
const tar = tarOf([{ bytes: bytesOf('y'.repeat(1025)), name: 'vmagent-prod' }, binary]);
|
|
39
|
+
expect(read(tar, ['vmagent-prod', 'vmalert-prod'], 1).members).toEqual(
|
|
40
|
+
read(tar, ['vmagent-prod', 'vmalert-prod']).members,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('matching is exact: no ./, no prefix, no suffix', () => {
|
|
45
|
+
const tar = tarOf([
|
|
46
|
+
{ ...binary, name: './vmalert-prod' },
|
|
47
|
+
{ ...binary, name: 'vmalert-prod-x' },
|
|
48
|
+
]);
|
|
49
|
+
expect(read(tar).members.size).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a POSIX prefix is part of the name', () => {
|
|
53
|
+
const tar = tarOf([{ ...binary, format: 'posix', prefix: 'bin' }]);
|
|
54
|
+
expect(read(tar, ['bin/vmalert-prod']).members.size).toBe(1);
|
|
55
|
+
expect(read(tar).members.size).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('an empty member and trailing zero padding are fine', () => {
|
|
59
|
+
const tar = tarOf([{ name: 'empty' }, binary], 10_240);
|
|
60
|
+
expect(read(tar, ['empty']).members.get('empty')?.length).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('extractMembers gunzips as it reads', async () => {
|
|
64
|
+
const { members } = await extractMembers(gzip(tarOf([binary])), new Set(['vmalert-prod']));
|
|
65
|
+
expect(members.get('vmalert-prod')).toEqual(binary.bytes);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('tar-slip and every other entry that could lie is refused, asked for or not', () => {
|
|
70
|
+
const beside = (entry: TarEntry) => tarOf([binary, entry]);
|
|
71
|
+
test.each([
|
|
72
|
+
['an absolute name', { name: '/etc/passwd' }, 'is absolute'],
|
|
73
|
+
['a .. segment', { name: '../vmalert-prod' }, 'climbs out'],
|
|
74
|
+
['a .. deeper in', { name: 'bin/../../x' }, 'climbs out'],
|
|
75
|
+
['a symlink', { name: 'link', type: '2' }, 'a symlink'],
|
|
76
|
+
['a hard link', { name: 'hard', type: '1' }, 'a hard link'],
|
|
77
|
+
['a directory', { name: 'bin/', type: '5' }, 'a directory'],
|
|
78
|
+
['a FIFO', { name: 'fifo', type: '6' }, 'a FIFO'],
|
|
79
|
+
['a GNU long name', { name: '././@LongLink', type: 'L' }, 'GNU long-name'],
|
|
80
|
+
['a PAX header', { name: 'PaxHeader', type: 'x' }, 'PAX extended'],
|
|
81
|
+
['an unknown type', { name: 'odd', type: 'Z' }, 'unknown type'],
|
|
82
|
+
['a repeated name', binary, 'appears twice'],
|
|
83
|
+
['a failed header checksum', { corruptChecksum: true, name: 'x' }, 'fails its checksum'],
|
|
84
|
+
['a base-256 size', { base256: true, bytes: bytesOf('z'), name: 'big' }, 'base-256'],
|
|
85
|
+
['a character device', { name: 'tty', type: '3' }, 'a character device'],
|
|
86
|
+
['a block device', { name: 'disk', type: '4' }, 'a block device'],
|
|
87
|
+
['a contiguous file', { name: 'contig', type: '7' }, 'a contiguous file'],
|
|
88
|
+
['a GNU long link', { name: '././@LongLink', type: 'K' }, 'GNU long-link'],
|
|
89
|
+
['a PAX global header', { name: 'pax_global_header', type: 'g' }, 'PAX global'],
|
|
90
|
+
['an empty name', { bytes: bytesOf('z'), name: '' }, 'empty name'],
|
|
91
|
+
] as const)('%s', (_, entry, message) => {
|
|
92
|
+
expect(refusal(() => read(beside(entry)))).toContain(message);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a name that is not UTF-8 is refused, not decoded loosely into another name', () => {
|
|
96
|
+
const tar = tarOf([binary, { bytes: bytesOf('z'), name: 'x' }]);
|
|
97
|
+
const at = 1024; // the second header: one header block and one data block in
|
|
98
|
+
tar[at] = 0xff;
|
|
99
|
+
tar.fill(0x20, at + 148, at + 156);
|
|
100
|
+
const sum = tar.subarray(at, at + 512).reduce((total, byte) => total + byte, 0);
|
|
101
|
+
tar.set(bytesOf(`${sum.toString(8).padStart(6, '0')}\0 `), at + 148);
|
|
102
|
+
expect(refusal(() => read(tar))).toContain('not UTF-8');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a symlink under the asked-for name is refused, not followed', () => {
|
|
106
|
+
expect(refusal(() => read(tarOf([{ name: 'vmalert-prod', type: '2' }])))).toContain('symlink');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a header that is neither ustar nor GNU (v7 tar) is refused', () => {
|
|
110
|
+
const tar = tarOf([binary]);
|
|
111
|
+
tar.fill(0, 257, 265);
|
|
112
|
+
const header = tarHeader(binary);
|
|
113
|
+
header.fill(0, 257, 265);
|
|
114
|
+
header.fill(0x20, 148, 156);
|
|
115
|
+
const sum = header.reduce((total, byte) => total + byte, 0);
|
|
116
|
+
header.set(new TextEncoder().encode(`${sum.toString(8).padStart(6, '0')}\0 `), 148);
|
|
117
|
+
tar.set(header, 0);
|
|
118
|
+
expect(refusal(() => read(tar))).toContain('neither ustar nor GNU');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a truncated archive — no end marker — is refused', () => {
|
|
122
|
+
const tar = tarOf([binary]);
|
|
123
|
+
expect(refusal(() => read(tar.subarray(0, tar.length - 1024)))).toContain('stops before');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a lone zero block between entries is refused', () => {
|
|
127
|
+
const one = tarOf([binary], 512);
|
|
128
|
+
const two = tarOf([{ ...binary, name: 'other' }]);
|
|
129
|
+
const spliced = new Uint8Array([...one, ...two]);
|
|
130
|
+
expect(refusal(() => read(spliced))).toContain('zero block');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('data after the end marker is refused', () => {
|
|
134
|
+
const tar = new Uint8Array([...tarOf([binary]), 1]);
|
|
135
|
+
expect(refusal(() => read(tar))).toContain('follows the end-of-archive');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('bytes that are not gzip are refused as an archive, not thrown raw', async () => {
|
|
139
|
+
const promise = extractMembers(bytesOf('not gzip at all'), new Set(['x']));
|
|
140
|
+
await expect(promise).rejects.toBeInstanceOf(ArchiveRefused);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tar reader with one job: hand back the NAMED regular files of a vendor archive, and refuse the
|
|
3
|
+
* whole archive if any entry could make a name mean something other than what it says.
|
|
4
|
+
*
|
|
5
|
+
* ★ IN PROCESS, NOT `tar` ON THE HOST. An exec'd tar writes through its own path — past the
|
|
6
|
+
* HostRunner seam, past sudoRunner's allowlist, and past the member digest check, which has to
|
|
7
|
+
* see the bytes BEFORE they exist on any disk. Nothing this file reads is ever written by it.
|
|
8
|
+
* ★ STREAMING. It is fed the gunzip output chunk by chunk; an entry nobody asked for is counted
|
|
9
|
+
* past, never held. vmutils unpacks to 264 MB across seven binaries (measured 2026-09-22) and a
|
|
10
|
+
* stack wants two, so the unpacked archive never exists whole in memory.
|
|
11
|
+
* ⛔ REFUSED, WHOLE ARCHIVE, whichever entry it is and whether or not it was asked for:
|
|
12
|
+
* an absolute name; a `..` segment; a hard link, symlink, directory, device, FIFO or contiguous
|
|
13
|
+
* file (only a regular file is accepted); a name given twice; a header whose checksum fails; a
|
|
14
|
+
* size in GNU base-256; a header that is neither ustar nor GNU; a lone zero block between entries;
|
|
15
|
+
* non-zero bytes after the end marker; an archive that stops before it.
|
|
16
|
+
* ★ GNU LONG NAMES ('L', 'K') AND PAX HEADERS ('x', 'g') ARE REFUSED, NOT INTERPRETED. Each renames
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
import { ArchiveRefused } from './refused.ts';
|
|
24
|
+
|
|
25
|
+
const BLOCK = 512;
|
|
26
|
+
|
|
27
|
+
const KINDS: Readonly<Record<string, string>> = {
|
|
28
|
+
'1': 'a hard link',
|
|
29
|
+
'2': 'a symlink',
|
|
30
|
+
'3': 'a character device',
|
|
31
|
+
'4': 'a block device',
|
|
32
|
+
'5': 'a directory',
|
|
33
|
+
'6': 'a FIFO',
|
|
34
|
+
'7': 'a contiguous file',
|
|
35
|
+
K: 'a GNU long-link header',
|
|
36
|
+
L: 'a GNU long-name header',
|
|
37
|
+
g: 'a PAX global header',
|
|
38
|
+
x: 'a PAX extended header',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const refuse = (message: string): ArchiveRefused => new ArchiveRefused({ message });
|
|
42
|
+
const strict = new TextDecoder('utf-8', { fatal: true });
|
|
43
|
+
const ascii = new TextDecoder('latin1');
|
|
44
|
+
|
|
45
|
+
/** One NUL-terminated header field. */
|
|
46
|
+
const field = (block: Uint8Array, start: number, length: number): Uint8Array => {
|
|
47
|
+
const slice = block.subarray(start, start + length);
|
|
48
|
+
const end = slice.indexOf(0);
|
|
49
|
+
return end === -1 ? slice : slice.subarray(0, end);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const text = (block: Uint8Array, start: number, length: number): string => {
|
|
53
|
+
try {
|
|
54
|
+
return strict.decode(field(block, start, length));
|
|
55
|
+
} catch {
|
|
56
|
+
throw refuse('an entry name is not UTF-8');
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const octal = (block: Uint8Array, start: number, length: number, what: string): number => {
|
|
61
|
+
const raw = block.subarray(start, start + length);
|
|
62
|
+
if (((raw[0] ?? 0) & 0x80) !== 0) throw refuse(`${what} is base-256 encoded`);
|
|
63
|
+
const digits = ascii
|
|
64
|
+
.decode(raw)
|
|
65
|
+
.replace(/[\0 ]+$/, '')
|
|
66
|
+
.replace(/^ +/, '');
|
|
67
|
+
if (!/^[0-7]+$/.test(digits)) throw refuse(`${what} is not octal: ${JSON.stringify(digits)}`);
|
|
68
|
+
return Number.parseInt(digits, 8);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const checksumHolds = (block: Uint8Array): boolean => {
|
|
72
|
+
let sum = 0;
|
|
73
|
+
// The checksum field itself counts as eight spaces (POSIX ustar).
|
|
74
|
+
for (let at = 0; at < BLOCK; at += 1) sum += at >= 148 && at < 156 ? 0x20 : (block[at] ?? 0);
|
|
75
|
+
return sum === octal(block, 148, 8, 'a header checksum');
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Why an entry name is unsafe to trust, or undefined. */
|
|
79
|
+
const nameProblem = (name: string): string | undefined => {
|
|
80
|
+
if (name === '') return 'has an empty name';
|
|
81
|
+
if (name.startsWith('/')) return 'is absolute';
|
|
82
|
+
if (name.split('/').includes('..')) return 'climbs out of the archive with ".."';
|
|
83
|
+
return undefined;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Concatenate chunks into one buffer. */
|
|
87
|
+
export const concatBytes = (parts: readonly Uint8Array[]): Uint8Array => {
|
|
88
|
+
const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
89
|
+
let at = 0;
|
|
90
|
+
for (const part of parts) {
|
|
91
|
+
out.set(part, at);
|
|
92
|
+
at += part.length;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type TarContents = {
|
|
98
|
+
/** The asked-for regular files, by exact name. An asked-for name that is absent is absent here. */
|
|
99
|
+
readonly members: ReadonlyMap<string, Uint8Array>;
|
|
100
|
+
/** Every entry name, in archive order — so a refusal can say what the archive did hold. */
|
|
101
|
+
readonly names: readonly string[];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** A push parser: `push` each chunk of the unzipped stream, then `finish`. Either may throw. */
|
|
105
|
+
export const tarReader = (wanted: ReadonlySet<string>) => {
|
|
106
|
+
const header = new Uint8Array(BLOCK);
|
|
107
|
+
const members = new Map<string, Uint8Array>();
|
|
108
|
+
const names: string[] = [];
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
let phase: 'header' | 'data' | 'pad' | 'end' = 'header';
|
|
111
|
+
let filled = 0;
|
|
112
|
+
let remaining = 0;
|
|
113
|
+
let padding = 0;
|
|
114
|
+
let zeros = 0;
|
|
115
|
+
let name = '';
|
|
116
|
+
let parts: Uint8Array[] | undefined;
|
|
117
|
+
|
|
118
|
+
const endEntry = () => {
|
|
119
|
+
if (parts !== undefined) members.set(name, concatBytes(parts));
|
|
120
|
+
parts = undefined;
|
|
121
|
+
phase = padding > 0 ? 'pad' : 'header';
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const startEntry = () => {
|
|
125
|
+
if (header.every((byte) => byte === 0)) {
|
|
126
|
+
zeros += 1;
|
|
127
|
+
if (zeros === 2) phase = 'end';
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (zeros > 0) throw refuse('a zero block sits between entries (truncated or spliced)');
|
|
131
|
+
if (!checksumHolds(header)) throw refuse('an entry header fails its checksum');
|
|
132
|
+
const magic = ascii.decode(header.subarray(257, 265));
|
|
133
|
+
const posix = magic === 'ustar\u000000';
|
|
134
|
+
if (!posix && magic !== 'ustar \u0000')
|
|
135
|
+
throw refuse('an entry header is neither ustar nor GNU');
|
|
136
|
+
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}`);
|
|
140
|
+
const type = String.fromCharCode(header[156] ?? 0);
|
|
141
|
+
if (type !== '0' && type !== '\0') {
|
|
142
|
+
const kind = KINDS[type] ?? `of unknown type ${JSON.stringify(type)}`;
|
|
143
|
+
throw refuse(`entry "${name}" is ${kind}; only regular files are accepted`);
|
|
144
|
+
}
|
|
145
|
+
if (seen.has(name)) throw refuse(`entry "${name}" appears twice`);
|
|
146
|
+
seen.add(name);
|
|
147
|
+
names.push(name);
|
|
148
|
+
remaining = octal(header, 124, 12, `entry "${name}"'s size`);
|
|
149
|
+
padding = (BLOCK - (remaining % BLOCK)) % BLOCK;
|
|
150
|
+
parts = wanted.has(name) ? [] : undefined;
|
|
151
|
+
phase = 'data';
|
|
152
|
+
if (remaining === 0) endEntry();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const push = (chunk: Uint8Array): void => {
|
|
156
|
+
let at = 0;
|
|
157
|
+
while (at < chunk.length) {
|
|
158
|
+
if (phase === 'end') {
|
|
159
|
+
if (chunk.subarray(at).some((byte) => byte !== 0)) {
|
|
160
|
+
throw refuse('data follows the end-of-archive marker');
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (phase === 'header') {
|
|
165
|
+
const take = Math.min(BLOCK - filled, chunk.length - at);
|
|
166
|
+
header.set(chunk.subarray(at, at + take), filled);
|
|
167
|
+
filled += take;
|
|
168
|
+
at += take;
|
|
169
|
+
if (filled === BLOCK) {
|
|
170
|
+
filled = 0;
|
|
171
|
+
startEntry();
|
|
172
|
+
}
|
|
173
|
+
} else if (phase === 'data') {
|
|
174
|
+
const take = Math.min(remaining, chunk.length - at);
|
|
175
|
+
// ⚠️ slice, not subarray: a chunk's buffer may be reused by the stream after this returns.
|
|
176
|
+
parts?.push(chunk.slice(at, at + take));
|
|
177
|
+
remaining -= take;
|
|
178
|
+
at += take;
|
|
179
|
+
if (remaining === 0) endEntry();
|
|
180
|
+
} else {
|
|
181
|
+
const take = Math.min(padding, chunk.length - at);
|
|
182
|
+
padding -= take;
|
|
183
|
+
at += take;
|
|
184
|
+
if (padding === 0) phase = 'header';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const finish = (): TarContents => {
|
|
190
|
+
if (phase !== 'end') throw refuse('the archive stops before its end-of-archive marker');
|
|
191
|
+
return { members, names };
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return { finish, push };
|
|
195
|
+
};
|