diff --git a/src/download.js b/src/download.js index b334846..3b361e6 100644 --- a/src/download.js +++ b/src/download.js @@ -22,10 +22,49 @@ function mapOS(os) { return mappings[os] || os; } -export function getDownloadUrl({ version, platform, arch }) { - const filename = `kosli_${version}_${mapOS(platform)}_${mapArch(arch)}`; +// Name of the release asset for a given version/platform/arch, e.g. +// "kosli_2.11.43_linux_amd64.tar.gz". This is also the filename used to look the +// asset up in the release's checksums.txt, so it is the single source of truth. +export function getAssetFilename({ version, platform, arch }) { const extension = platform === "win32" ? "zip" : "tar.gz"; - return `https://github.com/kosli-dev/cli/releases/download/v${version}/${filename}.${extension}`; + return `kosli_${version}_${mapOS(platform)}_${mapArch(arch)}.${extension}`; +} + +export function getDownloadUrl({ version, platform, arch }) { + const filename = getAssetFilename({ version, platform, arch }); + return `https://github.com/kosli-dev/cli/releases/download/v${version}/${filename}`; +} + +// URL of the SHA-256 checksums file published alongside each release. +export function getChecksumsUrl(version) { + return `https://github.com/kosli-dev/cli/releases/download/v${version}/kosli_${version}_checksums.txt`; +} + +// Verify that `actualHex` matches the digest recorded for `assetFilename` in a +// goreleaser-style checksums file (lines of " "). Throws if the +// asset is not listed or the digest differs. Comparison is case-insensitive. +export function verifyChecksum(actualHex, checksumsText, assetFilename) { + let expected = null; + for (const line of checksumsText.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const parts = trimmed.split(/\s+/); + const name = parts[parts.length - 1]; + if (name === assetFilename) { + expected = parts[0]; + break; + } + } + if (expected === null) { + throw new Error(`checksums file does not list an entry for "${assetFilename}"`); + } + if (expected.toLowerCase() !== actualHex.toLowerCase()) { + throw new Error( + `checksum mismatch for "${assetFilename}": expected ${expected.toLowerCase()}, got ${actualHex.toLowerCase()}` + ); + } } // Classify the `version` input: @@ -92,8 +131,14 @@ function highestStableRelease(releases, major, minor) { export async function resolveVersion(version, token, octokit) { const spec = classifyVersion(version); - // A full semver or any literal tag is used exactly as given, with no API call. - if (spec.kind === "exact" || spec.kind === "literal") { + // A full semver is used as-is (no API call), but with any leading "v" stripped so + // the resolved value is a bare "x.y.z" like the latest/partial paths produce. + if (spec.kind === "exact") { + return version.replace(/^v/, ""); + } + + // Any other literal tag (e.g. "Latest") is used exactly as given, no API call. + if (spec.kind === "literal") { return version; } diff --git a/src/index.js b/src/index.js index 6f39f3f..22c91b6 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,9 @@ import os from "os"; +import crypto from "crypto"; +import fs from "fs"; import * as core from "@actions/core"; import * as tc from "@actions/tool-cache"; -import { getDownloadUrl, resolveVersion } from "./download.js"; +import { getAssetFilename, getChecksumsUrl, getDownloadUrl, resolveVersion, verifyChecksum } from "./download.js"; import { withRetries } from "./retry.js"; async function setup() { @@ -24,6 +26,7 @@ async function setup() { () => tc.downloadTool(downloadUrl), { onRetry: logRetry("downloading Kosli CLI") } ); + await verifyDownload({ pathToTarball, version: resolvedVersion, platform, arch }); const extracted = await tc.extractTar(pathToTarball); pathToCLI = await tc.cacheDir(extracted, "kosli", resolvedVersion); } else { @@ -38,6 +41,35 @@ async function setup() { } } +// Verify the downloaded asset against the release's SHA-256 checksums file. A +// mismatch (or the asset missing from the file) throws and fails the action. If the +// release published no checksums file at all (e.g. very old versions), we warn and +// continue rather than break the install. +async function verifyDownload({ pathToTarball, version, platform, arch }) { + const checksumsUrl = getChecksumsUrl(version); + let checksumsPath; + try { + checksumsPath = await withRetries( + () => tc.downloadTool(checksumsUrl), + { onRetry: logRetry("downloading Kosli CLI checksums") } + ); + } catch (e) { + if (e.httpStatusCode === 404) { + core.warning( + `no checksums file published for Kosli CLI v${version}; skipping checksum verification` + ); + return; + } + throw e; + } + + const assetFilename = getAssetFilename({ version, platform, arch }); + const actualHex = crypto.createHash("sha256").update(fs.readFileSync(pathToTarball)).digest("hex"); + const checksumsText = fs.readFileSync(checksumsPath, "utf8"); + verifyChecksum(actualHex, checksumsText, assetFilename); + console.log(`verified Kosli CLI ${assetFilename} checksum`); +} + function logRetry(label) { return ({ attempt, retries, delayMs, error }) => { core.warning( diff --git a/test/download.test.js b/test/download.test.js index 1abacb8..62ae96d 100644 --- a/test/download.test.js +++ b/test/download.test.js @@ -1,5 +1,11 @@ import test from "ava"; -import { getDownloadUrl, resolveVersion } from "../src/download.js"; +import { + getAssetFilename, + getChecksumsUrl, + getDownloadUrl, + resolveVersion, + verifyChecksum +} from "../src/download.js"; const baseUrl = "https://github.com/kosli-dev/cli/releases/download/"; const testCases = [ @@ -169,3 +175,77 @@ test("resolveVersion surfaces a descriptive error when listing releases fails", message: /failed to resolve Kosli CLI version "2".*rate limit/ }); }); + +// --- leading "v" on an exact pin --- + +test("resolveVersion strips a leading v from an exact semver pin", async t => { + // No octokit/token needed: an exact pin must not hit the API. + t.is(await resolveVersion("v2.11.43", "token-unused"), "2.11.43"); +}); + +test("resolveVersion leaves an unprefixed exact semver unchanged", async t => { + t.is(await resolveVersion("2.11.43", "token-unused"), "2.11.43"); +}); + +// --- asset filename / checksums url --- + +test("getAssetFilename renders the tar.gz asset name on linux", t => { + t.is(getAssetFilename({ version: "2.11.43", platform: "linux", arch: "x64" }), "kosli_2.11.43_linux_amd64.tar.gz"); +}); + +test("getAssetFilename renders the zip asset name on windows", t => { + t.is(getAssetFilename({ version: "2.11.43", platform: "win32", arch: "amd64" }), "kosli_2.11.43_windows_amd64.zip"); +}); + +test("getAssetFilename renders the darwin arm64 asset name", t => { + t.is(getAssetFilename({ version: "2.11.43", platform: "darwin", arch: "arm64" }), "kosli_2.11.43_darwin_arm64.tar.gz"); +}); + +test("getChecksumsUrl points at the release's checksums file", t => { + t.is( + getChecksumsUrl("2.11.43"), + "https://github.com/kosli-dev/cli/releases/download/v2.11.43/kosli_2.11.43_checksums.txt" + ); +}); + +// --- verifyChecksum --- + +const checksumsFixture = [ + "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111 kosli_2.11.43_linux_amd64.tar.gz", + "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222 kosli_2.11.43_windows_amd64.zip", + "" +].join("\n"); + +test("verifyChecksum passes when the digest matches", t => { + t.notThrows(() => + verifyChecksum( + "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111", + checksumsFixture, + "kosli_2.11.43_linux_amd64.tar.gz" + ) + ); +}); + +test("verifyChecksum matches case-insensitively", t => { + t.notThrows(() => + verifyChecksum( + "AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111", + checksumsFixture, + "kosli_2.11.43_linux_amd64.tar.gz" + ) + ); +}); + +test("verifyChecksum throws on a digest mismatch", t => { + const err = t.throws(() => + verifyChecksum("deadbeef", checksumsFixture, "kosli_2.11.43_linux_amd64.tar.gz") + ); + t.regex(err.message, /checksum mismatch for "kosli_2\.11\.43_linux_amd64\.tar\.gz"/); +}); + +test("verifyChecksum throws when the asset is not listed", t => { + const err = t.throws(() => + verifyChecksum("aaaa1111", checksumsFixture, "kosli_2.11.43_linux_arm64.tar.gz") + ); + t.regex(err.message, /does not list an entry for "kosli_2\.11\.43_linux_arm64\.tar\.gz"/); +});