getculpa 1.0.9 → 1.1.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/lib/fetch.d.mts CHANGED
@@ -1,17 +1,17 @@
1
- export declare const ORIGIN: string;
2
- export function targetTriple(platform?: string, arch?: string): string;
3
-
4
- export interface FetchOptions {
5
- vendorDir?: string;
6
- origin?: string;
7
- trustedHosts?: Set<string>;
8
- allowInsecureHttp?: boolean;
9
- }
10
-
11
- export interface FetchResult {
12
- dest: string;
13
- sha256: string;
14
- }
15
-
16
- export function fetchLauncher(opts?: FetchOptions): Promise<FetchResult>;
17
- export function fetchCollectord(opts?: FetchOptions): Promise<FetchResult>;
1
+ export declare const ORIGIN: string;
2
+ export function targetTriple(platform?: string, arch?: string): string;
3
+
4
+ export interface FetchOptions {
5
+ vendorDir?: string;
6
+ origin?: string;
7
+ trustedHosts?: Set<string>;
8
+ allowInsecureHttp?: boolean;
9
+ }
10
+
11
+ export interface FetchResult {
12
+ dest: string;
13
+ sha256: string;
14
+ }
15
+
16
+ export function fetchLauncher(opts?: FetchOptions): Promise<FetchResult>;
17
+ export function fetchCollectord(opts?: FetchOptions): Promise<FetchResult>;
package/lib/fetch.mjs CHANGED
@@ -1,211 +1,211 @@
1
- // CF20-T3 — checksum-verified GitHub-release fetch, extracted from the
2
- // original scripts/install.js (AU-011) so it can be reused for the
3
- // collector binary and unit-tested against a local fixture server. Every
4
- // security property of the original is kept byte-for-byte:
5
- // - host allowlist enforced on every redirect hop (github.com,
6
- // objects.githubusercontent.com, release-assets.githubusercontent.com)
7
- // - https only
8
- // - bounded redirects (MAX_REDIRECTS)
9
- // - size caps enforced WHILE streaming, not after buffering
10
- // - write-temp + rename (never a half-written executable at the dest)
11
- // - sha256 verified against the release's published checksums file before
12
- // the file is ever written to its final path
13
- //
14
- // The one behavioral extension: fetchCollectord() treats "no checksum line
15
- // published for this asset" as an EXPECTED, non-fatal outcome (the release
16
- // process may ship without a collectord for a given triple yet) — everything
17
- // else, including a checksum MISMATCH, still fails closed exactly like the
18
- // launcher fetch.
19
-
20
- import crypto from "node:crypto";
21
- import fs from "node:fs";
22
- import path from "node:path";
23
- import { fileURLToPath } from "node:url";
24
-
25
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
- const packageRoot = path.join(__dirname, "..");
27
-
28
- // The ONE origin — mirrors native/culpa-launcher/src/origin.rs. Never read
29
- // from env or config. D-094: repointed to the public releases-only mirror
30
- // (MyaigiDev/culpa-releases); the source repo (myaigidev/culpa) stays
31
- // private.
32
- // Exported so tests can pin the production origin (CF21-T5 review: a revert
33
- // of this constant would 404 every fresh install and no injected-fixture test
34
- // can see it).
35
- export const ORIGIN = "https://github.com/MyaigiDev/culpa-releases/releases/latest/download";
36
- const TRUSTED_HOSTS = new Set([
37
- "github.com",
38
- "objects.githubusercontent.com",
39
- "release-assets.githubusercontent.com",
40
- ]);
41
- const MAX_REDIRECTS = 5;
42
-
43
- export function targetTriple(platform = process.platform, arch = process.arch) {
44
- if (platform === "win32" && arch === "x64") return "x86_64-pc-windows-msvc";
45
- if (platform === "linux" && arch === "x64") return "x86_64-unknown-linux-gnu";
46
- if (platform === "darwin" && arch === "x64") return "x86_64-apple-darwin";
47
- if (platform === "darwin" && arch === "arm64") return "aarch64-apple-darwin";
48
- throw new Error(`unsupported platform: ${platform}/${arch}`);
49
- }
50
-
51
- // allowInsecureHttp defaults false and is ONLY ever set by the fixture-server
52
- // tests (see tests/npm-core-fetch.test.ts) — no production call site (the
53
- // fetchLauncher()/fetchCollectord() callers in scripts/install.js) passes
54
- // it, so the real https-only enforcement is unchanged.
55
- function assertTrustedUrl(u, trustedHosts, allowInsecureHttp) {
56
- const protocolOk = u.protocol === "https:" || (allowInsecureHttp && u.protocol === "http:");
57
- if (!protocolOk) throw new Error(`${u}: only https is trusted (got ${u.protocol})`);
58
- if (!trustedHosts.has(u.hostname)) throw new Error(`${u}: untrusted redirect host refused (${u.hostname})`);
59
- }
60
-
61
- // Follows redirects manually so every hop is pinned to https + a trusted
62
- // host, up to MAX_REDIRECTS hops. `timeoutMs` bounds each hop individually.
63
- async function fetchTrusted(url, timeoutMs, trustedHosts, allowInsecureHttp) {
64
- let current = new URL(url);
65
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
66
- assertTrustedUrl(current, trustedHosts, allowInsecureHttp);
67
- const res = await fetch(current, { redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
68
- if (res.status >= 300 && res.status < 400) {
69
- const location = res.headers.get("location");
70
- if (!location) throw new Error(`${current}: redirect with no Location header`);
71
- current = new URL(location, current);
72
- continue;
73
- }
74
- if (res.status === 404) {
75
- const err = new Error(`GET ${current} -> 404`);
76
- err.notFound = true;
77
- throw err;
78
- }
79
- if (!res.ok) throw new Error(`GET ${current} -> ${res.status}`);
80
- return res;
81
- }
82
- throw new Error(`${url}: too many redirects (> ${MAX_REDIRECTS})`);
83
- }
84
-
85
- // Enforces maxBytes WHILE STREAMING so a hostile/misconfigured response with
86
- // no (or a lying) Content-Length cannot be buffered past the cap before it
87
- // is checked.
88
- async function fetchBytes(url, maxBytes, timeoutMs, trustedHosts, allowInsecureHttp) {
89
- const res = await fetchTrusted(url, timeoutMs, trustedHosts, allowInsecureHttp);
90
- if (!res.body) throw new Error(`${url}: response has no body`);
91
- const reader = res.body.getReader();
92
- const chunks = [];
93
- let total = 0;
94
- for (;;) {
95
- const { done, value } = await reader.read();
96
- if (done) break;
97
- total += value.length;
98
- if (total > maxBytes) {
99
- await reader.cancel();
100
- throw new Error(`${url}: response exceeds ${maxBytes} bytes`);
101
- }
102
- chunks.push(value);
103
- }
104
- return Buffer.concat(chunks);
105
- }
106
-
107
- function sha256Hex(buf) {
108
- return crypto.createHash("sha256").update(buf).digest("hex");
109
- }
110
-
111
- // CF20-R: the checksums file is `sha256sum`'s own output — one line per
112
- // asset, "<sha256> <filename>" (verified format: release-cli.yml runs
113
- // `sha256sum culpa-launcher-* culpa-collectord-*` on ubuntu-latest, GNU
114
- // coreutils' default text-mode two-space separator; tests/npm-core-fetch.test.ts's
115
- // fixtures construct lines the same way). `endsWith(assetName)` was a
116
- // substring match on the WHOLE line, not the filename field — a shorter
117
- // asset name that happens to be a suffix of an unrelated line (or a
118
- // malformed/commented line ending in the same text) could match the wrong
119
- // entry. Split on whitespace and compare the filename field exactly.
120
- function findChecksumLine(checksumsText, assetName) {
121
- const line = checksumsText
122
- .split("\n")
123
- .map((l) => l.trim())
124
- .find((l) => l.split(/\s+/)[1] === assetName);
125
- if (!line) return null;
126
- const expected = line.split(/\s+/)[0];
127
- if (!/^[0-9a-f]{64}$/.test(expected)) throw new Error("malformed checksum line");
128
- return expected;
129
- }
130
-
131
- function writeVerified(destDir, destName, binary) {
132
- fs.mkdirSync(destDir, { recursive: true });
133
- const dest = path.join(destDir, destName);
134
- const tmp = `${dest}.tmp.${process.pid}`;
135
- fs.writeFileSync(tmp, binary);
136
- fs.renameSync(tmp, dest);
137
- if (process.platform !== "win32") fs.chmodSync(dest, 0o755);
138
- return dest;
139
- }
140
-
141
- // origin/trustedHosts/allowInsecureHttp are all injectable — this is what
142
- // makes the fixture-server tests possible without touching the real GitHub
143
- // origin or weakening production's https-only enforcement.
144
- async function fetchVerifiedAsset({
145
- assetName,
146
- destDir,
147
- destName,
148
- maxBytes,
149
- origin = ORIGIN,
150
- trustedHosts = TRUSTED_HOSTS,
151
- allowInsecureHttp = false,
152
- }) {
153
- const checksumsBuf = await fetchBytes(
154
- `${origin}/culpa-launcher-checksums.txt`,
155
- 64 * 1024,
156
- 120_000,
157
- trustedHosts,
158
- allowInsecureHttp,
159
- );
160
- const expected = findChecksumLine(checksumsBuf.toString("utf8"), assetName);
161
- if (!expected) {
162
- const err = new Error(`no checksum published for ${assetName}`);
163
- err.notFound = true;
164
- throw err;
165
- }
166
- const binary = await fetchBytes(`${origin}/${assetName}`, maxBytes, 15 * 60_000, trustedHosts, allowInsecureHttp);
167
- const actual = sha256Hex(binary);
168
- if (actual !== expected) {
169
- throw new Error(`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`);
170
- }
171
- const dest = writeVerified(destDir, destName, binary);
172
- return { dest, sha256: actual };
173
- }
174
-
175
- export async function fetchLauncher(opts = {}) {
176
- const triple = targetTriple();
177
- const exe = process.platform === "win32" ? ".exe" : "";
178
- const assetName = `culpa-launcher-${triple}${exe}`;
179
- const vendorDir = opts.vendorDir ?? path.join(packageRoot, "vendor");
180
- const result = await fetchVerifiedAsset({
181
- assetName,
182
- destDir: vendorDir,
183
- destName: `culpa-launcher${exe}`,
184
- maxBytes: 64 * 1024 * 1024,
185
- origin: opts.origin,
186
- trustedHosts: opts.trustedHosts,
187
- allowInsecureHttp: opts.allowInsecureHttp,
188
- });
189
- console.log(`getculpa: installed culpa launcher (${triple}, sha256 ${result.sha256.slice(0, 12)}…)`);
190
- return result;
191
- }
192
-
193
- // Graceful-degrade case lives here, not in the caller: "not published for
194
- // this release yet" is the ONE outcome that must not abort the install.
195
- export async function fetchCollectord(opts = {}) {
196
- const triple = targetTriple();
197
- const exe = process.platform === "win32" ? ".exe" : "";
198
- const assetName = `culpa-collectord-${triple}${exe}`;
199
- const vendorDir = opts.vendorDir ?? path.join(packageRoot, "vendor");
200
- const result = await fetchVerifiedAsset({
201
- assetName,
202
- destDir: vendorDir,
203
- destName: `culpa-collectord${exe}`,
204
- maxBytes: 64 * 1024 * 1024,
205
- origin: opts.origin,
206
- trustedHosts: opts.trustedHosts,
207
- allowInsecureHttp: opts.allowInsecureHttp,
208
- });
209
- console.log(`getculpa: installed capture collector (${triple}, sha256 ${result.sha256.slice(0, 12)}…)`);
210
- return result;
211
- }
1
+ // CF20-T3 — checksum-verified GitHub-release fetch, extracted from the
2
+ // original scripts/install.js (AU-011) so it can be reused for the
3
+ // collector binary and unit-tested against a local fixture server. Every
4
+ // security property of the original is kept byte-for-byte:
5
+ // - host allowlist enforced on every redirect hop (github.com,
6
+ // objects.githubusercontent.com, release-assets.githubusercontent.com)
7
+ // - https only
8
+ // - bounded redirects (MAX_REDIRECTS)
9
+ // - size caps enforced WHILE streaming, not after buffering
10
+ // - write-temp + rename (never a half-written executable at the dest)
11
+ // - sha256 verified against the release's published checksums file before
12
+ // the file is ever written to its final path
13
+ //
14
+ // The one behavioral extension: fetchCollectord() treats "no checksum line
15
+ // published for this asset" as an EXPECTED, non-fatal outcome (the release
16
+ // process may ship without a collectord for a given triple yet) — everything
17
+ // else, including a checksum MISMATCH, still fails closed exactly like the
18
+ // launcher fetch.
19
+
20
+ import crypto from "node:crypto";
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ const packageRoot = path.join(__dirname, "..");
27
+
28
+ // The ONE origin — mirrors native/culpa-launcher/src/origin.rs. Never read
29
+ // from env or config. D-094: repointed to the public releases-only mirror
30
+ // (MyaigiDev/culpa-releases); the source repo (myaigidev/culpa) stays
31
+ // private.
32
+ // Exported so tests can pin the production origin (CF21-T5 review: a revert
33
+ // of this constant would 404 every fresh install and no injected-fixture test
34
+ // can see it).
35
+ export const ORIGIN = "https://github.com/MyaigiDev/culpa-releases/releases/latest/download";
36
+ const TRUSTED_HOSTS = new Set([
37
+ "github.com",
38
+ "objects.githubusercontent.com",
39
+ "release-assets.githubusercontent.com",
40
+ ]);
41
+ const MAX_REDIRECTS = 5;
42
+
43
+ export function targetTriple(platform = process.platform, arch = process.arch) {
44
+ if (platform === "win32" && arch === "x64") return "x86_64-pc-windows-msvc";
45
+ if (platform === "linux" && arch === "x64") return "x86_64-unknown-linux-gnu";
46
+ if (platform === "darwin" && arch === "x64") return "x86_64-apple-darwin";
47
+ if (platform === "darwin" && arch === "arm64") return "aarch64-apple-darwin";
48
+ throw new Error(`unsupported platform: ${platform}/${arch}`);
49
+ }
50
+
51
+ // allowInsecureHttp defaults false and is ONLY ever set by the fixture-server
52
+ // tests (see tests/npm-core-fetch.test.ts) — no production call site (the
53
+ // fetchLauncher()/fetchCollectord() callers in scripts/install.js) passes
54
+ // it, so the real https-only enforcement is unchanged.
55
+ function assertTrustedUrl(u, trustedHosts, allowInsecureHttp) {
56
+ const protocolOk = u.protocol === "https:" || (allowInsecureHttp && u.protocol === "http:");
57
+ if (!protocolOk) throw new Error(`${u}: only https is trusted (got ${u.protocol})`);
58
+ if (!trustedHosts.has(u.hostname)) throw new Error(`${u}: untrusted redirect host refused (${u.hostname})`);
59
+ }
60
+
61
+ // Follows redirects manually so every hop is pinned to https + a trusted
62
+ // host, up to MAX_REDIRECTS hops. `timeoutMs` bounds each hop individually.
63
+ async function fetchTrusted(url, timeoutMs, trustedHosts, allowInsecureHttp) {
64
+ let current = new URL(url);
65
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
66
+ assertTrustedUrl(current, trustedHosts, allowInsecureHttp);
67
+ const res = await fetch(current, { redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
68
+ if (res.status >= 300 && res.status < 400) {
69
+ const location = res.headers.get("location");
70
+ if (!location) throw new Error(`${current}: redirect with no Location header`);
71
+ current = new URL(location, current);
72
+ continue;
73
+ }
74
+ if (res.status === 404) {
75
+ const err = new Error(`GET ${current} -> 404`);
76
+ err.notFound = true;
77
+ throw err;
78
+ }
79
+ if (!res.ok) throw new Error(`GET ${current} -> ${res.status}`);
80
+ return res;
81
+ }
82
+ throw new Error(`${url}: too many redirects (> ${MAX_REDIRECTS})`);
83
+ }
84
+
85
+ // Enforces maxBytes WHILE STREAMING so a hostile/misconfigured response with
86
+ // no (or a lying) Content-Length cannot be buffered past the cap before it
87
+ // is checked.
88
+ async function fetchBytes(url, maxBytes, timeoutMs, trustedHosts, allowInsecureHttp) {
89
+ const res = await fetchTrusted(url, timeoutMs, trustedHosts, allowInsecureHttp);
90
+ if (!res.body) throw new Error(`${url}: response has no body`);
91
+ const reader = res.body.getReader();
92
+ const chunks = [];
93
+ let total = 0;
94
+ for (;;) {
95
+ const { done, value } = await reader.read();
96
+ if (done) break;
97
+ total += value.length;
98
+ if (total > maxBytes) {
99
+ await reader.cancel();
100
+ throw new Error(`${url}: response exceeds ${maxBytes} bytes`);
101
+ }
102
+ chunks.push(value);
103
+ }
104
+ return Buffer.concat(chunks);
105
+ }
106
+
107
+ function sha256Hex(buf) {
108
+ return crypto.createHash("sha256").update(buf).digest("hex");
109
+ }
110
+
111
+ // CF20-R: the checksums file is `sha256sum`'s own output — one line per
112
+ // asset, "<sha256> <filename>" (verified format: release-cli.yml runs
113
+ // `sha256sum culpa-launcher-* culpa-collectord-*` on ubuntu-latest, GNU
114
+ // coreutils' default text-mode two-space separator; tests/npm-core-fetch.test.ts's
115
+ // fixtures construct lines the same way). `endsWith(assetName)` was a
116
+ // substring match on the WHOLE line, not the filename field — a shorter
117
+ // asset name that happens to be a suffix of an unrelated line (or a
118
+ // malformed/commented line ending in the same text) could match the wrong
119
+ // entry. Split on whitespace and compare the filename field exactly.
120
+ function findChecksumLine(checksumsText, assetName) {
121
+ const line = checksumsText
122
+ .split("\n")
123
+ .map((l) => l.trim())
124
+ .find((l) => l.split(/\s+/)[1] === assetName);
125
+ if (!line) return null;
126
+ const expected = line.split(/\s+/)[0];
127
+ if (!/^[0-9a-f]{64}$/.test(expected)) throw new Error("malformed checksum line");
128
+ return expected;
129
+ }
130
+
131
+ function writeVerified(destDir, destName, binary) {
132
+ fs.mkdirSync(destDir, { recursive: true });
133
+ const dest = path.join(destDir, destName);
134
+ const tmp = `${dest}.tmp.${process.pid}`;
135
+ fs.writeFileSync(tmp, binary);
136
+ fs.renameSync(tmp, dest);
137
+ if (process.platform !== "win32") fs.chmodSync(dest, 0o755);
138
+ return dest;
139
+ }
140
+
141
+ // origin/trustedHosts/allowInsecureHttp are all injectable — this is what
142
+ // makes the fixture-server tests possible without touching the real GitHub
143
+ // origin or weakening production's https-only enforcement.
144
+ async function fetchVerifiedAsset({
145
+ assetName,
146
+ destDir,
147
+ destName,
148
+ maxBytes,
149
+ origin = ORIGIN,
150
+ trustedHosts = TRUSTED_HOSTS,
151
+ allowInsecureHttp = false,
152
+ }) {
153
+ const checksumsBuf = await fetchBytes(
154
+ `${origin}/culpa-launcher-checksums.txt`,
155
+ 64 * 1024,
156
+ 120_000,
157
+ trustedHosts,
158
+ allowInsecureHttp,
159
+ );
160
+ const expected = findChecksumLine(checksumsBuf.toString("utf8"), assetName);
161
+ if (!expected) {
162
+ const err = new Error(`no checksum published for ${assetName}`);
163
+ err.notFound = true;
164
+ throw err;
165
+ }
166
+ const binary = await fetchBytes(`${origin}/${assetName}`, maxBytes, 15 * 60_000, trustedHosts, allowInsecureHttp);
167
+ const actual = sha256Hex(binary);
168
+ if (actual !== expected) {
169
+ throw new Error(`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`);
170
+ }
171
+ const dest = writeVerified(destDir, destName, binary);
172
+ return { dest, sha256: actual };
173
+ }
174
+
175
+ export async function fetchLauncher(opts = {}) {
176
+ const triple = targetTriple();
177
+ const exe = process.platform === "win32" ? ".exe" : "";
178
+ const assetName = `culpa-launcher-${triple}${exe}`;
179
+ const vendorDir = opts.vendorDir ?? path.join(packageRoot, "vendor");
180
+ const result = await fetchVerifiedAsset({
181
+ assetName,
182
+ destDir: vendorDir,
183
+ destName: `culpa-launcher${exe}`,
184
+ maxBytes: 64 * 1024 * 1024,
185
+ origin: opts.origin,
186
+ trustedHosts: opts.trustedHosts,
187
+ allowInsecureHttp: opts.allowInsecureHttp,
188
+ });
189
+ console.log(`getculpa: installed culpa launcher (${triple}, sha256 ${result.sha256.slice(0, 12)}…)`);
190
+ return result;
191
+ }
192
+
193
+ // Graceful-degrade case lives here, not in the caller: "not published for
194
+ // this release yet" is the ONE outcome that must not abort the install.
195
+ export async function fetchCollectord(opts = {}) {
196
+ const triple = targetTriple();
197
+ const exe = process.platform === "win32" ? ".exe" : "";
198
+ const assetName = `culpa-collectord-${triple}${exe}`;
199
+ const vendorDir = opts.vendorDir ?? path.join(packageRoot, "vendor");
200
+ const result = await fetchVerifiedAsset({
201
+ assetName,
202
+ destDir: vendorDir,
203
+ destName: `culpa-collectord${exe}`,
204
+ maxBytes: 64 * 1024 * 1024,
205
+ origin: opts.origin,
206
+ trustedHosts: opts.trustedHosts,
207
+ allowInsecureHttp: opts.allowInsecureHttp,
208
+ });
209
+ console.log(`getculpa: installed capture collector (${triple}, sha256 ${result.sha256.slice(0, 12)}…)`);
210
+ return result;
211
+ }
@@ -1,18 +1,20 @@
1
- import type { UpgradeClassification } from "./preflight.d.mts";
2
- import type { DockerState } from "./provision.d.mts";
3
-
4
- export interface InstallSummaryOptions {
5
- classification: UpgradeClassification;
6
- version: string;
7
- previousVersion?: string | null;
8
- appDir: string;
9
- dockerState: DockerState;
10
- imagesStaged?: boolean;
11
- collectorStaged?: boolean;
12
- payloadState?: "installed" | "staged" | "missing";
13
- platform?: string;
14
- isTTY?: boolean;
15
- env?: Record<string, string | undefined>;
16
- }
17
-
18
- export function buildInstallSummary(opts: InstallSummaryOptions): string[];
1
+ import type { UpgradeClassification } from "./preflight.d.mts";
2
+ import type { DockerState } from "./provision.d.mts";
3
+
4
+ export interface InstallSummaryOptions {
5
+ classification: UpgradeClassification;
6
+ version: string;
7
+ previousVersion?: string | null;
8
+ appDir: string;
9
+ dockerState: DockerState;
10
+ imagesStaged?: boolean;
11
+ collectorStaged?: boolean;
12
+ payloadState?: "installed" | "staged" | "behind" | "missing";
13
+ /** T-V1010-1: the versions read from updates/state.json, so the row can name them. */
14
+ payloadDetail?: { current: string | null; pending: string | null } | null;
15
+ platform?: string;
16
+ isTTY?: boolean;
17
+ env?: Record<string, string | undefined>;
18
+ }
19
+
20
+ export function buildInstallSummary(opts: InstallSummaryOptions): string[];