feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
+186 -107
View File
@@ -1,15 +1,15 @@
//! # `chanora_storage`
//!
//! Two strictly separated repositories per SAD-067:
//! Two strictly separated storage concerns per SAD-067:
//!
//! * [`LocalDatabaseRepository`] — non-secret state (bookmarks,
//! settings, identity *references*) via SQLite. Crate choice:
//! `rusqlite` bundled (DEC-013.1). **Not yet implemented** —
//! `poc/sqlite-storage-spike` lands in v0.4.
//! * [`SecretStorageRepository`] — secret material (identity private
//! keys, server passwords) via platform secure storage. Linux
//! policy: Secret Service preferred, kernel keyutils fallback
//! (DEC-013.2). Other platforms TBD per SS-TC-001/002/004/005.
//! * [`BookmarkRepository`] — non-secret bookmark state via SQLite
//! with optional encrypted password fields. Crate choice:
//! `rusqlite` bundled (DEC-013.1).
//! * [`IdentityFileStore`] — Beta fallback storage for identity
//! material while the platform secure-storage backends mature.
//! Linux policy remains Secret Service preferred, kernel keyutils
//! fallback (DEC-013.2). Other platforms TBD per
//! SS-TC-001/002/004/005.
//!
//! Secret values **never** appear in the local DB (SS-AUD-001/002);
//! bookmarks store only an `identity_ref` lookup name into the
@@ -48,7 +48,7 @@ use std::sync::Mutex;
use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{info, warn};
@@ -79,16 +79,6 @@ pub enum StorageError {
Crypto(String),
}
/// Marker trait for the non-secret database side. Concrete impl will
/// land alongside the `BookmarkRepository` / `SettingsRepository`
/// traits promoted from the SQLite PoC.
pub trait LocalDatabaseRepository: Send + Sync {}
/// Marker trait for the platform secure-storage side. Concrete impl
/// will land alongside the `Secret` newtype + per-platform adapters
/// promoted from the secure-storage PoC.
pub trait SecretStorageRepository: Send + Sync {}
/// Audio-related per-identity settings persisted alongside the
/// identity file as a small JSON blob (SDD-095 / SDD-096). These
/// are *not* secrets; they sit beside the encrypted identity in
@@ -118,6 +108,22 @@ struct AudioMeta {
ptt_key_label: String,
}
/// Persisted PTT binding metadata.
///
/// `input_class` is a stable privacy-safe category string, `platform_key`
/// is the opaque identifier consumed by the platform backend, and
/// `key_label` is the display-only label shown in the UI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttBindingMeta {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Opaque platform key identifier.
pub platform_key: String,
/// Display-only key label.
pub key_label: String,
}
fn default_release_tail_ms() -> u32 {
200
}
@@ -214,59 +220,57 @@ impl IdentityFileStore {
/// Honours `CHANORA_DISABLE_KEYRING=1` for tests and headless
/// environments where a real Secret Service call would block on
/// a missing D-Bus session.
#[allow(unused_variables)]
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
if keyring_disabled() {
return Ok(None);
}
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file");
return Ok(None);
}
};
match entry.get_password() {
Ok(b64) => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
if bytes.len() != 32 {
return Err(StorageError::Crypto(format!(
"keyring dek length {} (expected 32)",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(Some(key))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
// Bus unreachable, no session, locked keychain
// — best-effort: fall through to file.
warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file");
Ok(None)
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file");
return Ok(None);
}
};
match entry.get_password() {
Ok(b64) => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
if bytes.len() != 32 {
return Err(StorageError::Crypto(format!(
"keyring dek length {} (expected 32)",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(Some(key))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
// Bus unreachable, no session, locked keychain
// — best-effort: fall through to file.
warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file");
Ok(None)
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
{
Ok(None)
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
Ok(None)
}
/// Persist the DEK in the platform keyring. Returns true on
@@ -274,44 +278,42 @@ impl IdentityFileStore {
/// should then fall back to the file path).
///
/// Honours `CHANORA_DISABLE_KEYRING=1`.
#[allow(unused_variables)]
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
fn keyring_save(&self, key: &[u8; 32]) -> bool {
if keyring_disabled() {
return false;
}
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(_) => return false,
};
let b64 = base64::engine::general_purpose::STANDARD.encode(key);
match entry.set_password(&b64) {
Ok(()) => {
info!(target: "chanora_storage", "DEK stored in platform keyring");
true
}
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file");
false
}
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(_) => return false,
};
let b64 = base64::engine::general_purpose::STANDARD.encode(key);
match entry.set_password(&b64) {
Ok(()) => {
info!(target: "chanora_storage", "DEK stored in platform keyring");
true
}
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file");
false
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
{
false
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
fn keyring_save(&self, _key: &[u8; 32]) -> bool {
false
}
fn ensure_dek(&self) -> Result<(), StorageError> {
@@ -562,12 +564,14 @@ impl IdentityFileStore {
self.write_meta(&m)
}
/// Read the persisted PTT binding. Returns
/// `(input_class, platform_key, key_label)` with empty strings
/// meaning "no binding".
pub fn get_ptt_binding(&self) -> (String, String, String) {
/// Read the persisted PTT binding. Empty strings mean "no binding".
pub fn get_ptt_binding(&self) -> PttBindingMeta {
let m = self.read_meta();
(m.ptt_input_class, m.ptt_platform_key, m.ptt_key_label)
PttBindingMeta {
input_class: m.ptt_input_class,
platform_key: m.ptt_platform_key,
key_label: m.ptt_key_label,
}
}
/// Remove any persisted identity. No-op if none exists. Leaves
@@ -874,6 +878,49 @@ impl BookmarkRepository {
Ok(conn.last_insert_rowid())
}
/// Insert or update a bookmark identified by host. If a row
/// with the same host exists, update connection fields while
/// preserving the user-facing display name; otherwise insert.
/// Returns the row id.
pub fn upsert_or_add(&self, b: &Bookmark) -> Result<i64, StorageError> {
let conn = self
.conn
.lock()
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
let blob = match (&self.crypto, &b.password) {
(Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?),
_ => None,
};
let plain: Option<&str> = if self.crypto.is_some() {
None
} else {
b.password.as_deref()
};
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM bookmarks WHERE host = ?1",
params![b.host],
|row| row.get(0),
)
.optional()
.map_err(|e| StorageError::Sqlite(format!("select: {e}")))?;
if let Some(id) = existing {
conn.execute(
"UPDATE bookmarks SET nickname = ?1, password = ?2, password_blob = ?3 WHERE id = ?4",
params![b.nickname, plain, blob, id],
)
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
Ok(id)
} else {
conn.execute(
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
params![b.display_name, b.host, b.nickname, plain, blob],
)
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
Ok(conn.last_insert_rowid())
}
}
/// Replace an existing bookmark identified by `id`. Errors with
/// [`StorageError::NotFound`] if no such row exists. Honours the
/// password-column encryption setting and clears the legacy
@@ -1067,6 +1114,38 @@ mod tests {
assert!(repo.list().unwrap().is_empty());
}
#[test]
fn bookmark_upsert_preserves_existing_display_name() {
force_keyring_off();
let tmp = tempdir();
let repo = BookmarkRepository::new(&tmp).unwrap();
let id = repo
.add(&Bookmark {
id: 0,
display_name: "my custom title".to_string(),
host: "cn.teamspeak.app".to_string(),
nickname: "old nick".to_string(),
password: None,
})
.unwrap();
let upserted = repo
.upsert_or_add(&Bookmark {
id: 0,
display_name: "live server name".to_string(),
host: "cn.teamspeak.app".to_string(),
nickname: "new nick".to_string(),
password: Some("pw".to_string()),
})
.unwrap();
assert_eq!(upserted, id);
let rows = repo.list().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].display_name, "my custom title");
assert_eq!(rows[0].nickname, "new nick");
assert_eq!(rows[0].password.as_deref(), Some("pw"));
}
#[test]
fn bookmark_update_missing_is_notfound() {
force_keyring_off();
@@ -1228,14 +1307,14 @@ mod tests {
fn tempdir() -> PathBuf {
let p = std::env::temp_dir()
.join("chanora_storage_test")
.join(format!("{}", std::process::id()))
.join(format!(
"{}",
.join(std::process::id().to_string())
.join(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
.to_string(),
);
fs::create_dir_all(&p).unwrap();
p
}