refactor(core): remove prototype contact paths for experimental saved devices

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 06:05:31 +02:00
parent 0a6ecb5c3b
commit a2385912c2
63 changed files with 514 additions and 7806 deletions

View File

@@ -595,80 +595,6 @@ pub struct TicketInspection {
pub metadata: TransferMetadata,
}
/// A device the user has chosen to remember.
///
/// Deliberately carries no grant material: capabilities never cross the UniFFI
/// boundary, only the fact that one exists (`can_send`).
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct ContactSummary {
pub endpoint_id: String,
/// Set locally by the user. Authoritative for display.
pub local_label: Option<String>,
/// Last name the device claimed. Untrusted; never promoted to the label.
pub remote_display_name: Option<String>,
pub last_transfer_at: Option<i64>,
pub created_at: i64,
/// Whether this device can currently be sent to, i.e. a live grant is held.
/// False after the peer revoked, expired, or reinstalled.
pub can_send: bool,
}
/// Outcome of sending straight to a remembered device.
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct ContactSendResult {
pub share: ShareResult,
/// False when the device was not running: the transfer is held here and the
/// device collects it the next time it opens VniDrop.
pub delivered: bool,
}
/// A transfer this device is holding until its target comes back online.
///
/// Cancelling the underlying transfer withdraws it.
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct HeldOfferSummary {
pub offer_id: String,
pub endpoint_id: String,
pub transfer_id: u64,
pub transfer_name: String,
pub file_count: u64,
pub total_bytes: u64,
pub created_at: i64,
}
/// A transfer a paired device is offering.
///
/// The ticket is deliberately absent: it is a capability, and it is handed over
/// only when the user accepts.
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct IncomingOffer {
pub offer_id: String,
pub from_endpoint_id: String,
pub sender_display_name: Option<String>,
pub transfer_name: String,
pub file_count: u64,
pub total_bytes: u64,
pub received_at: i64,
}
/// A device offering to be remembered, awaiting the local user's decision.
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct PendingPairing {
pub endpoint_id: String,
pub display_name: Option<String>,
pub received_at: i64,
}
/// How long a grant survives without use, renewed on every accepted proof.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum GrantLifetimeSetting {
Days30,
#[default]
Days90,
Days365,
Never,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct ReceiverRequest {
pub id: String,

View File

@@ -180,7 +180,7 @@ impl ApprovalService {
) -> HandshakeResponse {
if self
.repository
.contacts()
.blocked_devices()
.is_blocked(&remote_endpoint_id)
.await
.unwrap_or(true)

View File

@@ -0,0 +1,78 @@
//! Identity-wide deny list for saved-device and invitation traffic.
//!
//! Unreleased prototype contact / grant / held-offer tables are dropped on open
//! so they leave no compatibility commitment or orphaned authorization.
use anyhow::Result;
use sqlx::{Row, SqlitePool};
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
// Prototype artifacts from the unreleased device-history experiment.
for table in ["held_offers", "grants_held", "grants_issued", "contacts"] {
sqlx::query(&format!("DROP TABLE IF EXISTS {table}"))
.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(())
}
/// Durable deny records over the shared repository pool.
#[derive(Debug, Clone)]
pub(crate) struct BlockStore {
pool: SqlitePool,
}
impl BlockStore {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub(crate) async fn block_endpoint(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
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())
}
}

View File

@@ -1,627 +0,0 @@
//! 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.
use anyhow::{Context, Result};
use sqlx::{Row, SqlitePool};
use crate::grant::{parse_secret, GrantId, HeldGrant, IssuedGrant};
/// How long a dead grant is kept before being swept.
///
/// A revoked grant stays as a tombstone so a returning peer is told `Revoked`
/// rather than `Unknown`; after this long, a peer that has not come back is
/// unlikely to, and the row is noise.
pub(crate) const DEAD_GRANT_RETENTION_MS: i64 = 30 * 24 * 60 * 60 * 1_000;
/// 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 held_offers (
offer_id TEXT PRIMARY KEY,
endpoint_id TEXT NOT NULL,
transfer_id INTEGER NOT NULL,
ticket TEXT NOT NULL,
transfer_name TEXT NOT NULL,
sender_display_name TEXT,
file_count INTEGER NOT NULL,
total_bytes INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_held_offers_endpoint ON held_offers(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(())
}
/// An offer that could not be delivered because the target was not running.
///
/// Held on this device, not a server: the share stays here and the receiver
/// collects the ticket when its app next comes to the foreground.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HeldOffer {
pub(crate) offer_id: String,
pub(crate) endpoint_id: String,
pub(crate) transfer_id: u64,
pub(crate) ticket: String,
pub(crate) transfer_name: String,
pub(crate) sender_display_name: Option<String>,
pub(crate) file_count: u64,
pub(crate) total_bytes: u64,
pub(crate) created_at: i64,
}
/// 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())
}
// -- held offers ------------------------------------------------------
pub(crate) async fn insert_held_offer(&self, offer: &HeldOffer) -> Result<()> {
sqlx::query(
r#"
INSERT INTO held_offers
(offer_id, endpoint_id, transfer_id, ticket, transfer_name,
sender_display_name, file_count, total_bytes, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
"#,
)
.bind(&offer.offer_id)
.bind(&offer.endpoint_id)
.bind(offer.transfer_id as i64)
.bind(&offer.ticket)
.bind(&offer.transfer_name)
.bind(offer.sender_display_name.as_deref())
.bind(offer.file_count as i64)
.bind(offer.total_bytes as i64)
.bind(offer.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
/// Offers waiting for one device to come and collect them.
pub(crate) async fn held_offers_for(&self, endpoint_id: &str) -> Result<Vec<HeldOffer>> {
let rows = sqlx::query(
r#"
SELECT offer_id, endpoint_id, transfer_id, ticket, transfer_name,
sender_display_name, file_count, total_bytes, created_at
FROM held_offers
WHERE endpoint_id = ?1
ORDER BY created_at ASC
"#,
)
.bind(endpoint_id)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(row_to_held_offer).collect())
}
pub(crate) async fn list_held_offers(&self) -> Result<Vec<HeldOffer>> {
let rows = sqlx::query(
r#"
SELECT offer_id, endpoint_id, transfer_id, ticket, transfer_name,
sender_display_name, file_count, total_bytes, created_at
FROM held_offers
ORDER BY created_at ASC
"#,
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(row_to_held_offer).collect())
}
/// Consumed once handed over, so a device polling twice is not offered the
/// same transfer again.
pub(crate) async fn delete_held_offers(&self, offer_ids: &[String]) -> Result<()> {
let mut tx = self.pool.begin().await?;
for offer_id in offer_ids {
sqlx::query("DELETE FROM held_offers WHERE offer_id = ?1")
.bind(offer_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub(crate) async fn delete_held_offers_for_transfer(&self, transfer_id: u64) -> Result<()> {
sqlx::query("DELETE FROM held_offers WHERE transfer_id = ?1")
.bind(transfer_id as i64)
.execute(&self.pool)
.await?;
Ok(())
}
// -- 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_held_offer(row: sqlx::sqlite::SqliteRow) -> HeldOffer {
HeldOffer {
offer_id: row.get(0),
endpoint_id: row.get(1),
transfer_id: row.get::<i64, _>(2) as u64,
ticket: row.get(3),
transfer_name: row.get(4),
sender_display_name: row.get(5),
file_count: row.get::<i64, _>(6) as u64,
total_bytes: row.get::<i64, _>(7) as u64,
created_at: row.get(8),
}
}
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

@@ -60,6 +60,7 @@ impl DeviceRelationshipService {
let _guard = peer_lock.lock().await;
let Some(row) = self.find_row(&peer_endpoint_id).await? else {
self.eligibility.remove_for_peer(&peer_endpoint_id).await?;
return Ok(ForgetOutcome {
had_relationship: false,
generation: None,

View File

@@ -39,7 +39,7 @@ mod lifecycle;
#[cfg(test)]
pub(crate) use lifecycle::GenerationTombstone;
use crate::contacts::ContactStore;
use crate::blocked_devices::BlockStore;
use crypto::{
encode_relationship_grant_secret, prove_relationship_grant, secret_from_material,
verify_relationship_grant,
@@ -171,13 +171,13 @@ impl DeviceRelationshipService {
Ok(())
}
pub(super) fn contacts(&self) -> ContactStore {
ContactStore::new(self.pool.clone())
pub(super) fn blocked_devices(&self) -> BlockStore {
BlockStore::new(self.pool.clone())
}
pub(super) async fn is_blocked(&self, endpoint_id: &str) -> bool {
// Fail closed: a store error must not admit blocked traffic.
self.contacts()
self.blocked_devices()
.is_blocked(endpoint_id)
.await
.unwrap_or(true)

View File

@@ -50,10 +50,6 @@ enum EventPhase {
Delivery,
/// Saved-device pairing eligibility and consent prompts.
Pairing,
/// Prototype contact lifecycle notifications.
Contacts,
/// Prototype contact offer prompts.
Offer,
/// Saved-device targeted-transfer pre-approval prompts.
TargetedTransfer,
}
@@ -78,8 +74,6 @@ impl EventPhase {
"transfer" => Some(Self::Transfer),
"delivery" => Some(Self::Delivery),
"pairing" => Some(Self::Pairing),
"contacts" => Some(Self::Contacts),
"offer" => Some(Self::Offer),
"targeted_transfer" => Some(Self::TargetedTransfer),
_ => None,
}
@@ -104,8 +98,6 @@ impl EventPhase {
Self::Transfer => "transfer",
Self::Delivery => "delivery",
Self::Pairing => "pairing",
Self::Contacts => "contacts",
Self::Offer => "offer",
Self::TargetedTransfer => "targeted_transfer",
}
}

View File

@@ -1,24 +1,15 @@
//! Grants: the capability a device issues so a known peer may reach it.
//! Grant identity and possession-proof primitives for saved-device relationships.
//!
//! A history entry is not "I remember this endpoint id", it is "this device
//! issued me a capability". The issuer is the only party that can validate a
//! grant, which is what makes both consent and revocation enforceable: refusing
//! to issue leaves the peer with nothing usable, and deleting the issued record
//! ends the relationship without the peer's cooperation.
//!
//! This module is pure: no storage, no network, no clock of its own. Callers
//! supply `now_ms` so expiry and renewal stay testable.
//! The issuer is the only party that can validate a grant, which is what makes
//! both consent and revocation enforceable. This module is pure: no storage and
//! no network. Relationship-bound MACs live in `device_relationship::crypto`.
use std::fmt;
use anyhow::{bail, Context, Result};
use anyhow::{Context, Result};
use data_encoding::HEXLOWER;
use serde::{Deserialize, Serialize};
/// Domain separator for the possession proof. Changing this invalidates every
/// outstanding grant, so it is versioned rather than edited.
const PROOF_CONTEXT: &[u8] = b"vnidrop-grant-v1";
const GRANT_ID_LEN: usize = 16;
const GRANT_SECRET_LEN: usize = 32;
const CHALLENGE_LEN: usize = 32;
@@ -168,9 +159,6 @@ impl fmt::Debug for GrantProof {
pub(crate) enum GrantRejection {
Unknown,
Revoked,
Expired,
WrongEndpoint,
BadProof,
}
impl GrantRejection {
@@ -178,207 +166,10 @@ impl GrantRejection {
match self {
Self::Unknown => "unknown",
Self::Revoked => "revoked",
Self::Expired => "expired",
Self::WrongEndpoint => "wrong-endpoint",
Self::BadProof => "bad-proof",
}
}
}
/// A grant as held by the party that issued it. This is the authoritative
/// record: `grants_held` on the peer is only a copy for display.
#[derive(Debug, Clone)]
pub(crate) struct IssuedGrant {
pub(crate) grant_id: GrantId,
pub(crate) secret: GrantSecret,
/// The grant is usable only by this endpoint, so it cannot be lent onward.
pub(crate) issued_to_endpoint_id: String,
pub(crate) created_at: i64,
/// Idle expiry, pushed forward on every accepted proof. `None` never expires.
pub(crate) expires_at: Option<i64>,
pub(crate) revoked_at: Option<i64>,
}
impl IssuedGrant {
pub(crate) fn mint(
issued_to_endpoint_id: String,
now_ms: i64,
lifetime: GrantLifetime,
) -> Self {
Self {
grant_id: GrantId::generate(),
secret: GrantSecret::generate(),
issued_to_endpoint_id,
created_at: now_ms,
expires_at: lifetime.deadline_from(now_ms),
revoked_at: None,
}
}
/// Validate a proof presented by `remote_endpoint_id` over this connection's
/// challenge. Returns the renewed expiry the caller must persist.
///
/// Checks run in a fixed order so a caller cannot learn more from an early
/// return than from a late one: revocation and expiry are properties of the
/// issuer's own record, and the endpoint binding is checked before the MAC
/// so a stolen grant cannot be probed for validity from another device.
pub(crate) fn accept(
&self,
proof: &GrantProof,
challenge: &Challenge,
issuer_endpoint_id: &str,
remote_endpoint_id: &str,
now_ms: i64,
lifetime: GrantLifetime,
) -> Result<Option<i64>, GrantRejection> {
if proof.grant_id != self.grant_id {
return Err(GrantRejection::Unknown);
}
if self.revoked_at.is_some() {
return Err(GrantRejection::Revoked);
}
if self.is_expired(now_ms) {
return Err(GrantRejection::Expired);
}
if remote_endpoint_id != self.issued_to_endpoint_id {
return Err(GrantRejection::WrongEndpoint);
}
let expected = compute_proof(
&self.secret,
challenge,
issuer_endpoint_id,
remote_endpoint_id,
);
// Constant-time: blake3::Hash's PartialEq is constant-time by design.
if !constant_time_eq(&expected, &proof.mac) {
return Err(GrantRejection::BadProof);
}
Ok(lifetime.deadline_from(now_ms))
}
pub(crate) fn is_expired(&self, now_ms: i64) -> bool {
self.expires_at
.is_some_and(|expires_at| expires_at < now_ms)
}
}
/// 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
/// age, so a relationship in regular use never lapses while a forgotten one
/// cleans itself up.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GrantLifetime {
Days(u32),
Never,
}
impl GrantLifetime {
pub(crate) const DEFAULT_DAYS: u32 = 90;
pub(crate) fn deadline_from(self, now_ms: i64) -> Option<i64> {
match self {
Self::Never => None,
Self::Days(days) => Some(now_ms + i64::from(days) * 24 * 60 * 60 * 1_000),
}
}
}
impl Default for GrantLifetime {
fn default() -> Self {
Self::Days(Self::DEFAULT_DAYS)
}
}
impl From<crate::api::GrantLifetimeSetting> for GrantLifetime {
fn from(setting: crate::api::GrantLifetimeSetting) -> Self {
match setting {
crate::api::GrantLifetimeSetting::Days30 => Self::Days(30),
crate::api::GrantLifetimeSetting::Days90 => Self::Days(90),
crate::api::GrantLifetimeSetting::Days365 => Self::Days(365),
crate::api::GrantLifetimeSetting::Never => Self::Never,
}
}
}
/// Build the proof for a grant this device holds.
pub(crate) fn prove(
grant_id: GrantId,
secret: &GrantSecret,
challenge: &Challenge,
issuer_endpoint_id: &str,
holder_endpoint_id: &str,
) -> GrantProof {
GrantProof {
grant_id,
mac: compute_proof(secret, challenge, issuer_endpoint_id, holder_endpoint_id),
}
}
/// Keyed MAC over the challenge and both endpoint identities.
///
/// Binding the challenge stops a captured proof being replayed; binding both
/// endpoint ids stops it being replayed against a different peer. Lengths are
/// prefixed so two different id pairs cannot produce the same input.
fn compute_proof(
secret: &GrantSecret,
challenge: &Challenge,
issuer_endpoint_id: &str,
holder_endpoint_id: &str,
) -> [u8; PROOF_LEN] {
let mut input = Vec::with_capacity(
PROOF_CONTEXT.len()
+ CHALLENGE_LEN
+ issuer_endpoint_id.len()
+ holder_endpoint_id.len()
+ 16,
);
input.extend_from_slice(PROOF_CONTEXT);
input.extend_from_slice(&challenge.0);
push_length_prefixed(&mut input, issuer_endpoint_id.as_bytes());
push_length_prefixed(&mut input, holder_endpoint_id.as_bytes());
*blake3::keyed_hash(&secret.0, &input).as_bytes()
}
fn push_length_prefixed(buffer: &mut Vec<u8>, bytes: &[u8]) {
buffer.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
buffer.extend_from_slice(bytes);
}
fn constant_time_eq(left: &[u8; PROOF_LEN], right: &[u8; PROOF_LEN]) -> bool {
// blake3::Hash compares in constant time; reuse it rather than hand-rolling.
blake3::Hash::from_bytes(*left) == blake3::Hash::from_bytes(*right)
}
/// Cryptographically secure random bytes.
///
/// Panics if the OS entropy source fails. That is unrecoverable and must never
@@ -388,13 +179,3 @@ fn random_bytes<const N: usize>() -> [u8; N] {
getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
bytes
}
/// Parse a stored grant secret, rejecting anything malformed rather than
/// silently producing a grant that can never validate.
pub(crate) fn parse_secret(value: &str) -> Result<GrantSecret> {
let secret = GrantSecret::decode(value)?;
if secret.0.iter().all(|byte| *byte == 0) {
bail!("refusing an all-zero grant secret");
}
Ok(secret)
}

View File

@@ -1,7 +1,7 @@
mod access_policy;
mod api;
mod approval;
mod contacts;
mod blocked_devices;
mod control_plane;
mod device_relationship;
mod error;
@@ -10,9 +10,6 @@ mod filesystem;
mod grant;
mod handshake;
mod logging;
mod offer;
mod offer_inbox;
mod pairing;
mod pairing_eligibility;
mod repository;
mod runtime;
@@ -29,11 +26,10 @@ mod util;
pub use api::{
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent,
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage,
DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities,
GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PairingEligibilitySummary,
PendingPairing, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
experimental_saved_device_capabilities, CoreEvent, CoreEventSink, CoreLimits,
CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, DeviceRelationship,
DeviceRelationshipState, ExperimentalSavedDeviceCapabilities, PairingEligibilitySummary,
PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice,
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TargetedTransfer,
TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata,

View File

@@ -1,335 +0,0 @@
//! The contacts protocol: how paired devices reach each other directly.
//!
//! Separate ALPN from the transfer handshake because the trust model differs.
//! `/vnidrop/handshake/2` serves anyone holding a ticket, subject to sender
//! approval. This one serves nobody without a grant (see [`crate::grant`]), so
//! an unpaired device cannot even raise a prompt on the far side.
//!
//! Every request except grant delivery carries a proof over a challenge this
//! connection issued, so a captured proof cannot be replayed onto another
//! connection.
use std::fmt;
use anyhow::Result;
use iroh::{
endpoint::Connection,
protocol::{AcceptError, ProtocolHandler},
Endpoint, EndpointAddr,
};
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
use serde::{Deserialize, Serialize};
use crate::{
grant::{Challenge, GrantId, GrantProof},
offer_inbox::OfferInbox,
pairing::PairingService,
};
#[derive(Clone)]
pub(crate) struct OfferService {
pairing: PairingService,
inbox: OfferInbox,
/// This device's endpoint id. Grants we issued are bound to it, so proofs
/// must be verified against it rather than against whatever a peer claims.
self_endpoint_id: String,
}
impl fmt::Debug for OfferService {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("OfferService")
}
}
impl OfferService {
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/offer/1";
pub(crate) fn new(
pairing: PairingService,
inbox: OfferInbox,
self_endpoint_id: String,
) -> Self {
Self {
pairing,
inbox,
self_endpoint_id,
}
}
pub(crate) fn client(endpoint: Endpoint, addr: EndpointAddr) -> OfferClient {
OfferClient {
inner: Client::boxed(IrohLazyRemoteConnection::new(
endpoint,
addr,
Self::ALPN.to_vec(),
)),
}
}
}
impl OfferService {
/// Validate the grant, then hand the offer to the local user.
///
/// A refusal names the grant failure so the peer can drop a dead entry;
/// `Unknown` covers both "never issued" and "blocked", which is what keeps
/// blocking undetectable.
async fn handle_offer(
&self,
remote_endpoint_id: &str,
challenge: &Challenge,
offer: SubmitOffer,
) -> OfferResponse {
if let Err(rejection) = self
.pairing
.verify_and_renew(
&offer.proof,
challenge,
&self.self_endpoint_id,
remote_endpoint_id,
)
.await
{
return OfferResponse::Refused {
reason: rejection.as_str().to_string(),
};
}
self.inbox
.submit(
remote_endpoint_id.to_string(),
offer.transfer_name,
offer.sender_display_name,
offer.file_count,
offer.total_bytes,
offer.ticket,
)
.await
}
}
impl OfferService {
/// Hand a device the offers this one is holding for it.
///
/// Needs no grant proof: iroh has already authenticated the remote endpoint
/// key, and the only thing returned is what this device already decided to
/// send to precisely that endpoint. A stranger polling gets an empty list.
async fn handle_poll(&self, remote_endpoint_id: &str) -> PolledOffers {
PolledOffers {
offers: self.pairing.collect_held_offers(remote_endpoint_id).await,
}
}
}
impl ProtocolHandler for OfferService {
/// Accepts inbound connections from paired peers.
///
/// The challenge is per connection and never leaves this scope, which is
/// what binds a proof to one session: a proof captured from an earlier
/// connection cannot be presented on a later one.
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let remote_endpoint_id = connection.remote_id().to_string();
let challenge = Challenge::generate();
while let Some(message) = read_request::<OfferProtocol>(&connection).await? {
match message {
OfferMessage::RequestChallenge(message) => {
let WithChannels { tx, .. } = message;
let _ = tx
.send(ChallengeResponse {
challenge: challenge.clone(),
})
.await;
}
OfferMessage::DeliverGrant(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.pairing
.receive_grant(remote_endpoint_id.clone(), inner)
.await;
let _ = tx.send(response).await;
}
OfferMessage::RevokeGrant(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.pairing
.receive_revocation(remote_endpoint_id.clone(), inner)
.await;
let _ = tx.send(response).await;
}
OfferMessage::PollOffers(message) => {
let WithChannels { tx, .. } = message;
let response = self.handle_poll(&remote_endpoint_id).await;
let _ = tx.send(response).await;
}
OfferMessage::SubmitOffer(message) => {
let WithChannels { inner, tx, .. } = message;
let response = self
.handle_offer(&remote_endpoint_id, &challenge, inner)
.await;
let _ = tx.send(response).await;
}
}
}
connection.closed().await;
Ok(())
}
}
#[derive(Debug, Clone)]
pub(crate) struct OfferClient {
inner: Client<OfferProtocol>,
}
impl OfferClient {
pub(crate) async fn poll_offers(&self) -> Result<PolledOffers, irpc::Error> {
self.inner.rpc(PollOffers).await
}
pub(crate) async fn request_challenge(&self) -> Result<Challenge, irpc::Error> {
Ok(self.inner.rpc(RequestChallenge).await?.challenge)
}
pub(crate) async fn submit_offer(
&self,
offer: SubmitOffer,
) -> Result<OfferResponse, irpc::Error> {
self.inner.rpc(offer).await
}
pub(crate) async fn deliver_grant(
&self,
grant: DeliverGrant,
) -> Result<GrantDeliveryResponse, irpc::Error> {
self.inner.rpc(grant).await
}
pub(crate) async fn revoke_grant(
&self,
revocation: RevokeGrant,
) -> Result<RevocationResponse, irpc::Error> {
self.inner.rpc(revocation).await
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RequestChallenge;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ChallengeResponse {
pub(crate) challenge: Challenge,
}
/// Hand a peer the capability to reach this device.
///
/// Carries the secret itself, which is safe only because the iroh connection is
/// already authenticated and encrypted to the recipient's endpoint key. The
/// recipient still has to consent before it is stored.
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct DeliverGrant {
pub(crate) grant_id: GrantId,
/// Hex-encoded grant secret.
pub(crate) secret: String,
pub(crate) expires_at: Option<i64>,
/// Untrusted display data, shown only after the user consents.
pub(crate) display_name: Option<String>,
}
// The secret must not reach a log line through a derived Debug.
impl fmt::Debug for DeliverGrant {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeliverGrant")
.field("grant_id", &self.grant_id)
.field("display_name", &self.display_name)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum GrantDeliveryResponse {
/// Held pending the local user's decision. Not yet a contact.
AwaitingConsent,
/// Stored: the local user had already agreed to remember this device.
Stored,
Rejected {
reason: String,
},
}
/// Tell a peer that a grant it holds is dead, so its entry disappears promptly
/// rather than at its next attempt. Best effort: revocation is already complete
/// on the issuing side before this is sent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RevokeGrant {
pub(crate) grant_id: GrantId,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum RevocationResponse {
Removed,
/// No such grant held from this peer. Also returned when the grant belongs
/// to someone else, so a stranger cannot probe for grant ids.
Unknown,
}
/// Hand a paired device a ticket for content it may fetch.
///
/// The ticket is a capability, so this is sent only over a connection where the
/// grant proof has already been presented.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SubmitOffer {
pub(crate) proof: GrantProof,
pub(crate) ticket: String,
pub(crate) transfer_name: String,
pub(crate) sender_display_name: Option<String>,
pub(crate) file_count: u64,
pub(crate) total_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum OfferResponse {
/// The receiving user agreed. They fetch the content themselves next.
Accepted,
/// The receiving user said no, or never answered.
Declined { reason: String },
/// The grant did not validate. Names the reason so a peer holding a dead
/// grant can clear it.
Refused { reason: String },
}
/// Ask a device whether it is holding anything for this one.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PollOffers;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PolledOffers {
pub(crate) offers: Vec<PolledOffer>,
}
/// An offer collected by polling rather than pushed. Carries the ticket because
/// the sender already decided to send it to this endpoint; the local user still
/// confirms before anything is fetched.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PolledOffer {
pub(crate) ticket: String,
pub(crate) transfer_name: String,
pub(crate) sender_display_name: Option<String>,
pub(crate) file_count: u64,
pub(crate) total_bytes: u64,
}
#[rpc_requests(message = OfferMessage)]
#[derive(Debug, Serialize, Deserialize)]
enum OfferProtocol {
#[rpc(tx=oneshot::Sender<ChallengeResponse>)]
RequestChallenge(RequestChallenge),
#[rpc(tx=oneshot::Sender<GrantDeliveryResponse>)]
DeliverGrant(DeliverGrant),
#[rpc(tx=oneshot::Sender<RevocationResponse>)]
RevokeGrant(RevokeGrant),
#[rpc(tx=oneshot::Sender<OfferResponse>)]
SubmitOffer(SubmitOffer),
#[rpc(tx=oneshot::Sender<PolledOffers>)]
PollOffers(PollOffers),
}

View File

@@ -1,279 +0,0 @@
//! Incoming transfer offers from paired devices.
//!
//! An offer is only a delivery mechanism for a ticket: it replaces the QR code,
//! not the transfer. Accepting hands the ticket to the platform layer, which
//! runs the ordinary receive with its own destination rules.
//!
//! Nothing in this inbox is persisted: a prompt belongs to a live connection,
//! so a restart correctly loses it rather than resurrecting one whose sender is
//! long gone. Offers the *sender* could not deliver are a different thing and
//! do persist — see `held_offers` in [`crate::contacts`].
use std::{collections::HashMap, sync::Arc, time::Duration};
use serde_json::json;
use tokio::sync::{oneshot, Mutex};
use uuid::Uuid;
use crate::{event_hub::EventHub, offer::OfferResponse, util::now_ms};
/// How long the sender waits for the receiving user to decide.
const OFFER_WAIT_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone)]
pub(crate) struct PendingOffer {
pub(crate) offer_id: String,
pub(crate) from_endpoint_id: String,
pub(crate) sender_display_name: Option<String>,
pub(crate) transfer_name: String,
pub(crate) file_count: u64,
pub(crate) total_bytes: u64,
pub(crate) received_at: i64,
/// Released to the caller only once the local user accepts.
ticket: String,
}
struct Waiter {
endpoint_id: String,
responder: oneshot::Sender<bool>,
}
#[derive(Clone)]
pub(crate) struct OfferInbox {
event_hub: Arc<EventHub>,
pending: Arc<Mutex<HashMap<String, PendingOffer>>>,
waiters: Arc<Mutex<HashMap<String, Waiter>>>,
/// Endpoint → time before which new offers are refused.
cooldowns: Arc<Mutex<HashMap<String, i64>>>,
max_pending: usize,
decline_cooldown_ms: i64,
}
impl OfferInbox {
pub(crate) fn new(
event_hub: Arc<EventHub>,
max_pending: usize,
identity_cooldown_ms: u64,
) -> Self {
Self {
event_hub,
pending: Arc::new(Mutex::new(HashMap::new())),
waiters: Arc::new(Mutex::new(HashMap::new())),
cooldowns: Arc::new(Mutex::new(HashMap::new())),
max_pending,
decline_cooldown_ms: identity_cooldown_ms as i64,
}
}
/// Surface an offer and block until the local user decides.
///
/// The caller has already proven a live grant, so this is a known device;
/// the limits here bound nuisance rather than attack.
pub(crate) async fn submit(
&self,
from_endpoint_id: String,
transfer_name: String,
sender_display_name: Option<String>,
file_count: u64,
total_bytes: u64,
ticket: String,
) -> OfferResponse {
let now = now_ms();
{
let mut cooldowns = self.cooldowns.lock().await;
cooldowns.retain(|_, until| *until > now);
if cooldowns.contains_key(&from_endpoint_id) {
return OfferResponse::Declined {
reason: "declined-recently".to_string(),
};
}
}
let offer_id = Uuid::new_v4().to_string();
let (tx, rx) = oneshot::channel();
{
let mut pending = self.pending.lock().await;
if pending.len() >= self.max_pending {
return OfferResponse::Declined {
reason: "too-many-pending-offers".to_string(),
};
}
// One prompt per device at a time: a second offer would stack
// notifications for the same sender.
if pending
.values()
.any(|offer| offer.from_endpoint_id == from_endpoint_id)
{
return OfferResponse::Declined {
reason: "offer-already-pending".to_string(),
};
}
pending.insert(
offer_id.clone(),
PendingOffer {
offer_id: offer_id.clone(),
from_endpoint_id: from_endpoint_id.clone(),
sender_display_name: sender_display_name.clone(),
transfer_name: transfer_name.clone(),
file_count,
total_bytes,
received_at: now,
ticket,
},
);
}
self.waiters.lock().await.insert(
offer_id.clone(),
Waiter {
endpoint_id: from_endpoint_id.clone(),
responder: tx,
},
);
// The ticket is deliberately absent: an event is a log record, and a
// ticket is a capability.
self.event_hub.emit_endpoint(
"offer",
"offer-received",
json!({
"offer_id": offer_id,
"from_endpoint_id": from_endpoint_id,
"sender_display_name": sender_display_name,
"transfer_name": transfer_name,
"file_count": file_count,
"total_bytes": total_bytes,
}),
);
match tokio::time::timeout(OFFER_WAIT_TIMEOUT, rx).await {
Ok(Ok(true)) => OfferResponse::Accepted,
Ok(Ok(false)) => OfferResponse::Declined {
reason: "receiver-declined".to_string(),
},
// Dropped responder or timeout: clear the prompt so it cannot
// linger after the sender has given up.
Ok(Err(_)) | Err(_) => {
self.discard(&offer_id).await;
OfferResponse::Declined {
reason: "no-response".to_string(),
}
}
}
}
/// Add an offer collected by polling.
///
/// Unlike [`Self::submit`] there is no remote waiting on the answer: the
/// sender handed the ticket over and moved on, so this returns immediately.
pub(crate) async fn enqueue(
&self,
from_endpoint_id: String,
transfer_name: String,
sender_display_name: Option<String>,
file_count: u64,
total_bytes: u64,
ticket: String,
) -> bool {
let offer_id = uuid::Uuid::new_v4().to_string();
{
let mut pending = self.pending.lock().await;
if pending.len() >= self.max_pending {
return false;
}
if pending
.values()
.any(|offer| offer.from_endpoint_id == from_endpoint_id)
{
return false;
}
pending.insert(
offer_id.clone(),
PendingOffer {
offer_id: offer_id.clone(),
from_endpoint_id: from_endpoint_id.clone(),
sender_display_name: sender_display_name.clone(),
transfer_name: transfer_name.clone(),
file_count,
total_bytes,
received_at: now_ms(),
ticket,
},
);
}
self.event_hub.emit_endpoint(
"offer",
"offer-collected",
json!({
"offer_id": offer_id,
"from_endpoint_id": from_endpoint_id,
"sender_display_name": sender_display_name,
"transfer_name": transfer_name,
"file_count": file_count,
"total_bytes": total_bytes,
}),
);
true
}
pub(crate) async fn list(&self) -> Vec<PendingOffer> {
self.pending.lock().await.values().cloned().collect()
}
/// Record the local user's decision.
///
/// Returns the ticket on acceptance: it leaves the core at the moment of
/// consent and not before, so a declined offer never hands over a
/// capability. The caller then runs the ordinary receive with it.
pub(crate) async fn respond(&self, offer_id: &str, accepted: bool) -> Option<String> {
let offer = self.pending.lock().await.remove(offer_id)?;
let waiter = self.waiters.lock().await.remove(offer_id);
if !accepted {
self.cooldowns.lock().await.insert(
offer.from_endpoint_id.clone(),
now_ms() + self.decline_cooldown_ms,
);
}
if let Some(waiter) = waiter {
let _ = waiter.responder.send(accepted);
}
self.event_hub.emit_endpoint(
"offer",
if accepted {
"offer-accepted"
} else {
"offer-declined"
},
json!({
"offer_id": offer_id,
"from_endpoint_id": offer.from_endpoint_id,
}),
);
accepted.then_some(offer.ticket)
}
/// Drop every prompt from a device, used when it is forgotten or blocked
/// while an offer is on screen.
pub(crate) async fn discard_from(&self, endpoint_id: &str) {
let ids: Vec<String> = {
let pending = self.pending.lock().await;
pending
.values()
.filter(|offer| offer.from_endpoint_id == endpoint_id)
.map(|offer| offer.offer_id.clone())
.collect()
};
for offer_id in ids {
self.discard(&offer_id).await;
}
}
async fn discard(&self, offer_id: &str) {
self.pending.lock().await.remove(offer_id);
if let Some(waiter) = self.waiters.lock().await.remove(offer_id) {
let _ = waiter.responder.send(false);
let _ = waiter.endpoint_id;
}
}
}

View File

@@ -1,386 +0,0 @@
//! Consent and grant exchange for device history.
//!
//! Mirrors [`crate::approval`]: the protocol handler stays thin and the
//! decisions live here. The rule this module exists to enforce is that a device
//! is remembered only if *both* sides agree — refusing to issue a grant leaves
//! the peer with a contact entry that cannot do anything.
use std::{collections::HashMap, sync::Arc, time::Duration};
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
contacts::ContactStore,
event_hub::EventHub,
grant::{
Challenge, GrantLifetime, GrantProof, GrantRejection, GrantSecret, HeldGrant, IssuedGrant,
},
offer::{DeliverGrant, GrantDeliveryResponse, PolledOffer, RevocationResponse, RevokeGrant},
util::now_ms,
};
/// How long an incoming grant waits for the local user's decision.
///
/// Bounded so a peer cannot park entries in memory indefinitely, and short
/// enough that a stale prompt does not outlive the context the user remembers.
const CONSENT_WINDOW: Duration = Duration::from_secs(10 * 60);
/// A grant a peer has offered, waiting on the local user.
///
/// Not persisted: if the app restarts, the prompt is gone and the peer can
/// offer again. Persisting would resurrect prompts whose context the user has
/// long forgotten.
#[derive(Debug, Clone)]
pub(crate) struct PendingGrant {
pub(crate) peer_endpoint_id: String,
pub(crate) display_name: Option<String>,
pub(crate) received_at: i64,
grant: HeldGrant,
}
#[derive(Clone)]
pub(crate) struct PairingService {
contacts: ContactStore,
event_hub: Arc<EventHub>,
/// Keyed by peer endpoint id: one outstanding offer per peer, so a peer
/// cannot flood the prompt queue by reconnecting.
pending: Arc<Mutex<HashMap<String, PendingGrant>>>,
max_pending: usize,
max_metadata_bytes: u64,
lifetime: Arc<Mutex<GrantLifetime>>,
}
impl PairingService {
pub(crate) fn new(
contacts: ContactStore,
event_hub: Arc<EventHub>,
max_pending: usize,
max_metadata_bytes: u64,
) -> Self {
Self {
contacts,
event_hub,
pending: Arc::new(Mutex::new(HashMap::new())),
max_pending,
max_metadata_bytes,
lifetime: Arc::new(Mutex::new(GrantLifetime::default())),
}
}
pub(crate) async fn set_grant_lifetime(&self, lifetime: GrantLifetime) {
*self.lifetime.lock().await = lifetime;
}
pub(crate) async fn grant_lifetime(&self) -> GrantLifetime {
*self.lifetime.lock().await
}
// -- inbound ----------------------------------------------------------
/// A peer offers this device the capability to reach it.
///
/// Never stored on arrival: an unsolicited grant would otherwise create a
/// contact the local user never agreed to. It waits for consent instead.
pub(crate) async fn receive_grant(
&self,
peer_endpoint_id: String,
delivery: DeliverGrant,
) -> GrantDeliveryResponse {
if self
.contacts
.is_blocked(&peer_endpoint_id)
.await
.unwrap_or(false)
{
// Indistinguishable from any other refusal: blocking must not be
// detectable by probing.
return GrantDeliveryResponse::Rejected {
reason: "not-accepted".to_string(),
};
}
if delivery
.display_name
.as_deref()
.is_some_and(|name| name.len() as u64 > self.max_metadata_bytes)
{
return GrantDeliveryResponse::Rejected {
reason: "metadata-too-large".to_string(),
};
}
let secret = match GrantSecret::decode(&delivery.secret) {
Ok(secret) => secret,
Err(_) => {
return GrantDeliveryResponse::Rejected {
reason: "malformed-grant".to_string(),
}
}
};
let now = now_ms();
let held = HeldGrant {
grant_id: delivery.grant_id,
secret,
peer_endpoint_id: peer_endpoint_id.clone(),
created_at: now,
expires_at: delivery.expires_at,
};
// Already a contact: the user agreed to this relationship, so a refreshed
// grant (re-pairing, or a renewal after reinstall) replaces the old one
// without prompting again.
let already_known = self
.contacts
.find_contact(&peer_endpoint_id)
.await
.ok()
.flatten()
.is_some();
if already_known {
if self.contacts.insert_held_grant(&held).await.is_err() {
return GrantDeliveryResponse::Rejected {
reason: "storage-error".to_string(),
};
}
self.emit(
"grant-refreshed",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
return GrantDeliveryResponse::Stored;
}
let mut pending = self.pending.lock().await;
self.drop_expired(&mut pending, now);
if !pending.contains_key(&peer_endpoint_id) && pending.len() >= self.max_pending {
drop(pending);
return GrantDeliveryResponse::Rejected {
reason: "too-many-pending".to_string(),
};
}
pending.insert(
peer_endpoint_id.clone(),
PendingGrant {
peer_endpoint_id: peer_endpoint_id.clone(),
display_name: delivery.display_name.clone(),
received_at: now,
grant: held,
},
);
drop(pending);
self.emit(
"pairing-requested",
json!({
"peer_endpoint_id": peer_endpoint_id,
"display_name": delivery.display_name,
}),
);
GrantDeliveryResponse::AwaitingConsent
}
/// A peer reports that a grant this device holds is dead.
///
/// Only the issuer may retire its own grant, so the held record must name
/// this peer. A mismatch answers `Unknown` rather than an error, so a
/// stranger cannot probe for grant ids belonging to someone else.
pub(crate) async fn receive_revocation(
&self,
peer_endpoint_id: String,
revocation: RevokeGrant,
) -> RevocationResponse {
let held = self
.contacts
.held_grant_for(&peer_endpoint_id)
.await
.ok()
.flatten();
let Some(held) = held else {
return RevocationResponse::Unknown;
};
if held.grant_id != revocation.grant_id {
return RevocationResponse::Unknown;
}
if self
.contacts
.delete_held_grant(revocation.grant_id)
.await
.is_err()
{
return RevocationResponse::Unknown;
}
self.emit(
"contact-revoked-by-peer",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
RevocationResponse::Removed
}
// -- local decisions --------------------------------------------------
pub(crate) async fn list_pending_grants(&self) -> Vec<PendingGrant> {
let mut pending = self.pending.lock().await;
self.drop_expired(&mut pending, now_ms());
pending.values().cloned().collect()
}
/// Accept a peer's offer to be remembered.
///
/// Stores their grant and records the contact. Issuing our own grant in
/// return is a separate decision the caller makes, because "I want to reach
/// them" and "they may reach me" are independent.
pub(crate) async fn accept_pending_grant(
&self,
peer_endpoint_id: &str,
) -> anyhow::Result<bool> {
let pending = {
let mut pending = self.pending.lock().await;
self.drop_expired(&mut pending, now_ms());
pending.remove(peer_endpoint_id)
};
let Some(pending) = pending else {
return Ok(false);
};
self.contacts
.upsert_contact(
peer_endpoint_id,
pending.display_name.as_deref(),
pending.received_at,
)
.await?;
self.contacts.insert_held_grant(&pending.grant).await?;
self.emit(
"contact-added",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
Ok(true)
}
/// Decline to be reachable through this peer's grant. The grant is dropped
/// unstored, so nothing about the peer is retained.
pub(crate) async fn decline_pending_grant(&self, peer_endpoint_id: &str) -> bool {
let removed = {
let mut pending = self.pending.lock().await;
pending.remove(peer_endpoint_id).is_some()
};
if removed {
self.emit(
"pairing-declined",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
}
removed
}
/// Mint a grant for a peer: our consent to be reached by them.
///
/// The caller delivers it over the offer protocol. Persisted before
/// delivery so a grant we may already have handed over is never forgotten.
pub(crate) async fn issue_grant(&self, peer_endpoint_id: &str) -> anyhow::Result<IssuedGrant> {
let lifetime = self.grant_lifetime().await;
let grant = IssuedGrant::mint(peer_endpoint_id.to_string(), now_ms(), lifetime);
self.contacts.insert_issued_grant(&grant).await?;
self.contacts
.upsert_contact(peer_endpoint_id, None, now_ms())
.await?;
self.emit(
"grant-issued",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
Ok(grant)
}
/// Held offers addressed to `endpoint_id`, consumed as they are handed over.
///
/// Deleting on delivery is what keeps a device that polls twice from being
/// offered the same transfer again.
pub(crate) async fn collect_held_offers(&self, endpoint_id: &str) -> Vec<PolledOffer> {
if self.contacts.is_blocked(endpoint_id).await.unwrap_or(false) {
return Vec::new();
}
let Ok(held) = self.contacts.held_offers_for(endpoint_id).await else {
return Vec::new();
};
if held.is_empty() {
return Vec::new();
}
let ids: Vec<String> = held.iter().map(|offer| offer.offer_id.clone()).collect();
if let Err(error) = self.contacts.delete_held_offers(&ids).await {
// Handing the same offer over twice is worse than not handing it
// over at all, so a failed consume aborts the delivery.
tracing::warn!(%error, "failed to consume held offers");
return Vec::new();
}
self.emit(
"held-offers-collected",
json!({ "peer_endpoint_id": endpoint_id, "count": held.len() }),
);
held.into_iter()
.map(|offer| PolledOffer {
ticket: offer.ticket,
transfer_name: offer.transfer_name,
sender_display_name: offer.sender_display_name,
file_count: offer.file_count,
total_bytes: offer.total_bytes,
})
.collect()
}
/// Validate a proof a peer presented, and push the idle deadline forward.
///
/// The grant record is ours: we issued it, so we are the only party that
/// can decide it is still alive. A blocked endpoint is answered `Unknown`,
/// the same as one we never issued to.
pub(crate) async fn verify_and_renew(
&self,
proof: &GrantProof,
challenge: &Challenge,
issuer_endpoint_id: &str,
remote_endpoint_id: &str,
) -> Result<(), GrantRejection> {
if self
.contacts
.is_blocked(remote_endpoint_id)
.await
.unwrap_or(false)
{
return Err(GrantRejection::Unknown);
}
let grant = self
.contacts
.find_issued_grant(proof.grant_id)
.await
.map_err(|_| GrantRejection::Unknown)?
.ok_or(GrantRejection::Unknown)?;
let now = now_ms();
let lifetime = self.grant_lifetime().await;
let renewed = grant.accept(
proof,
challenge,
issuer_endpoint_id,
remote_endpoint_id,
now,
lifetime,
)?;
// A failed renewal is not grounds to refuse a peer that just proved
// possession; the grant stays valid until its existing deadline.
if let Err(error) = self
.contacts
.renew_issued_grant(proof.grant_id, renewed)
.await
{
tracing::warn!(%error, "failed to renew grant deadline");
}
Ok(())
}
fn drop_expired(&self, pending: &mut HashMap<String, PendingGrant>, now_ms: i64) {
let window = CONSENT_WINDOW.as_millis() as i64;
pending.retain(|_, entry| now_ms - entry.received_at < window);
}
fn emit(&self, kind: &str, data: serde_json::Value) {
self.event_hub.emit_endpoint("contacts", kind, data);
}
}

View File

@@ -252,14 +252,6 @@ impl PairingEligibilityService {
Ok(())
}
pub(crate) async fn remove_all(&self) -> Result<(), VnidropError> {
let entries = self.repository.list_pairing_eligibility_records().await?;
for entry in entries {
self.delete_entry(&entry).await?;
}
Ok(())
}
/// Returns the matching record when the capability is valid; otherwise `None`
/// without emitting prompts or eligibility-removed events for the reject path.
#[allow(

View File

@@ -19,7 +19,7 @@ use crate::{
CoreEvent, PairingEligibilitySummary, ReceivedArtifact, ReceivedLocatorKind,
ReceiverRequest, StoredTransfer,
},
contacts::ContactStore,
blocked_devices::BlockStore,
error::VnidropError,
pairing_eligibility::{PairingEligibilityInsert, PairingEligibilityRecord},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
@@ -336,7 +336,7 @@ impl Repository {
.await?;
}
crate::contacts::ensure_schema(&self.pool).await?;
crate::blocked_devices::ensure_schema(&self.pool).await?;
crate::secure_secret::ensure_schema(&self.pool).await?;
crate::device_relationship::DeviceRelationshipService::ensure_schema(&self.pool).await?;
crate::targeted_transfer::ensure_schema(&self.pool).await?;
@@ -369,10 +369,9 @@ impl Repository {
Ok(())
}
/// Device history, grants, and the block list. Shares this pool so the
/// tables migrate together with the rest of the schema.
pub(crate) fn contacts(&self) -> ContactStore {
ContactStore::new(self.pool.clone())
/// Identity-wide deny list for saved-device and invitation traffic.
pub(crate) fn blocked_devices(&self) -> BlockStore {
BlockStore::new(self.pool.clone())
}
pub(crate) fn sqlite_pool(&self) -> SqlitePool {

View File

@@ -1,868 +0,0 @@
//! Runtime operations for device history: pairing, forgetting, and blocking.
//!
//! The protocol side lives in [`crate::offer`] and the decisions in
//! [`crate::pairing`]; this is where those meet the endpoint and the UniFFI
//! surface.
use std::{sync::Arc, time::Duration};
use anyhow::{Context, Result};
use iroh::{EndpointAddr, EndpointId};
use serde_json::json;
use super::{CoreInner, POLL_MIN_INTERVAL_MS};
use crate::{
api::{
ContactSendResult, ContactSummary, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer,
PendingPairing, ShareMetadataInput, ShareResult, ShareSource, TransferAccessMode,
},
contacts::HeldOffer,
error::VnidropError,
grant::{GrantId, HeldGrant},
offer::{
DeliverGrant, GrantDeliveryResponse, OfferResponse, OfferService, RevokeGrant, SubmitOffer,
},
ticket::{encode_persisted_sender_address, parse_persisted_sender_address},
transfer_state::{TransferDirection, TransferStatus},
util::now_ms,
};
/// How long to wait for a device to answer before treating it as not running.
///
/// Without this an offline peer never fails, it just keeps being retried, and
/// the offer is never handed to the hold-for-later path.
const OFFER_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// Whether a device may be polled again yet.
///
/// Split out because the surrounding call needs two live nodes to exercise,
/// while the window itself is worth asserting on its own.
pub(crate) fn should_poll(last_polled_ms: Option<i64>, now_ms: i64) -> bool {
last_polled_ms.is_none_or(|last| now_ms - last >= POLL_MIN_INTERVAL_MS)
}
impl CoreInner {
pub(super) async fn list_pairing_eligibilities(
&self,
) -> Result<Vec<crate::api::PairingEligibilitySummary>, crate::error::VnidropError> {
self.pairing_eligibility.list().await
}
pub(super) async fn decline_pairing_eligibility(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.pairing_eligibility.decline(&peer_endpoint_id).await
}
pub(super) async fn request_saved_device_pairing(
&self,
peer_endpoint_id: String,
) -> Result<bool, crate::error::VnidropError> {
self.device_relationships
.request_pairing(peer_endpoint_id)
.await
}
pub(super) async fn list_device_relationships(
&self,
) -> Result<Vec<crate::api::DeviceRelationship>, crate::error::VnidropError> {
self.device_relationships.list().await
}
pub(super) async fn list_saved_devices(
&self,
) -> Result<Vec<crate::api::SavedDevice>, crate::error::VnidropError> {
self.device_relationships.list_saved_devices().await
}
pub(super) async fn respond_to_device_pairing(
self: &Arc<Self>,
peer_endpoint_id: String,
accepted: bool,
) -> Result<bool, crate::error::VnidropError> {
if self
.repository
.contacts()
.is_blocked(&peer_endpoint_id)
.await
.unwrap_or(true)
{
return Ok(false);
}
self.device_relationships
.respond_to_pairing(peer_endpoint_id, accepted)
.await
}
pub(super) async fn forget_saved_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let outcome = self
.device_relationships
.forget(peer_endpoint_id.clone())
.await?;
// Targeted transfers for this relationship only.
// Invitation-domain shares are deliberately not cancelled here.
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.emit_endpoint(
"pairing",
"saved-device-forgotten",
json!({
"peer_endpoint_id": peer_endpoint_id,
"had_relationship": outcome.had_relationship,
}),
);
if outcome.had_relationship {
if let Some(generation) = outcome.generation {
self.device_relationships
.notify_remote_revoke(&peer_endpoint_id, generation, outcome.issued_grant_id)
.await;
}
}
Ok(())
}
pub(super) async fn block_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let now = now_ms();
self.repository
.contacts()
.block_endpoint(&peer_endpoint_id, now)
.await
.map_err(VnidropError::repository)?;
self.device_relationships
.revoke_for_block(&peer_endpoint_id)
.await?;
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.offers.discard_from(&peer_endpoint_id).await;
self.emit_endpoint(
"pairing",
"device-blocked",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
// Silence: blocked peers are not notified (design §8).
Ok(())
}
pub(super) async fn unblock_device(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.repository
.contacts()
.unblock_endpoint(&peer_endpoint_id)
.await
.map_err(VnidropError::repository)?;
// Unblock removes only the deny rule; grants/relationships stay gone.
Ok(())
}
pub(super) async fn list_blocked_devices(
&self,
) -> Result<Vec<String>, crate::error::VnidropError> {
self.repository
.contacts()
.list_blocked()
.await
.map_err(VnidropError::repository)
}
pub(super) async fn rotate_relationship_grant(
&self,
peer_endpoint_id: String,
) -> Result<u64, crate::error::VnidropError> {
self.device_relationships
.rotate_relationship_grant(peer_endpoint_id)
.await
}
#[cfg(test)]
pub(super) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
self.targeted_cancel_log
.lock()
.expect("targeted cancel log")
.clone()
}
#[cfg(test)]
pub(super) async fn submit_pairing_eligibility_for_test(
&self,
peer_endpoint_id: String,
session_id: String,
capability: Vec<u8>,
) -> Result<bool, crate::error::VnidropError> {
let material = crate::secure_secret::SecretMaterial::new(capability)?;
self.pairing_eligibility
.accept_presented_eligibility(&peer_endpoint_id, &session_id, &material)
.await
}
pub(super) async fn list_contacts(&self) -> Result<Vec<ContactSummary>> {
let contacts = self
.repository
.contacts()
.list_contacts()
.await
.map_err(VnidropError::repository)?;
let store = self.repository.contacts();
let mut summaries = Vec::with_capacity(contacts.len());
for contact in contacts {
// "Can I reach them" is exactly "do I hold a live grant", so the two
// never drift apart in the UI.
let can_send = store
.held_grant_for(&contact.endpoint_id)
.await
.map_err(VnidropError::repository)?
.is_some();
summaries.push(ContactSummary {
endpoint_id: contact.endpoint_id,
local_label: contact.local_label,
remote_display_name: contact.remote_display_name,
last_transfer_at: contact.last_transfer_at,
created_at: contact.created_at,
can_send,
});
}
Ok(summaries)
}
pub(super) async fn list_pending_pairings(&self) -> Vec<PendingPairing> {
self.pairing
.list_pending_grants()
.await
.into_iter()
.map(|pending| PendingPairing {
endpoint_id: pending.peer_endpoint_id,
display_name: pending.display_name,
received_at: pending.received_at,
})
.collect()
}
/// Agree to be remembered by a peer, and hand them the capability to reach
/// us.
///
/// The grant is persisted before delivery: a grant that may already have
/// arrived must never be one we have forgotten issuing, or the peer would
/// hold a capability we cannot validate or revoke.
pub(super) async fn allow_device_to_reach_me(
self: &Arc<Self>,
endpoint_id: String,
display_name: Option<String>,
) -> Result<()> {
self.limits
.validate_metadata_text("display name", display_name.as_deref())
.map_err(VnidropError::invalid_input)?;
if self
.repository
.contacts()
.is_blocked(&endpoint_id)
.await
.map_err(VnidropError::repository)?
{
return Err(VnidropError::invalid_input(anyhow::anyhow!(
"endpoint is blocked; unblock it before pairing"
))
.into());
}
let grant = self
.pairing
.issue_grant(&endpoint_id)
.await
.map_err(VnidropError::repository)?;
let addr = self.contact_addr(&endpoint_id).await?;
let client = OfferService::client(self.endpoint.clone(), addr);
let response = client
.deliver_grant(DeliverGrant {
grant_id: grant.grant_id,
secret: grant.secret.encode(),
expires_at: grant.expires_at,
display_name,
})
.await
.context("failed to deliver grant")
.map_err(VnidropError::transfer)?;
match response {
GrantDeliveryResponse::AwaitingConsent | GrantDeliveryResponse::Stored => {
self.remember_addr(&endpoint_id).await;
self.emit_endpoint(
"contacts",
"grant-delivered",
json!({ "peer_endpoint_id": endpoint_id }),
);
Ok(())
}
GrantDeliveryResponse::Rejected { reason } => {
// The peer would not take it, so the grant we just minted can
// never be used. Retire it rather than leaving a live
// capability nobody holds.
let _ = self
.repository
.contacts()
.revoke_issued_grant(grant.grant_id, now_ms())
.await;
Err(
VnidropError::transfer(anyhow::anyhow!("peer refused the pairing: {reason}"))
.into(),
)
}
}
}
/// Share content and push the ticket straight to a paired device.
///
/// Two things make this one prompt rather than two: the share is created
/// with the ticket never leaving this device except over the authenticated
/// offer connection, and the target endpoint is pre-authorised so the
/// handshake it runs next does not ask us to approve a transfer we started.
pub(super) async fn send_to_contact(
self: &Arc<Self>,
endpoint_id: String,
sources: Vec<ShareSource>,
mut metadata: ShareMetadataInput,
) -> Result<ContactSendResult> {
let store = self.repository.contacts();
let grant = store
.held_grant_for(&endpoint_id)
.await
.map_err(VnidropError::repository)?
.ok_or_else(|| {
VnidropError::permission(anyhow::anyhow!(
"no live grant for this device; pair with it again"
))
})?;
// Invariant: an offer-created share is never public. The recipient is a
// specific device, so serving it to anyone holding the ticket would
// widen access beyond what the user asked for.
metadata.access_mode = TransferAccessMode::ApprovalRequired;
let sender_name = metadata.sender_name.clone();
let share = self.share_files(sources, metadata).await?;
self.offer_share(endpoint_id, grant, share, sender_name.as_deref())
.await
}
/// Offer a share that already exists, so a transfer created for an
/// invitation can also be pushed to a remembered device.
///
/// The ticket is the one already stored for the transfer: this adds another
/// way to deliver it, it does not create a second share of the same files.
pub(super) async fn offer_transfer_to_contact(
self: &Arc<Self>,
transfer_id: u64,
endpoint_id: String,
) -> Result<ContactSendResult> {
let grant = self
.repository
.contacts()
.held_grant_for(&endpoint_id)
.await
.map_err(VnidropError::repository)?
.ok_or_else(|| {
VnidropError::permission(anyhow::anyhow!(
"no live grant for this device; pair with it again"
))
})?;
let stored = self
.repository
.list_transfers()
.await
.map_err(VnidropError::repository)?
.into_iter()
.find(|transfer| transfer.transfer_id == transfer_id)
.ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!("unknown transfer {transfer_id}"))
})?;
// Only a live share can be offered: a stopped one no longer serves its
// content, so handing out its ticket would promise nothing.
if stored.direction != TransferDirection::Send.as_str()
|| stored.status != TransferStatus::Sharing.as_str()
{
return Err(VnidropError::invalid_input(anyhow::anyhow!(
"transfer {transfer_id} is not an active share"
))
.into());
}
let ticket = stored.ticket.clone().ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!("transfer {transfer_id} has no invitation"))
})?;
let share = ShareResult {
transfer_id,
ticket,
hash: stored.content_hash.unwrap_or_default(),
transfer_name: stored.transfer_name.unwrap_or_default(),
file_count: stored.file_count,
total_size: stored.total_size,
};
self.offer_share(endpoint_id, grant, share, None).await
}
/// Deliver an offer for `share`, holding it when the device is not running.
async fn offer_share(
self: &Arc<Self>,
endpoint_id: String,
grant: HeldGrant,
share: ShareResult,
sender_name: Option<&str>,
) -> Result<ContactSendResult> {
let store = self.repository.contacts();
// An unreachable device is the common case on mobile, not an error: the
// share stays here and the ticket waits for the peer to come and get it.
let outcome = match self
.deliver_offer(&endpoint_id, &grant, &share, sender_name)
.await
{
Ok(outcome) => outcome,
Err(error) => {
self.hold_offer(&endpoint_id, &share, sender_name).await?;
tracing::debug!(%error, "offer held for later pickup");
return Ok(ContactSendResult {
share,
delivered: false,
});
}
};
match outcome {
OfferResponse::Accepted => {
store
.touch_transfer(&endpoint_id, now_ms())
.await
.map_err(VnidropError::repository)?;
self.remember_addr(&endpoint_id).await;
self.emit_transfer(
share.transfer_id,
"send",
"offer",
"offer-accepted",
json!({ "peer_endpoint_id": endpoint_id }),
);
Ok(ContactSendResult {
share,
delivered: true,
})
}
OfferResponse::Declined { reason } | OfferResponse::Refused { reason } => {
let _ = self.cancel_idle_or_share(share.transfer_id).await;
self.emit_transfer(
share.transfer_id,
"send",
"offer",
"offer-refused",
json!({ "peer_endpoint_id": endpoint_id, "reason": reason }),
);
// A refusal naming a dead grant is the peer telling us to stop
// believing we can reach them.
if matches!(reason.as_str(), "revoked" | "unknown" | "expired") {
let _ = store.delete_held_grant(grant.grant_id).await;
}
Err(VnidropError::permission(anyhow::anyhow!(
"device did not accept the transfer: {reason}"
))
.into())
}
}
}
/// Keep an undeliverable offer on this device.
///
/// The target is pre-authorised now rather than at pickup: it will dial
/// straight back after collecting the ticket, and the session outlives the
/// round trip.
async fn hold_offer(
self: &Arc<Self>,
endpoint_id: &str,
share: &ShareResult,
sender_name: Option<&str>,
) -> Result<()> {
self.access_policy
.approve_endpoint(share.transfer_id, endpoint_id.to_string())
.await;
self.repository
.contacts()
.insert_held_offer(&HeldOffer {
offer_id: uuid::Uuid::new_v4().to_string(),
endpoint_id: endpoint_id.to_string(),
transfer_id: share.transfer_id,
ticket: share.ticket.clone(),
transfer_name: share.transfer_name.clone(),
sender_display_name: sender_name.map(ToOwned::to_owned),
file_count: share.file_count,
total_bytes: share.total_size,
created_at: now_ms(),
})
.await
.map_err(VnidropError::repository)?;
self.emit_transfer(
share.transfer_id,
"send",
"offer",
"offer-held",
json!({ "peer_endpoint_id": endpoint_id }),
);
Ok(())
}
/// Ask remembered devices whether they are holding anything for this one.
///
/// Deliberately only ever called from a foreground transition or an explicit
/// user action: polling reveals to every contact that the app was opened,
/// which is why it is neither automatic nor backgrounded.
pub(super) async fn poll_contacts_for_offers(self: &Arc<Self>) -> Result<u64> {
let store = self.repository.contacts();
let contacts = store
.list_contacts()
.await
.map_err(VnidropError::repository)?;
let now = now_ms();
let mut collected = 0u64;
for contact in contacts {
if store
.is_blocked(&contact.endpoint_id)
.await
.unwrap_or(false)
{
continue;
}
{
// Rate limited per device so repeated app switching does not
// turn into a presence beacon.
let mut polled = self.last_polled.lock().await;
if !should_poll(polled.get(&contact.endpoint_id).copied(), now) {
continue;
}
polled.insert(contact.endpoint_id.clone(), now);
}
let Ok(addr) = self.contact_addr(&contact.endpoint_id).await else {
continue;
};
let client = OfferService::client(self.endpoint.clone(), addr);
let Ok(polled) = client.poll_offers().await else {
// Offline is the expected outcome, not a failure worth surfacing.
continue;
};
for offer in polled.offers {
let added = self
.offers
.enqueue(
contact.endpoint_id.clone(),
offer.transfer_name,
offer.sender_display_name,
offer.file_count,
offer.total_bytes,
offer.ticket,
)
.await;
if added {
collected += 1;
}
}
self.remember_addr(&contact.endpoint_id).await;
}
Ok(collected)
}
async fn deliver_offer(
self: &Arc<Self>,
endpoint_id: &str,
grant: &HeldGrant,
share: &ShareResult,
sender_name: Option<&str>,
) -> Result<OfferResponse> {
let addr = self.contact_addr(endpoint_id).await?;
let client = OfferService::client(self.endpoint.clone(), addr);
let challenge = tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge())
.await
.map_err(|_| VnidropError::transfer(anyhow::anyhow!("device did not answer in time")))?
.context("device is not reachable")
.map_err(VnidropError::transfer)?;
// Authorise before offering: the receiver may dial back the instant it
// accepts, and an unauthorised endpoint would be refused by the
// provider.
self.access_policy
.approve_endpoint(share.transfer_id, endpoint_id.to_string())
.await;
client
.submit_offer(SubmitOffer {
proof: grant.prove(&challenge, &self.endpoint.id().to_string()),
ticket: share.ticket.clone(),
transfer_name: share.transfer_name.clone(),
sender_display_name: sender_name.map(ToOwned::to_owned),
file_count: share.file_count,
total_bytes: share.total_size,
})
.await
.context("failed to deliver the offer")
.map_err(VnidropError::transfer)
.map_err(Into::into)
}
/// Transfers waiting for their target to come back online.
pub(super) async fn list_held_offers(&self) -> Result<Vec<HeldOfferSummary>> {
let held = self
.repository
.contacts()
.list_held_offers()
.await
.map_err(VnidropError::repository)?;
Ok(held
.into_iter()
.map(|offer| HeldOfferSummary {
offer_id: offer.offer_id,
endpoint_id: offer.endpoint_id,
transfer_id: offer.transfer_id,
transfer_name: offer.transfer_name,
file_count: offer.file_count,
total_bytes: offer.total_bytes,
created_at: offer.created_at,
})
.collect())
}
pub(super) async fn list_pending_offers(&self) -> Vec<IncomingOffer> {
self.offers
.list()
.await
.into_iter()
.map(|offer| IncomingOffer {
offer_id: offer.offer_id,
from_endpoint_id: offer.from_endpoint_id,
sender_display_name: offer.sender_display_name,
transfer_name: offer.transfer_name,
file_count: offer.file_count,
total_bytes: offer.total_bytes,
received_at: offer.received_at,
})
.collect()
}
/// Answer an incoming offer. Returns the ticket when accepted, so the
/// platform layer can run the ordinary receive with its own destination.
pub(super) async fn respond_to_offer(
&self,
offer_id: String,
accepted: bool,
) -> Option<String> {
self.offers.respond(&offer_id, accepted).await
}
pub(super) async fn respond_to_pairing(
&self,
endpoint_id: String,
accepted: bool,
) -> Result<bool> {
if accepted {
self.pairing
.accept_pending_grant(&endpoint_id)
.await
.map_err(VnidropError::repository)
.map_err(Into::into)
} else {
Ok(self.pairing.decline_pending_grant(&endpoint_id).await)
}
}
/// Stop a peer from reaching us and drop the relationship locally.
///
/// Revocation completes locally first: the notification is best effort and
/// the peer losing access must not depend on being online to hear about it.
pub(super) async fn forget_contact(self: &Arc<Self>, endpoint_id: String) -> Result<()> {
let store = self.repository.contacts();
let revoked = store
.delete_contact(&endpoint_id)
.await
.map_err(VnidropError::repository)?;
// A prompt on screen from a device we just forgot would be actionable
// with a grant that no longer exists.
self.offers.discard_from(&endpoint_id).await;
self.pairing_eligibility
.remove_for_peer(&endpoint_id)
.await
.map_err(anyhow::Error::from)?;
self.emit_endpoint(
"contacts",
"contact-forgotten",
json!({ "peer_endpoint_id": endpoint_id, "revoked": revoked.len() }),
);
self.notify_revoked(endpoint_id, revoked).await;
Ok(())
}
/// Forget every device at once, alongside the existing history-clearing
/// actions. Every peer loses access; each is notified best effort.
pub(super) async fn forget_all_contacts(self: &Arc<Self>) -> Result<u64> {
let store = self.repository.contacts();
let contacts = store
.list_contacts()
.await
.map_err(VnidropError::repository)?;
let revoked = store
.delete_all_contacts()
.await
.map_err(VnidropError::repository)?;
for contact in &contacts {
self.offers.discard_from(&contact.endpoint_id).await;
}
self.pairing_eligibility
.remove_all()
.await
.map_err(anyhow::Error::from)?;
self.emit_endpoint(
"contacts",
"contacts-cleared",
json!({ "contacts": contacts.len(), "revoked": revoked.len() }),
);
for contact in contacts.iter() {
self.notify_revoked(contact.endpoint_id.clone(), revoked.clone())
.await;
}
Ok(revoked.len() as u64)
}
pub(super) async fn block_contact(self: &Arc<Self>, endpoint_id: String) -> Result<()> {
let store = self.repository.contacts();
let revoked = store
.revoke_issued_grants_for(&endpoint_id, now_ms())
.await
.map_err(VnidropError::repository)?;
store
.block_endpoint(&endpoint_id, now_ms())
.await
.map_err(VnidropError::repository)?;
store
.delete_contact(&endpoint_id)
.await
.map_err(VnidropError::repository)?;
self.offers.discard_from(&endpoint_id).await;
self.pairing_eligibility
.remove_for_peer(&endpoint_id)
.await
.map_err(anyhow::Error::from)?;
self.emit_endpoint(
"contacts",
"contact-blocked",
json!({ "peer_endpoint_id": endpoint_id }),
);
// A blocked peer is told nothing: silence here is what makes blocking
// undetectable, unlike ordinary revocation.
let _ = revoked;
Ok(())
}
pub(super) async fn unblock_contact(&self, endpoint_id: String) -> Result<()> {
self.repository
.contacts()
.unblock_endpoint(&endpoint_id)
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(super) async fn list_blocked_contacts(&self) -> Result<Vec<String>> {
self.repository
.contacts()
.list_blocked()
.await
.map_err(VnidropError::repository)
.map_err(Into::into)
}
pub(super) async fn set_contact_label(
&self,
endpoint_id: String,
label: Option<String>,
) -> Result<()> {
self.limits
.validate_metadata_text("contact label", label.as_deref())
.map_err(VnidropError::invalid_input)?;
self.repository
.contacts()
.set_contact_label(&endpoint_id, label.as_deref())
.await
.map_err(VnidropError::repository)?;
Ok(())
}
pub(super) async fn set_grant_lifetime(&self, setting: GrantLifetimeSetting) {
self.pairing.set_grant_lifetime(setting.into()).await;
}
/// Best-effort "your entry is dead" notification, so the peer's list clears
/// promptly instead of at its next attempt.
async fn notify_revoked(self: &Arc<Self>, endpoint_id: String, revoked: Vec<GrantId>) {
if revoked.is_empty() {
return;
}
let Ok(addr) = self.contact_addr(&endpoint_id).await else {
return;
};
let client = OfferService::client(self.endpoint.clone(), addr);
for grant_id in revoked {
if let Err(error) = client.revoke_grant(RevokeGrant { grant_id }).await {
tracing::debug!(%error, "revocation notice undeliverable; peer will learn on next attempt");
return;
}
}
}
/// Where to dial a contact.
///
/// Prefers the address cached from the last successful connection, which is
/// what keeps contacts usable in relay profiles that do not resolve
/// endpoint ids through public discovery.
async fn contact_addr(&self, endpoint_id: &str) -> Result<EndpointAddr> {
let cached = self
.repository
.contacts()
.find_contact(endpoint_id)
.await
.ok()
.flatten()
.and_then(|contact| contact.last_known_addr)
.and_then(|encoded| parse_persisted_sender_address(&encoded).ok());
if let Some(addr) = cached {
return Ok(addr);
}
let parsed: EndpointId = endpoint_id
.parse()
.context("contact has an unusable endpoint id")
.map_err(VnidropError::invalid_input)?;
Ok(EndpointAddr::from(parsed))
}
/// Refresh the cached address after a successful exchange.
async fn remember_addr(&self, endpoint_id: &str) {
let Ok(parsed) = endpoint_id.parse::<EndpointId>() else {
return;
};
let Some(info) = self.endpoint.remote_info(parsed).await else {
return;
};
let mut addr = EndpointAddr::from(parsed);
addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect();
if let Ok(encoded) = encode_persisted_sender_address(&addr) {
let _ = self
.repository
.contacts()
.set_last_known_addr(endpoint_id, &encoded)
.await;
}
}
}

View File

@@ -6,11 +6,10 @@ use serde_json::json;
use super::{CoreInner, IdentityMode};
use crate::{
api::{
ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer,
PairingEligibilitySummary, PendingPairing, ReceiveOutputSink, ReceiveOutputSinkV2,
ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
ShareSource, StoredTransfer, TicketInspection, TransferAccessMode,
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
PairingEligibilitySummary, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact,
ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
StoredTransfer, TicketInspection, TransferAccessMode,
},
error::VnidropError,
filesystem::platform_path,
@@ -634,148 +633,6 @@ impl VnidropCore {
self.block_on(self.inner.resume_targeted_transfer(id, output_dir))
}
/// Devices the user has chosen to remember.
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
self.block_on(self.inner.list_contacts())
.map_err(VnidropError::repository)
}
/// Share content and push it straight to a paired device.
///
/// Only the receiving user is prompted: this device authorised the target
/// when it created the offer.
pub fn send_to_contact(
&self,
endpoint_id: String,
sources: Vec<ShareSource>,
metadata: ShareMetadataInput,
) -> Result<ContactSendResult, VnidropError> {
self.block_on(self.inner.send_to_contact(endpoint_id, sources, metadata))
.map_err(VnidropError::transfer)
}
/// Ask remembered devices whether they are holding transfers for this one.
///
/// Call only from a foreground transition or an explicit user action: it
/// tells every contact that this device is awake. Returns how many offers
/// were collected.
pub fn poll_contacts_for_offers(&self) -> Result<u64, VnidropError> {
self.block_on(self.inner.poll_contacts_for_offers())
.map_err(VnidropError::transfer)
}
/// Offer an existing share to a remembered device.
///
/// Another way to deliver the invitation already created for a transfer,
/// alongside the QR code — not a second share of the same files.
pub fn offer_transfer_to_contact(
&self,
transfer_id: u64,
endpoint_id: String,
) -> Result<ContactSendResult, VnidropError> {
self.block_on(
self.inner
.offer_transfer_to_contact(transfer_id, endpoint_id),
)
.map_err(VnidropError::transfer)
}
/// Transfers this device is holding for contacts that were not running.
pub fn list_held_offers(&self) -> Result<Vec<HeldOfferSummary>, VnidropError> {
self.block_on(self.inner.list_held_offers())
.map_err(VnidropError::repository)
}
/// Transfers paired devices are offering, awaiting this user's decision.
pub fn list_pending_offers(&self) -> Vec<IncomingOffer> {
self.block_on(self.inner.list_pending_offers())
}
/// Accept or decline an incoming offer.
///
/// Returns the ticket when accepted, which the caller passes to `receive`
/// with its own destination. Declining returns none: a refused offer never
/// yields a capability.
pub fn respond_to_offer(&self, offer_id: String, accepted: bool) -> Option<String> {
self.block_on(self.inner.respond_to_offer(offer_id, accepted))
}
/// Devices offering to be remembered, awaiting the local user's decision.
pub fn list_pending_pairings(&self) -> Vec<PendingPairing> {
self.block_on(self.inner.list_pending_pairings())
}
/// Agree to be reachable by a device, handing it a revocable capability.
///
/// Independent of whether that device agrees to be reachable by us: each
/// direction is a separate decision.
pub fn allow_device_to_reach_me(
&self,
endpoint_id: String,
display_name: Option<String>,
) -> Result<(), VnidropError> {
self.block_on(
self.inner
.allow_device_to_reach_me(endpoint_id, display_name),
)
.map_err(VnidropError::transfer)
}
/// Accept or decline a device's offer to be remembered. Returns false when
/// the offer already lapsed.
pub fn respond_to_pairing(
&self,
endpoint_id: String,
accepted: bool,
) -> Result<bool, VnidropError> {
self.block_on(self.inner.respond_to_pairing(endpoint_id, accepted))
.map_err(VnidropError::repository)
}
/// Forget a device and revoke its access. Takes effect locally at once; the
/// peer is notified best effort.
pub fn forget_contact(&self, endpoint_id: String) -> Result<(), VnidropError> {
self.block_on(self.inner.forget_contact(endpoint_id))
.map_err(VnidropError::repository)
}
/// Forget every device at once. Returns how many grants were revoked.
pub fn forget_all_contacts(&self) -> Result<u64, VnidropError> {
self.block_on(self.inner.forget_all_contacts())
.map_err(VnidropError::repository)
}
/// Refuse a device outright. Unlike forgetting, the peer is told nothing.
pub fn block_contact(&self, endpoint_id: String) -> Result<(), VnidropError> {
self.block_on(self.inner.block_contact(endpoint_id))
.map_err(VnidropError::repository)
}
pub fn unblock_contact(&self, endpoint_id: String) -> Result<(), VnidropError> {
self.block_on(self.inner.unblock_contact(endpoint_id))
.map_err(VnidropError::repository)
}
pub fn list_blocked_contacts(&self) -> Result<Vec<String>, VnidropError> {
self.block_on(self.inner.list_blocked_contacts())
.map_err(VnidropError::repository)
}
pub fn set_contact_label(
&self,
endpoint_id: String,
label: Option<String>,
) -> Result<(), VnidropError> {
self.block_on(self.inner.set_contact_label(endpoint_id, label))
.map_err(VnidropError::repository)
}
/// Idle lifetime applied to grants issued from now on. Existing grants keep
/// the lifetime they were issued with until they next renew.
pub fn set_grant_lifetime(&self, lifetime: GrantLifetimeSetting) {
self.block_on(self.inner.set_grant_lifetime(lifetime));
}
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
self.block_on(self.inner.repository.list_transfers())
.map_err(VnidropError::repository)

View File

@@ -54,13 +54,6 @@ impl CoreInner {
drop(active_shares);
self.unregister_transfer_hashes(transfer_id).await;
self.access_policy.remove_transfer(transfer_id).await;
// An offer waiting for pickup would hand out a ticket for content
// this device no longer serves.
let _ = self
.repository
.contacts()
.delete_held_offers_for_transfer(transfer_id)
.await;
self.store.tags().delete(share_tag_name(&local_id)).await?;
self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({}));
return Ok(());

View File

@@ -6,21 +6,19 @@
//! - [`receive`] — ticket receive, download, export
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
//! - [`provider`] — blob provider events and per-connection send progress
//! - [`contacts`] — device history: pairing, forgetting, blocking
//! - [`saved_devices`] — experimental saved-device pairing, forget, block
//! - [`targeted`] — saved-device targeted transfers
mod contacts;
mod delivery;
mod facade;
mod lifecycle;
mod provider;
mod receive;
mod saved_devices;
mod share;
mod storage;
mod targeted;
#[cfg(test)]
pub(crate) use self::contacts::should_poll;
pub use facade::VnidropCore;
#[cfg(test)]
pub(crate) use provider::{consume_request_updates, RequestStreamOutcome};
@@ -62,9 +60,6 @@ use crate::{
event_hub::EventHub,
handshake::HandshakeService,
logging::init_logging,
offer::OfferService,
offer_inbox::OfferInbox,
pairing::PairingService,
pairing_eligibility::PairingEligibilityService,
repository::Repository,
secret::load_or_create_secret,
@@ -76,10 +71,6 @@ use crate::{
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Minimum gap between polls of the same device, so switching in and out of the
/// app does not announce presence to every contact repeatedly.
pub(super) const POLL_MIN_INTERVAL_MS: i64 = 5 * 60 * 1_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RelayStatus {
Disabled,
@@ -109,13 +100,9 @@ pub(super) struct CoreInner {
pub(super) secret_custody: Option<Arc<crate::secure_secret::SecretCustody>>,
pub(super) event_hub: Arc<EventHub>,
pub(super) approval: ApprovalService,
pub(super) pairing: PairingService,
pub(super) pairing_eligibility: PairingEligibilityService,
pub(super) device_relationships: Arc<DeviceRelationshipService>,
pub(super) offers: OfferInbox,
pub(super) targeted_offers: TargetedOfferInbox,
/// Endpoint → last poll time, for the rate limit above.
pub(super) last_polled: TokioMutex<HashMap<String, i64>>,
pub(super) limits: CoreLimits,
pub(super) relay_mode: CoreRelayMode,
pub(super) custom_relay_urls: Vec<RelayUrl>,
@@ -380,25 +367,6 @@ impl CoreInner {
Some(pairing_eligibility.clone()),
);
let handshake = HandshakeService::new(approval.clone());
let pairing = PairingService::new(
repository.contacts(),
event_hub.clone(),
limits.max_pending_offers as usize,
limits.max_metadata_bytes,
);
// Sweep grants dead long enough that no peer still needs the tombstone.
if let Err(error) = repository
.contacts()
.purge_dead_grants(crate::util::now_ms() - crate::contacts::DEAD_GRANT_RETENTION_MS)
.await
{
tracing::warn!(%error, "failed to sweep dead grants");
}
let offers = OfferInbox::new(
event_hub.clone(),
limits.max_pending_offers as usize,
limits.identity_cooldown_ms,
);
let identity_cooldown = crate::control_plane::IdentityCooldown::new(
limits.identity_cooldown_ms,
limits.malformed_strike_limit,
@@ -424,10 +392,6 @@ impl CoreInner {
let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs)
.accept(HandshakeService::ALPN, handshake)
.accept(
OfferService::ALPN,
OfferService::new(pairing.clone(), offers.clone(), endpoint.id().to_string()),
)
.accept(
RelationshipProtocol::ALPN,
RelationshipProtocol::new(device_relationships.clone()),
@@ -456,12 +420,9 @@ impl CoreInner {
secret_custody: secret_custody.clone(),
event_hub,
approval,
pairing,
pairing_eligibility,
device_relationships,
offers,
targeted_offers,
last_polled: TokioMutex::new(HashMap::new()),
relay_mode,
custom_relay_urls: relay_urls,
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),

View File

@@ -0,0 +1,171 @@
//! Runtime operations for experimental saved devices and device relationships.
use std::sync::Arc;
use anyhow::Result;
use serde_json::json;
use super::CoreInner;
use crate::{error::VnidropError, util::now_ms};
impl CoreInner {
pub(super) async fn list_pairing_eligibilities(
&self,
) -> Result<Vec<crate::api::PairingEligibilitySummary>, crate::error::VnidropError> {
self.pairing_eligibility.list().await
}
pub(super) async fn decline_pairing_eligibility(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.pairing_eligibility.decline(&peer_endpoint_id).await
}
pub(super) async fn request_saved_device_pairing(
&self,
peer_endpoint_id: String,
) -> Result<bool, crate::error::VnidropError> {
self.device_relationships
.request_pairing(peer_endpoint_id)
.await
}
pub(super) async fn list_device_relationships(
&self,
) -> Result<Vec<crate::api::DeviceRelationship>, crate::error::VnidropError> {
self.device_relationships.list().await
}
pub(super) async fn list_saved_devices(
&self,
) -> Result<Vec<crate::api::SavedDevice>, crate::error::VnidropError> {
self.device_relationships.list_saved_devices().await
}
pub(super) async fn respond_to_device_pairing(
self: &Arc<Self>,
peer_endpoint_id: String,
accepted: bool,
) -> Result<bool, crate::error::VnidropError> {
if self
.repository
.blocked_devices()
.is_blocked(&peer_endpoint_id)
.await
.unwrap_or(true)
{
return Ok(false);
}
self.device_relationships
.respond_to_pairing(peer_endpoint_id, accepted)
.await
}
pub(super) async fn forget_saved_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let outcome = self
.device_relationships
.forget(peer_endpoint_id.clone())
.await?;
// Targeted transfers for this relationship only.
// Invitation-domain shares are deliberately not cancelled here.
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.emit_endpoint(
"pairing",
"saved-device-forgotten",
json!({
"peer_endpoint_id": peer_endpoint_id,
"had_relationship": outcome.had_relationship,
}),
);
if outcome.had_relationship {
if let Some(generation) = outcome.generation {
self.device_relationships
.notify_remote_revoke(&peer_endpoint_id, generation, outcome.issued_grant_id)
.await;
}
}
Ok(())
}
pub(super) async fn block_device(
self: &Arc<Self>,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
let now = now_ms();
self.repository
.blocked_devices()
.block_endpoint(&peer_endpoint_id, now)
.await
.map_err(VnidropError::repository)?;
self.device_relationships
.revoke_for_block(&peer_endpoint_id)
.await?;
self.cancel_targeted_transfers_for_peer(&peer_endpoint_id)
.await?;
self.emit_endpoint(
"pairing",
"device-blocked",
json!({ "peer_endpoint_id": peer_endpoint_id }),
);
// Silence: blocked peers are not notified (design §8).
Ok(())
}
pub(super) async fn unblock_device(
&self,
peer_endpoint_id: String,
) -> Result<(), crate::error::VnidropError> {
self.repository
.blocked_devices()
.unblock_endpoint(&peer_endpoint_id)
.await
.map_err(VnidropError::repository)?;
// Unblock removes only the deny rule; grants/relationships stay gone.
Ok(())
}
pub(super) async fn list_blocked_devices(
&self,
) -> Result<Vec<String>, crate::error::VnidropError> {
self.repository
.blocked_devices()
.list_blocked()
.await
.map_err(VnidropError::repository)
}
pub(super) async fn rotate_relationship_grant(
&self,
peer_endpoint_id: String,
) -> Result<u64, crate::error::VnidropError> {
self.device_relationships
.rotate_relationship_grant(peer_endpoint_id)
.await
}
#[cfg(test)]
pub(super) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
self.targeted_cancel_log
.lock()
.expect("targeted cancel log")
.clone()
}
#[cfg(test)]
pub(super) async fn submit_pairing_eligibility_for_test(
&self,
peer_endpoint_id: String,
session_id: String,
capability: Vec<u8>,
) -> Result<bool, crate::error::VnidropError> {
let material = crate::secure_secret::SecretMaterial::new(capability)?;
self.pairing_eligibility
.accept_presented_eligibility(&peer_endpoint_id, &session_id, &material)
.await
}
}

View File

@@ -1,9 +1,9 @@
#[path = "tests/access_policy.rs"]
mod access_policy_tests;
#[path = "tests/contact_polling.rs"]
mod contact_polling_tests;
#[path = "tests/contacts.rs"]
mod contacts_tests;
#[path = "tests/api_surface.rs"]
mod api_surface_tests;
#[path = "tests/blocked_devices.rs"]
mod blocked_devices_tests;
#[path = "tests/control_plane.rs"]
mod control_plane_tests;
#[path = "tests/device_relationship.rs"]

View File

@@ -0,0 +1,73 @@
//! Public API surface after prototype contact/offer removal.
#[test]
fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_points() {
let facade = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/runtime/facade.rs"
));
let api = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/api.rs"));
let lib = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs"));
for forbidden in [
"fn list_contacts(",
"fn send_to_contact(",
"fn poll_contacts_for_offers(",
"fn offer_transfer_to_contact(",
"fn list_held_offers(",
"fn list_pending_offers(",
"fn respond_to_offer(",
"fn list_pending_pairings(",
"fn allow_device_to_reach_me(",
"fn respond_to_pairing(",
"fn forget_contact(",
"fn forget_all_contacts(",
"fn block_contact(",
"fn unblock_contact(",
"fn list_blocked_contacts(",
"fn set_contact_label(",
"fn set_grant_lifetime(",
"struct ContactSummary",
"struct ContactSendResult",
"struct HeldOfferSummary",
"struct IncomingOffer",
"struct PendingPairing",
"enum GrantLifetimeSetting",
] {
assert!(
!facade.contains(forbidden),
"facade must not expose prototype entry point {forbidden}"
);
assert!(
!api.contains(forbidden),
"api.rs must not define prototype type {forbidden}"
);
assert!(
!lib.contains(forbidden),
"lib.rs must not re-export prototype symbol {forbidden}"
);
}
for required in [
"fn list_saved_devices(",
"fn list_device_relationships(",
"fn request_saved_device_pairing(",
"fn create_targeted_transfer(",
"fn list_pending_targeted_offers(",
"fn block_device(",
"fn forget_saved_device(",
"fn share_files(",
"fn receive(",
"experimental_saved_device_capabilities",
] {
assert!(
facade.contains(required) || api.contains(required) || lib.contains(required),
"public surface must keep {required}"
);
}
let caps = crate::experimental_saved_device_capabilities();
assert_eq!(caps.domain_contract_version, 1);
assert_eq!(caps.relationship_protocol_version, 1);
assert_eq!(caps.targeted_transfer_protocol_version, 1);
}

View File

@@ -0,0 +1,66 @@
use crate::{blocked_devices::BlockStore, repository::Repository};
async fn store(temp: &tempfile::TempDir) -> BlockStore {
let repository = Repository::open(temp.path()).await.unwrap();
repository.blocked_devices()
}
#[tokio::test]
async fn block_list_persists_and_unblocks() {
let temp = tempfile::tempdir().unwrap();
let blocks = store(&temp).await;
assert!(!blocks.is_blocked("peer-a").await.unwrap());
blocks.block_endpoint("peer-a", 100).await.unwrap();
assert!(blocks.is_blocked("peer-a").await.unwrap());
assert_eq!(
blocks.list_blocked().await.unwrap(),
vec!["peer-a".to_string()]
);
blocks.unblock_endpoint("peer-a").await.unwrap();
assert!(!blocks.is_blocked("peer-a").await.unwrap());
assert!(blocks.list_blocked().await.unwrap().is_empty());
}
#[tokio::test]
async fn opening_repository_drops_unreleased_prototype_tables() {
let temp = tempfile::tempdir().unwrap();
let db = temp.path().join("vnidrop.sqlite3");
{
let options = sqlx::sqlite::SqliteConnectOptions::new()
.filename(&db)
.create_if_missing(true);
let pool = sqlx::SqlitePool::connect_with(options).await.unwrap();
for ddl in [
"CREATE TABLE contacts (endpoint_id TEXT PRIMARY KEY)",
"CREATE TABLE grants_issued (grant_id TEXT PRIMARY KEY, grant_secret TEXT NOT NULL)",
"CREATE TABLE grants_held (grant_id TEXT PRIMARY KEY, grant_secret TEXT NOT NULL)",
"CREATE TABLE held_offers (offer_id TEXT PRIMARY KEY, ticket TEXT NOT NULL)",
"CREATE TABLE blocked_endpoints (endpoint_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL)",
"INSERT INTO blocked_endpoints (endpoint_id, created_at) VALUES ('keep-me', 1)",
"INSERT INTO held_offers (offer_id, ticket) VALUES ('orphan', 'ticket')",
] {
sqlx::query(ddl).execute(&pool).await.unwrap();
}
}
let repository = Repository::open(temp.path()).await.unwrap();
let pool = repository.sqlite_pool();
for table in ["contacts", "grants_issued", "grants_held", "held_offers"] {
let row = sqlx::query(&format!(
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = '{table}'"
))
.fetch_one(&pool)
.await
.unwrap();
let n: i64 = sqlx::Row::get(&row, "n");
assert_eq!(n, 0, "{table} must be dropped without migration");
}
assert!(repository
.blocked_devices()
.is_blocked("keep-me")
.await
.unwrap());
}

View File

@@ -1,30 +0,0 @@
use crate::runtime::should_poll;
const MINUTE_MS: i64 = 60 * 1_000;
const NOW: i64 = 1_700_000_000_000;
#[test]
fn a_device_never_polled_is_polled() {
assert!(should_poll(None, NOW));
}
#[test]
fn a_device_polled_recently_is_skipped() {
// Switching in and out of the app must not re-announce presence.
assert!(!should_poll(Some(NOW), NOW));
assert!(!should_poll(Some(NOW - MINUTE_MS), NOW));
assert!(!should_poll(Some(NOW - 4 * MINUTE_MS), NOW));
}
#[test]
fn a_device_polled_before_the_window_is_polled_again() {
assert!(should_poll(Some(NOW - 5 * MINUTE_MS), NOW));
assert!(should_poll(Some(NOW - 60 * MINUTE_MS), NOW));
}
/// A clock that jumped backwards must not lock polling out forever.
#[test]
fn a_future_timestamp_is_treated_as_recent_rather_than_permanent() {
assert!(!should_poll(Some(NOW + MINUTE_MS), NOW));
assert!(should_poll(Some(NOW + MINUTE_MS), NOW + 6 * MINUTE_MS));
}

View File

@@ -1,386 +0,0 @@
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

@@ -524,13 +524,13 @@ fn reinstalled_peer_is_never_merged_by_name_or_metadata() {
// Same display-facing label on a different endpoint identity must not merge.
alice
.core
.set_contact_label(bob_id.clone(), Some("Kitchen Tablet".to_string()))
.ok();
.set_saved_device_label(bob_id.clone(), Some("Kitchen Tablet".to_string()))
.unwrap();
reach_saved(&alice, &charlie, 90_041);
alice
.core
.set_contact_label(charlie_id.clone(), Some("Kitchen Tablet".to_string()))
.ok();
.set_saved_device_label(charlie_id.clone(), Some("Kitchen Tablet".to_string()))
.unwrap();
let saved = alice.core.list_saved_devices().unwrap();
assert_eq!(saved.len(), 2);

View File

@@ -1,226 +1,35 @@
use crate::grant::{
parse_secret, prove, Challenge, GrantId, GrantLifetime, GrantRejection, GrantSecret,
IssuedGrant,
};
const ISSUER: &str = "issuer-endpoint";
const HOLDER: &str = "holder-endpoint";
const DAY_MS: i64 = 24 * 60 * 60 * 1_000;
fn issued(now_ms: i64) -> IssuedGrant {
IssuedGrant::mint(HOLDER.to_string(), now_ms, GrantLifetime::default())
}
fn accept_with(
grant: &IssuedGrant,
challenge: &Challenge,
remote_endpoint_id: &str,
now_ms: i64,
) -> Result<Option<i64>, GrantRejection> {
let proof = prove(
grant.grant_id,
&grant.secret,
challenge,
ISSUER,
remote_endpoint_id,
);
grant.accept(
&proof,
challenge,
ISSUER,
remote_endpoint_id,
now_ms,
GrantLifetime::default(),
)
}
use crate::grant::{Challenge, GrantId, GrantRejection, GrantSecret};
#[test]
fn accepts_a_valid_proof_and_returns_the_renewed_deadline() {
let now = 1_700_000_000_000;
let grant = issued(now);
let challenge = Challenge::generate();
let renewed = accept_with(&grant, &challenge, HOLDER, now + DAY_MS).expect("proof accepted");
assert_eq!(renewed, Some(now + DAY_MS + 90 * DAY_MS));
}
#[test]
fn renewal_extends_past_the_original_expiry() {
let now = 1_700_000_000_000;
let grant = issued(now);
let original = grant.expires_at.expect("default lifetime expires");
// Used one day before lapsing: the new deadline must be later than the old.
let use_at = original - DAY_MS;
let renewed = accept_with(&grant, &Challenge::generate(), HOLDER, use_at)
.expect("proof accepted")
.expect("renewed deadline");
assert!(renewed > original);
}
#[test]
fn rejects_a_proof_bound_to_a_different_challenge() {
let now = 1_700_000_000_000;
let grant = issued(now);
let captured = Challenge::from_bytes([7u8; 32]);
let proof = prove(grant.grant_id, &grant.secret, &captured, ISSUER, HOLDER);
// Replaying a captured proof against a fresh challenge must fail.
let outcome = grant.accept(
&proof,
&Challenge::from_bytes([9u8; 32]),
ISSUER,
HOLDER,
now,
GrantLifetime::default(),
);
assert_eq!(outcome, Err(GrantRejection::BadProof));
}
#[test]
fn rejects_a_proof_from_an_endpoint_the_grant_was_not_issued_to() {
let now = 1_700_000_000_000;
let grant = issued(now);
let outcome = accept_with(&grant, &Challenge::generate(), "someone-else", now);
assert_eq!(outcome, Err(GrantRejection::WrongEndpoint));
}
#[test]
fn rejects_a_proof_replayed_against_a_different_issuer() {
let now = 1_700_000_000_000;
let grant = issued(now);
let challenge = Challenge::generate();
let proof = prove(
grant.grant_id,
&grant.secret,
&challenge,
"other-issuer",
HOLDER,
);
let outcome = grant.accept(
&proof,
&challenge,
ISSUER,
HOLDER,
now,
GrantLifetime::default(),
);
assert_eq!(outcome, Err(GrantRejection::BadProof));
}
#[test]
fn rejects_a_revoked_grant_distinguishably() {
let now = 1_700_000_000_000;
let mut grant = issued(now);
grant.revoked_at = Some(now);
// Revocation is reported as such so the peer can drop the dead entry.
fn grant_identifiers_round_trip() {
let grant_id = GrantId::generate();
assert_eq!(
accept_with(&grant, &Challenge::generate(), HOLDER, now),
Err(GrantRejection::Revoked)
GrantId::decode(&grant_id.encode()).expect("id decodes"),
grant_id
);
}
#[test]
fn rejects_an_idle_grant_after_its_deadline() {
let now = 1_700_000_000_000;
let grant = issued(now);
let expires_at = grant.expires_at.expect("default lifetime expires");
assert_eq!(
accept_with(&grant, &Challenge::generate(), HOLDER, expires_at),
Ok(Some(expires_at + 90 * DAY_MS)),
"a grant is still usable on its deadline"
);
assert_eq!(
accept_with(&grant, &Challenge::generate(), HOLDER, expires_at + 1),
Err(GrantRejection::Expired)
);
}
#[test]
fn rejects_a_proof_for_a_different_grant_id() {
let now = 1_700_000_000_000;
let grant = issued(now);
let other = issued(now);
let challenge = Challenge::generate();
let proof = prove(other.grant_id, &other.secret, &challenge, ISSUER, HOLDER);
let outcome = grant.accept(
&proof,
&challenge,
ISSUER,
HOLDER,
now,
GrantLifetime::default(),
);
assert_eq!(outcome, Err(GrantRejection::Unknown));
}
#[test]
fn never_lifetime_produces_no_deadline() {
let now = 1_700_000_000_000;
let grant = IssuedGrant::mint(HOLDER.to_string(), now, GrantLifetime::Never);
assert_eq!(grant.expires_at, None);
let challenge = Challenge::generate();
let proof = prove(grant.grant_id, &grant.secret, &challenge, ISSUER, HOLDER);
let renewed = grant
.accept(
&proof,
&challenge,
ISSUER,
HOLDER,
now + 10_000 * DAY_MS,
GrantLifetime::Never,
)
.expect("proof accepted");
assert_eq!(renewed, None);
}
#[test]
fn grant_ids_and_secrets_round_trip_through_storage_encoding() {
let id = GrantId::generate();
assert_eq!(GrantId::decode(&id.encode()).expect("decodes"), id);
let secret = GrantSecret::generate();
assert_eq!(parse_secret(&secret.encode()).expect("decodes"), secret);
}
assert_eq!(
GrantSecret::decode(&secret.encode()).expect("secret decodes"),
secret
);
assert_eq!(format!("{secret:?}"), "GrantSecret(redacted)");
#[test]
fn rejects_malformed_or_degenerate_stored_secrets() {
assert!(parse_secret("not-hex").is_err());
assert!(parse_secret("aabb").is_err(), "wrong length");
assert!(
parse_secret(&"00".repeat(32)).is_err(),
"an all-zero secret means corrupt storage, not a usable grant"
let challenge = Challenge::generate();
assert_eq!(
Challenge::decode(&challenge.encode()).expect("challenge decodes"),
challenge
);
}
#[test]
fn secrets_are_redacted_in_debug_output() {
let secret = GrantSecret::generate();
let rendered = format!("{secret:?}");
assert!(!rendered.contains(&secret.encode()));
assert_eq!(rendered, "GrantSecret(redacted)");
fn grant_secret_decode_rejects_garbage() {
assert!(GrantSecret::decode("not-hex").is_err());
assert!(GrantSecret::decode("aabb").is_err(), "wrong length");
}
#[test]
fn generated_grants_are_unique() {
let now = 1_700_000_000_000;
let first = issued(now);
let second = issued(now);
assert_ne!(first.grant_id, second.grant_id);
assert_ne!(first.secret, second.secret);
fn grant_rejection_labels_are_stable() {
assert_eq!(GrantRejection::Unknown.as_str(), "unknown");
assert_eq!(GrantRejection::Revoked.as_str(), "revoked");
}

View File

@@ -399,7 +399,7 @@ fn decline_forget_block_and_replay_remove_eligibility_idempotently() {
wait_for_eligibility(&receiver.core, &sender2.core.status().endpoint_id);
receiver
.core
.forget_contact(sender2.core.status().endpoint_id.clone())
.forget_saved_device(sender2.core.status().endpoint_id.clone())
.unwrap();
assert!(receiver
.core
@@ -423,7 +423,7 @@ fn decline_forget_block_and_replay_remove_eligibility_idempotently() {
wait_for_eligibility(&receiver.core, &sender3.core.status().endpoint_id);
receiver
.core
.block_contact(sender3.core.status().endpoint_id.clone())
.block_device(sender3.core.status().endpoint_id.clone())
.unwrap();
assert!(receiver
.core
@@ -445,12 +445,16 @@ fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() {
let receiver_id = receiver.core.status().endpoint_id.clone();
let events_before = receiver.sink.events().len();
// Missing eligibility: request produces no pending pairing prompt/event.
// Missing eligibility: request produces no pending relationship prompt/event.
assert!(!receiver
.core
.request_saved_device_pairing(sender.core.status().endpoint_id.clone())
.unwrap());
assert!(receiver.core.list_pending_pairings().is_empty());
assert!(receiver
.core
.list_device_relationships()
.unwrap()
.is_empty());
assert_eq!(
receiver
.sink
@@ -483,8 +487,22 @@ fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() {
.core
.request_saved_device_pairing(receiver_id)
.unwrap());
assert!(sender.core.list_pending_pairings().is_empty());
assert!(receiver.core.list_pending_pairings().is_empty());
assert_eq!(
sender
.core
.list_device_relationships()
.unwrap()
.iter()
.filter(|row| row.state == crate::DeviceRelationshipState::PendingOutgoing)
.count(),
1
);
assert!(receiver
.core
.list_device_relationships()
.unwrap()
.iter()
.any(|row| row.state == crate::DeviceRelationshipState::PendingIncoming));
// Fabricated peer identity is rejected without growing pairing events.
let pairing_events_before = receiver
@@ -501,7 +519,12 @@ fn missing_expired_replayed_and_fabricated_eligibility_are_silently_rejected() {
vec![7u8; 32],
)
.unwrap());
assert!(receiver.core.list_pending_pairings().is_empty());
assert!(receiver
.core
.list_device_relationships()
.unwrap()
.iter()
.all(|row| row.remote_endpoint_id != "fabricated-endpoint"));
let pairing_events_after = receiver
.sink
.events()

View File

@@ -1,651 +0,0 @@
//! Send-to-contact offers between two real nodes.
mod support;
use std::{
path::Path,
sync::Arc,
time::{Duration, Instant},
};
use support::{RecordingSink, TestNode};
use vnidrop::{
ContactSendResult, IncomingOffer, ShareMetadataInput, ShareSource, SourceKind,
TransferAccessMode, VnidropCore, VnidropError,
};
fn endpoint_id(node: &TestNode) -> String {
node.core.status().endpoint_id
}
/// Establish a one-way relationship: `issuer` becomes reachable by `holder`.
fn pair(issuer: &TestNode, holder: &TestNode) {
let issuer_id = endpoint_id(issuer);
issuer
.core
.allow_device_to_reach_me(endpoint_id(holder), Some("Issuer".to_string()))
.expect("grant delivered");
let started = Instant::now();
while !holder
.core
.list_pending_pairings()
.iter()
.any(|pending| pending.endpoint_id == issuer_id)
{
assert!(
started.elapsed() < Duration::from_secs(10),
"pairing offer never surfaced"
);
std::thread::sleep(Duration::from_millis(25));
}
holder
.core
.respond_to_pairing(issuer_id, true)
.expect("consent recorded");
}
fn sources(path: &Path) -> Vec<ShareSource> {
vec![ShareSource {
kind: SourceKind::Path,
value: path.to_string_lossy().to_string(),
display_name: Some("shared.txt".to_string()),
is_directory: false,
}]
}
fn metadata(transfer_id: u64) -> ShareMetadataInput {
ShareMetadataInput {
transfer_id,
transfer_name: Some("shared.txt".to_string()),
sender_name: Some("Sender".to_string()),
access_mode: TransferAccessMode::ApprovalRequired,
}
}
/// Send in the background: the call blocks until the receiver decides.
fn send_in_background(
core: Arc<VnidropCore>,
to: String,
path: &Path,
transfer_id: u64,
) -> std::thread::JoinHandle<Result<ContactSendResult, VnidropError>> {
let sources = sources(path);
std::thread::spawn(move || core.send_to_contact(to, sources, metadata(transfer_id)))
}
fn wait_for_offer(core: &VnidropCore) -> IncomingOffer {
let started = Instant::now();
loop {
if let Some(offer) = core.list_pending_offers().into_iter().next() {
return offer;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"offer never surfaced on the receiver"
);
std::thread::sleep(Duration::from_millis(25));
}
}
/// The whole point: the receiver is asked exactly once, the sender not at all.
#[test]
fn an_accepted_offer_transfers_without_prompting_the_sender() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"offered content").unwrap();
let output_dir = tempfile::tempdir().unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
// The sender must be able to reach the receiver, so the receiver issues.
pair(&receiver, &sender);
let handle = send_in_background(
sender.core.arc(),
endpoint_id(&receiver),
&source_path,
4_001,
);
let offer = wait_for_offer(&receiver.core);
assert_eq!(offer.from_endpoint_id, endpoint_id(&sender));
assert_eq!(offer.transfer_name, "shared.txt");
assert_eq!(offer.file_count, 1);
assert_eq!(offer.sender_display_name.as_deref(), Some("Sender"));
let ticket = receiver
.core
.respond_to_offer(offer.offer_id, true)
.expect("accepting yields the ticket");
let share = handle.join().unwrap().expect("offer accepted");
receiver
.core
.receive(
ticket,
output_dir.path().to_string_lossy().to_string(),
Some("Receiver".to_string()),
)
.expect("receive completes");
assert_eq!(
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
b"offered content"
);
// The sender was never asked: the only receiver request on its side was
// recorded as already approved.
let requests = sender
.core
.list_receiver_requests(share.share.transfer_id)
.unwrap();
assert_eq!(requests.len(), 1);
assert!(
matches!(requests[0].status.as_str(), "accepted" | "completed"),
"sender should not have been prompted, got status {}",
requests[0].status
);
assert!(requests[0].reason.is_none());
}
/// Declining yields no ticket and stops the share.
#[test]
fn a_declined_offer_yields_no_ticket() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"offered content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
let handle = send_in_background(
sender.core.arc(),
endpoint_id(&receiver),
&source_path,
4_002,
);
let offer = wait_for_offer(&receiver.core);
assert!(
receiver
.core
.respond_to_offer(offer.offer_id, false)
.is_none(),
"a declined offer must not hand over a ticket"
);
let outcome = handle.join().unwrap();
assert!(outcome.is_err(), "sender should see the refusal");
assert!(receiver.core.list_pending_offers().is_empty());
}
/// A device with no grant cannot offer at all.
#[test]
fn sending_without_a_grant_is_refused_locally() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let outcome = sender.core.send_to_contact(
endpoint_id(&receiver),
sources(&source_path),
metadata(4_003),
);
assert!(outcome.is_err(), "no grant means nothing to send with");
assert!(receiver.core.list_pending_offers().is_empty());
}
/// After the peer revokes, the offer is refused and the dead grant is dropped.
#[test]
fn a_revoked_grant_cannot_be_used_to_offer() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
// The receiver decides it no longer wants to hear from the sender.
receiver
.core
.forget_contact(endpoint_id(&sender))
.expect("forgotten");
let outcome = sender.core.send_to_contact(
endpoint_id(&receiver),
sources(&source_path),
metadata(4_004),
);
assert!(outcome.is_err());
assert!(receiver.core.list_pending_offers().is_empty());
let contacts = sender.core.list_contacts().unwrap();
assert!(
contacts.iter().all(|contact| !contact.can_send),
"a refusal naming a dead grant must clear the sender's belief it can reach them"
);
}
/// An offer-created share is never public, whatever the caller asked for.
#[test]
fn an_offer_share_is_never_public() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
let core = sender.core.arc();
let to = endpoint_id(&receiver);
let sources = sources(&source_path);
let handle = std::thread::spawn(move || {
core.send_to_contact(
to,
sources,
ShareMetadataInput {
transfer_id: 4_005,
transfer_name: Some("shared.txt".to_string()),
sender_name: None,
// Deliberately asking for the wider mode.
access_mode: TransferAccessMode::Public,
},
)
});
let offer = wait_for_offer(&receiver.core);
receiver.core.respond_to_offer(offer.offer_id, true);
let share = handle.join().unwrap().expect("offer accepted");
let stored = sender
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == share.share.transfer_id)
.expect("share recorded");
assert_eq!(stored.access_mode, TransferAccessMode::ApprovalRequired);
}
/// A second offer while one is on screen is refused rather than stacked.
#[test]
fn only_one_offer_per_device_is_pending_at_a_time() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
let first = send_in_background(
sender.core.arc(),
endpoint_id(&receiver),
&source_path,
4_006,
);
wait_for_offer(&receiver.core);
let second = sender.core.send_to_contact(
endpoint_id(&receiver),
sources(&source_path),
metadata(4_007),
);
assert!(second.is_err(), "a second prompt must not stack");
assert_eq!(receiver.core.list_pending_offers().len(), 1);
let offer = receiver.core.list_pending_offers().remove(0);
receiver.core.respond_to_offer(offer.offer_id, false);
let _ = first.join().unwrap();
}
/// Forgetting a device clears any prompt it left on screen, which would
/// otherwise be actionable with a grant that no longer exists.
#[test]
fn forgetting_a_device_clears_its_pending_offer() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
let handle = send_in_background(
sender.core.arc(),
endpoint_id(&receiver),
&source_path,
4_008,
);
wait_for_offer(&receiver.core);
receiver
.core
.forget_contact(endpoint_id(&sender))
.expect("forgotten");
assert!(receiver.core.list_pending_offers().is_empty());
assert!(handle.join().unwrap().is_err());
}
/// The ordinary QR path still prompts the sender: pre-authorisation applies
/// only to transfers the sender pushed.
#[test]
fn an_ordinary_ticket_receive_still_prompts_the_sender() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let output_dir = tempfile::tempdir().unwrap();
let sender_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink::default());
let sender = support::CoreGuard::start(sender_dir.path(), sink);
let receiver = TestNode::new();
let share = sender
.share_files(sources(&source_path), metadata(4_009))
.expect("shared");
let core = receiver.core.arc();
let ticket = share.ticket.clone();
let output = output_dir.path().to_string_lossy().to_string();
let handle =
std::thread::spawn(move || core.receive(ticket, output, Some("Receiver".to_string())));
let request = support::wait_for_receiver_request(&sender, share.transfer_id);
assert_eq!(
request.status, "requested",
"an unsolicited ticket receive must still ask the sender"
);
sender
.respond_receiver_request(request.id, true, None)
.unwrap();
handle.join().unwrap().expect("receive completes");
}
// MARK: - Held offers and the foreground pull
/// Restartable node, for simulating a device that was not running.
struct RestartableNode {
dir: tempfile::TempDir,
core: Option<support::CoreGuard>,
}
impl RestartableNode {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let core = support::CoreGuard::start(dir.path(), Arc::new(RecordingSink::default()));
Self {
dir,
core: Some(core),
}
}
fn core(&self) -> &VnidropCore {
self.core.as_ref().expect("node is running")
}
fn stop(&mut self) {
if let Some(core) = self.core.take() {
core.shutdown();
}
}
fn start(&mut self) {
self.core = Some(support::CoreGuard::start(
self.dir.path(),
Arc::new(RecordingSink::default()),
));
}
}
/// Pair so `sender` may reach the restartable node.
fn pair_with_restartable(sender: &TestNode, receiver: &RestartableNode) {
let receiver_id = receiver.core().status().endpoint_id;
receiver
.core()
.allow_device_to_reach_me(endpoint_id(sender), Some("Receiver".to_string()))
.expect("grant delivered");
let started = Instant::now();
while !sender
.core
.list_pending_pairings()
.iter()
.any(|pending| pending.endpoint_id == receiver_id)
{
assert!(
started.elapsed() < Duration::from_secs(10),
"pairing offer never surfaced"
);
std::thread::sleep(Duration::from_millis(25));
}
sender
.core
.respond_to_pairing(receiver_id, true)
.expect("consent recorded");
}
/// The whole point of §11: a closed app is not an error, it is a delay.
#[test]
fn an_offer_to_a_device_that_is_not_running_is_held_and_collected_later() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"held content").unwrap();
let output_dir = tempfile::tempdir().unwrap();
let sender = TestNode::new();
let mut receiver = RestartableNode::new();
pair_with_restartable(&sender, &receiver);
let receiver_id = receiver.core().status().endpoint_id;
receiver.stop();
let outcome = sender
.core
.send_to_contact(receiver_id, sources(&source_path), metadata(5_001))
.expect("an unreachable device is not a failure");
assert!(
!outcome.delivered,
"nothing was delivered, the offer is waiting"
);
let held = sender.core.list_held_offers().unwrap();
assert_eq!(held.len(), 1);
assert_eq!(held[0].transfer_id, outcome.share.transfer_id);
receiver.start();
let collected = receiver
.core()
.poll_contacts_for_offers()
.expect("poll succeeds");
assert_eq!(collected, 1);
let offer = receiver.core().list_pending_offers().remove(0);
assert_eq!(offer.transfer_name, "shared.txt");
let ticket = receiver
.core()
.respond_to_offer(offer.offer_id, true)
.expect("accepting yields the ticket");
receiver
.core()
.receive(
ticket,
output_dir.path().to_string_lossy().to_string(),
Some("Receiver".to_string()),
)
.expect("receive completes");
assert_eq!(
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
b"held content"
);
assert!(
sender.core.list_held_offers().unwrap().is_empty(),
"a collected offer is no longer held"
);
}
/// Collected offers are consumed, so a second pull does not re-deliver them.
#[test]
fn polling_twice_does_not_collect_the_same_offer_again() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let mut receiver = RestartableNode::new();
pair_with_restartable(&sender, &receiver);
let receiver_id = receiver.core().status().endpoint_id;
receiver.stop();
sender
.core
.send_to_contact(receiver_id, sources(&source_path), metadata(5_002))
.unwrap();
// A fresh core each time, so the per-device poll rate limit does not mask
// the consume-on-delivery behaviour being asserted here.
receiver.start();
assert_eq!(receiver.core().poll_contacts_for_offers().unwrap(), 1);
receiver.stop();
receiver.start();
assert_eq!(
receiver.core().poll_contacts_for_offers().unwrap(),
0,
"the offer was already handed over"
);
}
/// Cancelling the transfer withdraws the ticket that was waiting for pickup.
#[test]
fn cancelling_a_transfer_withdraws_its_held_offer() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let mut receiver = RestartableNode::new();
pair_with_restartable(&sender, &receiver);
let receiver_id = receiver.core().status().endpoint_id;
receiver.stop();
let outcome = sender
.core
.send_to_contact(receiver_id, sources(&source_path), metadata(5_004))
.unwrap();
assert_eq!(sender.core.list_held_offers().unwrap().len(), 1);
sender
.core
.cancel_transfer(outcome.share.transfer_id)
.unwrap();
assert!(sender.core.list_held_offers().unwrap().is_empty());
receiver.start();
assert_eq!(receiver.core().poll_contacts_for_offers().unwrap(), 0);
}
/// A device with no relationship learns nothing by polling.
#[test]
fn polling_a_device_that_holds_nothing_for_you_returns_nothing() {
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
assert_eq!(receiver.core.poll_contacts_for_offers().unwrap(), 0);
assert!(receiver.core.list_pending_offers().is_empty());
}
/// A transfer created for an invitation can also be pushed to a device: the
/// same ticket, another way to deliver it.
#[test]
fn an_existing_share_can_be_offered_to_a_contact() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"existing share").unwrap();
let output_dir = tempfile::tempdir().unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
// An ordinary share, as if the user had created it for a QR code.
let share = sender
.core
.share_files(sources(&source_path), metadata(6_001))
.expect("shared");
let core = sender.core.arc();
let to = endpoint_id(&receiver);
let handle = std::thread::spawn(move || core.offer_transfer_to_contact(share.transfer_id, to));
let offer = wait_for_offer(&receiver.core);
let ticket = receiver
.core
.respond_to_offer(offer.offer_id, true)
.expect("accepting yields the ticket");
let outcome = handle.join().unwrap().expect("offer accepted");
assert!(outcome.delivered);
assert_eq!(
outcome.share.transfer_id, share.transfer_id,
"offering reuses the existing transfer rather than creating another"
);
assert_eq!(ticket, share.ticket, "the invitation is the stored one");
receiver
.core
.receive(
ticket,
output_dir.path().to_string_lossy().to_string(),
Some("Receiver".to_string()),
)
.expect("receive completes");
assert_eq!(
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
b"existing share"
);
}
/// A stopped share serves nothing, so its ticket must not be handed out.
#[test]
fn a_stopped_share_cannot_be_offered() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
let share = sender
.core
.share_files(sources(&source_path), metadata(6_002))
.expect("shared");
sender.core.cancel_transfer(share.transfer_id).unwrap();
let outcome = sender
.core
.offer_transfer_to_contact(share.transfer_id, endpoint_id(&receiver));
assert!(outcome.is_err());
assert!(receiver.core.list_pending_offers().is_empty());
}
/// Offering an unknown transfer is rejected rather than silently doing nothing.
#[test]
fn offering_an_unknown_transfer_is_rejected() {
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
assert!(sender
.core
.offer_transfer_to_contact(9_999, endpoint_id(&receiver))
.is_err());
}

