UpdateManifest.kt

10.1 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        /**36         * The cached manifest is re-read straight into the download path, so it37         * has to clear the same strict-SemVer bar [parseReleaseManifest] applies38         * to a fresh fetch — otherwise a rewritten cache entry reaches39         * `downloadAndVerify`, whose filename safety argument rests on that40         * validation having happened. A malformed cache entry parses to null,41         * which callers already treat as "no update known".42         */43        fun fromCachedJson(json: String): ReleaseManifest? = runCatching {44            val o = JSONObject(json)45            ReleaseManifest(46                version = o.getString("version").also(::validateSemver),47                url = o.getString("url"),48                checksum = o.getString("checksum"),49                notes = o.optTextOrNull("notes"),50            )51        }.getOrNull()52    }53}5455class ManifestFormatException(message: String) : Exception(message)5657/** Thrown when the APK on disk is not the package/version the updater was told to install. */58class ApkIdentityException(message: String) : Exception(message)5960/** Thrown when the download would exceed [MAX_APK_BYTES] or advertise a larger Content-Length. */61class DownloadSizeException(message: String) : Exception(message)6263/**64 * SETTLED — Kepr accepts `android-arm64` as of kepr v1.56.237, matching its65 * `<os>-<arch>` platform vocabulary. This is the key the release manifest is66 * published under, so it is the key the updater must look up. It has to stay67 * identical to `platforms =` in this repo's `kepr.build` and to Kepr's68 * `release_platforms` allowlist; changing any one alone breaks APK publishing.69 */70const val ANDROID_PLATFORM_KEY = "android-arm64"7172/**73 * Hard ceiling on a downloaded APK. Kepr publishes ~24 MB today; 50 MB leaves74 * headroom for growth without letting a hostile manifest exhaust cache or disk.75 */76const val MAX_APK_BYTES = 50L * 1024 * 10247778/**79 * Strict SemVer core: exactly three non-empty, non-leading-zero decimal80 * components, each small enough to fit in an Int. Anything else (NaN segments,81 * extra components, negatives, overflow) is refused — the manifest's version82 * is later used to derive the cache filename, so a permissive parser here would83 * let `..` or `/` reach the filesystem.84 */85private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")8687internal fun validateSemver(version: String) {88    if (!STRICT_SEMVER.matches(version)) {89        throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")90    }91    version.split('.').forEach { seg ->92        // STRICT_SEMVER already excludes non-digits and leading zeros, so this93        // overflow check is the only thing left standing between "9999999999"94        // and a silently-wrapped compareVersions result.95        if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {96            throw ManifestFormatException("version '$version' has an out-of-range segment")97        }98    }99}100101/**102 * True when a parsed APK archive is the package and version this updater103 * belongs to. Pure: takes the values PackageManager already extracted so the104 * Android side stays at the call site and this stays unit-testable.105 *106 * Both the production package (`com.peachpasswords`) and the debug suffix107 * variant (`com.peachpasswords.debug`) are accepted so debug builds can still108 * install their own updates; anything else is rejected outright.109 */110fun isExpectedApk(111    packageName: String?,112    versionCode: Long,113    expectedPackage: String,114    expectedVersionCode: Int,115): Boolean {116    if (packageName != expectedPackage && packageName != "$expectedPackage.debug") return false117    return versionCode >= expectedVersionCode118}119120/**121 * Parse a Kepr release manifest.122 *123 * Two contract wrinkles are handled deliberately:124 * - The notes key is `notes` canonically, but the published Guidelines still show125 *   the retired `release_notes`. Both are accepted, `notes` winning.126 * - `optString` on a JSON null yields the literal string "null" in org.json, which127 *   would render as changelog text. [optTextOrNull] rejects that class of value128 *   outright rather than trusting the caller to notice.129 *130 * Security note (H-08): the canonical platform key is required and the131 * sole-artifact fallback is gone. A Kepr build that only published, say,132 * `android-x86` would have been accepted before and pointed an Android phone at133 * the wrong binary; now it is rejected outright. A signed release-manifest134 * channel is the real fix — that is an architectural change and is out of scope135 * for H-08.136 */137fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {138    val root = runCatching { JSONObject(body) }139        .getOrElse { throw ManifestFormatException("manifest is not JSON") }140141    val version = root.optTextOrNull("version")142        ?: throw ManifestFormatException("manifest has no version")143    validateSemver(version)144145    val platforms = root.optJSONObject("platforms")146        ?: throw ManifestFormatException("manifest has no platforms")147148    val platform = platforms.optJSONObject(platformKey)149        ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")150151    val url = platform.optTextOrNull("url")152        ?: throw ManifestFormatException("artifact has no url")153    val checksum = platform.optTextOrNull("checksum")154        ?: throw ManifestFormatException("artifact has no checksum")155156    return ReleaseManifest(157        version = version,158        url = url,159        checksum = checksum,160        notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),161    )162}163164/** Non-blank string value, or null — never JSON null surfaced as the text "null". */165internal fun JSONObject.optTextOrNull(key: String): String? {166    if (!has(key) || isNull(key)) return null167    return optString(key).trim().takeIf { it.isNotEmpty() }168}169170/* ─────────────────────────────  version compare  ───────────────────────────── */171172/**173 * Strip everything that isn't the numeric release, so a debug build174 * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was175 * built from rather than as an unrelated string.176 */177fun normalizeVersion(version: String): String =178    version.trim().removePrefix("v").substringBefore('-').substringBefore('+')179180/**181 * Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong.182 *183 * Tolerant on purpose, and only safely so: both untrusted entry points184 * ([parseReleaseManifest] and [ReleaseManifest.fromCachedJson]) run185 * [validateSemver] first, so the `?: 0` fallback is only ever reachable for the186 * locally built `BuildConfig.VERSION_NAME`. Throwing here instead would turn a187 * malformed local version string into a crash on every update check.188 */189fun compareVersions(a: String, b: String): Int {190    val left = normalizeVersion(a).split('.')191    val right = normalizeVersion(b).split('.')192    for (i in 0 until maxOf(left.size, right.size)) {193        val x = left.getOrNull(i)?.toIntOrNull() ?: 0194        val y = right.getOrNull(i)?.toIntOrNull() ?: 0195        if (x != y) return x.compareTo(y)196    }197    return 0198}199200fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0201202/* ──────────────────────────────────  checksum  ─────────────────────────────── */203204data class ExpectedChecksum(val algorithm: String, val hex: String)205206class ChecksumMismatchException(message: String) : Exception(message)207208private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")209210/** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */211fun parseChecksum(raw: String): ExpectedChecksum? {212    val separator = raw.indexOf(':')213    if (separator <= 0) return null214    val algorithm = raw.substring(0, separator).trim().lowercase()215    val hex = raw.substring(separator + 1).trim().lowercase()216    if (algorithm !in SUPPORTED_CHECKSUMS) return null217    if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null218    return ExpectedChecksum(algorithm, hex)219}220221fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {222    "blake3" -> {223        val digest = Blake3Digest(32)224        val buffer = ByteArray(64 * 1024)225        while (true) {226            val read = input.read(buffer)227            if (read <= 0) break228            digest.update(buffer, 0, read)229        }230        val out = ByteArray(digest.digestSize)231        digest.doFinal(out, 0)232        out.toHex()233    }234    "sha256" -> {235        val digest = MessageDigest.getInstance("SHA-256")236        val buffer = ByteArray(64 * 1024)237        while (true) {238            val read = input.read(buffer)239            if (read <= 0) break240            digest.update(buffer, 0, read)241        }242        digest.digest().toHex()243    }244    else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")245}246247fun hashFile(file: File, algorithm: String): String =248    file.inputStream().use { hashStream(it, algorithm) }249250private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }