1use std::path::Path;
20use std::time::Duration;
21
22use reqwest::Client;
23use serde::{Deserialize, Serialize};
24
25use super::auth::acquire_service_auth;
26use super::error::CliError;
27use super::output::truncate;
28use super::pds::PdsClient;
29use super::session::SessionFile;
30
31const GET_TRUST_CHAIN_LXM: &str = "tools.cairn.admin.getTrustChain";
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
37pub struct TrustChainResponse {
38 #[serde(rename = "serviceDid")]
40 pub service_did: String,
41 #[serde(rename = "signingKeys")]
43 pub signing_keys: Vec<SigningKeyEntry>,
44 pub maintainers: Vec<MaintainerEntry>,
46 #[serde(
51 rename = "serviceRecord",
52 skip_serializing_if = "Option::is_none",
53 default
54 )]
55 pub service_record: Option<ServiceRecordSummary>,
56 pub instance: InstanceInfo,
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct SigningKeyEntry {
63 #[serde(rename = "publicKeyMultibase")]
66 pub public_key_multibase: String,
67 #[serde(rename = "validFrom")]
69 pub valid_from: String,
70 #[serde(rename = "validTo", skip_serializing_if = "Option::is_none", default)]
74 pub valid_to: Option<String>,
75 #[serde(rename = "createdAt")]
77 pub created_at: String,
78 #[serde(rename = "isActive")]
80 pub is_active: bool,
81}
82
83#[derive(Debug, Clone, Deserialize, Serialize)]
85pub struct MaintainerEntry {
86 pub did: String,
88 pub role: String,
90 #[serde(rename = "addedAt")]
92 pub added_at: String,
93 #[serde(rename = "addedBy", skip_serializing_if = "Option::is_none", default)]
97 pub added_by: Option<String>,
98 #[serde(rename = "provenanceAttested")]
102 pub provenance_attested: bool,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
108pub struct ServiceRecordSummary {
109 #[serde(rename = "contentHash")]
111 pub content_hash: String,
112 #[serde(rename = "labelValues")]
115 pub label_values: Vec<String>,
116}
117
118#[derive(Debug, Clone, Deserialize, Serialize)]
120pub struct InstanceInfo {
121 pub version: String,
123 #[serde(rename = "serviceEndpoint")]
125 pub service_endpoint: String,
126}
127
128#[derive(Debug, Clone, Default)]
131pub struct TrustChainShowInput {
132 pub cairn_server_override: Option<String>,
134}
135
136pub async fn show(
143 session: &mut SessionFile,
144 session_path: &Path,
145 input: TrustChainShowInput,
146) -> Result<TrustChainResponse, CliError> {
147 let cairn_server = input
148 .cairn_server_override
149 .as_deref()
150 .unwrap_or(&session.cairn_server_url)
151 .trim_end_matches('/')
152 .to_string();
153 let pds = PdsClient::new(&session.pds_url)?;
154 let token = acquire_service_auth(&pds, session, session_path, GET_TRUST_CHAIN_LXM).await?;
155
156 let url = format!("{cairn_server}/xrpc/{GET_TRUST_CHAIN_LXM}");
157 let client = Client::builder()
158 .timeout(Duration::from_secs(30))
159 .build()
160 .expect("reqwest build");
161 let resp = client
162 .get(&url)
163 .bearer_auth(&token)
164 .send()
165 .await
166 .map_err(|source| CliError::Http {
167 url: url.clone(),
168 source,
169 })?;
170 if !resp.status().is_success() {
171 let status = resp.status().as_u16();
172 let body = resp.text().await.unwrap_or_default();
173 return Err(CliError::CairnStatus { url, status, body });
174 }
175 let bytes = resp.bytes().await.map_err(|source| CliError::Http {
176 url: url.clone(),
177 source,
178 })?;
179 serde_json::from_slice::<TrustChainResponse>(&bytes)
180 .map_err(|source| CliError::MalformedResponse { url, source })
181}
182
183pub fn format_show_human(resp: &TrustChainResponse) -> String {
188 use std::fmt::Write;
189 let mut s = String::new();
190 let _ = writeln!(s, "service: {}", resp.service_did);
191
192 let _ = writeln!(s);
194 let _ = writeln!(s, "signing keys ({}):", resp.signing_keys.len());
195 if resp.signing_keys.is_empty() {
196 let _ = writeln!(s, " (none)");
197 } else {
198 let key_w = resp
199 .signing_keys
200 .iter()
201 .map(|k| k.public_key_multibase.len().min(48))
202 .max()
203 .unwrap_or(20)
204 .max(20);
205 let _ = writeln!(
206 s,
207 " {:<key_w$} {:<24} {:<6}",
208 "PUBLIC_KEY_MULTIBASE",
209 "VALID_FROM",
210 "ACTIVE",
211 key_w = key_w,
212 );
213 for k in &resp.signing_keys {
214 let key = truncate(&k.public_key_multibase, 48);
215 let active = if k.is_active { "yes" } else { "no" };
216 let _ = writeln!(
217 s,
218 " {:<key_w$} {:<24} {:<6}",
219 key,
220 k.valid_from,
221 active,
222 key_w = key_w,
223 );
224 }
225 }
226
227 let _ = writeln!(s);
229 let _ = writeln!(s, "maintainers ({}):", resp.maintainers.len());
230 if resp.maintainers.is_empty() {
231 let _ = writeln!(s, " (none)");
232 } else {
233 let did_w = resp
234 .maintainers
235 .iter()
236 .map(|m| m.did.len().min(40))
237 .max()
238 .unwrap_or(8)
239 .max(8);
240 let _ = writeln!(
241 s,
242 " {:<did_w$} {:<5} {:<24} {:<10}",
243 "DID",
244 "ROLE",
245 "ADDED_AT",
246 "PROVENANCE",
247 did_w = did_w,
248 );
249 for m in &resp.maintainers {
250 let did = truncate(&m.did, 40);
251 let prov = if m.provenance_attested {
252 "attested"
253 } else {
254 "unattested"
255 };
256 let _ = writeln!(
257 s,
258 " {:<did_w$} {:<5} {:<24} {:<10}",
259 did,
260 m.role,
261 m.added_at,
262 prov,
263 did_w = did_w,
264 );
265 }
266 }
267
268 let _ = writeln!(s);
270 match &resp.service_record {
271 Some(sr) => {
272 let _ = writeln!(s, "service record:");
273 let _ = writeln!(s, " content_hash: {}", sr.content_hash);
274 let _ = writeln!(s, " label values: {}", sr.label_values.join(", "));
275 }
276 None => {
277 let _ = writeln!(s, "service record: (not published)");
278 }
279 }
280
281 let _ = writeln!(s);
283 let _ = writeln!(s, "instance:");
284 let _ = writeln!(s, " version: {}", resp.instance.version);
285 let _ = write!(s, " service endpoint: {}", resp.instance.service_endpoint);
286 s
287}
288
289pub fn format_show_json(resp: &TrustChainResponse) -> String {
292 serde_json::to_string_pretty(resp).expect("TrustChainResponse serializes")
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn sample(service_record: Option<ServiceRecordSummary>) -> TrustChainResponse {
300 TrustChainResponse {
301 service_did: "did:plc:cairn0000000000000000000000".into(),
302 signing_keys: vec![SigningKeyEntry {
303 public_key_multibase: "zKeyAbc123".into(),
304 valid_from: "2026-04-23T00:00:00.000Z".into(),
305 valid_to: None,
306 created_at: "2026-04-23T00:00:00.000Z".into(),
307 is_active: true,
308 }],
309 maintainers: vec![
310 MaintainerEntry {
311 did: "did:plc:admin00000000000000000000".into(),
312 role: "admin".into(),
313 added_at: "2026-04-25T00:00:00.000Z".into(),
314 added_by: None,
315 provenance_attested: false,
316 },
317 MaintainerEntry {
318 did: "did:plc:byhttp00000000000000000000".into(),
319 role: "mod".into(),
320 added_at: "2026-04-26T00:00:00.000Z".into(),
321 added_by: Some("did:plc:admin00000000000000000000".into()),
322 provenance_attested: true,
323 },
324 ],
325 service_record,
326 instance: InstanceInfo {
327 version: "1.2.0".into(),
328 service_endpoint: "https://labeler.example".into(),
329 },
330 }
331 }
332
333 #[test]
334 fn format_human_shows_required_sections() {
335 let r = sample(Some(ServiceRecordSummary {
336 content_hash: "abcdef0123".into(),
337 label_values: vec!["spam".into(), "abuse".into()],
338 }));
339 let s = format_show_human(&r);
340 assert!(s.contains("service: did:plc:cairn0000000000000000000000"));
341 assert!(s.contains("signing keys (1)"));
342 assert!(s.contains("zKeyAbc123"));
343 assert!(s.contains("maintainers (2)"));
344 assert!(s.contains("attested"));
345 assert!(s.contains("unattested"));
346 assert!(s.contains("service record:"));
347 assert!(s.contains("content_hash: abcdef0123"));
348 assert!(s.contains("spam, abuse"));
349 assert!(s.contains("version: 1.2.0"));
350 assert!(s.contains("https://labeler.example"));
351 }
352
353 #[test]
354 fn format_human_marks_service_record_not_published_when_absent() {
355 let r = sample(None);
356 let s = format_show_human(&r);
357 assert!(s.contains("service record: (not published)"));
358 assert!(
359 !s.contains("content_hash:"),
360 "no content_hash line when service_record is None"
361 );
362 }
363
364 #[test]
365 fn format_json_round_trips() {
366 let r = sample(Some(ServiceRecordSummary {
367 content_hash: "deadbeef".into(),
368 label_values: vec!["spam".into()],
369 }));
370 let json = format_show_json(&r);
371 let parsed: TrustChainResponse = serde_json::from_str(&json).expect("round trip");
372 assert_eq!(parsed.service_did, r.service_did);
373 assert_eq!(parsed.signing_keys.len(), 1);
374 assert_eq!(parsed.maintainers.len(), 2);
375 assert_eq!(
376 parsed
377 .service_record
378 .as_ref()
379 .map(|s| s.content_hash.as_str()),
380 Some("deadbeef")
381 );
382 assert_eq!(parsed.instance.version, "1.2.0");
383 }
384
385 #[test]
386 fn deserialize_omits_optional_fields() {
387 let body = r#"{
390 "serviceDid": "did:plc:x",
391 "signingKeys": [{
392 "publicKeyMultibase": "zk",
393 "validFrom": "2026-01-01T00:00:00.000Z",
394 "createdAt": "2026-01-01T00:00:00.000Z",
395 "isActive": true
396 }],
397 "maintainers": [{
398 "did": "did:plc:m",
399 "role": "admin",
400 "addedAt": "2026-01-01T00:00:00.000Z",
401 "provenanceAttested": false
402 }],
403 "instance": {
404 "version": "1.2.0",
405 "serviceEndpoint": "https://labeler.example"
406 }
407 }"#;
408 let parsed: TrustChainResponse = serde_json::from_str(body).expect("parse");
409 assert!(parsed.signing_keys[0].valid_to.is_none());
410 assert!(parsed.maintainers[0].added_by.is_none());
411 assert!(parsed.service_record.is_none());
412 }
413}