use serde::ser::SerializeMap;
use serde::{Serialize, Serializer};
use crate::error::KeriTranslationError;
use crate::events::KERI_VERSION_PREFIX;
use crate::said::{Protocol, compute_said_with_protocol};
use crate::state::KeyState;
use crate::types::{Prefix, Said};
use crate::validate::{TrustedKel, ValidationError, parse_kel_json};
const KERI_VERSION_PLACEHOLDER: &str = "KERI10JSON000000_";
fn recompute_version_string<T: Serialize>(event: &T) -> Result<String, OobiError> {
let bytes = serde_json::to_vec(event).map_err(KeriTranslationError::SerializationFailed)?;
Ok(format!("{KERI_VERSION_PREFIX}{:06x}_", bytes.len()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Role {
Controller,
Witness,
Watcher,
Registrar,
Judge,
Juror,
Peer,
Mailbox,
Agent,
Gateway,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::Controller => "controller",
Role::Witness => "witness",
Role::Watcher => "watcher",
Role::Registrar => "registrar",
Role::Judge => "judge",
Role::Juror => "juror",
Role::Peer => "peer",
Role::Mailbox => "mailbox",
Role::Agent => "agent",
Role::Gateway => "gateway",
}
}
pub fn parse(s: &str) -> Result<Self, OobiError> {
Ok(match s {
"controller" => Role::Controller,
"witness" => Role::Witness,
"watcher" => Role::Watcher,
"registrar" => Role::Registrar,
"judge" => Role::Judge,
"juror" => Role::Juror,
"peer" => Role::Peer,
"mailbox" => Role::Mailbox,
"agent" => Role::Agent,
"gateway" => Role::Gateway,
other => return Err(OobiError::Role(other.to_string())),
})
}
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Oobi {
pub scheme: String,
pub authority: String,
pub cid: Prefix,
pub role: Role,
pub eid: Option<Prefix>,
}
impl Oobi {
pub fn parse(url: &str) -> Result<Self, OobiError> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| OobiError::Url(format!("missing scheme separator in {url:?}")))?;
let scheme = scheme.to_ascii_lowercase();
if !matches!(scheme.as_str(), "http" | "https" | "tcp") {
return Err(OobiError::Scheme(scheme));
}
let (authority, path) = match rest.split_once('/') {
Some((authority, path)) => (authority, path),
None => return Err(OobiError::Url(format!("missing /oobi path in {url:?}"))),
};
if authority.is_empty() {
return Err(OobiError::Url(format!("empty authority in {url:?}")));
}
let path = path.split(['?', '#']).next().unwrap_or(path);
let mut segs = path.split('/').filter(|s| !s.is_empty());
match segs.next() {
Some("oobi") => {}
_ => return Err(OobiError::Url(format!("path is not /oobi/... in {url:?}"))),
}
let cid_str = segs
.next()
.ok_or_else(|| OobiError::Url(format!("missing cid segment in {url:?}")))?;
let cid = Prefix::new(cid_str.to_string()).map_err(|e| OobiError::Prefix {
segment: "cid",
source: e,
})?;
let role_str = segs
.next()
.ok_or_else(|| OobiError::Url(format!("missing role segment in {url:?}")))?;
let role = Role::parse(role_str)?;
let eid = match segs.next() {
Some(eid_str) => {
Some(
Prefix::new(eid_str.to_string()).map_err(|e| OobiError::Prefix {
segment: "eid",
source: e,
})?,
)
}
None => None,
};
if segs.next().is_some() {
return Err(OobiError::Url(format!("trailing path segment in {url:?}")));
}
Ok(Oobi {
scheme,
authority: authority.to_string(),
cid,
role,
eid,
})
}
pub fn url(&self) -> String {
let base = format!(
"{}://{}/oobi/{}/{}",
self.scheme, self.authority, self.cid, self.role
);
match &self.eid {
Some(eid) => format!("{base}/{eid}"),
None => base,
}
}
}
impl std::fmt::Display for Oobi {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.url())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocSchemeReply {
pub v: String,
pub d: Said,
pub dt: String,
pub eid: Prefix,
pub scheme: String,
pub url: String,
}
impl LocSchemeReply {
pub fn new(
eid: Prefix,
scheme: impl Into<String>,
url: impl Into<String>,
dt: impl Into<String>,
) -> Result<Self, OobiError> {
let mut reply = Self {
v: KERI_VERSION_PLACEHOLDER.to_string(),
d: Said::default(),
dt: dt.into(),
eid,
scheme: scheme.into(),
url: url.into(),
};
reply.saidify()?;
Ok(reply)
}
fn saidify(&mut self) -> Result<(), OobiError> {
let body =
serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?;
self.d = compute_said_with_protocol(&body, Protocol::Keri)?;
self.v = recompute_version_string(&*self)?;
Ok(())
}
}
impl Serialize for LocSchemeReply {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(6))?;
map.serialize_entry("v", &self.v)?;
map.serialize_entry("t", "rpy")?;
map.serialize_entry("d", &self.d)?;
map.serialize_entry("dt", &self.dt)?;
map.serialize_entry("r", "/loc/scheme")?;
let mut a = serde_json::Map::new();
a.insert(
"eid".into(),
serde_json::Value::String(self.eid.to_string()),
);
a.insert(
"scheme".into(),
serde_json::Value::String(self.scheme.clone()),
);
a.insert("url".into(), serde_json::Value::String(self.url.clone()));
map.serialize_entry("a", &serde_json::Value::Object(a))?;
map.end()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndRoleReply {
pub v: String,
pub d: Said,
pub dt: String,
pub cid: Prefix,
pub role: Role,
pub eid: Prefix,
}
impl EndRoleReply {
pub fn new(
cid: Prefix,
role: Role,
eid: Prefix,
dt: impl Into<String>,
) -> Result<Self, OobiError> {
let mut reply = Self {
v: KERI_VERSION_PLACEHOLDER.to_string(),
d: Said::default(),
dt: dt.into(),
cid,
role,
eid,
};
reply.saidify()?;
Ok(reply)
}
fn saidify(&mut self) -> Result<(), OobiError> {
let body =
serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?;
self.d = compute_said_with_protocol(&body, Protocol::Keri)?;
self.v = recompute_version_string(&*self)?;
Ok(())
}
}
impl Serialize for EndRoleReply {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(6))?;
map.serialize_entry("v", &self.v)?;
map.serialize_entry("t", "rpy")?;
map.serialize_entry("d", &self.d)?;
map.serialize_entry("dt", &self.dt)?;
map.serialize_entry("r", "/end/role/add")?;
let mut a = serde_json::Map::new();
a.insert(
"cid".into(),
serde_json::Value::String(self.cid.to_string()),
);
a.insert(
"role".into(),
serde_json::Value::String(self.role.to_string()),
);
a.insert(
"eid".into(),
serde_json::Value::String(self.eid.to_string()),
);
map.serialize_entry("a", &serde_json::Value::Object(a))?;
map.end()
}
}
#[derive(Debug, Clone)]
pub struct OobiEndpoint {
pub oobi: Oobi,
pub loc_scheme: LocSchemeReply,
pub end_role: EndRoleReply,
}
impl OobiEndpoint {
pub fn for_controller(
state: &KeyState,
scheme: impl Into<String>,
authority: impl Into<String>,
url: impl Into<String>,
dt: impl Into<String>,
) -> Result<Self, OobiError> {
let scheme = scheme.into();
let authority = authority.into();
let dt = dt.into();
let cid = state.prefix.clone();
let oobi = Oobi {
scheme: scheme.clone(),
authority,
cid: cid.clone(),
role: Role::Controller,
eid: None,
};
let loc_scheme = LocSchemeReply::new(cid.clone(), scheme, url, dt.clone())?;
let end_role = EndRoleReply::new(cid.clone(), Role::Controller, cid, dt)?;
Ok(OobiEndpoint {
oobi,
loc_scheme,
end_role,
})
}
pub fn reply_stream(&self) -> Result<String, OobiError> {
let loc = serde_json::to_string(&self.loc_scheme)
.map_err(KeriTranslationError::SerializationFailed)?;
let end = serde_json::to_string(&self.end_role)
.map_err(KeriTranslationError::SerializationFailed)?;
Ok(format!("{loc}\n{end}"))
}
}
#[derive(Debug, Clone)]
pub struct OobiResolution {
pub cid: Prefix,
pub state: KeyState,
pub event_count: usize,
}
pub fn ingest_oobi_stream(
expected_cid: &Prefix,
kel_json: &str,
) -> Result<OobiResolution, OobiError> {
let events = parse_kel_json(kel_json)?;
if events.is_empty() {
return Err(OobiError::EmptyKel);
}
let event_count = events.len();
let state = TrustedKel::from_trusted_source(&events).replay()?;
if state.prefix != *expected_cid {
return Err(OobiError::CidMismatch {
expected: expected_cid.to_string(),
actual: state.prefix.to_string(),
});
}
Ok(OobiResolution {
cid: state.prefix.clone(),
state,
event_count,
})
}
#[derive(Debug, thiserror::Error)]
pub enum OobiError {
#[error("invalid OOBI URL: {0}")]
Url(String),
#[error("unsupported OOBI scheme: {0:?}")]
Scheme(String),
#[error("invalid {segment} prefix in OOBI URL: {source}")]
Prefix {
segment: &'static str,
source: crate::types::KeriTypeError,
},
#[error("unknown OOBI role: {0:?}")]
Role(String),
#[error("OOBI stream carried no KEL events")]
EmptyKel,
#[error("OOBI introduced {expected} but delivered a KEL for {actual}")]
CidMismatch {
expected: String,
actual: String,
},
#[error("KEL replay failed: {0}")]
Replay(#[from] ValidationError),
#[error("KERI record build failed: {0}")]
Record(#[from] KeriTranslationError),
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const CID: &str = "EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM";
const EID: &str = "BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6";
#[test]
fn parses_controller_oobi() {
let url = format!("http://127.0.0.1:5642/oobi/{CID}/controller");
let oobi = Oobi::parse(&url).unwrap();
assert_eq!(oobi.scheme, "http");
assert_eq!(oobi.authority, "127.0.0.1:5642");
assert_eq!(oobi.cid.as_str(), CID);
assert_eq!(oobi.role, Role::Controller);
assert_eq!(oobi.eid, None);
}
#[test]
fn parses_witness_oobi_with_eid() {
let url = format!("https://witness.example:5631/oobi/{CID}/witness/{EID}");
let oobi = Oobi::parse(&url).unwrap();
assert_eq!(oobi.scheme, "https");
assert_eq!(oobi.role, Role::Witness);
assert_eq!(oobi.eid.as_ref().unwrap().as_str(), EID);
}
#[test]
fn url_round_trips() {
for url in [
format!("http://127.0.0.1:5642/oobi/{CID}/controller"),
format!("https://w.example:5631/oobi/{CID}/witness/{EID}"),
format!("tcp://10.0.0.1:5621/oobi/{CID}/mailbox"),
] {
let oobi = Oobi::parse(&url).unwrap();
assert_eq!(oobi.url(), url);
assert_eq!(Oobi::parse(&oobi.url()).unwrap(), oobi);
}
}
#[test]
fn drops_query_alias_hint() {
let url = format!("http://127.0.0.1:5642/oobi/{CID}/controller?name=alice");
let oobi = Oobi::parse(&url).unwrap();
assert_eq!(oobi.cid.as_str(), CID);
assert_eq!(oobi.role, Role::Controller);
}
#[test]
fn rejects_bad_scheme() {
let err = Oobi::parse(&format!("ftp://h/oobi/{CID}/controller")).unwrap_err();
assert!(matches!(err, OobiError::Scheme(_)));
}
#[test]
fn rejects_unknown_role() {
let err = Oobi::parse(&format!("http://h:1/oobi/{CID}/overlord")).unwrap_err();
assert!(matches!(err, OobiError::Role(_)));
}
#[test]
fn rejects_missing_path() {
assert!(matches!(
Oobi::parse(&format!("http://h:1/oobi/{CID}")).unwrap_err(),
OobiError::Url(_)
));
assert!(matches!(
Oobi::parse("http://h:1").unwrap_err(),
OobiError::Url(_)
));
}
#[test]
fn rejects_invalid_cid_prefix() {
let err = Oobi::parse("http://h:1/oobi/not-a-prefix/controller").unwrap_err();
assert!(matches!(err, OobiError::Prefix { segment: "cid", .. }));
}
#[test]
fn role_parse_total() {
for r in [
Role::Controller,
Role::Witness,
Role::Watcher,
Role::Registrar,
Role::Judge,
Role::Juror,
Role::Peer,
Role::Mailbox,
Role::Agent,
Role::Gateway,
] {
assert_eq!(Role::parse(r.as_str()).unwrap(), r);
}
assert!(Role::parse("nope").is_err());
}
#[test]
fn loc_scheme_reply_byte_exact_keripy() {
let reply = LocSchemeReply::new(
Prefix::new(EID.to_string()).unwrap(),
"http",
"http://127.0.0.1:5642/",
"2024-01-01T00:00:00.000000+00:00",
)
.unwrap();
let json = serde_json::to_string(&reply).unwrap();
let expected = r#"{"v":"KERI10JSON0000fa_","t":"rpy","d":"EHrMc5EKCqJHrpCAAlgG6UPaupi-tmlDw8SvspQobfC1","dt":"2024-01-01T00:00:00.000000+00:00","r":"/loc/scheme","a":{"eid":"BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6","scheme":"http","url":"http://127.0.0.1:5642/"}}"#;
assert_eq!(json, expected);
}
#[test]
fn end_role_add_reply_byte_exact_keripy() {
let reply = EndRoleReply::new(
Prefix::new(CID.to_string()).unwrap(),
Role::Controller,
Prefix::new(EID.to_string()).unwrap(),
"2024-01-01T00:00:00.000000+00:00",
)
.unwrap();
let json = serde_json::to_string(&reply).unwrap();
let expected = r#"{"v":"KERI10JSON000116_","t":"rpy","d":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","dt":"2024-01-01T00:00:00.000000+00:00","r":"/end/role/add","a":{"cid":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","role":"controller","eid":"BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6"}}"#;
assert_eq!(json, expected);
}
}