From a69692df6f2a6888dd00703d00c3e8ca5f17de82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Sun, 19 Jul 2026 15:00:56 -0300 Subject: [PATCH 1/2] test(tauri-release): characterize AppImage repair failures --- packages/tauri-release/test/release.test.ts | 143 +++++++++++++++++++- 1 file changed, 141 insertions(+), 2 deletions(-) diff --git a/packages/tauri-release/test/release.test.ts b/packages/tauri-release/test/release.test.ts index 8a7b4c5..0a58c5d 100644 --- a/packages/tauri-release/test/release.test.ts +++ b/packages/tauri-release/test/release.test.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { runCli } from "../src/cli.js"; import { collectAssets, @@ -29,12 +29,34 @@ import { type TauriReleaseConfig, } from "../src/index.js"; +const injectedWriteFailure = vi.hoisted(() => ({ path: null as string | null, remaining: 0 })); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + writeFileSync(path: Parameters[0], ...args: unknown[]) { + if ( + typeof path === "string" && + path === injectedWriteFailure.path && + injectedWriteFailure.remaining > 0 + ) { + injectedWriteFailure.remaining -= 1; + throw new Error(`injected write failure for ${path}`); + } + return Reflect.apply(actual.writeFileSync, actual, [path, ...args]); + }, + }; +}); + const tempRoots: string[] = []; const squashfsToolsAvailable = hasCommand("mksquashfs") && hasCommand("unsquashfs"); const zstdSquashfsAvailable = squashfsToolsAvailable && hasSquashfsCompressor("zstd"); const toolOutputMaxBuffer = 64 * 1024 * 1024; afterEach(() => { + injectedWriteFailure.path = null; + injectedWriteFailure.remaining = 0; for (const root of tempRoots.splice(0)) { rmSync(root, { force: true, recursive: true }); } @@ -843,23 +865,109 @@ describe("@pickforge/tauri-release", () => { ); it.skipIf(!squashfsToolsAvailable)( - "removes stale signatures before invoking the signer", + "currently replaces the AppImage and removes its stale signature when signing fails", () => { const root = tempRoot(); const { appimage } = createSyntheticAppImage(root); const signStub = join(root, "failing-sign-stub.sh"); + const latestJson = join(root, "latest.json"); writeFileSync(signStub, "#!/bin/sh\nexit 7\n"); writeFileSync(`${appimage}.sig`, "stale-signature"); + writeLatestJsonFixture(latestJson, "PickGauge_1.0.0_amd64.AppImage"); chmodSync(signStub, 0o755); + const artifactBefore = readFileSync(appimage); + const feedBefore = readFileSync(latestJson); expect(() => fixAppImage({ appimage, env: { ...envWithoutSigning(), TAURI_SIGNING_PRIVATE_KEY: "test-key" }, + latestJson, signCommand: signStub, }), ).toThrow(/sign command failed with exit code 7/u); + expect(readFileSync(appimage)).not.toEqual(artifactBefore); expect(existsSync(`${appimage}.sig`)).toBe(false); + expect(readFileSync(latestJson)).toEqual(feedBefore); + }, + ); + + it.skipIf(!squashfsToolsAvailable)( + "currently leaves the release set unchanged when rebuilding fails", + () => { + const root = tempRoot(); + const { appimage } = createSyntheticAppImage(root); + const latestJson = join(root, "latest.json"); + writeFileSync(`${appimage}.sig`, "stale-signature"); + writeLatestJsonFixture(latestJson, "PickGauge_1.0.0_amd64.AppImage"); + const artifactBefore = readFileSync(appimage); + const signatureBefore = readFileSync(`${appimage}.sig`); + const feedBefore = readFileSync(latestJson); + + expect(() => + fixAppImage({ + appimage, + env: failingMksquashfsEnv(root), + latestJson, + }), + ).toThrow(/mksquashfs failed with exit code 19/u); + expect(readFileSync(appimage)).toEqual(artifactBefore); + expect(readFileSync(`${appimage}.sig`)).toEqual(signatureBefore); + expect(readFileSync(latestJson)).toEqual(feedBefore); + }, + ); + + it.skipIf(!squashfsToolsAvailable)( + "currently leaves earlier release entries changed when the feed write fails", + () => { + const root = tempRoot(); + const { appimage } = createSyntheticAppImage(root); + const signStub = join(root, "sign-stub.sh"); + const latestJson = join(root, "latest.json"); + writeFileSync(signStub, "#!/bin/sh\nprintf 'new-signature\\n' > \"$1.sig\"\n"); + writeFileSync(`${appimage}.sig`, "stale-signature"); + writeLatestJsonFixture(latestJson, "PickGauge_1.0.0_amd64.AppImage"); + chmodSync(signStub, 0o755); + const artifactBefore = readFileSync(appimage); + const feedBefore = readFileSync(latestJson); + failNextWrite(latestJson); + + expect(() => + fixAppImage({ + appimage, + env: { ...envWithoutSigning(), TAURI_SIGNING_PRIVATE_KEY: "test-key" }, + latestJson, + signCommand: signStub, + }), + ).toThrow(/injected write failure/u); + expect(readFileSync(appimage)).not.toEqual(artifactBefore); + expect(readFileSync(`${appimage}.sig`, "utf8")).toBe("new-signature\n"); + expect(readFileSync(latestJson)).toEqual(feedBefore); + }, + ); + + it.skipIf(!squashfsToolsAvailable)( + "currently stops before changing the signature or feed when the artifact write fails", + () => { + const root = tempRoot(); + const { appimage } = createSyntheticAppImage(root); + const latestJson = join(root, "latest.json"); + writeFileSync(`${appimage}.sig`, "stale-signature"); + writeLatestJsonFixture(latestJson, "PickGauge_1.0.0_amd64.AppImage"); + const artifactBefore = readFileSync(appimage); + const signatureBefore = readFileSync(`${appimage}.sig`); + const feedBefore = readFileSync(latestJson); + failNextWrite(appimage); + + expect(() => + fixAppImage({ + appimage, + env: envWithoutSigning(), + }), + ).toThrow(/injected write failure/u); + expect(readFileSync(appimage)).toEqual(artifactBefore); + expect(readFileSync(`${appimage}.sig`)).toEqual(signatureBefore); + expect(readFileSync(latestJson)).toEqual(feedBefore); }, ); @@ -1135,6 +1243,37 @@ async function captureCli(argv: string[]): Promise<{ code: number; stdout: strin } } +function failNextWrite(path: string): void { + injectedWriteFailure.path = path; + injectedWriteFailure.remaining = 1; +} + +function failingMksquashfsEnv(root: string): NodeJS.ProcessEnv { + const bin = join(root, "failing-tools"); + const mksquashfs = commandPath("mksquashfs"); + const unsquashfs = commandPath("unsquashfs"); + if (mksquashfs === null || unsquashfs === null) { + throw new Error("SquashFS tools are required for this fixture"); + } + mkdirSync(bin); + writeFileSync( + join(bin, "mksquashfs"), + `#!/bin/sh\nif [ "\${1:-}" = "-version" ]; then exec ${JSON.stringify(mksquashfs)} "$@"; fi\nexit 19\n`, + ); + chmodSync(join(bin, "mksquashfs"), 0o755); + symlinkSync(unsquashfs, join(bin, "unsquashfs")); + return { + ...envWithoutSigning(), + PATH: bin, + TAURI_SIGNING_PRIVATE_KEY: "test-key", + }; +} + +function commandPath(command: string): string | null { + const result = spawnSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" }); + return result.status === 0 ? result.stdout.trim() : null; +} + function envWithoutSigning(): NodeJS.ProcessEnv { return { ...process.env, From ad1bcd551c809b711c6f3b12d0ccf284a2b6dcc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Sun, 19 Jul 2026 15:08:07 -0300 Subject: [PATCH 2/2] fix(tauri-release): stage AppImage repair release set --- packages/tauri-release/src/index.ts | 258 +++++++++++++++-- packages/tauri-release/test/release.test.ts | 297 ++++++++++++++++++-- 2 files changed, 502 insertions(+), 53 deletions(-) diff --git a/packages/tauri-release/src/index.ts b/packages/tauri-release/src/index.ts index 64c6c26..f2545a7 100644 --- a/packages/tauri-release/src/index.ts +++ b/packages/tauri-release/src/index.ts @@ -8,9 +8,11 @@ import { mkdirSync, readdirSync, readFileSync, + readlinkSync, realpathSync, rmSync, statSync, + symlinkSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -161,6 +163,21 @@ interface LatestJsonPatchTarget { platforms: Array<{ platform: string; value: JsonRecord }>; } +interface ReleaseFileContents { + path: string; + data: Buffer; + mode: number; +} + +type ExistingReleaseFileSnapshot = + | (ReleaseFileContents & { kind: "file" }) + | (ReleaseFileContents & { kind: "symlink"; linkTarget: string }); + +type ReleaseFileSnapshot = + | ExistingReleaseFileSnapshot + | { path: string; kind: "dangling-symlink"; linkTarget: string } + | { path: string; kind: "missing" }; + export class ReleaseToolError extends Error { constructor(message: string) { super(message); @@ -392,11 +409,15 @@ export function fixAppImage(options: FixAppImageOptions): FixAppImageResult { const appimage = resolve(options.appimage); const signaturePath = `${appimage}.sig`; - if (options.latestJson !== undefined) { - readLatestJsonPatchTarget(options.latestJson, appimage); - } - const originalMode = statSync(appimage).mode & 0o7777; - const original = readFileSync(appimage); + const appimageSnapshot = snapshotReleaseFile(appimage, true); + const signatureSnapshot = snapshotReleaseFile(signaturePath, false); + const latestJsonSnapshot = + options.latestJson === undefined ? undefined : snapshotReleaseFile(options.latestJson, true); + const latestJsonTarget = + options.latestJson === undefined + ? undefined + : readLatestJsonPatchTarget(options.latestJson, appimage); + const original = appimageSnapshot.data; const offset = findSquashfsOffset(original); const runtimePrefix = original.subarray(0, offset); const compression = squashfsCompression(original, offset); @@ -405,6 +426,10 @@ export function fixAppImage(options: FixAppImageOptions): FixAppImageResult { try { const appDir = join(tempDir, "AppDir"); const squashfs = join(tempDir, "fixed.squashfs"); + const stagedAppimage = join(tempDir, basename(appimage)); + const stagedSignature = `${stagedAppimage}.sig`; + const stagedLatestJson = + options.latestJson === undefined ? undefined : join(tempDir, basename(options.latestJson)); runTool("unsquashfs", ["-dest", appDir, "-offset", String(offset), appimage], env); const strippedCount = stripWaylandLibraries(appDir); @@ -413,24 +438,47 @@ export function fixAppImage(options: FixAppImageOptions): FixAppImageResult { [appDir, squashfs, "-comp", compression, "-root-owned", "-noappend"], env, ); - writeFileSync(appimage, Buffer.concat([runtimePrefix, readFileSync(squashfs)])); - chmodSync(appimage, originalMode); - verifyNoWaylandLibraries(appimage, runtimePrefix.length, env); + writeFileSync(stagedAppimage, Buffer.concat([runtimePrefix, readFileSync(squashfs)])); + chmodSync(stagedAppimage, appimageSnapshot.mode); + let signature: string | undefined; if (signed) { - if (existsSync(signaturePath)) { - unlinkSync(signaturePath); - } - runSignCommand(appimage, options.signCommand, env); - readSignatureFile(signaturePath); - } else if (existsSync(signaturePath)) { - unlinkSync(signaturePath); + runSignCommand(stagedAppimage, options.signCommand, env); + signature = readSignatureFile(stagedSignature); } const platformsPatched = - options.latestJson === undefined + latestJsonTarget === undefined || stagedLatestJson === undefined || signature === undefined ? [] - : patchLatestJsonSignatures(options.latestJson, appimage, signaturePath, signed); + : stageLatestJsonSignatures(latestJsonTarget, signature, stagedLatestJson); + verifyStagedReleaseSet({ + appimage: stagedAppimage, + appimageMode: appimageSnapshot.mode, + appimageOffset: runtimePrefix.length, + env, + latestJson: stagedLatestJson, + liveAppimage: appimage, + signature, + signaturePath: signed ? stagedSignature : undefined, + }); + + commitReleaseSet({ + appimage, + appimageMode: appimageSnapshot.mode, + appimageData: readFileSync(stagedAppimage), + latestJson: options.latestJson, + latestJsonData: + stagedLatestJson === undefined ? undefined : readFileSync(stagedLatestJson), + latestJsonMode: latestJsonSnapshot?.mode, + signatureData: signed ? readFileSync(stagedSignature) : undefined, + signatureMode: signed ? statSync(stagedSignature).mode & 0o7777 : undefined, + signaturePath, + snapshots: { + appimage: appimageSnapshot, + latestJson: latestJsonSnapshot, + signature: signatureSnapshot, + }, + }); return { appimage, @@ -851,28 +899,180 @@ function runSignCommand( runShellCommand(`${signCommand} ${shellQuote(appimage)}`, env); } -function patchLatestJsonSignatures( - latestJson: string, - appimage: string, - signaturePath: string, - signed: boolean, +function stageLatestJsonSignatures( + target: LatestJsonPatchTarget, + signature: string, + stagedLatestJson: string, ): string[] { - if (!signed) { - throw new ReleaseToolError("--latest-json requires TAURI_SIGNING_PRIVATE_KEY"); - } - - const signature = readSignatureFile(signaturePath); - const target = readLatestJsonPatchTarget(latestJson, appimage); for (const { value } of target.platforms) { value.signature = signature; } - writeFileSync(latestJson, `${JSON.stringify(target.parsed, null, 2)}\n`); + writeFileSync(stagedLatestJson, `${JSON.stringify(target.parsed, null, 2)}\n`); return target.platforms .map(({ platform }) => platform) .sort((left, right) => left.localeCompare(right)); } +function verifyStagedReleaseSet(options: { + appimage: string; + appimageMode: number; + appimageOffset: number; + env: NodeJS.ProcessEnv; + latestJson?: string; + liveAppimage: string; + signature?: string; + signaturePath?: string; +}): void { + verifyNoWaylandLibraries(options.appimage, options.appimageOffset, options.env); + if ((statSync(options.appimage).mode & 0o7777) !== options.appimageMode) { + throw new ReleaseToolError("staged AppImage did not preserve its executable mode"); + } + + if (options.signaturePath !== undefined) { + const stagedSignature = readSignatureFile(options.signaturePath); + if (stagedSignature !== options.signature) { + throw new ReleaseToolError("staged AppImage signature changed during verification"); + } + } + + if (options.latestJson !== undefined) { + if (options.signature === undefined) { + throw new ReleaseToolError("staged latest.json is missing its AppImage signature"); + } + const target = readLatestJsonPatchTarget(options.latestJson, options.liveAppimage); + if (target.platforms.some(({ value }) => value.signature !== options.signature)) { + throw new ReleaseToolError("staged latest.json does not contain the AppImage signature"); + } + } +} + +function commitReleaseSet(options: { + appimage: string; + appimageData: Buffer; + appimageMode: number; + signaturePath: string; + signatureData?: Buffer; + signatureMode?: number; + latestJson?: string; + latestJsonData?: Buffer; + latestJsonMode?: number; + snapshots: { + appimage: ExistingReleaseFileSnapshot; + signature: ReleaseFileSnapshot; + latestJson?: ExistingReleaseFileSnapshot; + }; +}): void { + const rollbackJournal: ReleaseFileSnapshot[] = []; + try { + rollbackJournal.push(options.snapshots.appimage); + writeReleaseFile(options.appimage, options.appimageData, options.appimageMode); + + rollbackJournal.push(options.snapshots.signature); + rmSync(options.signaturePath, { force: true }); + if (options.signatureData !== undefined) { + writeReleaseFile( + options.signaturePath, + options.signatureData, + options.signatureMode ?? 0o644, + ); + } + + if ( + options.latestJson !== undefined && + options.latestJsonData !== undefined && + options.snapshots.latestJson !== undefined + ) { + rollbackJournal.push(options.snapshots.latestJson); + writeReleaseFile( + options.latestJson, + options.latestJsonData, + options.latestJsonMode ?? 0o644, + ); + } + } catch (error) { + const rollbackErrors: string[] = []; + for (const snapshot of rollbackJournal.reverse()) { + try { + restoreReleaseFile(snapshot); + } catch (rollbackError) { + rollbackErrors.push( + `${basename(snapshot.path)}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + } + if (rollbackErrors.length > 0) { + const commitError = error instanceof Error ? error.message : String(error); + throw new ReleaseToolError( + `AppImage release commit failed (${commitError}) and rollback was incomplete: ${rollbackErrors.join("; ")}`, + ); + } + throw error; + } +} + +function writeReleaseFile(path: string, data: Buffer, mode: number): void { + writeFileSync(path, data); + chmodSync(path, mode); +} + +function snapshotReleaseFile(path: string, required: true): ExistingReleaseFileSnapshot; +function snapshotReleaseFile(path: string, required: false): ReleaseFileSnapshot; +function snapshotReleaseFile(path: string, required: boolean): ReleaseFileSnapshot { + let entry; + try { + entry = lstatSync(path); + } catch (error) { + if (isFileNotFoundError(error) && !required) { + return { path, kind: "missing" }; + } + throw error; + } + + const kind = entry.isSymbolicLink() ? "symlink" : entry.isFile() ? "file" : null; + if (kind === "symlink" && !existsSync(path)) { + if (!required) { + return { path, kind: "dangling-symlink", linkTarget: readlinkSync(path) }; + } + throw new ReleaseToolError(`${basename(path)} must point to a file`); + } + const target = statSync(path); + if (kind === null || !target.isFile()) { + throw new ReleaseToolError(`${basename(path)} must be a file`); + } + const contents = { + path, + data: readFileSync(path), + mode: target.mode & 0o7777, + }; + return kind === "symlink" + ? { ...contents, kind, linkTarget: readlinkSync(path) } + : { ...contents, kind }; +} + +function restoreReleaseFile(snapshot: ReleaseFileSnapshot): void { + rmSync(snapshot.path, { force: true }); + if (snapshot.kind === "missing") { + return; + } + if (snapshot.kind === "dangling-symlink") { + symlinkSync(snapshot.linkTarget, snapshot.path); + return; + } + if (snapshot.kind === "symlink") { + symlinkSync(snapshot.linkTarget, snapshot.path); + } + writeReleaseFile(snapshot.path, snapshot.data, snapshot.mode); +} + +function isFileNotFoundError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + function readSignatureFile(signaturePath: string): string { if (!existsSync(signaturePath)) { throw new ReleaseToolError(`missing signature file ${basename(signaturePath)}`); diff --git a/packages/tauri-release/test/release.test.ts b/packages/tauri-release/test/release.test.ts index 0a58c5d..b613a28 100644 --- a/packages/tauri-release/test/release.test.ts +++ b/packages/tauri-release/test/release.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, existsSync, + lstatSync, mkdtempSync, mkdirSync, readFileSync, @@ -11,7 +12,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runCli } from "../src/cli.js"; import { @@ -29,21 +30,38 @@ import { type TauriReleaseConfig, } from "../src/index.js"; -const injectedWriteFailure = vi.hoisted(() => ({ path: null as string | null, remaining: 0 })); +const injectedFsFailure = vi.hoisted(() => ({ + operation: null as "chmodSync" | "rmSync" | "writeFileSync" | null, + path: null as string | null, + remaining: 0, +})); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); + function fail(operation: "chmodSync" | "rmSync" | "writeFileSync", path: unknown): void { + if ( + operation === injectedFsFailure.operation && + typeof path === "string" && + path === injectedFsFailure.path && + injectedFsFailure.remaining > 0 + ) { + injectedFsFailure.remaining -= 1; + const label = operation === "writeFileSync" ? "write" : operation; + throw new Error(`injected ${label} failure for ${path}`); + } + } return { ...actual, + chmodSync(path: Parameters[0], ...args: unknown[]) { + fail("chmodSync", path); + return Reflect.apply(actual.chmodSync, actual, [path, ...args]); + }, + rmSync(path: Parameters[0], ...args: unknown[]) { + fail("rmSync", path); + return Reflect.apply(actual.rmSync, actual, [path, ...args]); + }, writeFileSync(path: Parameters[0], ...args: unknown[]) { - if ( - typeof path === "string" && - path === injectedWriteFailure.path && - injectedWriteFailure.remaining > 0 - ) { - injectedWriteFailure.remaining -= 1; - throw new Error(`injected write failure for ${path}`); - } + fail("writeFileSync", path); return Reflect.apply(actual.writeFileSync, actual, [path, ...args]); }, }; @@ -55,8 +73,9 @@ const zstdSquashfsAvailable = squashfsToolsAvailable && hasSquashfsCompressor("z const toolOutputMaxBuffer = 64 * 1024 * 1024; afterEach(() => { - injectedWriteFailure.path = null; - injectedWriteFailure.remaining = 0; + injectedFsFailure.operation = null; + injectedFsFailure.path = null; + injectedFsFailure.remaining = 0; for (const root of tempRoots.splice(0)) { rmSync(root, { force: true, recursive: true }); } @@ -742,6 +761,26 @@ describe("@pickforge/tauri-release", () => { }, ); + it.skipIf(!squashfsToolsAvailable)( + "repairs a symlinked AppImage without replacing the symlink", + () => { + const root = tempRoot(); + const { appimage: target, runtimePrefix } = createSyntheticAppImage(root, { mode: 0o744 }); + const appimage = join(root, "PickGauge-nightly_1.0.0_amd64.AppImage"); + symlinkSync(target, appimage); + + const result = fixAppImage({ + appimage, + env: envWithoutSigning(), + }); + + expect(result.appimage).toBe(appimage); + expect(lstatSync(appimage).isSymbolicLink()).toBe(true); + expect(statSync(appimage).mode & 0o777).toBe(0o744); + expect(listAppImage(appimage, runtimePrefix.length)).not.toContain("libwayland-client.so.0"); + }, + ); + it.skipIf(!squashfsToolsAvailable)( "uses the ELF-derived SquashFS offset before scanning for magic bytes", () => { @@ -841,6 +880,61 @@ describe("@pickforge/tauri-release", () => { }, ); + it.skipIf(!squashfsToolsAvailable)( + "patches a nightly latest.json without changing its feed shape", + () => { + const root = tempRoot(); + const { appimage } = createSyntheticAppImage(root); + const signStub = join(root, "sign-stub.sh"); + const latestJson = join(root, "nightly-latest.json"); + writeFileSync(signStub, "#!/bin/sh\nprintf 'nightly-signature\\n' > \"$1.sig\"\n"); + chmodSync(signStub, 0o755); + writeFileSync( + latestJson, + `${JSON.stringify( + { + notes: "Nightly channel", + platforms: { + "linux-x86_64": { + signature: "old-signature", + url: `https://github.com/pickforge/pickgauge/releases/download/nightly/${basename(appimage)}`, + }, + }, + pub_date: "2026-07-19T00:00:00.000Z", + version: "1.0.0-nightly.20260719.abcdef123456", + }, + null, + 2, + )}\n`, + ); + + fixAppImage({ + appimage, + env: { ...envWithoutSigning(), TAURI_SIGNING_PRIVATE_KEY: "test-key" }, + latestJson, + signCommand: signStub, + }); + const patched = JSON.parse(readFileSync(latestJson, "utf8")) as { + notes: string; + platforms: Record; + pub_date: string; + version: string; + }; + + expect(patched).toEqual({ + notes: "Nightly channel", + platforms: { + "linux-x86_64": { + signature: "nightly-signature", + url: `https://github.com/pickforge/pickgauge/releases/download/nightly/${basename(appimage)}`, + }, + }, + pub_date: "2026-07-19T00:00:00.000Z", + version: "1.0.0-nightly.20260719.abcdef123456", + }); + }, + ); + it.skipIf(!squashfsToolsAvailable)( "signs when TAURI_SIGNING_PRIVATE_KEY_PATH is set", () => { @@ -865,7 +959,7 @@ describe("@pickforge/tauri-release", () => { ); it.skipIf(!squashfsToolsAvailable)( - "currently replaces the AppImage and removes its stale signature when signing fails", + "leaves the complete release set unchanged when signing fails", () => { const root = tempRoot(); const { appimage } = createSyntheticAppImage(root); @@ -886,14 +980,14 @@ describe("@pickforge/tauri-release", () => { signCommand: signStub, }), ).toThrow(/sign command failed with exit code 7/u); - expect(readFileSync(appimage)).not.toEqual(artifactBefore); - expect(existsSync(`${appimage}.sig`)).toBe(false); + expect(readFileSync(appimage)).toEqual(artifactBefore); + expect(readFileSync(`${appimage}.sig`, "utf8")).toBe("stale-signature"); expect(readFileSync(latestJson)).toEqual(feedBefore); }, ); it.skipIf(!squashfsToolsAvailable)( - "currently leaves the release set unchanged when rebuilding fails", + "leaves the release set unchanged when rebuilding fails", () => { const root = tempRoot(); const { appimage } = createSyntheticAppImage(root); @@ -918,14 +1012,13 @@ describe("@pickforge/tauri-release", () => { ); it.skipIf(!squashfsToolsAvailable)( - "currently leaves earlier release entries changed when the feed write fails", + "rolls back the artifact and signature when the feed commit fails", () => { const root = tempRoot(); const { appimage } = createSyntheticAppImage(root); const signStub = join(root, "sign-stub.sh"); const latestJson = join(root, "latest.json"); writeFileSync(signStub, "#!/bin/sh\nprintf 'new-signature\\n' > \"$1.sig\"\n"); - writeFileSync(`${appimage}.sig`, "stale-signature"); writeLatestJsonFixture(latestJson, "PickGauge_1.0.0_amd64.AppImage"); chmodSync(signStub, 0o755); const artifactBefore = readFileSync(appimage); @@ -940,20 +1033,23 @@ describe("@pickforge/tauri-release", () => { signCommand: signStub, }), ).toThrow(/injected write failure/u); - expect(readFileSync(appimage)).not.toEqual(artifactBefore); - expect(readFileSync(`${appimage}.sig`, "utf8")).toBe("new-signature\n"); + expect(readFileSync(appimage)).toEqual(artifactBefore); + expect(existsSync(`${appimage}.sig`)).toBe(false); expect(readFileSync(latestJson)).toEqual(feedBefore); }, ); it.skipIf(!squashfsToolsAvailable)( - "currently stops before changing the signature or feed when the artifact write fails", + "rolls back the complete release set when the artifact commit fails", () => { const root = tempRoot(); const { appimage } = createSyntheticAppImage(root); + const signStub = join(root, "sign-stub.sh"); const latestJson = join(root, "latest.json"); + writeFileSync(signStub, "#!/bin/sh\nprintf 'new-signature\\n' > \"$1.sig\"\n"); writeFileSync(`${appimage}.sig`, "stale-signature"); writeLatestJsonFixture(latestJson, "PickGauge_1.0.0_amd64.AppImage"); + chmodSync(signStub, 0o755); const artifactBefore = readFileSync(appimage); const signatureBefore = readFileSync(`${appimage}.sig`); const feedBefore = readFileSync(latestJson); @@ -962,7 +1058,9 @@ describe("@pickforge/tauri-release", () => { expect(() => fixAppImage({ appimage, - env: envWithoutSigning(), + env: { ...envWithoutSigning(), TAURI_SIGNING_PRIVATE_KEY: "test-key" }, + latestJson, + signCommand: signStub, }), ).toThrow(/injected write failure/u); expect(readFileSync(appimage)).toEqual(artifactBefore); @@ -971,6 +1069,149 @@ describe("@pickforge/tauri-release", () => { }, ); + it.skipIf(!squashfsToolsAvailable)( + "rolls back the complete release set when the signature commit fails", + () => { + const root = tempRoot(); + const { appimage } = createSyntheticAppImage(root); + const signaturePath = `${appimage}.sig`; + const signStub = join(root, "sign-stub.sh"); + const latestJson = join(root, "latest.json"); + writeFileSync(signStub, "#!/bin/sh\nprintf 'new-signature\\n' > \"$1.sig\"\n"); + writeFileSync(signaturePath, "stale-signature"); + writeLatestJsonFixture(latestJson, "PickGauge_1.0.0_amd64.AppImage"); + chmodSync(signStub, 0o755); + const artifactBefore = readFileSync(appimage); + const signatureBefore = readFileSync(signaturePath); + const feedBefore = readFileSync(latestJson); + failNextWrite(signaturePath); + + expect(() => + fixAppImage({ + appimage, + env: { ...envWithoutSigning(), TAURI_SIGNING_PRIVATE_KEY: "test-key" }, + latestJson, + signCommand: signStub, + }), + ).toThrow(/injected write failure/u); + expect(readFileSync(appimage)).toEqual(artifactBefore); + expect(readFileSync(signaturePath)).toEqual(signatureBefore); + expect(readFileSync(latestJson)).toEqual(feedBefore); + }, + ); + + it.skipIf(!squashfsToolsAvailable)( + "rolls back artifact bytes and mode when its commit chmod fails", + () => { + const root = tempRoot(); + const { appimage } = createSyntheticAppImage(root, { mode: 0o741 }); + const signStub = join(root, "sign-stub.sh"); + const latestJson = join(root, "latest.json"); + writeFileSync(signStub, "#!/bin/sh\nprintf 'new-signature\\n' > \"$1.sig\"\n"); + writeFileSync(`${appimage}.sig`, "stale-signature"); + writeLatestJsonFixture(latestJson, basename(appimage)); + chmodSync(signStub, 0o755); + const artifactBefore = readFileSync(appimage); + const feedBefore = readFileSync(latestJson); + failNextFsOperation("chmodSync", appimage); + + expect(() => + fixAppImage({ + appimage, + env: { ...envWithoutSigning(), TAURI_SIGNING_PRIVATE_KEY: "test-key" }, + latestJson, + signCommand: signStub, + }), + ).toThrow(/injected chmodSync failure/u); + expect(readFileSync(appimage)).toEqual(artifactBefore); + expect(statSync(appimage).mode & 0o777).toBe(0o741); + expect(readFileSync(`${appimage}.sig`, "utf8")).toBe("stale-signature"); + expect(readFileSync(latestJson)).toEqual(feedBefore); + }, + ); + + it.skipIf(!squashfsToolsAvailable)( + "rolls back the artifact when unsigned stale-signature removal fails", + () => { + const root = tempRoot(); + const { appimage } = createSyntheticAppImage(root, { mode: 0o745 }); + const signaturePath = `${appimage}.sig`; + writeFileSync(signaturePath, "stale-signature"); + chmodSync(signaturePath, 0o640); + const artifactBefore = readFileSync(appimage); + failNextFsOperation("rmSync", signaturePath); + + expect(() => fixAppImage({ appimage, env: envWithoutSigning() })).toThrow( + /injected rmSync failure/u, + ); + expect(readFileSync(appimage)).toEqual(artifactBefore); + expect(statSync(appimage).mode & 0o777).toBe(0o745); + expect(readFileSync(signaturePath, "utf8")).toBe("stale-signature"); + expect(statSync(signaturePath).mode & 0o777).toBe(0o640); + }, + ); + + it.skipIf(!squashfsToolsAvailable)( + "restores symlinked artifact, signature, and feed entries after a later commit failure", + () => { + const root = tempRoot(); + const targetRoot = join(root, "targets"); + mkdirSync(targetRoot); + const { appimage: appimageTarget } = createSyntheticAppImage(targetRoot, { mode: 0o741 }); + const appimage = join(root, basename(appimageTarget)); + const signaturePath = `${appimage}.sig`; + const signatureTarget = join(targetRoot, "previous.sig"); + const latestJson = join(root, "latest.json"); + const latestJsonTarget = join(targetRoot, "previous-latest.json"); + const signStub = join(root, "sign-stub.sh"); + symlinkSync(appimageTarget, appimage); + writeFileSync(signatureTarget, "stale-signature"); + chmodSync(signatureTarget, 0o640); + symlinkSync(signatureTarget, signaturePath); + writeLatestJsonFixture(latestJsonTarget, basename(appimage)); + chmodSync(latestJsonTarget, 0o600); + symlinkSync(latestJsonTarget, latestJson); + writeFileSync(signStub, "#!/bin/sh\nprintf 'new-signature\\n' > \"$1.sig\"\n"); + chmodSync(signStub, 0o755); + const artifactBefore = readFileSync(appimage); + const signatureBefore = readFileSync(signaturePath); + const feedBefore = readFileSync(latestJson); + failNextWrite(latestJson); + + expect(() => + fixAppImage({ + appimage, + env: { ...envWithoutSigning(), TAURI_SIGNING_PRIVATE_KEY: "test-key" }, + latestJson, + signCommand: signStub, + }), + ).toThrow(/injected write failure/u); + expect(lstatSync(appimage).isSymbolicLink()).toBe(true); + expect(lstatSync(signaturePath).isSymbolicLink()).toBe(true); + expect(lstatSync(latestJson).isSymbolicLink()).toBe(true); + expect(readFileSync(appimage)).toEqual(artifactBefore); + expect(readFileSync(signaturePath)).toEqual(signatureBefore); + expect(readFileSync(latestJson)).toEqual(feedBefore); + expect(statSync(appimage).mode & 0o777).toBe(0o741); + expect(statSync(signaturePath).mode & 0o777).toBe(0o640); + expect(statSync(latestJson).mode & 0o777).toBe(0o600); + }, + ); + + it.skipIf(!squashfsToolsAvailable)( + "removes a dangling stale signature symlink only after unsigned commit", + () => { + const root = tempRoot(); + const { appimage } = createSyntheticAppImage(root); + const signaturePath = `${appimage}.sig`; + symlinkSync(join(root, "missing-signature"), signaturePath); + + fixAppImage({ appimage, env: envWithoutSigning() }); + + expect(() => lstatSync(signaturePath)).toThrow(); + }, + ); + it.skipIf(!squashfsToolsAvailable)("rejects empty signatures after signing", () => { const root = tempRoot(); const { appimage } = createSyntheticAppImage(root); @@ -1244,8 +1485,16 @@ async function captureCli(argv: string[]): Promise<{ code: number; stdout: strin } function failNextWrite(path: string): void { - injectedWriteFailure.path = path; - injectedWriteFailure.remaining = 1; + failNextFsOperation("writeFileSync", path); +} + +function failNextFsOperation( + operation: "chmodSync" | "rmSync" | "writeFileSync", + path: string, +): void { + injectedFsFailure.operation = operation; + injectedFsFailure.path = path; + injectedFsFailure.remaining = 1; } function failingMksquashfsEnv(root: string): NodeJS.ProcessEnv {