From 8f3814946d476e032ccce35cf2574b25e5c678a6 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 1 Nov 2026 00:30:19 +0300 Subject: [PATCH] feat: Added offline AppImage-only release verification and built the... - scripts/gitea-release.mjs - tests/gitea-release.test.ts - dist/LearnHarness-0.6.0-x86_64.AppImage - dist/latest-linux.yml - dist/SHA256SUMS - dist/linux-unpacked/resources/app-update.yml - dist/linux-unpacked/resources/server/standalone/server.js GSD context: - Milestone: M001 - Merge upstream v0.4.0 into the Gitea fork - Slice: S03 - Build and verify Linux desktop - Task: T01 - Added offline AppImage-only release verification and built the local Linux x64 LearnHarness 0.6.0 AppImage without publishing. GSD-Task: S03/T01 --- scripts/gitea-release.mjs | 37 ++++++++----- tests/gitea-release.test.ts | 101 +++++++++++++++++++++++++++++++++--- 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/scripts/gitea-release.mjs b/scripts/gitea-release.mjs index 8db12a9..4bd8c00 100644 --- a/scripts/gitea-release.mjs +++ b/scripts/gitea-release.mjs @@ -18,9 +18,9 @@ export const REPOSITORY = "replicated/learnharness"; export const UPDATE_URL = `${SERVER}/${REPOSITORY}/releases/download/latest`; const API = `${SERVER}/api/v1/repos/${REPOSITORY}`; -export function expectedLinuxAssets(version) { +export function expectedLinuxAssets(version, { appimageOnly = false } = {}) { assert.match(version, /^\d+\.\d+\.\d+$/, "release version must be a stable semver"); - return [`LearnHarness-${version}-x86_64.AppImage`, `LearnHarness-${version}-amd64.deb`, "latest-linux.yml", "SHA256SUMS"]; + return [`LearnHarness-${version}-x86_64.AppImage`, ...(!appimageOnly ? [`LearnHarness-${version}-amd64.deb`] : []), "latest-linux.yml", "SHA256SUMS"]; } export async function fileDigest(file, algorithm, encoding = "hex") { const hash = createHash(algorithm); @@ -28,10 +28,15 @@ export async function fileDigest(file, algorithm, encoding = "hex") { return hash.digest(encoding); } -export async function writeChecksums(directory, version) { - const names = expectedLinuxAssets(version).filter((name) => name !== "SHA256SUMS"); +export async function writeChecksums(directory, version, options = {}) { + const names = expectedLinuxAssets(version, options).filter((name) => name !== "SHA256SUMS"); const lines = []; - for (const name of names) lines.push(`${await fileDigest(path.join(directory, name), "sha256")} ${name}`); + for (const name of names) { + const file = path.join(directory, name); + const stat = await fsp.lstat(file); + assert(stat.isFile() && stat.size > 0, `${name} must be a nonempty regular file`); + lines.push(`${await fileDigest(file, "sha256")} ${name}`); + } await fsp.writeFile(path.join(directory, "SHA256SUMS"), lines.join("\n") + "\n"); } @@ -42,8 +47,8 @@ export async function verifyPackagedUpdater(directory) { assert(!config.owner && !config.repo, "packaged updater must not retain an upstream GitHub destination"); } -export async function verifyLocal(directory, version) { - const names = expectedLinuxAssets(version); +export async function verifyLocal(directory, version, options = {}) { + const names = expectedLinuxAssets(version, options); const inventory = new Map(); for (const name of names) { const file = path.join(directory, name); @@ -51,7 +56,8 @@ export async function verifyLocal(directory, version) { assert(stat.isFile() && stat.size > 0, `${name} must be a nonempty regular file`); inventory.set(name, { name, size: stat.size, sha256: await fileDigest(file, "sha256") }); } - for (const [name, magic] of [[names[0], Buffer.from([0x7f, 0x45, 0x4c, 0x46])], [names[1], Buffer.from("!\n")]]) { + for (const name of names.filter((name) => /\.(AppImage|deb)$/.test(name))) { + const magic = name.endsWith(".AppImage") ? Buffer.from([0x7f, 0x45, 0x4c, 0x46]) : Buffer.from("!\n"); const handle = await fsp.open(path.join(directory, name), "r"); try { const header = Buffer.alloc(magic.length); await handle.read(header, 0, header.length, 0); assert(header.equals(magic), `${name} has an invalid installer header`); } finally { await handle.close(); } } @@ -149,11 +155,18 @@ async function upload(directory, version) { } async function main() { - const [command, directory = "dist"] = process.argv.slice(2); + const [command, ...args] = process.argv.slice(2); + const appimageOnly = args.includes("--appimage-only"); + assert(!appimageOnly || ["checksums", "verify"].includes(command), "--appimage-only is supported only by offline checksums and verify"); + assert(args.filter((arg) => arg === "--appimage-only").length <= 1, "duplicate --appimage-only option"); + assert(args.every((arg) => !arg.startsWith("--") || arg === "--appimage-only"), "unknown release option"); + const positional = args.filter((arg) => arg !== "--appimage-only"); + assert(positional.length <= 1, "expected at most one release directory"); + const directory = positional[0] ?? "dist"; const { version } = JSON.parse(await fsp.readFile(path.join(ROOT, "package.json"), "utf8")); - if (command === "checksums") await writeChecksums(directory, version); + if (command === "checksums") await writeChecksums(directory, version, { appimageOnly }); else if (command === "verify-package") await verifyPackagedUpdater(directory); - else if (command === "verify") console.log(JSON.stringify(await verifyLocal(directory, version), null, 2)); + else if (command === "verify") console.log(JSON.stringify(await verifyLocal(directory, version, { appimageOnly }), null, 2)); else if (command === "upload") await upload(directory, version); else if (command === "verify-remote") { const inventory = await verifyLocal(directory, version); @@ -161,7 +174,7 @@ async function main() { assert(release, "draft not found"); await verifyRemote(release, inventory, authorization()); console.log(`Verified draft: ${release.html_url}`); - } else throw new Error("Usage: node scripts/gitea-release.mjs checksums|verify|upload|verify-remote [dist-directory], or verify-package [unpacked-app-directory]"); + } else throw new Error("Usage: node scripts/gitea-release.mjs checksums|verify [dist-directory] [--appimage-only], upload|verify-remote [dist-directory], or verify-package [unpacked-app-directory]"); } if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { main().catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/tests/gitea-release.test.ts b/tests/gitea-release.test.ts index 5ee838c..1634227 100644 --- a/tests/gitea-release.test.ts +++ b/tests/gitea-release.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -11,14 +12,15 @@ const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-gitea-gate-")); after(() => fs.rm(root, { recursive: true, force: true })); const version = "0.4.0"; const names = expectedLinuxAssets(version); -async function fixture(name: string) { +async function fixture(name: string, appimageOnly = false, fixtureVersion = version) { const directory = path.join(root, name); await fs.mkdir(directory); - const contents = [Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from("AppImage fixture")]), Buffer.from("!\nDebian fixture")]; - for (let i = 0; i < 2; i++) await fs.writeFile(path.join(directory, names[i]!), contents[i]!); - const entries = contents.map((data, i) => ({ url: names[i], size: data.length, sha512: createHash("sha512").update(data).digest("base64") })); - await fs.writeFile(path.join(directory, "latest-linux.yml"), JSON.stringify({ version, files: entries, path: entries[0]!.url, sha512: entries[0]!.sha512 })); - await writeChecksums(directory, version); + const fixtureNames = expectedLinuxAssets(fixtureVersion, { appimageOnly }); + const contents = [Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from("AppImage fixture")]), ...(!appimageOnly ? [Buffer.from("!\nDebian fixture")] : [])]; + for (let i = 0; i < contents.length; i++) await fs.writeFile(path.join(directory, fixtureNames[i]!), contents[i]!); + const entries = contents.map((data, i) => ({ url: fixtureNames[i], size: data.length, sha512: createHash("sha512").update(data).digest("base64") })); + await fs.writeFile(path.join(directory, "latest-linux.yml"), JSON.stringify({ version: fixtureVersion, files: entries, path: entries[0]!.url, sha512: entries[0]!.sha512 })); + await writeChecksums(directory, fixtureVersion, { appimageOnly }); return directory; } @@ -84,6 +86,93 @@ test("release configuration preserves packaging while replacing the upstream upd assert(!workflow.on.pull_request, "untrusted pull requests must not run release publishing"); assert.equal(workflow.permissions.releases, "write"); }); +const scopedVersion = "0.6.0"; +const scopedOptions = { appimageOnly: true }; +const scopedNames = expectedLinuxAssets(scopedVersion, scopedOptions); + +test("AppImage-only offline scope succeeds without deb; default full-release scope still requires deb", async () => { + assert.deepEqual(scopedNames, ["LearnHarness-0.6.0-x86_64.AppImage", "latest-linux.yml", "SHA256SUMS"]); + const directory = await fixture("scoped-healthy", true, scopedVersion); + assert.deepEqual((await verifyLocal(directory, scopedVersion, scopedOptions)).map((asset) => asset.name), scopedNames); + await assert.rejects(verifyLocal(directory, scopedVersion), /ENOENT.*amd64\.deb/); + await assert.rejects(writeChecksums(directory, scopedVersion), /ENOENT.*amd64\.deb/); + const full = await fixture("full-current", false, scopedVersion); + assert.equal((await verifyLocal(full, scopedVersion)).length, 4); + await assert.rejects(verifyLocal(full, scopedVersion, scopedOptions), /reference exactly/); +}); + +for (const [label, mutate, expected] of [ + ["version", (feed: any) => { feed.version = "0.4.0"; }, /metadata version/], + ["missing-files", (feed: any) => { delete feed.files; }, /must contain files/], + ["duplicate", (feed: any) => { feed.files.push({ ...feed.files[0] }); }, /reference exactly/], + ["foreign-url", (feed: any) => { feed.files[0].url = `https://other.example/${scopedNames[0]}`; }, /reference exactly/], + ["traversal", (feed: any) => { feed.files[0].url = `../${scopedNames[0]}`; }, /reference exactly/], + ["absolute-path", (feed: any) => { feed.files[0].url = `/tmp/${scopedNames[0]}`; }, /reference exactly/], + ["wrong-filename", (feed: any) => { feed.files[0].url = "LearnHarness-0.4.0-x86_64.AppImage"; }, /reference exactly/], + ["sha512", (feed: any) => { feed.files[0].sha512 = "bad"; }, /update checksum/], + ["size", (feed: any) => { feed.files[0].size++; }, /update size/], + ["legacy-path", (feed: any) => { feed.path = "https://other.example/payload"; }, /legacy updater path/], + ["legacy-hash", (feed: any) => { feed.sha512 = "bad"; }, /legacy updater checksum/], +] as const) { + test(`AppImage-only rejects ${label} metadata even with regenerated SHA256SUMS`, async () => { + const directory = await fixture(`scoped-${label}`, true, scopedVersion); + const file = path.join(directory, "latest-linux.yml"); + const feed = JSON.parse(await fs.readFile(file, "utf8")); + mutate(feed); + await fs.writeFile(file, JSON.stringify(feed)); + await writeChecksums(directory, scopedVersion, scopedOptions); + await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), expected); + }); +} + +test("AppImage-only rejects malformed YAML and stale SHA256 manifest", async () => { + const directory = await fixture("scoped-malformed", true, scopedVersion); + await fs.writeFile(path.join(directory, "SHA256SUMS"), "0".repeat(64) + ` ${scopedNames[0]}\n`); + await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), /SHA256SUMS/); + await fs.writeFile(path.join(directory, "latest-linux.yml"), "version: [unclosed"); + await writeChecksums(directory, scopedVersion, scopedOptions); + await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), /unexpected end/); +}); + +for (const kind of ["missing", "empty", "directory", "symlink", "corrupt"] as const) { + test(`AppImage-only fails closed on ${kind} installer`, async () => { + const directory = await fixture(`scoped-installer-${kind}`, true, scopedVersion); + const file = path.join(directory, scopedNames[0]!); + await fs.rm(file); + if (kind === "empty") await fs.writeFile(file, ""); + if (kind === "directory") await fs.mkdir(file); + if (kind === "symlink") await fs.symlink("latest-linux.yml", file); + if (kind === "corrupt") { + await fs.writeFile(file, "download failed"); + await writeChecksums(directory, scopedVersion, scopedOptions); + } else await assert.rejects(writeChecksums(directory, scopedVersion, scopedOptions), /ENOENT|nonempty regular file/); + await assert.rejects(verifyLocal(directory, scopedVersion, scopedOptions), /ENOENT|nonempty regular file|invalid installer header/); + }); +} + +test("CLI retains positional directory for scoped offline commands and rejects remote scope before auth or network", async () => { + const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf8")); + const directory = await fixture("scoped-cli", true, pkg.version); + const run = (...args: string[]) => spawnSync(process.execPath, [path.resolve(import.meta.dirname, "../scripts/gitea-release.mjs"), ...args], { + encoding: "utf8", timeout: 10_000, + }); + for (const command of ["checksums", "verify"]) { + const result = run(command, directory, "--appimage-only"); + assert.equal(result.status, 0, result.stderr); + } + const defaultResult = run("verify", directory); + assert.equal(defaultResult.status, 1); + assert.match(defaultResult.stderr, /amd64\.deb/); + for (const command of ["upload", "verify-remote", "verify-package"]) { + const result = run(command, directory, "--appimage-only"); + assert.equal(result.status, 1); + assert.match(result.stderr, /supported only by offline/); + } + for (const args of [["--unknown"], ["--appimage-only", "--appimage-only"], [directory, "extra"]]) { + assert.equal(run("verify", ...args).status, 1); + } +}); + test("packaged updater verification refuses inherited GitHub or incorrect generic destinations", async () => { const directory = path.join(root, "package"); await fs.mkdir(path.join(directory, "resources"), { recursive: true });