//! Scenario 7: the disposable cache and the mandatory state store. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod common; use std::io::Write; use common::{TestAgent, config_with, local_config, network, wait_for_peers}; use tsunagi::config::StoragePaths; use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::storage::CacheOutcome; use tsunagi::{Agent, Error}; /// Overwrites a file with bytes that are definitely not a SQLite database. fn corrupt(path: &std::path::Path) { let mut file = std::fs::File::create(path).unwrap(); file.write_all(&[0x7f; 8192]).unwrap(); file.sync_all().unwrap(); } #[tokio::test] async fn a_missing_cache_is_recreated_and_does_not_block_connecting() { let discovery = SharedMemoryDiscovery::new(); let (name, secret) = network("missing-cache"); let peer = TestAgent::spawn(&discovery).await.unwrap(); let subject = TestAgent::spawn(&discovery).await.unwrap(); let network_id = peer.agent.join_network(&name, &secret).await.unwrap(); subject.agent.join_network(&name, &secret).await.unwrap(); wait_for_peers(&peer.agent, network_id, 1).await; let dir = subject.stop().await; let paths = StoragePaths::under(dir.path()); std::fs::remove_dir_all(&paths.cache_dir).unwrap(); assert!(!paths.cache_db().exists()); let restarted = Agent::spawn(config_with(dir.path(), &discovery)) .await .unwrap(); assert_eq!( restarted.status().await.unwrap().cache_outcome, CacheOutcome::Created ); wait_for_peers(&restarted, network_id, 1).await; restarted.shutdown().await; peer.agent.shutdown().await; drop(restarted); drop(dir); } #[tokio::test] async fn a_corrupt_cache_is_discarded_and_does_not_block_connecting() { let discovery = SharedMemoryDiscovery::new(); let (name, secret) = network("corrupt-cache"); let peer = TestAgent::spawn(&discovery).await.unwrap(); let subject = TestAgent::spawn(&discovery).await.unwrap(); let network_id = peer.agent.join_network(&name, &secret).await.unwrap(); subject.agent.join_network(&name, &secret).await.unwrap(); wait_for_peers(&peer.agent, network_id, 1).await; let device_id = subject.agent.endpoint_id(); let dir = subject.stop().await; let paths = StoragePaths::under(dir.path()); corrupt(&paths.cache_db()); let restarted = Agent::spawn(config_with(dir.path(), &discovery)) .await .unwrap(); let status = restarted.status().await.unwrap(); assert!( matches!(status.cache_outcome, CacheOutcome::Reset(_)), "expected the cache to be discarded, got {:?}", status.cache_outcome ); assert!(status.cache_healthy); assert_eq!( restarted.endpoint_id(), device_id, "a bad cache must not touch the identity" ); wait_for_peers(&restarted, network_id, 1).await; restarted.shutdown().await; peer.agent.shutdown().await; drop(restarted); drop(dir); } #[tokio::test] async fn a_stale_cache_does_not_prevent_connecting_through_discovery() { let discovery = SharedMemoryDiscovery::new(); let (name, secret) = network("stale-cache"); let peer = TestAgent::spawn(&discovery).await.unwrap(); let subject = TestAgent::spawn(&discovery).await.unwrap(); let network_id = peer.agent.join_network(&name, &secret).await.unwrap(); subject.agent.join_network(&name, &secret).await.unwrap(); wait_for_peers(&peer.agent, network_id, 1).await; // Stop both. When they come back they bind new ports, so every cached // address hint is stale, and only fresh discovery can bridge the gap. let peer_dir = peer.stop().await; let subject_dir = subject.stop().await; assert!( StoragePaths::under(subject_dir.path()).cache_db().exists(), "hints were written, so the cache is genuinely stale now" ); let peer_again = Agent::spawn(config_with(peer_dir.path(), &discovery)) .await .unwrap(); let subject_again = Agent::spawn(config_with(subject_dir.path(), &discovery)) .await .unwrap(); wait_for_peers(&subject_again, network_id, 1).await; wait_for_peers(&peer_again, network_id, 1).await; peer_again.shutdown().await; subject_again.shutdown().await; drop(peer_again); drop(subject_again); drop(peer_dir); drop(subject_dir); } #[tokio::test] async fn a_corrupt_state_store_is_an_error_and_never_a_fresh_identity() { let discovery = SharedMemoryDiscovery::new(); let (name, secret) = network("corrupt-state"); let subject = TestAgent::spawn(&discovery).await.unwrap(); let device_id = subject.agent.endpoint_id(); subject.agent.join_network(&name, &secret).await.unwrap(); let dir = subject.stop().await; let paths = StoragePaths::under(dir.path()); corrupt(&paths.state_db()); let result = Agent::spawn(config_with(dir.path(), &discovery)).await; match result { Err(Error::StateCorrupted { path, reason }) => { assert_eq!(path, paths.state_db()); assert!(!reason.is_empty()); } Err(other) => panic!("expected StateCorrupted, got {other:?}"), Ok(agent) => { let new_id = agent.endpoint_id(); agent.shutdown().await; panic!( "a corrupt state store must not yield a working agent (id {new_id}) — the previous identity was {device_id}" ); } } drop(dir); } #[tokio::test] async fn a_state_store_from_a_newer_build_is_refused() { let dir = tempfile::TempDir::new().unwrap(); let paths = StoragePaths::under(dir.path()); std::fs::create_dir_all(&paths.state_dir).unwrap(); { let conn = rusqlite::Connection::open(paths.state_db()).unwrap(); conn.pragma_update(None, "user_version", 999i64).unwrap(); } let result = Agent::spawn(local_config(dir.path())).await; assert!( matches!(result, Err(Error::UnsupportedSchema { found: 999, .. })), "expected UnsupportedSchema" ); } /// A store written by the previous schema must come up, not break. /// /// The record body gained a hostname, which changed the signing domain, so /// records written before it can never verify again. Leaving them in place /// would mean every read rejecting rows that look exactly like corruption. #[tokio::test] async fn a_state_store_from_the_previous_schema_is_migrated_and_stays_usable() { let dir = tempfile::TempDir::new().unwrap(); let paths = StoragePaths::under(dir.path()); std::fs::create_dir_all(&paths.state_dir).unwrap(); // Build a schema-2 store by hand, with a record in it. { let conn = rusqlite::Connection::open(paths.state_db()).unwrap(); conn.execute_batch( "BEGIN; CREATE TABLE device_identity ( id INTEGER PRIMARY KEY CHECK (id = 1), secret_key BLOB NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE networks ( network_id BLOB PRIMARY KEY, name TEXT NOT NULL, secret BLOB NOT NULL, auto_start INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL ); CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE signed_records ( network_id BLOB NOT NULL, author BLOB NOT NULL, version INTEGER NOT NULL, body BLOB NOT NULL, signature BLOB NOT NULL, PRIMARY KEY (network_id, author) ); CREATE TABLE own_record_version ( network_id BLOB PRIMARY KEY, version INTEGER NOT NULL ); INSERT INTO signed_records VALUES (x'00', x'11', 7, x'2222', x'3333'); INSERT INTO own_record_version VALUES (x'00', 7); PRAGMA user_version = 2; COMMIT;", ) .unwrap(); } // An agent comes up on it, which is the whole point. let discovery = SharedMemoryDiscovery::new(); let agent = Agent::spawn(config_with(dir.path(), &discovery)) .await .unwrap(); let (name, secret) = network("migrated"); let network_id = agent.join_network(&name, &secret).await.unwrap(); assert!(agent.network_status(network_id).await.is_ok()); agent.shutdown().await; let conn = rusqlite::Connection::open(paths.state_db()).unwrap(); let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); assert_eq!(version, tsunagi::storage::SCHEMA_VERSION); // The unverifiable record is gone rather than left to be rejected for // ever, and the counter is keyed by author now. let stale: i64 = conn .query_row( "SELECT count(*) FROM signed_records WHERE author = x'11'", [], |row| row.get(0), ) .unwrap(); assert_eq!(stale, 0, "records from the old signing domain are dropped"); conn.query_row( "SELECT count(*) FROM own_record_version WHERE author IS NOT NULL", [], |row| row.get::<_, i64>(0), ) .expect("the counter is keyed by author"); } #[tokio::test] async fn secrets_never_appear_in_status_or_debug_output() { let discovery = SharedMemoryDiscovery::new(); let (name, secret) = network("no-leaks"); let agent = TestAgent::spawn(&discovery).await.unwrap(); let network_id = agent.agent.join_network(&name, &secret).await.unwrap(); let status = agent.agent.status().await.unwrap(); let rendered = format!("{status:?}"); let encoded = secret.encode(); assert!(!rendered.contains(encoded.as_str())); assert!(rendered.contains(&network_id.to_string()) || rendered.contains("NetworkId")); let network_status = agent.agent.network_status(network_id).await.unwrap(); assert!(!format!("{network_status:?}").contains(encoded.as_str())); let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret); let keys_debug = format!("{keys:?}"); assert!(keys_debug.contains("")); assert!(!keys_debug.contains(encoded.as_str())); agent.agent.shutdown().await; }