UpdateManifest.kt

6.9 KB · raw

1package com.peachpasswords.updates23import org.bouncycastle.crypto.digests.Blake3Digest4import org.json.JSONObject5import java.io.File6import java.io.InputStream7import java.security.MessageDigest89/**10 * Kepr's generic release manifest, as returned by11 * `GET https://kepr.uk/api/<repo>/releases/latest`, flattened to the single12 * platform this app can install.13 *14 * ```json15 * { "version": "2.3.68",16 *   "notes": "...",17 *   "platforms": { "android-arm64": { "url": "...", "checksum": "blake3:<64 hex>" } } }18 * ```19 */20data class ReleaseManifest(21    val version: String,22    val url: String,23    val checksum: String,24    /** Absent — not empty, not "null" — when the release carries no notes. */25    val notes: String?,26) {27    fun toJson(): String = JSONObject().apply {28        put("version", version)29        put("url", url)30        put("checksum", checksum)31        if (notes != null) put("notes", notes)32    }.toString()3334    companion object {35        fun fromCachedJson(json: String): ReleaseManifest? = runCatching {36            val o = JSONObject(json)37            ReleaseManifest(38                version = o.getString("version"),39                url = o.getString("url"),40                checksum = o.getString("checksum"),41                notes = o.optTextOrNull("notes"),42            )43        }.getOrNull()44    }45}4647class ManifestFormatException(message: String) : Exception(message)4849/**50 * SETTLED — Kepr accepts `android-arm64` as of kepr v1.56.237, matching its51 * `<os>-<arch>` platform vocabulary. This is the key the release manifest is52 * published under, so it is the key the updater must look up. It has to stay53 * identical to `platforms =` in this repo's `kepr.build` and to Kepr's54 * `release_platforms` allowlist; changing any one alone breaks APK publishing.55 */56const val ANDROID_PLATFORM_KEY = "android-arm64"5758/**59 * Parse a Kepr release manifest.60 *61 * Two contract wrinkles are handled deliberately:62 * - The notes key is `notes` canonically, but the published Guidelines still show63 *   the retired `release_notes`. Both are accepted, `notes` winning.64 * - `optString` on a JSON null yields the literal string "null" in org.json, which65 *   would render as changelog text. [optTextOrNull] rejects that class of value66 *   outright rather than trusting the caller to notice.67 */68fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {69    val root = runCatching { JSONObject(body) }70        .getOrElse { throw ManifestFormatException("manifest is not JSON") }7172    val version = root.optTextOrNull("version")73        ?: throw ManifestFormatException("manifest has no version")7475    val platforms = root.optJSONObject("platforms")76        ?: throw ManifestFormatException("manifest has no platforms")7778    // Fall back to the sole platform when the key doesn't match: a single-artifact79    // Android release is unambiguous, and a naming mismatch shouldn't strand every80    // installed build with "no download available".81    val platform = platforms.optJSONObject(platformKey)82        ?: platforms.keys().asSequence().toList().singleOrNull()?.let { platforms.optJSONObject(it) }83        ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")8485    val url = platform.optTextOrNull("url")86        ?: throw ManifestFormatException("artifact has no url")87    val checksum = platform.optTextOrNull("checksum")88        ?: throw ManifestFormatException("artifact has no checksum")8990    return ReleaseManifest(91        version = version,92        url = url,93        checksum = checksum,94        notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),95    )96}9798/** Non-blank string value, or null — never JSON null surfaced as the text "null". */99internal fun JSONObject.optTextOrNull(key: String): String? {100    if (!has(key) || isNull(key)) return null101    return optString(key).trim().takeIf { it.isNotEmpty() }102}103104/* ─────────────────────────────  version compare  ───────────────────────────── */105106/**107 * Strip everything that isn't the numeric release, so a debug build108 * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was109 * built from rather than as an unrelated string.110 */111fun normalizeVersion(version: String): String =112    version.trim().removePrefix("v").substringBefore('-').substringBefore('+')113114/** Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong. */115fun compareVersions(a: String, b: String): Int {116    val left = normalizeVersion(a).split('.')117    val right = normalizeVersion(b).split('.')118    for (i in 0 until maxOf(left.size, right.size)) {119        val x = left.getOrNull(i)?.toIntOrNull() ?: 0120        val y = right.getOrNull(i)?.toIntOrNull() ?: 0121        if (x != y) return x.compareTo(y)122    }123    return 0124}125126fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0127128/* ──────────────────────────────────  checksum  ─────────────────────────────── */129130data class ExpectedChecksum(val algorithm: String, val hex: String)131132class ChecksumMismatchException(message: String) : Exception(message)133134private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")135136/** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */137fun parseChecksum(raw: String): ExpectedChecksum? {138    val separator = raw.indexOf(':')139    if (separator <= 0) return null140    val algorithm = raw.substring(0, separator).trim().lowercase()141    val hex = raw.substring(separator + 1).trim().lowercase()142    if (algorithm !in SUPPORTED_CHECKSUMS) return null143    if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null144    return ExpectedChecksum(algorithm, hex)145}146147fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {148    "blake3" -> {149        val digest = Blake3Digest(32)150        val buffer = ByteArray(64 * 1024)151        while (true) {152            val read = input.read(buffer)153            if (read <= 0) break154            digest.update(buffer, 0, read)155        }156        val out = ByteArray(digest.digestSize)157        digest.doFinal(out, 0)158        out.toHex()159    }160    "sha256" -> {161        val digest = MessageDigest.getInstance("SHA-256")162        val buffer = ByteArray(64 * 1024)163        while (true) {164            val read = input.read(buffer)165            if (read <= 0) break166            digest.update(buffer, 0, read)167        }168        digest.digest().toHex()169    }170    else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")171}172173fun hashFile(file: File, algorithm: String): String =174    file.inputStream().use { hashStream(it, algorithm) }175176private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }