From c4b87560b165aa2f79a1ea1da83ee819072913c4 Mon Sep 17 00:00:00 2001 From: Retsumdk Date: Wed, 16 Sep 2026 16:12:42 +0000 Subject: [PATCH] release: make SHA256SUMS cover every payload asset The publish job writes SHA256SUMS from the assets that exist when it runs - the standalone tarballs and the win32 zip - so the two snaps and the CycloneDX SBOM shipped with no checksum at all, and a third of the release could only be verified by knowing which of the two mechanisms applied to it. publish-release now rebuilds that file on the draft, where the complete asset set is attached, and re-uploads it before the release is flipped - the last point at which an asset can still be replaced. Assets are hashed from the sha256 digest the releases API reports for them, falling back to hashing the downloaded bytes when an asset has no digest, and every entry the file already carried must survive the rebuild unchanged or the step refuses to continue. The per-file .sha256 sidecars stay exactly as they are. Closes #913 --- .github/workflows/release-artifacts.yml | 35 +++ docs/DISTRIBUTION.md | 22 +- scripts/release-sums.mjs | 188 ++++++++++++++++ tests/unit/release-sums.test.ts | 278 ++++++++++++++++++++++++ 4 files changed, 515 insertions(+), 8 deletions(-) create mode 100644 scripts/release-sums.mjs create mode 100644 tests/unit/release-sums.test.ts diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 9c3590792..b88d6a20d 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -408,6 +408,10 @@ jobs: - name: Generate SHA256SUMS # Covers the POSIX tarballs AND the win32 zip: winget, Chocolatey, # and the npx launcher all verify against this one checksum file. + # Those are the only payload assets that exist this early - the snap, + # .deb/.rpm/.AppImage and SBOM assets are built by later jobs, so + # publish-release widens this file over the whole draft asset set + # (issue #913) before the release is published. run: | cd dist sha256sum libredb-studio-standalone-*.tar.gz libredb-studio-standalone-*.zip > SHA256SUMS @@ -1121,6 +1125,37 @@ jobs: permissions: contents: write steps: + # The widening step below runs a repo script, so the job needs the tree. + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + + # Close the checksum gap (issue #913). The publish job can only cover the + # tarballs and the win32 zip, because nothing else exists when it runs; + # by now every payload asset is on the draft, so rebuild the one combined + # file over the whole set - the two snaps and the CycloneDX SBOM were the + # assets left with no checksum of any kind. This has to happen before the + # publish below: from there the asset set is frozen (issue #154), and + # dist/SHA256SUMS is the file the widening rewrites. The script reads + # each asset's recorded sha256 from the releases API and refuses to emit + # a file in which an entry the release already carried has changed, so + # the npx launcher, Homebrew and Chocolatey/winget lookups stay valid - + # a rebuilt file only ever widens. + - name: Widen SHA256SUMS to cover every payload asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.guard.outputs.version }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" > /tmp/release.json + mkdir -p dist + gh release download "$TAG" --pattern SHA256SUMS --dir dist --clobber --repo "$GITHUB_REPOSITORY" + node scripts/release-sums.mjs /tmp/release.json \ + --existing dist/SHA256SUMS --allow-download > dist/SHA256SUMS.new + mv dist/SHA256SUMS.new dist/SHA256SUMS + cat dist/SHA256SUMS + gh release upload "$TAG" dist/SHA256SUMS --clobber --repo "$GITHUB_REPOSITORY" + - name: Verify the draft carries the full asset set env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 79bdaf3ad..f8b98e519 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -196,15 +196,19 @@ Release tags carry **no `v` prefix** (tag `0.9.41` == package.json version). Eac |---|---|---| | Standalone server tarball | `libredb-studio-standalone---.tar.gz` | `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64` | | Standalone server zip (Windows) | `libredb-studio-standalone--win32-x64.zip` | `win32-x64` (bundled Node runtime + `libredb-studio.exe` launcher) | -| Checksums | `SHA256SUMS` | covers all standalone tarballs and the win32 zip | +| Checksums | `SHA256SUMS` | covers every payload asset except the `.sha256` sidecars | | Debian package | `libredb-studio__.deb` (+ `.sha256` sidecar) | `amd64`, `arm64` | | RPM package | `libredb-studio-..rpm` (+ `.sha256` sidecar) | `x86_64`, `aarch64` | | Snap | `libredb-studio__.snap` | `amd64`, `arm64` (also published to the Snap Store) | | Desktop AppImage | `libredb-studio-desktop--linux-.AppImage` (+ `.sha256` sidecar) | `x64`, `arm64` (also the artifact the in-repo Flatpak manifest repacks) | | Desktop Debian package | `libredb-studio-desktop__.deb` (+ `.sha256` sidecar) | `amd64`, `arm64` (from 0.9.62; the artifact FlatPark pins as extra-data) | -`SHA256SUMS` covers the standalone tarballs and the win32 zip; each `.deb`/`.rpm`/`.AppImage` -ships its own per-file `.sha256` sidecar instead (those are built in separate jobs). +`SHA256SUMS` covers every payload asset in the release — the standalone tarballs, the win32 zip, +both `.snap` files and the CycloneDX SBOM — so one file answers "what is the hash of what I just +downloaded?", whichever artifact that was. The `.deb`/`.rpm` packages and the desktop AppImage +additionally ship a per-file `.sha256` sidecar, written by the job that builds them; the +combined file covers them too, so a reader no longer has to know which of the two mechanisms +applies to the artifact they picked. **Two different `.deb`s ship per release and they are not interchangeable.** `libredb-studio__.deb` is the headless server: it installs a systemd unit and is @@ -1281,11 +1285,13 @@ All channels have now had their first live run (the Snap publish completed its f ### Artifact provenance roadmap -Standalone tarballs and `.deb`/`.rpm` packages are checksum-verified against `SHA256SUMS` / -per-package `.sha256` sidecars (see [Release artifact naming](#release-artifact-naming)) — both -from the same GitHub release. That pairing detects corruption, not substitution: whoever can -replace an asset can replace its checksum line too. The `.snap` release asset ships no sidecar -(`--dangerous` installs skip the Snap Store's own verification). +Every payload asset is checksum-verified against `SHA256SUMS`, and the `.deb`/`.rpm` packages and +the desktop AppImage ship their own `.sha256` sidecar as well +(see [Release artifact naming](#release-artifact-naming)) — all from the same GitHub release. That +pairing detects corruption, not substitution: whoever can replace an asset can replace its +checksum line too, which is what the signed provenance below is for. The `.snap` assets are covered +by `SHA256SUMS` like every other payload asset, which matters for `snap install --dangerous`: the +Snap Store's own verification is skipped there, so the release checksum is the only one a user has. Signed provenance moves the trust root out of the release — the signer is the workflow's GitHub OIDC identity (repo + workflow + commit), recorded in a public transparency log. All three steps of diff --git a/scripts/release-sums.mjs b/scripts/release-sums.mjs new file mode 100644 index 000000000..7c6032423 --- /dev/null +++ b/scripts/release-sums.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/** + * Rebuild the release's combined SHA256SUMS so it covers every payload asset + * (issue #913). + * + * Why not in the `publish` job: it runs before the snap, SBOM and desktop jobs + * have attached anything, so the file it writes can only cover the standalone + * tarballs and the win32 zip. The .deb/.rpm/.AppImage assets were given + * per-file `.sha256` sidecars as a workaround, and the two snaps and the + * CycloneDX SBOM ended up with neither - so a reader who downloads a .snap + * from the release page has nothing to check it against. + * + * This runs in `publish-release`, immediately before the draft is published, + * when every payload asset exists and the asset set can still be amended. + * + * - every payload asset is covered (anything that is not SHA256SUMS itself + * and not a `.sha256` sidecar); + * - the digests come from the releases API, which records the sha256 of the + * bytes as stored - hashing a fresh download would be a second opinion on + * the same bytes, at the cost of pulling ~1.5 GB in the last job of the + * release chain. An asset with no digest recorded is downloaded and hashed + * locally instead; + * - every entry the file already carried must survive with the same hash. + * The npx launcher, the Homebrew formula and the Chocolatey/winget + * renderers all read this file, so the rebuild may only widen it; + * - the `sha256sum` output format is preserved (hash, two spaces, bare file + * name, newline) and names are sorted, which is the order the `publish` + * job's glob already produced. + * + * Usage: + * gh api "repos///releases/tags/" > release.json + * gh release download --pattern SHA256SUMS --dir dist + * node scripts/release-sums.mjs release.json --existing dist/SHA256SUMS > SHA256SUMS + * + * --existing the SHA256SUMS already on the release; its entries are + * asserted to survive unchanged + * --allow-download hash an asset locally when the API records no digest for + * it (without this flag that is an error) + */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +const SUMS_NAME = "SHA256SUMS"; +const SIDECAR_SUFFIX = ".sha256"; +const DIGEST_PREFIX = "sha256:"; +const HEX64 = /^[0-9a-f]{64}$/; +const SUMS_LINE = /^([0-9a-f]{64}) {2}(.+)$/; + +/** Payload assets are everything a user downloads and runs. */ +export function isPayloadAsset(name) { + return name !== SUMS_NAME && !name.endsWith(SIDECAR_SUFFIX); +} + +/** The sha256 the releases API records for an asset, or null when it has none. */ +export function assetSha256(asset) { + const digest = typeof asset?.digest === "string" ? asset.digest : ""; + if (!digest.startsWith(DIGEST_PREFIX)) return null; + const hex = digest.slice(DIGEST_PREFIX.length); + return HEX64.test(hex) ? hex : null; +} + +/** One `sha256sum` output line. Bare file name: consumers look entries up by name. */ +export function formatSum(hash, name) { + return `${hash} ${name}\n`; +} + +/** Payload assets, sorted by name - the order the release's own glob produces. */ +export function payloadAssets(assets) { + const payload = (assets ?? []).filter((asset) => isPayloadAsset(asset?.name)); + const names = new Set(); + for (const asset of payload) { + if (names.has(asset.name)) throw new Error(`Release asset list carries '${asset.name}' twice`); + names.add(asset.name); + } + return payload.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} + +export function parseSums(text) { + const entries = new Map(); + for (const line of (text ?? "").split("\n")) { + if (line.trim() === "") continue; + const match = SUMS_LINE.exec(line); + if (!match) throw new Error(`Malformed SHA256SUMS line: ${JSON.stringify(line)}`); + entries.set(match[2], match[1]); + } + return entries; +} + +/** Hash a fetch Response body. */ +export async function hashResponse(response) { + if (!response.ok) throw new Error(`Cannot download the asset: HTTP ${response.status}`); + const hash = crypto.createHash("sha256"); + for await (const chunk of Readable.fromWeb(response.body)) hash.update(chunk); + return hash.digest("hex"); +} + +export async function hashUrl(url, fetchImpl = fetch) { + return hashResponse(await fetchImpl(url)); +} + +/** + * Rebuild the combined checksum file from a release's asset list. + * Throws rather than emitting a partial file: this runs on a draft that is + * about to become immutable, so a wrong file is permanent. + */ +export async function buildSums(assets, { allowDownload = false, fetchImpl = fetch } = {}) { + const payload = payloadAssets(assets); + if (payload.length === 0) throw new Error("Release asset list has no payload assets"); + + let sums = ""; + for (const asset of payload) { + let hash = assetSha256(asset); + if (hash === null) { + if (!allowDownload) { + throw new Error( + `Release asset '${asset.name}' has no sha256 digest recorded - re-run with --allow-download to hash it locally`, + ); + } + if (!asset.browser_download_url) { + throw new Error(`Release asset '${asset.name}' has no digest and no download URL`); + } + hash = await hashUrl(asset.browser_download_url, fetchImpl); + } + sums += formatSum(hash, asset.name); + } + return sums; +} + +/** Every entry the previous file carried must survive, unchanged. */ +export function assertSuperset(existingText, rebuiltText) { + const existing = parseSums(existingText); + const rebuilt = parseSums(rebuiltText); + for (const [name, hash] of existing) { + const current = rebuilt.get(name); + if (current === undefined) { + throw new Error(`Rebuilt SHA256SUMS dropped the existing entry for '${name}'`); + } + if (current !== hash) { + throw new Error(`Rebuilt SHA256SUMS changed the hash for '${name}' (was ${hash}, now ${current})`); + } + } +} + +function parseArgs(argv) { + const args = { releasePath: "", existingPath: "", allowDownload: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--allow-download") { + args.allowDownload = true; + } else if (arg === "--existing") { + args.existingPath = argv[i + 1] ?? ""; + if (args.existingPath === "") throw new Error("--existing needs a path"); + i += 1; + } else if (arg.startsWith("--")) { + throw new Error(`Unknown argument: ${arg}`); + } else if (args.releasePath === "") { + args.releasePath = arg; + } else { + throw new Error(`Unexpected argument: ${arg}`); + } + } + if (args.releasePath === "") { + throw new Error("Usage: node scripts/release-sums.mjs [--existing ] [--allow-download]"); + } + return args; +} + +async function main(argv) { + const args = parseArgs(argv); + const release = JSON.parse(fs.readFileSync(args.releasePath, "utf8")); + const rebuilt = await buildSums(release.assets, { allowDownload: args.allowDownload }); + if (args.existingPath !== "") { + assertSuperset(fs.readFileSync(args.existingPath, "utf8"), rebuilt); + } + process.stdout.write(rebuilt); + console.error(`Rebuilt SHA256SUMS over ${rebuilt.trimEnd().split("\n").length} payload assets`); +} + +// CLI entry only when executed directly (the unit test imports this module). +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch((error) => { + console.error(`release-sums: ${error.message}`); + process.exit(1); + }); +} diff --git a/tests/unit/release-sums.test.ts b/tests/unit/release-sums.test.ts new file mode 100644 index 000000000..dfe2df4c3 --- /dev/null +++ b/tests/unit/release-sums.test.ts @@ -0,0 +1,278 @@ +/** + * Unit tests for the release checksum rebuild + * (scripts/release-sums.mjs, issue #913). + * + * Why a test for this one: SHA256SUMS is what a user checks a downloaded + * artifact against, and it is rebuilt on a draft release that is about to + * become immutable - a file that silently drops or changes an entry would be + * permanent and would invalidate the checksums the npx launcher, the Homebrew + * formula and the Chocolatey/winget renderers are already pinned to. The + * no-network paths and the superset assertion are what keep that from + * happening; the CLI is exercised as a child process in the last block so the + * argument handling and the exit codes are covered too. + */ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + assertSuperset, + assetSha256, + buildSums, + formatSum, + hashResponse, + hashUrl, + isPayloadAsset, + parseSums, + payloadAssets, +} from "../../scripts/release-sums.mjs"; + +const SCRIPT = path.join(__dirname, "../../scripts/release-sums.mjs"); +const VERSION = "0.16.0"; +const HEX_A = "a".repeat(64); +const HEX_B = "b".repeat(64); + +function asset(name: string, digest: string | null = HEX_A, url = `https://example.test/${name}`) { + return { name, digest: digest === null ? null : `sha256:${digest}`, browser_download_url: url }; +} + +/** A release asset list shaped like `gh api releases/tags/`. */ +function releaseFixture() { + const assets = [ + asset(`libredb-studio-standalone-${VERSION}-linux-x64.tar.gz`), + asset(`libredb-studio-standalone-${VERSION}-win32-x64.zip`), + asset(`libredb-studio_${VERSION}_amd64.deb`), + asset(`libredb-studio_${VERSION}_amd64.deb.sha256`), + asset(`libredb-studio_${VERSION}_amd64.snap`), + asset(`libredb-studio-${VERSION}.cdx.json`), + asset("SHA256SUMS"), + ]; + return { tag_name: VERSION, assets }; +} + +describe("payload asset selection", () => { + test("treats everything except SHA256SUMS and the .sha256 sidecars as payload", () => { + expect(isPayloadAsset("libredb-studio_0.16.0_amd64.snap")).toBe(true); + expect(isPayloadAsset("libredb-studio-0.16.0.cdx.json")).toBe(true); + expect(isPayloadAsset("libredb-studio_0.16.0_amd64.deb.sha256")).toBe(false); + expect(isPayloadAsset("SHA256SUMS")).toBe(false); + }); + + test("sorts by name, which is the order the publish job's glob already produced", () => { + const names = payloadAssets([ + asset(`libredb-studio-standalone-${VERSION}-win32-x64.zip`), + asset(`libredb-studio-standalone-${VERSION}-darwin-arm64.tar.gz`), + asset("SHA256SUMS"), + ]).map((a) => a.name); + + expect(names).toEqual([ + `libredb-studio-standalone-${VERSION}-darwin-arm64.tar.gz`, + `libredb-studio-standalone-${VERSION}-win32-x64.zip`, + ]); + }); + + test("refuses a list that carries the same asset twice", () => { + expect(() => payloadAssets([asset("a.snap"), asset("a.snap")])).toThrow(/twice/); + }); +}); + +describe("assetSha256", () => { + test("reads the digest the API records", () => { + expect(assetSha256(asset("a.snap", HEX_B))).toBe(HEX_B); + }); + + test("returns null when there is no usable digest", () => { + expect(assetSha256(asset("a.snap", null))).toBeNull(); + expect(assetSha256({ name: "a.snap" })).toBeNull(); + expect(assetSha256({ name: "a.snap", digest: HEX_A })).toBeNull(); // no sha256: prefix + expect(assetSha256({ name: "a.snap", digest: "sha256:not-hex" })).toBeNull(); + expect(assetSha256({ name: "a.snap", digest: `sha256:${"a".repeat(63)}` })).toBeNull(); + expect(assetSha256(undefined)).toBeNull(); + }); +}); + +describe("buildSums", () => { + test("covers every payload asset and leaves the sidecars out", async () => { + const sums = await buildSums(releaseFixture().assets); + + // The three assets that had no checksum of any kind before issue #913. + expect(sums).toContain(`${HEX_A} libredb-studio_${VERSION}_amd64.snap\n`); + expect(sums).toContain(`${HEX_A} libredb-studio-${VERSION}.cdx.json\n`); + expect(sums).toContain(`${HEX_A} libredb-studio_${VERSION}_amd64.deb\n`); + expect(sums).not.toContain(".sha256\n"); + expect(sums).not.toContain("SHA256SUMS"); + expect(sums.endsWith("\n")).toBe(true); + }); + + test("emits sha256sum's exact line format, sorted, and nothing else", async () => { + const sums = await buildSums(releaseFixture().assets); + const lines = sums.trimEnd().split("\n"); + + expect(lines).toEqual([...lines].sort()); + for (const line of lines) { + expect(line).toMatch(/^[0-9a-f]{64} {2}[^/]+$/); + } + expect(lines.length).toBe(5); + }); + + test("hashes locally when an asset has no digest and downloads are allowed", async () => { + const body = "snap payload"; + const sums = await buildSums([asset("a.snap", null)], { + allowDownload: true, + fetchImpl: async () => new Response(body), + }); + + expect(sums).toBe(`${crypto.createHash("sha256").update(body).digest("hex")} a.snap\n`); + }); + + test("refuses to hash locally unless --allow-download was given", async () => { + await expect(buildSums([asset("a.snap", null)])).rejects.toThrow( + /'a\.snap' has no sha256 digest recorded - re-run with --allow-download/, + ); + }); + + test("rejects an asset that has neither a digest nor a download URL", async () => { + await expect(buildSums([{ name: "a.snap" }], { allowDownload: true })).rejects.toThrow( + /no digest and no download URL/, + ); + }); + + test("rejects an empty payload set rather than writing an empty file", async () => { + await expect(buildSums([asset("SHA256SUMS")])).rejects.toThrow(/no payload assets/); + }); +}); + +describe("hashResponse / hashUrl", () => { + test("hashes a downloaded asset", async () => { + expect(await hashResponse(new Response("payload"))).toBe( + crypto.createHash("sha256").update("payload").digest("hex"), + ); + }); + + test("fails on a non-2xx download instead of hashing an error page", async () => { + await expect(hashResponse(new Response("nope", { status: 404 }))).rejects.toThrow(/HTTP 404/); + }); + + test("fetches the asset's own URL", async () => { + let requested: string | undefined; + const hash = await hashUrl("https://example.test/a.snap", async (url) => { + requested = url; + return new Response("payload"); + }); + + expect(requested).toBe("https://example.test/a.snap"); + expect(hash).toBe(crypto.createHash("sha256").update("payload").digest("hex")); + }); +}); + +describe("assertSuperset", () => { + const before = `${HEX_A} libredb-studio-standalone-${VERSION}-linux-x64.tar.gz\n`; + + test("accepts a widened file", () => { + expect(() => assertSuperset(before, before + `${HEX_B} libredb-studio_${VERSION}_amd64.snap\n`)).not.toThrow(); + }); + + test("rejects a dropped entry", () => { + expect(() => assertSuperset(before, `${HEX_B} other.snap\n`)).toThrow(/dropped the existing entry/); + }); + + test("rejects a changed hash for an entry that is still listed", () => { + expect(() => assertSuperset(before, `${HEX_B} libredb-studio-standalone-${VERSION}-linux-x64.tar.gz\n`)).toThrow( + /changed the hash for 'libredb-studio-standalone-0\.16\.0-linux-x64\.tar\.gz'/, + ); + }); +}); + +describe("parseSums", () => { + test("reads entries and skips blank lines", () => { + const entries = parseSums(`${HEX_A} a.tar.gz\n\n${HEX_B} b.snap\n`); + + expect(entries.get("a.tar.gz")).toBe(HEX_A); + expect(entries.get("b.snap")).toBe(HEX_B); + expect(entries.size).toBe(2); + expect(parseSums(undefined).size).toBe(0); + }); + + test("rejects a line it cannot read rather than ignoring it", () => { + expect(() => parseSums("not a checksum line\n")).toThrow(/Malformed SHA256SUMS line/); + }); + + test("formats a line the way sha256sum does", () => { + expect(formatSum(HEX_A, "a.snap")).toBe(`${HEX_A} a.snap\n`); + }); +}); + +describe("the CLI", () => { + function run(args: string[]) { + return spawnSync("node", [SCRIPT, ...args], { encoding: "utf8" }); + } + + function fixtureDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "release-sums-")); + const releasePath = path.join(dir, "release.json"); + fs.writeFileSync(releasePath, JSON.stringify(releaseFixture())); + const existingPath = path.join(dir, "SHA256SUMS"); + fs.writeFileSync( + existingPath, + `${HEX_A} libredb-studio-standalone-${VERSION}-linux-x64.tar.gz\n` + + `${HEX_A} libredb-studio-standalone-${VERSION}-win32-x64.zip\n`, + ); + return { dir, releasePath, existingPath }; + } + + test("rebuilds the file and keeps the entries it already carried", () => { + const { releasePath, existingPath } = fixtureDir(); + const result = run([releasePath, "--existing", existingPath]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(`${HEX_A} libredb-studio_${VERSION}_amd64.snap\n`); + expect(result.stderr).toContain("Rebuilt SHA256SUMS over 5 payload assets"); + }); + + test("fails when an entry the release already carried would change", () => { + const { releasePath, existingPath } = fixtureDir(); + fs.writeFileSync(existingPath, `${HEX_B} libredb-studio-${VERSION}.cdx.json\n`); + const result = run([releasePath, "--existing", existingPath]); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("changed the hash for 'libredb-studio-0.16.0.cdx.json'"); + }); + + test("fails on a missing digest rather than writing a partial file", () => { + const { dir, releasePath } = fixtureDir(); + const release = JSON.parse(fs.readFileSync(releasePath, "utf8")); + release.assets[0].digest = null; + fs.writeFileSync(releasePath, JSON.stringify(release)); + const result = run([releasePath]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(`'${release.assets[0].name}' has no sha256 digest recorded`); + expect(fs.readdirSync(dir).sort()).toEqual(["SHA256SUMS", "release.json"]); + }); + + test("prints the usage line when the release document is missing", () => { + const result = run([]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage: node scripts/release-sums.mjs "); + }); + + test("rejects an --existing flag with no path, an unknown flag and a stray argument", () => { + const { releasePath } = fixtureDir(); + + expect(run([releasePath, "--existing"]).stderr).toContain("--existing needs a path"); + expect(run([releasePath, "--nope"]).stderr).toContain("Unknown argument: --nope"); + expect(run([releasePath, "extra.json"]).stderr).toContain("Unexpected argument: extra.json"); + }); + + test("accepts --allow-download", () => { + const { releasePath } = fixtureDir(); + const result = run([releasePath, "--allow-download"]); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Rebuilt SHA256SUMS over 5 payload assets"); + }); +});