mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
refactor(core): remove prototype contact paths for experimental saved devices
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -180,7 +180,7 @@ impl ApprovalService {
|
||||
) -> HandshakeResponse {
|
||||
if self
|
||||
.repository
|
||||
.contacts()
|
||||
.blocked_devices()
|
||||
.is_blocked(&remote_endpoint_id)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
|
||||
78
crates/vnidrop/src/blocked_devices.rs
Normal file
78
crates/vnidrop/src/blocked_devices.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -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),
|
||||
|
||||
171
crates/vnidrop/src/runtime/saved_devices.rs
Normal file
171
crates/vnidrop/src/runtime/saved_devices.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
73
crates/vnidrop/src/tests/api_surface.rs
Normal file
73
crates/vnidrop/src/tests/api_surface.rs
Normal 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);
|
||||
}
|
||||
66
crates/vnidrop/src/tests/blocked_devices.rs
Normal file
66
crates/vnidrop/src/tests/blocked_devices.rs
Normal 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());
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user