Skip to content

Commit e44bea4

Browse files
authored
Merge pull request #30 from kosli-dev/20260623_verify_downloads
2 parents 1b92917 + 8b6303b commit e44bea4

3 files changed

Lines changed: 164 additions & 7 deletions

File tree

src/download.js

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,49 @@ function mapOS(os) {
2222
return mappings[os] || os;
2323
}
2424

25-
export function getDownloadUrl({ version, platform, arch }) {
26-
const filename = `kosli_${version}_${mapOS(platform)}_${mapArch(arch)}`;
25+
// Name of the release asset for a given version/platform/arch, e.g.
26+
// "kosli_2.11.43_linux_amd64.tar.gz". This is also the filename used to look the
27+
// asset up in the release's checksums.txt, so it is the single source of truth.
28+
export function getAssetFilename({ version, platform, arch }) {
2729
const extension = platform === "win32" ? "zip" : "tar.gz";
28-
return `https://github.com/kosli-dev/cli/releases/download/v${version}/${filename}.${extension}`;
30+
return `kosli_${version}_${mapOS(platform)}_${mapArch(arch)}.${extension}`;
31+
}
32+
33+
export function getDownloadUrl({ version, platform, arch }) {
34+
const filename = getAssetFilename({ version, platform, arch });
35+
return `https://github.com/kosli-dev/cli/releases/download/v${version}/${filename}`;
36+
}
37+
38+
// URL of the SHA-256 checksums file published alongside each release.
39+
export function getChecksumsUrl(version) {
40+
return `https://github.com/kosli-dev/cli/releases/download/v${version}/kosli_${version}_checksums.txt`;
41+
}
42+
43+
// Verify that `actualHex` matches the digest recorded for `assetFilename` in a
44+
// goreleaser-style checksums file (lines of "<sha256> <filename>"). Throws if the
45+
// asset is not listed or the digest differs. Comparison is case-insensitive.
46+
export function verifyChecksum(actualHex, checksumsText, assetFilename) {
47+
let expected = null;
48+
for (const line of checksumsText.split("\n")) {
49+
const trimmed = line.trim();
50+
if (!trimmed) {
51+
continue;
52+
}
53+
const parts = trimmed.split(/\s+/);
54+
const name = parts[parts.length - 1];
55+
if (name === assetFilename) {
56+
expected = parts[0];
57+
break;
58+
}
59+
}
60+
if (expected === null) {
61+
throw new Error(`checksums file does not list an entry for "${assetFilename}"`);
62+
}
63+
if (expected.toLowerCase() !== actualHex.toLowerCase()) {
64+
throw new Error(
65+
`checksum mismatch for "${assetFilename}": expected ${expected.toLowerCase()}, got ${actualHex.toLowerCase()}`
66+
);
67+
}
2968
}
3069

3170
// Classify the `version` input:
@@ -92,8 +131,14 @@ function highestStableRelease(releases, major, minor) {
92131
export async function resolveVersion(version, token, octokit) {
93132
const spec = classifyVersion(version);
94133

95-
// A full semver or any literal tag is used exactly as given, with no API call.
96-
if (spec.kind === "exact" || spec.kind === "literal") {
134+
// A full semver is used as-is (no API call), but with any leading "v" stripped so
135+
// the resolved value is a bare "x.y.z" like the latest/partial paths produce.
136+
if (spec.kind === "exact") {
137+
return version.replace(/^v/, "");
138+
}
139+
140+
// Any other literal tag (e.g. "Latest") is used exactly as given, no API call.
141+
if (spec.kind === "literal") {
97142
return version;
98143
}
99144

src/index.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import os from "os";
2+
import crypto from "crypto";
3+
import fs from "fs";
24
import * as core from "@actions/core";
35
import * as tc from "@actions/tool-cache";
4-
import { getDownloadUrl, resolveVersion } from "./download.js";
6+
import { getAssetFilename, getChecksumsUrl, getDownloadUrl, resolveVersion, verifyChecksum } from "./download.js";
57
import { withRetries } from "./retry.js";
68

79
async function setup() {
@@ -24,6 +26,7 @@ async function setup() {
2426
() => tc.downloadTool(downloadUrl),
2527
{ onRetry: logRetry("downloading Kosli CLI") }
2628
);
29+
await verifyDownload({ pathToTarball, version: resolvedVersion, platform, arch });
2730
const extracted = await tc.extractTar(pathToTarball);
2831
pathToCLI = await tc.cacheDir(extracted, "kosli", resolvedVersion);
2932
} else {
@@ -38,6 +41,35 @@ async function setup() {
3841
}
3942
}
4043

44+
// Verify the downloaded asset against the release's SHA-256 checksums file. A
45+
// mismatch (or the asset missing from the file) throws and fails the action. If the
46+
// release published no checksums file at all (e.g. very old versions), we warn and
47+
// continue rather than break the install.
48+
async function verifyDownload({ pathToTarball, version, platform, arch }) {
49+
const checksumsUrl = getChecksumsUrl(version);
50+
let checksumsPath;
51+
try {
52+
checksumsPath = await withRetries(
53+
() => tc.downloadTool(checksumsUrl),
54+
{ onRetry: logRetry("downloading Kosli CLI checksums") }
55+
);
56+
} catch (e) {
57+
if (e.httpStatusCode === 404) {
58+
core.warning(
59+
`no checksums file published for Kosli CLI v${version}; skipping checksum verification`
60+
);
61+
return;
62+
}
63+
throw e;
64+
}
65+
66+
const assetFilename = getAssetFilename({ version, platform, arch });
67+
const actualHex = crypto.createHash("sha256").update(fs.readFileSync(pathToTarball)).digest("hex");
68+
const checksumsText = fs.readFileSync(checksumsPath, "utf8");
69+
verifyChecksum(actualHex, checksumsText, assetFilename);
70+
console.log(`verified Kosli CLI ${assetFilename} checksum`);
71+
}
72+
4173
function logRetry(label) {
4274
return ({ attempt, retries, delayMs, error }) => {
4375
core.warning(

test/download.test.js

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import test from "ava";
2-
import { getDownloadUrl, resolveVersion } from "../src/download.js";
2+
import {
3+
getAssetFilename,
4+
getChecksumsUrl,
5+
getDownloadUrl,
6+
resolveVersion,
7+
verifyChecksum
8+
} from "../src/download.js";
39

410
const baseUrl = "https://github.com/kosli-dev/cli/releases/download/";
511
const testCases = [
@@ -169,3 +175,77 @@ test("resolveVersion surfaces a descriptive error when listing releases fails",
169175
message: /failed to resolve Kosli CLI version "2".*rate limit/
170176
});
171177
});
178+
179+
// --- leading "v" on an exact pin ---
180+
181+
test("resolveVersion strips a leading v from an exact semver pin", async t => {
182+
// No octokit/token needed: an exact pin must not hit the API.
183+
t.is(await resolveVersion("v2.11.43", "token-unused"), "2.11.43");
184+
});
185+
186+
test("resolveVersion leaves an unprefixed exact semver unchanged", async t => {
187+
t.is(await resolveVersion("2.11.43", "token-unused"), "2.11.43");
188+
});
189+
190+
// --- asset filename / checksums url ---
191+
192+
test("getAssetFilename renders the tar.gz asset name on linux", t => {
193+
t.is(getAssetFilename({ version: "2.11.43", platform: "linux", arch: "x64" }), "kosli_2.11.43_linux_amd64.tar.gz");
194+
});
195+
196+
test("getAssetFilename renders the zip asset name on windows", t => {
197+
t.is(getAssetFilename({ version: "2.11.43", platform: "win32", arch: "amd64" }), "kosli_2.11.43_windows_amd64.zip");
198+
});
199+
200+
test("getAssetFilename renders the darwin arm64 asset name", t => {
201+
t.is(getAssetFilename({ version: "2.11.43", platform: "darwin", arch: "arm64" }), "kosli_2.11.43_darwin_arm64.tar.gz");
202+
});
203+
204+
test("getChecksumsUrl points at the release's checksums file", t => {
205+
t.is(
206+
getChecksumsUrl("2.11.43"),
207+
"https://github.com/kosli-dev/cli/releases/download/v2.11.43/kosli_2.11.43_checksums.txt"
208+
);
209+
});
210+
211+
// --- verifyChecksum ---
212+
213+
const checksumsFixture = [
214+
"aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111 kosli_2.11.43_linux_amd64.tar.gz",
215+
"bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222 kosli_2.11.43_windows_amd64.zip",
216+
""
217+
].join("\n");
218+
219+
test("verifyChecksum passes when the digest matches", t => {
220+
t.notThrows(() =>
221+
verifyChecksum(
222+
"aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111",
223+
checksumsFixture,
224+
"kosli_2.11.43_linux_amd64.tar.gz"
225+
)
226+
);
227+
});
228+
229+
test("verifyChecksum matches case-insensitively", t => {
230+
t.notThrows(() =>
231+
verifyChecksum(
232+
"AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111AAAA1111",
233+
checksumsFixture,
234+
"kosli_2.11.43_linux_amd64.tar.gz"
235+
)
236+
);
237+
});
238+
239+
test("verifyChecksum throws on a digest mismatch", t => {
240+
const err = t.throws(() =>
241+
verifyChecksum("deadbeef", checksumsFixture, "kosli_2.11.43_linux_amd64.tar.gz")
242+
);
243+
t.regex(err.message, /checksum mismatch for "kosli_2\.11\.43_linux_amd64\.tar\.gz"/);
244+
});
245+
246+
test("verifyChecksum throws when the asset is not listed", t => {
247+
const err = t.throws(() =>
248+
verifyChecksum("aaaa1111", checksumsFixture, "kosli_2.11.43_linux_arm64.tar.gz")
249+
);
250+
t.regex(err.message, /does not list an entry for "kosli_2\.11\.43_linux_arm64\.tar\.gz"/);
251+
});

0 commit comments

Comments
 (0)