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