Добавить безопасный клиент каталога прошивок Android

This commit is contained in:
2026-09-04 12:30:37 +03:00
parent 392817432b
commit fa6f615e81
2 changed files with 176 additions and 0 deletions

View File

@@ -13,3 +13,9 @@ the Android adapter for protocol operations; the portable protocol core remains
workspace, up to 2×16384 doubles). `trends/SpectrumAnalyzer` maps timestamps and
errors but does not duplicate FFT/filter math. Run it off the UI thread.
`trends/PlotViewport` is a toolkit-free normalized zoom/pan model.
`kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt` is a UI-independent
firmware release client. It reads the optional `firmware.releases` array from
the shared `update.json`, accepts only HTTPS assets, limits their size and
returns image bytes only after SHA-256 verification. Applications provide
their own UI and pass optional HTTP Basic credentials.

View File

@@ -0,0 +1,170 @@
package ru.setcorp.setprotocol.update
import android.util.Base64
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
/** Учётные данные HTTP Basic для закрытого каталога релизов. */
data class FirmwareCatalogCredentials(val login: String, val password: String)
/** Одна проверенная запись секции `firmware.releases` общего update.json. */
data class FirmwareRelease(
val product: String,
val versionName: String,
val versionCode: Long,
val imageUrl: String,
val fileName: String,
val sha256: String,
val notes: String,
val transport: String,
val baseAddress: Long?,
)
/**
* Переиспользуемый клиент базы прошивок для Android-приложений SET.
*
* Не зависит от UI: читает общий manifest, валидирует записи, загружает образ
* только по HTTPS и возвращает байты после обязательной проверки SHA-256.
*/
class FirmwareCatalogClient(private val userAgent: String) {
fun readCatalog(manifestUrl: String, credentials: FirmwareCatalogCredentials?): List<FirmwareRelease> {
val connection = openChecked(URL(manifestUrl), "application/json", credentials)
val text = try {
connection.inputStream.bufferedReader().use { reader ->
reader.readText().also {
require(it.length <= MAX_MANIFEST_CHARS) { "Файл каталога прошивок слишком большой" }
}
}
} finally {
connection.disconnect()
}
return parse(manifestUrl, text)
}
fun download(
release: FirmwareRelease,
credentials: FirmwareCatalogCredentials?,
onProgress: (Float) -> Unit = {},
): ByteArray {
val connection = openChecked(URL(release.imageUrl), "application/octet-stream", credentials)
val total = connection.contentLengthLong
require(total < 0 || total <= MAX_FIRMWARE_BYTES) { "Файл прошивки превышает допустимый размер" }
return try {
val bytes = connection.inputStream.use { input ->
val output = java.io.ByteArrayOutputStream()
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var received = 0L
while (true) {
val count = input.read(buffer)
if (count < 0) break
received += count
require(received <= MAX_FIRMWARE_BYTES) { "Файл прошивки превышает допустимый размер" }
output.write(buffer, 0, count)
if (total > 0) onProgress((received.toFloat() / total).coerceIn(0f, 1f))
}
output.toByteArray()
}
require(bytes.isNotEmpty()) { "Сервер вернул пустой файл прошивки" }
val actual = MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
require(actual.equals(release.sha256, ignoreCase = true)) {
"SHA-256 загруженной прошивки не совпадает с каталогом"
}
onProgress(1f)
bytes
} finally {
connection.disconnect()
}
}
private fun openChecked(
initial: URL,
accept: String,
credentials: FirmwareCatalogCredentials?,
): HttpURLConnection {
var current = initial.requireHttps()
val credentialHost = current.host
for (redirect in 0..MAX_REDIRECTS) {
val connection = current.openConnection() as HttpURLConnection
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
connection.instanceFollowRedirects = false
connection.setRequestProperty("Accept", accept)
connection.setRequestProperty("User-Agent", userAgent)
if (credentials != null && current.host.equals(credentialHost, ignoreCase = true)) {
val basic = Base64.encodeToString(
"${credentials.login}:${credentials.password}".toByteArray(), Base64.NO_WRAP,
)
connection.setRequestProperty("Authorization", "Basic $basic")
}
val code = connection.responseCode
if (code in REDIRECT_CODES) {
val location = connection.getHeaderField("Location")
?: run { connection.disconnect(); error("Перенаправление без адреса") }
connection.disconnect()
require(redirect < MAX_REDIRECTS) { "Слишком много перенаправлений" }
current = URL(current, location).requireHttps()
continue
}
if (code !in 200..299) {
connection.disconnect()
error("Сервер каталога прошивок вернул HTTP $code")
}
return connection
}
error("Слишком много перенаправлений")
}
companion object {
fun parse(manifestUrl: String, text: String): List<FirmwareRelease> {
require(text.length <= MAX_MANIFEST_CHARS) { "Файл каталога прошивок слишком большой" }
val root = JSONObject(text)
val firmware = root.optJSONObject("firmware")
?: error("В update.json не опубликован каталог прошивок")
val rows = firmware.optJSONArray("releases")
?: error("В каталоге отсутствует массив releases")
return buildList {
for (index in 0 until rows.length()) {
val row = rows.getJSONObject(index)
val product = row.optString("product", row.optString("device")).trim()
val versionName = row.optString("versionName", row.optString("version")).trim()
val versionCode = row.optLong("versionCode", -1)
val imageUrl = URL(URL(manifestUrl), row.getString("imageUrl")).toString()
URL(imageUrl).requireHttps()
val sha256 = row.getString("sha256").trim().lowercase()
val fileName = row.optString("fileName", URL(imageUrl).path.substringAfterLast('/')).trim()
val transport = row.optString("transport", "rs485").trim().lowercase()
require(product.isNotEmpty() && versionName.isNotEmpty() && versionCode >= 0) {
"Не заполнены обязательные поля прошивки №${index + 1}"
}
require(sha256.matches(Regex("[0-9a-f]{64}"))) { "Некорректный SHA-256 прошивки №${index + 1}" }
require(fileName.matches(Regex("[A-Za-zА-Яа-яЁё0-9._ -]+\\.(bin|hex)", RegexOption.IGNORE_CASE))) {
"Некорректное имя файла прошивки №${index + 1}"
}
require(transport in setOf("rs485", "can", "stm32")) { "Некорректный канал прошивки №${index + 1}" }
val baseAddress = row.opt("baseAddress")?.takeUnless { it == JSONObject.NULL }?.toString()?.let(::parseAddress)
add(FirmwareRelease(
product, versionName, versionCode, imageUrl, fileName, sha256,
row.optString("notes").trim(), transport, baseAddress,
))
}
}.sortedWith(compareBy<FirmwareRelease> { it.product.lowercase() }.thenByDescending { it.versionCode })
}
private fun parseAddress(value: String): Long =
if (value.startsWith("0x", ignoreCase = true)) value.drop(2).toLong(16) else value.toLong()
private fun URL.requireHttps(): URL = also {
require(protocol.equals("https", ignoreCase = true)) { "Обновления разрешены только по HTTPS" }
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private const val CONNECT_TIMEOUT_MS = 15_000
private const val READ_TIMEOUT_MS = 60_000
private const val MAX_REDIRECTS = 5
private const val MAX_MANIFEST_CHARS = 128 * 1024
private const val MAX_FIRMWARE_BYTES = 128L * 1024 * 1024
private val REDIRECT_CODES = setOf(301, 302, 303, 307, 308)
}
}