mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
refactor(core): deepen domain stores and invitation-only persistence
Peel relationships, eligibility, and secrets off the shared pool into AppDataStores adapters, split pairing service/protocol, and move the invitation Repository into its own module so open_all owns schemas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -139,7 +139,7 @@ crates/vnidrop/src/runtime/
|
||||
provider.rs # provider events, per-connection send progress
|
||||
```
|
||||
|
||||
Other core modules: `filesystem.rs`, `repository.rs`, `approval.rs`,
|
||||
Other core modules: `filesystem.rs`, `invitation/`, `approval.rs`,
|
||||
`handshake.rs`, `ticket.rs`, `access_policy.rs`, `event_hub.rs`, `api.rs`.
|
||||
|
||||
### Shared app
|
||||
|
||||
@@ -23,11 +23,11 @@ _Avoid_: contact record, friendship
|
||||
## Persistence (core)
|
||||
|
||||
**Domain store**:
|
||||
The module that owns schema and queries for one domain (invitation history, targeted transfers, blocked devices, relationship rows, secret metadata). Callers use store methods — never a raw SQL pool.
|
||||
The module that owns schema and queries for one domain (invitation history, targeted transfers, blocked devices, relationship rows, pairing eligibility, secret metadata). Callers use store methods — never a raw SQL pool.
|
||||
_Avoid_: repository-for-everything, DAO, database layer
|
||||
|
||||
**Invitation repository**:
|
||||
The domain store for invitation-transfer history, artifacts, receiver requests, and related events. Today’s type name may still be `Repository`.
|
||||
The domain store for invitation-transfer history, artifacts, receiver requests, and related events. Module path `invitation`; today’s type name may still be `Repository`.
|
||||
_Avoid_: “the database”, AppDataStores
|
||||
|
||||
**AppDataStores**:
|
||||
|
||||
@@ -57,11 +57,12 @@ src/
|
||||
saved_devices.rs # experimental saved-device pairing, forget, block
|
||||
targeted.rs # saved-device targeted transfers
|
||||
persistence.rs # AppDataStores / persistence open (domain stores)
|
||||
repository.rs # invitation-transfer domain store (not raw pool export)
|
||||
device_relationship/ # mutual consent + grants
|
||||
invitation/ # invitation-transfer domain store (type name: Repository)
|
||||
pairing_eligibility/ # eligibility service + store
|
||||
device_relationship/ # store + service + protocol (ALPN pairing)
|
||||
targeted_transfer/ # targeted protocol + store adapter
|
||||
blocked_devices.rs
|
||||
secure_secret/ # custody + platform credential adapters
|
||||
secure_secret/ # custody + platform credential adapters (+ metadata store)
|
||||
filesystem.rs # collect sources, atomic publish, path rules
|
||||
approval.rs / handshake.rs / ticket.rs / access_policy.rs / event_hub.rs
|
||||
api.rs # UniFFI records/enums
|
||||
|
||||
@@ -7,13 +7,14 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
access_policy::{AccessDecision, AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
blocked_devices::BlockStore,
|
||||
event_hub::EventHub,
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||
RequestTransfer,
|
||||
},
|
||||
invitation::{ReceiverRequestInsert, Repository},
|
||||
pairing_eligibility::PairingEligibilityService,
|
||||
repository::{ReceiverRequestInsert, Repository},
|
||||
transfer_state::ReceiverRequestStatus,
|
||||
util::now_ms,
|
||||
};
|
||||
@@ -31,6 +32,7 @@ pub(crate) struct ApprovalDecision {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ApprovalService {
|
||||
repository: Repository,
|
||||
blocked: BlockStore,
|
||||
event_hub: Arc<EventHub>,
|
||||
access_policy: Arc<AccessPolicy>,
|
||||
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
|
||||
@@ -129,6 +131,7 @@ impl ApprovalService {
|
||||
|
||||
pub(crate) fn new(
|
||||
repository: Repository,
|
||||
blocked: BlockStore,
|
||||
event_hub: Arc<EventHub>,
|
||||
access_policy: Arc<AccessPolicy>,
|
||||
max_pending: usize,
|
||||
@@ -137,6 +140,7 @@ impl ApprovalService {
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
blocked,
|
||||
event_hub,
|
||||
access_policy,
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
@@ -179,8 +183,7 @@ impl ApprovalService {
|
||||
request: RequestTransfer,
|
||||
) -> HandshakeResponse {
|
||||
if self
|
||||
.repository
|
||||
.blocked_devices()
|
||||
.blocked
|
||||
.is_blocked(&remote_endpoint_id)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
//! 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 (
|
||||
@@ -28,7 +18,7 @@ pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Durable deny records over the shared repository pool.
|
||||
/// Durable deny records for one app-data profile.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BlockStore {
|
||||
pool: SqlitePool,
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
//! Forget, block, grant rotation, and minimal revocation tombstones (design §7–§8).
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{DeviceRelationshipService, RelationshipRow};
|
||||
use super::{store::RelationshipRow, DeviceRelationshipService};
|
||||
use crate::{
|
||||
api::DeviceRelationshipState, error::VnidropError, grant::GrantRejection,
|
||||
secure_secret::SecretHandle, util::now_ms,
|
||||
secure_secret::SecretHandle,
|
||||
};
|
||||
|
||||
/// Minimal non-secret tombstone for a revoked relationship generation.
|
||||
///
|
||||
/// Retains only what is needed to reject replay: peer identity, generation,
|
||||
/// opaque grant ids, and revocation time. No names, filenames, history, or
|
||||
/// capability material.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GenerationTombstone {
|
||||
pub(crate) remote_endpoint_id: String,
|
||||
pub(crate) generation: u64,
|
||||
pub(crate) issued_grant_id: Option<String>,
|
||||
pub(crate) held_grant_id: Option<String>,
|
||||
pub(crate) revoked_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ForgetOutcome {
|
||||
pub(crate) had_relationship: bool,
|
||||
@@ -31,24 +16,6 @@ pub(crate) struct ForgetOutcome {
|
||||
}
|
||||
|
||||
impl DeviceRelationshipService {
|
||||
pub(crate) async fn ensure_lifecycle_schema(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS relationship_generation_tombstones (
|
||||
remote_endpoint_id TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
issued_grant_id TEXT,
|
||||
held_grant_id TEXT,
|
||||
revoked_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (remote_endpoint_id, generation)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a saved (or pending) device: revoke locally first, clean secrets,
|
||||
/// then the caller sends a best-effort remote notice. Invitation-domain
|
||||
/// transfers are untouched.
|
||||
@@ -127,25 +94,9 @@ impl DeviceRelationshipService {
|
||||
self.clear_grant_secrets(&row).await?;
|
||||
|
||||
let new_generation = row.generation.saturating_add(1);
|
||||
let now = now_ms();
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET generation = ?2,
|
||||
issued_grant_handle = NULL,
|
||||
held_grant_handle = NULL,
|
||||
issued_grant_id = NULL,
|
||||
held_grant_id = NULL,
|
||||
updated_at = ?3
|
||||
WHERE remote_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(&peer_endpoint_id)
|
||||
.bind(new_generation as i64)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
self.store
|
||||
.begin_grant_rotation(&peer_endpoint_id, new_generation)
|
||||
.await?;
|
||||
|
||||
let _wire = self
|
||||
.mint_and_store_issued_grant(
|
||||
@@ -210,29 +161,8 @@ impl DeviceRelationshipService {
|
||||
pub(crate) async fn list_tombstones(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Vec<GenerationTombstone>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||
FROM relationship_generation_tombstones
|
||||
WHERE remote_endpoint_id = ?1
|
||||
ORDER BY generation ASC
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| GenerationTombstone {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
issued_grant_id: row.get("issued_grant_id"),
|
||||
held_grant_id: row.get("held_grant_id"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
})
|
||||
.collect())
|
||||
) -> Result<Vec<super::store::GenerationTombstone>, VnidropError> {
|
||||
self.store.list_tombstones(peer_endpoint_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -254,52 +184,17 @@ impl DeviceRelationshipService {
|
||||
peer_endpoint_id: &str,
|
||||
row: &RelationshipRow,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO relationship_generation_tombstones (
|
||||
remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(remote_endpoint_id, generation) DO UPDATE SET
|
||||
issued_grant_id = COALESCE(excluded.issued_grant_id, relationship_generation_tombstones.issued_grant_id),
|
||||
held_grant_id = COALESCE(excluded.held_grant_id, relationship_generation_tombstones.held_grant_id),
|
||||
revoked_at = excluded.revoked_at
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(row.generation as i64)
|
||||
.bind(row.issued_grant_id.as_deref())
|
||||
.bind(row.held_grant_id.as_deref())
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
self.store.insert_tombstone(peer_endpoint_id, row).await
|
||||
}
|
||||
|
||||
async fn find_tombstone(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
generation: u64,
|
||||
) -> Result<Option<GenerationTombstone>, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||
FROM relationship_generation_tombstones
|
||||
WHERE remote_endpoint_id = ?1 AND generation = ?2
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(generation as i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(row.map(|row| GenerationTombstone {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
issued_grant_id: row.get("issued_grant_id"),
|
||||
held_grant_id: row.get("held_grant_id"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
}))
|
||||
) -> Result<Option<super::store::GenerationTombstone>, VnidropError> {
|
||||
self.store
|
||||
.find_tombstone(peer_endpoint_id, generation)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn clear_grant_secrets(&self, row: &RelationshipRow) -> Result<(), VnidropError> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
226
crates/vnidrop/src/device_relationship/protocol.rs
Normal file
226
crates/vnidrop/src/device_relationship/protocol.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! Iroh ALPN handler and client for mutual-consent pairing.
|
||||
//!
|
||||
//! Wire messages and transport live here; durable state and grant custody stay on
|
||||
//! [`super::service::DeviceRelationshipService`].
|
||||
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
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 super::DeviceRelationshipService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RelationshipProtocol {
|
||||
relationships: Arc<DeviceRelationshipService>,
|
||||
}
|
||||
|
||||
impl RelationshipProtocol {
|
||||
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/relationship/1";
|
||||
|
||||
pub(crate) fn new(relationships: Arc<DeviceRelationshipService>) -> Self {
|
||||
Self { relationships }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RelationshipProtocol {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("RelationshipProtocol")
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolHandler for RelationshipProtocol {
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
let remote_endpoint_id = connection.remote_id().to_string();
|
||||
while let Some(message) = read_request::<RelationshipMessages>(&connection).await? {
|
||||
match message {
|
||||
RelationshipMessage::PairingRequest(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.relationships
|
||||
.handle_pairing_request(remote_endpoint_id.clone(), inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
RelationshipMessage::PairingConsent(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.relationships
|
||||
.handle_pairing_consent(remote_endpoint_id.clone(), inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
RelationshipMessage::PairingAck(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.relationships
|
||||
.handle_pairing_ack(remote_endpoint_id.clone(), inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
RelationshipMessage::RevokeNotice(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let acknowledged = self
|
||||
.relationships
|
||||
.handle_remote_revoke(remote_endpoint_id.clone(), inner.generation)
|
||||
.await;
|
||||
let response = if acknowledged {
|
||||
RevokeNoticeResponse::Acknowledged
|
||||
} else {
|
||||
RevokeNoticeResponse::Rejected
|
||||
};
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.closed().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RelationshipClient {
|
||||
inner: Client<RelationshipMessages>,
|
||||
}
|
||||
|
||||
impl RelationshipClient {
|
||||
pub(super) fn connect(endpoint: Endpoint, addr: EndpointAddr) -> Self {
|
||||
Self {
|
||||
inner: Client::boxed(IrohLazyRemoteConnection::new(
|
||||
endpoint,
|
||||
addr,
|
||||
RelationshipProtocol::ALPN.to_vec(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn pairing_request(
|
||||
&self,
|
||||
request: PairingRequest,
|
||||
) -> Result<PairingRequestResponse, irpc::Error> {
|
||||
self.inner.rpc(request).await
|
||||
}
|
||||
|
||||
pub(super) async fn pairing_consent(
|
||||
&self,
|
||||
consent: PairingConsent,
|
||||
) -> Result<PairingConsentResponse, irpc::Error> {
|
||||
self.inner.rpc(consent).await
|
||||
}
|
||||
|
||||
pub(super) async fn pairing_ack(
|
||||
&self,
|
||||
ack: PairingAck,
|
||||
) -> Result<PairingAckResponse, irpc::Error> {
|
||||
self.inner.rpc(ack).await
|
||||
}
|
||||
|
||||
pub(super) async fn revoke_notice(
|
||||
&self,
|
||||
notice: RevokeNotice,
|
||||
) -> Result<RevokeNoticeResponse, irpc::Error> {
|
||||
self.inner.rpc(notice).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PairingRequest {
|
||||
pub(super) session_id: String,
|
||||
pub(super) capability: Vec<u8>,
|
||||
pub(super) protocol_version: u16,
|
||||
pub(super) generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum PairingRequestResponse {
|
||||
AwaitingConsent,
|
||||
Merged,
|
||||
AlreadySaved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PairingConsent {
|
||||
pub(super) accepted: bool,
|
||||
pub(super) grant: Option<WireGrant>,
|
||||
pub(super) challenge: Option<String>,
|
||||
pub(super) generation: u64,
|
||||
pub(super) protocol_version: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) enum PairingConsentResponse {
|
||||
Completed {
|
||||
grant: Box<WireGrant>,
|
||||
possession_proof: WireProof,
|
||||
ack_challenge: String,
|
||||
},
|
||||
AlreadySaved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PairingAck {
|
||||
pub(super) possession_proof: WireProof,
|
||||
pub(super) challenge: String,
|
||||
pub(super) generation: u64,
|
||||
pub(super) protocol_version: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum PairingAckResponse {
|
||||
Acknowledged,
|
||||
AlreadySaved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct WireGrant {
|
||||
pub(super) grant_id: String,
|
||||
pub(super) secret: String,
|
||||
pub(super) issuer_endpoint_id: String,
|
||||
pub(super) holder_endpoint_id: String,
|
||||
pub(super) generation: u64,
|
||||
pub(super) protocol_version: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct WireProof {
|
||||
pub(crate) grant_id: String,
|
||||
pub(crate) mac: String,
|
||||
pub(crate) challenge: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RevokeNotice {
|
||||
pub(super) generation: u64,
|
||||
pub(super) issued_grant_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum RevokeNoticeResponse {
|
||||
Acknowledged,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[rpc_requests(message = RelationshipMessage)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[allow(
|
||||
clippy::enum_variant_names,
|
||||
reason = "Pairing* names mirror the wire RPC surface"
|
||||
)]
|
||||
enum RelationshipMessages {
|
||||
#[rpc(tx = oneshot::Sender<PairingRequestResponse>)]
|
||||
PairingRequest(PairingRequest),
|
||||
#[rpc(tx = oneshot::Sender<PairingConsentResponse>)]
|
||||
PairingConsent(PairingConsent),
|
||||
#[rpc(tx = oneshot::Sender<PairingAckResponse>)]
|
||||
PairingAck(PairingAck),
|
||||
#[rpc(tx = oneshot::Sender<RevokeNoticeResponse>)]
|
||||
RevokeNotice(RevokeNotice),
|
||||
}
|
||||
1129
crates/vnidrop/src/device_relationship/service.rs
Normal file
1129
crates/vnidrop/src/device_relationship/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
574
crates/vnidrop/src/device_relationship/store.rs
Normal file
574
crates/vnidrop/src/device_relationship/store.rs
Normal file
@@ -0,0 +1,574 @@
|
||||
//! Durable device-relationship rows (schema + queries).
|
||||
//!
|
||||
//! Orchestration (custody, pairing RPC, events) stays on
|
||||
//! [`super::DeviceRelationshipService`]; this store is the domain adapter held
|
||||
//! in [`crate::persistence::AppDataStores`].
|
||||
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
api::{DeviceRelationship, DeviceRelationshipState, SavedDevice},
|
||||
error::VnidropError,
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
/// Minimal non-secret tombstone for a revoked relationship generation.
|
||||
///
|
||||
/// Retains only what is needed to reject replay: peer identity, generation,
|
||||
/// opaque grant ids, and revocation time. No names, filenames, history, or
|
||||
/// capability material.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GenerationTombstone {
|
||||
pub(crate) remote_endpoint_id: String,
|
||||
pub(crate) generation: u64,
|
||||
pub(crate) issued_grant_id: Option<String>,
|
||||
pub(crate) held_grant_id: Option<String>,
|
||||
pub(crate) revoked_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct RelationshipRow {
|
||||
pub(super) state: DeviceRelationshipState,
|
||||
pub(super) generation: u64,
|
||||
pub(super) minimum_protocol_version: u16,
|
||||
pub(super) session_id: Option<String>,
|
||||
pub(super) issued_grant_handle: Option<String>,
|
||||
pub(super) held_grant_handle: Option<String>,
|
||||
pub(super) issued_grant_id: Option<String>,
|
||||
pub(super) held_grant_id: Option<String>,
|
||||
pub(super) created_at: i64,
|
||||
}
|
||||
|
||||
/// Compact projection used by grant-secret reconcile.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ReconcileRow {
|
||||
pub(super) remote_endpoint_id: String,
|
||||
pub(super) state: DeviceRelationshipState,
|
||||
pub(super) issued_grant_handle: Option<String>,
|
||||
pub(super) held_grant_handle: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct RelationshipUpsert<'a> {
|
||||
pub(super) remote_endpoint_id: &'a str,
|
||||
pub(super) state: DeviceRelationshipState,
|
||||
pub(super) generation: u64,
|
||||
pub(super) minimum_protocol_version: u16,
|
||||
pub(super) session_id: Option<&'a str>,
|
||||
pub(super) issued_grant_handle: Option<&'a str>,
|
||||
pub(super) held_grant_handle: Option<&'a str>,
|
||||
pub(super) issued_grant_id: Option<&'a str>,
|
||||
pub(super) held_grant_id: Option<&'a str>,
|
||||
pub(super) peer_ack: bool,
|
||||
pub(super) local_ack: bool,
|
||||
pub(super) created_at: i64,
|
||||
pub(super) updated_at: i64,
|
||||
}
|
||||
|
||||
/// Domain store for `device_relationships` (+ generation tombstones).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DeviceRelationshipStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl DeviceRelationshipStore {
|
||||
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS device_relationships (
|
||||
remote_endpoint_id TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
minimum_protocol_version INTEGER NOT NULL,
|
||||
session_id TEXT,
|
||||
issued_grant_handle TEXT,
|
||||
held_grant_handle TEXT,
|
||||
issued_grant_id TEXT,
|
||||
held_grant_id TEXT,
|
||||
peer_ack INTEGER NOT NULL DEFAULT 0,
|
||||
local_ack INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let columns = sqlx::query("PRAGMA table_info(device_relationships)")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let has = |name: &str| columns.iter().any(|row| row.get::<String, _>(1) == name);
|
||||
if !has("issued_grant_id") {
|
||||
sqlx::query("ALTER TABLE device_relationships ADD COLUMN issued_grant_id TEXT")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
if !has("held_grant_id") {
|
||||
sqlx::query("ALTER TABLE device_relationships ADD COLUMN held_grant_id TEXT")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
if !has("local_label") {
|
||||
sqlx::query("ALTER TABLE device_relationships ADD COLUMN local_label TEXT")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS relationship_generation_tombstones (
|
||||
remote_endpoint_id TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
issued_grant_id TEXT,
|
||||
held_grant_id TEXT,
|
||||
revoked_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (remote_endpoint_id, generation)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn count_active_slots(&self) -> Result<u64, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(*) AS n FROM device_relationships
|
||||
WHERE state IN ('saved', 'pending_outgoing', 'pending_incoming')
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(row.get::<i64, _>("n") as u64)
|
||||
}
|
||||
|
||||
pub(super) async fn list_reconcile_rows(&self) -> Result<Vec<ReconcileRow>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, issued_grant_handle, held_grant_handle, state
|
||||
FROM device_relationships
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(ReconcileRow {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
state: parse_state(&row.get::<String, _>("state"))?,
|
||||
issued_grant_handle: row.get("issued_grant_handle"),
|
||||
held_grant_handle: row.get("held_grant_handle"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) async fn list(&self) -> Result<Vec<DeviceRelationship>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, state, generation, minimum_protocol_version, created_at, updated_at
|
||||
FROM device_relationships
|
||||
ORDER BY updated_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
rows.into_iter().map(row_to_relationship).collect()
|
||||
}
|
||||
|
||||
pub(super) async fn list_saved_devices(&self) -> Result<Vec<SavedDevice>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, local_label, created_at, updated_at
|
||||
FROM device_relationships
|
||||
WHERE state = 'saved'
|
||||
ORDER BY updated_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| SavedDevice {
|
||||
endpoint_id: row.get("remote_endpoint_id"),
|
||||
local_label: row.get("local_label"),
|
||||
remote_display_name: None,
|
||||
created_at: row.get("created_at"),
|
||||
last_authenticated_at: Some(row.get("updated_at")),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) async fn set_saved_device_label(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
label: Option<String>,
|
||||
) -> Result<bool, VnidropError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET local_label = ?2, updated_at = ?3
|
||||
WHERE remote_endpoint_id = ?1 AND state = 'saved'
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(label)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub(super) async fn set_issued_grant(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
handle: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET issued_grant_handle = ?2, issued_grant_id = ?3, updated_at = ?4
|
||||
WHERE remote_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(handle)
|
||||
.bind(grant_id)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set_held_grant(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
handle: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET held_grant_handle = ?2, held_grant_id = ?3, updated_at = ?4
|
||||
WHERE remote_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(handle)
|
||||
.bind(grant_id)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn set_minimum_protocol_version(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
minimum_protocol_version: u16,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
"UPDATE device_relationships SET minimum_protocol_version = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1",
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(i64::from(minimum_protocol_version))
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set_acks(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
local_ack: bool,
|
||||
peer_ack: bool,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
"UPDATE device_relationships SET local_ack = ?2, peer_ack = ?3, updated_at = ?4 WHERE remote_endpoint_id = ?1",
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(i64::from(local_ack))
|
||||
.bind(i64::from(peer_ack))
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set_state(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
state: DeviceRelationshipState,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
"UPDATE device_relationships SET state = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1",
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(state_as_str(state))
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn list_expired_pending_peers(
|
||||
&self,
|
||||
cutoff_ms: i64,
|
||||
) -> Result<Vec<String>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id FROM device_relationships
|
||||
WHERE state IN ('pending_outgoing', 'pending_incoming') AND updated_at < ?1
|
||||
"#,
|
||||
)
|
||||
.bind(cutoff_ms)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(|row| row.get(0)).collect())
|
||||
}
|
||||
|
||||
pub(super) async fn delete(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
|
||||
sqlx::query("DELETE FROM device_relationships WHERE remote_endpoint_id = ?1")
|
||||
.bind(peer_endpoint_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn find_row(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Option<RelationshipRow>, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, state, generation, minimum_protocol_version, session_id,
|
||||
issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id,
|
||||
peer_ack, local_ack, created_at, updated_at
|
||||
FROM device_relationships WHERE remote_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
row.map(relationship_row_from_sql).transpose()
|
||||
}
|
||||
|
||||
pub(super) async fn upsert(&self, entry: RelationshipUpsert<'_>) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO device_relationships (
|
||||
remote_endpoint_id, state, generation, minimum_protocol_version, session_id,
|
||||
issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id,
|
||||
peer_ack, local_ack, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||
ON CONFLICT(remote_endpoint_id) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
generation = excluded.generation,
|
||||
minimum_protocol_version = excluded.minimum_protocol_version,
|
||||
session_id = excluded.session_id,
|
||||
issued_grant_handle = COALESCE(excluded.issued_grant_handle, device_relationships.issued_grant_handle),
|
||||
held_grant_handle = COALESCE(excluded.held_grant_handle, device_relationships.held_grant_handle),
|
||||
issued_grant_id = COALESCE(excluded.issued_grant_id, device_relationships.issued_grant_id),
|
||||
held_grant_id = COALESCE(excluded.held_grant_id, device_relationships.held_grant_id),
|
||||
peer_ack = excluded.peer_ack,
|
||||
local_ack = excluded.local_ack,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(entry.remote_endpoint_id)
|
||||
.bind(state_as_str(entry.state))
|
||||
.bind(entry.generation as i64)
|
||||
.bind(i64::from(entry.minimum_protocol_version))
|
||||
.bind(entry.session_id)
|
||||
.bind(entry.issued_grant_handle)
|
||||
.bind(entry.held_grant_handle)
|
||||
.bind(entry.issued_grant_id)
|
||||
.bind(entry.held_grant_id)
|
||||
.bind(i64::from(entry.peer_ack))
|
||||
.bind(i64::from(entry.local_ack))
|
||||
.bind(entry.created_at)
|
||||
.bind(entry.updated_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump generation and clear grant columns after a prior generation was tombstoned.
|
||||
pub(super) async fn begin_grant_rotation(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
new_generation: u64,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET generation = ?2,
|
||||
issued_grant_handle = NULL,
|
||||
held_grant_handle = NULL,
|
||||
issued_grant_id = NULL,
|
||||
held_grant_id = NULL,
|
||||
updated_at = ?3
|
||||
WHERE remote_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(new_generation as i64)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn insert_tombstone(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
row: &RelationshipRow,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO relationship_generation_tombstones (
|
||||
remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(remote_endpoint_id, generation) DO UPDATE SET
|
||||
issued_grant_id = COALESCE(excluded.issued_grant_id, relationship_generation_tombstones.issued_grant_id),
|
||||
held_grant_id = COALESCE(excluded.held_grant_id, relationship_generation_tombstones.held_grant_id),
|
||||
revoked_at = excluded.revoked_at
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(row.generation as i64)
|
||||
.bind(row.issued_grant_id.as_deref())
|
||||
.bind(row.held_grant_id.as_deref())
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn find_tombstone(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
generation: u64,
|
||||
) -> Result<Option<GenerationTombstone>, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||
FROM relationship_generation_tombstones
|
||||
WHERE remote_endpoint_id = ?1 AND generation = ?2
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(generation as i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(row.map(|row| GenerationTombstone {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
issued_grant_id: row.get("issued_grant_id"),
|
||||
held_grant_id: row.get("held_grant_id"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn list_tombstones(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Vec<GenerationTombstone>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||
FROM relationship_generation_tombstones
|
||||
WHERE remote_endpoint_id = ?1
|
||||
ORDER BY generation ASC
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| GenerationTombstone {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
issued_grant_id: row.get("issued_grant_id"),
|
||||
held_grant_id: row.get("held_grant_id"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn state_as_str(state: DeviceRelationshipState) -> &'static str {
|
||||
match state {
|
||||
DeviceRelationshipState::PendingOutgoing => "pending_outgoing",
|
||||
DeviceRelationshipState::PendingIncoming => "pending_incoming",
|
||||
DeviceRelationshipState::Saved => "saved",
|
||||
DeviceRelationshipState::Revoked => "revoked",
|
||||
DeviceRelationshipState::Blocked => "blocked",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_state(value: &str) -> Result<DeviceRelationshipState, VnidropError> {
|
||||
match value {
|
||||
"pending_outgoing" => Ok(DeviceRelationshipState::PendingOutgoing),
|
||||
"pending_incoming" => Ok(DeviceRelationshipState::PendingIncoming),
|
||||
"saved" => Ok(DeviceRelationshipState::Saved),
|
||||
"revoked" => Ok(DeviceRelationshipState::Revoked),
|
||||
"blocked" => Ok(DeviceRelationshipState::Blocked),
|
||||
_ => Err(VnidropError::Internal {
|
||||
reason: "unknown device relationship state".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_relationship(row: sqlx::sqlite::SqliteRow) -> Result<DeviceRelationship, VnidropError> {
|
||||
Ok(DeviceRelationship {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
state: parse_state(&row.get::<String, _>("state"))?,
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
minimum_protocol_version: row.get::<i64, _>("minimum_protocol_version") as u16,
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})
|
||||
}
|
||||
|
||||
fn relationship_row_from_sql(
|
||||
row: sqlx::sqlite::SqliteRow,
|
||||
) -> Result<RelationshipRow, VnidropError> {
|
||||
Ok(RelationshipRow {
|
||||
state: parse_state(&row.get::<String, _>("state"))?,
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
minimum_protocol_version: row.get::<i64, _>("minimum_protocol_version") as u16,
|
||||
session_id: row.get("session_id"),
|
||||
issued_grant_handle: row.get("issued_grant_handle"),
|
||||
held_grant_handle: row.get("held_grant_handle"),
|
||||
issued_grant_id: row.get("issued_grant_id"),
|
||||
held_grant_id: row.get("held_grant_id"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use tokio::{
|
||||
use crate::{
|
||||
api::{CoreEvent, CoreEventSink},
|
||||
control_plane::redact_json,
|
||||
repository::Repository,
|
||||
invitation::Repository,
|
||||
transfer_state::TransferDirection,
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
//! Invitation-transfer domain store (history, approvals, delivery receipts).
|
||||
//!
|
||||
//! This is the invitation half of [`crate::persistence::AppDataStores`]. It owns
|
||||
//! only invitation-transfer tables — not relationships, eligibility, blocks, or
|
||||
//! secret metadata (those have their own domain stores).
|
||||
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
|
||||
@@ -13,19 +19,16 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
access_policy::mode_from_storage,
|
||||
api::{
|
||||
CoreEvent, PairingEligibilitySummary, ReceivedArtifact, ReceivedLocatorKind,
|
||||
ReceiverRequest, StoredTransfer,
|
||||
},
|
||||
blocked_devices::BlockStore,
|
||||
error::VnidropError,
|
||||
pairing_eligibility::{PairingEligibilityInsert, PairingEligibilityRecord},
|
||||
api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer},
|
||||
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 13;
|
||||
|
||||
/// Invitation-transfer durable store (history, receiver requests, receipts, events).
|
||||
///
|
||||
/// Type name kept for call-site stability; module path is [`crate::invitation`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Repository {
|
||||
pool: SqlitePool,
|
||||
@@ -120,8 +123,9 @@ impl Repository {
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_schema(&self) -> Result<()> {
|
||||
// The app owns this SQLite file. Keep migrations explicit so future
|
||||
// desktop/mobile releases can move user history forward in place.
|
||||
// Invitation-transfer tables only. Other domains apply schema from
|
||||
// [`crate::persistence::open_all`]. Keep migrations explicit so releases
|
||||
// can move invitation history forward in place.
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS transfers (
|
||||
@@ -330,196 +334,12 @@ impl Repository {
|
||||
.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?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS pairing_eligibilities (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
peer_endpoint_id TEXT NOT NULL,
|
||||
protocol_version INTEGER NOT NULL,
|
||||
secret_handle TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS pairing_eligibilities_peer
|
||||
ON pairing_eligibilities(peer_endpoint_id);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Identity-wide deny list for saved-device and invitation traffic.
|
||||
pub(crate) fn blocked_devices(&self) -> BlockStore {
|
||||
BlockStore::new(self.pool.clone())
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "the private custody seam is activated by platform credential adapters"
|
||||
)]
|
||||
pub(crate) fn protected_secrets(&self) -> crate::secure_secret::SecretMetadataStore {
|
||||
crate::secure_secret::SecretMetadataStore::new(self.pool.clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn insert_pairing_eligibility(
|
||||
&self,
|
||||
entry: PairingEligibilityInsert<'_>,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pairing_eligibilities (
|
||||
session_id, peer_endpoint_id, protocol_version, secret_handle, created_at, expires_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
"#,
|
||||
)
|
||||
.bind(entry.session_id)
|
||||
.bind(entry.peer_endpoint_id)
|
||||
.bind(i64::from(entry.protocol_version))
|
||||
.bind(entry.secret_handle)
|
||||
.bind(entry.created_at)
|
||||
.bind(entry.expires_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_pairing_eligibilities(
|
||||
&self,
|
||||
) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| PairingEligibilitySummary {
|
||||
peer_endpoint_id: row.get("peer_endpoint_id"),
|
||||
session_id: row.get("session_id"),
|
||||
protocol_version: row.get::<i64, _>("protocol_version") as u16,
|
||||
created_at: row.get("created_at"),
|
||||
expires_at: row.get("expires_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_pairing_eligibility_records(
|
||||
&self,
|
||||
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(row_to_pairing_eligibility).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_pairing_eligibilities_for_peer(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
WHERE peer_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(row_to_pairing_eligibility).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_expired_pairing_eligibilities(
|
||||
&self,
|
||||
now_ms: i64,
|
||||
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
WHERE expires_at <= ?1
|
||||
"#,
|
||||
)
|
||||
.bind(now_ms)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(row_to_pairing_eligibility).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn find_pairing_eligibility_by_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Option<PairingEligibilityRecord>, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
WHERE session_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(row.map(row_to_pairing_eligibility))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_pairing_eligibility(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query("DELETE FROM pairing_eligibilities WHERE session_id = ?1")
|
||||
.bind(session_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn force_pairing_eligibility_expiry_for_test(
|
||||
&self,
|
||||
session_id: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query("UPDATE pairing_eligibilities SET expires_at = ?2 WHERE session_id = ?1")
|
||||
.bind(session_id)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn schema_version(&self) -> Result<i64> {
|
||||
let row = sqlx::query("PRAGMA user_version")
|
||||
@@ -1450,14 +1270,3 @@ fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest {
|
||||
completed_at: row.get("completed_at"),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_pairing_eligibility(row: sqlx::sqlite::SqliteRow) -> PairingEligibilityRecord {
|
||||
PairingEligibilityRecord {
|
||||
peer_endpoint_id: row.get("peer_endpoint_id"),
|
||||
session_id: row.get("session_id"),
|
||||
protocol_version: row.get::<i64, _>("protocol_version") as u16,
|
||||
secret_handle: row.get("secret_handle"),
|
||||
created_at: row.get("created_at"),
|
||||
expires_at: row.get("expires_at"),
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@ mod event_hub;
|
||||
mod filesystem;
|
||||
mod grant;
|
||||
mod handshake;
|
||||
mod invitation;
|
||||
mod logging;
|
||||
mod pairing_eligibility;
|
||||
mod persistence;
|
||||
mod repository;
|
||||
mod runtime;
|
||||
mod secret;
|
||||
#[allow(
|
||||
|
||||
@@ -9,11 +9,14 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
mod store;
|
||||
|
||||
pub(crate) use store::PairingEligibilityStore;
|
||||
|
||||
use crate::{
|
||||
api::{experimental_saved_device_capabilities, PairingEligibilitySummary},
|
||||
error::VnidropError,
|
||||
event_hub::EventHub,
|
||||
repository::Repository,
|
||||
secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial},
|
||||
util::now_ms,
|
||||
};
|
||||
@@ -23,7 +26,7 @@ const CAPABILITY_CONTEXT: &str = "vnidrop-pairing-eligibility-v1";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PairingEligibilityService {
|
||||
repository: Repository,
|
||||
store: PairingEligibilityStore,
|
||||
custody: Option<Arc<SecretCustody>>,
|
||||
event_hub: Arc<EventHub>,
|
||||
local_endpoint_id: String,
|
||||
@@ -31,13 +34,13 @@ pub(crate) struct PairingEligibilityService {
|
||||
|
||||
impl PairingEligibilityService {
|
||||
pub(crate) fn new(
|
||||
repository: Repository,
|
||||
store: PairingEligibilityStore,
|
||||
custody: Option<Arc<SecretCustody>>,
|
||||
event_hub: Arc<EventHub>,
|
||||
local_endpoint_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
store,
|
||||
custody,
|
||||
event_hub,
|
||||
local_endpoint_id,
|
||||
@@ -46,7 +49,7 @@ impl PairingEligibilityService {
|
||||
|
||||
/// Removes orphaned eligibility secrets and rows whose secrets are missing.
|
||||
pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> {
|
||||
let records = self.repository.list_pairing_eligibility_records().await?;
|
||||
let records = self.store.list_records().await?;
|
||||
let mut referenced = HashSet::new();
|
||||
for entry in records {
|
||||
referenced.insert(entry.secret_handle.clone());
|
||||
@@ -73,7 +76,7 @@ impl PairingEligibilityService {
|
||||
|
||||
pub(crate) async fn list(&self) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
|
||||
self.expire_due(true).await?;
|
||||
self.repository.list_pairing_eligibilities().await
|
||||
self.store.list_summaries().await
|
||||
}
|
||||
|
||||
/// Activates eligibility after a durable completed authenticated transfer.
|
||||
@@ -89,12 +92,7 @@ impl PairingEligibilityService {
|
||||
if peer_endpoint_id.is_empty() || session_id.is_empty() || approval_token.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if self
|
||||
.repository
|
||||
.find_pairing_eligibility_by_session(session_id)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
if self.store.find_by_session(session_id).await?.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -116,8 +114,8 @@ impl PairingEligibilityService {
|
||||
let created_at = now_ms();
|
||||
let expires_at = created_at + ELIGIBILITY_TTL_MS;
|
||||
if let Err(error) = self
|
||||
.repository
|
||||
.insert_pairing_eligibility(PairingEligibilityInsert {
|
||||
.store
|
||||
.insert(PairingEligibilityInsert {
|
||||
peer_endpoint_id,
|
||||
session_id,
|
||||
protocol_version,
|
||||
@@ -165,10 +163,7 @@ impl PairingEligibilityService {
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Option<TakenEligibility>, VnidropError> {
|
||||
self.expire_due(true).await?;
|
||||
let entries = self
|
||||
.repository
|
||||
.list_pairing_eligibilities_for_peer(peer_endpoint_id)
|
||||
.await?;
|
||||
let entries = self.store.list_for_peer(peer_endpoint_id).await?;
|
||||
let Some(entry) = entries.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -225,11 +220,7 @@ impl PairingEligibilityService {
|
||||
peer_endpoint_id: &str,
|
||||
session_id: &str,
|
||||
) -> Result<(), VnidropError> {
|
||||
if let Some(entry) = self
|
||||
.repository
|
||||
.find_pairing_eligibility_by_session(session_id)
|
||||
.await?
|
||||
{
|
||||
if let Some(entry) = self.store.find_by_session(session_id).await? {
|
||||
if entry.peer_endpoint_id == peer_endpoint_id {
|
||||
self.delete_entry(&entry).await?;
|
||||
}
|
||||
@@ -242,10 +233,7 @@ impl PairingEligibilityService {
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_for_peer(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
|
||||
let entries = self
|
||||
.repository
|
||||
.list_pairing_eligibilities_for_peer(peer_endpoint_id)
|
||||
.await?;
|
||||
let entries = self.store.list_for_peer(peer_endpoint_id).await?;
|
||||
for entry in entries {
|
||||
self.delete_entry(&entry).await?;
|
||||
}
|
||||
@@ -268,11 +256,7 @@ impl PairingEligibilityService {
|
||||
let Some(custody) = &self.custody else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(entry) = self
|
||||
.repository
|
||||
.find_pairing_eligibility_by_session(session_id)
|
||||
.await?
|
||||
else {
|
||||
let Some(entry) = self.store.find_by_session(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.peer_endpoint_id != peer_endpoint_id || entry.expires_at <= now_ms() {
|
||||
@@ -294,10 +278,7 @@ impl PairingEligibilityService {
|
||||
|
||||
async fn expire_due(&self, emit_events: bool) -> Result<(), VnidropError> {
|
||||
let now = now_ms();
|
||||
let expired = self
|
||||
.repository
|
||||
.list_expired_pairing_eligibilities(now)
|
||||
.await?;
|
||||
let expired = self.store.list_expired(now).await?;
|
||||
for entry in expired {
|
||||
if emit_events {
|
||||
self.delete_entry(&entry).await?;
|
||||
@@ -321,6 +302,17 @@ impl PairingEligibilityService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn force_expiry_for_test(
|
||||
&self,
|
||||
session_id: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.store
|
||||
.force_expiry_for_test(session_id, expires_at)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_entry_silent(
|
||||
&self,
|
||||
entry: &PairingEligibilityRecord,
|
||||
@@ -329,9 +321,7 @@ impl PairingEligibilityService {
|
||||
let handle = SecretHandle::from_stored(entry.secret_handle.clone());
|
||||
let _ = custody.remove(&handle).await;
|
||||
}
|
||||
self.repository
|
||||
.delete_pairing_eligibility(&entry.session_id)
|
||||
.await
|
||||
self.store.delete(&entry.session_id).await
|
||||
}
|
||||
}
|
||||
|
||||
195
crates/vnidrop/src/pairing_eligibility/store.rs
Normal file
195
crates/vnidrop/src/pairing_eligibility/store.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
//! Durable pairing-eligibility rows (schema + queries).
|
||||
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::{api::PairingEligibilitySummary, error::VnidropError};
|
||||
|
||||
use super::{PairingEligibilityInsert, PairingEligibilityRecord};
|
||||
|
||||
/// Domain store for `pairing_eligibilities`.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PairingEligibilityStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl PairingEligibilityStore {
|
||||
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS pairing_eligibilities (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
peer_endpoint_id TEXT NOT NULL,
|
||||
protocol_version INTEGER NOT NULL,
|
||||
secret_handle TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS pairing_eligibilities_peer
|
||||
ON pairing_eligibilities(peer_endpoint_id);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn insert(
|
||||
&self,
|
||||
entry: PairingEligibilityInsert<'_>,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pairing_eligibilities (
|
||||
session_id, peer_endpoint_id, protocol_version, secret_handle, created_at, expires_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
"#,
|
||||
)
|
||||
.bind(entry.session_id)
|
||||
.bind(entry.peer_endpoint_id)
|
||||
.bind(i64::from(entry.protocol_version))
|
||||
.bind(entry.secret_handle)
|
||||
.bind(entry.created_at)
|
||||
.bind(entry.expires_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_summaries(
|
||||
&self,
|
||||
) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| PairingEligibilitySummary {
|
||||
peer_endpoint_id: row.get("peer_endpoint_id"),
|
||||
session_id: row.get("session_id"),
|
||||
protocol_version: row.get::<i64, _>("protocol_version") as u16,
|
||||
created_at: row.get("created_at"),
|
||||
expires_at: row.get("expires_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_records(&self) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(row_to_record).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_for_peer(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
WHERE peer_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(row_to_record).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_expired(
|
||||
&self,
|
||||
now_ms: i64,
|
||||
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
WHERE expires_at <= ?1
|
||||
"#,
|
||||
)
|
||||
.bind(now_ms)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(row_to_record).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn find_by_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Option<PairingEligibilityRecord>, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, session_id, protocol_version, secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
WHERE session_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(row.map(row_to_record))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete(&self, session_id: &str) -> Result<(), VnidropError> {
|
||||
sqlx::query("DELETE FROM pairing_eligibilities WHERE session_id = ?1")
|
||||
.bind(session_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn force_expiry_for_test(
|
||||
&self,
|
||||
session_id: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query("UPDATE pairing_eligibilities SET expires_at = ?2 WHERE session_id = ?1")
|
||||
.bind(session_id)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_record(row: sqlx::sqlite::SqliteRow) -> PairingEligibilityRecord {
|
||||
PairingEligibilityRecord {
|
||||
peer_endpoint_id: row.get("peer_endpoint_id"),
|
||||
session_id: row.get("session_id"),
|
||||
protocol_version: row.get::<i64, _>("protocol_version") as u16,
|
||||
secret_handle: row.get("secret_handle"),
|
||||
created_at: row.get("created_at"),
|
||||
expires_at: row.get("expires_at"),
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
//! Persistence open: one SQLite pool, every domain schema, [`AppDataStores`].
|
||||
//!
|
||||
//! Runtime talks to domain stores — not a raw pool. Unmigrated modules may still
|
||||
//! take [`AppDataStores::pool_for_unmigrated`] until their own stores deepen.
|
||||
//! Runtime talks to domain stores — not a raw pool. Schema application for each
|
||||
//! domain is owned here (not orchestrated from the invitation store).
|
||||
|
||||
use std::{path::Path, str::FromStr};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::{
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
SqlitePool,
|
||||
};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
use crate::{
|
||||
blocked_devices::BlockStore, repository::Repository, targeted_transfer::TargetedTransferStore,
|
||||
blocked_devices::{self, BlockStore},
|
||||
device_relationship::DeviceRelationshipStore,
|
||||
invitation::Repository,
|
||||
pairing_eligibility::PairingEligibilityStore,
|
||||
secure_secret::{self, SecretMetadataStore},
|
||||
targeted_transfer::{self, TargetedTransferStore},
|
||||
};
|
||||
|
||||
/// Concrete domain stores for one app-data profile.
|
||||
@@ -22,17 +24,14 @@ pub(crate) struct AppDataStores {
|
||||
pub(crate) invitation: Repository,
|
||||
/// Targeted-transfer durable rows.
|
||||
pub(crate) targeted: TargetedTransferStore,
|
||||
/// Mutual-consent device relationships (+ generation tombstones).
|
||||
pub(crate) relationships: DeviceRelationshipStore,
|
||||
/// Post-transfer pairing eligibility rows.
|
||||
pub(crate) eligibility: PairingEligibilityStore,
|
||||
/// Non-secret metadata for protected credential handles.
|
||||
pub(crate) secrets: SecretMetadataStore,
|
||||
/// Identity-wide deny list.
|
||||
pub(crate) blocked: BlockStore,
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl AppDataStores {
|
||||
/// Temporary: remaining domain modules still construct on a shared pool.
|
||||
/// Do not add new callers — migrate them to domain stores instead.
|
||||
pub(crate) fn pool_for_unmigrated(&self) -> SqlitePool {
|
||||
self.pool.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the profile pool, apply all domain schemas, return [`AppDataStores`].
|
||||
@@ -47,15 +46,27 @@ pub(crate) async fn open_all(app_data_dir: &Path) -> Result<AppDataStores> {
|
||||
.await
|
||||
.context("failed to open app data sqlite")?;
|
||||
|
||||
// Invitation ensure_schema still orchestrates cross-domain schemas until
|
||||
// each domain store owns its ensure_schema call from this path alone.
|
||||
// Unreleased device-history prototype tables — no migration path.
|
||||
for table in ["held_offers", "grants_held", "grants_issued", "contacts"] {
|
||||
sqlx::query(&format!("DROP TABLE IF EXISTS {table}"))
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let invitation = Repository::from_pool(pool.clone());
|
||||
invitation.ensure_schema().await?;
|
||||
blocked_devices::ensure_schema(&pool).await?;
|
||||
secure_secret::ensure_schema(&pool).await?;
|
||||
DeviceRelationshipStore::ensure_schema(&pool).await?;
|
||||
targeted_transfer::ensure_schema(&pool).await?;
|
||||
PairingEligibilityStore::ensure_schema(&pool).await?;
|
||||
|
||||
Ok(AppDataStores {
|
||||
targeted: TargetedTransferStore::new(pool.clone()),
|
||||
blocked: BlockStore::new(pool.clone()),
|
||||
relationships: DeviceRelationshipStore::new(pool.clone()),
|
||||
eligibility: PairingEligibilityStore::new(pool.clone()),
|
||||
secrets: SecretMetadataStore::new(pool.clone()),
|
||||
blocked: BlockStore::new(pool),
|
||||
invitation,
|
||||
pool,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService,
|
||||
},
|
||||
repository::PendingDeliveryReceipt,
|
||||
invitation::PendingDeliveryReceipt,
|
||||
ticket::parse_persisted_sender_address,
|
||||
};
|
||||
|
||||
|
||||
@@ -130,8 +130,8 @@ impl VnidropCore {
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.repository
|
||||
.force_pairing_eligibility_expiry_for_test(&session_id, expires_at),
|
||||
.pairing_eligibility
|
||||
.force_expiry_for_test(&session_id, expires_at),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ use crate::{
|
||||
device_relationship::{DeviceRelationshipService, RelationshipProtocol},
|
||||
event_hub::EventHub,
|
||||
handshake::HandshakeService,
|
||||
invitation::Repository,
|
||||
logging::init_logging,
|
||||
pairing_eligibility::PairingEligibilityService,
|
||||
repository::Repository,
|
||||
secret::load_or_create_secret,
|
||||
secure_secret::{start_endpoint_identity, ProfileLock, SecureSecretStore},
|
||||
targeted_transfer::{TargetedOfferInbox, TargetedTransferProtocol},
|
||||
@@ -163,7 +163,7 @@ impl CoreInner {
|
||||
profile_lock,
|
||||
} => {
|
||||
let (secret_key, custody) = start_endpoint_identity(
|
||||
repository.protected_secrets(),
|
||||
stores.secrets.clone(),
|
||||
store,
|
||||
&app_data_dir.join("iroh.secret"),
|
||||
)
|
||||
@@ -358,13 +358,14 @@ impl CoreInner {
|
||||
}
|
||||
}
|
||||
let pairing_eligibility = PairingEligibilityService::new(
|
||||
repository.clone(),
|
||||
stores.eligibility.clone(),
|
||||
secret_custody.clone(),
|
||||
event_hub.clone(),
|
||||
endpoint.id().to_string(),
|
||||
);
|
||||
let approval = ApprovalService::new(
|
||||
repository.clone(),
|
||||
blocked_devices.clone(),
|
||||
event_hub.clone(),
|
||||
access_policy.clone(),
|
||||
limits.max_pending_approvals as usize,
|
||||
@@ -383,7 +384,8 @@ impl CoreInner {
|
||||
limits.offer_timeout_ms,
|
||||
);
|
||||
let device_relationships = Arc::new(DeviceRelationshipService::new(
|
||||
stores.pool_for_unmigrated(),
|
||||
stores.relationships.clone(),
|
||||
stores.blocked.clone(),
|
||||
secret_custody.clone(),
|
||||
pairing_eligibility.clone(),
|
||||
event_hub.clone(),
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::{
|
||||
AtomicOutputFile,
|
||||
},
|
||||
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
|
||||
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
|
||||
invitation::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
|
||||
ticket::{
|
||||
encode_persisted_sender_address, parse_transfer_ticket_with_limits, ParsedTransferTicket,
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::{
|
||||
collect_import_files_with_limits, default_collection_name,
|
||||
read_stream_from_blocking_reader, TransferImport,
|
||||
},
|
||||
repository::TransferUpsert,
|
||||
invitation::TransferUpsert,
|
||||
ticket::VnidropTicket,
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
util::non_empty,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{blocked_devices::BlockStore, persistence, repository::Repository};
|
||||
use crate::{blocked_devices::BlockStore, invitation::Repository, persistence};
|
||||
|
||||
async fn store(temp: &tempfile::TempDir) -> BlockStore {
|
||||
persistence::open_all(temp.path()).await.unwrap().blocked
|
||||
@@ -45,7 +45,12 @@ async fn opening_app_data_drops_unreleased_prototype_tables() {
|
||||
}
|
||||
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let pool = stores.pool_for_unmigrated();
|
||||
let pool = {
|
||||
let options = sqlx::sqlite::SqliteConnectOptions::new()
|
||||
.filename(temp.path().join("vnidrop.sqlite3"))
|
||||
.create_if_missing(false);
|
||||
sqlx::SqlitePool::connect_with(options).await.unwrap()
|
||||
};
|
||||
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}'"
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
api::{CoreEvent, CoreEventSink, CoreLimits, PendingTargetedOffer},
|
||||
control_plane::IdentityCooldown,
|
||||
event_hub::EventHub,
|
||||
repository::Repository,
|
||||
invitation::Repository,
|
||||
secure_secret::FaultInjectingSecretStore,
|
||||
targeted_transfer::inbox::{TargetedOfferDecision, TargetedOfferInbox},
|
||||
CoreNetworkConfig, DeviceRelationshipState, ShareMetadataInput, ShareSource, SourceKind,
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
//! Persistence open returns domain stores without exporting a raw pool to callers.
|
||||
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::persistence;
|
||||
|
||||
async fn open_profile_pool(app_data_dir: &std::path::Path) -> sqlx::SqlitePool {
|
||||
let db = app_data_dir.join("vnidrop.sqlite3");
|
||||
let options = sqlx::sqlite::SqliteConnectOptions::new()
|
||||
.filename(&db)
|
||||
.create_if_missing(false);
|
||||
sqlx::SqlitePool::connect_with(options).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_all_returns_invitation_targeted_and_blocked_stores() {
|
||||
async fn open_all_returns_all_domain_stores_and_schemas() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
|
||||
assert!(stores.blocked.list_blocked().await.unwrap().is_empty());
|
||||
assert!(stores.targeted.list().await.unwrap().is_empty());
|
||||
assert!(stores.invitation.list_transfers().await.unwrap().is_empty());
|
||||
assert!(stores
|
||||
.eligibility
|
||||
.list_summaries()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
let pool = open_profile_pool(temp.path()).await;
|
||||
for table in [
|
||||
"device_relationships",
|
||||
"relationship_generation_tombstones",
|
||||
"pairing_eligibilities",
|
||||
"protected_secret_refs",
|
||||
"blocked_endpoints",
|
||||
"targeted_transfers",
|
||||
"transfers",
|
||||
] {
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = '{table}'"
|
||||
))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
row.get::<i64, _>("n"),
|
||||
1,
|
||||
"{table} must exist after open_all"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
api::{CoreEvent, ReceivedLocatorKind},
|
||||
repository::{
|
||||
invitation::{
|
||||
PendingDeliveryReceiptInsert, ReceivedArtifactInsert, ReceiverRequestInsert, Repository,
|
||||
TransferUpsert,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ use iroh_blobs::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
||||
invitation::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
||||
runtime::{consume_request_updates, CoreInner, IdentityMode, RequestStreamOutcome},
|
||||
secure_secret::{lock_profile, FaultInjectingSecretStore},
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
|
||||
@@ -7,7 +7,7 @@ use data_encoding::HEXLOWER;
|
||||
use iroh::SecretKey;
|
||||
|
||||
use crate::{
|
||||
repository::Repository,
|
||||
persistence,
|
||||
secure_secret::{
|
||||
lock_profile, scope_store, CustodyCrashPoint, FaultInjectingSecretStore,
|
||||
ReferenceStoreFailure, SecretCustody, SecretKind, SecretMaterial, SecureSecretStore,
|
||||
@@ -55,10 +55,10 @@ async fn reconciliation_is_scoped_to_one_application_profile() {
|
||||
let shared_platform_store = Arc::new(FaultInjectingSecretStore::default());
|
||||
let first_store = scope_store(&first_dir, shared_platform_store.clone());
|
||||
let second_store = scope_store(&second_dir, shared_platform_store);
|
||||
let first_repository = Repository::open(&first_dir).await.unwrap();
|
||||
let second_repository = Repository::open(&second_dir).await.unwrap();
|
||||
let first = SecretCustody::new(first_repository.protected_secrets(), first_store.clone());
|
||||
let second = SecretCustody::new(second_repository.protected_secrets(), second_store.clone());
|
||||
let first_stores = persistence::open_all(&first_dir).await.unwrap();
|
||||
let second_stores = persistence::open_all(&second_dir).await.unwrap();
|
||||
let first = SecretCustody::new(first_stores.secrets.clone(), first_store.clone());
|
||||
let second = SecretCustody::new(second_stores.secrets.clone(), second_store.clone());
|
||||
let first_handle = first
|
||||
.protect(
|
||||
SecretKind::RelationshipGrant,
|
||||
@@ -77,10 +77,9 @@ async fn reconciliation_is_scoped_to_one_application_profile() {
|
||||
.unwrap();
|
||||
|
||||
drop(first);
|
||||
let (restarted, summary) =
|
||||
SecretCustody::start(first_repository.protected_secrets(), first_store)
|
||||
.await
|
||||
.unwrap();
|
||||
let (restarted, summary) = SecretCustody::start(first_stores.secrets.clone(), first_store)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(summary.orphans_deleted, 0);
|
||||
assert_eq!(
|
||||
@@ -96,9 +95,9 @@ async fn reconciliation_is_scoped_to_one_application_profile() {
|
||||
#[tokio::test]
|
||||
async fn custody_maps_reference_store_failures_to_typed_core_errors() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
|
||||
let secret = SecretMaterial::new(vec![0x5a; 32]).unwrap();
|
||||
let handle = custody
|
||||
.protect(SecretKind::RelationshipGrant, secret.clone(), None)
|
||||
@@ -140,9 +139,9 @@ async fn custody_maps_reference_store_failures_to_typed_core_errors() {
|
||||
#[tokio::test]
|
||||
async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut repository = Repository::open(temp.path()).await.unwrap();
|
||||
let mut stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
|
||||
|
||||
custody.crash_once_at(CustodyCrashPoint::StoreWrite);
|
||||
assert!(custody
|
||||
@@ -154,9 +153,9 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets()
|
||||
.await
|
||||
.is_err());
|
||||
drop(custody);
|
||||
drop(repository);
|
||||
repository = Repository::open(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||
drop(stores);
|
||||
stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.orphans_deleted, 1);
|
||||
@@ -173,9 +172,9 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets()
|
||||
.is_err());
|
||||
let staged_handle = store.only_handle_for_test();
|
||||
drop(custody);
|
||||
drop(repository);
|
||||
repository = Repository::open(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||
drop(stores);
|
||||
stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.staged_activated, 1);
|
||||
@@ -186,9 +185,9 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets()
|
||||
|
||||
store.remove_for_test(&staged_handle);
|
||||
drop(custody);
|
||||
drop(repository);
|
||||
repository = Repository::open(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||
drop(stores);
|
||||
stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.disabled, 1);
|
||||
@@ -207,9 +206,8 @@ async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets()
|
||||
.unwrap();
|
||||
store.corrupt_for_test(&corrupted);
|
||||
drop(custody);
|
||||
drop(repository);
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.disabled, 1);
|
||||
@@ -225,9 +223,9 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem
|
||||
let legacy_path = temp.path().join("iroh.secret");
|
||||
let original = SecretKey::generate();
|
||||
std::fs::write(&legacy_path, HEXLOWER.encode(&original.to_bytes())).unwrap();
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
|
||||
|
||||
custody.crash_once_at(CustodyCrashPoint::MetadataActivation);
|
||||
assert!(custody
|
||||
@@ -240,9 +238,8 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem
|
||||
);
|
||||
|
||||
drop(custody);
|
||||
drop(repository);
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.staged_activated, 0);
|
||||
@@ -272,8 +269,8 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem
|
||||
let empty_store = Arc::new(FaultInjectingSecretStore::default());
|
||||
let other_dir = temp.path().join("other");
|
||||
std::fs::create_dir(&other_dir).unwrap();
|
||||
let other_repository = Repository::open(&other_dir).await.unwrap();
|
||||
let empty_custody = SecretCustody::new(other_repository.protected_secrets(), empty_store);
|
||||
let other_stores = persistence::open_all(&other_dir).await.unwrap();
|
||||
let empty_custody = SecretCustody::new(other_stores.secrets.clone(), empty_store);
|
||||
assert!(matches!(
|
||||
empty_custody
|
||||
.migrate_legacy_endpoint_identity(&missing)
|
||||
@@ -286,9 +283,9 @@ async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacem
|
||||
async fn first_install_identity_is_protected_once_and_never_silently_replaced() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let legacy_path = temp.path().join("iroh.secret");
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||
let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||
let (custody, _) = SecretCustody::start(stores.secrets.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -299,10 +296,9 @@ async fn first_install_identity_is_protected_once_and_never_silently_replaced()
|
||||
assert!(!legacy_path.exists());
|
||||
let handle = store.only_handle_for_test();
|
||||
drop(custody);
|
||||
drop(repository);
|
||||
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let (custody, _) = SecretCustody::start(stores.secrets.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -315,9 +311,8 @@ async fn first_install_identity_is_protected_once_and_never_silently_replaced()
|
||||
|
||||
store.remove_for_test(&handle);
|
||||
drop(custody);
|
||||
drop(repository);
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let (custody, summary) = SecretCustody::start(stores.secrets.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.disabled, 1);
|
||||
@@ -332,10 +327,10 @@ async fn first_install_identity_is_protected_once_and_never_silently_replaced()
|
||||
async fn concurrent_first_starts_converge_on_one_protected_endpoint_identity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let legacy_path = temp.path().join("iroh.secret");
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||
let first = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||
let second = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||
let first = SecretCustody::new(stores.secrets.clone(), store.clone());
|
||||
let second = SecretCustody::new(stores.secrets.clone(), store.clone());
|
||||
|
||||
let (first_identity, second_identity) = tokio::join!(
|
||||
first.initialize_endpoint_identity(&legacy_path),
|
||||
@@ -349,9 +344,9 @@ async fn concurrent_first_starts_converge_on_one_protected_endpoint_identity() {
|
||||
#[tokio::test]
|
||||
async fn protected_material_is_absent_from_database_and_diagnostics() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
|
||||
let raw = (0u8..32).map(|value| value + 1).collect::<Vec<_>>();
|
||||
let encoded = HEXLOWER.encode(&raw);
|
||||
let material = SecretMaterial::new(raw.clone()).unwrap();
|
||||
@@ -380,6 +375,7 @@ async fn protected_material_is_absent_from_database_and_diagnostics() {
|
||||
let diagnostics = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap();
|
||||
assert!(!diagnostics.contains(&encoded));
|
||||
|
||||
let repository = stores.invitation.clone();
|
||||
assert!(repository.list_events(None, 500).await.unwrap().is_empty());
|
||||
|
||||
let mut persisted = Vec::new();
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
use secret_service::Error;
|
||||
|
||||
use crate::{
|
||||
repository::Repository,
|
||||
persistence,
|
||||
secure_secret::{
|
||||
linux::{map_error, LinuxSecretServiceApi, LinuxSecretServiceStore},
|
||||
SecretCustody, SecretHandle, SecretKind, SecretMaterial, SecureSecretStore,
|
||||
@@ -101,10 +101,10 @@ fn adapter_survives_restart_and_deletes_only_the_selected_item() {
|
||||
#[tokio::test]
|
||||
async fn transient_backend_failures_do_not_delete_protected_metadata_or_material() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
let stores = persistence::open_all(temp.path()).await.unwrap();
|
||||
let api = Arc::new(RecordingSecretService::default());
|
||||
let store = Arc::new(LinuxSecretServiceStore::with_api(api.clone()));
|
||||
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||
let custody = SecretCustody::new(stores.secrets.clone(), store.clone());
|
||||
let protected = custody
|
||||
.protect(
|
||||
SecretKind::RelationshipGrant,
|
||||
@@ -117,12 +117,12 @@ async fn transient_backend_failures_do_not_delete_protected_metadata_or_material
|
||||
*api.failure.lock().unwrap() = Some(SecureSecretStoreError::Unavailable);
|
||||
drop(custody);
|
||||
assert!(matches!(
|
||||
SecretCustody::start(repository.protected_secrets(), store.clone()).await,
|
||||
SecretCustody::start(stores.secrets.clone(), store.clone()).await,
|
||||
Err(VnidropError::SecureStorageUnavailable { .. })
|
||||
));
|
||||
|
||||
*api.failure.lock().unwrap() = None;
|
||||
let (restarted, _) = SecretCustody::start(repository.protected_secrets(), store)
|
||||
let (restarted, _) = SecretCustody::start(stores.secrets.clone(), store)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -4,7 +4,7 @@ use data_encoding::HEXLOWER;
|
||||
use iroh::SecretKey;
|
||||
|
||||
use crate::{
|
||||
repository::Repository,
|
||||
persistence,
|
||||
secure_secret::{
|
||||
windows::WindowsDpapiSecretStore, CustodyCrashPoint, SecretCustody, SecretMaterial,
|
||||
SecureSecretStore, SecureSecretStoreError,
|
||||
@@ -144,10 +144,10 @@ async fn endpoint_migration_survives_activation_crash_without_changing_identity(
|
||||
let original = SecretKey::generate();
|
||||
fs::write(&legacy, HEXLOWER.encode(&original.to_bytes())).unwrap();
|
||||
|
||||
let repository = Repository::open(&app_data).await.unwrap();
|
||||
let stores = persistence::open_all(&app_data).await.unwrap();
|
||||
let protected_directory = app_data.join("protected-secrets");
|
||||
let store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap());
|
||||
let custody = SecretCustody::new(repository.protected_secrets(), store);
|
||||
let custody = SecretCustody::new(stores.secrets.clone(), store);
|
||||
custody.crash_once_at(CustodyCrashPoint::MetadataActivation);
|
||||
assert!(custody
|
||||
.migrate_legacy_endpoint_identity(&legacy)
|
||||
@@ -155,11 +155,10 @@ async fn endpoint_migration_survives_activation_crash_without_changing_identity(
|
||||
.is_err());
|
||||
assert!(legacy.exists());
|
||||
drop(custody);
|
||||
drop(repository);
|
||||
|
||||
let repository = Repository::open(&app_data).await.unwrap();
|
||||
let stores = persistence::open_all(&app_data).await.unwrap();
|
||||
let restarted_store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap());
|
||||
let (custody, _) = SecretCustody::start(repository.protected_secrets(), restarted_store)
|
||||
let (custody, _) = SecretCustody::start(stores.secrets.clone(), restarted_store)
|
||||
.await
|
||||
.unwrap();
|
||||
let handle = custody
|
||||
|
||||
Reference in New Issue
Block a user