Добавить безопасный клиент каталога прошивок 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
|
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.
|
errors but does not duplicate FFT/filter math. Run it off the UI thread.
|
||||||
`trends/PlotViewport` is a toolkit-free normalized zoom/pan model.
|
`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