From 0aa63c98eed2bcd24207eeddda3992f6558a6c65 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 10:13:10 +0100 Subject: [PATCH] Document Android file framing and error status contracts --- .../java/com/utun/chat/data/FileStatusText.kt | 3 +- .../com/utun/chat/data/MemberFilesProtocol.kt | 28 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/FileStatusText.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/FileStatusText.kt index 39a15b56..8e0af31c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/FileStatusText.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/FileStatusText.kt @@ -2,7 +2,8 @@ package com.utun.chat.data import com.utun.chat.R -/** UI получает ID ресурса, поэтому ошибки переводятся при каждом отображении. */ +/** ID строки для MF-ошибки 1..14; IO_ERROR=9 и неизвестное значение дают files_error_io. + * Успех 0 не предназначен для этого API. Перевод выполняется UI при отображении, а не сохраняется в состоянии. */ fun fileStatusResource(status: Int): Int = when (status) { 1 -> R.string.files_error_access 2 -> R.string.files_error_missing diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberFilesProtocol.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberFilesProtocol.kt index d3213c7d..bedb81f5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberFilesProtocol.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberFilesProtocol.kt @@ -5,7 +5,13 @@ import java.nio.ByteOrder import java.nio.charset.CodingErrorAction import java.util.concurrent.atomic.AtomicLong -/** Общий wire-кодек файлов и аватарок. UID хранится как исходные 64 бита. */ +/** + * Kotlin-кодек member_files v4, общий для файлового браузера и аватаров. + * Числа LE; UID/ID хранят исходные 64 бита в Long, размеры/счётчики в проверяемых payload должны быть неотрицательными. + * request кодирует только LIST/STAT/MKDIR/RENAME/REMOVE/QUOTA, без group/server-префикса JNI-события 49. + * response принимает событие 49 с этим префиксом, progress — локальное событие 50, не сетевой STREAM_DATA. + * Неверные данные бросают исключение; caller логирует отказ с контекстом запроса. Проверки ACL и смысла команд остаются в core. + */ object MemberFilesProtocol { const val LIST = 1 const val STAT = 2 @@ -20,11 +26,14 @@ object MemberFilesProtocol { const val NOT_FOUND = 2 const val CANCELLED = 12 private val ids = AtomicLong(System.nanoTime()) + /** Общий потокобезопасный счётчик запросов браузера/аватаров; нулевой ID пропускается. */ fun nextId(): Long { var id = ids.incrementAndGet(); if (id == 0L) id = ids.incrementAndGet(); return id } private fun buffer(data: ByteArray) = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) private fun utf8(data: ByteArray): String = Charsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(data)).toString() + /** Относительный UTF-8 путь до 1024 байт/60 компонентов по 1..255 байт; root разрешает пустой путь. + * Отклоняет leading '.', trailing '.'/' ', C0/DEL, Windows device names и запрещённые символы; путь не нормализуется. */ fun validPath(path: String, root: Boolean = true): Boolean { if (path.isEmpty()) return root if (path.toByteArray().size > 1024 || path.any { it.isSurrogate() } && @@ -36,20 +45,31 @@ object MemberFilesProtocol { !Regex("(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])").matches(name.substringBefore('.')) } } + /** Первый компонент avatars без учёта регистра; это признак служебного дерева, не проверка валидности пути. */ fun servicePath(path: String) = path.substringBefore('/').equals("avatars", true) + /** Добавить одно имя к path и проверить итог; имя со '/' или неверный итог бросают IllegalArgumentException. */ fun child(path: String, name: String): String { require(!name.contains('/') && validPath(name, false)) { "invalid file name" } return (if (path.isEmpty()) name else "$path/$name").also { require(validPath(it, false)) { "invalid path" } } } + /** Ответ 49: status=0 — успех, 1..14 — MF-ошибка; mtime в Unix seconds, size в байтах. + * version — непрозрачные 16 байт снимка, hash — 32 байта; STAT не вычисляет хеш содержимого. + * cursor/more/payload зависят от команды; контекст id/group/server/owner/command/path сверяет репозиторий. */ data class Reply(val id: Long, val group: Long, val server: Long, val owner: Long, val command: Int, val status: Int, val size: Long, val mtime: Long, val hash: ByteArray, val type: Int, val cursor: Long, val version: ByteArray, val more: Boolean, val path: String, val payload: ByteArray) + /** Одна запись LIST: имя без '/', FILE/DIRECTORY, байты/Unix seconds и 16-байтная версия для операций. */ data class Entry(val name: String, val type: Int, val size: Long, val mtime: Long, val version: ByteArray) + /** Событие 50: done/total — байты; окончательный status действителен только при finished. */ data class Progress(val id: Long, val group: Long, val server: Long, val owner: Long, val done: Long, val total: Long, val status: Int, val finished: Boolean) + /** Общий учёт UID по группам сервера: первые три поля в байтах, последние два — числа опубликованных объектов. */ data class Quota(val used: Long, val limit: Long, val reserved: Long, val files: Long, val directories: Long) + /** Заголовок 125 байт и UTF-8 path/target. LIST начинает с cursor=0/version=0, продолжает снимком ответа. + * RENAME/REMOVE используют версию entry, target допустим только RENAME; прочие wire-поля остаются нулевыми. + * id ненулевой; допустимость owner=0 и полномочия вызывающего дополнительно проверяются JNI/core. */ fun request(id: Long, owner: Long, command: Int, path: String, target: String = "", cursor: Long = 0, version: ByteArray = ByteArray(16)): ByteArray { require(id != 0L && command in listOf(LIST, STAT, MKDIR, RENAME, REMOVE, QUOTA)) @@ -63,6 +83,8 @@ object MemberFilesProtocol { }.array() } + /** Разобрать group/server:u64 и полный v4 RESPONSE с точными длинами и строгим UTF-8 путей. + * target проверяется, но не возвращается; семантика size/type/version и корректность страниц проверяются caller. */ fun response(data: ByteArray): Reply { require(data.size >= 141) { "file response length=${data.size}" } val b = buffer(data); val group = b.long; val server = b.long @@ -82,6 +104,8 @@ object MemberFilesProtocol { path, ByteArray(length).also { b.get(it) }) } + /** Полный payload страницы LIST: записи с заголовком 35 байт и UTF-8 name; пустая страница допустима. + * Дубликат имени внутри страницы/усечённая запись/неверное имя отклоняют всю страницу. */ fun entries(data: ByteArray): List { val b = buffer(data); val entries = mutableListOf(); val names = mutableSetOf() while (b.hasRemaining()) { @@ -96,6 +120,7 @@ object MemberFilesProtocol { return entries } + /** Ровно 51 байт: id/group/server/owner/done/total:u64, status:u16, state:u8 (1 progress, 2 finished). */ fun progress(data: ByteArray): Progress { require(data.size == 51) { "transfer length=${data.size}" } val b = buffer(data); val id = b.long; val group = b.long; val server = b.long; val owner = b.long @@ -103,6 +128,7 @@ object MemberFilesProtocol { require(id != 0L && state in 1..2 && status in 0..14 && done >= 0 && total >= 0 && done <= total) return Progress(id, group, server, owner, done, total, status, state == 2) } + /** QUOTA payload: ровно пять u64 LE, limit > 0; used может превышать limit после снижения квоты. */ fun quota(data: ByteArray): Quota { require(data.size == 40) { "quota length=${data.size}" } val b = buffer(data); val q = Quota(b.long, b.long, b.long, b.long, b.long)