Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,42 @@ if ! download_file "$asset_url" "$binary_path"; then
exit 1
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 [ "$HASH_TOOL" = "shasum" ]; then
actual=$(shasum -a 256 "$binary_path" | cut -d ' ' -f 1)
elif [ "$HASH_TOOL" = "sha256sum" ]; then
actual=$(sha256sum "$binary_path" | cut -d ' ' -f 1)
else
echo "Error: unknown checksum tool selected: $HASH_TOOL" >&2
exit 1
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"
Expand Down
5 changes: 3 additions & 2 deletions src/loop/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

Expand Down Expand Up @@ -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;
};
Expand Down
70 changes: 59 additions & 11 deletions tests/loop/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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}`;

Expand Down Expand Up @@ -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,
Expand Down