@hanamesh/dsh-app-host 0.2.0-rc.2

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 (82) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +104 -0
  3. package/consistency.json +101 -0
  4. package/dist/client-ui.d.ts +19 -0
  5. package/dist/client-ui.js +271 -0
  6. package/dist/client.d.ts +16 -0
  7. package/dist/client.js +42 -0
  8. package/dist/consistency.js +12 -0
  9. package/dist/descriptor.js +141 -0
  10. package/dist/dsh.d.ts +25 -0
  11. package/dist/dsh.js +194 -0
  12. package/dist/errors.js +25 -0
  13. package/dist/gateway.js +224 -0
  14. package/dist/guardian.js +113 -0
  15. package/dist/index.d.ts +130 -0
  16. package/dist/index.js +15 -0
  17. package/dist/launcher.js +17 -0
  18. package/dist/library/catalog.js +108 -0
  19. package/dist/library/domain.js +8 -0
  20. package/dist/library/install.js +87 -0
  21. package/dist/library/installed.js +67 -0
  22. package/dist/library/locate.js +79 -0
  23. package/dist/library/routes.js +17 -0
  24. package/dist/library/service.js +76 -0
  25. package/dist/manager.js +605 -0
  26. package/dist/provision/LICENSE +6 -0
  27. package/dist/provision/THIRD_PARTY_NOTICES.md +13 -0
  28. package/dist/provision/archive.d.ts +28 -0
  29. package/dist/provision/archive.js +66 -0
  30. package/dist/provision/check.d.ts +10 -0
  31. package/dist/provision/check.js +51 -0
  32. package/dist/provision/checkpoints.d.ts +12 -0
  33. package/dist/provision/checkpoints.js +23 -0
  34. package/dist/provision/cli.d.ts +1 -0
  35. package/dist/provision/cli.js +169 -0
  36. package/dist/provision/download.d.ts +21 -0
  37. package/dist/provision/download.js +242 -0
  38. package/dist/provision/errors.d.ts +11 -0
  39. package/dist/provision/errors.js +13 -0
  40. package/dist/provision/extract.d.ts +17 -0
  41. package/dist/provision/extract.js +124 -0
  42. package/dist/provision/fsutil.d.ts +8 -0
  43. package/dist/provision/fsutil.js +71 -0
  44. package/dist/provision/index.d.ts +7 -0
  45. package/dist/provision/index.js +6 -0
  46. package/dist/provision/ledger.d.ts +14 -0
  47. package/dist/provision/ledger.js +50 -0
  48. package/dist/provision/manifest.d.ts +11 -0
  49. package/dist/provision/manifest.js +171 -0
  50. package/dist/provision/paths.d.ts +27 -0
  51. package/dist/provision/paths.js +95 -0
  52. package/dist/provision/platform.d.ts +6 -0
  53. package/dist/provision/platform.js +7 -0
  54. package/dist/provision/provision.d.ts +17 -0
  55. package/dist/provision/provision.js +454 -0
  56. package/dist/provision/tar.d.ts +7 -0
  57. package/dist/provision/tar.js +169 -0
  58. package/dist/provision/types.d.ts +174 -0
  59. package/dist/provision/types.js +7 -0
  60. package/dist/provision/zip.d.ts +4 -0
  61. package/dist/provision/zip.js +194 -0
  62. package/dist/router/broker.js +161 -0
  63. package/dist/router/domain.js +18 -0
  64. package/dist/router/receipts-domain.js +8 -0
  65. package/dist/router/receipts.js +156 -0
  66. package/dist/router/routes.js +48 -0
  67. package/dist/router/sources.js +87 -0
  68. package/dist/routes.js +74 -0
  69. package/dist/runtime.js +222 -0
  70. package/dist/store.js +250 -0
  71. package/dist/usage-evidence.js +117 -0
  72. package/docs/APP_PACKAGE.md +38 -0
  73. package/docs/CONTRACT.md +190 -0
  74. package/docs/DSH_INTEGRATION.md +43 -0
  75. package/docs/LIBRARY.md +45 -0
  76. package/docs/LICENSES.json +185 -0
  77. package/docs/PROVENANCE.json +63 -0
  78. package/docs/SECURITY.md +52 -0
  79. package/package.json +112 -0
  80. package/profile/cordis.patch.yml +4 -0
  81. package/schemas/catalog-provider-page.schema.json +376 -0
  82. package/schemas/catalog-source.schema.json +151 -0
