Files
chanora/docs/architecture/file-transfer-research.md
T
Edison Jwa aa796d7395 feat: file transfer system (avatar/icon download with cacache) (#40)
* docs(architecture): add file transfer design, research, and implementation plan

* feat(cache): add chanora_cache crate with cacache-backed blob cache

- New chanora_cache crate: content-addressed blob store wrapping cacache
- BlobCache API: async put/get/remove/clear/total_size/evict
- Key validation: av_ prefix (32 hex chars), ic_ prefix (decimal digits)
- Cacache provides crash safety, SSRI integrity, content dedup
- Mtime-based eviction via cacache::list_sync + sort by timestamp
- 7 unit tests all passing
- Added to workspace members

* feat(protocol): add file download support for avatars and icons

- Add Request::DownloadFile variant with oneshot reply
- Add ProtocolClient::download_avatar(client_uid) and download_icon(icon_id)
- Track pending file downloads by FiletransferHandle
- Handle StreamItem::FileDownload: read bytes from TCP stream
- Handle StreamItem::FiletransferFailed: map to ProtocolError
- Add ProtocolError::FileTransfer(String) variant
- Add path helper tests for avatar/icon download paths
- No tsclientlib types leak across the adapter boundary

* feat(core): add blob cache wiring and avatar download orchestration

- Add chanora_cache dependency to Cargo.toml
- Add blob_cache field to ChanoraSession (Arc<Mutex<Option<BlobCache>>>)
- Add init_cache() method: creates BlobCache, runs eviction
- Add get_avatar() method: cache-first, download on miss, store in cache
- Add clear_cache() and cache_size() methods for cache management
- Add CoreError::Cache variant for BlobCacheError conversion
- Add avatar_cache integration test

* feat(bridge): add init_cache, download_avatar, and cache management functions

- Add init_cache(dir) bridge function
- Add download_avatar(avatar_hash, client_uid) bridge function
- Add clear_file_cache() and file_cache_size() bridge functions
- Map CoreError::Cache and ProtocolError::FileTransfer in BridgeError

* feat(flutter): add cache initialization wiring and avatar download shims

- Add wireCache() to app_bootstrap using getApplicationCacheDirectory()
- Call wireCache() after wireStorage() in main bootstrap flow
- Add Dart-side initCache and downloadAvatar wrapper shims in api.dart
- Update Cargo.lock for new chanora_cache dependency

* feat(core): FileTransferService with coalescing, throttling, negative cache

- New file_transfer module with FileTransferService struct
- Semaphore(2) throttles concurrent downloads
- In-flight HashMap coalesces duplicate avatar requests
- 5-min negative cache short-circuits ServerRejected misses
- ChanoraSession delegates get_avatar through the service
- connect/disconnect update shared protocol handle
- clear_cache/cache_size delegate to service
- 2 new unit tests (cached hit, negative cache)

* feat(core,bridge): add get_icon with coalescing and negative cache

- FileTransferService::get_icon() mirrors get_avatar pattern
- ChanoraSession::get_icon() delegates through FileTransferService
- Bridge download_icon() exposed for Flutter
- Dart downloadIcon() shim added
- Uses PREFIX_ICON (ic_<crc32u>) cache key format
- 1 new unit test (cached icon hit)

* fix(core,protocol): simplify store_protocol and add download size cap

- store_protocol: always write to shared Arc<Mutex<Option<ProtocolClient>>>;
  the FileTransferService holds the same Arc so it sees updates automatically
- read_download_bytes: reject downloads exceeding 10 MB to prevent
  malicious servers from causing OOM
2026-06-10 11:45:14 +09:00

771 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# File Transfer Cache Research
**Date:** 2026-06-10
**Status:** Research complete, design implications noted (updated with TeaSpeak server findings)
**Companion to:** `docs/architecture/file-transfer-design.md`
**Purpose:** Factual findings from protocol analysis, existing client implementations, and cross-platform research that inform the cache architecture decision.
---
## 1. TS3 Protocol Identity Semantics
### 1.1 Avatar Identity
| Aspect | Value |
|---|---|
| Protocol field | `client_flag_avatar` |
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:200`) |
| Meaning | MD5 hash of the avatar file bytes |
| Scope | Per-client per-server — a user can have different avatars on different servers |
| Freshness | Automatically up-to-date for any client "in view" (`FLAG_CLIENT_VIEW`) |
| Empty value | No avatar set |
**Key fact:** Identical avatar image bytes produce the **same** `client_flag_avatar` hash on any TS3 server. The hash is a content fingerprint, not a server-assigned identifier.
**Download path on server:** `/avatar_<base64HashClientUID>` — the filename is derived from the client's unique identifier (UID), not from the content hash. The content hash is communicated separately via `client_flag_avatar`.
**Sources:**
- TeaSpeakLibrary `PropertyDefinition.h:200`: `PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "", TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}`
- TS3AudioBot avatar upload: computes MD5 of image bytes, then sets `client_flag_avatar` to that hash ([`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341))
- TS3 NodeJS Library: avatar filename is `avatar_${clientBase64HashClientUID}` ([`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313))
- TS3 PHP Framework: avatar name derivation from UID ([`ts3phpframework/src/Node/Client.php:288-307`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Client.php#L288-L307))
### 1.2 Icon Identity
| Aspect | Value |
|---|---|
| Protocol fields | `channel_icon_id`, `client_icon_id`, `virtualserver_icon_id` |
| Type | `TYPE_UNSIGNED_NUMBER` (TeaSpeakLibrary `PropertyDefinition.h:83,146,217`) |
| Meaning | CRC32 (unsigned) of the icon file bytes |
| Scope | Per-entity per-server — but CRC32 is content-derived |
| Download path | `/icon_<unsigned_crc32>` |
**Key fact:** Identical icon bytes produce the **same** CRC32 on any TS3 server. The icon ID is a content fingerprint. The upload process computes `crc32.unsigned(data)` and stores at `/icon_<id>`.
**CRC32 collision caveat:** CRC32 is only 32 bits. Different icon content can theoretically produce the same CRC32. Qint's `filecache.rs` explicitly notes this: "there could be collisions because only CRC-32 is used." ForChanora's purposes (small icons, not security-critical), this is acceptable.
**Sources:**
- TeaSpeakLibrary `PropertyDefinition.h:83,146,217`: all icon IDs are `TYPE_UNSIGNED_NUMBER`
- TS3 NodeJS Library `uploadIcon()`: computes `crc32.unsigned(data)`, uploads to `/icon_<id>` ([`TS3-NodeJS-Library/src/TeamSpeak.ts:2234-2241`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/TeamSpeak.ts#L2234-L2241))
- TS3 PHP Framework: icon path uses `/icon_<unsigned id>` ([`ts3phpframework/src/Node/Node.php:137-146`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Node.php#L137-L146))
- TS3 community forum: "The filename itself is the result of the CRC32 checksum" (TeamSpeak staff)
### 1.3 Implication for Cache Design
Both avatars and icons are **content-addressed by the protocol itself**:
| Asset | Content hash source | Same content across servers? |
|---|---|---|
| Avatar | `client_flag_avatar` = MD5 of bytes | Same bytes → same hash → same ID |
| Icon | `icon_id` = CRC32 of bytes | Same bytes → same CRC32 → same ID |
This means a **flat content-addressed blob store** can achieve zero-duplication without any per-server directories, hardlinks, or ref-counting.
---
## 2. Virtual Server Identity
### 2.1 Server UID
| Aspect | Value |
|---|---|
| Protocol field | `virtualserver_unique_identifier` |
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:22`) |
| Generated by | The server instance, on creation |
| Globally unique? | **Not guaranteed** — locally generated, no central registry |
| Stable? | Yes — persists across restarts of the same virtual server |
**Key fact:** `virtualserver_unique_identifier` is generated by each TS3 server. Two physically different servers could theoretically produce the same UID. It is **not safe as a global cache key**.
### 2.2 What Chanora Currently Tracks
| Layer | Server identity fields | Source |
|---|---|---|
| Protocol adapter (`adapter.rs:1752-1755`) | `server_name`, `welcome_message`, `platform`, `version` | `state.server.*` from tsclientlib |
| DTO (`ServerSnapshot`) | `server_name`, `welcome_message`, `platform`, `version` | No UID field |
| Bridge (`BridgeSnapshot`) | Same as DTO | Same |
| Storage (bookmarks) | Keyed by `host` (hostname:port) | SQLite `WHERE host = ?1` |
| Core (recent servers) | `cfg.address` as host | Auto-saved on connect |
**Chanora does not currently plumb `virtualserver_unique_identifier` through the DTO stack.** The field exists in tsclientlib's state but is not extracted.
### 2.3 Implication for Cache Design
Using `virtualserver_unique_identifier` as the sole cache key is risky (not globally unique). Using connection address (`host:port`) is safe but duplicates cache entries when the same server is accessed via different addresses.
**Recommendation:** For a content-addressed blob store, server identity is only needed for per-server metadata (eviction, "clear cache for this server"), not for the blob key itself. The blob key is the content hash.
---
## 3. Existing TS3 Client Cache Implementations
### 3.1 Qint (tsclientlib-based, Tauri + Rust)
**Architecture:** Per-server directory with SQLite metadata.
```
<cache>/files/<server-uid>/<channel-id>/<base64(path)>
```
**Avatar handling:**
- Avatar state stored per `(server, client)` row in SQLite
- On avatar hash change, deletes the cached `/avatar_<uid>` file for that server
- Avatar download path: `/avatar_<uid_base64>`
**Icon handling:**
- Icons path-cached with CRC32
- Code comments note CRC32 collisions and freshness by mtime
- Qint explicitly deletes and re-downloads when icon mtime changes
**Dedup:** None. Same avatar on 5 servers = 5 stored copies.
**Sources:**
- [`Qint/proxy/src/filecache.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/filecache.rs#L1-L6): "Stores files transferred via the TS3 file transfer protocol. This includes icons and avatars."
- [`Qint/proxy/src/db/mod.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/db/mod.rs#L1158-L1176): avatar hash change triggers delete
- [`Qint/src-tauri/src/cmd.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/src-tauri/src/cmd.rs#L524-L539): file download command
### 3.2 TS3 Official Client (closed source)
**Architecture:** Lazy cache with SDK callbacks.
- `getAvatar()` returns cached path if present; otherwise triggers download
- `onAvatarUpdated` callback fires when avatar is downloaded or deleted
- Cache paths (from community documentation):
- Windows: `%LOCALAPPDATA%\TeamSpeak\Cache\Default`
- Linux: `~/.cache/TeamSpeak/Default`
- macOS: `~/Library/Caches/TeamSpeak/Default`
- SDK also exposes `CLIENT_MYTS_AVATAR` / `client_myteamspeak_avatar` for cross-server myTeamSpeak avatars
**Sources:**
- [`ts3client-pluginsdk/src/plugin.c`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/src/plugin.c#L384-L396): `getAvatar()` and `onAvatarUpdated`
- [`ts3client-pluginsdk/public_rare_definitions.h`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/include/teamspeak/public_rare_definitions.h#L284-L313): `CLIENT_FLAG_AVATAR`, `CLIENT_MYTS_AVATAR`
- Community: [clear cache](https://community.teamspeak.com/t/clear-cache/41511), [broken icons](https://community.teamspeak.com/t/server-icons-are-displaying-a-broken-image-issues-with-local-cache/58680)
### 3.3 TeaSpeak Client (TypeScript + C++ native)
**Architecture:** Browser Cache API for images, per-server own-avatar storage.
**Key components (from `.d.ts` type declarations):**
- `AvatarManager` — per-connection (`FileManager`) avatar handler
- `cachedAvatars` (private) — in-memory cache of `ClientAvatar` objects
- `updateCache(clientAvatarId, clientAvatarHash)` — updates cache when hash changes
- `resolveAvatar(clientAvatarId, avatarHash?, cacheOnly?)` — resolves avatar by ID
- `flush_cache()` — clears cache
- `create_avatar_download(client_avatar_id)` — initiates file transfer
- `ClientAvatar` — tracks individual avatar state
- `clientAvatarId` — derived from client UID via `uniqueId2AvatarId()`
- `currentAvatarHash` — the `client_flag_avatar` value
- State machine: `unset``loading``loaded` / `errored`
- `loadingTimestamp` — when download started
- `ImageCache` — generic image cache using browser Cache API
- `resolveCached(key, maxAge?)` — check if cached
- `putCache(key, value, type?, headers?)` — store
- `cleanup(maxAge)` — evict old entries
- `reset()` — clear all
- `isPersistent()` — whether cache persists to disk
- `OwnAvatarStorage` — user's own avatar, keyed by `serverUniqueId + mode`
- `loadAvatarImage(serverUniqueId, mode)` — load own avatar for a server
- `updateAvatar(serverUniqueId, mode, target)` — update own avatar
- `avatarUploadSucceeded(serverUniqueId)` — move from "uploading" to "server" state
- Stores `LocalAvatarInfo`: fileName, fileSize, **fileHashMD5**, timestamps, contentType
- `FileManager` — per-connection file transfer manager
- `MAX_CONCURRENT_TRANSFERS` — transfer concurrency limit
- `avatars: AvatarManager` — avatar subsystem
- `initializeFileDownload(options)` — start download (path, name, channel, target)
- `deleteIcon(iconId: number)` — delete icon by ID
- `FileTransfer` — transfer state machine
- States: `PENDING → INITIALIZING → CONNECTING → RUNNING → FINISHED / ERRORED / CANCELED`
- `InitializedTransferProperties`: serverTransferId, transferKey, **addresses[]**, protocol, seekOffset, fileSize
- Multiple addresses returned by server for file transfer (failover)
- `localIconCache: ImageCache` — global icon cache (singleton)
**Sources:**
- TeaSpeak-Client `imports/shared-app/file/Avatars.d.ts` — ClientAvatar, AbstractAvatarManager
- TeaSpeak-Client `imports/shared-app/file/LocalAvatars.d.ts` — AvatarManager
- TeaSpeak-Client `imports/shared-app/file/LocalIcons.d.ts` — localIconCache
- TeaSpeak-Client `imports/shared-app/file/ImageCache.d.ts` — ImageCache (browser Cache API)
- TeaSpeak-Client `imports/shared-app/file/FileManager.d.ts` — FileManager, transfer API
- TeaSpeak-Client `imports/shared-app/file/Transfer.d.ts` — FileTransfer, state machine, error types
- TeaSpeak-Client `imports/shared-app/file/OwnAvatarStorage.d.ts` — own avatar per-server storage
- TeaSpeak-Client `native/serverconnection/test/js/ft.ts` — file transfer test (TCP + ftkey protocol)
### 3.4 TS3AudioBot (C#)
**Architecture:** No local avatar cache. Avatar upload is hash-driven.
- Uploads avatar bytes to `/avatar`, computes MD5, sets `client_flag_avatar` to that hash
- Bot avatar selection reads local files from an `avatars/` directory
- No caching of other users' avatars
**Sources:**
- [`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341)
- [`TS3AudioBot/Bot.cs:420-470`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TS3AudioBot/Bot.cs#L420-L470)
### 3.5 TS3 NodeJS Library
**Architecture:** No local cache. Downloads on demand.
- Avatar filename: `avatar_${clientBase64HashClientUID}`
- `getAvatar()` downloads directly — no caching layer
- Tests assert the exact `/avatar_<base64uid>` path
**Sources:**
- [`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313)
- [`TS3-NodeJS-Library/tests/Client.spec.ts:342-358`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/tests/Client.spec.ts#L342-L358)
### 3.6 Summary Table
| Client | Cache Key Strategy | Dedup Across Servers? | Icon Cache |
|---|---|---|---|
| **Qint** | `<server-uid>/<channel-id>/<path>` | No | Yes (CRC32, mtime freshness) |
| **TS3 Official** | Lazy cache (path-based) | Unknown | Yes |
| **TeaSpeak** | UID-derived avatar ID + browser Cache API | Implicit (same hash = same cache) | Yes (global ImageCache) |
| **TS3AudioBot** | None | N/A | No |
| **TS3 NodeLib** | None | N/A | No |
| **Chanora (decided)** | Content hash (MD5/CRC32), flat `blobs/` in `chanora_cache` crate | **Yes** | Yes |
---
## 4. TeaSpeak Protocol Definitions (Authoritative)
From TeaSpeakLibrary `src/PropertyDefinition.h` — the most complete open-source reference for TS3 protocol property types:
### 4.1 Avatar Properties
```cpp
// Line 200
PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "",
TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}
// "automatically up-to-date for any manager 'in view', this manager got an avatar"
```
### 4.2 Icon Properties
```cpp
// Line 83 — server icon
PropertyDescription{VIRTUALSERVER_ICON_ID, "virtualserver_icon_id", "0",
TYPE_UNSIGNED_NUMBER, FLAG_SERVER_VVSS | FLAG_USER_EDITABLE}
// Line 146 — channel icon
PropertyDescription{CHANNEL_ICON_ID, "channel_icon_id", "0",
TYPE_UNSIGNED_NUMBER, FLAG_CHANNEL_VIEW | FLAG_SS | FLAG_USER_EDITABLE}
// Line 217 — client icon
PropertyDescription{CLIENT_ICON_ID, "client_icon_id", "0",
TYPE_UNSIGNED_NUMBER, FLAG_CLIENT_VIEW | FLAG_CLIENT_VARIABLE}
```
### 4.3 Server Identity
```cpp
// Line 22
PropertyDescription{VIRTUALSERVER_UNIQUE_IDENTIFIER,
"virtualserver_unique_identifier", "",
TYPE_STRING, FLAG_SERVER_VV | FLAG_SNAPSHOT}
```
### 4.4 File Transfer Permissions
```cpp
// From PermissionManager.cpp
PermissionType::i_client_max_avatar_filesize // "Max avatar filesize in bytes"
PermissionType::b_client_avatar_delete_other // "Allow deletion of avatars from other clients"
PermissionType::b_ft_transfer_list // "Retrieve list of running filetransfers"
```
### 4.5 File Transfer Error Codes
```cpp
// From Error.h
channel_no_filetransfer_supported = 0x30C
file_transfer_connection_timeout = 0x80E
file_transfer_complete = 0x811
file_transfer_canceled = 0x812
file_transfer_interrupted = 0x813
file_transfer_server_quota_exceeded = 0x814
file_transfer_client_quota_exceeded = 0x815
file_transfer_reset = 0x816
file_transfer_limit_reached = 0x817
```
---
## 5. Cross-Platform Filesystem Research
### 5.1 Hardlink Support
| Platform | Filesystem | Hardlinks in App-Private Storage? | Gotcha |
|---|---|---|---|
| Android (API 28+) | ext4 / f2fs | **Yes** | Rust uses `libc::link`; not FUSE-mounted; same-filesystem only |
| iOS | APFS | **Yes** (writable sandbox dirs) | App bundle is read-only; avoid hardlinks to bundle assets |
| macOS | APFS | **Yes** | — |
| Linux | ext4 / btrfs / xfs | **Yes** | — |
| Windows | NTFS | **Yes** | Rust uses `CreateHardLinkW` |
**`std::fs::hard_link` gotchas (all platforms):**
- Same filesystem required
- Destination must not exist (returns error)
- Symlink behavior is platform-specific
- All hardlinks share the same inode — modifying one modifies all
- Files must be treated as **immutable** for hardlink safety
**Sources:**
- Rust stdlib: `hard_link` maps to `libc::link` (Unix), `CreateHardLinkW` (Windows) ([Rust source](https://github.com/rust-lang/rust/blob/beae781308e9ddef13074a03faf57ca2fac59a5b/library/std/src/fs.rs#L2898-L2900))
- Android: internal storage uses ext4/f2fs, not FUSE ([Android scoped storage docs](https://source.android.com/docs/core/storage/scoped))
- iOS: APFS supports hardlinks; writable sandbox directories work ([Apple FileSystem basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html))
### 5.2 Rust Cache Libraries
**`cacache`** (MIT licensed, production-ready):
- Content-addressed disk cache
- Automatic dedup, atomic writes, integrity verification
- Exposes `hard_link`, `copy`, and `reflink` paths for retrieval
- On-disk layout: `content-v2/sha512/...`
- Could replace a custom implementation, but adds a dependency
**Sources:**
- [`cacache-rs` README](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/README.md#L39-L60)
- [`cacache-rs` content path](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/path.rs#L6-L19)
- [`cacache-rs` hard_link impl](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/read.rs#L257-L285)
### 5.3 Flutter Cache Patterns
Common Flutter packages use **cache-dir + metadata DB**, not hardlink dedup:
- `flutter_cache_manager`: files in cache dir + `sqflite` metadata
- `super_cache_disk`: file-per-entry (`.dat` + `.meta`) in app cache dir
**Sources:**
- [flutter_cache_manager on pub.dev](https://pub.dev/packages/flutter_cache_manager)
- [super_cache_disk on pub.dev](https://pub.dev/packages/super_cache_disk/versions/1.0.0)
---
## 6. Chanora Codebase Context
### 6.1 Storage Patterns
| Component | Pattern | Location |
|---|---|---|
| Identity storage | Atomic write (temp + `sync_all` + `rename`), mode 0600 on Unix | `chanora_storage/src/lib.rs:446-476` |
| Metadata | Same atomic write pattern | `chanora_storage/src/lib.rs:480-515` |
| Bookmarks | SQLite at `<storage_dir>/chanora.db`, keyed by `host` | `chanora_storage/src/lib.rs:782-928` |
| Storage root | `getApplicationSupportDirectory()` from Flutter | `app_bootstrap.dart:19-24` |
| Bridge init | `rust.initStorage(dir: dir)` | `app_bootstrap.dart:23-25,108-133` |
### 6.2 Existing Avatar Handling
| Component | What | Location |
|---|---|---|
| UID to avatar path | `uid_to_avatar_path()` — base64 decode UID, encode each byte as 2 chars (a-p) | `adapter.rs:1561-1568` |
| Client profile DTO | `avatar_path` field — set when `client.avatar_hash` and `unique_id` non-empty | `dto.rs:135-136`, `adapter.rs:1323-1332` |
| No download | Currently no file download implementation exists | — |
| No icon handling | No icon field/path in protocol DTO or adapter | — |
### 6.3 Server Identity in Chanora
Chanora currently tracks servers by **connection address** (`host:port`), not by server UID:
- Bookmarks: `WHERE host = ?1`
- Recent servers: auto-saved by `cfg.address`
- Prefetch cache: keyed by normalized host
- ServerSnapshot: has `server_name` but no `server_uid`
The `virtualserver_unique_identifier` field is available from tsclientlib's state but is **not extracted** by the adapter.
### 6.4 tsclientlib File Transfer API
| Aspect | Detail |
|---|---|
| Download method | `Connection::download_file()` |
| Stream items | `StreamItem::FileDownload(FileDownloadResult { size, stream })` |
| Failure | `StreamItem::FiletransferFailed(handle, error)` |
| TCP handling | tsclientlib handles TCP connection + ftkey writing automatically |
| Chanora's job | Read `size` bytes from the returned `TcpStream` |
| Async behavior | `StreamItem::FileDownload` fires asynchronously, not inline with the request |
### 6.5 ts-bookkeeping Generated Fields
From the generated parser in `target/debug/build/ts-bookkeeping-*/out/`:
- `virtual_server_id: u64` — numeric, per-virtual-server, may change across restarts
- `virtual_server_uid` — string, the `virtualserver_unique_identifier`
Both are available in the `InInitServer` struct from the init handshake but are not currently plumbed through.
---
## 7. TeaSpeak Server Internals (Authoritative)
Source: TeaSpeak Server at `https://git.did.science/TeaSpeak/Server/Server` (branch `new-groups`, commit `b54c6d4e`).
### 7.1 Avatar ID Derivation — Server Side
The server derives the avatar filename from the **client UID**, not from the avatar content:
```cpp
// DataClient.cpp:242-244
std::string DataClient::getAvatarId() {
return hex::hex(base64::validate(this->getUid()) ? base64::decode(this->getUid()) : this->getUid(), 'a', 'q');
}
```
The same transform produces `client_base64HashClientUID` (shown to other clients):
```cpp
// client.cpp:1113-1114
bulk.put_unchecked("client_base64HashClientUID",
hex::hex(base64::validate(info->client_unique_id) ? base64::decode(info->client_unique_id) : info->client_unique_id, 'a', 'q'));
```
**This matches Chanora's existing `uid_to_avatar_path()` in `adapter.rs:1561-1568`.**
### 7.2 Avatar Upload Path
When a client uploads an avatar, the server stores it as `/avatar_<avatarId>`:
```cpp
// file.cpp:696-702
} else if (cmd["path"].as<std::string>().empty() && cmd["name"].string() == "/avatar") {
...
info.file_path = "/avatar_" + this->getAvatarId();
transfer_response = file::server()->file_transfer().initialize_avatar_transfer(...);
}
```
The avatar file path is identity-based (from UID), not content-based.
### 7.3 `client_flag_avatar` — Who Computes the Hash?
**The CLIENT computes the MD5 and sends it to the server during upload.** The server stores it as a string property (`FLAG_USER_EDITABLE`). The server does NOT compute or verify the hash.
This means `client_flag_avatar` is:
- Set by the uploading client
- Stored verbatim by the server
- Broadcast to other clients as part of the client properties
- A reliable content fingerprint: same avatar bytes → same MD5 → same `client_flag_avatar` on any server
### 7.4 Icon IDs — Server Does NOT Compute CRC32
Icon IDs are **permission values**, not content hashes computed by the server:
```cpp
// ConnectedClient.cpp:186-210 — client icon ID from permissions
auto permission_flags = local_permissions->permission_flags(permission::i_icon_id);
new_icon_id = value.value;
updated_client_properties.emplace_back(property::CLIENT_ICON_ID);
```
```cpp
// channel.cpp:1495-1504 — channel icon ID
if(key == property::CHANNEL_ICON_ID) {
auto icon_id = converter<uint32_t>::from_string_view(value);
channel->permissions()->set_permission(permission::i_icon_id, { ... icon_id ... });
}
```
```cpp
// server.cpp:76-89 — server icon ID
SERVEREDIT_CHK_PROP_CACHED("virtualserver_icon_id", permission::b_virtualserver_modify_icon_id, int64_t)
```
**The CLIENT computes the CRC32 during upload and uses it as the filename `/icon_<crc32>`.** The server stores the file and records the ID as a permission value. No server-side CRC32 or MD5 computation exists.
### 7.5 Per-Server Storage Layout
Avatars and icons are stored **per virtual server** on the server's filesystem:
```cpp
// LocalFileSystem.cpp:39-45
fs::path LocalFileSystem::server_path(const std::shared_ptr<VirtualFileServer> &server) {
return fs::u8path(this->root_path_) / fs::u8path("server_" + std::to_string(server->server_id()));
}
// target_path = this->server_path(server) / "icons" / path;
// target_path = this->server_path(server) / "avatars" / path;
```
```
<server_root>/
server_<sid>/
avatars/
/avatar_<avatarId> ← one per client who uploaded
icons/
/icon_<id> ← one per unique icon
```
### 7.6 File Transfer Protocol (Server Side)
Upload/delete/query routing:
```cpp
// file.cpp:273-341 — delete routing
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... delete_icons(...); }
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... delete_avatars(...); }
```
```cpp
// file.cpp:483-523 — query routing
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... query_icon_info(...); }
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... query_avatar_info(...); }
```
Transfer initialization returns ftkey and metadata:
```cpp
// file.cpp:759-761
result.put_unchecked(0, "ftkey", transfer->transfer_key);
result.put_unchecked(0, "seekpos", transfer->file_offset);
```
```cpp
// file.cpp:887-899
result.put_unchecked(0, "ftkey", transfer->transfer_key);
result.put_unchecked(0, "proto", "1");
result.put_unchecked(0, "size", transfer->expected_file_size);
```
### 7.7 Key Takeaway for Chanora
| Who does what | Avatar | Icon |
|---|---|---|
| **Uploader (client)** computes | MD5 of avatar bytes → sets `client_flag_avatar` | CRC32 of icon bytes → filename `/icon_<crc32>` |
| **Server** does | Stores file as `/avatar_<uid>`, saves property | Stores file as `/icon_<id>`, saves permission |
| **Other clients** receive | `client_flag_avatar` (MD5) as a property update | `icon_id` (CRC32) as a property update |
| **Chanora cache key** | `av_<md5>.dat` — content fingerprint | `ic_<crc32>.dat` — content fingerprint |
The content hash is computed once (by the uploader) and then broadcast as a property. Chanora never needs to hash anything — it just uses the protocol-provided values as cache keys.
---
## 8. Design Implications
### 8.1 The Core Insight
The TS3 protocol provides content hashes as part of normal server-to-client updates:
| Event | Data provided by server | What Chanora gets for free |
|---|---|---|
| Client enters view | `client_flag_avatar` = MD5 of avatar bytes | Content key for blob store |
| Channel update | `channel_icon_id` = CRC32 of icon bytes | Content key for blob store |
| Server update | `virtualserver_icon_id` = CRC32 of icon bytes | Content key for blob store |
No hashing needed on the client side. The protocol is **already content-addressed**.
### 8.2 Recommended Cache Architecture
```
<app_cache_dir>/chanora/
blobs/ ← cacache content store root
content-v2/ ← content-addressed by SHA-512
<sha512-hex>/data ← raw blob bytes
index-v2/ ← key → content mapping
```
Where `<app_cache_dir>` is the platform cache directory (not the support directory used by `chanora_storage`). Chanora's `BlobCache` maps protocol keys (`av_<md5>`, `ic_<crc32>`) to `cacache` string keys. Physical layout is managed by `cacache`.
**Lookup flow:**
1. Server sends `client_flag_avatar = "a1b2c3d4..."` for user X
2. Check: does `blobs/av_a1b2c3d4....dat` exist?
3. Yes → use it, zero downloads (works for ANY server)
4. No → download from `/avatar_<uid_base64>` → save as `blobs/av_a1b2c3d4....dat`
**Same for icons with `ic_<crc32>.dat`.**
### 8.3 Why This Beats Alternatives
| Approach | Dedup | Globally unique key | Needs server UID plumbing | Needs hardlinks | Complexity |
|---|---|---|---|---|---|
| `<server_uid>/<hash>.dat` | No | No (UID not guaranteed unique) | Yes | Optional | Medium |
| `<host>_<port>/<hash>.dat` | No | Yes | No | Optional | Medium |
| `<host>_<port>/<hash>.dat` + hardlinks | Yes | Yes | No | Yes | Medium-High |
| **`blobs/av_<hash>.dat` (flat)** | **Yes** | **Yes (content hash)** | **No** | **No** | **Low** |
### 8.4 Trade-offs
| Pro | Con |
|---|---|
| Zero duplication across all servers | "Clear cache for server X only" requires metadata layer (Phase 3+) |
| No hardlinks needed | Orphan cleanup requires scanning for unreferenced blobs |
| No server UID plumbing needed | Cannot distinguish same-hash-different-content for icons (CRC32 collision) |
| Simplest possible implementation | — |
| Freshness = hash change = different filename (automatic) | — |
| Cross-platform (just file I/O) | — |
### 8.5 Phased Implementation
| Phase | What | Delivers |
|---|---|---|
| 1 | Protocol download (raw bytes via adapter, no cache) | Working download pipeline |
| 2 | Bridge + Flutter display (`Image.memory()`) | Visible avatars in UI |
| 3 | `chanora_cache` crate: cacache-backed blob cache, separate crate, cache dir, mtime eviction | Zero re-downloads, zero duplication |
| 4 | Session orchestration (coalescing, rate limiting, negative cache) | Anti-flood, robustness |
| 5 | Cache management (clear all, orphan cleanup, optional per-server metadata) | User control |
---
## 9. Resolved Questions
### Q1: Icon CRC32 Collisions — Accept with Size Guard
**Risk assessment:** CRC32 produces a 32-bit hash. For N unique icons, the Birthday paradox gives collision probability ≈ N² / (2 × 2³²).
| Icons (N) | Collision probability |
|---|---|
| 100 | ~0.0001% (negligible) |
| 1,000 | ~0.01% (negligible) |
| 10,000 | ~1.2% (marginal) |
| 65,536 | ~50% (likely) |
A single user typically encounters fewer than 1,000 unique icons across all servers. The practical collision risk is negligible.
**What happens on collision:** Wrong icon displayed for a channel/client/server. This is a visual glitch, not a security issue. The icon will appear incorrect until the cache is cleared.
**Existing practice:** Qint explicitly notes CRC32 collisions (`filecache.rs:4`) but does NOT guard against them — they only refresh icons by mtime. No other TS3 client guards against CRC32 collisions.
**Recommendation:** Accept CRC32 as the cache key. Add a lightweight **file size guard**: when downloading an icon, if `ic_<crc32>.dat` already exists but has a different size than the `ftinitdownload` response reported, re-download. File size is available from the protocol (`msg.size` in `InFileDownloadPart`). This catches most collisions (different content = different size with high probability) without computing a secondary hash.
**Decision:** CRC32 + file size guard. No SHA256 overhead needed.
---
### Q2: `client_myteamspeak_avatar` — Defer Indefinitely
**What it is:** A string property (`Option<String>` in ts-bookkeeping) broadcast alongside `client_flag_avatar`. It represents a myTeamSpeak cross-server avatar — a user linked to a myTeamSpeak account can set a global avatar that follows them across all servers.
**Current state in Chanora's dependency chain:**
- ts-bookkeeping exposes it: `InInitServer` has `my_team_speak_avatar: Option<String>`
- TeaSpeakLibrary tracks `client_myteamspeak_id` but not the avatar
- tsclientlib exposes it as a property on client state
**Value for Chanora:**
- myTeamSpeak is a TeamSpeak-specific cloud service (account sync, cross-server features)
- Chanora is an independent client — no myTeamSpeak account integration is planned
- The property may contain a URL or identifier that requires myTeamSpeak API access to resolve
- Without myTeamSpeak integration, the avatar cannot be fetched
**Recommendation:** Defer indefinitely. If Chanora ever integrates myTeamSpeak accounts, this can be handled as a separate avatar source (URL-based HTTP download) alongside the existing protocol-based avatar download. The cache architecture supports this — just add a different blob prefix (e.g., `mt_<hash>.dat`).
**Decision:** Out of scope for MVP and foreseeable roadmap.
---
### Q3: Cache Backing Store — `cacache` Wrapper in Separate `chanora_cache` Crate
**Decision:** Use `cacache` as the backing store inside `chanora_cache`. Not a custom flat-file implementation.
**Why cacache won over custom:**
1. **Crash safety is production-tested.** `cacache` handles partial writes, power loss, crash mid-write. A custom implementation would need to get `sync_all` + atomic rename right — one bug = corrupted cache. Even though cache data is disposable (reconstructible from server), `cacache` eliminates this entire class of bugs.
2. **Less code to maintain.** ~120 LOC wrapper vs ~200 LOC custom implementation. The hard parts (atomic writes, integrity, content dedup) are owned by `cacache`, tested by the npm ecosystem.
3. **Integrity verification on every read.** SSRI verification detects corruption, bit rot, partial writes automatically. A custom impl would need to add this separately or accept silent corruption.
4. **Content dedup by SHA-512.** Same avatar on two servers = stored once automatically. The protocol's MD5/CRC32 keys map to `cacache` string keys; content dedup happens at the SHA-512 layer underneath.
**What about the downsides:**
| Concern | Assessment |
|---|---|
| ~6 transitive deps | `sha2` already in tree via `chacha20poly1305`. `serde_json`, `tempfile`, `digest` are lightweight. Acceptable for the safety benefit. |
| SHA-512 overhead on every write/read | For <100KB avatars, SHA-512 takes ~0.1ms. Negligible. |
| Opaque on-disk format | `cacache` provides `ls()` API for enumeration and inspection. Not as simple as `ls blobs/` but adequate. |
| `cacache` has no built-in LRU eviction | We write a custom eviction pass using `cacache::ls()` + timestamp sort. ~20 lines. Same complexity as custom impl's eviction. |
**Separate crate rationale:**
- `chanora_cache` is separate from `chanora_storage` because cache data has different durability semantics (disposable vs persistent), different backup semantics (excluded vs included), and different directory placement (cache dir vs support dir).
- `chanora_cache` lives in the platform's cache directory (`getApplicationCacheDirectory()`). `chanora_storage` lives in the support directory (`getApplicationSupportDirectory()`).
- Bridge init is separate: `init_cache(cache_dir)` vs `init_storage(support_dir)`.
**API design:**
```rust
pub struct BlobCache { cache_dir: PathBuf, max_bytes: u64 }
impl BlobCache {
pub fn new(cache_dir: impl AsRef<Path>, max_bytes: u64) -> Result<Self, BlobCacheError>;
pub async fn put(&self, prefix: &str, key: &str, data: &[u8]) -> Result<(), BlobCacheError>;
pub async fn get(&self, prefix: &str, key: &str) -> Result<Option<Vec<u8>>, BlobCacheError>;
pub async fn remove(&self, prefix: &str, key: &str) -> Result<(), BlobCacheError>;
pub async fn clear(&self) -> Result<(), BlobCacheError>;
pub async fn total_size(&self) -> Result<u64, BlobCacheError>;
pub async fn evict(&self) -> Result<(), BlobCacheError>;
}
```
All methods are async (cacache is async-native). Key validation at API boundary (`av_` = 32 hex chars, `ic_` = decimal digits).
---
### Q4: Per-Blob Metadata — No Metadata Sidecars (Resolved)
**Original options:**
| Approach | Pros | Cons |
|---|---|---|
| SQLite (chanora.db) | ACID, queryable, already in use | Schema migration, couples cache to bookmark DB |
| JSON sidecar files | Simple, self-contained, easy to debug | Write amplification (2 files per blob), concurrent write risk |
| In-memory only | Simplest | Lost on restart, can't do orphan cleanup offline |
| **No metadata (mtime-based)** | **Simplest, zero write amplification, 1 file per blob** | **No per-blob metadata beyond mtime** |
**Why no metadata is sufficient:**
1. **Content is immutable.** A given hash (MD5 or CRC32) always maps to the same bytes. There is no "stale content" problem — if the hash changes, it's a new file with a new name. No invalidation needed.
2. **mtime = insertion time.** Since content is never modified after write, the filesystem mtime equals the time the blob was cached. This is sufficient for "delete oldest files first" eviction.
3. **Write amplification avoided.** One file per blob (just the data) instead of two (data + JSON sidecar). For a cache that may hold thousands of small files, this matters.
4. **Eviction is simple.** `walk dir → stat → sort by mtime → delete oldest`. No JSON parsing, no schema, no migration.
5. **Per-server metadata deferred.** "Clear cache for server X only" and orphan cleanup are post-MVP features. If needed, a refs-layer can be added later without changing the blob layout.
**Oracle consultation:** Oracle recommended this approach explicitly — no metadata files, mtime-based eviction, separate crate. The immutability guarantee makes metadata redundant.
**Decision:** No metadata sidecars. One file per blob. Mtime-based eviction. Per-server metadata deferred to post-MVP.
---
### Q5: File Transfer Address Failover — Not Needed
**What the protocol provides:**
The TeaSpeak client's `InitializedTransferProperties` returns `addresses[]` — an array of `{serverAddress, serverPort}`. The official TS3 client can try multiple addresses for failover.
**What tsclientlib provides:**
```rust
// tsclientlib/src/lib.rs:1373-1375
let ip = msg.ip.unwrap_or_else(|| self.client.address.ip());
let addr = SocketAddr::new(ip, msg.port);
TcpStream::connect(&addr).await
```
tsclientlib's `InFileDownloadPart` has `ip: Option<IpAddr>`**single IP only**, not an array. If the server provides an IP, it uses that. Otherwise, it falls back to the connection address. **No multi-address failover.**
**What ts-bookkeeping parses:**
```rust
pub struct InFileDownloadPart {
pub client_filetransfer_id: u16,
pub server_filetransfer_id: u16,
pub filetransfer_key: String,
pub port: u16,
pub size: u64,
pub protocol: u8,
pub ip: Option<IpAddr>, // ← single optional IP
}
```
**The server's `notifystartdownload` response** sends `ip` as an optional single value, not an array. The TeaSpeak client's `addresses[]` is a higher-level abstraction (likely the client's own fallback logic), not a protocol feature.
**Recommendation:** Chanora follows tsclientlib's existing behavior — use `msg.ip` or fallback to connection address. No custom failover logic needed. If the TCP connection fails, the download fails and retries follow the exponential backoff strategy from the design doc.
**Decision:** Single address (from tsclientlib). No failover needed.