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"; import { after, test } from "node:test"; import { createRequire } from "node:module"; import { expectedLinuxAssets, writeChecksums, verifyLocal, verifyRemote, verifyPackagedUpdater, UPDATE_URL } from "../scripts/gitea-release.mjs"; 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, appimageOnly = false, fixtureVersion = version) { const directory = path.join(root, name); await fs.mkdir(directory); 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; } test("Linux release inventory and Gitea feed match the current release scope", () => { assert.deepEqual(names, ["LearnHarness-0.4.0-x86_64.AppImage", "LearnHarness-0.4.0-amd64.deb", "latest-linux.yml", "SHA256SUMS"]); assert.equal(UPDATE_URL, "https://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest"); assert.throws(() => expectedLinuxAssets("0.4.0-preview")); }); test("local verification checks actual installer bytes, updater hashes, sizes and checksum manifest", async () => { const directory = await fixture("healthy"); const inventory = await verifyLocal(directory, version); assert.equal(inventory.length, 4); await fs.appendFile(path.join(directory, names[0]!), "tampered"); await assert.rejects(verifyLocal(directory, version), /update size differs/); }); test("updater cannot point at traversal paths, other versions, or substituted installer bytes", async () => { for (const [name, change, expected] of [ ["traversal", (feed: any) => { feed.files[0].url = "../outside.AppImage"; }, /reference exactly/], ["version", (feed: any) => { feed.version = "0.3.7"; }, /metadata version/], ["hash", (feed: any) => { feed.files[0].sha512 = "bad"; }, /update checksum/], ["duplicate", (feed: any) => { feed.files.push({ ...feed.files[1] }); }, /reference exactly/], ] as const) { const directory = await fixture(name); const file = path.join(directory, "latest-linux.yml"); const feed = JSON.parse(await fs.readFile(file, "utf8")); change(feed); await fs.writeFile(file, JSON.stringify(feed)); await writeChecksums(directory, version); await assert.rejects(verifyLocal(directory, version), expected); } }); test("empty files and arbitrary downloads cannot pass as installers", async () => { const directory = await fixture("format"); await fs.writeFile(path.join(directory, names[0]!), "HTTP error"); await writeChecksums(directory, version); await assert.rejects(verifyLocal(directory, version), /invalid installer header/); await fs.writeFile(path.join(directory, names[0]!), ""); await assert.rejects(verifyLocal(directory, version), /nonempty regular file/); }); test("remote verification downloads and hashes every draft attachment, and refuses published or incomplete releases", async () => { const directory = await fixture("remote"); const inventory = await verifyLocal(directory, version); const assets = inventory.map((asset) => ({ ...asset, browser_download_url: `https://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/v0.4.0/${asset.name}` })); const release = { draft: true, prerelease: false, assets }; const request = async (input: URL | RequestInfo) => { const url = input instanceof URL ? input : new URL(String(input)); return new Response(await fs.readFile(path.join(directory, path.basename(url.pathname)))); }; await verifyRemote(release, inventory, {}, request as typeof fetch); await assert.rejects(verifyRemote({ ...release, draft: false }, inventory), /only a draft/); await assert.rejects(verifyRemote({ ...release, assets: assets.slice(1) }, inventory), /missing, duplicate/); await assert.rejects(verifyRemote(release, inventory, {}, (async () => new Response("wrong bytes")) as typeof fetch), /remote bytes differ/); await assert.rejects(verifyRemote({ ...release, assets: [{ ...assets[0], browser_download_url: "https://other.example/file" }, ...assets.slice(1)] }, inventory), /outside Gitea/); }); test("release configuration preserves packaging while replacing the upstream update origin", async () => { const require = createRequire(import.meta.url); const yaml = require("js-yaml"); const { getConfig } = require("app-builder-lib/out/util/config/config.js"); const config = await getConfig(path.resolve(import.meta.dirname, ".."), "electron-builder.gitea.yml", null); assert.deepEqual(config.publish, { provider: "generic", url: UPDATE_URL }); assert.equal(config.appId, "com.learnharness.LearnHarness"); assert.deepEqual(config.linux.target.map((target: any) => target.target), ["AppImage", "deb"]); const workflow = yaml.load(await fs.readFile(new URL("../.gitea/workflows/release.yml", import.meta.url), "utf8")); assert(workflow.on.workflow_dispatch); 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 }); const file = path.join(directory, "resources", "app-update.yml"); await fs.writeFile(file, JSON.stringify({ provider: "github", owner: "amosblomqvist", repo: "learnharness" })); await assert.rejects(verifyPackagedUpdater(directory), /Gitea generic provider/); await fs.writeFile(file, JSON.stringify({ provider: "generic", url: "https://other.example" })); await assert.rejects(verifyPackagedUpdater(directory), /public Gitea release feed/); await fs.writeFile(file, JSON.stringify({ provider: "generic", url: UPDATE_URL })); await verifyPackagedUpdater(directory); });