From 5445d265f1f9b433252838f1d60fc13b95972a6a Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 22 Feb 2026 10:12:34 -0800 Subject: [PATCH 1/2] Add SHA256 checksum verification for install and update - Generate and upload .sha256 files in the release workflow - Make checksum verification mandatory in update.ts (reject unverified binaries) - Add checksum verification to install.sh - Update tests to expect errors on missing/mismatched checksums --- .github/workflows/release.yml | 28 +++++++++++++- install.sh | 26 +++++++++++++ src/loop/update.ts | 5 ++- tests/loop/update.test.ts | 70 +++++++++++++++++++++++++++++------ 4 files changed, 115 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e19a60..ad4131b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,11 +112,37 @@ jobs: } throw "Missing build output file" + - name: Generate SHA256 checksum (Unix) + if: runner.os != 'Windows' + run: shasum -a 256 ${{ matrix.asset_name }} > ${{ matrix.asset_name }}.sha256 + + - name: Generate SHA256 checksum (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $hash = (Get-FileHash -Algorithm SHA256 ${{ matrix.asset_name }}).Hash.ToLower() + "$hash ${{ matrix.asset_name }}" | Out-File -Encoding ascii ${{ matrix.asset_name }}.sha256 + + - name: Verify checksum file (Unix) + if: runner.os != 'Windows' + run: shasum -a 256 -c ${{ matrix.asset_name }}.sha256 + + - name: Verify checksum file (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $line = Get-Content ${{ matrix.asset_name }}.sha256 + $expected = ($line -split '\s+')[0] + $actual = (Get-FileHash -Algorithm SHA256 ${{ matrix.asset_name }}).Hash.ToLower() + if ($expected -ne $actual) { throw "Checksum verification failed: expected $expected, got $actual" } + - name: Upload release asset uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.meta.outputs.tag }} - files: ${{ matrix.asset_name }} + files: | + ${{ matrix.asset_name }} + ${{ matrix.asset_name }}.sha256 create-release: needs: meta diff --git a/install.sh b/install.sh index 15029c2..54cf2b0 100755 --- a/install.sh +++ b/install.sh @@ -89,6 +89,32 @@ if ! download_file "$asset_url" "$binary_path"; then exit 1 fi +checksum_url="${asset_url}.sha256" +checksum_path="$DOWNLOAD_DIR/${asset}.sha256" + +if download_file "$checksum_url" "$checksum_path"; then + expected=$(cut -d ' ' -f 1 < "$checksum_path") + if command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$binary_path" | cut -d ' ' -f 1) + elif command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$binary_path" | cut -d ' ' -f 1) + else + echo "Warning: no sha256 tool found, skipping verification" >&2 + actual="$expected" + fi + + if [ "$expected" != "$actual" ]; then + echo "Checksum verification failed!" >&2 + echo " expected: $expected" >&2 + echo " got: $actual" >&2 + exit 1 + fi + echo "Checksum verified." +else + echo "Error: could not download checksum file" >&2 + exit 1 +fi + mkdir -p "$INSTALL_DIR" chmod +x "$binary_path" cp "$binary_path" "$TARGET_PATH" diff --git a/src/loop/update.ts b/src/loop/update.ts index cca9937..d95ce58 100644 --- a/src/loop/update.ts +++ b/src/loop/update.ts @@ -171,8 +171,8 @@ const downloadAndStage = async ( if (checksumUrl) { await verifyChecksum(buf, checksumUrl); } else { - console.error( - "[loop] warning: no .sha256 checksum available, skipping verification" + throw new Error( + "No .sha256 checksum available — refusing to install unverified binary" ); } @@ -273,6 +273,7 @@ export const handleManualUpdateCommand = async ( } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(`[loop] update failed: ${msg}`); + throw new Error(`[loop] update failed: ${msg}`); } return true; }; diff --git a/tests/loop/update.test.ts b/tests/loop/update.test.ts index 22297c1..13bca8a 100644 --- a/tests/loop/update.test.ts +++ b/tests/loop/update.test.ts @@ -349,6 +349,9 @@ test("handleManualUpdateCommand stages update with correct metadata", async () = configurable: true, }); + const binaryData = "fake-binary-data"; + const expectedHash = createHash("sha256").update(binaryData).digest("hex"); + const fetchMock = mock((...args: unknown[]) => { const url = String(args[0]); if (url.includes("api.github.com")) { @@ -360,11 +363,18 @@ test("handleManualUpdateCommand stages update with correct metadata", async () = name: assetName, browser_download_url: "https://example.com/loop-binary", }, + { + name: `${assetName}.sha256`, + browser_download_url: "https://example.com/loop-binary.sha256", + }, ], }) ); } - return Promise.resolve(new Response("fake-binary-data")); + if (url.includes(".sha256")) { + return Promise.resolve(new Response(`${expectedHash} ${assetName}\n`)); + } + return Promise.resolve(new Response(binaryData)); }); globalThis.fetch = fetchMock as typeof fetch; @@ -504,17 +514,35 @@ test("update verifies matching checksum", async () => { }); globalThis.fetch = fetchMock as typeof fetch; - console.log = mock(() => undefined); - console.error = mock(() => undefined); + const logMock = mock(() => undefined); + const errorMock = mock(() => undefined); + console.log = logMock; + console.error = errorMock; try { const { handleManualUpdateCommand } = await import( `../../src/loop/update?checksum=${Date.now()}` ); - await handleManualUpdateCommand(["update"]); + expect(await handleManualUpdateCommand(["update"])).toBe(true); expect(existsSync(STAGED_BINARY)).toBe(true); expect(existsSync(METADATA_FILE)).toBe(true); + expect(readFileSync(STAGED_BINARY, "utf-8")).toBe(binaryData); + const metadata = JSON.parse(readFileSync(METADATA_FILE, "utf-8")); + expect(metadata.targetVersion).toBe("99.0.0"); + expect(metadata.sourceUrl).toBe("https://example.com/loop-binary"); + expect(metadata.downloadedAt).toBeTruthy(); + expect(errorMock).not.toHaveBeenCalled(); + const logMessages = logMock.mock.calls.map((call) => String(call[0])); + expect( + logMessages.some((msg) => msg.includes("[loop] downloading v99.0.0...")) + ).toBe(true); + expect( + logMessages.some((msg) => + msg.includes("[loop] v99.0.0 staged — will apply on next startup") + ) + ).toBe(true); + expect(fetchMock.mock.calls).toHaveLength(3); } finally { Object.defineProperty(process, "execPath", { value: originalExecPath, @@ -577,20 +605,33 @@ test("update rejects mismatched checksum", async () => { globalThis.fetch = fetchMock as typeof fetch; const errorMock = mock(() => undefined); - console.log = mock(() => undefined); + const logMock = mock(() => undefined); console.error = errorMock; + console.log = logMock; try { const { handleManualUpdateCommand } = await import( `../../src/loop/update?badchecksum=${Date.now()}` ); - await handleManualUpdateCommand(["update"]); + await expect(handleManualUpdateCommand(["update"])).rejects.toThrow( + "Checksum mismatch" + ); const errorCalls = errorMock.mock.calls.map((c) => String(c[0])); expect(errorCalls.some((msg) => msg.includes("Checksum mismatch"))).toBe( true ); + expect( + errorCalls.some((msg) => + msg.includes("[loop] update failed: Checksum mismatch") + ) + ).toBe(true); expect(existsSync(STAGED_BINARY)).toBe(false); + expect(existsSync(METADATA_FILE)).toBe(false); + const logMessages = logMock.mock.calls.map((call) => String(call[0])); + expect(logMessages.some((msg) => msg.includes("v99.0.0 staged"))).toBe( + false + ); } finally { Object.defineProperty(process, "execPath", { value: originalExecPath, @@ -608,7 +649,7 @@ test("update rejects mismatched checksum", async () => { } }); -test("update warns when no checksum available", async () => { +test("update rejects when no checksum available", async () => { const osName = process.platform === "darwin" ? "macos" : "linux"; const assetName = `loop-${osName}-${process.arch}`; @@ -642,20 +683,27 @@ test("update warns when no checksum available", async () => { globalThis.fetch = fetchMock as typeof fetch; const errorMock = mock(() => undefined); - console.log = mock(() => undefined); + const logMock = mock(() => undefined); + console.log = logMock; console.error = errorMock; try { const { handleManualUpdateCommand } = await import( `../../src/loop/update?nochecksum=${Date.now()}` ); - await handleManualUpdateCommand(["update"]); + await expect(handleManualUpdateCommand(["update"])).rejects.toThrow( + "No .sha256 checksum available" + ); const errorCalls = errorMock.mock.calls.map((c) => String(c[0])); expect( - errorCalls.some((msg) => msg.includes("no .sha256 checksum available")) + errorCalls.some((msg) => + msg.includes("refusing to install unverified binary") + ) ).toBe(true); - expect(existsSync(STAGED_BINARY)).toBe(true); + expect(existsSync(STAGED_BINARY)).toBe(false); + expect(existsSync(METADATA_FILE)).toBe(false); + expect(fetchMock.mock.calls).toHaveLength(2); } finally { Object.defineProperty(process, "execPath", { value: originalExecPath, From 0d50f50c03a4609e7adf50a8b1843ef77c0eb071 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 22 Feb 2026 10:40:49 -0800 Subject: [PATCH 2/2] Fail install when SHA256 tool is unavailable --- install.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index 54cf2b0..24c00ee 100755 --- a/install.sh +++ b/install.sh @@ -91,16 +91,26 @@ fi checksum_url="${asset_url}.sha256" checksum_path="$DOWNLOAD_DIR/${asset}.sha256" +HASH_TOOL="" + +if command -v shasum >/dev/null 2>&1; then + HASH_TOOL="shasum" +elif command -v sha256sum >/dev/null 2>&1; then + HASH_TOOL="sha256sum" +else + echo "Error: unable to verify SHA256 checksum (shasum or sha256sum is required)" >&2 + exit 1 +fi if download_file "$checksum_url" "$checksum_path"; then expected=$(cut -d ' ' -f 1 < "$checksum_path") - if command -v shasum >/dev/null 2>&1; then + if [ "$HASH_TOOL" = "shasum" ]; then actual=$(shasum -a 256 "$binary_path" | cut -d ' ' -f 1) - elif command -v sha256sum >/dev/null 2>&1; then + elif [ "$HASH_TOOL" = "sha256sum" ]; then actual=$(sha256sum "$binary_path" | cut -d ' ' -f 1) else - echo "Warning: no sha256 tool found, skipping verification" >&2 - actual="$expected" + echo "Error: unknown checksum tool selected: $HASH_TOOL" >&2 + exit 1 fi if [ "$expected" != "$actual" ]; then