@serve.zone/pallet-bundle 35.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist_ts_bundle/classes.controlprocess.d.ts +71 -0
  2. package/dist_ts_bundle/classes.controlprocess.js +441 -0
  3. package/dist_ts_bundle/classes.controlstderr.d.ts +36 -0
  4. package/dist_ts_bundle/classes.controlstderr.js +95 -0
  5. package/dist_ts_bundle/classes.releaseclient.d.ts +38 -0
  6. package/dist_ts_bundle/classes.releaseclient.js +285 -0
  7. package/dist_ts_bundle/errors.d.ts +35 -0
  8. package/dist_ts_bundle/errors.js +20 -0
  9. package/dist_ts_bundle/index.d.ts +10 -0
  10. package/dist_ts_bundle/index.js +11 -0
  11. package/dist_ts_bundle/inventory.d.ts +90 -0
  12. package/dist_ts_bundle/inventory.js +78 -0
  13. package/dist_ts_bundle/paths.d.ts +12 -0
  14. package/dist_ts_bundle/paths.js +76 -0
  15. package/dist_ts_bundle/plugins.d.ts +14 -0
  16. package/dist_ts_bundle/plugins.js +19 -0
  17. package/dist_ts_bundle/protocol.d.ts +48 -0
  18. package/dist_ts_bundle/protocol.js +47 -0
  19. package/dist_ts_bundle/units.d.ts +67 -0
  20. package/dist_ts_bundle/units.js +128 -0
  21. package/dist_ts_bundle/verify.d.ts +75 -0
  22. package/dist_ts_bundle/verify.js +263 -0
  23. package/dist_ts_bundle/version.d.ts +7 -0
  24. package/dist_ts_bundle/version.js +17 -0
  25. package/license.md +21 -0
  26. package/package.json +35 -0
  27. package/readme.md +176 -0
  28. package/ts_bundle/classes.controlprocess.ts +453 -0
  29. package/ts_bundle/classes.controlstderr.ts +92 -0
  30. package/ts_bundle/classes.releaseclient.ts +288 -0
  31. package/ts_bundle/errors.ts +40 -0
  32. package/ts_bundle/index.ts +10 -0
  33. package/ts_bundle/inventory.ts +104 -0
  34. package/ts_bundle/paths.ts +74 -0
  35. package/ts_bundle/plugins.ts +25 -0
  36. package/ts_bundle/protocol.ts +56 -0
  37. package/ts_bundle/readme.md +176 -0
  38. package/ts_bundle/units.ts +165 -0
  39. package/ts_bundle/verify.ts +353 -0
  40. package/ts_bundle/version.ts +18 -0