@@ -0,0 +1,28 @@
1
+ /** Common archive entry shape shared by the tar.gz and zip readers. */
2
+ export type EntryKind = 'file' | 'dir' | 'symlink' | 'hardlink';
3
+ export interface ArchiveEntry {
4
+ kind: EntryKind;
5
+ /** Raw entry name as stored in the archive (unsafe until checked). */
6
+ name: string;
7
+ /** POSIX mode bits; 0 when the archive has none. */
8
+ mode: number;
9
+ size: number;
10
+ /** Symlink / hardlink target as stored. */
11
+ linkTarget?: string;
12
+ /** File body. Must be fully consumed before the next entry is requested. */
13
+ body: AsyncIterable<Uint8Array>;
14
+ }
15
+ /** Pull exact byte counts out of a chunk stream (used by the tar reader). */
16
+ export declare class ByteReader {
17
+ private readonly it;
18
+ private buffered;
19
+ private bufferedLength;
20
+ private done;
21
+ constructor(source: AsyncIterable<Uint8Array>);
22
+ private fill;
23
+ /** Read exactly `n` bytes; returns fewer only at EOF. */
24
+ read(n: number): Promise<Uint8Array>;
25
+ /** Yield exactly `n` bytes as chunks. Throws on premature EOF. */
26
+ stream(n: number): AsyncGenerator<Uint8Array>;
27
+ skip(n: number): Promise<void>;
28
+ }
@@ -0,0 +1,66 @@
1
+ /** Pull exact byte counts out of a chunk stream (used by the tar reader). */
2
+ export class ByteReader {
3
+ it;
4
+ buffered = [];
5
+ bufferedLength = 0;
6
+ done = false;
7
+ constructor(source) {
8
+ this.it = source[Symbol.asyncIterator]();
9
+ }
10
+ async fill() {
11
+ if (this.done)
12
+ return false;
13
+ const step = await this.it.next();
14
+ if (step.done) {
15
+ this.done = true;
16
+ return false;
17
+ }
18
+ if (step.value.length > 0) {
19
+ this.buffered.push(step.value);
20
+ this.bufferedLength += step.value.length;
21
+ }
22
+ return true;
23
+ }
24
+ /** Read exactly `n` bytes; returns fewer only at EOF. */
25
+ async read(n) {
26
+ while (this.bufferedLength < n && (await this.fill()))
27
+ ;
28
+ const out = new Uint8Array(Math.min(n, this.bufferedLength));
29
+ let off = 0;
30
+ while (off < out.length) {
31
+ const head = this.buffered[0];
32
+ const take = Math.min(head.length, out.length - off);
33
+ out.set(head.subarray(0, take), off);
34
+ off += take;
35
+ if (take === head.length)
36
+ this.buffered.shift();
37
+ else
38
+ this.buffered[0] = head.subarray(take);
39
+ this.bufferedLength -= take;
40
+ }
41
+ return out;
42
+ }
43
+ /** Yield exactly `n` bytes as chunks. Throws on premature EOF. */
44
+ async *stream(n) {
45
+ let remaining = n;
46
+ while (remaining > 0) {
47
+ if (this.bufferedLength === 0 && !(await this.fill()))
48
+ throw new Error('unexpected end of archive');
49
+ const head = this.buffered[0];
50
+ const take = Math.min(head.length, remaining);
51
+ const chunk = head.subarray(0, take);
52
+ if (take === head.length)
53
+ this.buffered.shift();
54
+ else
55
+ this.buffered[0] = head.subarray(take);
56
+ this.bufferedLength -= take;
57
+ remaining -= take;
58
+ yield chunk;
59
+ }
60
+ }
61
+ async skip(n) {
62
+ for await (const _ of this.stream(n)) {
63
+ /* discard */
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,10 @@
1
+ import type { VerifySpec } from './types.js';
2
+ /**
3
+ * Run `verify.exec` from the freshly extracted tree with an isolated environment:
4
+ * PATH is empty, so nothing on the host can be picked up by accident, and the
5
+ * executable is addressed by absolute path inside the staging tree.
6
+ */
7
+ export declare function runCheck(tree: string, spec: VerifySpec, item: string): Promise<{
8
+ stdout: string;
9
+ stderr: string;
10
+ }>;
@@ -0,0 +1,51 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { ProvisionError } from './errors.js';
4
+ import { isInside } from './paths.js';
5
+ /**
6
+ * Run `verify.exec` from the freshly extracted tree with an isolated environment:
7
+ * PATH is empty, so nothing on the host can be picked up by accident, and the
8
+ * executable is addressed by absolute path inside the staging tree.
9
+ */
10
+ export async function runCheck(tree, spec, item) {
11
+ const exe = path.join(tree, ...spec.exec.split('/'));
12
+ if (!isInside(tree, exe))
13
+ throw new ProvisionError('E_VERIFY', `verify.exec escapes the tree: ${spec.exec}`, { item });
14
+ const env = { PATH: '' };
15
+ if (process.platform === 'win32') {
16
+ // A Windows process cannot start without SystemRoot; nothing else is inherited.
17
+ for (const k of ['SystemRoot', 'SYSTEMROOT', 'windir', 'TEMP', 'TMP']) {
18
+ const v = process.env[k];
19
+ if (v !== undefined)
20
+ env[k] = v;
21
+ }
22
+ }
23
+ const timeoutMs = spec.timeoutMs ?? 60_000;
24
+ return new Promise((resolve, reject) => {
25
+ const child = spawn(exe, spec.args ?? [], { env, cwd: tree, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
26
+ let stdout = '';
27
+ let stderr = '';
28
+ const timer = setTimeout(() => {
29
+ child.kill('SIGKILL');
30
+ reject(new ProvisionError('E_VERIFY', `verify.exec timed out after ${timeoutMs}ms: ${spec.exec}`, { item }));
31
+ }, timeoutMs);
32
+ child.stdout.on('data', (d) => (stdout += d.toString()));
33
+ child.stderr.on('data', (d) => (stderr += d.toString()));
34
+ child.on('error', (err) => {
35
+ clearTimeout(timer);
36
+ reject(new ProvisionError('E_VERIFY', `verify.exec could not start (${spec.exec}): ${err.message}`, { item, cause: err }));
37
+ });
38
+ child.on('close', (code, signal) => {
39
+ clearTimeout(timer);
40
+ if (code !== 0) {
41
+ reject(new ProvisionError('E_VERIFY', `verify.exec exited ${code ?? signal}: ${spec.exec}; stderr: ${stderr.trim().slice(0, 500)}`, { item }));
42
+ return;
43
+ }
44
+ if (spec.expect !== undefined && stdout.trim() !== spec.expect) {
45
+ reject(new ProvisionError('E_VERIFY', `verify.exec output ${JSON.stringify(stdout.trim().slice(0, 200))} != expected ${JSON.stringify(spec.expect)}`, { item }));
46
+ return;
47
+ }
48
+ resolve({ stdout, stderr });
49
+ });
50
+ });
51
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Crash-injection checkpoints for consistency tests (CONSISTENCY.md X02/X03).
3
+ *
4
+ * Set `HANAMESH_PROVISION_KILL_AT=<name>` to SIGKILL the current process the
5
+ * first time that checkpoint is reached, or `download@<bytes>` to kill once the
6
+ * download has written at least that many bytes. Every checkpoint sits *after*
7
+ * the durability point of the step it names (file closed / fsync'd / rename returned),
8
+ * never after a merely resolved promise.
9
+ */
10
+ export type CheckpointName = 'after-download' | 'after-sha256' | 'after-extract' | 'after-check' | 'after-manifest-temp' | 'after-trash' | 'after-manifest' | 'after-rename' | 'after-ledger';
11
+ export declare function checkpoint(name: CheckpointName): void;
12
+ export declare function downloadCheckpoint(bytes: number): void;
@@ -0,0 +1,23 @@
1
+ const ENV = 'HANAMESH_PROVISION_KILL_AT';
2
+ export function checkpoint(name) {
3
+ const want = process.env[ENV];
4
+ if (want === undefined || want !== name)
5
+ return;
6
+ killSelf(name);
7
+ }
8
+ export function downloadCheckpoint(bytes) {
9
+ const want = process.env[ENV];
10
+ if (want === undefined || !want.startsWith('download@'))
11
+ return;
12
+ const threshold = Number(want.slice('download@'.length));
13
+ if (Number.isFinite(threshold) && bytes >= threshold)
14
+ killSelf(want);
15
+ }
16
+ function killSelf(name) {
17
+ process.stderr.write(`[hanamesh-provision] kill injected at ${name}\n`);
18
+ process.kill(process.pid, 'SIGKILL');
19
+ // SIGKILL is not deliverable to ourselves synchronously on every platform; block until it lands.
20
+ for (;;) {
21
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
22
+ }
23
+ }
@@ -0,0 +1 @@
1
+ export declare function main(argv: string[]): Promise<number>;
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isProvisionError } from './errors.js';
4
+ import { platformKey } from './platform.js';
5
+ import { plan, provision, remove, verify } from './provision.js';
6
+ const USAGE = `hanamesh-provision — put a large file on this machine safely (no guessing, no PATH)
7
+
8
+ hanamesh-provision plan <manifest.json> --root <dir> [--platform <key>] [--only <item>]...
9
+ hanamesh-provision install <manifest.json> --root <dir> [--source <id>]... [--only <item>]... [--force] [--platform <key>]
10
+ hanamesh-provision remove <item> --root <dir>
11
+ hanamesh-provision verify --root <dir>
12
+ hanamesh-provision platform
13
+
14
+ Progress goes to stderr, the JSON result to stdout. Exit code 0 only when everything was done.
15
+ `;
16
+ class UsageError extends Error {
17
+ }
18
+ function parseArgs(argv) {
19
+ const out = { command: argv[0], positional: [], flags: new Map(), bools: new Set() };
20
+ for (let i = 1; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a === '--force' || a === '--help' || a === '-h' || a === '--quiet') {
23
+ out.bools.add(a.replace(/^-+/, ''));
24
+ continue;
25
+ }
26
+ if (a.startsWith('--')) {
27
+ const eq = a.indexOf('=');
28
+ const key = eq === -1 ? a.slice(2) : a.slice(2, eq);
29
+ const val = eq === -1 ? argv[++i] : a.slice(eq + 1);
30
+ if (val === undefined)
31
+ throw new UsageError(`missing value for --${key}`);
32
+ const list = out.flags.get(key) ?? [];
33
+ list.push(val);
34
+ out.flags.set(key, list);
35
+ continue;
36
+ }
37
+ out.positional.push(a);
38
+ }
39
+ return out;
40
+ }
41
+ function need(p, key) {
42
+ const v = p.flags.get(key)?.at(-1);
43
+ if (v === undefined)
44
+ throw new UsageError(`--${key} is required`);
45
+ return v;
46
+ }
47
+ function progressPrinter(quiet) {
48
+ let lastLine = '';
49
+ let lastTick = 0;
50
+ let lastPct = -1;
51
+ return (e) => {
52
+ if (quiet)
53
+ return;
54
+ let line = `[${e.item}] ${e.phase}`;
55
+ if (e.source !== undefined)
56
+ line += ` via ${e.source}`;
57
+ const isByteTick = e.phase === 'download' && e.bytes !== undefined && e.note === undefined;
58
+ if (isByteTick) {
59
+ // Throttle byte progress: at most every 500 ms or on a whole-percent change.
60
+ const pct = e.total !== undefined && e.total > 0 ? Math.floor((e.bytes / e.total) * 100) : -1;
61
+ const now = Date.now();
62
+ if (now - lastTick < 500 && pct === lastPct)
63
+ return;
64
+ lastTick = now;
65
+ lastPct = pct;
66
+ line += e.total !== undefined ? ` ${e.bytes}/${e.total} (${pct}%)` : ` ${e.bytes} bytes`;
67
+ }
68
+ else if (e.phase === 'extract' && e.bytes !== undefined) {
69
+ return; // per-entry ticks are noise on a terminal
70
+ }
71
+ if (e.note !== undefined)
72
+ line += ` — ${e.note}`;
73
+ if (line === lastLine)
74
+ return;
75
+ lastLine = line;
76
+ process.stderr.write(line + '\n');
77
+ };
78
+ }
79
+ export async function main(argv) {
80
+ let p;
81
+ try {
82
+ p = parseArgs(argv);
83
+ }
84
+ catch (err) {
85
+ process.stderr.write(`${err.message}\n${USAGE}`);
86
+ return 2;
87
+ }
88
+ if (p.command === undefined || p.bools.has('help') || p.command === 'help') {
89
+ process.stdout.write(USAGE);
90
+ return p.command === undefined ? 2 : 0;
91
+ }
92
+ const quiet = p.bools.has('quiet');
93
+ try {
94
+ switch (p.command) {
95
+ case 'platform': {
96
+ process.stdout.write(JSON.stringify({ platform: platformKey() }) + '\n');
97
+ return 0;
98
+ }
99
+ case 'plan': {
100
+ const manifest = await readManifest(p.positional[0]);
101
+ const root = need(p, 'root');
102
+ const opts = { root };
103
+ const platform = p.flags.get('platform')?.at(-1);
104
+ if (platform !== undefined)
105
+ opts.platform = platform;
106
+ const only = p.flags.get('only');
107
+ if (only !== undefined)
108
+ opts.only = only;
109
+ const result = await plan(manifest, opts);
110
+ process.stdout.write(JSON.stringify({ root: path.resolve(root), platform: platform ?? platformKey(), plan: result }, null, 2) + '\n');
111
+ return 0;
112
+ }
113
+ case 'install': {
114
+ const manifest = await readManifest(p.positional[0]);
115
+ const root = need(p, 'root');
116
+ const opts = { root, onProgress: progressPrinter(quiet) };
117
+ const platform = p.flags.get('platform')?.at(-1);
118
+ if (platform !== undefined)
119
+ opts.platform = platform;
120
+ const only = p.flags.get('only');
121
+ if (only !== undefined)
122
+ opts.only = only;
123
+ const sources = p.flags.get('source');
124
+ if (sources !== undefined)
125
+ opts.sourceOrder = sources;
126
+ if (p.bools.has('force'))
127
+ opts.force = true;
128
+ const result = await provision(manifest, opts);
129
+ process.stdout.write(JSON.stringify({ root: path.resolve(root), platform: platform ?? platformKey(), results: result }, null, 2) + '\n');
130
+ return 0;
131
+ }
132
+ case 'remove': {
133
+ const item = p.positional[0];
134
+ if (item === undefined)
135
+ throw new UsageError('remove needs an <item>');
136
+ const result = await remove(item, { root: need(p, 'root') });
137
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
138
+ return 0;
139
+ }
140
+ case 'verify': {
141
+ const result = await verify({ root: need(p, 'root') });
142
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
143
+ return result.ok ? 0 : 1;
144
+ }
145
+ default:
146
+ process.stderr.write(`unknown command "${p.command}"\n${USAGE}`);
147
+ return 2;
148
+ }
149
+ }
150
+ catch (err) {
151
+ if (err instanceof UsageError) {
152
+ process.stderr.write(`${err.message}\n${USAGE}`);
153
+ return 2;
154
+ }
155
+ if (isProvisionError(err)) {
156
+ process.stderr.write(`error ${err.code}${err.item !== undefined ? ` [${err.item}]` : ''}: ${err.message}\n`);
157
+ process.stdout.write(JSON.stringify({ error: { code: err.code, item: err.item ?? null, message: err.message } }) + '\n');
158
+ return 1;
159
+ }
160
+ process.stderr.write(`error: ${err.message}\n`);
161
+ process.stdout.write(JSON.stringify({ error: { code: 'E_INTERNAL', message: err.message } }) + '\n');
162
+ return 1;
163
+ }
164
+ }
165
+ async function readManifest(file) {
166
+ if (file === undefined)
167
+ throw new UsageError('a <manifest.json> path is required');
168
+ return JSON.parse(await fs.readFile(file, 'utf8'));
169
+ }
@@ -0,0 +1,21 @@
1
+ import type { ProgressEvent } from './types.js';
2
+ export interface DownloadCandidate {
3
+ source: string;
4
+ kind: 'https' | 'file';
5
+ url: URL;
6
+ }
7
+ export interface DownloadOptions {
8
+ item: string;
9
+ /** Final path of the archive (inside staging). `.part` / `.part.json` live next to it. */
10
+ file: string;
11
+ expectedSha256: string;
12
+ expectedSize?: number | undefined;
13
+ onProgress?: ((e: ProgressEvent) => void) | undefined;
14
+ signal?: AbortSignal | undefined;
15
+ }
16
+ /**
17
+ * Try each candidate in order until one yields a file whose sha256 equals `expectedSha256`.
18
+ * A transport failure keeps the `.part` for a later resume; a digest mismatch deletes the file.
19
+ * Returns the source id that succeeded.
20
+ */
21
+ export declare function downloadVerified(candidates: DownloadCandidate[], opts: DownloadOptions): Promise<string>;
@@ -0,0 +1,242 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { checkpoint, downloadCheckpoint } from './checkpoints.js';
6
+ import { ProvisionError } from './errors.js';
7
+ import { exists, fsyncDir, readJson, sha256File, writeFileAtomic } from './fsutil.js';
8
+ import { redactUrl } from './paths.js';
9
+ const MAX_REDIRECTS = 10;
10
+ function abortError(item) {
11
+ return new ProvisionError('E_ABORTED', 'aborted by caller', { item });
12
+ }
13
+ /**
14
+ * Try each candidate in order until one yields a file whose sha256 equals `expectedSha256`.
15
+ * A transport failure keeps the `.part` for a later resume; a digest mismatch deletes the file.
16
+ * Returns the source id that succeeded.
17
+ */
18
+ export async function downloadVerified(candidates, opts) {
19
+ const { item, file, expectedSha256, onProgress } = opts;
20
+ let lastError;
21
+ if (await exists(file)) {
22
+ // A fully downloaded archive from an earlier run; trust it only if the digest matches.
23
+ if ((await sha256File(file)) === expectedSha256) {
24
+ onProgress?.({ item, phase: 'verify', note: 'reused' });
25
+ const meta = await readJson(file + '.json');
26
+ return meta?.url !== undefined ? candidates.find((c) => redactUrl(c.url.toString()) === meta.url)?.source ?? 'cached' : 'cached';
27
+ }
28
+ await fs.rm(file, { force: true });
29
+ }
30
+ for (const candidate of candidates) {
31
+ if (opts.signal?.aborted)
32
+ throw abortError(item);
33
+ try {
34
+ await downloadOne(candidate, opts);
35
+ }
36
+ catch (err) {
37
+ if (err instanceof ProvisionError && err.code === 'E_ABORTED')
38
+ throw err;
39
+ lastError = err instanceof ProvisionError ? err : new ProvisionError('E_DOWNLOAD', `download failed from ${candidate.source}: ${err.message}`, { item, cause: err });
40
+ onProgress?.({ item, phase: 'download', source: candidate.source, note: `source-failed: ${lastError.message}` });
41
+ continue;
42
+ }
43
+ onProgress?.({ item, phase: 'verify', source: candidate.source });
44
+ const actual = await sha256File(file);
45
+ /* MUTATION:sha256-compare */
46
+ if (actual !== expectedSha256) {
47
+ await fs.rm(file, { force: true });
48
+ await fs.rm(file + '.json', { force: true });
49
+ lastError = new ProvisionError('E_SHA256', `sha256 mismatch from source ${candidate.source}: expected ${expectedSha256}, got ${actual}`, { item });
50
+ onProgress?.({ item, phase: 'verify', source: candidate.source, note: 'sha256-mismatch' });
51
+ continue;
52
+ }
53
+ checkpoint('after-sha256');
54
+ return candidate.source;
55
+ }
56
+ throw lastError ?? new ProvisionError('E_SOURCE', 'no download candidates', { item });
57
+ }
58
+ async function downloadOne(candidate, opts) {
59
+ const { item, file, onProgress } = opts;
60
+ const part = file + '.part';
61
+ const metaFile = file + '.part.json';
62
+ await fs.mkdir(path.dirname(file), { recursive: true });
63
+ const cleanUrl = redactUrl(candidate.url.toString());
64
+ if (candidate.kind === 'file') {
65
+ await copyFromFileUrl(candidate, part, opts);
66
+ }
67
+ else {
68
+ await fetchHttps(candidate, part, metaFile, opts);
69
+ }
70
+ // Durability point of the download: bytes on disk, then the atomic rename to the final name.
71
+ const h = await fs.open(part, 'r+');
72
+ try {
73
+ await h.sync();
74
+ }
75
+ finally {
76
+ await h.close();
77
+ }
78
+ await fs.rename(part, file);
79
+ await writeFileAtomic(file + '.json', JSON.stringify({ url: cleanUrl, source: candidate.source }));
80
+ await fs.rm(metaFile, { force: true });
81
+ await fsyncDir(path.dirname(file));
82
+ checkpoint('after-download');
83
+ onProgress?.({ item, phase: 'download', source: candidate.source, note: 'complete' });
84
+ }
85
+ async function copyFromFileUrl(candidate, part, opts) {
86
+ const { item, onProgress, signal } = opts;
87
+ const src = fileURLToPath(candidate.url);
88
+ let total;
89
+ try {
90
+ total = (await fs.stat(src)).size;
91
+ }
92
+ catch (err) {
93
+ throw new ProvisionError('E_DOWNLOAD', `file source unreadable: ${redactUrl(candidate.url.toString())}`, { item, cause: err });
94
+ }
95
+ const out = await fs.open(part, 'w');
96
+ let bytes = 0;
97
+ try {
98
+ for await (const chunk of createReadStream(src, { highWaterMark: 1 << 20 })) {
99
+ if (signal?.aborted)
100
+ throw abortError(item);
101
+ await out.write(chunk);
102
+ bytes += chunk.length;
103
+ onProgress?.({ item, phase: 'download', source: candidate.source, bytes, total });
104
+ downloadCheckpoint(bytes);
105
+ }
106
+ }
107
+ finally {
108
+ await out.close();
109
+ }
110
+ }
111
+ async function fetchHttps(candidate, part, metaFile, opts) {
112
+ const { item, onProgress, signal } = opts;
113
+ const cleanUrl = redactUrl(candidate.url.toString());
114
+ let offset = 0;
115
+ let meta = await readJson(metaFile);
116
+ if (meta !== undefined && meta.url === cleanUrl && (await exists(part))) {
117
+ offset = (await fs.stat(part)).size;
118
+ }
119
+ else {
120
+ meta = undefined;
121
+ await fs.rm(part, { force: true });
122
+ await fs.rm(metaFile, { force: true });
123
+ }
124
+ for (let attempt = 0; attempt < 2; attempt++) {
125
+ const headers = { 'user-agent': 'hanamesh-provision/0.1', accept: '*/*' };
126
+ if (offset > 0) {
127
+ headers['range'] = `bytes=${offset}-`;
128
+ if (meta?.etag !== undefined)
129
+ headers['if-range'] = meta.etag;
130
+ }
131
+ const res = await followRedirects(candidate.url, headers, signal, item);
132
+ if (res.status === 416) {
133
+ // Server says our offset is past the end: our part is unusable.
134
+ await res.body?.cancel();
135
+ await fs.rm(part, { force: true });
136
+ await fs.rm(metaFile, { force: true });
137
+ offset = 0;
138
+ meta = undefined;
139
+ continue;
140
+ }
141
+ if (res.status !== 200 && res.status !== 206) {
142
+ await res.body?.cancel();
143
+ throw new ProvisionError('E_DOWNLOAD', `HTTP ${res.status} from ${cleanUrl}`, { item });
144
+ }
145
+ const etag = res.headers.get('etag') ?? undefined;
146
+ let total;
147
+ if (res.status === 206) {
148
+ const cr = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(res.headers.get('content-range') ?? '');
149
+ if (cr === null || Number(cr[1]) !== offset) {
150
+ await res.body?.cancel();
151
+ throw new ProvisionError('E_DOWNLOAD', `unexpected Content-Range for resume from ${cleanUrl}`, { item });
152
+ }
153
+ if (cr[3] !== '*')
154
+ total = Number(cr[3]);
155
+ onProgress?.({ item, phase: 'download', source: candidate.source, bytes: offset, total, note: `resume@${offset}` });
156
+ }
157
+ else {
158
+ // Server ignored Range (or fresh start): begin from zero.
159
+ if (offset > 0)
160
+ onProgress?.({ item, phase: 'download', source: candidate.source, note: 'range-unsupported' });
161
+ offset = 0;
162
+ const len = res.headers.get('content-length');
163
+ if (len !== null)
164
+ total = Number(len);
165
+ }
166
+ if (total === undefined)
167
+ total = opts.expectedSize;
168
+ const newMeta = { url: cleanUrl };
169
+ if (etag !== undefined)
170
+ newMeta.etag = etag;
171
+ if (total !== undefined)
172
+ newMeta.total = total;
173
+ await writeFileAtomic(metaFile, JSON.stringify(newMeta));
174
+ meta = newMeta;
175
+ if (res.body === null)
176
+ throw new ProvisionError('E_DOWNLOAD', `empty body from ${cleanUrl}`, { item });
177
+ const out = await fs.open(part, offset > 0 ? 'r+' : 'w');
178
+ let bytes = offset;
179
+ try {
180
+ if (offset > 0)
181
+ await out.truncate(offset);
182
+ const reader = res.body.getReader();
183
+ for (;;) {
184
+ if (signal?.aborted) {
185
+ await reader.cancel().catch(() => undefined);
186
+ throw abortError(item);
187
+ }
188
+ let step;
189
+ try {
190
+ step = await reader.read();
191
+ }
192
+ catch (err) {
193
+ if (signal?.aborted)
194
+ throw abortError(item);
195
+ throw new ProvisionError('E_DOWNLOAD', `connection lost at byte ${bytes} from ${cleanUrl}`, { item, cause: err });
196
+ }
197
+ if (step.done)
198
+ break;
199
+ await out.write(step.value, 0, step.value.length, bytes);
200
+ bytes += step.value.length;
201
+ onProgress?.({ item, phase: 'download', source: candidate.source, bytes, total });
202
+ downloadCheckpoint(bytes);
203
+ }
204
+ }
205
+ finally {
206
+ await out.close();
207
+ }
208
+ if (total !== undefined && bytes !== total) {
209
+ throw new ProvisionError('E_DOWNLOAD', `short read: got ${bytes} of ${total} bytes from ${cleanUrl}`, { item });
210
+ }
211
+ return;
212
+ }
213
+ throw new ProvisionError('E_DOWNLOAD', `could not establish a download from ${cleanUrl}`, { item });
214
+ }
215
+ async function followRedirects(start, headers, signal, item) {
216
+ let url = start;
217
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
218
+ if (url.protocol !== 'https:') {
219
+ /* MUTATION:https-only */
220
+ throw new ProvisionError('E_SOURCE', `refusing non-https URL ${redactUrl(url.toString())}`, { item });
221
+ }
222
+ let res;
223
+ try {
224
+ res = await fetch(url, { headers, redirect: 'manual', signal: signal ?? null });
225
+ }
226
+ catch (err) {
227
+ if (signal?.aborted)
228
+ throw abortError(item);
229
+ throw new ProvisionError('E_DOWNLOAD', `network error for ${redactUrl(url.toString())}: ${err.message}`, { item, cause: err });
230
+ }
231
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
232
+ const loc = res.headers.get('location');
233
+ await res.body?.cancel();
234
+ if (loc === null)
235
+ throw new ProvisionError('E_DOWNLOAD', `redirect without Location from ${redactUrl(url.toString())}`, { item });
236
+ url = new URL(loc, url);
237
+ continue;
238
+ }
239
+ return res;
240
+ }
241
+ throw new ProvisionError('E_DOWNLOAD', `too many redirects from ${redactUrl(start.toString())}`, { item });
242
+ }
@@ -0,0 +1,11 @@
1
+ export type ProvisionErrorCode = 'E_MANIFEST' | 'E_SOURCE' | 'E_DOWNLOAD' | 'E_SHA256' | 'E_ARCHIVE' | 'E_UNSAFE_PATH' | 'E_VERIFY' | 'E_PROMOTE' | 'E_LEDGER' | 'E_NOT_INSTALLED' | 'E_ABORTED' | 'E_UNSUPPORTED';
2
+ export declare class ProvisionError extends Error {
3
+ readonly name = "ProvisionError";
4
+ readonly code: ProvisionErrorCode;
5
+ readonly item: string | undefined;
6
+ constructor(code: ProvisionErrorCode, message: string, options?: {
7
+ item?: string;
8
+ cause?: unknown;
9
+ });
10
+ }
11
+ export declare function isProvisionError(err: unknown): err is ProvisionError;
@@ -0,0 +1,13 @@
1
+ export class ProvisionError extends Error {
2
+ name = 'ProvisionError';
3
+ code;
4
+ item;
5
+ constructor(code, message, options) {
6
+ super(message, options?.cause === undefined ? undefined : { cause: options.cause });
7
+ this.code = code;
8
+ this.item = options?.item;
9
+ }
10
+ }
11
+ export function isProvisionError(err) {
12
+ return err instanceof ProvisionError;
13
+ }
@@ -0,0 +1,17 @@
1
+ import type { ArchiveKind, OwnedFile, OwnedSymlink, ProgressEvent } from './types.js';
2
+ export interface ExtractOptions {
3
+ item: string;
4
+ strip?: number | undefined;
5
+ onProgress?: ((e: ProgressEvent) => void) | undefined;
6
+ }
7
+ export interface ExtractedTree {
8
+ files: OwnedFile[];
9
+ dirs: string[];
10
+ symlinks: OwnedSymlink[];
11
+ }
12
+ /**
13
+ * Extract an archive into `dest` (which must be empty or absent). Every entry is checked
14
+ * before anything touches the disk: no absolute paths, no `..`, no symlink that resolves
15
+ * outside `dest`, no write through a symlinked ancestor. Returns the ownership lists.
16
+ */
17
+ export declare function extractArchive(kind: ArchiveKind, archive: string, dest: string, opts: ExtractOptions): Promise<ExtractedTree>;