Добавить безопасный клиент каталога прошивок Android
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user