@lpm-registry/cli 0.42.0 → 0.43.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lpm-registry/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "CLI for Licensed Package Manager",
|
|
5
5
|
"author": "LPM <hello@lpm.dev> (https://lpm.dev)",
|
|
6
6
|
"repository": {
|
|
@@ -61,12 +61,13 @@
|
|
|
61
61
|
"xcode": "^3.0.1"
|
|
62
62
|
},
|
|
63
63
|
"optionalDependencies": {
|
|
64
|
-
"@lpm-registry/cli-darwin-arm64": "0.
|
|
65
|
-
"@lpm-registry/cli-darwin-x64": "0.
|
|
66
|
-
"@lpm-registry/cli-linux-x64": "0.
|
|
67
|
-
"@lpm-registry/cli-linux-arm64": "0.
|
|
68
|
-
"@lpm-registry/cli-win32-x64": "0.
|
|
69
|
-
"keytar": "^7.9.0"
|
|
64
|
+
"@lpm-registry/cli-darwin-arm64": "0.43.0",
|
|
65
|
+
"@lpm-registry/cli-darwin-x64": "0.43.0",
|
|
66
|
+
"@lpm-registry/cli-linux-x64": "0.43.0",
|
|
67
|
+
"@lpm-registry/cli-linux-arm64": "0.43.0",
|
|
68
|
+
"@lpm-registry/cli-win32-x64": "0.43.0",
|
|
69
|
+
"keytar": "^7.9.0",
|
|
70
|
+
"sigstore": "^3.0.0"
|
|
70
71
|
},
|
|
71
72
|
"type": "module",
|
|
72
73
|
"engines": {
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-2 integrity-gate tests for scripts/install-binary.js.
|
|
3
|
+
*
|
|
4
|
+
* The suite mocks the network primitives (`download`, `downloadToBuffer`,
|
|
5
|
+
* `sha256File`, `tryVerifyManifestSignature`) via the dependency-injection
|
|
6
|
+
* `deps` parameter on `tryGitHubDownload`. No real network calls fire.
|
|
7
|
+
*
|
|
8
|
+
* Coverage:
|
|
9
|
+
* - happy path: manifest SHA matches → binary installed.
|
|
10
|
+
* - manifest 404 → return null (falls through to JS CLI fallback).
|
|
11
|
+
* - SHA mismatch → return null + tmp file cleaned up.
|
|
12
|
+
* - manifest missing the platform binary entry → return null.
|
|
13
|
+
* - Sigstore verifier rejected → return null.
|
|
14
|
+
* - Sigstore package unavailable → SHA-only path runs, WARN logged.
|
|
15
|
+
* - Sigstore verified → both gates surface.
|
|
16
|
+
* - LPM_INSTALL_INSECURE=1 → all integrity skipped, install proceeds.
|
|
17
|
+
*
|
|
18
|
+
* Pure-helper coverage:
|
|
19
|
+
* - parseManifestEntry: canonical, malformed, missing-platform.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import crypto from "node:crypto"
|
|
23
|
+
import fs from "node:fs"
|
|
24
|
+
import os from "node:os"
|
|
25
|
+
import path from "node:path"
|
|
26
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
download,
|
|
30
|
+
downloadToBuffer,
|
|
31
|
+
parseManifestEntry,
|
|
32
|
+
tryGitHubDownload,
|
|
33
|
+
} from "../install-binary.js"
|
|
34
|
+
|
|
35
|
+
function sha256(bytes) {
|
|
36
|
+
return crypto.createHash("sha256").update(bytes).digest("hex")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("parseManifestEntry", () => {
|
|
40
|
+
it("returns the SHA for a matching filename", () => {
|
|
41
|
+
const manifest =
|
|
42
|
+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa lpm-darwin-arm64\n" +
|
|
43
|
+
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb lpm-linux-x64\n"
|
|
44
|
+
expect(parseManifestEntry(manifest, "lpm-linux-x64")).toBe(
|
|
45
|
+
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("returns null when the filename is not enumerated", () => {
|
|
50
|
+
const manifest =
|
|
51
|
+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa lpm-darwin-arm64\n"
|
|
52
|
+
expect(parseManifestEntry(manifest, "lpm-linux-x64")).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("returns null on malformed lines (short digest)", () => {
|
|
56
|
+
const manifest = "abc lpm-linux-x64\n"
|
|
57
|
+
expect(parseManifestEntry(manifest, "lpm-linux-x64")).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("returns null on malformed lines (non-hex)", () => {
|
|
61
|
+
const manifest =
|
|
62
|
+
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz lpm-linux-x64\n"
|
|
63
|
+
expect(parseManifestEntry(manifest, "lpm-linux-x64")).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("returns null on malformed lines (single-space separator)", () => {
|
|
67
|
+
const manifest =
|
|
68
|
+
"0000000000000000000000000000000000000000000000000000000000000000 lpm-linux-x64\n"
|
|
69
|
+
expect(parseManifestEntry(manifest, "lpm-linux-x64")).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("strips trailing carriage returns for Windows-format manifests", () => {
|
|
73
|
+
const manifest =
|
|
74
|
+
"1111111111111111111111111111111111111111111111111111111111111111 lpm-darwin-arm64\r\n"
|
|
75
|
+
expect(parseManifestEntry(manifest, "lpm-darwin-arm64")).toBe(
|
|
76
|
+
"1111111111111111111111111111111111111111111111111111111111111111",
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("lowercases hex digests", () => {
|
|
81
|
+
const manifest =
|
|
82
|
+
"ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890 X\n"
|
|
83
|
+
expect(parseManifestEntry(manifest, "X")).toBe(
|
|
84
|
+
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("tryGitHubDownload", () => {
|
|
90
|
+
let tmpBinDir
|
|
91
|
+
let assetBytes
|
|
92
|
+
let assetSha
|
|
93
|
+
let manifestText
|
|
94
|
+
let priorInsecure
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
tmpBinDir = fs.mkdtempSync(path.join(os.tmpdir(), "lpm-install-binary-"))
|
|
98
|
+
assetBytes = Buffer.from("native-binary-fixture-bytes")
|
|
99
|
+
assetSha = sha256(assetBytes)
|
|
100
|
+
manifestText = `${assetSha} lpm-linux-x64\n`
|
|
101
|
+
priorInsecure = process.env.LPM_INSTALL_INSECURE
|
|
102
|
+
delete process.env.LPM_INSTALL_INSECURE
|
|
103
|
+
// Stub console.log so the test output stays clean. Replace
|
|
104
|
+
// per-test so individual cases can re-bind to capture lines.
|
|
105
|
+
vi.spyOn(console, "log").mockImplementation(() => {})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
fs.rmSync(tmpBinDir, { recursive: true, force: true })
|
|
110
|
+
if (priorInsecure === undefined) {
|
|
111
|
+
delete process.env.LPM_INSTALL_INSECURE
|
|
112
|
+
} else {
|
|
113
|
+
process.env.LPM_INSTALL_INSECURE = priorInsecure
|
|
114
|
+
}
|
|
115
|
+
vi.restoreAllMocks()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
function mkDeps(overrides = {}) {
|
|
119
|
+
return {
|
|
120
|
+
destDir: tmpBinDir,
|
|
121
|
+
download: vi.fn(async (_url, dest) => {
|
|
122
|
+
fs.writeFileSync(dest, assetBytes)
|
|
123
|
+
}),
|
|
124
|
+
downloadToBuffer: vi.fn(async url => {
|
|
125
|
+
if (url.endsWith("SHA256SUMS.txt")) {
|
|
126
|
+
return Buffer.from(manifestText, "utf-8")
|
|
127
|
+
}
|
|
128
|
+
if (url.endsWith("SHA256SUMS.txt.sigstore")) {
|
|
129
|
+
return Buffer.from("dummy-bundle-bytes")
|
|
130
|
+
}
|
|
131
|
+
throw Object.assign(new Error("not mocked"), { statusCode: 404 })
|
|
132
|
+
}),
|
|
133
|
+
sha256File: vi.fn(async filePath => sha256(fs.readFileSync(filePath))),
|
|
134
|
+
tryVerifyManifestSignature: vi.fn(async () => "unavailable"),
|
|
135
|
+
...overrides,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it("installs the verified binary when SHA matches and Sigstore is unavailable", async () => {
|
|
140
|
+
const deps = mkDeps()
|
|
141
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
142
|
+
expect(result).toBeTruthy()
|
|
143
|
+
expect(fs.existsSync(result)).toBe(true)
|
|
144
|
+
// The installed bytes must match the verified bytes — guards
|
|
145
|
+
// against a regression that "verifies" but installs garbage.
|
|
146
|
+
expect(fs.readFileSync(result)).toEqual(assetBytes)
|
|
147
|
+
expect(deps.downloadToBuffer).toHaveBeenCalledTimes(2)
|
|
148
|
+
expect(deps.sha256File).toHaveBeenCalledTimes(1)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("returns null when the manifest is 404 (predates signed-install gate)", async () => {
|
|
152
|
+
const deps = mkDeps({
|
|
153
|
+
downloadToBuffer: vi.fn(async url => {
|
|
154
|
+
if (url.endsWith("SHA256SUMS.txt")) {
|
|
155
|
+
throw Object.assign(new Error("HTTP 404"), { statusCode: 404 })
|
|
156
|
+
}
|
|
157
|
+
return Buffer.from("never reached")
|
|
158
|
+
}),
|
|
159
|
+
})
|
|
160
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
161
|
+
expect(result).toBeNull()
|
|
162
|
+
// Asset must NOT have been fetched once we know the manifest
|
|
163
|
+
// is missing — releases that predate the gate are install-via-
|
|
164
|
+
// manual-link, not silently downgraded to unsigned.
|
|
165
|
+
expect(deps.download).not.toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("returns null and cleans up the tmp file when SHA mismatches", async () => {
|
|
169
|
+
const wrongSha =
|
|
170
|
+
"0000000000000000000000000000000000000000000000000000000000000000"
|
|
171
|
+
const deps = mkDeps({
|
|
172
|
+
downloadToBuffer: vi.fn(async url => {
|
|
173
|
+
if (url.endsWith("SHA256SUMS.txt")) {
|
|
174
|
+
return Buffer.from(`${wrongSha} lpm-linux-x64\n`, "utf-8")
|
|
175
|
+
}
|
|
176
|
+
return Buffer.from("dummy-bundle-bytes")
|
|
177
|
+
}),
|
|
178
|
+
})
|
|
179
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
180
|
+
expect(result).toBeNull()
|
|
181
|
+
expect(deps.download).toHaveBeenCalledTimes(1)
|
|
182
|
+
// destDir must be empty — the rejected binary's tmp file must be
|
|
183
|
+
// cleaned up so no partially-downloaded asset survives the gate.
|
|
184
|
+
expect(fs.readdirSync(tmpBinDir)).toEqual([])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("returns null when the manifest does not enumerate the platform binary", async () => {
|
|
188
|
+
const deps = mkDeps({
|
|
189
|
+
downloadToBuffer: vi.fn(async url => {
|
|
190
|
+
if (url.endsWith("SHA256SUMS.txt")) {
|
|
191
|
+
return Buffer.from(`${assetSha} lpm-some-other-platform\n`, "utf-8")
|
|
192
|
+
}
|
|
193
|
+
return Buffer.from("dummy-bundle-bytes")
|
|
194
|
+
}),
|
|
195
|
+
})
|
|
196
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
197
|
+
expect(result).toBeNull()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("returns null when Sigstore verifier explicitly rejects the bundle", async () => {
|
|
201
|
+
const deps = mkDeps({
|
|
202
|
+
tryVerifyManifestSignature: vi.fn(async () => "rejected"),
|
|
203
|
+
})
|
|
204
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
205
|
+
expect(result).toBeNull()
|
|
206
|
+
expect(deps.download).not.toHaveBeenCalled()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("degrades to SHA-only with WARN when sigstore package is unavailable", async () => {
|
|
210
|
+
const logSpy = vi.spyOn(console, "log")
|
|
211
|
+
const deps = mkDeps({
|
|
212
|
+
tryVerifyManifestSignature: vi.fn(async () => "unavailable"),
|
|
213
|
+
})
|
|
214
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
215
|
+
expect(result).toBeTruthy()
|
|
216
|
+
const warned = logSpy.mock.calls.some(args =>
|
|
217
|
+
String(args[0] ?? "").includes(
|
|
218
|
+
"optional 'sigstore' package not available",
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
expect(warned).toBe(true)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("emits Sigstore verified message when verifier succeeds", async () => {
|
|
225
|
+
const logSpy = vi.spyOn(console, "log")
|
|
226
|
+
const deps = mkDeps({
|
|
227
|
+
tryVerifyManifestSignature: vi.fn(async () => "verified"),
|
|
228
|
+
})
|
|
229
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
230
|
+
expect(result).toBeTruthy()
|
|
231
|
+
const verifiedLine = logSpy.mock.calls.some(args =>
|
|
232
|
+
String(args[0] ?? "").includes("Verified Sigstore signature on manifest"),
|
|
233
|
+
)
|
|
234
|
+
expect(verifiedLine).toBe(true)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("skips ALL integrity checks under LPM_INSTALL_INSECURE=1 with a loud WARN", async () => {
|
|
238
|
+
process.env.LPM_INSTALL_INSECURE = "1"
|
|
239
|
+
const logSpy = vi.spyOn(console, "log")
|
|
240
|
+
// downloadToBuffer must NOT be called when insecure mode is on —
|
|
241
|
+
// the manifest fetch is part of the integrity flow that gets
|
|
242
|
+
// skipped wholesale.
|
|
243
|
+
const deps = mkDeps({
|
|
244
|
+
downloadToBuffer: vi.fn(async () => {
|
|
245
|
+
throw new Error(
|
|
246
|
+
"manifest fetch must not run under LPM_INSTALL_INSECURE=1",
|
|
247
|
+
)
|
|
248
|
+
}),
|
|
249
|
+
})
|
|
250
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
251
|
+
expect(result).toBeTruthy()
|
|
252
|
+
expect(fs.readFileSync(result)).toEqual(assetBytes)
|
|
253
|
+
const warned = logSpy.mock.calls.some(args =>
|
|
254
|
+
String(args[0] ?? "").includes("LPM_INSTALL_INSECURE=1"),
|
|
255
|
+
)
|
|
256
|
+
expect(warned).toBe(true)
|
|
257
|
+
expect(deps.downloadToBuffer).not.toHaveBeenCalled()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("returns null and cleans up if the asset download fails", async () => {
|
|
261
|
+
const deps = mkDeps({
|
|
262
|
+
download: vi.fn(async () => {
|
|
263
|
+
throw new Error("network down")
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
267
|
+
expect(result).toBeNull()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("treats a missing Sigstore bundle as opportunistic skip (SHA gate still runs)", async () => {
|
|
271
|
+
const logSpy = vi.spyOn(console, "log")
|
|
272
|
+
// Bundle fetch fails; manifest succeeds. SHA gate still runs.
|
|
273
|
+
const deps = mkDeps({
|
|
274
|
+
downloadToBuffer: vi.fn(async url => {
|
|
275
|
+
if (url.endsWith("SHA256SUMS.txt")) {
|
|
276
|
+
return Buffer.from(manifestText, "utf-8")
|
|
277
|
+
}
|
|
278
|
+
throw Object.assign(new Error("HTTP 404"), { statusCode: 404 })
|
|
279
|
+
}),
|
|
280
|
+
tryVerifyManifestSignature: vi.fn(async () => {
|
|
281
|
+
throw new Error("must not be called when bundle is absent")
|
|
282
|
+
}),
|
|
283
|
+
})
|
|
284
|
+
const result = await tryGitHubDownload("0.42.0", "lpm-linux-x64", deps)
|
|
285
|
+
expect(result).toBeTruthy()
|
|
286
|
+
expect(deps.tryVerifyManifestSignature).not.toHaveBeenCalled()
|
|
287
|
+
// SHA-only path was the floor — no Sigstore line printed.
|
|
288
|
+
const sigstoreLine = logSpy.mock.calls.some(args =>
|
|
289
|
+
String(args[0] ?? "").includes("Verified Sigstore signature"),
|
|
290
|
+
)
|
|
291
|
+
expect(sigstoreLine).toBe(false)
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* HTTPS-only transport hardening. The integrity gate's SHA / Sigstore
|
|
297
|
+
* verification only protects what's on the wire — a redirect that
|
|
298
|
+
* downgrades transport to plaintext would let an on-path attacker
|
|
299
|
+
* intercept the bytes that later get hashed and force the unsigned-
|
|
300
|
+
* fallback warning path.
|
|
301
|
+
*
|
|
302
|
+
* Mitigation has two layers:
|
|
303
|
+
*
|
|
304
|
+
* 1. Entry check on both helpers refuses any non-`https://` URL.
|
|
305
|
+
* 2. The redirect handler re-enters the same function with the
|
|
306
|
+
* resolved Location, so a `Location: http://…` triggers the
|
|
307
|
+
* same entry check on the recursive call. No real HTTPS server
|
|
308
|
+
* is needed to prove this — the property is structural.
|
|
309
|
+
*
|
|
310
|
+
* A regression guard asserts that `node:http` is no longer imported,
|
|
311
|
+
* so the dual-client selector that triggered the audit finding can't
|
|
312
|
+
* silently come back via a future refactor.
|
|
313
|
+
*/
|
|
314
|
+
describe("Tier-2 transport hardening (HTTPS-only)", () => {
|
|
315
|
+
it("download() rejects non-HTTPS initial URLs", async () => {
|
|
316
|
+
await expect(download("http://example.com/x", "/tmp/x")).rejects.toThrow(
|
|
317
|
+
/refusing non-HTTPS transport/,
|
|
318
|
+
)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("downloadToBuffer() rejects non-HTTPS initial URLs", async () => {
|
|
322
|
+
await expect(
|
|
323
|
+
downloadToBuffer("http://example.com/x", 1024),
|
|
324
|
+
).rejects.toThrow(/refusing non-HTTPS transport/)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("rejects ftp://, file://, javascript:, and other non-https schemes", async () => {
|
|
328
|
+
for (const scheme of ["ftp", "file", "javascript", "data"]) {
|
|
329
|
+
await expect(
|
|
330
|
+
downloadToBuffer(`${scheme}://payload`, 1024),
|
|
331
|
+
).rejects.toThrow(/refusing non-HTTPS transport/)
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it("does not import node:http (regression guard for the dual-client selector)", () => {
|
|
336
|
+
const src = fs.readFileSync(
|
|
337
|
+
path.join(
|
|
338
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
339
|
+
"..",
|
|
340
|
+
"install-binary.js",
|
|
341
|
+
),
|
|
342
|
+
"utf-8",
|
|
343
|
+
)
|
|
344
|
+
expect(src).not.toMatch(/from\s+["']node:http["']/)
|
|
345
|
+
})
|
|
346
|
+
})
|
|
@@ -5,55 +5,50 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Runs as a postinstall hook. Resolution order:
|
|
7
7
|
*
|
|
8
|
-
* 1. optionalDependencies — platform package installed by npm (fastest, cached)
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* 1. optionalDependencies — platform package installed by npm (fastest, cached).
|
|
9
|
+
* Integrity story: npm provenance attestation, verified by the npm registry
|
|
10
|
+
* at install time. No extra check needed here.
|
|
11
|
+
* 2. GitHub Releases — direct download if optionalDependencies missing
|
|
12
|
+
* (--no-optional, corporate proxies blocking scoped packages, resolution
|
|
13
|
+
* failure). Integrity story: every byte is anchored to a Sigstore-signed
|
|
14
|
+
* `SHA256SUMS.txt` manifest published per release. The `sigstore` npm
|
|
15
|
+
* package is consulted as an optional dependency — when resolvable, full
|
|
16
|
+
* Sigstore bundle verification runs; when not, SHA-256 against the signed
|
|
17
|
+
* manifest is the floor. Fail-closed on any integrity gate.
|
|
18
|
+
* 3. Graceful fallback — JS CLI handles commands if Tier 2 returns null.
|
|
11
19
|
*
|
|
12
20
|
* After resolving the binary, hard-links it over bin/lpm.js so subsequent
|
|
13
21
|
* runs execute the native binary directly (zero Node.js overhead).
|
|
14
22
|
*
|
|
15
|
-
*
|
|
23
|
+
* Set `LPM_INSTALL_INSECURE=1` to skip every Tier-2 integrity check (escape
|
|
24
|
+
* valve for emergency installs from releases that predate the signed-install
|
|
25
|
+
* gate). Emits a loud WARN on every use.
|
|
26
|
+
*
|
|
27
|
+
* Follows the esbuild postinstall pattern: https://github.com/evanw/esbuild
|
|
16
28
|
*/
|
|
17
29
|
|
|
18
30
|
import { execSync } from "node:child_process"
|
|
31
|
+
import crypto from "node:crypto"
|
|
19
32
|
import fs from "node:fs"
|
|
20
|
-
import http from "node:http"
|
|
21
33
|
import https from "node:https"
|
|
22
34
|
import { createRequire } from "node:module"
|
|
23
35
|
import os from "node:os"
|
|
24
36
|
import path from "node:path"
|
|
25
|
-
import { fileURLToPath } from "node:url"
|
|
37
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
26
38
|
|
|
27
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
28
40
|
const pkgDir = path.join(__dirname, "..")
|
|
29
41
|
const binDir = path.join(pkgDir, "bin")
|
|
30
42
|
const require = createRequire(import.meta.url)
|
|
31
43
|
|
|
32
|
-
// Platform → { package name, binary filename in package }
|
|
33
44
|
const PLATFORMS = {
|
|
34
|
-
"darwin-arm64": {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
},
|
|
38
|
-
"
|
|
39
|
-
pkg: "@lpm-registry/cli-darwin-x64",
|
|
40
|
-
binary: "lpm",
|
|
41
|
-
},
|
|
42
|
-
"linux-x64": {
|
|
43
|
-
pkg: "@lpm-registry/cli-linux-x64",
|
|
44
|
-
binary: "lpm",
|
|
45
|
-
},
|
|
46
|
-
"linux-arm64": {
|
|
47
|
-
pkg: "@lpm-registry/cli-linux-arm64",
|
|
48
|
-
binary: "lpm",
|
|
49
|
-
},
|
|
50
|
-
"win32-x64": {
|
|
51
|
-
pkg: "@lpm-registry/cli-win32-x64",
|
|
52
|
-
binary: "lpm.exe",
|
|
53
|
-
},
|
|
45
|
+
"darwin-arm64": { pkg: "@lpm-registry/cli-darwin-arm64", binary: "lpm" },
|
|
46
|
+
"darwin-x64": { pkg: "@lpm-registry/cli-darwin-x64", binary: "lpm" },
|
|
47
|
+
"linux-x64": { pkg: "@lpm-registry/cli-linux-x64", binary: "lpm" },
|
|
48
|
+
"linux-arm64": { pkg: "@lpm-registry/cli-linux-arm64", binary: "lpm" },
|
|
49
|
+
"win32-x64": { pkg: "@lpm-registry/cli-win32-x64", binary: "lpm.exe" },
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
// GitHub Releases fallback URLs
|
|
57
52
|
const GITHUB_RELEASES =
|
|
58
53
|
"https://github.com/lpm-dev/rust-client/releases/download"
|
|
59
54
|
const GITHUB_BINARY_NAMES = {
|
|
@@ -64,7 +59,17 @@ const GITHUB_BINARY_NAMES = {
|
|
|
64
59
|
"win32-x64": "lpm-win32-x64.exe",
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
|
|
62
|
+
const MANIFEST_MAX_BYTES = 64 * 1024
|
|
63
|
+
const BUNDLE_MAX_BYTES = 1024 * 1024
|
|
64
|
+
const ASSET_MAX_BYTES = 256 * 1024 * 1024
|
|
65
|
+
|
|
66
|
+
const SIGSTORE_IDENTITY = {
|
|
67
|
+
certificateIdentityRegExp:
|
|
68
|
+
"^https://github\\.com/lpm-dev/rust-client/\\.github/workflows/release\\.yml@.+",
|
|
69
|
+
certificateOIDCIssuer: "https://token.actions.githubusercontent.com",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function main() {
|
|
68
73
|
const platform = `${process.platform}-${os.arch()}`
|
|
69
74
|
const platformInfo = PLATFORMS[platform]
|
|
70
75
|
|
|
@@ -79,10 +84,8 @@ async function main() {
|
|
|
79
84
|
fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"),
|
|
80
85
|
)
|
|
81
86
|
|
|
82
|
-
// ── Tier 1: Try optionalDependencies (platform package) ──────────
|
|
83
87
|
let binaryPath = tryOptionalDependency(platformInfo)
|
|
84
88
|
|
|
85
|
-
// ── Tier 2: Direct download from GitHub Releases ─────────────────
|
|
86
89
|
if (!binaryPath) {
|
|
87
90
|
const githubBinaryName = GITHUB_BINARY_NAMES[platform]
|
|
88
91
|
if (githubBinaryName) {
|
|
@@ -95,30 +98,20 @@ async function main() {
|
|
|
95
98
|
return
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
// ── Hard-link optimization ───────────────────────────────────────
|
|
99
|
-
// Replace bin/lpm.js with a hard link to the native binary.
|
|
100
|
-
// After this, `lpm` on PATH IS the Rust binary — zero Node.js overhead.
|
|
101
101
|
if (process.platform !== "win32") {
|
|
102
102
|
tryHardLink(binaryPath)
|
|
103
103
|
} else {
|
|
104
|
-
// Windows: copy instead of hard-link (npm creates .cmd shims)
|
|
105
104
|
tryCopyBinary(binaryPath)
|
|
106
105
|
}
|
|
107
106
|
}
|
|
108
107
|
|
|
109
|
-
/**
|
|
110
|
-
* Tier 1: Resolve binary from optionalDependencies platform package.
|
|
111
|
-
* Returns the path to the binary, or null if not found.
|
|
112
|
-
*/
|
|
113
108
|
function tryOptionalDependency(platformInfo) {
|
|
114
109
|
try {
|
|
115
|
-
// require.resolve finds the package in node_modules
|
|
116
110
|
const pkgJsonPath = require.resolve(`${platformInfo.pkg}/package.json`)
|
|
117
|
-
const
|
|
118
|
-
const binaryPath = path.join(
|
|
111
|
+
const platformPkgDir = path.dirname(pkgJsonPath)
|
|
112
|
+
const binaryPath = path.join(platformPkgDir, platformInfo.binary)
|
|
119
113
|
|
|
120
114
|
if (fs.existsSync(binaryPath)) {
|
|
121
|
-
// Verify the binary is executable
|
|
122
115
|
try {
|
|
123
116
|
execSync(`"${binaryPath}" --version`, {
|
|
124
117
|
stdio: "ignore",
|
|
@@ -133,55 +126,162 @@ function tryOptionalDependency(platformInfo) {
|
|
|
133
126
|
}
|
|
134
127
|
}
|
|
135
128
|
} catch {
|
|
136
|
-
// Package not installed (--no-optional, or resolution failed)
|
|
129
|
+
// Package not installed (--no-optional, or resolution failed).
|
|
137
130
|
}
|
|
138
131
|
|
|
139
132
|
return null
|
|
140
133
|
}
|
|
141
134
|
|
|
142
135
|
/**
|
|
143
|
-
* Tier 2:
|
|
144
|
-
*
|
|
136
|
+
* Tier 2: download binary from GitHub Releases with end-to-end integrity.
|
|
137
|
+
*
|
|
138
|
+
* Returns the path to the verified, executable binary on success, or null
|
|
139
|
+
* on any failure (manifest 404, SHA mismatch, Sigstore rejection, network
|
|
140
|
+
* error). `null` falls through to the JS CLI fallback in `main` — Tier 2
|
|
141
|
+
* never silently installs an unverified binary.
|
|
142
|
+
*
|
|
143
|
+
* Exported for testability — `vi.mock` of the network calls drives this
|
|
144
|
+
* function directly.
|
|
145
145
|
*/
|
|
146
|
-
async function tryGitHubDownload(version, githubBinaryName) {
|
|
147
|
-
const
|
|
146
|
+
export async function tryGitHubDownload(version, githubBinaryName, deps = {}) {
|
|
147
|
+
const downloadFn = deps.download || download
|
|
148
|
+
const downloadToBufferFn = deps.downloadToBuffer || downloadToBuffer
|
|
149
|
+
const sha256FileFn = deps.sha256File || sha256File
|
|
150
|
+
const verifyFn = deps.tryVerifyManifestSignature || tryVerifyManifestSignature
|
|
151
|
+
const destDir = deps.destDir || binDir
|
|
152
|
+
const insecure = process.env.LPM_INSTALL_INSECURE === "1"
|
|
153
|
+
|
|
154
|
+
const manifestUrl = `${GITHUB_RELEASES}/v${version}/SHA256SUMS.txt`
|
|
155
|
+
const bundleUrl = `${GITHUB_RELEASES}/v${version}/SHA256SUMS.txt.sigstore`
|
|
156
|
+
const assetUrl = `${GITHUB_RELEASES}/v${version}/${githubBinaryName}`
|
|
157
|
+
|
|
158
|
+
let manifestBody = null
|
|
159
|
+
let bundleBody = null
|
|
160
|
+
|
|
161
|
+
if (insecure) {
|
|
162
|
+
console.log(
|
|
163
|
+
"[lpm] WARN: LPM_INSTALL_INSECURE=1 — skipping ALL integrity verification on Tier-2 download",
|
|
164
|
+
)
|
|
165
|
+
} else {
|
|
166
|
+
try {
|
|
167
|
+
manifestBody = await downloadToBufferFn(manifestUrl, MANIFEST_MAX_BYTES)
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err && err.statusCode === 404) {
|
|
170
|
+
console.log(
|
|
171
|
+
`[lpm] ERROR: release v${version} does not ship SHA256SUMS.txt — this release predates LPM's signed-install gate.`,
|
|
172
|
+
)
|
|
173
|
+
console.log(
|
|
174
|
+
`[lpm] Install manually from https://github.com/lpm-dev/rust-client/releases/v${version}`,
|
|
175
|
+
)
|
|
176
|
+
console.log(
|
|
177
|
+
"[lpm] Or set LPM_INSTALL_INSECURE=1 to skip integrity verification (NOT recommended).",
|
|
178
|
+
)
|
|
179
|
+
} else {
|
|
180
|
+
console.log(
|
|
181
|
+
`[lpm] ERROR: could not fetch signed manifest: ${err.message}`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
bundleBody = await downloadToBufferFn(bundleUrl, BUNDLE_MAX_BYTES)
|
|
188
|
+
} catch {
|
|
189
|
+
// Bundle 404 / fetch failure is not an attack signal by itself —
|
|
190
|
+
// the SHA-256 gate against the manifest still runs. cosign /
|
|
191
|
+
// sigstore verification is opportunistic on this channel.
|
|
192
|
+
bundleBody = null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (bundleBody) {
|
|
196
|
+
const verdict = await verifyFn(manifestBody, bundleBody)
|
|
197
|
+
if (verdict === "verified") {
|
|
198
|
+
console.log(
|
|
199
|
+
"[lpm] Verified Sigstore signature on manifest (identity-pinned to release.yml)",
|
|
200
|
+
)
|
|
201
|
+
} else if (verdict === "rejected") {
|
|
202
|
+
// Verifier ran and refused — strong attack signal.
|
|
203
|
+
console.log(
|
|
204
|
+
"[lpm] ERROR: Sigstore verifier rejected the manifest signature.",
|
|
205
|
+
)
|
|
206
|
+
console.log(
|
|
207
|
+
"[lpm] Refusing to install. See https://github.com/lpm-dev/rust-client/issues",
|
|
208
|
+
)
|
|
209
|
+
return null
|
|
210
|
+
} else {
|
|
211
|
+
// Optional `sigstore` package not resolvable — degrade to
|
|
212
|
+
// SHA-only with a loud WARN so the user knows what to
|
|
213
|
+
// install for the stronger posture.
|
|
214
|
+
console.log(
|
|
215
|
+
"[lpm] WARN: optional 'sigstore' package not available; falling back to SHA-256 against the signed manifest only.",
|
|
216
|
+
)
|
|
217
|
+
console.log(
|
|
218
|
+
"[lpm] For Sigstore verification, run `npm install --include=optional` or unblock the sigstore package.",
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
148
224
|
const destName = process.platform === "win32" ? "lpm-bin.exe" : "lpm-bin"
|
|
149
|
-
const destPath = path.join(
|
|
225
|
+
const destPath = path.join(destDir, destName)
|
|
226
|
+
const tmpPath = `${destPath}.tmp.${process.pid}`
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
console.log("[lpm] Downloading native binary from GitHub Releases...")
|
|
230
|
+
await downloadFn(assetUrl, tmpPath, 0, ASSET_MAX_BYTES)
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.log(`[lpm] Download failed: ${err.message}. Using JS CLI fallback.`)
|
|
233
|
+
safeUnlink(tmpPath)
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (manifestBody) {
|
|
238
|
+
const actualSha = await sha256FileFn(tmpPath)
|
|
239
|
+
const expectedSha = parseManifestEntry(
|
|
240
|
+
manifestBody.toString("utf-8"),
|
|
241
|
+
githubBinaryName,
|
|
242
|
+
)
|
|
243
|
+
if (!expectedSha) {
|
|
244
|
+
console.log(
|
|
245
|
+
`[lpm] ERROR: manifest does not enumerate ${githubBinaryName}; release-pipeline bug.`,
|
|
246
|
+
)
|
|
247
|
+
safeUnlink(tmpPath)
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
if (actualSha !== expectedSha) {
|
|
251
|
+
console.log(`[lpm] ERROR: SHA-256 mismatch for ${githubBinaryName}`)
|
|
252
|
+
console.log(
|
|
253
|
+
`[lpm] expected: ${expectedSha} (from signed manifest)`,
|
|
254
|
+
)
|
|
255
|
+
console.log(`[lpm] actual: ${actualSha}`)
|
|
256
|
+
console.log("[lpm] Refusing to install — strong tampering signal.")
|
|
257
|
+
safeUnlink(tmpPath)
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
console.log(`[lpm] Verified SHA-256: ${actualSha}`)
|
|
261
|
+
}
|
|
150
262
|
|
|
151
263
|
try {
|
|
152
|
-
|
|
153
|
-
await download(url, destPath)
|
|
264
|
+
fs.renameSync(tmpPath, destPath)
|
|
154
265
|
fs.chmodSync(destPath, 0o755)
|
|
155
|
-
console.log("[lpm] Native binary downloaded from GitHub Releases.")
|
|
156
|
-
return destPath
|
|
157
266
|
} catch (err) {
|
|
158
|
-
console.log(`[lpm]
|
|
159
|
-
|
|
160
|
-
if (fs.existsSync(destPath)) fs.unlinkSync(destPath)
|
|
267
|
+
console.log(`[lpm] Could not install verified binary: ${err.message}`)
|
|
268
|
+
safeUnlink(tmpPath)
|
|
161
269
|
return null
|
|
162
270
|
}
|
|
271
|
+
|
|
272
|
+
console.log("[lpm] Native binary installed from GitHub Releases.")
|
|
273
|
+
return destPath
|
|
163
274
|
}
|
|
164
275
|
|
|
165
|
-
/**
|
|
166
|
-
* Hard-link the native binary over bin/lpm.js (Unix only).
|
|
167
|
-
*
|
|
168
|
-
* After this, the `lpm` symlink on PATH points directly to the Rust binary.
|
|
169
|
-
* Node.js is never started. Zero overhead.
|
|
170
|
-
*
|
|
171
|
-
* We keep lpm.js.bak as the JS fallback — if the hard link is broken
|
|
172
|
-
* (npm rebuild, etc.), the JS wrapper can be restored.
|
|
173
|
-
*/
|
|
174
276
|
function tryHardLink(binaryPath) {
|
|
175
277
|
const shimPath = path.join(binDir, "lpm.js")
|
|
176
278
|
const backupPath = path.join(binDir, "lpm.js.bak")
|
|
177
279
|
|
|
178
280
|
try {
|
|
179
|
-
// Back up the JS shim (only if not already backed up)
|
|
180
281
|
if (fs.existsSync(shimPath) && !fs.existsSync(backupPath)) {
|
|
181
282
|
fs.copyFileSync(shimPath, backupPath)
|
|
182
283
|
}
|
|
183
284
|
|
|
184
|
-
// Replace JS shim with hard link to native binary
|
|
185
285
|
if (fs.existsSync(shimPath)) {
|
|
186
286
|
fs.unlinkSync(shimPath)
|
|
187
287
|
}
|
|
@@ -189,8 +289,6 @@ function tryHardLink(binaryPath) {
|
|
|
189
289
|
|
|
190
290
|
console.log("[lpm] Hard-linked native binary (zero Node.js overhead)")
|
|
191
291
|
} catch (err) {
|
|
192
|
-
// Hard link failed (cross-device mount, permissions, etc.)
|
|
193
|
-
// Restore JS shim — it will delegate at runtime (50ms overhead)
|
|
194
292
|
if (fs.existsSync(backupPath) && !fs.existsSync(shimPath)) {
|
|
195
293
|
fs.copyFileSync(backupPath, shimPath)
|
|
196
294
|
}
|
|
@@ -198,10 +296,6 @@ function tryHardLink(binaryPath) {
|
|
|
198
296
|
}
|
|
199
297
|
}
|
|
200
298
|
|
|
201
|
-
/**
|
|
202
|
-
* Copy binary to bin/ directory (Windows fallback).
|
|
203
|
-
* npm on Windows creates .cmd shims, so hard-linking doesn't work the same way.
|
|
204
|
-
*/
|
|
205
299
|
function tryCopyBinary(binaryPath) {
|
|
206
300
|
const destPath = path.join(binDir, "lpm-bin.exe")
|
|
207
301
|
try {
|
|
@@ -214,41 +308,264 @@ function tryCopyBinary(binaryPath) {
|
|
|
214
308
|
|
|
215
309
|
/**
|
|
216
310
|
* Download a file from a URL, following redirects (GitHub → S3).
|
|
311
|
+
*
|
|
312
|
+
* HTTPS-only end-to-end: the entry check rejects any non-`https://`
|
|
313
|
+
* URL, and redirect Locations are resolved against the current URL
|
|
314
|
+
* before re-entering the function — so an attacker-controlled mirror
|
|
315
|
+
* cannot downgrade transport mid-chain via `Location: http://…`.
|
|
316
|
+
*
|
|
317
|
+
* Streams to disk with a per-response byte cap so a compromised mirror
|
|
318
|
+
* cannot exhaust disk by serving an unbounded body. The 5-redirect cap
|
|
319
|
+
* is preserved from the pre-integrity-gate shape.
|
|
320
|
+
*
|
|
321
|
+
* Exported for testability.
|
|
217
322
|
*/
|
|
218
|
-
function download(url, dest, redirects = 0) {
|
|
323
|
+
export function download(url, dest, redirects = 0, maxBytes = ASSET_MAX_BYTES) {
|
|
219
324
|
if (redirects > 5) return Promise.reject(new Error("Too many redirects"))
|
|
220
|
-
|
|
221
|
-
|
|
325
|
+
if (!url.startsWith("https://")) {
|
|
326
|
+
return Promise.reject(
|
|
327
|
+
new Error(
|
|
328
|
+
`refusing non-HTTPS transport: ${url} (Tier-2 download requires HTTPS end-to-end)`,
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
}
|
|
222
332
|
|
|
223
333
|
return new Promise((resolve, reject) => {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
231
|
-
|
|
334
|
+
const req = https.get(url, res => {
|
|
335
|
+
if (
|
|
336
|
+
res.statusCode >= 300 &&
|
|
337
|
+
res.statusCode < 400 &&
|
|
338
|
+
res.headers.location
|
|
339
|
+
) {
|
|
340
|
+
res.resume()
|
|
341
|
+
let nextUrl
|
|
342
|
+
try {
|
|
343
|
+
nextUrl = new URL(res.headers.location, url).href
|
|
344
|
+
} catch {
|
|
345
|
+
reject(
|
|
346
|
+
new Error(`invalid redirect Location: ${res.headers.location}`),
|
|
347
|
+
)
|
|
232
348
|
return
|
|
233
349
|
}
|
|
350
|
+
resolve(download(nextUrl, dest, redirects + 1, maxBytes))
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (res.statusCode !== 200) {
|
|
355
|
+
const err = new Error(`HTTP ${res.statusCode}`)
|
|
356
|
+
err.statusCode = res.statusCode
|
|
357
|
+
res.resume()
|
|
358
|
+
reject(err)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const advertised = Number(res.headers["content-length"])
|
|
363
|
+
if (Number.isFinite(advertised) && advertised > maxBytes) {
|
|
364
|
+
res.resume()
|
|
365
|
+
reject(
|
|
366
|
+
new Error(
|
|
367
|
+
`response advertises ${advertised} bytes; cap of ${maxBytes} would be exceeded`,
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let received = 0
|
|
374
|
+
const out = fs.createWriteStream(dest)
|
|
375
|
+
res.on("data", chunk => {
|
|
376
|
+
received += chunk.length
|
|
377
|
+
if (received > maxBytes) {
|
|
378
|
+
out.destroy()
|
|
379
|
+
res.destroy()
|
|
380
|
+
safeUnlink(dest)
|
|
381
|
+
reject(
|
|
382
|
+
new Error(
|
|
383
|
+
`response body exceeded cap of ${maxBytes} bytes mid-stream`,
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
res.pipe(out)
|
|
389
|
+
out.on("finish", () => {
|
|
390
|
+
out.close()
|
|
391
|
+
resolve()
|
|
392
|
+
})
|
|
393
|
+
out.on("error", reject)
|
|
394
|
+
})
|
|
395
|
+
req.on("error", reject)
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Buffered variant of [`download`] — fetches a bounded body into memory
|
|
401
|
+
* and returns it as a Buffer. Used for the manifest + Sigstore bundle,
|
|
402
|
+
* both of which are small and must be inspected as a whole before any
|
|
403
|
+
* other byte is honored.
|
|
404
|
+
*
|
|
405
|
+
* HTTPS-only: identical transport-hardening posture as [`download`].
|
|
406
|
+
*
|
|
407
|
+
* Exported for testability.
|
|
408
|
+
*/
|
|
409
|
+
export function downloadToBuffer(url, maxBytes, redirects = 0) {
|
|
410
|
+
if (redirects > 5) return Promise.reject(new Error("Too many redirects"))
|
|
411
|
+
if (!url.startsWith("https://")) {
|
|
412
|
+
return Promise.reject(
|
|
413
|
+
new Error(
|
|
414
|
+
`refusing non-HTTPS transport: ${url} (Tier-2 download requires HTTPS end-to-end)`,
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
}
|
|
234
418
|
|
|
235
|
-
|
|
236
|
-
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
const req = https.get(url, res => {
|
|
421
|
+
if (
|
|
422
|
+
res.statusCode >= 300 &&
|
|
423
|
+
res.statusCode < 400 &&
|
|
424
|
+
res.headers.location
|
|
425
|
+
) {
|
|
426
|
+
res.resume()
|
|
427
|
+
let nextUrl
|
|
428
|
+
try {
|
|
429
|
+
nextUrl = new URL(res.headers.location, url).href
|
|
430
|
+
} catch {
|
|
431
|
+
reject(
|
|
432
|
+
new Error(`invalid redirect Location: ${res.headers.location}`),
|
|
433
|
+
)
|
|
237
434
|
return
|
|
238
435
|
}
|
|
436
|
+
resolve(downloadToBuffer(nextUrl, maxBytes, redirects + 1))
|
|
437
|
+
return
|
|
438
|
+
}
|
|
239
439
|
|
|
240
|
-
|
|
241
|
-
res.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
440
|
+
if (res.statusCode !== 200) {
|
|
441
|
+
const err = new Error(`HTTP ${res.statusCode}`)
|
|
442
|
+
err.statusCode = res.statusCode
|
|
443
|
+
res.resume()
|
|
444
|
+
reject(err)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const advertised = Number(res.headers["content-length"])
|
|
449
|
+
if (Number.isFinite(advertised) && advertised > maxBytes) {
|
|
450
|
+
res.resume()
|
|
451
|
+
reject(
|
|
452
|
+
new Error(
|
|
453
|
+
`response advertises ${advertised} bytes; cap of ${maxBytes} would be exceeded`,
|
|
454
|
+
),
|
|
455
|
+
)
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const chunks = []
|
|
460
|
+
let received = 0
|
|
461
|
+
res.on("data", chunk => {
|
|
462
|
+
received += chunk.length
|
|
463
|
+
if (received > maxBytes) {
|
|
464
|
+
res.destroy()
|
|
465
|
+
reject(
|
|
466
|
+
new Error(
|
|
467
|
+
`response body exceeded cap of ${maxBytes} bytes mid-stream`,
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
chunks.push(chunk)
|
|
247
473
|
})
|
|
248
|
-
.on("
|
|
474
|
+
res.on("end", () => resolve(Buffer.concat(chunks)))
|
|
475
|
+
res.on("error", reject)
|
|
476
|
+
})
|
|
477
|
+
req.on("error", reject)
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Compute the SHA-256 of a file as a lowercase hex string, streaming
|
|
483
|
+
* the file so we never buffer the whole asset in memory.
|
|
484
|
+
*
|
|
485
|
+
* Exported for testability.
|
|
486
|
+
*/
|
|
487
|
+
export function sha256File(filePath) {
|
|
488
|
+
return new Promise((resolve, reject) => {
|
|
489
|
+
const hash = crypto.createHash("sha256")
|
|
490
|
+
const stream = fs.createReadStream(filePath)
|
|
491
|
+
stream.on("data", chunk => hash.update(chunk))
|
|
492
|
+
stream.on("end", () => resolve(hash.digest("hex")))
|
|
493
|
+
stream.on("error", reject)
|
|
249
494
|
})
|
|
250
495
|
}
|
|
251
496
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Look up the SHA-256 for `filename` in a parsed SHA256SUMS.txt body.
|
|
499
|
+
*
|
|
500
|
+
* Format: `<64-hex> <filename>\n` per line, two ASCII spaces between
|
|
501
|
+
* digest and name (GNU coreutils default; macOS shasum -a 256 matches).
|
|
502
|
+
* Malformed lines return null — a malformed manifest is an integrity
|
|
503
|
+
* failure, not a silent miss; callers should refuse to install.
|
|
504
|
+
*
|
|
505
|
+
* Exported for testability.
|
|
506
|
+
*/
|
|
507
|
+
export function parseManifestEntry(manifestText, filename) {
|
|
508
|
+
const lines = manifestText.split("\n")
|
|
509
|
+
for (const raw of lines) {
|
|
510
|
+
const line = raw.replace(/\r$/, "")
|
|
511
|
+
if (line.length === 0) continue
|
|
512
|
+
const idx = line.indexOf(" ")
|
|
513
|
+
if (idx !== 64) return null
|
|
514
|
+
const digest = line.slice(0, 64).toLowerCase()
|
|
515
|
+
if (!/^[0-9a-f]{64}$/.test(digest)) return null
|
|
516
|
+
const name = line.slice(idx + 2).trim()
|
|
517
|
+
if (name.length === 0) return null
|
|
518
|
+
if (name === filename) return digest
|
|
519
|
+
}
|
|
520
|
+
return null
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function safeUnlink(p) {
|
|
524
|
+
try {
|
|
525
|
+
if (fs.existsSync(p)) fs.unlinkSync(p)
|
|
526
|
+
} catch {
|
|
527
|
+
// Best-effort.
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Verify a Sigstore bundle against a manifest body using the optional
|
|
533
|
+
* `sigstore` package. Returns:
|
|
534
|
+
*
|
|
535
|
+
* "verified" — bundle verified under the lpm-dev/rust-client
|
|
536
|
+
* release.yml identity pin.
|
|
537
|
+
* "rejected" — sigstore package present and refused the bundle.
|
|
538
|
+
* "unavailable" — sigstore package not resolvable (--no-optional,
|
|
539
|
+
* firewalled, install failure). Caller degrades to
|
|
540
|
+
* SHA-only.
|
|
541
|
+
*
|
|
542
|
+
* Exported for testability.
|
|
543
|
+
*/
|
|
544
|
+
export async function tryVerifyManifestSignature(manifestBody, bundleBody) {
|
|
545
|
+
let sigstore
|
|
546
|
+
try {
|
|
547
|
+
sigstore = await import("sigstore")
|
|
548
|
+
} catch {
|
|
549
|
+
return "unavailable"
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
const bundle = JSON.parse(bundleBody.toString("utf-8"))
|
|
553
|
+
await sigstore.verify(bundle, manifestBody, SIGSTORE_IDENTITY)
|
|
554
|
+
return "verified"
|
|
555
|
+
} catch {
|
|
556
|
+
return "rejected"
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Only run `main` when this module is executed as a script (the
|
|
561
|
+
// postinstall hook). Test files that `import { tryGitHubDownload, … }`
|
|
562
|
+
// from this module should not trigger a real install attempt during
|
|
563
|
+
// suite setup.
|
|
564
|
+
const invokedAsScript =
|
|
565
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
566
|
+
|
|
567
|
+
if (invokedAsScript) {
|
|
568
|
+
main().catch(() => {
|
|
569
|
+
// Silently fail — JS CLI fallback will handle all commands.
|
|
570
|
+
})
|
|
571
|
+
}
|