use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::Serialize;
use tokio::sync::RwLock;
use tracing::{debug, warn};
use vti_common::store::KeyspaceHandle;
use super::model::{RegistryRecord, RegistryStatus};
use super::storage::list_records;
use super::{RegistryError, TrustRegistryClient};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub enum Disagreement {
MissingAtRegistry,
UnknownLocally,
StatusMismatch,
}
impl Disagreement {
pub fn is_fault(self) -> bool {
matches!(self, Self::MissingAtRegistry | Self::StatusMismatch)
}
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct DriftEntry {
pub member_did: String,
pub disagreement: Disagreement,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_status: Option<RegistryStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registry_status: Option<RegistryStatus>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct DriftSnapshot {
pub checked_at: DateTime<Utc>,
pub local_count: usize,
pub registry_count: usize,
pub entries: Vec<DriftEntry>,
pub total: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl DriftSnapshot {
pub fn fault_count(&self) -> usize {
self.entries
.iter()
.filter(|e| e.disagreement.is_fault())
.count()
}
}
pub const MAX_REPORTED: usize = 100;
#[derive(Debug, Clone, Default)]
pub struct DriftState {
inner: Arc<RwLock<Option<DriftSnapshot>>>,
}
impl DriftState {
pub fn new() -> Self {
Self::default()
}
pub async fn snapshot(&self) -> Option<DriftSnapshot> {
self.inner.read().await.clone()
}
async fn publish(&self, snapshot: DriftSnapshot) {
*self.inner.write().await = Some(snapshot);
}
async fn publish_error(&self, error: String) {
let mut guard = self.inner.write().await;
match guard.as_mut() {
Some(existing) => existing.error = Some(error),
None => {
*guard = Some(DriftSnapshot {
checked_at: Utc::now(),
local_count: 0,
registry_count: 0,
entries: Vec::new(),
total: 0,
error: Some(error),
})
}
}
}
}
pub async fn check(
client: &dyn TrustRegistryClient,
registry_records_ks: &KeyspaceHandle,
state: &DriftState,
) {
let local = match list_records(registry_records_ks).await {
Ok(rows) => rows,
Err(e) => {
warn!(error = %e, "drift check could not read the local registry mirror");
state
.publish_error(format!("local mirror unreadable: {e}"))
.await;
return;
}
};
let remote = match client.list_records().await {
Ok(rows) => rows,
Err(e) => {
debug!(error = %e, "drift check could not enumerate the trust registry");
state
.publish_error(format!("registry unreadable: {e}"))
.await;
return;
}
};
state.publish(compare(&local, &remote)).await;
}
pub fn compare(local: &[RegistryRecord], remote: &[RegistryRecord]) -> DriftSnapshot {
use std::collections::BTreeMap;
let local_by_did: BTreeMap<&str, &RegistryRecord> =
local.iter().map(|r| (r.member_did.as_str(), r)).collect();
let remote_by_did: BTreeMap<&str, &RegistryRecord> =
remote.iter().map(|r| (r.member_did.as_str(), r)).collect();
let mut entries = Vec::new();
for (did, ours) in &local_by_did {
match remote_by_did.get(did) {
None => entries.push(DriftEntry {
member_did: (*did).to_string(),
disagreement: Disagreement::MissingAtRegistry,
local_status: Some(ours.status),
registry_status: None,
}),
Some(theirs) if theirs.status != ours.status => entries.push(DriftEntry {
member_did: (*did).to_string(),
disagreement: Disagreement::StatusMismatch,
local_status: Some(ours.status),
registry_status: Some(theirs.status),
}),
Some(_) => {}
}
}
for (did, theirs) in &remote_by_did {
if !local_by_did.contains_key(did) {
entries.push(DriftEntry {
member_did: (*did).to_string(),
disagreement: Disagreement::UnknownLocally,
local_status: None,
registry_status: Some(theirs.status),
});
}
}
entries.sort_by_key(|e| (!e.disagreement.is_fault(), e.member_did.clone()));
let total = entries.len();
entries.truncate(MAX_REPORTED);
DriftSnapshot {
checked_at: Utc::now(),
local_count: local.len(),
registry_count: remote.len(),
entries,
total,
error: None,
}
}
pub fn unsupported() -> RegistryError {
RegistryError::Permanent(
"this trust-registry transport cannot enumerate records, so drift cannot be checked".into(),
)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn active(did: &str) -> RegistryRecord {
RegistryRecord::fresh_active(did)
}
fn departed(did: &str) -> RegistryRecord {
RegistryRecord::departed(did, Utc::now(), None)
}
#[test]
fn a_member_we_published_and_the_registry_lacks_is_a_fault() {
let snap = compare(&[active("did:key:zGone")], &[]);
assert_eq!(snap.total, 1);
assert_eq!(
snap.entries[0].disagreement,
Disagreement::MissingAtRegistry
);
assert_eq!(snap.fault_count(), 1);
}
#[test]
fn a_record_only_the_registry_has_is_reported_but_not_a_fault() {
let snap = compare(&[], &[active("did:key:zExtra")]);
assert_eq!(snap.total, 1);
assert_eq!(snap.entries[0].disagreement, Disagreement::UnknownLocally);
assert_eq!(snap.fault_count(), 0);
}
#[test]
fn a_status_disagreement_is_a_fault() {
let snap = compare(&[departed("did:key:zLeft")], &[active("did:key:zLeft")]);
assert_eq!(snap.entries[0].disagreement, Disagreement::StatusMismatch);
assert_eq!(snap.entries[0].local_status, Some(RegistryStatus::Departed));
assert_eq!(
snap.entries[0].registry_status,
Some(RegistryStatus::Active)
);
assert_eq!(snap.fault_count(), 1);
}
#[test]
fn agreement_produces_no_entries_but_still_reports_counts() {
let snap = compare(&[active("did:key:zA")], &[active("did:key:zA")]);
assert!(snap.entries.is_empty());
assert_eq!(snap.local_count, 1);
assert_eq!(snap.registry_count, 1);
assert_eq!(snap.total, 0);
}
#[test]
fn truncation_keeps_faults_ahead_of_benign_entries() {
let local: Vec<_> = (0..5)
.map(|i| active(&format!("did:key:zLost{i}")))
.collect();
let remote: Vec<_> = (0..MAX_REPORTED + 50)
.map(|i| active(&format!("did:key:zExtra{i:04}")))
.collect();
let snap = compare(&local, &remote);
assert_eq!(snap.total, 5 + MAX_REPORTED + 50);
assert_eq!(snap.entries.len(), MAX_REPORTED);
assert_eq!(
snap.fault_count(),
5,
"every lost write survives truncation: {:?}",
snap.entries.iter().take(8).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn a_failed_check_annotates_rather_than_erases() {
let state = DriftState::new();
state
.publish(compare(&[active("did:key:zGone")], &[]))
.await;
state
.publish_error("registry unreadable: timeout".into())
.await;
let snap = state.snapshot().await.unwrap();
assert_eq!(snap.total, 1, "the finding survives");
assert!(snap.error.as_deref().unwrap().contains("timeout"));
}
#[tokio::test]
async fn state_is_empty_until_the_first_check() {
assert!(DriftState::new().snapshot().await.is_none());
}
}