View File

@@ -1,252 +0,0 @@
//! Device history pairing over the offer ALPN, between two real nodes.
mod support;
use std::time::{Duration, Instant};
use support::TestNode;
use vnidrop::VnidropCore;
fn endpoint_id(node: &TestNode) -> String {
node.core.status().endpoint_id
}
/// The pairing prompt arrives asynchronously on the peer's side.
fn wait_for_pending_pairing(core: &VnidropCore, from_endpoint: &str) {
let started = Instant::now();
loop {
if core
.list_pending_pairings()
.iter()
.any(|pending| pending.endpoint_id == from_endpoint)
{
return;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"pairing offer from {from_endpoint} never surfaced"
);
std::thread::sleep(Duration::from_millis(25));
}
}
/// Alice agrees to be reachable by Bob; Bob consents; Bob can now reach Alice.
#[test]
fn a_delivered_grant_becomes_a_contact_only_after_the_peer_consents() {
let alice = TestNode::new();
let bob = TestNode::new();
let bob_id = endpoint_id(&bob);
let alice_id = endpoint_id(&alice);
alice
.core
.allow_device_to_reach_me(bob_id.clone(), Some("Alice Laptop".to_string()))
.expect("grant delivered");
// Delivery alone must not create a contact: Bob has not agreed yet.
wait_for_pending_pairing(&bob.core, &alice_id);
assert!(
bob.core.list_contacts().unwrap().is_empty(),
"an undelivered-consent grant must not appear as a contact"
);
assert!(bob
.core
.respond_to_pairing(alice_id.clone(), true)
.expect("consent recorded"));
let contacts = bob.core.list_contacts().unwrap();
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0].endpoint_id, alice_id);
assert!(
contacts[0].can_send,
"holding a live grant is what makes a contact reachable"
);
assert!(bob.core.list_pending_pairings().is_empty());
}
/// Declining leaves nothing behind: no contact, no stored capability.
#[test]
fn declining_a_pairing_stores_nothing() {
let alice = TestNode::new();
let bob = TestNode::new();
let alice_id = endpoint_id(&alice);
alice
.core
.allow_device_to_reach_me(endpoint_id(&bob), None)
.expect("grant delivered");
wait_for_pending_pairing(&bob.core, &alice_id);
assert!(bob
.core
.respond_to_pairing(alice_id.clone(), false)
.unwrap());
assert!(bob.core.list_contacts().unwrap().is_empty());
assert!(bob.core.list_pending_pairings().is_empty());
assert!(
!bob.core.respond_to_pairing(alice_id, true).unwrap(),
"a declined offer cannot be accepted afterwards"
);
}
/// The pairing is directional: Alice issuing to Bob does not let Alice reach Bob.
#[test]
fn each_direction_is_a_separate_decision() {
let alice = TestNode::new();
let bob = TestNode::new();
let alice_id = endpoint_id(&alice);
let bob_id = endpoint_id(&bob);
alice
.core
.allow_device_to_reach_me(bob_id.clone(), None)
.expect("grant delivered");
wait_for_pending_pairing(&bob.core, &alice_id);
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
// Alice recorded Bob as a contact when she issued, but she holds no grant
// from him, so she cannot reach him.
let alice_contacts = alice.core.list_contacts().unwrap();
assert_eq!(alice_contacts.len(), 1);
assert_eq!(alice_contacts[0].endpoint_id, bob_id);
assert!(
!alice_contacts[0].can_send,
"issuing a grant does not grant the issuer anything in return"
);
}
/// Revoking kills the peer's entry without their cooperation, and tells them.
#[test]
fn forgetting_a_contact_revokes_the_peers_access() {
let alice = TestNode::new();
let bob = TestNode::new();
let alice_id = endpoint_id(&alice);
let bob_id = endpoint_id(&bob);
alice
.core
.allow_device_to_reach_me(bob_id.clone(), None)
.expect("grant delivered");
wait_for_pending_pairing(&bob.core, &alice_id);
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
assert!(bob.core.list_contacts().unwrap()[0].can_send);
alice.core.forget_contact(bob_id).expect("forgotten");
// Best-effort notification: Bob is online, so his dead entry should clear
// promptly rather than at his next attempt.
let started = Instant::now();
loop {
let contacts = bob.core.list_contacts().unwrap();
let cleared = contacts.first().is_none_or(|contact| !contact.can_send);
if cleared {
break;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"revocation notice never reached the peer"
);
std::thread::sleep(Duration::from_millis(25));
}
assert!(alice.core.list_contacts().unwrap().is_empty());
}
/// A blocked device is refused, and cannot tell blocking from any other refusal.
#[test]
fn a_blocked_device_cannot_pair() {
let alice = TestNode::new();
let bob = TestNode::new();
let bob_id = endpoint_id(&bob);
bob.core
.block_contact(endpoint_id(&alice))
.expect("blocked");
let outcome = alice.core.allow_device_to_reach_me(bob_id, None);
assert!(outcome.is_err(), "a blocked peer must refuse the grant");
assert!(bob.core.list_pending_pairings().is_empty());
assert!(bob.core.list_contacts().unwrap().is_empty());
}
/// Blocking locally also prevents pairing outward, so the block is symmetric
/// from the user's point of view.
#[test]
fn blocking_prevents_issuing_a_grant_to_that_device() {
let alice = TestNode::new();
let bob = TestNode::new();
let bob_id = endpoint_id(&bob);
alice.core.block_contact(bob_id.clone()).expect("blocked");
let outcome = alice.core.allow_device_to_reach_me(bob_id.clone(), None);
assert!(outcome.is_err());
alice
.core
.unblock_contact(bob_id.clone())
.expect("unblocked");
assert!(alice.core.list_blocked_contacts().unwrap().is_empty());
}
/// Re-pairing an existing contact refreshes the grant without a second prompt.
#[test]
fn re_pairing_a_known_contact_does_not_prompt_again() {
let alice = TestNode::new();
let bob = TestNode::new();
let alice_id = endpoint_id(&alice);
let bob_id = endpoint_id(&bob);
alice
.core
.allow_device_to_reach_me(bob_id.clone(), None)
.unwrap();
wait_for_pending_pairing(&bob.core, &alice_id);
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
alice
.core
.allow_device_to_reach_me(bob_id, None)
.expect("re-issued");
assert!(
bob.core.list_pending_pairings().is_empty(),
"an established contact must not raise a fresh consent prompt"
);
assert_eq!(bob.core.list_contacts().unwrap().len(), 1);
}
/// The user's own label survives whatever the remote later calls itself.
#[test]
fn a_local_label_survives_a_remote_rename() {
let alice = TestNode::new();
let bob = TestNode::new();
let alice_id = endpoint_id(&alice);
let bob_id = endpoint_id(&bob);
alice
.core
.allow_device_to_reach_me(bob_id.clone(), Some("Alice Laptop".to_string()))
.unwrap();
wait_for_pending_pairing(&bob.core, &alice_id);
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
bob.core
.set_contact_label(alice_id.clone(), Some("Work Mac".to_string()))
.unwrap();
alice
.core
.allow_device_to_reach_me(bob_id, Some("Totally Not Evil".to_string()))
.unwrap();
let contact = bob
.core
.list_contacts()
.unwrap()
.into_iter()
.find(|contact| contact.endpoint_id == alice_id)
.expect("contact");
assert_eq!(contact.local_label.as_deref(), Some("Work Mac"));
}