use affinidi_messaging_sdk::protocols::tsp::{RelationshipState, RelationshipStore, ThreadDigests};
use crate::server::config::StoreConfig;
use crate::server::store::{KS_TSP_RELATIONSHIPS, Store};
use crate::server::tsp_relationship_store::{KeyspaceRelationshipStore, open_relationship_store};
type CliResult = Result<(), Box<dyn std::error::Error>>;
pub enum Target {
Peer { peer: String, our: Option<String> },
All,
}
pub async fn run_list(store_config: &StoreConfig) -> CliResult {
let store = Store::open(store_config).await?;
let relationships = open_relationship_store(&store)?;
let established = relationships.established_relationships().await?;
let records = store
.keyspace(KS_TSP_RELATIONSHIPS)?
.approximate_len()
.await?;
eprintln!();
if established.is_empty() {
eprintln!(" No established TSP relationships.");
} else {
let now_ms = crate::server::auth::session::now_epoch() * 1000;
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; `tsp-relationship-delete --all`\n \
clears it, or target it with `--peer <did> --our <vid>`."
);
eprintln!();
}
Ok(())
}
pub async fn run_reset(store_config: &StoreConfig, target: Target) -> CliResult {
let store = Store::open(store_config).await?;
let relationships = open_relationship_store(&store)?;
let Target::Peer { peer, our } = target else {
return Err(
"reset takes a --peer; use `tsp-relationship-delete --all` to clear everything".into(),
);
};
let pairs = pairs_for(&relationships, &peer, our).await?;
for (our, their) in &pairs {
relationships
.set(our, their, RelationshipState::None)
.await?;
relationships
.set_thread_digests(our, their, ThreadDigests::default())
.await?;
eprintln!(" Reset relationship with {their} (local VID {our}).");
}
eprintln!(" The next send to the peer re-invites; restart the service to pick this up.");
Ok(())
}
pub async fn run_delete(store_config: &StoreConfig, target: Target, yes: bool) -> CliResult {
let store = Store::open(store_config).await?;
let relationships = open_relationship_store(&store)?;
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}).");
}
}
Target::All => {
let ks = store.keyspace(KS_TSP_RELATIONSHIPS)?;
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(());
}
if !yes {
eprintln!(
" Would delete {} record(s) ({established} established relationship(s)). \
Re-run with --yes to delete.",
keys.len()
);
return Ok(());
}
for key in &keys {
ks.remove(key.clone()).await?;
}
eprintln!(
" Deleted {} record(s) ({established} established relationship(s)).",
keys.len()
);
}
}
eprintln!(" Peers that kept their half re-form it on the next invite.");
Ok(())
}
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 `tsp-relationship-list`). \
A half-formed one needs `--our <vid>`, or clear everything with \
`tsp-relationship-delete --all`."
)
.into());
}
Ok(pairs)
}
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(all(test, feature = "store-fjall"))]
mod tests {
use super::*;
fn store_config(dir: &tempfile::TempDir) -> StoreConfig {
StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
}
}
async fn seed(config: &StoreConfig) {
let store = Store::open(config).await.expect("open store");
let rel = open_relationship_store(&store).expect("relationship store");
for peer in ["did:example:vta", "did:example:edge"] {
rel.set("did:example:us", peer, RelationshipState::Bidirectional)
.await
.expect("seed established");
}
rel.set(
"did:example:us",
"did:example:half",
RelationshipState::Pending,
)
.await
.expect("seed pending");
store.persist().await.expect("persist");
}
async fn state(config: &StoreConfig, peer: &str) -> RelationshipState {
let store = Store::open(config).await.expect("open store");
open_relationship_store(&store)
.expect("relationship store")
.get("did:example:us", peer)
.await
.expect("get")
}
#[tokio::test]
async fn reset_returns_one_peer_to_none_and_leaves_the_rest() {
let dir = tempfile::tempdir().expect("temp dir");
let config = store_config(&dir);
seed(&config).await;
run_reset(
&config,
Target::Peer {
peer: "did:example:vta".into(),
our: None,
},
)
.await
.expect("reset");
assert_eq!(
state(&config, "did:example:vta").await,
RelationshipState::None
);
assert_eq!(
state(&config, "did:example:edge").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 dir = tempfile::tempdir().expect("temp dir");
let config = store_config(&dir);
seed(&config).await;
let err = run_reset(
&config,
Target::Peer {
peer: "did:example:typo".into(),
our: 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 dir = tempfile::tempdir().expect("temp dir");
let config = store_config(&dir);
seed(&config).await;
run_delete(
&config,
Target::Peer {
peer: "did:example:half".into(),
our: Some("did:example:us".into()),
},
false,
)
.await
.expect("delete");
assert_eq!(
state(&config, "did:example:half").await,
RelationshipState::None
);
}
#[tokio::test]
async fn delete_all_only_reports_without_yes_and_clears_everything_with_it() {
let dir = tempfile::tempdir().expect("temp dir");
let config = store_config(&dir);
seed(&config).await;
run_delete(&config, Target::All, false)
.await
.expect("dry run");
assert_eq!(
state(&config, "did:example:vta").await,
RelationshipState::Bidirectional,
"without --yes nothing is deleted"
);
run_delete(&config, Target::All, true)
.await
.expect("delete all");
let store = Store::open(&config).await.expect("open store");
assert_eq!(
store
.keyspace(KS_TSP_RELATIONSHIPS)
.expect("keyspace")
.approximate_len()
.await
.expect("len"),
0,
"--all removes half-formed records too"
);
}
}