3 changed files with 121 additions and 41 deletions
@ -0,0 +1,112 @@
|
||||
package com.utun.chat.data |
||||
|
||||
import java.nio.ByteBuffer |
||||
import java.nio.ByteOrder |
||||
import java.nio.charset.CodingErrorAction |
||||
import java.util.concurrent.atomic.AtomicLong |
||||
|
||||
/** Общий wire-кодек файлов и аватарок. UID хранится как исходные 64 бита. */ |
||||
object MemberFilesProtocol { |
||||
const val LIST = 1 |
||||
const val STAT = 2 |
||||
const val MKDIR = 8 |
||||
const val RENAME = 9 |
||||
const val REMOVE = 10 |
||||
const val QUOTA = 16 |
||||
const val FILE = 1 |
||||
const val DIRECTORY = 2 |
||||
const val STALE = 7 |
||||
const val BUSY = 6 |
||||
const val NOT_FOUND = 2 |
||||
const val CANCELLED = 12 |
||||
private val ids = AtomicLong(System.nanoTime()) |
||||
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() |
||||
|
||||
fun validPath(path: String, root: Boolean = true): Boolean { |
||||
if (path.isEmpty()) return root |
||||
if (path.toByteArray().size > 1024 || path.any { it.isSurrogate() } && |
||||
runCatching { utf8(path.toByteArray()) != path }.getOrDefault(true)) return false |
||||
val components = path.split('/') |
||||
return components.size <= 60 && components.all { name -> |
||||
name.isNotEmpty() && name.toByteArray().size <= 255 && !name.startsWith('.') && |
||||
!name.endsWith('.') && !name.endsWith(' ') && name.none { it.code < 32 || it.code == 127 || it in "\\:*?\"<>|" } && |
||||
!Regex("(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])").matches(name.substringBefore('.')) |
||||
} |
||||
} |
||||
fun servicePath(path: String) = path.substringBefore('/').equals("avatars", true) |
||||
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" } } |
||||
} |
||||
|
||||
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) |
||||
data class Entry(val name: String, val type: Int, val size: Long, val mtime: Long, val version: ByteArray) |
||||
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) |
||||
data class Quota(val used: Long, val limit: Long, val reserved: Long, val files: Long, val directories: Long) |
||||
|
||||
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)) |
||||
require(validPath(path, command in listOf(LIST, STAT, QUOTA)) && version.size == 16 && cursor >= 0) |
||||
require(if (command == RENAME) validPath(target, false) else target.isEmpty()) |
||||
val a = path.toByteArray(); val b = target.toByteArray() |
||||
return ByteBuffer.allocate(125 + a.size + b.size).order(ByteOrder.LITTLE_ENDIAN).apply { |
||||
put(4); put(1); put(command.toByte()); position(5); putLong(id); putLong(owner) |
||||
position(37); putLong(cursor); put(version); position(119) |
||||
putShort(a.size.toShort()); putShort(b.size.toShort()); putShort(0); put(a); put(b) |
||||
}.array() |
||||
} |
||||
|
||||
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 |
||||
require(b.get().toInt() == 4 && b.get().toInt() == 2) { "invalid file response protocol/kind" } |
||||
val command = b.get().toInt() and 255; val status = b.short.toInt() and 65535 |
||||
val id = b.long; val owner = b.long; b.long; val size = b.long; val cursor = b.long |
||||
val version = ByteArray(16).also { b.get(it) }; b.position(93) |
||||
val hash = ByteArray(32).also { b.get(it) }; val mtime = b.long |
||||
val type = b.get().toInt() and 255; val more = b.get().toInt() and 255 |
||||
val a = b.short.toInt() and 65535; val target = b.short.toInt() and 65535; val length = b.short.toInt() and 65535 |
||||
require(id != 0L && command in 1..16 && status in 0..14 && more in 0..1 && a <= 1024 && target <= 1024 && |
||||
length <= 8192 && b.remaining() == a + target + length) { "invalid file response header" } |
||||
val pathBytes = ByteArray(a).also { b.get(it) }; val targetBytes = ByteArray(target).also { b.get(it) } |
||||
require(0.toByte() !in pathBytes && 0.toByte() !in targetBytes) { "NUL in file path" } |
||||
val path = utf8(pathBytes); utf8(targetBytes) |
||||
return Reply(id, group, server, owner, command, status, size, mtime, hash, type, cursor, version, more != 0, |
||||
path, ByteArray(length).also { b.get(it) }) |
||||
} |
||||
|
||||
fun entries(data: ByteArray): List<Entry> { |
||||
val b = buffer(data); val entries = mutableListOf<Entry>(); val names = mutableSetOf<String>() |
||||
while (b.hasRemaining()) { |
||||
require(b.remaining() >= 35) { "truncated directory entry" } |
||||
val length = b.short.toInt() and 65535; val type = b.get().toInt() and 255 |
||||
val size = b.long; val mtime = b.long; val version = ByteArray(16).also { b.get(it) } |
||||
require(length in 1..255 && length <= b.remaining() && type in FILE..DIRECTORY && size >= 0 && mtime >= 0) |
||||
val name = utf8(ByteArray(length).also { b.get(it) }) |
||||
require(!name.contains('/') && validPath(name, false) && names.add(name)) { "invalid/duplicate directory name" } |
||||
entries += Entry(name, type, size, mtime, version) |
||||
} |
||||
return entries |
||||
} |
||||
|
||||
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 |
||||
val done = b.long; val total = b.long; val status = b.short.toInt() and 65535; val state = b.get().toInt() |
||||
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) |
||||
} |
||||
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) |
||||
require(q.used >= 0 && q.limit > 0 && q.reserved >= 0 && q.files >= 0 && q.directories >= 0) |
||||
return q |
||||
} |
||||
} |
||||
Loading…
Reference in new issue