use serde_json::Value;
use trust_tasks_rs::specs::trust_task_discovery::v0_4 as discovery;
use crate::{MAX_DOCUMENT_RESPONSE_BYTES, VtcClient, VtcError, decode_payload};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum VtcTransport {
Tsp,
Didcomm,
Https,
}
impl std::fmt::Display for VtcTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Tsp => "TSP",
Self::Didcomm => "DIDComm",
Self::Https => "HTTPS",
})
}
}
fn response_uri(type_uri: &str) -> String {
format!("{type_uri}#response")
}
impl VtcClient {
pub fn transport(&self) -> Option<VtcTransport> {
#[cfg(feature = "didcomm")]
if let Some(documents) = &self.documents {
use vta_sdk::client::SurfaceTransport;
return match documents.trust_task_transport() {
SurfaceTransport::Tsp => Some(VtcTransport::Tsp),
SurfaceTransport::Didcomm => Some(VtcTransport::Didcomm),
SurfaceTransport::Rest => Some(VtcTransport::Https),
_ => None,
};
}
(!self.base_url.is_empty()).then_some(VtcTransport::Https)
}
pub async fn send_task(&self, type_uri: &str, payload: Value) -> Result<Value, VtcError> {
if type_uri.contains('#') {
return Err(VtcError::UnpinnedTask {
type_uri: type_uri.to_string(),
reason: "a request is sent by its bare Type URI, never a `#response` one".into(),
});
}
let Some(entry) = vti_specs::schema_index::entry(type_uri) else {
return Err(VtcError::UnpinnedTask {
type_uri: type_uri.to_string(),
reason: "not a Trust Task version this client pins (vti_specs::schema_index); \
a framework specification such as discovery has its own method"
.into(),
});
};
let Some(schema) = entry.schema else {
return Err(VtcError::UnpinnedTask {
type_uri: type_uri.to_string(),
reason: "pinned without a payload schema; refusing to send unchecked".into(),
});
};
trust_tasks_rs::validate::against_schema(schema, &payload).map_err(|e| {
VtcError::InvalidPayload(format!(
"does not conform to {type_uri}: {e} (an unset optional member is absent, \
never `null`)"
))
})?;
let declared = entry.error_codes.unwrap_or(&[]);
let max_bytes = vti_specs::schema_index::max_document_bytes_for(&response_uri(type_uri))
.unwrap_or(MAX_DOCUMENT_RESPONSE_BYTES);
self.document(type_uri, payload, declared, max_bytes).await
}
pub async fn discover(&self, patterns: &[&str]) -> Result<discovery::Response, VtcError> {
use trust_tasks_rs::Payload as _;
use trust_tasks_rs::validate::ValidatedPayload;
let payload = serde_json::json!({ "patterns": patterns });
discovery::Payload::validate_value(&payload)
.map_err(|e| VtcError::InvalidPayload(e.to_string()))?;
let reply = self
.document(
discovery::Payload::TYPE_URI,
payload,
&[],
MAX_DOCUMENT_RESPONSE_BYTES,
)
.await?;
decode_payload(reply, "trust-task-discovery")
}
}
pub fn supported_type_uris(response: &discovery::Response) -> Vec<&str> {
response
.supported_types
.iter()
.filter_map(|item| match item {
discovery::ResponseSupportedTypesItem::Uri(uri) => Some(uri.as_str()),
discovery::ResponseSupportedTypesItem::Object { type_, .. } => Some(type_.as_str()),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HolderKey;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const VTC_DID: &str = "did:web:vtc.example.com";
const MEMBERS_LIST: &str = "https://trusttasks.org/spec/vtc/members/list/0.2";
const GROUPS_CREATE: &str = "https://trusttasks.org/spec/vtc/groups/create/0.1";
fn holder() -> (String, HolderKey) {
let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
let did = format!(
"did:key:{}",
vta_sdk::did_key::ed25519_multibase_pubkey(&signing.verifying_key().to_bytes())
);
let private = multibase::encode(multibase::Base::Base58Btc, signing.to_bytes());
let key = HolderKey::from_did_key(&did, private).unwrap();
(did, key)
}
async fn read_request(sock: &mut tokio::net::TcpStream) -> (String, Vec<u8>) {
let mut buf = Vec::new();
let head_end = loop {
let mut chunk = [0u8; 4096];
let n = sock.read(&mut chunk).await.unwrap();
assert!(n > 0, "connection closed before the request head");
buf.extend_from_slice(&chunk[..n]);
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break i + 4;
}
};
let head = String::from_utf8_lossy(&buf[..head_end]).into_owned();
let length: usize = head
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(str::to_owned)
})
.map(|v| v.trim().parse().unwrap())
.unwrap_or(0);
let mut body = buf[head_end..].to_vec();
while body.len() < length {
let mut chunk = [0u8; 4096];
let n = sock.read(&mut chunk).await.unwrap();
assert!(n > 0, "connection closed before the request body");
body.extend_from_slice(&chunk[..n]);
}
body.truncate(length);
(head, body)
}
async fn one_shot_vtc(
reply_type: &str,
reply_payload: Value,
) -> (String, tokio::task::JoinHandle<(String, Value)>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let reply = serde_json::json!({
"id": "urn:uuid:4d2c3f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f",
"type": reply_type,
"issuer": VTC_DID,
"issuedAt": "2026-11-06T00:00:00Z",
"payload": reply_payload,
})
.to_string();
let served = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let (head, body) = read_request(&mut sock).await;
let document: Value = serde_json::from_slice(&body).expect("a JSON request body");
sock.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}",
reply.len()
)
.as_bytes(),
)
.await
.unwrap();
sock.shutdown().await.ok();
(head, document)
});
(format!("http://127.0.0.1:{port}/v1"), served)
}
#[tokio::test]
async fn an_unpinned_type_uri_is_refused_before_anything_is_sent() {
let (_, key) = holder();
let client = VtcClient::with_key("http://192.0.2.1:9/v1", VTC_DID, key);
let err = client
.send_task(
"https://trusttasks.org/spec/vtc/members/list/9.9",
serde_json::json!({}),
)
.await
.unwrap_err();
assert!(
matches!(err, VtcError::UnpinnedTask { ref type_uri, .. } if type_uri.ends_with("/9.9")),
"{err}"
);
let err = client
.send_task(
"https://trusttasks.org/spec/trust-task-discovery/0.4",
serde_json::json!({ "patterns": [] }),
)
.await
.unwrap_err();
assert!(matches!(err, VtcError::UnpinnedTask { .. }), "{err}");
}
#[tokio::test]
async fn a_response_type_uri_is_refused() {
let (_, key) = holder();
let client = VtcClient::with_key("http://192.0.2.1:9/v1", VTC_DID, key);
let err = client
.send_task(&format!("{MEMBERS_LIST}#response"), serde_json::json!({}))
.await
.unwrap_err();
assert!(matches!(err, VtcError::UnpinnedTask { .. }), "{err}");
}
#[tokio::test]
async fn a_payload_the_schema_rejects_is_refused_before_anything_is_sent() {
let (_, key) = holder();
let client = VtcClient::with_key("http://192.0.2.1:9/v1", VTC_DID, key);
let err = client
.send_task(MEMBERS_LIST, serde_json::json!({ "role": 5 }))
.await
.unwrap_err();
assert!(
matches!(err, VtcError::InvalidPayload(ref m) if m.contains(MEMBERS_LIST) && m.contains("string")),
"{err}"
);
let err = client
.send_task(GROUPS_CREATE, serde_json::json!({ "name": "x" }))
.await
.unwrap_err();
assert!(matches!(err, VtcError::InvalidPayload(_)), "{err}");
}
#[tokio::test]
async fn without_a_key_or_session_nothing_is_sent() {
let client = VtcClient::anonymous("http://192.0.2.1:9/v1", VTC_DID);
assert!(matches!(
client.send_task(MEMBERS_LIST, serde_json::json!({})).await,
Err(VtcError::NotAuthenticated)
));
assert!(matches!(
client.discover(&["vtc/*"]).await,
Err(VtcError::NotAuthenticated)
));
}
#[tokio::test]
async fn a_pinned_task_is_signed_posted_and_its_response_payload_returned() {
let (did, key) = holder();
let (base, served) = one_shot_vtc(
&response_uri(GROUPS_CREATE),
serde_json::json!({ "group": { "groupId": "g1" } }),
)
.await;
let client = VtcClient::with_key(&base, VTC_DID, key);
assert_eq!(client.transport(), Some(VtcTransport::Https));
let payload = serde_json::json!({
"name": "Gardeners",
"kind": "static",
"joinPolicy": "open",
"visibility": "members",
"showMembers": "members",
"issuesCredentials": false,
"reason": "the allotment working group",
});
let reply = client
.send_task(GROUPS_CREATE, payload.clone())
.await
.unwrap();
assert_eq!(reply["group"]["groupId"], "g1");
let (head, document) = served.await.unwrap();
assert!(head.starts_with("POST /v1/trust-tasks HTTP/1.1"), "{head}");
assert_eq!(document["type"], GROUPS_CREATE);
assert_eq!(document["payload"], payload);
assert_eq!(document["issuer"], did);
assert_eq!(document["recipient"], VTC_DID);
assert!(document["proof"].is_object(), "the document is signed");
}
#[tokio::test]
async fn a_declared_not_found_is_typed() {
let (_, key) = holder();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let refusal = serde_json::json!({
"id": "urn:uuid:4d2c3f4e-1b2a-4c3d-8e9f-0a1b2c3d4e50",
"type": "https://trusttasks.org/spec/trust-task-error/0.3",
"issuer": VTC_DID,
"issuedAt": "2026-11-06T00:00:00Z",
"payload": {
"code": "vtc/groups/show:notFound",
"message": "no such group",
"details": { "reason": "notFound" }
},
})
.to_string();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let _ = read_request(&mut sock).await;
sock.write_all(
format!(
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{refusal}",
refusal.len()
)
.as_bytes(),
)
.await
.unwrap();
});
let client = VtcClient::with_key(&format!("http://127.0.0.1:{port}/v1"), VTC_DID, key);
let err = client
.send_task(
"https://trusttasks.org/spec/vtc/groups/show/0.1",
serde_json::json!({ "groupId": "grp_0123456789abcdef" }),
)
.await
.unwrap_err();
assert!(
matches!(err, VtcError::NotFound { ref code, .. } if code == "vtc/groups/show:notFound"),
"{err}"
);
}
#[tokio::test]
async fn discovery_asks_0_4_and_flattens_the_answer() {
let (_, key) = holder();
let (base, served) = one_shot_vtc(
"https://trusttasks.org/spec/trust-task-discovery/0.4#response",
serde_json::json!({
"frameworkVersion": "0.6.0",
"supportedTypes": [
MEMBERS_LIST,
{
"type": GROUPS_CREATE,
"acceptanceWindow": { "clockSkewSeconds": 30, "maxAgeSeconds": 300 }
}
]
}),
)
.await;
let client = VtcClient::with_key(&base, VTC_DID, key);
let answer = client.discover(&["vtc/*"]).await.unwrap();
assert_eq!(
supported_type_uris(&answer),
vec![MEMBERS_LIST, GROUPS_CREATE]
);
let (_, document) = served.await.unwrap();
assert_eq!(
document["type"],
"https://trusttasks.org/spec/trust-task-discovery/0.4"
);
assert_eq!(
document["payload"]["patterns"],
serde_json::json!(["vtc/*"])
);
}
#[test]
fn a_client_with_no_base_has_no_transport() {
let (_, key) = holder();
assert_eq!(VtcClient::with_key("", VTC_DID, key).transport(), None);
assert_eq!(
VtcClient::anonymous("https://vtc.example.com/v1", VTC_DID).transport(),
Some(VtcTransport::Https)
);
}
}