use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
pub use affinidi_did_resolver_cache_sdk::network_resolvers::{ScidSourceRecord, ScidVersion};
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tracing::{debug, warn};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
pub const PREFIX: &str = "scid_floor:";
const MAX_SOURCES: usize = 4;
const QUEUE: usize = 1024;
pub type Authorised =
Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = bool> + Send>> + Send + Sync>;
fn key(bare: &str) -> String {
format!("{PREFIX}{bare}")
}
fn bare(did: &str) -> String {
let did = vta_sdk::did_scid::identity(did);
did.split('#').next().unwrap_or(&did).to_string()
}
fn merge(
current: Option<ScidSourceRecord>,
incoming: &ScidSourceRecord,
) -> Option<ScidSourceRecord> {
let did = bare(&incoming.did);
let mut merged = ScidSourceRecord {
did: did.clone(),
sources: incoming.sources.clone(),
version: incoming.version.clone(),
};
if let Some(current) = ¤t {
for src in ¤t.sources {
if !merged.sources.contains(src) {
merged.sources.push(src.clone());
}
}
merged.version = match (¤t.version, &incoming.version) {
(Some(c), Some(i)) if c.number >= i.number => Some(c.clone()),
(Some(c), None) => Some(c.clone()),
(_, i) => i.clone(),
};
}
merged.sources.truncate(MAX_SOURCES);
(current.as_ref() != Some(&merged)).then_some(merged)
}
pub async fn store(ks: &KeyspaceHandle, record: &ScidSourceRecord) -> Result<bool, AppError> {
if !record.did.starts_with("did:scid:") {
return Ok(false);
}
let did = bare(&record.did);
let current: Option<ScidSourceRecord> = ks.get(key(&did)).await?;
match merge(current, record) {
Some(merged) => {
ks.insert(key(&did), &merged).await?;
debug!(did = %did, version = ?merged.version.as_ref().map(|v| v.number), "did:scid floor persisted");
Ok(true)
}
None => Ok(false),
}
}
pub async fn get(ks: &KeyspaceHandle, did: &str) -> Result<Option<ScidSourceRecord>, AppError> {
ks.get(key(&bare(did))).await
}
pub async fn load(ks: &KeyspaceHandle) -> Result<Vec<ScidSourceRecord>, AppError> {
let mut records = Vec::new();
for (_key, raw) in ks.prefix_iter_raw(PREFIX.as_bytes().to_vec()).await? {
match serde_json::from_slice::<ScidSourceRecord>(&raw) {
Ok(record) => records.push(record),
Err(e) => warn!(error = %e, "unreadable did:scid floor row skipped"),
}
}
Ok(records)
}
pub async fn seed(ks: &KeyspaceHandle, resolver: &DIDCacheClient) -> Result<usize, AppError> {
let book = resolver.scid_sources();
Ok(load(ks).await?.iter().filter(|r| book.seed(r)).count())
}
pub async fn sweep(
ks: &KeyspaceHandle,
resolver: &DIDCacheClient,
authorised: &Authorised,
) -> usize {
let mut written = 0;
for record in resolver.scid_sources().records() {
if authorised(record.did.clone()).await {
match store(ks, &record).await {
Ok(true) => written += 1,
Ok(false) => {}
Err(e) => warn!(did = %record.did, error = %e, "did:scid floor not persisted"),
}
}
}
written
}
pub fn spawn(
ks: KeyspaceHandle,
resolver: DIDCacheClient,
authorised: Authorised,
mut shutdown: watch::Receiver<bool>,
sweep_every: Duration,
) -> JoinHandle<()> {
let (tx, mut rx) = mpsc::channel::<ScidSourceRecord>(QUEUE);
resolver
.scid_sources()
.set_listener(Arc::new(move |record: &ScidSourceRecord| {
if tx.try_send(record.clone()).is_err() {
warn!(did = %record.did, "did:scid floor queue full; left to the next sweep");
}
}));
tokio::spawn(async move {
let mut tick = tokio::time::interval(sweep_every);
tick.tick().await;
loop {
tokio::select! {
Some(record) = rx.recv() => {
if authorised(record.did.clone()).await
&& let Err(e) = store(&ks, &record).await
{
warn!(did = %record.did, error = %e, "did:scid floor not persisted");
}
}
_ = tick.tick() => {
sweep(&ks, &resolver, &authorised).await;
}
changed = shutdown.changed() => {
if changed.is_err() || *shutdown.borrow() {
sweep(&ks, &resolver, &authorised).await;
break;
}
}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn open_ks() -> (tempfile::TempDir, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = crate::store::Store::open(&crate::config::StoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let ks = store.keyspace("acl").unwrap();
(dir, ks)
}
fn at(number: u32, sources: &[&str]) -> ScidSourceRecord {
ScidSourceRecord {
did: "did:scid:vh:1:QmScid".into(),
sources: sources.iter().map(|s| s.to_string()).collect(),
version: Some(ScidVersion {
number,
id: format!("{number}-Qm"),
}),
}
}
#[tokio::test]
async fn a_stored_floor_is_never_lowered() {
let (_dir, ks) = open_ks();
assert!(store(&ks, &at(5, &["b.example"])).await.unwrap());
assert!(
store(&ks, &at(3, &["a.example"])).await.unwrap(),
"a new source is added"
);
let held = get(&ks, "did:scid:vh:1:QmScid?src=c.example")
.await
.unwrap()
.unwrap();
assert_eq!(held.version.unwrap().number, 5);
assert_eq!(held.sources, vec!["a.example", "b.example"]);
assert!(
!store(&ks, &at(5, &["a.example"])).await.unwrap(),
"unchanged: nothing written"
);
assert!(store(&ks, &at(7, &["a.example"])).await.unwrap());
assert_eq!(
get(&ks, "did:scid:vh:1:QmScid")
.await
.unwrap()
.unwrap()
.version
.unwrap()
.number,
7
);
}
#[tokio::test]
async fn only_did_scid_records_are_stored() {
let (_dir, ks) = open_ks();
let mut record = at(1, &["a.example"]);
record.did = "did:webvh:QmScid:a.example".into();
assert!(!store(&ks, &record).await.unwrap());
assert!(load(&ks).await.unwrap().is_empty());
}
async fn resolver() -> DIDCacheClient {
DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.unwrap()
}
async fn eventually(ks: &KeyspaceHandle, did: &str) -> Option<ScidSourceRecord> {
for _ in 0..50 {
if let Some(record) = get(ks, did).await.unwrap() {
return Some(record);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
None
}
#[tokio::test]
async fn the_writer_persists_authorised_identities_and_a_restart_reseeds_them() {
use std::sync::atomic::{AtomicBool, Ordering};
let (_dir, ks) = open_ks();
let live = resolver().await;
let late = Arc::new(AtomicBool::new(false));
let gate = late.clone();
let authorised: Authorised = Arc::new(move |did: String| {
let late = gate.load(Ordering::SeqCst);
Box::pin(async move {
did == "did:scid:vh:1:QmMember" || (late && did == "did:scid:vh:1:QmLater")
})
});
let (stop, shutdown) = watch::channel(false);
let task = spawn(
ks.clone(),
live.clone(),
authorised,
shutdown,
Duration::from_secs(3600),
);
let book = live.scid_sources();
assert!(book.add_source("did:scid:vh:1:QmMember?src=a.example/m"));
assert!(book.add_source("did:scid:vh:1:QmStranger?src=b.example"));
assert!(book.add_source("did:scid:vh:1:QmLater?src=c.example"));
let held = eventually(&ks, "did:scid:vh:1:QmMember")
.await
.expect("persisted");
assert_eq!(held.sources, vec!["a.example/m"]);
assert!(
get(&ks, "did:scid:vh:1:QmStranger")
.await
.unwrap()
.is_none()
);
assert!(get(&ks, "did:scid:vh:1:QmLater").await.unwrap().is_none());
late.store(true, Ordering::SeqCst);
stop.send(true).unwrap();
task.await.unwrap();
assert!(get(&ks, "did:scid:vh:1:QmLater").await.unwrap().is_some());
assert!(
get(&ks, "did:scid:vh:1:QmStranger")
.await
.unwrap()
.is_none()
);
let restarted = resolver().await;
assert_eq!(seed(&ks, &restarted).await.unwrap(), 2);
assert_eq!(
restarted
.scid_sources()
.source("did:scid:vh:1:QmMember")
.as_deref(),
Some("a.example/m")
);
}
#[tokio::test]
async fn rows_do_not_appear_as_acl_entries() {
let (_dir, ks) = open_ks();
store(&ks, &at(1, &["a.example"])).await.unwrap();
assert!(crate::acl::list_acl_entries(&ks).await.unwrap().is_empty());
}
}