UpdateManifest.kt

9.3 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/** Thrown when the APK on disk is not the package/version the updater was told to install. */50class ApkIdentityException(message: String) : Exception(message)5152/** Thrown when the download would exceed [MAX_APK_BYTES] or advertise a larger Content-Length. */53class DownloadSizeException(message: String) : Exception(message)5455/**56 * SETTLED — Kepr accepts `android-arm64` as of kepr v1.56.237, matching its57 * `<os>-<arch>` platform vocabulary. This is the key the release manifest is58 * published under, so it is the key the updater must look up. It has to stay59 * identical to `platforms =` in this repo's `kepr.build` and to Kepr's60 * `release_platforms` allowlist; changing any one alone breaks APK publishing.61 */62const val ANDROID_PLATFORM_KEY = "android-arm64"6364/**65 * Hard ceiling on a downloaded APK. Kepr publishes ~24 MB today; 50 MB leaves66 * headroom for growth without letting a hostile manifest exhaust cache or disk.67 */68const val MAX_APK_BYTES = 50L * 1024 * 10246970/**71 * Strict SemVer core: exactly three non-empty, non-leading-zero decimal72 * components, each small enough to fit in an Int. Anything else (NaN segments,73 * extra components, negatives, overflow) is refused — the manifest's version74 * is later used to derive the cache filename, so a permissive parser here would75 * let `..` or `/` reach the filesystem.76 */77private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")7879internal fun validateSemver(version: String) {80    if (!STRICT_SEMVER.matches(version)) {81        throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")82    }83    version.split('.').forEach { seg ->84        // STRICT_SEMVER already excludes non-digits and leading zeros, so this85        // overflow check is the only thing left standing between "9999999999"86        // and a silently-wrapped compareVersions result.87        if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {88            throw ManifestFormatException("version '$version' has an out-of-range segment")89        }90    }91}9293/**94 * True when a parsed APK archive is the package and version this updater95 * belongs to. Pure: takes the values PackageManager already extracted so the96 * Android side stays at the call site and this stays unit-testable.97 *98 * Both the production package (`com.peachpasswords`) and the debug suffix99 * variant (`com.peachpasswords.debug`) are accepted so debug builds can still100 * install their own updates; anything else is rejected outright.101 */102fun isExpectedApk(103    packageName: String?,104    versionCode: Long,105    expectedPackage: String,106    expectedVersionCode: Int,107): Boolean {108    if (packageName != expectedPackage && packageName != "$expectedPackage.debug") return false109    return versionCode >= expectedVersionCode110}111112/**113 * Parse a Kepr release manifest.114 *115 * Two contract wrinkles are handled deliberately:116 * - The notes key is `notes` canonically, but the published Guidelines still show117 *   the retired `release_notes`. Both are accepted, `notes` winning.118 * - `optString` on a JSON null yields the literal string "null" in org.json, which119 *   would render as changelog text. [optTextOrNull] rejects that class of value120 *   outright rather than trusting the caller to notice.121 *122 * Security note (H-08): the canonical platform key is required and the123 * sole-artifact fallback is gone. A Kepr build that only published, say,124 * `android-x86` would have been accepted before and pointed an Android phone at125 * the wrong binary; now it is rejected outright. A signed release-manifest126 * channel is the real fix — that is an architectural change and is out of scope127 * for H-08.128 */129fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {130    val root = runCatching { JSONObject(body) }131        .getOrElse { throw ManifestFormatException("manifest is not JSON") }132133    val version = root.optTextOrNull("version")134        ?: throw ManifestFormatException("manifest has no version")135    validateSemver(version)136137    val platforms = root.optJSONObject("platforms")138        ?: throw ManifestFormatException("manifest has no platforms")139140    val platform = platforms.optJSONObject(platformKey)141        ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")142143    val url = platform.optTextOrNull("url")144        ?: throw ManifestFormatException("artifact has no url")145    val checksum = platform.optTextOrNull("checksum")146        ?: throw ManifestFormatException("artifact has no checksum")147148    return ReleaseManifest(149        version = version,150        url = url,151        checksum = checksum,152        notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),153    )154}155156/** Non-blank string value, or null — never JSON null surfaced as the text "null". */157internal fun JSONObject.optTextOrNull(key: String): String? {158    if (!has(key) || isNull(key)) return null159    return optString(key).trim().takeIf { it.isNotEmpty() }160}161162/* ─────────────────────────────  version compare  ───────────────────────────── */163164/**165 * Strip everything that isn't the numeric release, so a debug build166 * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was167 * built from rather than as an unrelated string.168 */169fun normalizeVersion(version: String): String =170    version.trim().removePrefix("v").substringBefore('-').substringBefore('+')171172/** Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong. */173fun compareVersions(a: String, b: String): Int {174    val left = normalizeVersion(a).split('.')175    val right = normalizeVersion(b).split('.')176    for (i in 0 until maxOf(left.size, right.size)) {177        val x = left.getOrNull(i)?.toIntOrNull() ?: 0178        val y = right.getOrNull(i)?.toIntOrNull() ?: 0179        if (x != y) return x.compareTo(y)180    }181    return 0182}183184fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0185186/* ──────────────────────────────────  checksum  ─────────────────────────────── */187188data class ExpectedChecksum(val algorithm: String, val hex: String)189190class ChecksumMismatchException(message: String) : Exception(message)191192private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")193194/** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */195fun parseChecksum(raw: String): ExpectedChecksum? {196    val separator = raw.indexOf(':')197    if (separator <= 0) return null198    val algorithm = raw.substring(0, separator).trim().lowercase()199    val hex = raw.substring(separator + 1).trim().lowercase()200    if (algorithm !in SUPPORTED_CHECKSUMS) return null201    if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null202    return ExpectedChecksum(algorithm, hex)203}204205fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {206    "blake3" -> {207        val digest = Blake3Digest(32)208        val buffer = ByteArray(64 * 1024)209        while (true) {210            val read = input.read(buffer)211            if (read <= 0) break212            digest.update(buffer, 0, read)213        }214        val out = ByteArray(digest.digestSize)215        digest.doFinal(out, 0)216        out.toHex()217    }218    "sha256" -> {219        val digest = MessageDigest.getInstance("SHA-256")220        val buffer = ByteArray(64 * 1024)221        while (true) {222            val read = input.read(buffer)223            if (read <= 0) break224            digest.update(buffer, 0, read)225        }226        digest.digest().toHex()227    }228    else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")229}230231fun hashFile(file: File, algorithm: String): String =232    file.inputStream().use { hashStream(it, algorithm) }233234private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }