@lpm-registry/cli 0.41.1 → 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.41.1",
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.41.1",
65
- "@lpm-registry/cli-darwin-x64": "0.41.1",
66
- "@lpm-registry/cli-linux-x64": "0.41.1",
67
- "@lpm-registry/cli-linux-arm64": "0.41.1",
68
- "@lpm-registry/cli-win32-x64": "0.41.1",
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
- * 2. GitHub Releases — direct download if optionalDependencies missing (--no-optional)
10
- * 3. Graceful fallback — JS CLI handles commands if binary unavailable
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
- * Follows the esbuild pattern: https://github.com/evanw/esbuild
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
- pkg: "@lpm-registry/cli-darwin-arm64",
36
- binary: "lpm",
37
- },
38
- "darwin-x64": {
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
- async function main() {
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 pkgDir = path.dirname(pkgJsonPath)
118
- const binaryPath = path.join(pkgDir, platformInfo.binary)
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: Download binary directly from GitHub Releases.
144
- * Returns the path to the downloaded binary, or null on failure.
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 url = `${GITHUB_RELEASES}/v${version}/${githubBinaryName}`
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(binDir, destName)
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
- console.log(`[lpm] Platform package not found. Downloading from GitHub...`)
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] GitHub download failed: ${err.message}. Using JS CLI.`)
159
- // Clean up partial download
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
- const client = url.startsWith("https") ? https : http
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
- client
225
- .get(url, res => {
226
- if (
227
- res.statusCode >= 300 &&
228
- res.statusCode < 400 &&
229
- res.headers.location
230
- ) {
231
- resolve(download(res.headers.location, dest, redirects + 1))
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
- if (res.statusCode !== 200) {
236
- reject(new Error(`HTTP ${res.statusCode}`))
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
- const out = fs.createWriteStream(dest)
241
- res.pipe(out)
242
- out.on("finish", () => {
243
- out.close()
244
- resolve()
245
- })
246
- out.on("error", reject)
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("error", reject)
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
- main().catch(() => {
253
- // Silently fail — JS CLI fallback will handle all commands
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
+ }