diff --git a/crates/vnidrop/src/contacts.rs b/crates/vnidrop/src/contacts.rs new file mode 100644 index 0000000..5f671fc --- /dev/null +++ b/crates/vnidrop/src/contacts.rs @@ -0,0 +1,494 @@ +//! Storage for device history: contacts, the grants that make them usable, and +//! the block list. +//! +//! Split out of [`crate::repository`] to keep that file focused; the tables are +//! created as part of the same schema migration and share its pool. +//! +//! Grant secrets live here. They are key material and follow the same rule as +//! tickets: never logged, never emitted in an event, never returned across the +//! UniFFI boundary. + +// Exercised only by unit tests until the offer protocol consumes it. Remove +// this once that lands. +#![allow(dead_code)] + +use anyhow::{Context, Result}; +use sqlx::{Row, SqlitePool}; + +use crate::grant::{parse_secret, GrantId, HeldGrant, IssuedGrant}; + +/// A device the user has transferred with and chosen to remember. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Contact { + pub(crate) endpoint_id: String, + /// Set by the local user. Never overwritten by a name the remote claims. + pub(crate) local_label: Option, + /// Last name the remote sent. Untrusted display data. + pub(crate) remote_display_name: Option, + /// Encoded `EndpointAddr` from the last successful connection, so the peer + /// stays dialable in relay profiles without public address lookup. + pub(crate) last_known_addr: Option, + pub(crate) created_at: i64, + pub(crate) last_transfer_at: Option, +} + +pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS contacts ( + endpoint_id TEXT PRIMARY KEY, + local_label TEXT, + remote_display_name TEXT, + last_known_addr TEXT, + created_at INTEGER NOT NULL, + last_transfer_at INTEGER + ); + "#, + ) + .execute(pool) + .await?; + + // Authoritative side: only the issuer can validate or revoke these. + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS grants_issued ( + grant_id TEXT PRIMARY KEY, + grant_secret TEXT NOT NULL, + issued_to_endpoint_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER, + revoked_at INTEGER + ); + "#, + ) + .execute(pool) + .await?; + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_grants_issued_endpoint ON grants_issued(issued_to_endpoint_id);", + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS grants_held ( + grant_id TEXT PRIMARY KEY, + grant_secret TEXT NOT NULL, + peer_endpoint_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER + ); + "#, + ) + .execute(pool) + .await?; + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_grants_held_endpoint ON grants_held(peer_endpoint_id);", + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS blocked_endpoints ( + endpoint_id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL + ); + "#, + ) + .execute(pool) + .await?; + + Ok(()) +} + +/// Contacts, grants, and blocks over the shared repository pool. +#[derive(Debug, Clone)] +pub(crate) struct ContactStore { + pool: SqlitePool, +} + +impl ContactStore { + pub(crate) fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + // -- contacts --------------------------------------------------------- + + /// Record a contact, or refresh the untrusted display name of an existing + /// one. The local label is deliberately left untouched. + pub(crate) async fn upsert_contact( + &self, + endpoint_id: &str, + remote_display_name: Option<&str>, + now_ms: i64, + ) -> Result<()> { + sqlx::query( + r#" + INSERT INTO contacts (endpoint_id, remote_display_name, created_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(endpoint_id) DO UPDATE SET + remote_display_name = COALESCE(excluded.remote_display_name, contacts.remote_display_name) + "#, + ) + .bind(endpoint_id) + .bind(remote_display_name) + .bind(now_ms) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn set_contact_label( + &self, + endpoint_id: &str, + label: Option<&str>, + ) -> Result<()> { + sqlx::query("UPDATE contacts SET local_label = ?2 WHERE endpoint_id = ?1") + .bind(endpoint_id) + .bind(label) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn touch_transfer(&self, endpoint_id: &str, now_ms: i64) -> Result<()> { + sqlx::query("UPDATE contacts SET last_transfer_at = ?2 WHERE endpoint_id = ?1") + .bind(endpoint_id) + .bind(now_ms) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn set_last_known_addr(&self, endpoint_id: &str, addr: &str) -> Result<()> { + sqlx::query("UPDATE contacts SET last_known_addr = ?2 WHERE endpoint_id = ?1") + .bind(endpoint_id) + .bind(addr) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn list_contacts(&self) -> Result> { + let rows = sqlx::query( + r#" + SELECT endpoint_id, local_label, remote_display_name, last_known_addr, + created_at, last_transfer_at + FROM contacts + ORDER BY COALESCE(last_transfer_at, created_at) DESC + "#, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|row| Contact { + endpoint_id: row.get(0), + local_label: row.get(1), + remote_display_name: row.get(2), + last_known_addr: row.get(3), + created_at: row.get(4), + last_transfer_at: row.get(5), + }) + .collect()) + } + + pub(crate) async fn find_contact(&self, endpoint_id: &str) -> Result> { + Ok(self + .list_contacts() + .await? + .into_iter() + .find(|contact| contact.endpoint_id == endpoint_id)) + } + + /// Remove a contact and every grant in both directions. + /// + /// Returns the ids of the grants this device had issued, so the caller can + /// send the best-effort revoke notification. Deletion succeeds regardless of + /// whether that notification is ever delivered. + pub(crate) async fn delete_contact(&self, endpoint_id: &str) -> Result> { + let issued = self.issued_grant_ids_for(endpoint_id).await?; + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM grants_issued WHERE issued_to_endpoint_id = ?1") + .bind(endpoint_id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM grants_held WHERE peer_endpoint_id = ?1") + .bind(endpoint_id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM contacts WHERE endpoint_id = ?1") + .bind(endpoint_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(issued) + } + + /// Wholesale delete, for the same surface that clears transfer history. + pub(crate) async fn delete_all_contacts(&self) -> Result> { + let issued = self.all_issued_grant_ids().await?; + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM grants_issued") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM grants_held") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM contacts") + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(issued) + } + + // -- issued grants ---------------------------------------------------- + + pub(crate) async fn insert_issued_grant(&self, grant: &IssuedGrant) -> Result<()> { + sqlx::query( + r#" + INSERT INTO grants_issued + (grant_id, grant_secret, issued_to_endpoint_id, created_at, expires_at, revoked_at) + VALUES (?1, ?2, ?3, ?4, ?5, NULL) + "#, + ) + .bind(grant.grant_id.encode()) + .bind(grant.secret.encode()) + .bind(&grant.issued_to_endpoint_id) + .bind(grant.created_at) + .bind(grant.expires_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Look up a grant by the id a peer presented. + /// + /// A row whose secret fails to parse is corrupt storage, not a usable + /// grant: surface the error rather than silently refusing the peer, which + /// would look like revocation. + pub(crate) async fn find_issued_grant(&self, grant_id: GrantId) -> Result> { + let row = sqlx::query( + r#" + SELECT grant_id, grant_secret, issued_to_endpoint_id, created_at, expires_at, revoked_at + FROM grants_issued + WHERE grant_id = ?1 + "#, + ) + .bind(grant_id.encode()) + .fetch_optional(&self.pool) + .await?; + row.map(row_to_issued_grant).transpose() + } + + /// Push the idle deadline forward after an accepted proof. + pub(crate) async fn renew_issued_grant( + &self, + grant_id: GrantId, + expires_at: Option, + ) -> Result<()> { + sqlx::query("UPDATE grants_issued SET expires_at = ?2 WHERE grant_id = ?1") + .bind(grant_id.encode()) + .bind(expires_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// End the relationship from the issuing side. Tombstoned rather than + /// deleted so a later attempt is answered `Revoked` instead of `Unknown`. + pub(crate) async fn revoke_issued_grant(&self, grant_id: GrantId, now_ms: i64) -> Result<()> { + sqlx::query( + "UPDATE grants_issued SET revoked_at = ?2 WHERE grant_id = ?1 AND revoked_at IS NULL", + ) + .bind(grant_id.encode()) + .bind(now_ms) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn revoke_issued_grants_for( + &self, + endpoint_id: &str, + now_ms: i64, + ) -> Result> { + let ids = self.issued_grant_ids_for(endpoint_id).await?; + sqlx::query( + "UPDATE grants_issued SET revoked_at = ?2 WHERE issued_to_endpoint_id = ?1 AND revoked_at IS NULL", + ) + .bind(endpoint_id) + .bind(now_ms) + .execute(&self.pool) + .await?; + Ok(ids) + } + + async fn issued_grant_ids_for(&self, endpoint_id: &str) -> Result> { + let rows = + sqlx::query("SELECT grant_id FROM grants_issued WHERE issued_to_endpoint_id = ?1") + .bind(endpoint_id) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| GrantId::decode(row.get::(0).as_str())) + .collect() + } + + async fn all_issued_grant_ids(&self) -> Result> { + let rows = sqlx::query("SELECT grant_id FROM grants_issued") + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| GrantId::decode(row.get::(0).as_str())) + .collect() + } + + // -- held grants ------------------------------------------------------ + + pub(crate) async fn insert_held_grant(&self, grant: &HeldGrant) -> Result<()> { + sqlx::query( + r#" + INSERT INTO grants_held + (grant_id, grant_secret, peer_endpoint_id, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(grant_id) DO UPDATE SET + grant_secret = excluded.grant_secret, + expires_at = excluded.expires_at + "#, + ) + .bind(grant.grant_id.encode()) + .bind(grant.secret.encode()) + .bind(&grant.peer_endpoint_id) + .bind(grant.created_at) + .bind(grant.expires_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// The capability to reach `peer_endpoint_id`, if this device holds one. + /// + /// Newest wins: re-pairing issues a fresh grant, and the old one is dead on + /// the issuer's side anyway. + pub(crate) async fn held_grant_for(&self, peer_endpoint_id: &str) -> Result> { + let row = sqlx::query( + r#" + SELECT grant_id, grant_secret, peer_endpoint_id, created_at, expires_at + FROM grants_held + WHERE peer_endpoint_id = ?1 + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(peer_endpoint_id) + .fetch_optional(&self.pool) + .await?; + row.map(row_to_held_grant).transpose() + } + + /// Drop a grant this device holds, after the issuer reported it dead. + pub(crate) async fn delete_held_grant(&self, grant_id: GrantId) -> Result<()> { + sqlx::query("DELETE FROM grants_held WHERE grant_id = ?1") + .bind(grant_id.encode()) + .execute(&self.pool) + .await?; + Ok(()) + } + + // -- block list ------------------------------------------------------- + + /// Block an endpoint and revoke anything it still holds, so blocking is not + /// merely cosmetic while a live grant remains. + pub(crate) async fn block_endpoint(&self, endpoint_id: &str, now_ms: i64) -> Result<()> { + self.revoke_issued_grants_for(endpoint_id, now_ms).await?; + sqlx::query( + "INSERT INTO blocked_endpoints (endpoint_id, created_at) VALUES (?1, ?2) + ON CONFLICT(endpoint_id) DO NOTHING", + ) + .bind(endpoint_id) + .bind(now_ms) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn unblock_endpoint(&self, endpoint_id: &str) -> Result<()> { + sqlx::query("DELETE FROM blocked_endpoints WHERE endpoint_id = ?1") + .bind(endpoint_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn is_blocked(&self, endpoint_id: &str) -> Result { + let row = + sqlx::query("SELECT EXISTS(SELECT 1 FROM blocked_endpoints WHERE endpoint_id = ?1)") + .bind(endpoint_id) + .fetch_one(&self.pool) + .await?; + Ok(row.get::(0) == 1) + } + + pub(crate) async fn list_blocked(&self) -> Result> { + let rows = + sqlx::query("SELECT endpoint_id FROM blocked_endpoints ORDER BY created_at DESC") + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|row| row.get(0)).collect()) + } + + // -- maintenance ------------------------------------------------------ + + #[cfg(test)] + pub(crate) async fn corrupt_secret_for_test(&self, grant_id: GrantId) -> Result<()> { + sqlx::query("UPDATE grants_issued SET grant_secret = 'not-hex' WHERE grant_id = ?1") + .bind(grant_id.encode()) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Drop grants that lapsed or were revoked long enough ago that no peer + /// still needs to be told. Keeps tombstones bounded. + pub(crate) async fn purge_dead_grants(&self, before_ms: i64) -> Result { + let issued = sqlx::query( + "DELETE FROM grants_issued + WHERE (expires_at IS NOT NULL AND expires_at < ?1) + OR (revoked_at IS NOT NULL AND revoked_at < ?1)", + ) + .bind(before_ms) + .execute(&self.pool) + .await? + .rows_affected(); + Ok(issued) + } +} + +fn row_to_issued_grant(row: sqlx::sqlite::SqliteRow) -> Result { + let grant_id = GrantId::decode(row.get::(0).as_str())?; + let secret = parse_secret(row.get::(1).as_str()) + .context("stored grant secret is unusable")?; + Ok(IssuedGrant { + grant_id, + secret, + issued_to_endpoint_id: row.get(2), + created_at: row.get(3), + expires_at: row.get(4), + revoked_at: row.get(5), + }) +} + +fn row_to_held_grant(row: sqlx::sqlite::SqliteRow) -> Result { + let grant_id = GrantId::decode(row.get::(0).as_str())?; + let secret = parse_secret(row.get::(1).as_str()) + .context("stored grant secret is unusable")?; + Ok(HeldGrant { + grant_id, + secret, + peer_endpoint_id: row.get(2), + created_at: row.get(3), + expires_at: row.get(4), + }) +} diff --git a/crates/vnidrop/src/grant.rs b/crates/vnidrop/src/grant.rs index c7b6b19..57b1827 100644 --- a/crates/vnidrop/src/grant.rs +++ b/crates/vnidrop/src/grant.rs @@ -236,6 +236,35 @@ impl IssuedGrant { } } +/// A grant as held by the party it was issued to: the capability used to reach +/// the peer that minted it. +/// +/// `expires_at` here is advisory only — a copy of what the issuer said at issue +/// time, useful for showing "expires soon" in the UI. The issuer's record is +/// authoritative and may have been renewed or revoked since. +#[derive(Debug, Clone)] +pub(crate) struct HeldGrant { + pub(crate) grant_id: GrantId, + pub(crate) secret: GrantSecret, + /// The peer that issued this grant, and therefore the only one it works on. + pub(crate) peer_endpoint_id: String, + pub(crate) created_at: i64, + pub(crate) expires_at: Option, +} + +impl HeldGrant { + /// Build the proof to present to the issuing peer. + pub(crate) fn prove(&self, challenge: &Challenge, self_endpoint_id: &str) -> GrantProof { + prove( + self.grant_id, + &self.secret, + challenge, + &self.peer_endpoint_id, + self_endpoint_id, + ) + } +} + /// How long a grant survives without use. Grants expire on idleness rather than /// age, so a relationship in regular use never lapses while a forgotten one /// cleans itself up. diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 7097823..7f2e163 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -1,6 +1,7 @@ mod access_policy; mod api; mod approval; +mod contacts; mod error; mod event_hub; mod filesystem; diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index b913e8e..e1d46ea 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -16,11 +16,12 @@ use uuid::Uuid; use crate::{ access_policy::mode_from_storage, api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer}, + contacts::ContactStore, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, util::now_ms, }; -const SCHEMA_VERSION: i64 = 7; +const SCHEMA_VERSION: i64 = 8; #[derive(Debug, Clone)] pub(crate) struct Repository { @@ -315,12 +316,22 @@ impl Repository { .await?; } + crate::contacts::ensure_schema(&self.pool).await?; + sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) .execute(&self.pool) .await?; Ok(()) } + /// Device history, grants, and the block list. Shares this pool so the + /// tables migrate together with the rest of the schema. + // Reached from tests until the offer protocol lands. + #[allow(dead_code)] + pub(crate) fn contacts(&self) -> ContactStore { + ContactStore::new(self.pool.clone()) + } + #[cfg(test)] pub(crate) async fn schema_version(&self) -> Result { let row = sqlx::query("PRAGMA user_version") diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 2bf5c40..c14fbff 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -1,5 +1,7 @@ #[path = "tests/access_policy.rs"] mod access_policy_tests; +#[path = "tests/contacts.rs"] +mod contacts_tests; #[path = "tests/error.rs"] mod error_tests; #[path = "tests/filesystem.rs"] diff --git a/crates/vnidrop/src/tests/contacts.rs b/crates/vnidrop/src/tests/contacts.rs new file mode 100644 index 0000000..b0af327 --- /dev/null +++ b/crates/vnidrop/src/tests/contacts.rs @@ -0,0 +1,386 @@ +use crate::{ + contacts::ContactStore, + grant::{Challenge, GrantId, GrantLifetime, GrantRejection, HeldGrant, IssuedGrant}, + repository::Repository, +}; + +const PEER: &str = "peer-endpoint"; +const SELF_ID: &str = "self-endpoint"; +const NOW: i64 = 1_700_000_000_000; +const DAY_MS: i64 = 24 * 60 * 60 * 1_000; + +async fn store(temp: &tempfile::TempDir) -> (Repository, ContactStore) { + let repository = Repository::open(temp.path()).await.unwrap(); + let contacts = repository.contacts(); + (repository, contacts) +} + +async fn contact_with_issued_grant(contacts: &ContactStore) -> IssuedGrant { + contacts + .upsert_contact(PEER, Some("Peer Laptop"), NOW) + .await + .unwrap(); + let grant = IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()); + contacts.insert_issued_grant(&grant).await.unwrap(); + grant +} + +#[tokio::test] +async fn contacts_and_grants_survive_reopening_the_same_data_dir() { + let temp = tempfile::tempdir().unwrap(); + let minted = { + let (repository, contacts) = store(&temp).await; + let grant = contact_with_issued_grant(&contacts).await; + contacts + .insert_held_grant(&HeldGrant { + grant_id: GrantId::generate(), + secret: grant.secret.clone(), + peer_endpoint_id: PEER.to_string(), + created_at: NOW, + expires_at: Some(NOW + 90 * DAY_MS), + }) + .await + .unwrap(); + drop(repository); + grant + }; + + let (_repository, contacts) = store(&temp).await; + + let reloaded = contacts + .find_issued_grant(minted.grant_id) + .await + .unwrap() + .expect("issued grant persisted"); + assert_eq!(reloaded.secret, minted.secret); + assert_eq!(reloaded.issued_to_endpoint_id, PEER); + assert!(contacts.held_grant_for(PEER).await.unwrap().is_some()); + assert_eq!(contacts.list_contacts().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn a_persisted_grant_still_validates_a_proof() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let minted = contact_with_issued_grant(&contacts).await; + + // The round trip through hex storage must not disturb the secret. + let reloaded = contacts + .find_issued_grant(minted.grant_id) + .await + .unwrap() + .expect("issued grant persisted"); + let challenge = Challenge::generate(); + let held = HeldGrant { + grant_id: minted.grant_id, + secret: minted.secret.clone(), + peer_endpoint_id: SELF_ID.to_string(), + created_at: NOW, + expires_at: None, + }; + + let outcome = reloaded.accept( + &held.prove(&challenge, PEER), + &challenge, + SELF_ID, + PEER, + NOW, + GrantLifetime::default(), + ); + + assert!(outcome.is_ok(), "expected acceptance, got {outcome:?}"); +} + +#[tokio::test] +async fn renewal_is_persisted() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let minted = contact_with_issued_grant(&contacts).await; + let renewed_to = Some(NOW + 120 * DAY_MS); + + contacts + .renew_issued_grant(minted.grant_id, renewed_to) + .await + .unwrap(); + + let reloaded = contacts + .find_issued_grant(minted.grant_id) + .await + .unwrap() + .expect("issued grant persisted"); + assert_eq!(reloaded.expires_at, renewed_to); +} + +#[tokio::test] +async fn revocation_is_tombstoned_so_the_peer_learns_it_was_revoked() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let minted = contact_with_issued_grant(&contacts).await; + + contacts + .revoke_issued_grant(minted.grant_id, NOW) + .await + .unwrap(); + + let reloaded = contacts + .find_issued_grant(minted.grant_id) + .await + .unwrap() + .expect("a revoked grant is kept as a tombstone, not deleted"); + assert_eq!(reloaded.revoked_at, Some(NOW)); + + // A tombstone answers Revoked, never Unknown: the peer needs to know to + // drop the entry rather than retry forever. + let challenge = Challenge::generate(); + let held = HeldGrant { + grant_id: minted.grant_id, + secret: minted.secret.clone(), + peer_endpoint_id: SELF_ID.to_string(), + created_at: NOW, + expires_at: None, + }; + assert_eq!( + reloaded.accept( + &held.prove(&challenge, PEER), + &challenge, + SELF_ID, + PEER, + NOW, + GrantLifetime::default(), + ), + Err(GrantRejection::Revoked) + ); +} + +#[tokio::test] +async fn deleting_a_contact_removes_both_directions_and_reports_issued_grants() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let minted = contact_with_issued_grant(&contacts).await; + let held_id = GrantId::generate(); + contacts + .insert_held_grant(&HeldGrant { + grant_id: held_id, + secret: minted.secret.clone(), + peer_endpoint_id: PEER.to_string(), + created_at: NOW, + expires_at: None, + }) + .await + .unwrap(); + + let to_notify = contacts.delete_contact(PEER).await.unwrap(); + + assert_eq!(to_notify, vec![minted.grant_id]); + assert!(contacts.list_contacts().await.unwrap().is_empty()); + assert!(contacts + .find_issued_grant(minted.grant_id) + .await + .unwrap() + .is_none()); + assert!(contacts.held_grant_for(PEER).await.unwrap().is_none()); +} + +#[tokio::test] +async fn deleting_all_contacts_clears_every_grant() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + contact_with_issued_grant(&contacts).await; + contacts + .upsert_contact("other-peer", None, NOW) + .await + .unwrap(); + let other = IssuedGrant::mint("other-peer".to_string(), NOW, GrantLifetime::default()); + contacts.insert_issued_grant(&other).await.unwrap(); + + let to_notify = contacts.delete_all_contacts().await.unwrap(); + + assert_eq!(to_notify.len(), 2); + assert!(contacts.list_contacts().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn a_local_label_is_never_overwritten_by_a_name_the_remote_claims() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + contacts + .upsert_contact(PEER, Some("Original"), NOW) + .await + .unwrap(); + contacts + .set_contact_label(PEER, Some("My Laptop")) + .await + .unwrap(); + + contacts + .upsert_contact(PEER, Some("Totally Not Evil"), NOW + 1) + .await + .unwrap(); + + let contact = contacts.find_contact(PEER).await.unwrap().expect("contact"); + assert_eq!(contact.local_label.as_deref(), Some("My Laptop")); + assert_eq!( + contact.remote_display_name.as_deref(), + Some("Totally Not Evil"), + "the claimed name is still recorded, just not promoted to the label" + ); +} + +#[tokio::test] +async fn upsert_keeps_the_original_creation_time_and_records_activity() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + contacts.upsert_contact(PEER, None, NOW).await.unwrap(); + + contacts + .upsert_contact(PEER, None, NOW + 5 * DAY_MS) + .await + .unwrap(); + contacts + .touch_transfer(PEER, NOW + 6 * DAY_MS) + .await + .unwrap(); + + let contact = contacts.find_contact(PEER).await.unwrap().expect("contact"); + assert_eq!(contact.created_at, NOW); + assert_eq!(contact.last_transfer_at, Some(NOW + 6 * DAY_MS)); +} + +#[tokio::test] +async fn the_last_known_address_is_remembered_for_later_dialing() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + contacts.upsert_contact(PEER, None, NOW).await.unwrap(); + + contacts + .set_last_known_addr(PEER, "vndaddr1:encoded") + .await + .unwrap(); + + let contact = contacts.find_contact(PEER).await.unwrap().expect("contact"); + assert_eq!(contact.last_known_addr.as_deref(), Some("vndaddr1:encoded")); +} + +#[tokio::test] +async fn blocking_revokes_outstanding_grants_so_it_is_not_merely_cosmetic() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let minted = contact_with_issued_grant(&contacts).await; + + contacts.block_endpoint(PEER, NOW).await.unwrap(); + + assert!(contacts.is_blocked(PEER).await.unwrap()); + let reloaded = contacts + .find_issued_grant(minted.grant_id) + .await + .unwrap() + .expect("grant kept as tombstone"); + assert_eq!(reloaded.revoked_at, Some(NOW)); +} + +#[tokio::test] +async fn unblocking_does_not_restore_the_revoked_grant() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let minted = contact_with_issued_grant(&contacts).await; + contacts.block_endpoint(PEER, NOW).await.unwrap(); + + contacts.unblock_endpoint(PEER).await.unwrap(); + + assert!(!contacts.is_blocked(PEER).await.unwrap()); + let reloaded = contacts + .find_issued_grant(minted.grant_id) + .await + .unwrap() + .expect("grant kept as tombstone"); + assert!( + reloaded.revoked_at.is_some(), + "unblocking must not silently hand back access; the peer has to pair again" + ); +} + +#[tokio::test] +async fn newest_held_grant_wins_after_re_pairing() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let older = HeldGrant { + grant_id: GrantId::generate(), + secret: IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()).secret, + peer_endpoint_id: PEER.to_string(), + created_at: NOW, + expires_at: None, + }; + let newer = HeldGrant { + grant_id: GrantId::generate(), + secret: IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()).secret, + peer_endpoint_id: PEER.to_string(), + created_at: NOW + DAY_MS, + expires_at: None, + }; + contacts.insert_held_grant(&older).await.unwrap(); + contacts.insert_held_grant(&newer).await.unwrap(); + + let selected = contacts.held_grant_for(PEER).await.unwrap().expect("grant"); + + assert_eq!(selected.grant_id, newer.grant_id); +} + +#[tokio::test] +async fn a_held_grant_is_dropped_once_the_issuer_reports_it_dead() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let held = HeldGrant { + grant_id: GrantId::generate(), + secret: IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()).secret, + peer_endpoint_id: PEER.to_string(), + created_at: NOW, + expires_at: None, + }; + contacts.insert_held_grant(&held).await.unwrap(); + + contacts.delete_held_grant(held.grant_id).await.unwrap(); + + assert!(contacts.held_grant_for(PEER).await.unwrap().is_none()); +} + +#[tokio::test] +async fn purging_drops_lapsed_and_revoked_grants_but_keeps_live_ones() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let live = contact_with_issued_grant(&contacts).await; + let lapsed = IssuedGrant::mint( + "stale-peer".to_string(), + NOW - 400 * DAY_MS, + GrantLifetime::Days(1), + ); + contacts.insert_issued_grant(&lapsed).await.unwrap(); + + let purged = contacts.purge_dead_grants(NOW).await.unwrap(); + + assert_eq!(purged, 1); + assert!(contacts + .find_issued_grant(live.grant_id) + .await + .unwrap() + .is_some()); + assert!(contacts + .find_issued_grant(lapsed.grant_id) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn a_corrupt_stored_secret_is_an_error_not_a_silent_refusal() { + let temp = tempfile::tempdir().unwrap(); + let (_repository, contacts) = store(&temp).await; + let minted = contact_with_issued_grant(&contacts).await; + contacts + .corrupt_secret_for_test(minted.grant_id) + .await + .unwrap(); + + // Refusing the peer here would be indistinguishable from revocation, so the + // corruption has to surface instead. + assert!(contacts.find_issued_grant(minted.grant_id).await.is_err()); +} diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs index dfe9222..485b769 100644 --- a/crates/vnidrop/src/tests/repository.rs +++ b/crates/vnidrop/src/tests/repository.rs @@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() { async fn persists_transfers_and_events_across_reopen() { let temp = tempfile::tempdir().unwrap(); let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 7); + assert_eq!(repository.schema_version().await.unwrap(), 8); repository .insert_transfer(transfer( 7, @@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() { pool.close().await; let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 7); + assert_eq!(repository.schema_version().await.unwrap(), 8); let stored = repository.list_transfers().await.unwrap().remove(0); assert_eq!(stored.transfer_id, 7); assert_eq!(stored.local_id, "legacy-7-send");