`id` now shows what this device is — the key it signs with, the name it
answers to, the secret of every network it has joined — and changes all
of it. One shape throughout: name a thing to see it, name it with a value
to change it. `secret` folded in as `id secret generate`, and the path
flags became global so they work either side of a subcommand.
There is no separate signing certificate to show: the endpoint key is
what signs records, and the report says so rather than leaving it to be
guessed.
Secrets appear in `id`, which is where you go to ask for one, and stay
out of `status`, logs, `Debug` and anything sent to a peer.
The hostname is now a signed claim, which is what makes changing it a
revocation. Records are one per author, so a new version replaces the
whole claim and no replica can keep the old name standing. RecordBody
generalised to Claim { address, range, hostname } + Release for that,
with the signing domain bumped; a name is bounded and canonicalised, and
a non-canonical one is rejected rather than repaired, because a repaired
version is not what its author signed. Two members claiming one name
resolve it like an address: lowest id wins, computed identically
everywhere. A member with only a name now has a record too, so an
IPv6-only network finally has a durable roster and an absent member can
be named rather than shown as a bare id.
Replacing the signing key is allowed and does not break the store. The
outgoing key signs a release for every network first, so the address and
name it held are freed rather than reserved forever to a key nobody has
— nothing can sign for a retired author, and by design no authority
could overrule one. Identity and releases commit together: a crash
between them would leave the old key gone and unable to sign what it
owed. It refuses while an agent holds the directory, rather than failing
on the lock with a message that says nothing about what to do.
The version counter is keyed by author as well as network, so a
replacement key starts its own sequence. The migration drops records
written under the previous signing domain instead of carrying rows that
every read must reject and that look exactly like corruption.
The hostname defaults to the machine's own name. Also fixed a
pre-existing flaky test: 40 random authors in a /24 collide by the
birthday problem often enough that its threshold failed about one run in
six, so the authors are fixed now and it tests a property rather than a
coin flip.
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;
|
|
}
|