use std::path::PathBuf;
use affinidi_messaging_sdk::protocols::tsp::{RelationshipState, RelationshipStore, ThreadDigests};
use vti_common::relationship_store::{KeyspaceRelationshipKv, KeyspaceRelationshipStore};
use vti_common::store::KeyspaceHandle;
use crate::cli_store::CliStore;
use crate::config::AppConfig;
type CliResult = Result<(), Box<dyn std::error::Error>>;
pub enum Target {
Peer { peer: String, our: Option<String> },
All,
}
async fn open(
config_path: Option<PathBuf>,
) -> Result<(CliStore, KeyspaceHandle), Box<dyn std::error::Error>> {
let config = AppConfig::load(config_path)?;
let cs = CliStore::open(&config).await?;
let ks = cs.keyspace(crate::keyspaces::RELATIONSHIPS)?;
Ok((cs, ks))
}
fn store_over(ks: &KeyspaceHandle) -> KeyspaceRelationshipStore {
KeyspaceRelationshipStore::new(KeyspaceRelationshipKv::new(ks.clone()))
}
pub async fn run_list(config_path: Option<PathBuf>) -> CliResult {
let (_cs, ks) = open(config_path).await?;
list_on(&ks).await
}
async fn list_on(ks: &KeyspaceHandle) -> CliResult {
let relationships = store_over(ks);
let established = relationships.established_relationships().await?;
let records = ks.prefix_iter_raw(Vec::new()).await?.len();
eprintln!();
if established.is_empty() {
eprintln!(" No established TSP relationships.");
} else {
let now_ms = now_ms();
for (our, their) in &established {
eprintln!(" {their}");
eprintln!(" local VID : {our}");
match relationships.last_active(our, their).await? {
Some(at_ms) => eprintln!(
" last active : {} ago",
format_age(now_ms.saturating_sub(at_ms) / 1000)
),
None => eprintln!(" last active : never recorded"),
}
eprintln!();
}
}
if records > 0 {
eprintln!(
" {records} stored record(s) in total. A half-formed relationship (invite sent or\n \
received, never accepted) is stored but not listed; `vta tsp-relationships delete\n \
--all` clears it, or target it with `--peer <did> --our <vid>`."
);
eprintln!();
}
Ok(())
}
pub async fn run_reset(
config_path: Option<PathBuf>,
peer: String,
our: Option<String>,
) -> CliResult {
let (cs, ks) = open(config_path).await?;
reset_on(&ks, &peer, our).await?;
cs.persist().await?;
eprintln!(" The next send to the peer re-invites; restart the VTA to pick this up.");
Ok(())
}
async fn reset_on(ks: &KeyspaceHandle, peer: &str, our: Option<String>) -> CliResult {
let relationships = store_over(ks);
for (our, their) in pairs_for(&relationships, peer, our).await? {
relationships
.set(&our, &their, RelationshipState::None)
.await?;
relationships
.set_thread_digests(&our, &their, ThreadDigests::default())
.await?;
eprintln!(" Reset relationship with {their} (local VID {our}).");
}
Ok(())
}
pub async fn run_delete(config_path: Option<PathBuf>, target: Target, yes: bool) -> CliResult {
let (cs, ks) = open(config_path).await?;
if delete_on(&ks, target, yes).await? {
cs.persist().await?;
eprintln!(" Peers that kept their half re-form it on the next invite.");
}
Ok(())
}
async fn delete_on(
ks: &KeyspaceHandle,
target: Target,
yes: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let relationships = store_over(ks);
match target {
Target::Peer { peer, our } => {
for (our, their) in pairs_for(&relationships, &peer, our).await? {
relationships.forget(&our, &their).await?;
eprintln!(" Deleted relationship with {their} (local VID {our}).");
}
Ok(true)
}
Target::All => {
let keys: Vec<Vec<u8>> = ks
.prefix_iter_raw(Vec::new())
.await?
.into_iter()
.map(|(k, _)| k)
.collect();
let established = relationships.established_relationships().await?.len();
if keys.is_empty() {
eprintln!(" No TSP relationship records; nothing to delete.");
return Ok(false);
}
if !yes {
eprintln!(
" Would delete {} record(s) ({established} established relationship(s)). \
Re-run with --yes to delete.",
keys.len()
);
return Ok(false);
}
for key in &keys {
ks.remove(key.clone()).await?;
}
eprintln!(
" Deleted {} record(s) ({established} established relationship(s)).",
keys.len()
);
Ok(true)
}
}
}
async fn pairs_for(
relationships: &KeyspaceRelationshipStore,
peer: &str,
our: Option<String>,
) -> Result<Vec<(String, String)>, Box<dyn std::error::Error>> {
if let Some(our) = our {
return Ok(vec![(our, peer.to_string())]);
}
let pairs: Vec<_> = relationships
.established_relationships()
.await?
.into_iter()
.filter(|(_, their)| their == peer)
.collect();
if pairs.is_empty() {
return Err(format!(
"no established TSP relationship with `{peer}` (run `vta tsp-relationships list`). \
A half-formed one needs `--our <vid>`, or clear everything with \
`vta tsp-relationships delete --all`."
)
.into());
}
Ok(pairs)
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or_default()
}
fn format_age(secs: u64) -> String {
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h {}m", s / 3600, (s % 3600) / 60),
s => format!("{}d {}h", s / 86_400, (s % 86_400) / 3600),
}
}
#[cfg(test)]
mod tests {
use super::*;
const US: &str = "did:example:vta";
async fn seeded() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = crate::store::Store::open(&vti_common::config::StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let cs = CliStore::from_store(store, Some([0x5Au8; 32]));
let ks = cs
.keyspace(crate::keyspaces::RELATIONSHIPS)
.expect("keyspace");
let rel = store_over(&ks);
for peer in ["did:example:hosting", "did:example:vtc"] {
rel.set(US, peer, RelationshipState::Bidirectional)
.await
.expect("seed established");
}
rel.set(US, "did:example:half", RelationshipState::Pending)
.await
.expect("seed pending");
(ks, dir)
}
async fn state(ks: &KeyspaceHandle, peer: &str) -> RelationshipState {
store_over(ks).get(US, peer).await.expect("get")
}
#[tokio::test]
async fn list_reads_through_the_encrypted_keyspace() {
let (ks, _dir) = seeded().await;
list_on(&ks).await.expect("list");
assert_eq!(
store_over(&ks)
.established_relationships()
.await
.expect("enumerate")
.len(),
2,
"both established pairs decrypt and enumerate"
);
}
#[tokio::test]
async fn reset_returns_one_peer_to_none_and_leaves_the_rest() {
let (ks, _dir) = seeded().await;
reset_on(&ks, "did:example:hosting", None)
.await
.expect("reset");
assert_eq!(
state(&ks, "did:example:hosting").await,
RelationshipState::None
);
assert_eq!(
state(&ks, "did:example:vtc").await,
RelationshipState::Bidirectional,
"only the named peer is reset"
);
}
#[tokio::test]
async fn an_unknown_peer_is_an_error_not_a_silent_no_op() {
let (ks, _dir) = seeded().await;
let err = reset_on(&ks, "did:example:typo", None)
.await
.expect_err("a peer with no relationship must be refused");
assert!(err.to_string().contains("no established TSP relationship"));
}
#[tokio::test]
async fn a_half_formed_relationship_is_deleted_by_naming_the_local_vid() {
let (ks, _dir) = seeded().await;
delete_on(
&ks,
Target::Peer {
peer: "did:example:half".into(),
our: Some(US.into()),
},
false,
)
.await
.expect("delete");
assert_eq!(
state(&ks, "did:example:half").await,
RelationshipState::None
);
}
#[tokio::test]
async fn delete_all_only_reports_without_yes_and_clears_everything_with_it() {
let (ks, _dir) = seeded().await;
assert!(!delete_on(&ks, Target::All, false).await.expect("dry run"));
assert_eq!(
state(&ks, "did:example:hosting").await,
RelationshipState::Bidirectional,
"without --yes nothing is deleted"
);
assert!(delete_on(&ks, Target::All, true).await.expect("delete all"));
assert!(
ks.prefix_iter_raw(Vec::new())
.await
.expect("scan")
.is_empty(),
"--all removes half-formed records too"
);
}
}