diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 6a66ae095..409a019c0 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -9,11 +9,13 @@ "version": "0.0.1", "hasInstallScript": true, "dependencies": { - "node-pty": "^1.2.0-beta.11" + "node-pty": "^1.2.0-beta.11", + "semver": "^7.6.3" }, "devDependencies": { "@electron/notarize": "^3.0.0", "@types/node": "^22.10.5", + "@types/semver": "^7.5.8", "electron": "^41.5.0", "electron-builder": "^26.8.1", "esbuild": "^0.27.0", @@ -165,6 +167,16 @@ "global-agent": "^3.0.0" } }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@electron/notarize": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz", @@ -1492,6 +1504,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -1928,19 +1947,6 @@ "node": ">= 10.0.0" } }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3634,20 +3640,6 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -4618,19 +4610,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", @@ -4649,19 +4628,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-api-version/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp": { "version": "12.3.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", @@ -4697,19 +4663,6 @@ "node": ">=20" } }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -5349,13 +5302,15 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { @@ -5469,19 +5424,6 @@ "node": ">=10" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 479fea2c7..783eb9aa2 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -23,6 +23,7 @@ "devDependencies": { "@electron/notarize": "^3.0.0", "@types/node": "^22.10.5", + "@types/semver": "^7.5.8", "electron": "^41.5.0", "electron-builder": "^26.8.1", "esbuild": "^0.27.0", @@ -31,6 +32,7 @@ "vitest": "^4.1.0" }, "dependencies": { - "node-pty": "^1.2.0-beta.11" + "node-pty": "^1.2.0-beta.11", + "semver": "^7.6.3" } } diff --git a/packages/desktop/src/runtime-store.ts b/packages/desktop/src/runtime-store.ts new file mode 100644 index 000000000..33386cb3e --- /dev/null +++ b/packages/desktop/src/runtime-store.ts @@ -0,0 +1,219 @@ +/** + * On-disk layout for the staged Kanban runtime under `${userData}/runtime-store/`: + * + * current.json — pointer { version, cliEntry } + * bad-versions.json — versions to skip after a startup failure + * versions// — finalized runtime + * versions/.partial/ — in-flight extract; never read at boot + * + * The bundled runtime in `app.asar.unpacked/cli/` is the fallback — + * if the pointer is missing or its spawn fails, we drop the pointer + * and the shim launches bundled. + */ + +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +import semver from "semver"; + +export interface RuntimePointer { + version: string; + /** Absolute path to `versions//dist/cli.js`. */ + cliEntry: string; +} + +const POINTER_FILE = "current.json"; +const BAD_VERSIONS_FILE = "bad-versions.json"; + +const root = (userData: string): string => path.join(userData, "runtime-store"); +const pointerPath = (userData: string): string => + path.join(root(userData), POINTER_FILE); +const badVersionsPath = (userData: string): string => + path.join(root(userData), BAD_VERSIONS_FILE); + +const isSemver = (v: unknown): v is string => + typeof v === "string" && semver.valid(v) !== null; + +export function versionDir(userData: string, version: string): string { + if (!isSemver(version)) { + throw new Error(`runtime-store: invalid semver: ${version}`); + } + return path.join(root(userData), "versions", version); +} + +export function partialDir(userData: string, version: string): string { + return `${versionDir(userData, version)}.partial`; +} + +export function cliEntryFor(userData: string, version: string): string { + return path.join(versionDir(userData, version), "dist", "cli.js"); +} + +/** + * Inverse of `cliEntryFor`. Walks back from a canonical cliEntry to + * its `` segment. Returns `null` unless the full path fits the + * `/runtime-store/versions//dist/cli.js` shape AND + * `` is valid semver — checked by re-deriving via `cliEntryFor` + * and string-comparing. Validating the full shape (not just the + * `` segment) means a stray path like `/tmp/1.2.3/dist/not-cli.js` + * or `/elsewhere/1.2.3/dist/cli.js` doesn't accidentally produce + * `"1.2.3"`, which would let the rollback path mark a real-but- + * unrelated version bad and remove its on-disk dir. + * + * Callers that capture the override path at spawn time use this to + * roll back the version that *actually* ran — without re-reading the + * pointer (which a concurrent background stage may have replaced). + */ +export function versionFromCliEntry( + userData: string, + cliEntry: string, +): string | null { + if (!path.isAbsolute(cliEntry)) return null; + const v = path.basename(path.dirname(path.dirname(cliEntry))); + if (!isSemver(v)) return null; + return cliEntry === cliEntryFor(userData, v) ? v : null; +} + +function atomicWrite(target: string, body: string): void { + mkdirSync(path.dirname(target), { recursive: true }); + const tmp = `${target}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, body); + renameSync(tmp, target); +} + +/** + * Pointer's `cliEntry` must be the canonical path for the pointer's + * version. We pass `cliEntry` to the shim as `KANBAN_CLI_OVERRIDE`, + * so a non-canonical or out-of-tree path would let a tampered + * `current.json` execute arbitrary on-disk JS. Returns the canonical + * absolute path so callers don't have to re-resolve. + */ +export function readPointer(userData: string): RuntimePointer | null { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(pointerPath(userData), "utf8")); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const { version, cliEntry } = parsed as Record; + if (!isSemver(version)) return null; + if (typeof cliEntry !== "string" || cliEntry.length === 0) return null; + // Require absolute paths only — the on-disk contract is "absolute + // canonical path under runtime-store/". Accepting a relative form + // here would make pointer validity depend on `process.cwd()` at the + // moment of read, which is cwd-dependent footgun for no benefit. + if (!path.isAbsolute(cliEntry)) return null; + if (cliEntry !== cliEntryFor(userData, version)) return null; + return { version, cliEntry }; +} + +/** + * Whether a `current.json` exists on disk regardless of whether it + * parses/validates. Used by callers that need to clean up an invalid + * pointer file (e.g. tampered or hand-edited) — `readPointer()` returning + * null doesn't tell them apart from "no pointer at all". + */ +export function pointerFileExists(userData: string): boolean { + try { + return statSync(pointerPath(userData)).isFile(); + } catch { + return false; + } +} + +export function writePointer(userData: string, p: RuntimePointer): void { + if (!isSemver(p.version)) { + throw new Error(`runtime-store: invalid semver: ${p.version}`); + } + // Symmetric with `readPointer`'s absolute-path contract — a relative + // `cliEntry` that happens to resolve to the canonical path from the + // caller's `process.cwd()` would round-trip through writer + reader + // today, but pointer validity must not depend on cwd at *either* + // boundary. Require absolute input here so the on-disk contract + // ("`cliEntry` is the canonical absolute path") is enforced uniformly. + const canonical = cliEntryFor(userData, p.version); + if (!path.isAbsolute(p.cliEntry) || p.cliEntry !== canonical) { + throw new Error( + `runtime-store: cliEntry for ${p.version} must be ${canonical}, got ${p.cliEntry}`, + ); + } + atomicWrite( + pointerPath(userData), + `${JSON.stringify({ version: p.version, cliEntry: canonical })}\n`, + ); +} + +export function clearPointer(userData: string): void { + rmSync(pointerPath(userData), { force: true }); +} + +/** Pointer's cliEntry iff the file exists on disk. */ +export function resolvePointerCliEntry(userData: string): string | null { + const p = readPointer(userData); + if (!p) return null; + try { + return statSync(p.cliEntry).isFile() ? p.cliEntry : null; + } catch { + return null; + } +} + +/** Sweep `.partial/` left over from interrupted extracts. Best-effort. */ +export function cleanupPartials(userData: string): void { + const versions = path.join(root(userData), "versions"); + if (!existsSync(versions)) return; + for (const e of readdirSync(versions, { withFileTypes: true })) { + if (e.isDirectory() && e.name.endsWith(".partial")) { + rmSync(path.join(versions, e.name), { recursive: true, force: true }); + } + } +} + +export function removeVersionDir(userData: string, version: string): void { + if (!isSemver(version)) return; + rmSync(versionDir(userData, version), { recursive: true, force: true }); +} + +// ----------------------------------------------------------------- +// Bad-version markers — stop the updater from re-staging a version +// that already failed startup. Entries are never pruned; the registry +// only publishes monotonically increasing versions and we only ever +// check `isBadVersion(latest)`, so old entries are dead weight (a few +// bytes) but never re-examined. If the file ever needs trimming, do +// it lazily here against an `effectiveCurrentVersion` argument. +// ----------------------------------------------------------------- + +function readBadVersions(userData: string): string[] { + try { + const parsed = JSON.parse(readFileSync(badVersionsPath(userData), "utf8")); + return Array.isArray(parsed) ? parsed.filter(isSemver) : []; + } catch { + return []; + } +} + +export function isBadVersion(userData: string, version: string): boolean { + return isSemver(version) && readBadVersions(userData).includes(version); +} + +export function markBadVersion(userData: string, version: string): void { + if (!isSemver(version)) { + throw new Error(`runtime-store: invalid semver: ${version}`); + } + const set = new Set(readBadVersions(userData)); + set.add(version); + atomicWrite( + badVersionsPath(userData), + `${JSON.stringify([...set].sort(semver.compare))}\n`, + ); +} diff --git a/packages/desktop/test/runtime-store.test.ts b/packages/desktop/test/runtime-store.test.ts new file mode 100644 index 000000000..6d97f311e --- /dev/null +++ b/packages/desktop/test/runtime-store.test.ts @@ -0,0 +1,283 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + cleanupPartials, + clearPointer, + cliEntryFor, + isBadVersion, + markBadVersion, + partialDir, + readPointer, + removeVersionDir, + resolvePointerCliEntry, + versionDir, + versionFromCliEntry, + writePointer, +} from "../src/runtime-store.js"; + +let userData: string; + +beforeEach(() => { + userData = mkdtempSync(path.join(tmpdir(), "runtime-store-")); +}); + +afterEach(() => { + rmSync(userData, { recursive: true, force: true }); +}); + +const pointerPathFor = (root: string): string => + path.join(root, "runtime-store", "current.json"); + +/** Lay out `versions//dist/cli.js` so a pointer to it is valid. */ +function stageVersion(root: string, version: string): string { + const cliEntry = cliEntryFor(root, version); + mkdirSync(path.dirname(cliEntry), { recursive: true }); + writeFileSync(cliEntry, "// runtime"); + return cliEntry; +} + +describe("runtime-store: pointer", () => { + it("returns null when missing or corrupt", () => { + expect(readPointer(userData)).toBeNull(); + mkdirSync(path.dirname(pointerPathFor(userData)), { recursive: true }); + writeFileSync(pointerPathFor(userData), "{not-json"); + expect(readPointer(userData)).toBeNull(); + }); + + it("round-trips atomically (tmp + rename, no leftover *.tmp)", () => { + const cliEntry = stageVersion(userData, "0.1.66"); + writePointer(userData, { version: "0.1.66", cliEntry }); + + const dir = path.dirname(pointerPathFor(userData)); + expect(readdirSync(dir).some((n) => n.endsWith(".tmp"))).toBe(false); + expect(readPointer(userData)).toEqual({ + version: "0.1.66", + cliEntry, + }); + }); + + it("rejects garbage on read (semver-invalid version, missing cliEntry)", () => { + const writeRaw = (body: unknown): void => { + mkdirSync(path.dirname(pointerPathFor(userData)), { recursive: true }); + writeFileSync(pointerPathFor(userData), JSON.stringify(body)); + }; + + writeRaw({ version: "abc", cliEntry: "/x" }); + expect(readPointer(userData)).toBeNull(); + + writeRaw({ version: "1.0.0" }); + expect(readPointer(userData)).toBeNull(); + + writeRaw({ version: "1.0.0", cliEntry: "" }); + expect(readPointer(userData)).toBeNull(); + }); + + it("writePointer rejects a non-semver version", () => { + expect(() => + writePointer(userData, { version: "abc", cliEntry: "/x" }), + ).toThrow(/invalid semver/); + }); + + it("readPointer rejects a non-canonical cliEntry", () => { + // `cliEntry` is forwarded to the shim as KANBAN_CLI_OVERRIDE. + // A non-canonical path would let a tampered current.json execute + // arbitrary on-disk JS, so the pointer must be rejected. + mkdirSync(path.dirname(pointerPathFor(userData)), { recursive: true }); + writeFileSync( + pointerPathFor(userData), + JSON.stringify({ version: "1.0.0", cliEntry: "/elsewhere/cli.js" }), + ); + expect(readPointer(userData)).toBeNull(); + }); + + it("readPointer rejects a relative cliEntry even if it would resolve to the canonical path", () => { + // Pointer validity must not depend on `process.cwd()` at the + // moment of read — a relative form is always a packaging / + // hand-edit bug, not a legitimate state. + const canonical = cliEntryFor(userData, "1.0.0"); + mkdirSync(path.dirname(pointerPathFor(userData)), { recursive: true }); + const relative = path.relative(process.cwd(), canonical); + writeFileSync( + pointerPathFor(userData), + JSON.stringify({ version: "1.0.0", cliEntry: relative }), + ); + expect(readPointer(userData)).toBeNull(); + }); + + it("writePointer rejects a non-canonical cliEntry", () => { + expect(() => + writePointer(userData, { + version: "1.0.0", + cliEntry: "/elsewhere/cli.js", + }), + ).toThrow(/cliEntry for 1\.0\.0 must be/); + }); + + it("writePointer rejects a relative cliEntry (symmetric with readPointer)", () => { + // Even if the relative form would resolve to the canonical path + // from the current cwd, accepting it would let pointer validity + // depend on `process.cwd()` at write time. The on-disk contract + // is "absolute canonical path" at both boundaries. + const canonical = cliEntryFor(userData, "1.0.0"); + const relative = path.relative(process.cwd(), canonical); + expect(() => + writePointer(userData, { version: "1.0.0", cliEntry: relative }), + ).toThrow(/cliEntry for 1\.0\.0 must be/); + }); + + it("clearPointer is a no-op when missing and removes when present", () => { + expect(() => clearPointer(userData)).not.toThrow(); + const cliEntry = stageVersion(userData, "0.1.0"); + writePointer(userData, { version: "0.1.0", cliEntry }); + clearPointer(userData); + expect(readPointer(userData)).toBeNull(); + }); +}); + +describe("runtime-store: versionFromCliEntry", () => { + it("extracts the version from a canonical cliEntry", () => { + const cli = cliEntryFor(userData, "1.2.3"); + expect(versionFromCliEntry(userData, cli)).toBe("1.2.3"); + }); + + it("returns null for a relative cliEntry", () => { + expect(versionFromCliEntry(userData, "versions/1.2.3/dist/cli.js")).toBeNull(); + }); + + it("returns null when the path's segment isn't valid semver", () => { + const bogus = path.join(userData, "runtime-store", "versions", "abc", "dist", "cli.js"); + expect(versionFromCliEntry(userData, bogus)).toBeNull(); + }); + + it("returns null when the path-shape doesn't match `versions//dist/cli.js`", () => { + // Right `` segment in the right *position* but wrong root or + // wrong leaf must not yield a version — otherwise the rollback + // path could mark a real-but-unrelated version bad and remove + // its on-disk dir, just because some stray path happened to have + // `/dist/cli.js` somewhere in it. + + // Wrong leaf filename. + const wrongLeaf = path.join( + userData, + "runtime-store", + "versions", + "1.2.3", + "dist", + "not-cli.js", + ); + expect(versionFromCliEntry(userData, wrongLeaf)).toBeNull(); + + // Right shape under the wrong root (different userData). + const wrongRoot = path.join( + "/elsewhere", + "runtime-store", + "versions", + "1.2.3", + "dist", + "cli.js", + ); + expect(versionFromCliEntry(userData, wrongRoot)).toBeNull(); + + // `` in the right *segment* position but the parent isn't `dist`. + const wrongParent = path.join( + userData, + "runtime-store", + "versions", + "1.2.3", + "build", + "cli.js", + ); + expect(versionFromCliEntry(userData, wrongParent)).toBeNull(); + }); +}); + +describe("runtime-store: resolvePointerCliEntry", () => { + it("returns null when no pointer exists", () => { + expect(resolvePointerCliEntry(userData)).toBeNull(); + }); + + it("returns the cliEntry when it exists on disk", () => { + const cliEntry = stageVersion(userData, "0.5.0"); + writePointer(userData, { version: "0.5.0", cliEntry }); + expect(resolvePointerCliEntry(userData)).toBe(cliEntry); + }); + + it("returns null when pointer exists but cliEntry is missing on disk", () => { + // Caller (`createRuntimeAutoUpdate.loadOverride`) uses this signal + // to drop the pointer and unfreeze the background updater. + const cliEntry = stageVersion(userData, "0.6.0"); + writePointer(userData, { version: "0.6.0", cliEntry }); + rmSync(path.dirname(cliEntry), { recursive: true }); + expect(resolvePointerCliEntry(userData)).toBeNull(); + }); +}); + +describe("runtime-store: bad-versions", () => { + it("isBadVersion is false for everything when the file is missing", () => { + expect(isBadVersion(userData, "1.0.0")).toBe(false); + }); + + it("markBadVersion persists, deduplicates, and sorts by semver", () => { + markBadVersion(userData, "1.10.0"); + markBadVersion(userData, "1.2.0"); + markBadVersion(userData, "1.10.0"); + expect(isBadVersion(userData, "1.2.0")).toBe(true); + expect(isBadVersion(userData, "1.10.0")).toBe(true); + expect(isBadVersion(userData, "1.5.0")).toBe(false); + + const raw = readFileSync( + path.join(userData, "runtime-store", "bad-versions.json"), + "utf8", + ); + expect(JSON.parse(raw)).toEqual(["1.2.0", "1.10.0"]); + }); + + it("rejects non-semver on write, ignores corrupt file on read", () => { + expect(() => markBadVersion(userData, "junk")).toThrow(/invalid semver/); + + mkdirSync(path.join(userData, "runtime-store"), { recursive: true }); + writeFileSync( + path.join(userData, "runtime-store", "bad-versions.json"), + "{not-json", + ); + expect(isBadVersion(userData, "1.0.0")).toBe(false); + }); +}); + +describe("runtime-store: cleanup", () => { + it("cleanupPartials removes only `*.partial` directories", () => { + mkdirSync(versionDir(userData, "0.1.0"), { recursive: true }); + mkdirSync(partialDir(userData, "0.5.0"), { recursive: true }); + cleanupPartials(userData); + expect(existsSync(versionDir(userData, "0.1.0"))).toBe(true); + expect(existsSync(partialDir(userData, "0.5.0"))).toBe(false); + }); + + it("cleanupPartials is a no-op when versions root does not exist", () => { + expect(() => cleanupPartials(userData)).not.toThrow(); + }); + + it("removeVersionDir clears a finalized dir and is safe with garbage input", () => { + stageVersion(userData, "1.0.0"); + removeVersionDir(userData, "1.0.0"); + expect(existsSync(versionDir(userData, "1.0.0"))).toBe(false); + expect(() => removeVersionDir(userData, "../../etc")).not.toThrow(); + }); + + it("versionDir / partialDir reject non-semver inputs", () => { + expect(() => versionDir(userData, "../../etc")).toThrow(/invalid semver/); + expect(() => partialDir(userData, "abc")).toThrow(/invalid semver/); + }); +});