use std::time::Duration;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use serde_json::Value;
use trust_tasks_rs::TrustTask;
use vta_sdk::protocols::join_requests::manifest;
use super::protocol::{CriterionMeta, JoinProtocol, is_version_refusal, read_manifest};
use vta_sdk::trust_task_proof::{TrustTaskVmResolver, verify_trust_task_proof_with};
use crate::health::ProbePolicy;
const VTC_REST_SERVICE_TYPE: &str = "VTCRest";
const TRUST_TASKS_PATH: &str = "v1/trust-tasks";
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
pub enum DiscoverError {
NoEndpoint,
Blocked(String),
Unreachable(String),
Refused {
status: u16,
code: Option<String>,
detail: String,
},
Unreadable(String),
Unproven(String),
WrongSigner { proven: String },
}
impl std::fmt::Display for DiscoverError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoEndpoint => {
write!(f, "it publishes no REST endpoint to ask over")
}
Self::Blocked(reason) => {
write!(f, "the endpoint it publishes cannot be used ({reason})")
}
Self::Unreachable(error) => {
write!(f, "its endpoint could not be reached ({error})")
}
Self::Refused { status, detail, .. } => {
write!(f, "its endpoint refused the question ({status}: {detail})")
}
Self::Unreadable(detail) => write!(
f,
"its answer is in a form this client cannot read ({detail})"
),
Self::Unproven(detail) => {
write!(f, "its answer could not be shown to be genuine ({detail})")
}
Self::WrongSigner { proven } => write!(
f,
"its answer was signed by {proven}, which is not the community asked"
),
}
}
}
impl std::error::Error for DiscoverError {}
#[must_use]
pub fn rest_endpoint(doc: &Value) -> Option<String> {
let services = doc.get("service")?.as_array()?;
services.iter().find_map(|svc| {
let matches = match svc.get("type") {
Some(Value::String(t)) => t == VTC_REST_SERVICE_TYPE,
Some(Value::Array(types)) => types
.iter()
.any(|t| t.as_str() == Some(VTC_REST_SERVICE_TYPE)),
_ => false,
};
if !matches {
return None;
}
match svc.get("serviceEndpoint") {
Some(Value::String(uri)) => Some(uri.clone()),
Some(Value::Object(map)) => map.get("uri").and_then(Value::as_str).map(str::to_string),
_ => None,
}
})
}
fn trust_tasks_url(base: &str, policy: ProbePolicy) -> Result<reqwest::Url, DiscoverError> {
crate::health::vet_probe_url(&trust_tasks_url_string(base), policy)
.map_err(DiscoverError::Blocked)
}
fn trust_tasks_url_string(base: &str) -> String {
let base = base.trim_end_matches('/');
let (mount, rest) = TRUST_TASKS_PATH
.split_once('/')
.expect("TRUST_TASKS_PATH is <mount>/<path>");
if base.rsplit('/').next() == Some(mount) {
format!("{base}/{rest}")
} else {
format!("{base}/{TRUST_TASKS_PATH}")
}
}
fn anonymous_request(community_did: &str, protocol: JoinProtocol) -> Value {
serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": protocol.manifest_type(),
"recipient": community_did,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": {},
})
}
pub struct FetchedManifest {
pub manifest: manifest::v0_2::Response,
pub raw: Value,
pub protocol: JoinProtocol,
pub meta: Vec<CriterionMeta>,
}
pub async fn fetch_manifest(
doc: &Value,
community_did: &str,
resolver: &DIDCacheClient,
policy: ProbePolicy,
) -> Result<FetchedManifest, DiscoverError> {
let mut protocol = JoinProtocol::default();
loop {
match fetch_manifest_in(doc, community_did, resolver, policy, protocol).await {
Err(DiscoverError::Refused {
code: Some(code), ..
}) if is_version_refusal(&code) && protocol.fallback().is_some() => {
protocol = protocol.fallback().expect("checked above");
}
other => return other,
}
}
}
async fn fetch_manifest_in(
doc: &Value,
community_did: &str,
resolver: &DIDCacheClient,
policy: ProbePolicy,
protocol: JoinProtocol,
) -> Result<FetchedManifest, DiscoverError> {
let endpoint = rest_endpoint(doc).ok_or(DiscoverError::NoEndpoint)?;
let url = trust_tasks_url(&endpoint, policy)?;
let mut builder = reqwest::Client::builder()
.timeout(FETCH_TIMEOUT)
.connect_timeout(FETCH_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.no_proxy();
if policy == ProbePolicy::PublicOnly {
builder = builder.dns_resolver(affinidi_did_web::guarded_dns_resolver());
}
let client = builder
.build()
.map_err(|e| DiscoverError::Blocked(e.to_string()))?;
let response = client
.post(url)
.json(&anonymous_request(community_did, protocol))
.send()
.await
.map_err(|e| DiscoverError::Unreachable(e.to_string()))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| DiscoverError::Unreachable(e.to_string()))?;
if !status.is_success() {
let (code, detail) = refusal_detail(&body);
return Err(DiscoverError::Refused {
status: status.as_u16(),
code,
detail,
});
}
let reply: TrustTask<Value> =
serde_json::from_str(&body).map_err(|e| DiscoverError::Unreadable(e.to_string()))?;
if crate::messaging::is_trust_task_error_type(&reply.type_uri.to_string()) {
let (code, detail) = refusal_detail(&body);
return Err(DiscoverError::Refused {
status: status.as_u16(),
code,
detail,
});
}
let proven = verify_trust_task_proof_with(&reply, &TrustTaskVmResolver::new(resolver.clone()))
.await
.map_err(|e| DiscoverError::Unproven(e.to_string()))?;
if proven != community_did {
return Err(DiscoverError::WrongSigner { proven });
}
let protocol =
JoinProtocol::from_manifest_response(&reply.type_uri.to_string()).unwrap_or(protocol);
let raw = reply.payload;
let (manifest, meta) =
read_manifest(protocol, &raw).map_err(|e| DiscoverError::Unreadable(e.to_string()))?;
Ok(FetchedManifest {
manifest,
raw,
protocol,
meta,
})
}
fn refusal_detail(body: &str) -> (Option<String>, String) {
let parsed = serde_json::from_str::<Value>(body).ok();
let field = |name: &str| {
parsed.as_ref().and_then(|v| {
v.get("payload")
.and_then(|p| p.get(name))
.or_else(|| v.get(name))
.and_then(Value::as_str)
.map(str::to_string)
})
};
let code = field("code");
let detail = field("message").unwrap_or_else(|| body.to_string());
(
code,
crate::display::truncate_chars(&detail, 200).to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn doc_with(service: Value) -> Value {
json!({ "id": "did:webvh:x", "service": service })
}
#[test]
fn vti_55_the_api_mount_is_added_only_when_missing() {
for base in [
"https://vtc.example/v1",
"https://vtc.example/v1/",
"https://vtc.example",
"https://vtc.example/",
] {
assert_eq!(
trust_tasks_url_string(base),
"https://vtc.example/v1/trust-tasks",
"{base}"
);
}
assert_eq!(
trust_tasks_url_string("https://host.example/community/v1"),
"https://host.example/community/v1/trust-tasks"
);
assert_eq!(
trust_tasks_url_string("https://host.example/apiv1"),
"https://host.example/apiv1/v1/trust-tasks"
);
}
#[test]
fn the_rest_endpoint_is_found_under_either_type_form() {
let string_form = doc_with(json!([
{ "id": "#didcomm", "type": "DIDCommMessaging", "serviceEndpoint": "did:webvh:m" },
{ "id": "#vtc-rest", "type": "VTCRest", "serviceEndpoint": "https://vtc.example" },
]));
assert_eq!(
rest_endpoint(&string_form).as_deref(),
Some("https://vtc.example")
);
let array_form = doc_with(json!([
{ "id": "#vtc-rest", "type": ["VTCRest"], "serviceEndpoint": "https://vtc.example" },
]));
assert_eq!(
rest_endpoint(&array_form).as_deref(),
Some("https://vtc.example")
);
let object_form = doc_with(json!([
{ "id": "#vtc-rest", "type": "VTCRest",
"serviceEndpoint": { "uri": "https://vtc.example" } },
]));
assert_eq!(
rest_endpoint(&object_form).as_deref(),
Some("https://vtc.example")
);
}
#[test]
fn a_community_publishing_no_rest_service_has_no_endpoint() {
let messaging_only = doc_with(json!([
{ "id": "#didcomm", "type": "DIDCommMessaging", "serviceEndpoint": "did:webvh:m" },
]));
assert!(rest_endpoint(&messaging_only).is_none());
assert!(rest_endpoint(&json!({ "id": "did:webvh:x" })).is_none());
}
#[test]
fn a_published_endpoint_is_vetted_before_it_is_dialled() {
let blocked = |url: &str| {
matches!(
trust_tasks_url(url, ProbePolicy::PublicOnly),
Err(DiscoverError::Blocked(_))
)
};
assert!(blocked("http://vtc.example"), "plaintext");
assert!(blocked("https://user:pw@vtc.example"), "userinfo");
assert!(blocked("https://127.0.0.1"), "loopback");
assert!(blocked("file:///etc/passwd"), "scheme");
let ok = trust_tasks_url("https://vtc.example/", ProbePolicy::PublicOnly)
.expect("a public https endpoint is dialled");
assert_eq!(ok.as_str(), "https://vtc.example/v1/trust-tasks");
assert!(trust_tasks_url("http://127.0.0.1:8080", ProbePolicy::AllowPrivate).is_ok());
}
#[test]
fn the_question_carries_no_issuer() {
let request = anonymous_request("did:webvh:community", JoinProtocol::V0_3);
assert!(
request.get("issuer").is_none(),
"a pre-application read must not name the reader"
);
assert_eq!(request["recipient"], "did:webvh:community");
assert_eq!(request["type"], JoinProtocol::V0_3.manifest_type());
assert!(request["payload"].as_object().is_some_and(|p| p.is_empty()));
}
#[test]
fn a_refusal_is_reported_by_its_message_not_its_envelope() {
let trust_task_error = r#"{"type":"…/trust-task-error/0.5",
"payload":{"code":"malformedRequest","message":"body did not parse"}}"#;
assert_eq!(
refusal_detail(trust_task_error),
(
Some("malformedRequest".to_string()),
"body did not parse".to_string()
),
"the code is kept: it decides whether to ask in another version"
);
assert_eq!(
refusal_detail(r#"{"message":"unauthorized"}"#).1,
"unauthorized"
);
assert_eq!(
refusal_detail("<html>502</html>"),
(None, "<html>502</html>".to_string())
);
}
#[test]
fn every_failure_says_which_one_it_is() {
let said = |e: DiscoverError| e.to_string();
let all = [
said(DiscoverError::NoEndpoint),
said(DiscoverError::Blocked("loopback".into())),
said(DiscoverError::Unreachable("dns".into())),
said(DiscoverError::Refused {
status: 503,
code: None,
detail: "down".into(),
}),
said(DiscoverError::Unreadable("bad json".into())),
said(DiscoverError::Unproven("no proof".into())),
said(DiscoverError::WrongSigner {
proven: "did:webvh:someone-else".into(),
}),
];
let mut seen = all.clone().to_vec();
seen.sort();
seen.dedup();
assert_eq!(seen.len(), all.len(), "two failures read the same");
assert!(all.iter().all(|s| !s.is_empty()));
}
}