@@ -0,0 +1,288 @@
1
+ import * as plugins from './plugins.js';
2
+ import { PalletBundleError, requireBundle } from './errors.js';
3
+ import { canonicalPath } from './paths.js';
4
+ import { isPalletControlTarget, palletControlBundleName, palletControlPackageName, palletControlRepository,
5
+ palletControlTargets, type TPalletControlTarget } from './inventory.js';
6
+ import { requirePalletBundlePin, type IPalletBundlePin } from './verify.js';
7
+
8
+ export interface IPalletReleaseClientOptions {
9
+ /** Private TLS qualification only; production uses the default trusted Gitea origin. */
10
+ origin?: string;
11
+ requestTimeoutMs?: number;
12
+ downloadTimeoutMs?: number;
13
+ /** Owning-code qualification only. */
14
+ fetch?: typeof fetch;
15
+ }
16
+
17
+ interface IReleaseAsset {
18
+ name: string;
19
+ size: number;
20
+ url: string;
21
+ }
22
+
23
+ /** Any JSON object, arrays included: the fields each check reads decide the rest. */
24
+ const jsonObject = (valueArg: unknown): valueArg is Record<string, unknown> =>
25
+ typeof valueArg === 'object' && valueArg !== null;
26
+ const list = (valueArg: unknown): valueArg is readonly unknown[] => Array.isArray(valueArg);
27
+ const identifier = (valueArg: unknown): valueArg is number => Number.isSafeInteger(valueArg) && Number(valueArg) > 0;
28
+ const releaseAssetName = (valueArg: unknown): valueArg is string =>
29
+ typeof valueArg === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,239}$/.test(valueArg);
30
+ const archiveName = (versionArg: string, targetArg: TPalletControlTarget): string =>
31
+ `${palletControlBundleName}-${versionArg}-${targetArg}.tar.gz`;
32
+ const metadataLimit = 16 * 1024 ** 2;
33
+ const archiveLimit = 1024 ** 3;
34
+
35
+ /**
36
+ * Anonymous HTTPS download of a pinned Pallet release from its Gitea repository. It owns the
37
+ * downloads and the release shape; tspack owns the format and content verification. A release is
38
+ * only ever selected by its pin: the version, the tagged source commit and the digest of its sealed
39
+ * manifest, which must match this package's own Pallet version.
40
+ */
41
+ export class PalletReleaseClient {
42
+ private readonly origin: string;
43
+ private readonly requestTimeoutMs: number;
44
+ private readonly downloadTimeoutMs: number;
45
+ private readonly fetchOwner: typeof fetch;
46
+
47
+ constructor(optionsArg: IPalletReleaseClientOptions = {}) {
48
+ requireBundle(optionsArg && typeof optionsArg === 'object' && !plugins.nodeUtil.types.isProxy(optionsArg) &&
49
+ Object.getPrototypeOf(optionsArg) === Object.prototype &&
50
+ Object.keys(optionsArg).every((key) => ['origin', 'requestTimeoutMs', 'downloadTimeoutMs', 'fetch'].includes(key)),
51
+ 'invalid_options');
52
+ this.origin = optionsArg.origin ?? 'https://code.foss.global';
53
+ this.requestTimeoutMs = optionsArg.requestTimeoutMs ?? 30_000;
54
+ this.downloadTimeoutMs = optionsArg.downloadTimeoutMs ?? 10 * 60_000;
55
+ this.fetchOwner = optionsArg.fetch ?? fetch;
56
+ requireBundle(typeof this.origin === 'string' && typeof this.fetchOwner === 'function' &&
57
+ [this.requestTimeoutMs, this.downloadTimeoutMs].every((value) =>
58
+ Number.isSafeInteger(value) && value >= 1 && value <= 15 * 60_000), 'invalid_options');
59
+ let origin: URL;
60
+ try { origin = new URL(this.origin); } catch { throw new PalletBundleError('invalid_options'); }
61
+ requireBundle(origin.protocol === 'https:' && origin.origin === this.origin, 'invalid_options');
62
+ }
63
+
64
+ /**
65
+ * Downloads the pinned release's manifest, checksums and the target's archive into a fresh
66
+ * private directory below `parentArg`, verifies the archive against the pinned manifest and hands
67
+ * the directory to the callback. The directory is removed when the callback settles, so the
68
+ * callback must finish with the files (for example `extractPalletControlBundle`) before it returns.
69
+ */
70
+ public async run<TResult>(pinArg: IPalletBundlePin, targetArg: TPalletControlTarget, parentArg: string,
71
+ callbackArg: (directoryArg: string, pinArg: IPalletBundlePin) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
72
+ try {
73
+ return await this.download(pinArg, targetArg, parentArg, callbackArg, signalArg);
74
+ } catch (error) {
75
+ if (signalArg?.aborted) throw new PalletBundleError('aborted', { cause: error });
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ private async download<TResult>(pinArg: IPalletBundlePin, targetArg: TPalletControlTarget, parentArg: string,
81
+ callbackArg: (directoryArg: string, pinArg: IPalletBundlePin) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
82
+ const pin = await requirePalletBundlePin(pinArg);
83
+ requireBundle(isPalletControlTarget(targetArg) && typeof callbackArg === 'function' &&
84
+ (signalArg === undefined || signalArg instanceof AbortSignal), 'invalid_options');
85
+ const parent = canonicalPath(parentArg);
86
+ const assets = await this.resolveRelease(pin, signalArg);
87
+ const selected = ['tspack-manifest.json', 'SHA256SUMS.txt', archiveName(pin.version, targetArg)]
88
+ .map((name) => assets.find((asset) => asset.name === name)!);
89
+ const parentInfo = await plugins.fs.lstat(parent).catch(() => { throw new PalletBundleError('unsafe_path'); });
90
+ requireBundle(parentInfo.isDirectory() && await plugins.fs.realpath(parent) === parent, 'unsafe_path');
91
+ signalArg?.throwIfAborted();
92
+ const directory = await plugins.fs.mkdtemp(plugins.path.join(parent, '.pallet-download-'));
93
+ const owner = await plugins.fs.lstat(directory);
94
+ try {
95
+ // Sequential ownership bounds disk and network use; every response body settles before the next.
96
+ for (const asset of selected) {
97
+ await this.request(asset.url, signalArg, asset.name.endsWith('.tar.gz') ? this.downloadTimeoutMs : this.requestTimeoutMs,
98
+ async (response, signal) => {
99
+ const file = await plugins.fs.open(plugins.path.join(directory, asset.name),
100
+ plugins.fsConstants.O_WRONLY | plugins.fsConstants.O_CREAT | plugins.fsConstants.O_EXCL | plugins.fsConstants.O_NOFOLLOW,
101
+ 0o600);
102
+ try {
103
+ await this.consume(response, asset.size, signal, async (chunk) => {
104
+ let offset = 0;
105
+ while (offset < chunk.length) {
106
+ signal.throwIfAborted();
107
+ const { bytesWritten } = await file.write(chunk, offset, chunk.length - offset);
108
+ requireBundle(bytesWritten > 0, 'invalid_download');
109
+ offset += bytesWritten;
110
+ }
111
+ }, asset.size);
112
+ } finally {
113
+ await file.close();
114
+ }
115
+ });
116
+ }
117
+ let verified: plugins.tspack.ITsPackBundleResult;
118
+ try {
119
+ verified = await new plugins.tspack.TsPack().verifyBundle(directory, targetArg, {
120
+ expectedManifestSha256: pin.manifestSha256, expectedSourceCommit: pin.sourceCommit, requireRelease: true,
121
+ ...(signalArg ? { signal: signalArg } : {}),
122
+ });
123
+ } catch (error) {
124
+ throw new PalletBundleError('invalid_release', { cause: error });
125
+ }
126
+ requireBundle(verified.manifest.packageName === palletControlPackageName &&
127
+ verified.manifest.name === palletControlBundleName && verified.manifest.version === pin.version, 'invalid_release');
128
+ signalArg?.throwIfAborted();
129
+ return await callbackArg(directory, pin);
130
+ } finally {
131
+ const current = await plugins.fs.lstat(directory);
132
+ requireBundle(current.isDirectory() && !current.isSymbolicLink() && current.dev === owner.dev && current.ino === owner.ino &&
133
+ current.uid === owner.uid && await plugins.fs.realpath(directory) === directory, 'unsafe_path');
134
+ await plugins.fs.rm(directory, { recursive: true });
135
+ }
136
+ }
137
+
138
+ /** The pinned release's assets, after its metadata proves it is the pinned sealed set. */
139
+ private async resolveRelease(pinArg: IPalletBundlePin, signalArg?: AbortSignal): Promise<IReleaseAsset[]> {
140
+ const tag = `v${pinArg.version}`;
141
+ const base = `${this.origin}/api/v1/repos/${palletControlRepository}`;
142
+ const release: unknown = await this.json(`${base}/releases/tags/${tag}`, signalArg, true);
143
+ requireBundle(jsonObject(release), 'invalid_release');
144
+ const releaseAssets = release.assets;
145
+ requireBundle(identifier(release.id) && release.draft === false && release.prerelease === false && release.tag_name === tag &&
146
+ release.target_commitish === pinArg.sourceCommit && list(releaseAssets) && releaseAssets.length <= 130,
147
+ 'invalid_release');
148
+ const tagged: unknown = await this.json(`${base}/tags/${tag}`, signalArg);
149
+ requireBundle(jsonObject(tagged) && tagged.name === tag && jsonObject(tagged.commit) &&
150
+ tagged.commit.sha === pinArg.sourceCommit, 'invalid_release');
151
+ const names = new Set<string>();
152
+ const ids = new Set<number>();
153
+ const assets = releaseAssets.map((asset) => {
154
+ requireBundle(jsonObject(asset), 'invalid_release');
155
+ requireBundle(releaseAssetName(asset.name) && identifier(asset.id) && identifier(asset.size) &&
156
+ typeof asset.browser_download_url === 'string' && !names.has(asset.name) && !ids.has(asset.id), 'invalid_release');
157
+ this.url(asset.browser_download_url);
158
+ names.add(asset.name);
159
+ ids.add(asset.id);
160
+ return { name: asset.name, size: asset.size, url: asset.browser_download_url };
161
+ });
162
+ const canonical = new Set(['tspack-manifest.json', 'SHA256SUMS.txt',
163
+ ...palletControlTargets.map((target) => archiveName(pinArg.version, target))]);
164
+ const manifestAsset = assets.find((asset) => asset.name === 'tspack-manifest.json');
165
+ const checksumsAsset = assets.find((asset) => asset.name === 'SHA256SUMS.txt');
166
+ if (!manifestAsset || !checksumsAsset) {
167
+ if (assets.every((asset) => canonical.has(asset.name))) {
168
+ throw new PalletBundleError('release_pending', { missingAssets: [...canonical].filter((name) => !names.has(name)) });
169
+ }
170
+ throw new PalletBundleError('invalid_release');
171
+ }
172
+ requireBundle(manifestAsset.size <= metadataLimit && checksumsAsset.size <= metadataLimit, 'invalid_release');
173
+ const manifestBytes = await this.bytes(manifestAsset.url, manifestAsset.size, signalArg);
174
+ const checksumsBytes = await this.bytes(checksumsAsset.url, checksumsAsset.size, signalArg);
175
+ let metadata: plugins.tspack.ITsPackMetadataResult;
176
+ try {
177
+ metadata = new plugins.tspack.TsPack().verifyMetadata(manifestBytes, checksumsBytes, {
178
+ expectedManifestSha256: pinArg.manifestSha256, expectedSourceCommit: pinArg.sourceCommit, requireRelease: true,
179
+ });
180
+ } catch (error) {
181
+ throw new PalletBundleError('invalid_release', { cause: error });
182
+ }
183
+ const { manifest } = metadata;
184
+ requireBundle(manifest.packageName === palletControlPackageName && manifest.name === palletControlBundleName &&
185
+ manifest.version === pinArg.version && manifest.sourceCommit === pinArg.sourceCommit &&
186
+ manifest.artifacts.length === palletControlTargets.length &&
187
+ palletControlTargets.every((target) => manifest.artifacts.some((artifact) =>
188
+ artifact.id === target && artifact.kind === undefined && artifact.name === archiveName(pinArg.version, target) &&
189
+ artifact.size <= archiveLimit)), 'invalid_release');
190
+ const expected = new Map<string, number>([
191
+ ...manifest.artifacts.map((artifact) => [artifact.name, artifact.size] as const),
192
+ ['tspack-manifest.json', manifestBytes.length],
193
+ ['SHA256SUMS.txt', checksumsBytes.length],
194
+ ]);
195
+ const canonicalSubset = assets.every((asset) => expected.get(asset.name) === asset.size);
196
+ if (assets.length < expected.size && canonicalSubset) {
197
+ throw new PalletBundleError('release_pending', { missingAssets: [...expected.keys()].filter((name) => !names.has(name)) });
198
+ }
199
+ requireBundle(assets.length === expected.size && canonicalSubset, 'invalid_release');
200
+ return assets;
201
+ }
202
+
203
+ private async bytes(urlArg: string, sizeArg: number, signalArg?: AbortSignal): Promise<Uint8Array> {
204
+ requireBundle(Number.isSafeInteger(sizeArg) && sizeArg > 0 && sizeArg <= metadataLimit, 'invalid_release');
205
+ return await this.request(urlArg, signalArg, this.requestTimeoutMs, async (response, signal) => {
206
+ const chunks: Uint8Array[] = [];
207
+ await this.consume(response, sizeArg, signal, async (chunk) => { chunks.push(chunk.slice()); }, sizeArg);
208
+ return Buffer.concat(chunks);
209
+ });
210
+ }
211
+
212
+ private async json(urlArg: string, signalArg?: AbortSignal, absentReleaseIsPendingArg = false): Promise<unknown> {
213
+ return await this.request(urlArg, signalArg, this.requestTimeoutMs, async (response, signal) => {
214
+ const chunks: Uint8Array[] = [];
215
+ await this.consume(response, 1024 * 1024, signal, async (chunk) => { chunks.push(chunk.slice()); });
216
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); }
217
+ catch { throw new PalletBundleError('invalid_release'); }
218
+ }, absentReleaseIsPendingArg ? 'release_pending' : undefined);
219
+ }
220
+
221
+ private url(urlArg: string): URL {
222
+ let url: URL;
223
+ try { url = new URL(urlArg); } catch { throw new PalletBundleError('invalid_release'); }
224
+ requireBundle(url.origin === this.origin && !url.username && !url.password && !url.hash, 'invalid_release');
225
+ return url;
226
+ }
227
+
228
+ private async request<TResult>(urlArg: string, signalArg: AbortSignal | undefined, timeoutArg: number,
229
+ consumeArg: (responseArg: Response, signalArg: AbortSignal) => Promise<TResult>,
230
+ notFoundCodeArg?: 'release_pending'): Promise<TResult> {
231
+ if (signalArg?.aborted) throw new PalletBundleError('aborted');
232
+ const controller = new AbortController();
233
+ const abort = () => controller.abort();
234
+ signalArg?.addEventListener('abort', abort, { once: true });
235
+ const timer = setTimeout(abort, timeoutArg);
236
+ let response: Response | undefined;
237
+ try {
238
+ let url = this.url(urlArg);
239
+ for (let count = 0; count <= 3; count++) {
240
+ response = await this.fetchOwner(url, {
241
+ signal: controller.signal, redirect: 'manual', credentials: 'omit', cache: 'no-store',
242
+ headers: { 'Accept-Encoding': 'identity' },
243
+ });
244
+ if (response.status === 200) return await consumeArg(response, controller.signal);
245
+ if (response.status === 404 && notFoundCodeArg) throw new PalletBundleError(notFoundCodeArg);
246
+ const location = response.headers.get('location');
247
+ requireBundle([301, 302, 303, 307, 308].includes(response.status) && location && count < 3, 'request_failed');
248
+ await response.body?.cancel();
249
+ response = undefined;
250
+ url = this.url(new URL(location, url).href);
251
+ }
252
+ throw new PalletBundleError('request_failed');
253
+ } catch (error) {
254
+ if (signalArg?.aborted) throw new PalletBundleError('aborted', { cause: error });
255
+ if (controller.signal.aborted) throw new PalletBundleError('timeout', { cause: error });
256
+ if (error instanceof PalletBundleError) throw error;
257
+ throw new PalletBundleError('request_failed', { cause: error });
258
+ } finally {
259
+ try { await response?.body?.cancel(); } catch { /* A fully consumed body is already closed. */ }
260
+ clearTimeout(timer);
261
+ signalArg?.removeEventListener('abort', abort);
262
+ }
263
+ }
264
+
265
+ private async consume(responseArg: Response, limitArg: number, signalArg: AbortSignal,
266
+ writeArg: (chunkArg: Uint8Array) => Promise<void>, expectedArg?: number): Promise<number> {
267
+ const length = responseArg.headers.get('content-length');
268
+ requireBundle(length === null || (/^\d+$/.test(length) && Number(length) <= limitArg &&
269
+ (expectedArg === undefined || Number(length) === expectedArg)), 'invalid_download');
270
+ requireBundle(responseArg.body, 'invalid_download');
271
+ const reader = responseArg.body.getReader();
272
+ let size = 0;
273
+ try {
274
+ for (;;) {
275
+ signalArg.throwIfAborted();
276
+ const next = await reader.read();
277
+ if (next.done) break;
278
+ size += next.value.length;
279
+ requireBundle(size <= limitArg, 'invalid_download');
280
+ await writeArg(next.value);
281
+ }
282
+ requireBundle(expectedArg === undefined || size === expectedArg, 'invalid_download');
283
+ return size;
284
+ } finally {
285
+ try { await reader.cancel(); } finally { reader.releaseLock(); }
286
+ }
287
+ }
288
+ }
@@ -0,0 +1,40 @@
1
+ /** Why a Pallet control bundle, its release or its installed files were refused. */
2
+ export type TPalletBundleErrorCode =
3
+ /** The caller's own input is malformed (a pin, a target, an option). */
4
+ | 'invalid_options'
5
+ /** The pin names another Pallet version than this package's own. */
6
+ | 'version_mismatch'
7
+ /** The release is not published yet, or not with all of its assets. */
8
+ | 'release_pending'
9
+ /** The release, its metadata or its bundle is not the pinned Pallet control bundle. */
10
+ | 'invalid_release'
11
+ /** A request failed, was redirected off the origin or answered an unexpected status. */
12
+ | 'request_failed'
13
+ /** A download or request exceeded its deadline. */
14
+ | 'timeout'
15
+ /** The caller's signal aborted the operation. */
16
+ | 'aborted'
17
+ /** A path is not a canonical, protected location, or it changed while in use. */
18
+ | 'unsafe_path'
19
+ /** A download answered other bytes than its release asset states. */
20
+ | 'invalid_download'
21
+ /** An executable or file does not carry the digest the bundle states. */
22
+ | 'artifact_mismatch';
23
+
24
+ export class PalletBundleError extends Error {
25
+ /**
26
+ * Set for `release_pending` when the release exists but does not carry these assets yet; absent
27
+ * when the release itself is not published yet.
28
+ */
29
+ public readonly missingAssets?: readonly string[];
30
+
31
+ constructor(public readonly code: TPalletBundleErrorCode, detailsArg: { missingAssets?: readonly string[]; cause?: unknown } = {}) {
32
+ super(`Pallet control bundle refused: ${code}.`, detailsArg.cause === undefined ? undefined : { cause: detailsArg.cause });
33
+ this.name = 'PalletBundleError';
34
+ if (detailsArg.missingAssets) this.missingAssets = Object.freeze([...detailsArg.missingAssets]);
35
+ }
36
+ }
37
+
38
+ export function requireBundle(valueArg: unknown, codeArg: TPalletBundleErrorCode): asserts valueArg {
39
+ if (!valueArg) throw new PalletBundleError(codeArg);
40
+ }
@@ -0,0 +1,10 @@
1
+ export * from './errors.js';
2
+ export * from './inventory.js';
3
+ export * from './protocol.js';
4
+ export { palletBundleVersion } from './version.js';
5
+ export { verifyExecutable } from './paths.js';
6
+ export * from './verify.js';
7
+ export * from './classes.releaseclient.js';
8
+ export * from './units.js';
9
+ export * from './classes.controlstderr.js';
10
+ export * from './classes.controlprocess.js';
@@ -0,0 +1,104 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ /**
4
+ * What a Pallet control bundle is made of, stated once.
5
+ *
6
+ * Pallet's own build (`scripts/build-control.mjs`) writes its control record with these paths and
7
+ * keys, and its packaging (`scripts/control-package.mjs`) runs this package's verifier over every
8
+ * build before sealing it, so a bundle that disagrees with this description is never released. The
9
+ * files of each component (clock, DNS, VPN, containerd, notices and sources) are not listed here:
10
+ * the bundle states them itself in its control record and in the manifests of those components,
11
+ * and the verifier checks every file against them.
12
+ */
13
+
14
+ /** The tspack bundle name, the package that builds it and the Gitea repository that releases it. */
15
+ export const palletControlBundleName = 'pallet-control';
16
+ export const palletControlPackageName = '@serve.zone/pallet';
17
+ export const palletControlRepository = 'serve.zone/pallet';
18
+
19
+ /** The platforms a release carries, as tspack bundle ids. */
20
+ export const palletControlTargets = ['linux-amd64', 'linux-arm64'] as const;
21
+ export type TPalletControlTarget = typeof palletControlTargets[number];
22
+
23
+ export const isPalletControlTarget = (valueArg: unknown): valueArg is TPalletControlTarget =>
24
+ palletControlTargets.some((target) => target === valueArg);
25
+
26
+ /** The tsrust target every native executable of a bundle was built for. */
27
+ export const palletNativeTarget = (targetArg: TPalletControlTarget): string => `${targetArg.replace('-', '_')}_musl`;
28
+
29
+ /** The fixed host paths a control build compiles in; its control record repeats them. */
30
+ export const palletControlPaths = Object.freeze({
31
+ /** The node's store, created only by explicit provisioning. */
32
+ dataRoot: '/var/lib/serve.zone/pallet',
33
+ /** The Cloudly enrollment control socket (`enrollment-serve`, `enrollment-provision`). */
34
+ socketPath: plugins.interfaces.data.palletNodeEnrollmentContract.socketPath,
35
+ /** The local controller socket a node bound to a controller on its own host serves. */
36
+ localControllerSocketPath: plugins.interfaces.data.nodeRuntimeLocalControllerContract.socketPath,
37
+ /** The CRI socket of Pallet's own containerd, never the host's shared one. */
38
+ runtimeSocketPath: '/run/pallet/containerd/containerd.sock',
39
+ /** The uid every Pallet process and every installed file belongs to. */
40
+ expectedUid: 0,
41
+ });
42
+
43
+ /** Every key of a bundle's `control-build.json`, and nothing else. */
44
+ export const palletControlRecordKeys = Object.freeze([
45
+ 'packageName', 'version', 'sourceCommit', 'sourceDirty', 'target', 'compiler', 'lockSha256', 'dataRoot',
46
+ 'socketPath', 'runtimeSocketPath', 'expectedUid', 'clockManifestSha256', 'dnsManifestSha256',
47
+ 'vpnInventorySha256', 'containerdManifestSha256', 'files',
48
+ ] as const);
49
+
50
+ export const palletControlRecordName = 'control-build.json';
51
+
52
+ export type TPalletControlRole = 'control' | 'store' | 'runtime' | 'guard' | 'dns' | 'vpn';
53
+
54
+ export interface IPalletControlRoleDescription {
55
+ /** Bundle-relative path; the control executable finds each sibling by this name. */
56
+ readonly path: string;
57
+ /** The tsrust build provenance beside a native executable. The control itself is compiled by Deno. */
58
+ readonly provenance?: {
59
+ readonly path: string;
60
+ readonly projectName: string;
61
+ readonly binary: string;
62
+ };
63
+ }
64
+
65
+ /** The executables `pallet-control` starts by name, each verified against the digest it compiles in. */
66
+ export const palletControlRoles: Readonly<Record<TPalletControlRole, IPalletControlRoleDescription>> = Object.freeze({
67
+ control: { path: 'pallet-control' },
68
+ store: { path: 'pallet-smartdb', provenance: { path: 'pallet-smartdb.tsrust-build.json',
69
+ projectName: '@lossless.org/nosqldb', binary: 'rustdb' } },
70
+ runtime: { path: 'pallet-runtime', provenance: { path: 'pallet-runtime.tsrust-build.json',
71
+ projectName: palletControlPackageName, binary: 'pallet' } },
72
+ guard: { path: 'pallet-guard', provenance: { path: 'pallet-guard.tsrust-build.json',
73
+ projectName: '@push.rocks/smartnftables', binary: 'smartnftables' } },
74
+ dns: { path: 'pallet-dns', provenance: { path: 'pallet-dns.tsrust-build.json',
75
+ projectName: '@push.rocks/smartdns', binary: 'rustdns' } },
76
+ vpn: { path: 'pallet-vpn', provenance: { path: 'pallet-vpn.tsrust-build.json',
77
+ projectName: '@push.rocks/smartvpn', binary: 'smartvpn_daemon' } },
78
+ });
79
+
80
+ /**
81
+ * The components whose files a manifest inside the bundle lists, and the control record key that
82
+ * pins that manifest's digest. `listing` components state every file below `directory` by path,
83
+ * size, SHA-256 and mode; the others are pinned by digest only and their files are declared in the
84
+ * control record.
85
+ */
86
+ export const palletControlComponents = Object.freeze({
87
+ clock: { manifest: 'pallet-clock/manifest.json', recordKey: 'clockManifestSha256', directory: 'pallet-clock/' },
88
+ containerd: { manifest: 'pallet-containerd/manifest.json', recordKey: 'containerdManifestSha256',
89
+ directory: 'pallet-containerd/' },
90
+ dns: { manifest: 'notices/coredns/manifest.json', recordKey: 'dnsManifestSha256', directory: null },
91
+ vpn: { manifest: 'notices/smartvpn/notices/inventory.json', recordKey: 'vpnInventorySha256', directory: null },
92
+ } as const);
93
+
94
+ /** Notice trees in the `tsrust.native-notices` format: a manifest naming every file below it. */
95
+ export const palletControlNoticeTrees = Object.freeze(['native-notices/', 'notices/smartnftables/'] as const);
96
+
97
+ /**
98
+ * Notice and corresponding-source inventories: a manifest whose `files` list every file below both
99
+ * prefixes by path, size and SHA-256.
100
+ */
101
+ export const palletControlSourceInventories = Object.freeze([
102
+ { manifest: 'notices/clock/manifest.json', prefixes: ['notices/clock/', 'sources/clock/'] },
103
+ { manifest: 'notices/containerd/manifest.json', prefixes: ['notices/containerd/', 'sources/containerd/'] },
104
+ ] as const);
@@ -0,0 +1,74 @@
1
+ import * as plugins from './plugins.js';
2
+ import { PalletBundleError } from './errors.js';
3
+
4
+ /** An absolute, normalized path without a trailing slash; `/` itself is refused. */
5
+ export function canonicalPath(valueArg: unknown): string {
6
+ if (typeof valueArg !== 'string' || valueArg.length > 4096 || valueArg.includes('\0') ||
7
+ !plugins.path.isAbsolute(valueArg) || valueArg === '/' || plugins.path.normalize(valueArg) !== valueArg ||
8
+ valueArg.endsWith('/')) {
9
+ throw new PalletBundleError('invalid_options');
10
+ }
11
+ return valueArg;
12
+ }
13
+
14
+ const unsafe = (): never => { throw new PalletBundleError('unsafe_path'); };
15
+
16
+ /** No symlink ancestor, foreign owner or group/other writer can replace the protected leaf. */
17
+ export async function checkAncestors(pathArg: string, uidArg: number): Promise<void> {
18
+ let current = plugins.path.dirname(pathArg);
19
+ for (;;) {
20
+ const info = await plugins.fs.lstat(current);
21
+ const rootSticky = info.uid === 0 && (info.mode & 0o1000) !== 0;
22
+ if (!info.isDirectory() || info.isSymbolicLink() || (info.uid !== uidArg && info.uid !== 0) ||
23
+ ((info.mode & 0o022) !== 0 && !rootSticky)) unsafe();
24
+ if (current === '/') return;
25
+ current = plugins.path.dirname(current);
26
+ }
27
+ }
28
+
29
+ const checkExecutable = (infoArg: { isFile(): boolean; isSymbolicLink(): boolean; uid: number; nlink: number; mode: number },
30
+ uidArg: number): void => {
31
+ if (!infoArg.isFile() || infoArg.isSymbolicLink() || infoArg.uid !== uidArg || infoArg.nlink !== 1 ||
32
+ (infoArg.mode & 0o6022) !== 0 || (infoArg.mode & 0o100) === 0) unsafe();
33
+ };
34
+
35
+ /**
36
+ * Proves the executable at a protected path carries the digest trusted metadata states. The file is
37
+ * read through one descriptor opened without following links, and its identity must not change
38
+ * while it is read.
39
+ */
40
+ export async function verifyExecutable(pathArg: string, digestArg: string, uidArg: number,
41
+ signalArg?: AbortSignal): Promise<void> {
42
+ signalArg?.throwIfAborted();
43
+ await checkAncestors(pathArg, uidArg);
44
+ checkExecutable(await plugins.fs.lstat(pathArg), uidArg);
45
+ const file = await plugins.fs.open(pathArg, plugins.fsConstants.O_RDONLY | plugins.fsConstants.O_NOFOLLOW);
46
+ try {
47
+ const before = await file.stat();
48
+ checkExecutable(before, uidArg);
49
+ if (before.size < 1 || before.size > 1024 ** 3) unsafe();
50
+ const hash = plugins.crypto.createHash('sha256');
51
+ const buffer = Buffer.alloc(1024 * 1024);
52
+ let position = 0;
53
+ while (position < before.size) {
54
+ signalArg?.throwIfAborted();
55
+ const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, before.size - position), position);
56
+ if (bytesRead === 0) unsafe();
57
+ hash.update(buffer.subarray(0, bytesRead));
58
+ position += bytesRead;
59
+ }
60
+ const after = await file.stat();
61
+ if (after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs ||
62
+ after.ino !== before.ino || after.dev !== before.dev) unsafe();
63
+ if (hash.digest('hex') !== digestArg) throw new PalletBundleError('artifact_mismatch');
64
+ } finally {
65
+ await file.close();
66
+ }
67
+ }
68
+
69
+ /** A directory that is canonical (its real path is itself) and a directory. */
70
+ export async function requireCanonicalDirectory(pathArg: string): Promise<void> {
71
+ canonicalPath(pathArg);
72
+ const info = await plugins.fs.lstat(pathArg).catch(() => unsafe());
73
+ if (!info.isDirectory() || await plugins.fs.realpath(pathArg) !== pathArg) unsafe();
74
+ }
@@ -0,0 +1,25 @@
1
+ // node native scope
2
+ import * as childProcess from 'node:child_process';
3
+ import * as crypto from 'node:crypto';
4
+ import * as fs from 'node:fs/promises';
5
+ import { constants as fsConstants } from 'node:fs';
6
+ import * as nodeUtil from 'node:util';
7
+ import * as os from 'node:os';
8
+ import * as path from 'node:path';
9
+
10
+ export { childProcess, crypto, fs, fsConstants, nodeUtil, os, path };
11
+
12
+ // @git.zone scope
13
+ import * as tspack from '@git.zone/tspack';
14
+
15
+ export { tspack };
16
+
17
+ // @push.rocks scope
18
+ import * as smartdaemon from '@push.rocks/smartdaemon';
19
+
20
+ export { smartdaemon };
21
+
22
+ // @serve.zone scope
23
+ import * as interfaces from '@serve.zone/interfaces';
24
+
25
+ export { interfaces };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The process protocol of `pallet-control`: its modes, the protocol each mode reports on standard
3
+ * output and the reasons a `failed` line names. `pallet-control` itself reports with these names
4
+ * (`ts/control/process-cli.ts`), and the supervisors in this package read them. This module has no
5
+ * dependencies, so the compiled control can import it.
6
+ */
7
+
8
+ /** Every line of the protocol starts with this prefix, followed by one JSON object. */
9
+ export const palletControlLinePrefix = 'PALLET_CONTROL ';
10
+
11
+ export const palletControlProtocols = Object.freeze({
12
+ enrollment: 'pallet.enrollment.process',
13
+ node: 'pallet.node.process',
14
+ guard: 'pallet.guard.process',
15
+ acquisition: 'pallet.network.acquisition',
16
+ containerd: 'pallet.containerd.process',
17
+ storageImport: 'pallet.storage.import',
18
+ storeProvision: 'pallet.store.provision',
19
+ } as const);
20
+
21
+ /**
22
+ * Modes a supervisor holds for its lifetime through a stdin pipe: the child stops when that pipe
23
+ * closes. `runtime-serve` is the node process as a supervised child.
24
+ */
25
+ export const palletControlLifetimeModes = Object.freeze({
26
+ 'enrollment-serve': palletControlProtocols.enrollment,
27
+ 'enrollment-provision': palletControlProtocols.enrollment,
28
+ 'runtime-serve': palletControlProtocols.node,
29
+ } as const);
30
+ export type TPalletControlLifetimeMode = keyof typeof palletControlLifetimeModes;
31
+
32
+ /** Oneshot modes: null stdin is valid, and `ready` followed by `stopped` is their success. */
33
+ export const palletControlOneshotModes = Object.freeze({
34
+ 'guard-recover': palletControlProtocols.guard,
35
+ 'guard-commission': palletControlProtocols.guard,
36
+ 'network-acquire': palletControlProtocols.acquisition,
37
+ 'network-acquire-local': palletControlProtocols.acquisition,
38
+ 'store-provision': palletControlProtocols.storeProvision,
39
+ } as const);
40
+ export type TPalletControlOneshotMode = keyof typeof palletControlOneshotModes;
41
+
42
+ /**
43
+ * Service unit main processes. Their stdin is null and a signal stops them; the service manager,
44
+ * not a supervisor in this package, runs them.
45
+ */
46
+ export const palletControlUnitModes = Object.freeze({
47
+ 'containerd-serve': palletControlProtocols.containerd,
48
+ 'runtime-unit': palletControlProtocols.node,
49
+ } as const);
50
+ export type TPalletControlUnitMode = keyof typeof palletControlUnitModes;
51
+
52
+ /** The reasons a `failed` line names. */
53
+ export const palletControlFailureReasons = Object.freeze([
54
+ 'invalid_arguments', 'invalid_ca_store', 'invalid_stdin', 'owner_failed', 'unsupported_kernel',
55
+ ] as const);
56
+ export type TPalletControlFailureReason = typeof palletControlFailureReasons[number];