use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::{
CredentialKind,
config::{
KeyTypes,
account::{Account, CommunityRecord, CommunityStatus, PersonaId, PersonaRecord, VtcDid},
secured_config::{KeyInfoConfig, KeySourceMaterial},
},
rebuild::RebuildPlan,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyCandidate {
pub key_id: String,
pub label: Option<String>,
pub key_type: KeyPurposeHint,
pub created_at: DateTime<Utc>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum KeyPurposeHint {
Signing,
Encryption,
}
impl From<KeyPurposeHint> for KeyTypes {
fn from(hint: KeyPurposeHint) -> Self {
match hint {
KeyPurposeHint::Signing => KeyTypes::PersonaSigning,
KeyPurposeHint::Encryption => KeyTypes::PersonaEncryption,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PersonaSkipReason {
NoKeysMapped,
NoSigningKey,
}
impl PersonaSkipReason {
#[must_use]
pub fn summary(&self) -> String {
match self {
PersonaSkipReason::NoKeysMapped => {
"none of the VTA's keys could be matched to it".to_string()
}
PersonaSkipReason::NoSigningKey => "it has no signing key".to_string(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkippedPersona {
pub did: String,
pub reason: PersonaSkipReason,
}
#[derive(Clone, Debug, Default)]
pub struct RebuiltAccount {
pub account: Account,
pub key_info: HashMap<String, KeyInfoConfig>,
pub skipped: Vec<SkippedPersona>,
}
impl RebuiltAccount {
#[must_use]
pub fn summary(&self) -> String {
fn plural(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
let mut s = format!(
"{}, {}",
plural(self.account.personas.len(), "persona", "personas"),
plural(
self.account.memberships().count(),
"membership",
"memberships"
)
);
if !self.skipped.is_empty() {
s.push_str(&format!(
" ({} skipped)",
plural(self.skipped.len(), "persona", "personas")
));
}
s
}
}
fn keys_for_did<'a>(did: &str, keys: &'a [KeyCandidate]) -> Vec<(String, &'a KeyCandidate)> {
keys.iter()
.filter_map(|k| {
vta_sdk::did_secrets::select_secret_kid(did, &k.key_id, k.label.as_deref())
.map(|vm_id| (vm_id, k))
})
.collect()
}
#[must_use]
pub fn apply(plan: &RebuildPlan, keys: &[KeyCandidate], now: DateTime<Utc>) -> RebuiltAccount {
let mut account = Account {
top_context_id: plan.top_context_id.clone(),
..Account::default()
};
let mut key_info: HashMap<String, KeyInfoConfig> = HashMap::new();
let mut skipped = Vec::new();
let mut persona_ids: HashMap<String, PersonaId> = HashMap::new();
for persona in &plan.personas {
let mapped = keys_for_did(&persona.did, keys);
if mapped.is_empty() {
warn!(did = %persona.did, "rebuild: no VTA key maps to this persona");
skipped.push(SkippedPersona {
did: persona.did.clone(),
reason: PersonaSkipReason::NoKeysMapped,
});
continue;
}
if !mapped
.iter()
.any(|(_, k)| k.key_type == KeyPurposeHint::Signing)
{
warn!(did = %persona.did, "rebuild: persona has no signing key");
skipped.push(SkippedPersona {
did: persona.did.clone(),
reason: PersonaSkipReason::NoSigningKey,
});
continue;
}
let persona_id = PersonaId::new();
persona_ids.insert(persona.did.clone(), persona_id);
for (vm_id, key) in &mapped {
key_info.insert(
vm_id.clone(),
KeyInfoConfig {
path: KeySourceMaterial::VtaManaged {
key_id: key.key_id.clone(),
},
purpose: KeyTypes::from(key.key_type),
create_time: key.created_at,
},
);
}
account.personas.insert(
persona_id,
PersonaRecord {
persona_id,
did: persona.did.clone(),
did_document: None,
key_refs: Vec::new(),
mediator_did: persona.mediator_did.clone(),
origin_context_id: persona.context_id.clone(),
created_at: now,
label: None,
extra: serde_json::Map::new(),
},
);
}
for membership in &plan.memberships {
let Some(&persona_id) = persona_ids.get(&membership.persona_did) else {
warn!(
vtc = %membership.vtc_did,
"rebuild: membership's persona was not rebuilt — skipping"
);
continue;
};
let vtc_did = VtcDid::from(membership.vtc_did.clone());
let mut record = CommunityRecord::new_pending(
vtc_did.clone(),
None,
format!(
"{}/{}",
plan.top_context_id,
short_tail(&membership.vtc_did)
),
persona_id,
uuid::Uuid::new_v4(),
now,
);
record.status = CommunityStatus::Active;
record.member_since = Some(now);
record
.credentials
.insert(CredentialKind::Membership, membership.credential.clone());
account.communities.entry(vtc_did).or_default().push(record);
}
RebuiltAccount {
account,
key_info,
skipped,
}
}
fn short_tail(did: &str) -> String {
did.rsplit(':').next().unwrap_or(did).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rebuild::{RebuiltMembership, RebuiltPersona};
const ALICE: &str = "did:webvh:QmA:example.com:alice";
const BOB: &str = "did:webvh:QmB:example.com:bob";
const VTC: &str = "did:webvh:QmV:vtc.example.com:acme";
fn key(id: &str, label: Option<&str>, purpose: KeyPurposeHint) -> KeyCandidate {
KeyCandidate {
key_id: id.to_string(),
label: label.map(str::to_string),
key_type: purpose,
created_at: Utc::now(),
}
}
fn keys_for(did: &str) -> Vec<KeyCandidate> {
vec![
key(&format!("{did}#key-0"), None, KeyPurposeHint::Signing),
key(&format!("{did}#key-1"), None, KeyPurposeHint::Encryption),
]
}
fn persona(did: &str) -> RebuiltPersona {
RebuiltPersona {
did: did.to_string(),
context_id: "openvtc".to_string(),
mediator_did: Some("did:webvh:QmM:mediator.example.com".to_string()),
}
}
fn plan_with(
personas: Vec<RebuiltPersona>,
memberships: Vec<RebuiltMembership>,
) -> RebuildPlan {
RebuildPlan {
top_context_id: "openvtc".to_string(),
personas,
memberships,
rejected: Vec::new(),
other_credential_count: 0,
}
}
#[test]
fn a_persona_with_mapped_keys_is_rebuilt() {
let out = apply(
&plan_with(vec![persona(ALICE)], vec![]),
&keys_for(ALICE),
Utc::now(),
);
assert_eq!(out.account.personas.len(), 1);
assert!(out.skipped.is_empty());
let rebuilt = out.account.personas.values().next().expect("one persona");
assert_eq!(rebuilt.did, ALICE);
assert_eq!(rebuilt.origin_context_id, "openvtc");
assert!(rebuilt.mediator_did.is_some());
assert_eq!(out.key_info.len(), 2);
assert!(out.key_info.contains_key(&format!("{ALICE}#key-0")));
assert!(out.key_info.contains_key(&format!("{ALICE}#key-1")));
}
#[test]
fn key_info_points_at_the_vta_key_id() {
let keys = vec![key(
"vta-key-abc",
Some(&format!("{ALICE}#key-0")),
KeyPurposeHint::Signing,
)];
let out = apply(&plan_with(vec![persona(ALICE)], vec![]), &keys, Utc::now());
let entry = out
.key_info
.get(&format!("{ALICE}#key-0"))
.expect("mapped via label");
match &entry.path {
KeySourceMaterial::VtaManaged { key_id } => assert_eq!(key_id, "vta-key-abc"),
other => panic!("expected a VTA-managed key, got {other:?}"),
}
}
#[test]
fn a_persona_with_no_mappable_keys_is_skipped_and_reported() {
let keys = vec![key(
"opaque-1",
Some("some free text"),
KeyPurposeHint::Signing,
)];
let out = apply(&plan_with(vec![persona(ALICE)], vec![]), &keys, Utc::now());
assert!(out.account.personas.is_empty());
assert_eq!(out.skipped.len(), 1);
assert_eq!(out.skipped[0].did, ALICE);
assert_eq!(out.skipped[0].reason, PersonaSkipReason::NoKeysMapped);
}
#[test]
fn a_persona_with_only_an_encryption_key_is_skipped() {
let keys = vec![key(
&format!("{ALICE}#key-1"),
None,
KeyPurposeHint::Encryption,
)];
let out = apply(&plan_with(vec![persona(ALICE)], vec![]), &keys, Utc::now());
assert_eq!(out.skipped[0].reason, PersonaSkipReason::NoSigningKey);
}
#[test]
fn keys_are_not_mixed_between_personas() {
let mut keys = keys_for(ALICE);
keys.extend(keys_for(BOB));
let out = apply(
&plan_with(vec![persona(ALICE), persona(BOB)], vec![]),
&keys,
Utc::now(),
);
assert_eq!(out.account.personas.len(), 2);
assert_eq!(out.key_info.len(), 4);
for (vm_id, entry) in &out.key_info {
let did = vm_id.split('#').next().expect("vm id");
match &entry.path {
KeySourceMaterial::VtaManaged { key_id } => {
assert!(key_id.starts_with(did), "{key_id} attributed to {did}");
}
other => panic!("expected VtaManaged, got {other:?}"),
}
}
}
#[test]
fn a_verified_membership_is_rebuilt_active_and_keeps_its_credential() {
let vmc = serde_json::json!({ "id": "vmc-1", "issuer": VTC });
let out = apply(
&plan_with(
vec![persona(ALICE)],
vec![RebuiltMembership {
vtc_did: VTC.to_string(),
persona_did: ALICE.to_string(),
credential: vmc.clone(),
}],
),
&keys_for(ALICE),
Utc::now(),
);
let membership = out.account.memberships().next().expect("one membership");
assert!(membership.status.is_active());
assert_eq!(
membership.credentials.get(&CredentialKind::Membership),
Some(&vmc),
"the rebuilt record must carry its own proof"
);
let persona_id = out
.account
.personas
.values()
.find(|p| p.did == ALICE)
.expect("alice")
.persona_id;
assert_eq!(membership.persona_ref, persona_id);
}
#[test]
fn a_membership_for_a_skipped_persona_is_dropped() {
let out = apply(
&plan_with(
vec![persona(ALICE)],
vec![RebuiltMembership {
vtc_did: VTC.to_string(),
persona_did: ALICE.to_string(),
credential: serde_json::json!({}),
}],
),
&[key("opaque", None, KeyPurposeHint::Signing)],
Utc::now(),
);
assert!(out.account.personas.is_empty());
assert_eq!(out.account.memberships().count(), 0);
assert_eq!(out.skipped.len(), 1);
}
#[test]
fn two_personas_may_hold_memberships_in_one_community() {
let mut keys = keys_for(ALICE);
keys.extend(keys_for(BOB));
let out = apply(
&plan_with(
vec![persona(ALICE), persona(BOB)],
vec![
RebuiltMembership {
vtc_did: VTC.to_string(),
persona_did: ALICE.to_string(),
credential: serde_json::json!({ "id": "a" }),
},
RebuiltMembership {
vtc_did: VTC.to_string(),
persona_did: BOB.to_string(),
credential: serde_json::json!({ "id": "b" }),
},
],
),
&keys,
Utc::now(),
);
assert_eq!(out.account.memberships().count(), 2);
assert_eq!(
out.account.communities.len(),
1,
"one community, two members"
);
}
#[test]
fn the_summary_reports_skips_as_well_as_successes() {
let mut keys = keys_for(ALICE);
keys.push(key("opaque", None, KeyPurposeHint::Signing));
let out = apply(
&plan_with(
vec![persona(ALICE), persona(BOB)],
vec![RebuiltMembership {
vtc_did: VTC.to_string(),
persona_did: ALICE.to_string(),
credential: serde_json::json!({}),
}],
),
&keys,
Utc::now(),
);
assert_eq!(out.summary(), "1 persona, 1 membership (1 persona skipped)");
}
#[test]
fn an_empty_plan_produces_an_empty_account() {
let out = apply(&plan_with(vec![], vec![]), &[], Utc::now());
assert!(out.account.personas.is_empty());
assert!(out.key_info.is_empty());
assert!(out.skipped.is_empty());
assert_eq!(out.account.top_context_id, "openvtc");
}
}