use std::path::Path;
use std::time::Duration;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use super::auth::acquire_service_auth;
use super::error::CliError;
use super::output::truncate;
use super::pds::PdsClient;
use super::session::SessionFile;
const GET_TRUST_CHAIN_LXM: &str = "tools.cairn.admin.getTrustChain";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TrustChainResponse {
#[serde(rename = "serviceDid")]
pub service_did: String,
#[serde(rename = "signingKeys")]
pub signing_keys: Vec<SigningKeyEntry>,
pub maintainers: Vec<MaintainerEntry>,
#[serde(
rename = "serviceRecord",
skip_serializing_if = "Option::is_none",
default
)]
pub service_record: Option<ServiceRecordSummary>,
pub instance: InstanceInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SigningKeyEntry {
#[serde(rename = "publicKeyMultibase")]
pub public_key_multibase: String,
#[serde(rename = "validFrom")]
pub valid_from: String,
#[serde(rename = "validTo", skip_serializing_if = "Option::is_none", default)]
pub valid_to: Option<String>,
#[serde(rename = "createdAt")]
pub created_at: String,
#[serde(rename = "isActive")]
pub is_active: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MaintainerEntry {
pub did: String,
pub role: String,
#[serde(rename = "addedAt")]
pub added_at: String,
#[serde(rename = "addedBy", skip_serializing_if = "Option::is_none", default)]
pub added_by: Option<String>,
#[serde(rename = "provenanceAttested")]
pub provenance_attested: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServiceRecordSummary {
#[serde(rename = "contentHash")]
pub content_hash: String,
#[serde(rename = "labelValues")]
pub label_values: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InstanceInfo {
pub version: String,
#[serde(rename = "serviceEndpoint")]
pub service_endpoint: String,
}
#[derive(Debug, Clone, Default)]
pub struct TrustChainShowInput {
pub cairn_server_override: Option<String>,
}
pub async fn show(
session: &mut SessionFile,
session_path: &Path,
input: TrustChainShowInput,
) -> Result<TrustChainResponse, CliError> {
let cairn_server = input
.cairn_server_override
.as_deref()
.unwrap_or(&session.cairn_server_url)
.trim_end_matches('/')
.to_string();
let pds = PdsClient::new(&session.pds_url)?;
let token = acquire_service_auth(&pds, session, session_path, GET_TRUST_CHAIN_LXM).await?;
let url = format!("{cairn_server}/xrpc/{GET_TRUST_CHAIN_LXM}");
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest build");
let resp = client
.get(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::CairnStatus { url, status, body });
}
let bytes = resp.bytes().await.map_err(|source| CliError::Http {
url: url.clone(),
source,
})?;
serde_json::from_slice::<TrustChainResponse>(&bytes)
.map_err(|source| CliError::MalformedResponse { url, source })
}
pub fn format_show_human(resp: &TrustChainResponse) -> String {
use std::fmt::Write;
let mut s = String::new();
let _ = writeln!(s, "service: {}", resp.service_did);
let _ = writeln!(s);
let _ = writeln!(s, "signing keys ({}):", resp.signing_keys.len());
if resp.signing_keys.is_empty() {
let _ = writeln!(s, " (none)");
} else {
let key_w = resp
.signing_keys
.iter()
.map(|k| k.public_key_multibase.len().min(48))
.max()
.unwrap_or(20)
.max(20);
let _ = writeln!(
s,
" {:<key_w$} {:<24} {:<6}",
"PUBLIC_KEY_MULTIBASE",
"VALID_FROM",
"ACTIVE",
key_w = key_w,
);
for k in &resp.signing_keys {
let key = truncate(&k.public_key_multibase, 48);
let active = if k.is_active { "yes" } else { "no" };
let _ = writeln!(
s,
" {:<key_w$} {:<24} {:<6}",
key,
k.valid_from,
active,
key_w = key_w,
);
}
}
let _ = writeln!(s);
let _ = writeln!(s, "maintainers ({}):", resp.maintainers.len());
if resp.maintainers.is_empty() {
let _ = writeln!(s, " (none)");
} else {
let did_w = resp
.maintainers
.iter()
.map(|m| m.did.len().min(40))
.max()
.unwrap_or(8)
.max(8);
let _ = writeln!(
s,
" {:<did_w$} {:<5} {:<24} {:<10}",
"DID",
"ROLE",
"ADDED_AT",
"PROVENANCE",
did_w = did_w,
);
for m in &resp.maintainers {
let did = truncate(&m.did, 40);
let prov = if m.provenance_attested {
"attested"
} else {
"unattested"
};
let _ = writeln!(
s,
" {:<did_w$} {:<5} {:<24} {:<10}",
did,
m.role,
m.added_at,
prov,
did_w = did_w,
);
}
}
let _ = writeln!(s);
match &resp.service_record {
Some(sr) => {
let _ = writeln!(s, "service record:");
let _ = writeln!(s, " content_hash: {}", sr.content_hash);
let _ = writeln!(s, " label values: {}", sr.label_values.join(", "));
}
None => {
let _ = writeln!(s, "service record: (not published)");
}
}
let _ = writeln!(s);
let _ = writeln!(s, "instance:");
let _ = writeln!(s, " version: {}", resp.instance.version);
let _ = write!(s, " service endpoint: {}", resp.instance.service_endpoint);
s
}
pub fn format_show_json(resp: &TrustChainResponse) -> String {
serde_json::to_string_pretty(resp).expect("TrustChainResponse serializes")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(service_record: Option<ServiceRecordSummary>) -> TrustChainResponse {
TrustChainResponse {
service_did: "did:plc:cairn0000000000000000000000".into(),
signing_keys: vec![SigningKeyEntry {
public_key_multibase: "zKeyAbc123".into(),
valid_from: "2026-04-23T00:00:00.000Z".into(),
valid_to: None,
created_at: "2026-04-23T00:00:00.000Z".into(),
is_active: true,
}],
maintainers: vec![
MaintainerEntry {
did: "did:plc:admin00000000000000000000".into(),
role: "admin".into(),
added_at: "2026-04-25T00:00:00.000Z".into(),
added_by: None,
provenance_attested: false,
},
MaintainerEntry {
did: "did:plc:byhttp00000000000000000000".into(),
role: "mod".into(),
added_at: "2026-04-26T00:00:00.000Z".into(),
added_by: Some("did:plc:admin00000000000000000000".into()),
provenance_attested: true,
},
],
service_record,
instance: InstanceInfo {
version: "1.2.0".into(),
service_endpoint: "https://labeler.example".into(),
},
}
}
#[test]
fn format_human_shows_required_sections() {
let r = sample(Some(ServiceRecordSummary {
content_hash: "abcdef0123".into(),
label_values: vec!["spam".into(), "abuse".into()],
}));
let s = format_show_human(&r);
assert!(s.contains("service: did:plc:cairn0000000000000000000000"));
assert!(s.contains("signing keys (1)"));
assert!(s.contains("zKeyAbc123"));
assert!(s.contains("maintainers (2)"));
assert!(s.contains("attested"));
assert!(s.contains("unattested"));
assert!(s.contains("service record:"));
assert!(s.contains("content_hash: abcdef0123"));
assert!(s.contains("spam, abuse"));
assert!(s.contains("version: 1.2.0"));
assert!(s.contains("https://labeler.example"));
}
#[test]
fn format_human_marks_service_record_not_published_when_absent() {
let r = sample(None);
let s = format_show_human(&r);
assert!(s.contains("service record: (not published)"));
assert!(
!s.contains("content_hash:"),
"no content_hash line when service_record is None"
);
}
#[test]
fn format_json_round_trips() {
let r = sample(Some(ServiceRecordSummary {
content_hash: "deadbeef".into(),
label_values: vec!["spam".into()],
}));
let json = format_show_json(&r);
let parsed: TrustChainResponse = serde_json::from_str(&json).expect("round trip");
assert_eq!(parsed.service_did, r.service_did);
assert_eq!(parsed.signing_keys.len(), 1);
assert_eq!(parsed.maintainers.len(), 2);
assert_eq!(
parsed
.service_record
.as_ref()
.map(|s| s.content_hash.as_str()),
Some("deadbeef")
);
assert_eq!(parsed.instance.version, "1.2.0");
}
#[test]
fn deserialize_omits_optional_fields() {
let body = r#"{
"serviceDid": "did:plc:x",
"signingKeys": [{
"publicKeyMultibase": "zk",
"validFrom": "2026-01-01T00:00:00.000Z",
"createdAt": "2026-01-01T00:00:00.000Z",
"isActive": true
}],
"maintainers": [{
"did": "did:plc:m",
"role": "admin",
"addedAt": "2026-01-01T00:00:00.000Z",
"provenanceAttested": false
}],
"instance": {
"version": "1.2.0",
"serviceEndpoint": "https://labeler.example"
}
}"#;
let parsed: TrustChainResponse = serde_json::from_str(body).expect("parse");
assert!(parsed.signing_keys[0].valid_to.is_none());
assert!(parsed.maintainers[0].added_by.is_none());
assert!(parsed.service_record.is_none());
}
}