First step of separating the layers. The library and the binary are now crates/tsunagi and crates/tsunagi-cli, which means the plugin crate to come can be told apart from the core by the compiler rather than by discipline. Falls out of it immediately: the CLI's dependencies stop being features of the library. clap, anstream and tracing-subscriber were optional dependencies behind a `cli` feature that every library user had to remember to turn off; now they belong to the crate that uses them, and the library defaults to no features at all. The one test that drives the binary moved beside it — a library cannot depend on a binary built from a crate that depends on the library — and was rewritten against the public API instead of the test harness. AGENTS.md said to prefer one crate. It now says the system level and its plugins are separate crates, for the reason above, and that everything else stays one crate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
287 lines
10 KiB
Rust
287 lines
10 KiB
Rust
//! 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("<redacted>"));
|
|
assert!(!keys_debug.contains(encoded.as_str()));
|
|
|
|
agent.agent.shutdown().await;
|
|
}
|