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
55 changes: 50 additions & 5 deletions src/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<sha256> <filename>"). 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:
Expand Down Expand Up @@ -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;
}

Expand Down
34 changes: 33 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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 {
Expand All @@ -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(
Expand Down
82 changes: 81 additions & 1 deletion test/download.test.js
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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"/);
});
Loading