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)484950515253545556const val ANDROID_PLATFORM_KEY = "android-arm64"575859606162636465666768fun 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 79 80 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}979899internal fun JSONObject.optTextOrNull(key: String): String? {100 if (!has(key) || isNull(key)) return null101 return optString(key).trim().takeIf { it.isNotEmpty() }102}103104105106107108109110111fun normalizeVersion(version: String): String =112 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')113114115fun 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) > 0127128129130data class ExpectedChecksum(val algorithm: String, val hex: String)131132class ChecksumMismatchException(message: String) : Exception(message)133134private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")135136137fun 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) }