feat(core): persist contacts, grants, and the block list

Schema 7 -> 8 adds contacts, grants_issued, grants_held, and
blocked_endpoints. Kept in their own module so repository.rs does not grow
further; the tables migrate with the rest of the schema through the shared
pool.

Revocation tombstones rather than deletes, so a returning peer is answered
Revoked instead of Unknown and can drop its dead entry. Blocking revokes any
outstanding grant, and unblocking does not hand access back.
This commit is contained in:
2026-08-06 16:12:31 +02:00
parent 4cfee786fc
commit 9fbcf653e8
7 changed files with 926 additions and 3 deletions

View File

@@ -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<String>,
/// Last name the remote sent. Untrusted display data.
pub(crate) remote_display_name: Option<String>,
/// 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<String>,
pub(crate) created_at: i64,
pub(crate) last_transfer_at: Option<i64>,
}
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<Vec<Contact>> {
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<Option<Contact>> {
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<Vec<GrantId>> {
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<Vec<GrantId>> {
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<Option<IssuedGrant>> {
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<i64>,
) -> 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<Vec<GrantId>> {
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<Vec<GrantId>> {
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::<String, _>(0).as_str()))
.collect()
}
async fn all_issued_grant_ids(&self) -> Result<Vec<GrantId>> {
let rows = sqlx::query("SELECT grant_id FROM grants_issued")
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| GrantId::decode(row.get::<String, _>(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<Option<HeldGrant>> {
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<bool> {
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::<i64, _>(0) == 1)
}
pub(crate) async fn list_blocked(&self) -> Result<Vec<String>> {
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<u64> {
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<IssuedGrant> {
let grant_id = GrantId::decode(row.get::<String, _>(0).as_str())?;
let secret = parse_secret(row.get::<String, _>(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<HeldGrant> {
let grant_id = GrantId::decode(row.get::<String, _>(0).as_str())?;
let secret = parse_secret(row.get::<String, _>(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),
})
}

View File

@@ -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<i64>,
}
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 /// 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 /// age, so a relationship in regular use never lapses while a forgotten one
/// cleans itself up. /// cleans itself up.

View File

@@ -1,6 +1,7 @@
mod access_policy; mod access_policy;
mod api; mod api;
mod approval; mod approval;
mod contacts;
mod error; mod error;
mod event_hub; mod event_hub;
mod filesystem; mod filesystem;

View File

@@ -16,11 +16,12 @@ use uuid::Uuid;
use crate::{ use crate::{
access_policy::mode_from_storage, access_policy::mode_from_storage,
api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer}, api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer},
contacts::ContactStore,
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
util::now_ms, util::now_ms,
}; };
const SCHEMA_VERSION: i64 = 7; const SCHEMA_VERSION: i64 = 8;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Repository { pub(crate) struct Repository {
@@ -315,12 +316,22 @@ impl Repository {
.await?; .await?;
} }
crate::contacts::ensure_schema(&self.pool).await?;
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
Ok(()) 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)] #[cfg(test)]
pub(crate) async fn schema_version(&self) -> Result<i64> { pub(crate) async fn schema_version(&self) -> Result<i64> {
let row = sqlx::query("PRAGMA user_version") let row = sqlx::query("PRAGMA user_version")

View File

@@ -1,5 +1,7 @@
#[path = "tests/access_policy.rs"] #[path = "tests/access_policy.rs"]
mod access_policy_tests; mod access_policy_tests;
#[path = "tests/contacts.rs"]
mod contacts_tests;
#[path = "tests/error.rs"] #[path = "tests/error.rs"]
mod error_tests; mod error_tests;
#[path = "tests/filesystem.rs"] #[path = "tests/filesystem.rs"]

View File

@@ -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());
}

View File

@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
async fn persists_transfers_and_events_across_reopen() { async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.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 repository
.insert_transfer(transfer( .insert_transfer(transfer(
7, 7,
@@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
pool.close().await; pool.close().await;
let repository = Repository::open(temp.path()).await.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);
let stored = repository.list_transfers().await.unwrap().remove(0); let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7); assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send"); assert_eq!(stored.local_id, "legacy-7-send");