feat: integrate chat voice and diagnostics client
This commit is contained in:
+186
-107
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user