1package com.peachpasswords.updates23import org.bouncycastle.crypto.digests.Blake3Digest4import org.json.JSONObject5import java.io.File6import java.io.InputStream7import java.security.MessageDigest891011121314151617181920data class ReleaseManifest(21 val version: String,22 val url: String,23 val checksum: String,24 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)48495051525354555657const val ANDROID_PLATFORM_KEY = "arm64"585960616263646566676869fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {70 val root = runCatching { JSONObject(body) }71 .getOrElse { throw ManifestFormatException("manifest is not JSON") }7273 val version = root.optTextOrNull("version")74 ?: throw ManifestFormatException("manifest has no version")7576 val platforms = root.optJSONObject("platforms")77 ?: throw ManifestFormatException("manifest has no platforms")7879 80 81 82 val platform = platforms.optJSONObject(platformKey)83 ?: platforms.keys().asSequence().toList().singleOrNull()?.let { platforms.optJSONObject(it) }84 ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")8586 val url = platform.optTextOrNull("url")87 ?: throw ManifestFormatException("artifact has no url")88 val checksum = platform.optTextOrNull("checksum")89 ?: throw ManifestFormatException("artifact has no checksum")9091 return ReleaseManifest(92 version = version,93 url = url,94 checksum = checksum,95 notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),96 )97}9899100internal fun JSONObject.optTextOrNull(key: String): String? {101 if (!has(key) || isNull(key)) return null102 return optString(key).trim().takeIf { it.isNotEmpty() }103}104105106107108109110111112fun normalizeVersion(version: String): String =113 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')114115116fun compareVersions(a: String, b: String): Int {117 val left = normalizeVersion(a).split('.')118 val right = normalizeVersion(b).split('.')119 for (i in 0 until maxOf(left.size, right.size)) {120 val x = left.getOrNull(i)?.toIntOrNull() ?: 0121 val y = right.getOrNull(i)?.toIntOrNull() ?: 0122 if (x != y) return x.compareTo(y)123 }124 return 0125}126127fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0128129130131data class ExpectedChecksum(val algorithm: String, val hex: String)132133class ChecksumMismatchException(message: String) : Exception(message)134135private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")136137138fun parseChecksum(raw: String): ExpectedChecksum? {139 val separator = raw.indexOf(':')140 if (separator <= 0) return null141 val algorithm = raw.substring(0, separator).trim().lowercase()142 val hex = raw.substring(separator + 1).trim().lowercase()143 if (algorithm !in SUPPORTED_CHECKSUMS) return null144 if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null145 return ExpectedChecksum(algorithm, hex)146}147148fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {149 "blake3" -> {150 val digest = Blake3Digest(32)151 val buffer = ByteArray(64 * 1024)152 while (true) {153 val read = input.read(buffer)154 if (read <= 0) break155 digest.update(buffer, 0, read)156 }157 val out = ByteArray(digest.digestSize)158 digest.doFinal(out, 0)159 out.toHex()160 }161 "sha256" -> {162 val digest = MessageDigest.getInstance("SHA-256")163 val buffer = ByteArray(64 * 1024)164 while (true) {165 val read = input.read(buffer)166 if (read <= 0) break167 digest.update(buffer, 0, read)168 }169 digest.digest().toHex()170 }171 else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")172}173174fun hashFile(file: File, algorithm: String): String =175 file.inputStream().use { hashStream(it, algorithm) }176177private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }