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/LICENSE +43 -43
- package/README.md +9 -2
- package/assets/culpa-collector.ps1 +137 -137
- package/assets/culpa-compose.yml +161 -146
- package/assets/launch-culpa.ps1 +415 -415
- package/bin/culpa.js +22 -22
- package/bin/getculpa.js +186 -175
- package/lib/assets.d.mts +2 -2
- package/lib/bootstrap.d.mts +48 -29
- package/lib/bootstrap.mjs +163 -97
- package/lib/dispatch.d.mts +12 -12
- package/lib/dispatch.mjs +66 -59
- package/lib/docker.d.mts +76 -76
- package/lib/docker.mjs +290 -270
- package/lib/doctor.d.mts +29 -29
- package/lib/doctor.mjs +454 -420
- package/lib/fetch.d.mts +17 -17
- package/lib/fetch.mjs +211 -211
- package/lib/install-summary.d.mts +20 -18
- package/lib/install-summary.mjs +184 -156
- package/lib/paths.d.mts +9 -9
- package/lib/paths.mjs +61 -61
- package/lib/preflight.d.mts +45 -45
- package/lib/preflight.mjs +322 -274
- package/lib/provision.d.mts +54 -54
- package/lib/provision.mjs +47 -5
- package/lib/repair.d.mts +21 -21
- package/lib/repair.mjs +7 -7
- package/lib/start.d.mts +50 -44
- package/lib/start.mjs +575 -487
- package/lib/status.d.mts +23 -23
- package/lib/status.mjs +74 -74
- package/lib/stop.d.mts +15 -15
- package/lib/stop.mjs +46 -46
- package/lib/tty.d.mts +18 -18
- package/lib/tty.mjs +58 -58
- package/package.json +1 -1
- package/scripts/install.js +9 -2
- package/scripts/prepack.js +53 -53
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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[];
|