//! M1 store: identity on `self`, friends keyed by pubkey, onion is locator. use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::sync::{Mutex, MutexGuard}; use onionwire::Store; static ENV_LOCK: Mutex<()> = Mutex::new(()); struct TempHome { dir: tempfile::TempDir, _guard: MutexGuard<'static, ()>, } impl TempHome { fn new() -> Self { let dir = tempfile::tempdir().expect("tempdir"); let guard = ENV_LOCK.lock().expect("env lock"); unsafe { std::env::set_var("ONIONWIRE_HOME", dir.path()); std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test"); } Self { dir, _guard: guard } } fn path(&self) -> &Path { self.dir.path() } } impl Drop for TempHome { fn drop(&mut self) { unsafe { std::env::remove_var("ONIONWIRE_HOME"); std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); } } } fn mode(path: &Path) -> u32 { fs::metadata(path).unwrap().permissions().mode() & 0o777 } fn pk(tag: u8) -> [u8; 32] { let mut k = [0u8; 32]; k[0] = tag; k } #[test] fn first_run_creates_0700_dirs_and_self_row() { let home = TempHome::new(); let store = Store::open().expect("open"); let arti = home.path().join("arti"); let cache = home.path().join("cache"); let db = home.path().join("onionwire.db"); assert!(arti.is_dir(), "arti dir"); assert!(cache.is_dir(), "cache dir"); assert!(db.is_file(), "onionwire.db"); assert_eq!(mode(home.path()), 0o700); assert_eq!(mode(&arti), 0o700); assert_eq!(mode(&cache), 0o700); let me = store.self_identity().expect("self"); assert_eq!(me.identity_pk.len(), 32); assert_eq!(me.identity_sk.len(), 32); assert_ne!(me.identity_pk, me.identity_sk); assert_eq!(me.onion, ""); assert_eq!(store.friend_count().unwrap(), 0); drop(store); let store2 = Store::open().expect("reopen"); let me2 = store2.self_identity().expect("self again"); assert_eq!(me.identity_pk, me2.identity_pk); assert_eq!(me.identity_sk, me2.identity_sk); } #[test] fn insert_friend_by_pubkey() { let _home = TempHome::new(); let store = Store::open().expect("open"); store .upsert_friend(&pk(1), "aaa.onion", Some("alice")) .expect("insert"); let f = store.get_friend(&pk(1)).expect("row").expect("found"); assert_eq!(f.onion, "aaa.onion"); assert_eq!(f.petname.as_deref(), Some("alice")); assert_eq!(f.pubkey, pk(1)); assert!(!f.fingerprint.is_empty()); assert_eq!(store.friend_count().unwrap(), 1); } #[test] fn same_pubkey_different_onion_updates_locator() { let _home = TempHome::new(); let store = Store::open().expect("open"); store .upsert_friend(&pk(1), "old.onion", Some("alice")) .expect("insert"); store .upsert_friend(&pk(1), "new.onion", None) .expect("update"); assert_eq!(store.friend_count().unwrap(), 1); let f = store.get_friend(&pk(1)).expect("row").expect("found"); assert_eq!(f.onion, "new.onion"); assert_eq!(f.petname.as_deref(), Some("alice"), "petname is local"); } #[test] fn unknown_pubkey_inserts_new_row() { let _home = TempHome::new(); let store = Store::open().expect("open"); store.upsert_friend(&pk(1), "a.onion", None).expect("first"); store .upsert_friend(&pk(2), "b.onion", None) .expect("second"); assert_eq!(store.friend_count().unwrap(), 2); assert_eq!(store.get_friend(&pk(1)).unwrap().unwrap().onion, "a.onion"); assert_eq!(store.get_friend(&pk(2)).unwrap().unwrap().onion, "b.onion"); } #[test] fn open_at_two_homes_are_independent() { let a = tempfile::tempdir().expect("a"); let b = tempfile::tempdir().expect("b"); let sa = onionwire::Store::open_at_with_passphrase(a.path(), "a-pass").expect("open a"); let sb = onionwire::Store::open_at_with_passphrase(b.path(), "b-pass").expect("open b"); let ia = sa.self_identity().unwrap(); let ib = sb.self_identity().unwrap(); assert_ne!(ia.identity_pk, ib.identity_pk); } #[test] fn incoming_message_persists_plaintext() { let _home = TempHome::new(); let store = Store::open().expect("open"); store.upsert_friend(&pk(1), "b.onion", None).unwrap(); store.append_message(&pk(1), "in", b"hello wire").unwrap(); let msgs = store.list_messages(&pk(1)).unwrap(); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].dir, "in"); assert_eq!(msgs[0].plaintext, b"hello wire"); } #[test] fn self_has_x25519_prekey_and_set_onion() { let _home = TempHome::new(); let store = Store::open().expect("open"); let me = store.self_identity().unwrap(); assert_eq!(me.prekey_pk.len(), 32); assert_eq!(me.prekey_sk.len(), 32); assert_ne!(me.prekey_pk, me.prekey_sk); store.set_onion("abc.onion").unwrap(); assert_eq!(store.self_identity().unwrap().onion, "abc.onion"); } #[test] fn friend_prekey_lookup() { let _home = TempHome::new(); let store = Store::open().expect("open"); let spk = [9u8; 32]; store.upsert_friend(&pk(1), "b.onion", None).unwrap(); store.set_friend_prekey(&pk(1), &spk).unwrap(); let f = store.get_friend_by_prekey(&spk).unwrap().expect("found"); assert_eq!(f.pubkey, pk(1)); assert_eq!(f.prekey, spk); } #[test] fn open_uses_wal_journal() { let dir = tempfile::tempdir().expect("tempdir"); let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open"); assert_eq!(store.journal_mode().unwrap().to_ascii_lowercase(), "wal"); } #[test] fn corrupt_db_fails_closed() { let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join("onionwire.db"), b"not a sqlite database").unwrap(); assert!(Store::open_at_with_passphrase(dir.path(), "onionwire-test").is_err()); } fn home_contains_bytes(home: &Path, needle: &[u8]) -> bool { let Ok(rd) = fs::read_dir(home) else { return false; }; for ent in rd.flatten() { let name = ent.file_name(); let name = name.to_string_lossy(); if !name.starts_with("onionwire.db") { continue; } let Ok(bytes) = fs::read(ent.path()) else { continue; }; if bytes.windows(needle.len()).any(|w| w == needle) { return true; } } false } #[test] fn empty_passphrase_is_rejected() { let dir = tempfile::tempdir().expect("tempdir"); let err = match Store::open_at_with_passphrase(dir.path(), "") { Ok(_) => panic!("empty passphrase should fail"), Err(e) => e, }; assert!( err.to_string().to_ascii_lowercase().contains("empty"), "got {err}" ); } #[test] fn append_message_db_file_does_not_contain_plaintext() { let dir = tempfile::tempdir().expect("tempdir"); let store = Store::open_at_with_passphrase(dir.path(), "correct-horse").expect("open"); store.upsert_friend(&pk(1), "b.onion", None).unwrap(); let needle = b"needle-plaintext-xyzzy-at-rest"; store.append_message(&pk(1), "out", needle).unwrap(); let msgs = store.list_messages(&pk(1)).unwrap(); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].plaintext, needle); drop(store); assert!( !home_contains_bytes(dir.path(), needle), "sqlite files must not contain chat plaintext" ); } #[test] fn wrong_passphrase_cannot_open_or_list() { let dir = tempfile::tempdir().expect("tempdir"); let store = Store::open_at_with_passphrase(dir.path(), "right-pass").expect("open"); store.upsert_friend(&pk(1), "b.onion", None).unwrap(); store .append_message(&pk(1), "in", b"secret-chat-body") .unwrap(); drop(store); let err = match Store::open_at_with_passphrase(dir.path(), "wrong-pass") { Ok(_) => panic!("wrong passphrase should fail"), Err(e) => e, }; let msg = err.to_string().to_ascii_lowercase(); assert!( msg.contains("passphrase") || msg.contains("decrypt") || msg.contains("wrong"), "got {err}" ); } #[test] fn reopen_with_same_passphrase_decrypts() { let dir = tempfile::tempdir().expect("tempdir"); let store = Store::open_at_with_passphrase(dir.path(), "same-pass").expect("open"); store.upsert_friend(&pk(1), "b.onion", None).unwrap(); store.append_message(&pk(1), "out", b"hello again").unwrap(); drop(store); let store = Store::open_at_with_passphrase(dir.path(), "same-pass").expect("reopen"); let msgs = store.list_messages(&pk(1)).unwrap(); assert_eq!(msgs[0].plaintext, b"hello again"); } #[test] fn env_passphrase_skips_prompt() { let _g = ENV_LOCK.lock().expect("env lock"); unsafe { std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "from-env"); } let mut prompted = false; let got = onionwire::resolve_store_passphrase(|_| { prompted = true; Ok("from-tty".into()) }); unsafe { std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); } assert_eq!(got.unwrap(), "from-env"); assert!(!prompted); } #[test] fn empty_env_passphrase_is_rejected_without_prompt() { let _g = ENV_LOCK.lock().expect("env lock"); unsafe { std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", ""); } let mut prompted = false; let err = onionwire::resolve_store_passphrase(|_| { prompted = true; Ok("from-tty".into()) }) .unwrap_err(); unsafe { std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); } assert!(err.to_string().contains("empty"), "got {err}"); assert!(!prompted); } #[test] fn missing_env_reads_secret_and_strips_newline() { let _g = ENV_LOCK.lock().expect("env lock"); unsafe { std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); } let got = onionwire::resolve_store_passphrase(|prompt| { assert!(prompt.contains("store passphrase"), "prompt {prompt}"); Ok("secret-from-tty\n".into()) }) .unwrap(); assert_eq!(got, "secret-from-tty"); } #[test] fn missing_env_empty_secret_is_rejected() { let _g = ENV_LOCK.lock().expect("env lock"); unsafe { std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); } let err = onionwire::resolve_store_passphrase(|_| Ok(String::new())).unwrap_err(); assert!(err.to_string().contains("empty"), "got {err}"); }