1use std::path::Path;
28use std::time::Duration;
29
30use reqwest::Client;
31use serde::{Deserialize, Serialize};
32
33use super::auth::acquire_service_auth;
34use super::error::CliError;
35use super::output::truncate;
36use super::pds::PdsClient;
37use super::session::SessionFile;
38
39const LIST_AUDIT_LOG_LXM: &str = "tools.cairn.admin.listAuditLog";
40const GET_AUDIT_LOG_LXM: &str = "tools.cairn.admin.getAuditLog";
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
45pub struct AuditEntry {
46 pub id: i64,
48 #[serde(rename = "createdAt")]
50 pub created_at: String,
51 pub action: String,
54 #[serde(rename = "actorDid")]
57 pub actor_did: String,
58 #[serde(skip_serializing_if = "Option::is_none", default)]
61 pub target: Option<String>,
62 #[serde(rename = "targetCid", skip_serializing_if = "Option::is_none", default)]
65 pub target_cid: Option<String>,
66 pub outcome: String,
68 #[serde(skip_serializing_if = "Option::is_none", default)]
71 pub reason: Option<String>,
72 #[serde(rename = "prevHash", skip_serializing_if = "Option::is_none", default)]
77 pub prev_hash: Option<String>,
78 #[serde(rename = "rowHash", skip_serializing_if = "Option::is_none", default)]
82 pub row_hash: Option<String>,
83}
84
85#[derive(Debug, Deserialize, Serialize)]
87pub struct AuditListResponse {
88 pub entries: Vec<AuditEntry>,
90 #[serde(skip_serializing_if = "Option::is_none", default)]
93 pub cursor: Option<String>,
94}
95
96#[derive(Debug, Clone, Default)]
98pub struct AuditListInput {
99 pub actor: Option<String>,
101 pub action: Option<String>,
104 pub outcome: Option<String>,
107 pub since: Option<String>,
109 pub until: Option<String>,
111 pub limit: Option<i64>,
113 pub cursor: Option<String>,
115 pub cairn_server_override: Option<String>,
117}
118
119pub async fn list(
129 session: &mut SessionFile,
130 session_path: &Path,
131 input: AuditListInput,
132) -> Result<AuditListResponse, CliError> {
133 let cairn_server = input
134 .cairn_server_override
135 .as_deref()
136 .unwrap_or(&session.cairn_server_url)
137 .trim_end_matches('/')
138 .to_string();
139 let pds = PdsClient::new(&session.pds_url)?;
140 let token = acquire_service_auth(&pds, session, session_path, LIST_AUDIT_LOG_LXM).await?;
141
142 let url = format!("{cairn_server}/xrpc/{LIST_AUDIT_LOG_LXM}");
143 let limit_owned = input.limit.map(|n| n.to_string());
144 let mut query: Vec<(&str, &str)> = Vec::new();
145 if let Some(a) = &input.actor {
146 query.push(("actor", a.as_str()));
147 }
148 if let Some(a) = &input.action {
149 query.push(("action", a.as_str()));
150 }
151 if let Some(o) = &input.outcome {
152 query.push(("outcome", o.as_str()));
153 }
154 if let Some(s) = &input.since {
155 query.push(("since", s.as_str()));
156 }
157 if let Some(u) = &input.until {
158 query.push(("until", u.as_str()));
159 }
160 if let Some(n) = &limit_owned {
161 query.push(("limit", n.as_str()));
162 }
163 if let Some(c) = &input.cursor {
164 query.push(("cursor", c.as_str()));
165 }
166
167 let client = Client::builder()
168 .timeout(Duration::from_secs(30))
169 .build()
170 .expect("reqwest build");
171 let resp = client
172 .get(&url)
173 .bearer_auth(&token)
174 .query(&query)
175 .send()
176 .await
177 .map_err(|source| CliError::Http {
178 url: url.clone(),
179 source,
180 })?;
181 if !resp.status().is_success() {
182 let status = resp.status().as_u16();
183 let body = resp.text().await.unwrap_or_default();
184 return Err(CliError::CairnStatus { url, status, body });
185 }
186 let bytes = resp.bytes().await.map_err(|source| CliError::Http {
187 url: url.clone(),
188 source,
189 })?;
190 serde_json::from_slice::<AuditListResponse>(&bytes)
191 .map_err(|source| CliError::MalformedResponse { url, source })
192}
193
194pub fn format_list_human(resp: &AuditListResponse) -> String {
200 use std::fmt::Write;
201 if resp.entries.is_empty() {
202 let mut s = "(no audit entries)".to_string();
203 if let Some(c) = &resp.cursor {
204 let _ = write!(s, "\nnext cursor: {c}");
205 }
206 return s;
207 }
208 let id_w = resp
209 .entries
210 .iter()
211 .map(|e| e.id.to_string().len())
212 .max()
213 .unwrap_or(2)
214 .max(2);
215 let action_w = resp
216 .entries
217 .iter()
218 .map(|e| e.action.len().min(28))
219 .max()
220 .unwrap_or(6)
221 .max(6);
222 let actor_w = resp
223 .entries
224 .iter()
225 .map(|e| e.actor_did.len().min(40))
226 .max()
227 .unwrap_or(8)
228 .max(8);
229 let mut s = String::new();
230 let _ = writeln!(
231 s,
232 "{:>id_w$} {:<24} {:<action_w$} {:<actor_w$} {:<7}",
233 "ID",
234 "CREATED_AT",
235 "ACTION",
236 "ACTOR_DID",
237 "OUTCOME",
238 id_w = id_w,
239 action_w = action_w,
240 actor_w = actor_w,
241 );
242 for e in &resp.entries {
243 let action = truncate(&e.action, 28);
244 let actor = truncate(&e.actor_did, 40);
245 let _ = writeln!(
246 s,
247 "{:>id_w$} {:<24} {:<action_w$} {:<actor_w$} {:<7}",
248 e.id,
249 e.created_at,
250 action,
251 actor,
252 e.outcome,
253 id_w = id_w,
254 action_w = action_w,
255 actor_w = actor_w,
256 );
257 }
258 if let Some(c) = &resp.cursor {
259 let _ = write!(s, "next cursor: {c}");
260 } else if s.ends_with('\n') {
261 s.pop();
262 }
263 s
264}
265
266pub fn format_list_json(resp: &AuditListResponse) -> String {
268 serde_json::to_string_pretty(resp).expect("AuditListResponse serializes")
269}
270
271#[derive(Debug, Clone)]
279pub struct AuditShowInput {
280 pub id: i64,
282 pub cairn_server_override: Option<String>,
284}
285
286pub async fn show(
296 session: &mut SessionFile,
297 session_path: &Path,
298 input: AuditShowInput,
299) -> Result<AuditEntry, CliError> {
300 let cairn_server = input
301 .cairn_server_override
302 .as_deref()
303 .unwrap_or(&session.cairn_server_url)
304 .trim_end_matches('/')
305 .to_string();
306 let pds = PdsClient::new(&session.pds_url)?;
307 let token = acquire_service_auth(&pds, session, session_path, GET_AUDIT_LOG_LXM).await?;
308
309 let url = format!("{cairn_server}/xrpc/{GET_AUDIT_LOG_LXM}");
310 let id_str = input.id.to_string();
311 let client = Client::builder()
312 .timeout(Duration::from_secs(30))
313 .build()
314 .expect("reqwest build");
315 let resp = client
316 .get(&url)
317 .bearer_auth(&token)
318 .query(&[("id", id_str.as_str())])
319 .send()
320 .await
321 .map_err(|source| CliError::Http {
322 url: url.clone(),
323 source,
324 })?;
325 if !resp.status().is_success() {
326 let status = resp.status().as_u16();
327 let body = resp.text().await.unwrap_or_default();
328 return Err(CliError::CairnStatus { url, status, body });
329 }
330 let bytes = resp.bytes().await.map_err(|source| CliError::Http {
331 url: url.clone(),
332 source,
333 })?;
334 serde_json::from_slice::<AuditEntry>(&bytes)
335 .map_err(|source| CliError::MalformedResponse { url, source })
336}
337
338pub fn format_show_human(entry: &AuditEntry) -> String {
350 use std::fmt::Write;
351 let mut s = String::new();
352 let _ = writeln!(s, "Audit entry {}", entry.id);
353 let _ = writeln!(s, " created_at: {}", entry.created_at);
354 let _ = writeln!(s, " action: {}", entry.action);
355 let _ = writeln!(s, " actor_did: {}", entry.actor_did);
356 let _ = writeln!(s, " outcome: {}", entry.outcome);
357 if let Some(t) = &entry.target {
358 let _ = writeln!(s, " target: {t}");
359 }
360 if let Some(c) = &entry.target_cid {
361 let _ = writeln!(s, " target_cid: {c}");
362 }
363 if let Some(r) = &entry.reason {
364 let _ = writeln!(s, " reason: {r}");
365 }
366 let _ = writeln!(
367 s,
368 " prev_hash: {}",
369 entry
370 .prev_hash
371 .as_deref()
372 .unwrap_or(PRE_ATTESTATION_DISPLAY)
373 );
374 let _ = write!(
375 s,
376 " row_hash: {}",
377 entry.row_hash.as_deref().unwrap_or(PRE_ATTESTATION_DISPLAY)
378 );
379 s
380}
381
382const PRE_ATTESTATION_DISPLAY: &str = "(pre-attestation)";
388
389pub fn format_show_json(entry: &AuditEntry) -> String {
392 serde_json::to_string_pretty(entry).expect("AuditEntry serializes")
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn sample(reason: Option<&str>) -> AuditEntry {
400 AuditEntry {
401 id: 42,
402 created_at: "2026-04-23T00:00:00.000Z".into(),
403 action: "label_applied".into(),
404 actor_did: "did:plc:moderator0000000000000000".into(),
405 target: Some("at://did:plc:target/col/rec".into()),
406 target_cid: Some("bafytest".into()),
407 outcome: "success".into(),
408 reason: reason.map(str::to_string),
409 prev_hash: Some("a".repeat(64)),
411 row_hash: Some("b".repeat(64)),
412 }
413 }
414
415 #[test]
416 fn format_show_human_includes_all_present_fields() {
417 let s = format_show_human(&sample(Some(r#"{"val":"spam"}"#)));
418 assert!(s.contains("Audit entry 42"));
419 assert!(s.contains("created_at: 2026-04-23T00:00:00.000Z"));
420 assert!(s.contains("action: label_applied"));
421 assert!(s.contains("actor_did: did:plc:moderator0000000000000000"));
422 assert!(s.contains("outcome: success"));
423 assert!(s.contains("target: at://did:plc:target/col/rec"));
424 assert!(s.contains("target_cid: bafytest"));
425 assert!(s.contains(r#"reason: {"val":"spam"}"#));
426 assert!(s.contains(&format!("prev_hash: {}", "a".repeat(64))));
427 assert!(s.contains(&format!("row_hash: {}", "b".repeat(64))));
428 }
429
430 #[test]
431 fn format_show_human_omits_absent_optionals() {
432 let mut e = sample(None);
433 e.target = None;
434 e.target_cid = None;
435 let s = format_show_human(&e);
436 assert!(!s.contains("target:"));
437 assert!(!s.contains("target_cid:"));
438 assert!(!s.contains("reason:"));
439 }
440
441 #[test]
442 fn format_show_human_pre_attestation_row_renders_sentinel() {
443 let mut e = sample(None);
448 e.prev_hash = None;
449 e.row_hash = None;
450 let s = format_show_human(&e);
451 assert!(
452 s.contains("prev_hash: (pre-attestation)"),
453 "missing pre-attestation sentinel for prev_hash: {s}"
454 );
455 assert!(
456 s.contains("row_hash: (pre-attestation)"),
457 "missing pre-attestation sentinel for row_hash: {s}"
458 );
459 }
460
461 #[test]
462 fn format_show_human_genesis_row_renders_zero_sentinel_verbatim() {
463 let mut e = sample(None);
468 e.id = 1;
469 e.prev_hash = Some("0".repeat(64));
470 e.row_hash = Some("c".repeat(64));
471 let s = format_show_human(&e);
472 assert!(s.contains("Audit entry 1"));
473 assert!(s.contains(&format!("prev_hash: {}", "0".repeat(64))));
474 assert!(s.contains(&format!("row_hash: {}", "c".repeat(64))));
475 assert!(
476 !s.contains("genesis"),
477 "genesis row must not be special-cased in display: {s}"
478 );
479 }
480
481 #[test]
482 fn format_show_json_round_trips_with_hashes() {
483 let e = sample(Some(r#"{"val":"spam","neg":false}"#));
484 let json = format_show_json(&e);
485 let parsed: AuditEntry = serde_json::from_str(&json).expect("round trip");
486 assert_eq!(parsed.id, 42);
487 assert_eq!(parsed.action, "label_applied");
488 assert_eq!(
489 parsed.target.as_deref(),
490 Some("at://did:plc:target/col/rec")
491 );
492 assert_eq!(
493 parsed.reason.as_deref(),
494 Some(r#"{"val":"spam","neg":false}"#)
495 );
496 assert_eq!(parsed.prev_hash.as_deref(), Some("a".repeat(64).as_str()));
497 assert_eq!(parsed.row_hash.as_deref(), Some("b".repeat(64).as_str()));
498 assert!(
500 json.contains("\"prevHash\""),
501 "wire JSON must use camelCase prevHash: {json}"
502 );
503 assert!(
504 json.contains("\"rowHash\""),
505 "wire JSON must use camelCase rowHash: {json}"
506 );
507 }
508
509 #[test]
510 fn format_show_json_pre_attestation_row_omits_hash_fields() {
511 let mut e = sample(None);
512 e.prev_hash = None;
513 e.row_hash = None;
514 let json = format_show_json(&e);
515 assert!(
516 !json.contains("prevHash"),
517 "pre-attestation: prevHash must be field-absent: {json}"
518 );
519 assert!(
520 !json.contains("rowHash"),
521 "pre-attestation: rowHash must be field-absent: {json}"
522 );
523 }
524}