1mod cli;
2mod completion;
3
4use std::env;
5use std::ffi::OsString;
6use std::fs;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use clap::Parser;
12use clap::error::ErrorKind;
13use reqwest::Url;
14use reqwest::blocking::Client;
15use reqwest::header::{ACCEPT, HeaderMap, USER_AGENT};
16use serde::Serialize;
17use serde_json::{Value, json};
18
19use cli::{CaptureArgs, Cli, Command, HttpMethod, OutputFormat};
20use nils_common::cli_contract::exit;
21use nils_common::fs::{display_path, normalize_path as normalize_absolute_path};
22use nils_common::redact::{RedactedString, redact_text};
23
24const EXIT_OK: i32 = exit::SUCCESS;
25const EXIT_RUNTIME: i32 = exit::RUNTIME;
26const EXIT_USAGE: i32 = exit::USAGE;
27
28const CAPTURE_SCHEMA_VERSION: &str = "cli.web-evidence.capture.v1";
29const SUMMARY_SCHEMA_VERSION: &str = "web-evidence.summary.v1";
30const CAPTURE_COMMAND: &str = "web-evidence capture";
31
32const SUMMARY_FILE: &str = "summary.json";
33const HEADERS_FILE: &str = "headers.redacted.json";
34const BODY_PREVIEW_FILE: &str = "body-preview.redacted.txt";
35
36pub fn run() -> i32 {
37 run_with_args(env::args_os())
38}
39
40pub fn run_with_args<I, T>(args: I) -> i32
41where
42 I: IntoIterator<Item = T>,
43 T: Into<OsString> + Clone,
44{
45 let cli = match Cli::try_parse_from(args) {
46 Ok(cli) => cli,
47 Err(err) => {
48 let code = match err.kind() {
49 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => err.exit_code(),
50 _ => EXIT_USAGE,
51 };
52 let _ = err.print();
53 return code;
54 }
55 };
56
57 dispatch(cli)
58}
59
60fn dispatch(cli: Cli) -> i32 {
61 match cli.command {
62 Command::Capture(args) => run_capture(args),
63 Command::Completion(args) => completion::run(args.shell),
64 }
65}
66
67fn run_capture(args: CaptureArgs) -> i32 {
68 let format = args.format;
69 match capture(&args) {
70 Ok(outcome) => match outcome.error {
71 None => render_capture_success(format, &outcome.result),
72 Some(err) => render_capture_error(format, err),
73 },
74 Err(err) => render_capture_error(format, err),
75 }
76}
77
78fn capture(args: &CaptureArgs) -> Result<CaptureOutcome, CliError> {
79 let request = prepare_request(args)?;
80 let out_dir = prepare_artifact_dir(&request.out_dir)?;
81 let client = build_client(request.timeout_seconds)?;
82
83 let response = match client
84 .request(request.method.reqwest_method(), request.url.clone())
85 .header(ACCEPT, "*/*")
86 .send()
87 {
88 Ok(response) => response,
89 Err(err) => {
90 let code = classify_request_error(&err);
91 let message = format!(
92 "{} failed for {}",
93 request.method.as_str(),
94 request.safe_url
95 );
96 let artifacts = write_failure_summary(
97 &out_dir,
98 &request,
99 CliErrorView {
100 code,
101 message: &message,
102 details: Some(json!({
103 "url": request.safe_url,
104 "method": request.method.as_str(),
105 })),
106 },
107 )?;
108 let details = json!({
109 "url": request.safe_url,
110 "method": request.method.as_str(),
111 "artifact_dir": display_path(&out_dir),
112 "artifacts": artifacts,
113 });
114 return Ok(CaptureOutcome {
115 result: None,
116 error: Some(CliError::runtime(code, message, Some(details))),
117 });
118 }
119 };
120
121 let status_code = response.status().as_u16();
122 let status_class = status_class(status_code).to_string();
123 let final_url_redacted = redact_url(response.url()).value;
124 let response_headers = redact_headers(response.headers());
125 let content_type = header_value(response.headers(), "content-type");
126 let content_length_header = header_value(response.headers(), "content-length");
127 let mut reader = response.take((request.max_body_bytes as u64).saturating_add(1));
128 let mut body = Vec::new();
129 reader.read_to_end(&mut body).map_err(|err| {
130 CliError::runtime(
131 "body-read-failed",
132 format!(
133 "failed to read response body for {}: {err}",
134 request.safe_url
135 ),
136 Some(json!({ "url": request.safe_url })),
137 )
138 })?;
139 let body_truncated = body.len() > request.max_body_bytes;
140 if body_truncated {
141 body.truncate(request.max_body_bytes);
142 }
143
144 let request_headers = request_headers();
145 let body_artifact = body_preview_artifact(&out_dir, &body, content_type.as_deref(), &request)?;
146 let header_artifact = write_headers_artifact(
147 &out_dir,
148 HeaderArtifact {
149 schema_version: "web-evidence.headers.v1",
150 request: RequestHeaderSummary {
151 method: request.method.as_str(),
152 url: &request.safe_url,
153 headers: request_headers.entries.clone(),
154 },
155 response: ResponseHeaderSummary {
156 status_code,
157 final_url: &final_url_redacted,
158 headers: response_headers.entries.clone(),
159 },
160 },
161 )?;
162
163 let mut artifacts = vec![
164 summary_artifact_ref(&out_dir),
165 header_artifact,
166 body_artifact.ref_,
167 ];
168 artifacts.sort_by(|left, right| left.name.cmp(&right.name));
169
170 let redaction = RedactionReport {
171 query_values_redacted: request.url_redactions,
172 request_header_values_redacted: request_headers.redacted_values,
173 response_header_values_redacted: response_headers.redacted_values,
174 body_replacements: body_artifact.redaction_replacements,
175 };
176
177 let result = CaptureResult {
178 artifact_dir: display_path(&out_dir),
179 label: request.label.clone(),
180 method: request.method.as_str().to_string(),
181 requested_url: request.safe_url.clone(),
182 final_url: final_url_redacted,
183 status_code,
184 status_class: status_class.clone(),
185 content_type,
186 content_length_header,
187 body_bytes_captured: body.len(),
188 body_truncated,
189 artifacts,
190 redaction,
191 };
192
193 let error = if status_code >= 400 {
194 Some(CliError::runtime(
195 "http-status-error",
196 format!("HTTP status {status_code} for {}", request.safe_url),
197 Some(json!({
198 "url": request.safe_url,
199 "status_code": status_code,
200 "status_class": status_class,
201 "artifact_dir": result.artifact_dir,
202 "artifacts": result.artifacts,
203 })),
204 ))
205 } else {
206 None
207 };
208
209 write_result_summary(&out_dir, &result, error.as_ref())?;
210
211 Ok(CaptureOutcome {
212 result: Some(result),
213 error,
214 })
215}
216
217fn prepare_request(args: &CaptureArgs) -> Result<PreparedRequest, CliError> {
218 if args.timeout_seconds == 0 {
219 return Err(CliError::usage(
220 "invalid-timeout",
221 "--timeout-seconds must be greater than 0",
222 Some(json!({ "flag": "--timeout-seconds" })),
223 ));
224 }
225 if args.max_body_bytes == 0 {
226 return Err(CliError::usage(
227 "invalid-max-body-bytes",
228 "--max-body-bytes must be greater than 0",
229 Some(json!({ "flag": "--max-body-bytes" })),
230 ));
231 }
232 if args.body_preview_bytes == 0 {
233 return Err(CliError::usage(
234 "invalid-body-preview-bytes",
235 "--body-preview-bytes must be greater than 0",
236 Some(json!({ "flag": "--body-preview-bytes" })),
237 ));
238 }
239
240 let url = Url::parse(&args.url).map_err(|err| {
241 CliError::usage(
242 "invalid-url",
243 format!("invalid URL: {err}"),
244 Some(json!({ "url": redact_text(&args.url).value })),
245 )
246 })?;
247 match url.scheme() {
248 "http" | "https" => {}
249 scheme => {
250 return Err(CliError::usage(
251 "unsupported-url-scheme",
252 format!("unsupported URL scheme: {scheme}"),
253 Some(json!({ "scheme": scheme })),
254 ));
255 }
256 }
257
258 let redacted_url = redact_url(&url);
259 let label = args
260 .label
261 .as_ref()
262 .map(|value| redact_text(value).value)
263 .filter(|value| !value.trim().is_empty());
264
265 Ok(PreparedRequest {
266 url,
267 safe_url: redacted_url.value,
268 url_redactions: redacted_url.replacements,
269 out_dir: absolute_path(&args.out_dir)?,
270 label,
271 method: args.method,
272 timeout_seconds: args.timeout_seconds,
273 max_body_bytes: args.max_body_bytes,
274 body_preview_bytes: args.body_preview_bytes,
275 })
276}
277
278fn prepare_artifact_dir(out_dir: &Path) -> Result<PathBuf, CliError> {
279 fs::create_dir_all(out_dir).map_err(|err| {
280 CliError::runtime(
281 "artifact-dir-create-failed",
282 format!(
283 "failed to create artifact directory {}: {err}",
284 out_dir.display()
285 ),
286 Some(json!({ "artifact_dir": display_path(out_dir) })),
287 )
288 })?;
289 Ok(out_dir.to_path_buf())
290}
291
292fn build_client(timeout_seconds: u64) -> Result<Client, CliError> {
293 Client::builder()
294 .connect_timeout(Duration::from_secs(timeout_seconds.min(10)))
295 .timeout(Duration::from_secs(timeout_seconds))
296 .user_agent(format!("web-evidence/{}", env!("CARGO_PKG_VERSION")))
297 .redirect(reqwest::redirect::Policy::limited(10))
298 .build()
299 .map_err(|err| {
300 CliError::runtime(
301 "http-client-build-failed",
302 format!("failed to build HTTP client: {err}"),
303 None,
304 )
305 })
306}
307
308fn write_headers_artifact(
309 out_dir: &Path,
310 artifact: HeaderArtifact<'_>,
311) -> Result<ArtifactRef, CliError> {
312 let path = out_dir.join(HEADERS_FILE);
313 write_json_file(&path, &artifact)?;
314 Ok(ArtifactRef::new(HEADERS_FILE, &path, "headers", true))
315}
316
317fn body_preview_artifact(
318 out_dir: &Path,
319 body: &[u8],
320 content_type: Option<&str>,
321 request: &PreparedRequest,
322) -> Result<BodyArtifact, CliError> {
323 let path = out_dir.join(BODY_PREVIEW_FILE);
324 let (preview, replacements) = if request.method == HttpMethod::Head {
325 ("HEAD request; no response body captured.\n".to_string(), 0)
326 } else if body.is_empty() {
327 ("Response body was empty.\n".to_string(), 0)
328 } else if is_text_like(content_type) {
329 let preview_len = request.body_preview_bytes.min(body.len());
330 let raw_preview = String::from_utf8_lossy(&body[..preview_len]).to_string();
331 let mut redacted = redact_text(&raw_preview);
332 if body.len() > preview_len {
333 redacted
334 .value
335 .push_str("\n[body preview truncated before redaction]\n");
336 }
337 (redacted.value, redacted.replacements)
338 } else {
339 (
340 format!(
341 "Non-text response body omitted. content_type={}\n",
342 content_type.unwrap_or("unknown")
343 ),
344 0,
345 )
346 };
347
348 fs::write(&path, preview).map_err(|err| {
349 CliError::runtime(
350 "artifact-write-failed",
351 format!("failed to write {}: {err}", path.display()),
352 Some(json!({ "path": display_path(&path) })),
353 )
354 })?;
355
356 Ok(BodyArtifact {
357 ref_: ArtifactRef::new(BODY_PREVIEW_FILE, &path, "body-preview", true),
358 redaction_replacements: replacements,
359 })
360}
361
362fn write_result_summary(
363 out_dir: &Path,
364 result: &CaptureResult,
365 error: Option<&CliError>,
366) -> Result<(), CliError> {
367 let summary = SummaryDocument {
368 schema_version: SUMMARY_SCHEMA_VERSION,
369 command: CAPTURE_COMMAND,
370 ok: error.is_none(),
371 captured_at_unix_seconds: unix_seconds_now(),
372 result: Some(result),
373 error: error.map(|err| SummaryError {
374 code: err.code,
375 message: err.message.clone(),
376 details: err.details.clone(),
377 }),
378 redaction_policy: RedactionPolicy::default(),
379 };
380 write_json_file(&out_dir.join(SUMMARY_FILE), &summary)
381}
382
383fn write_failure_summary(
384 out_dir: &Path,
385 request: &PreparedRequest,
386 error: CliErrorView<'_>,
387) -> Result<Vec<ArtifactRef>, CliError> {
388 let summary_error = SummaryError {
389 code: error.code,
390 message: error.message.to_string(),
391 details: error.details,
392 };
393 let summary = SummaryDocument::<CaptureResult> {
394 schema_version: SUMMARY_SCHEMA_VERSION,
395 command: CAPTURE_COMMAND,
396 ok: false,
397 captured_at_unix_seconds: unix_seconds_now(),
398 result: None,
399 error: Some(summary_error),
400 redaction_policy: RedactionPolicy::default(),
401 };
402 write_json_file(&out_dir.join(SUMMARY_FILE), &summary)?;
403
404 let request_headers = request_headers();
405 let headers = HeaderArtifact {
406 schema_version: "web-evidence.headers.v1",
407 request: RequestHeaderSummary {
408 method: request.method.as_str(),
409 url: &request.safe_url,
410 headers: request_headers.entries,
411 },
412 response: ResponseHeaderSummary {
413 status_code: 0,
414 final_url: &request.safe_url,
415 headers: Vec::new(),
416 },
417 };
418 write_headers_artifact(out_dir, headers)?;
419
420 let body_path = out_dir.join(BODY_PREVIEW_FILE);
421 fs::write(&body_path, "No response body captured.\n").map_err(|err| {
422 CliError::runtime(
423 "artifact-write-failed",
424 format!("failed to write {}: {err}", body_path.display()),
425 Some(json!({ "path": display_path(&body_path) })),
426 )
427 })?;
428
429 let mut artifacts = vec![
430 summary_artifact_ref(out_dir),
431 ArtifactRef::new(HEADERS_FILE, &out_dir.join(HEADERS_FILE), "headers", true),
432 ArtifactRef::new(BODY_PREVIEW_FILE, &body_path, "body-preview", true),
433 ];
434 artifacts.sort_by(|left, right| left.name.cmp(&right.name));
435 Ok(artifacts)
436}
437
438fn write_json_file<T: Serialize>(path: &Path, value: &T) -> Result<(), CliError> {
439 let mut contents = serde_json::to_string_pretty(value).map_err(|err| {
440 CliError::runtime(
441 "json-render-failed",
442 format!("failed to render JSON for {}: {err}", path.display()),
443 Some(json!({ "path": display_path(path) })),
444 )
445 })?;
446 contents.push('\n');
447 fs::write(path, contents).map_err(|err| {
448 CliError::runtime(
449 "artifact-write-failed",
450 format!("failed to write {}: {err}", path.display()),
451 Some(json!({ "path": display_path(path) })),
452 )
453 })
454}
455
456fn render_capture_success(format: OutputFormat, result: &Option<CaptureResult>) -> i32 {
457 let result = result
458 .as_ref()
459 .expect("successful capture should include result");
460 match format {
461 OutputFormat::Json => print_json_success(CAPTURE_SCHEMA_VERSION, CAPTURE_COMMAND, result)
462 .unwrap_or_else(render_json_failure),
463 OutputFormat::Text => {
464 println!("web evidence captured: {}", result.artifact_dir);
465 if let Some(label) = result.label.as_deref() {
466 println!("label: {label}");
467 }
468 println!("status: {} {}", result.status_code, result.status_class);
469 println!("url: {}", result.final_url);
470 println!("artifacts:");
471 for artifact in &result.artifacts {
472 println!(" - {} ({})", artifact.name, artifact.kind);
473 }
474 EXIT_OK
475 }
476 }
477}
478
479fn render_capture_error(format: OutputFormat, err: CliError) -> i32 {
480 if format == OutputFormat::Json {
481 return print_json_error(
482 CAPTURE_SCHEMA_VERSION,
483 CAPTURE_COMMAND,
484 err.code,
485 &err.message,
486 err.details,
487 err.exit_code,
488 )
489 .unwrap_or_else(render_json_failure);
490 }
491
492 eprintln!("web-evidence: error: {}", err.message);
493 if let Some(details) = err.details
494 && let Some(artifact_dir) = details.get("artifact_dir").and_then(Value::as_str)
495 {
496 eprintln!("artifact dir: {artifact_dir}");
497 }
498 err.exit_code
499}
500
501fn print_json_success<T: Serialize>(
502 schema_version: &'static str,
503 command: &'static str,
504 result: &T,
505) -> Result<i32, serde_json::Error> {
506 let envelope = SuccessEnvelope {
507 schema_version,
508 command,
509 ok: true,
510 result,
511 };
512 println!("{}", serde_json::to_string_pretty(&envelope)?);
513 Ok(EXIT_OK)
514}
515
516fn print_json_error(
517 schema_version: &'static str,
518 command: &'static str,
519 code: &'static str,
520 message: &str,
521 details: Option<Value>,
522 exit_code: i32,
523) -> Result<i32, serde_json::Error> {
524 let envelope = ErrorEnvelope {
525 schema_version,
526 command,
527 ok: false,
528 error: ErrorBody {
529 code,
530 message,
531 details,
532 },
533 };
534 println!("{}", serde_json::to_string_pretty(&envelope)?);
535 Ok(exit_code)
536}
537
538fn render_json_failure(err: serde_json::Error) -> i32 {
539 eprintln!("web-evidence: error: failed to render json: {err}");
540 EXIT_RUNTIME
541}
542
543fn request_headers() -> RedactedHeaders {
544 let mut headers = HeaderMap::new();
545 headers.insert(ACCEPT, "*/*".parse().expect("static header"));
546 headers.insert(
547 USER_AGENT,
548 format!("web-evidence/{}", env!("CARGO_PKG_VERSION"))
549 .parse()
550 .expect("static user-agent"),
551 );
552 redact_headers(&headers)
553}
554
555fn redact_headers(headers: &HeaderMap) -> RedactedHeaders {
556 let mut entries = Vec::new();
557 let mut redacted_values = 0usize;
558
559 for (name, value) in headers {
560 let name_string = name.as_str().to_ascii_lowercase();
561 let raw_value = value
562 .to_str()
563 .map(str::to_string)
564 .unwrap_or_else(|_| "[NON_UTF8]".to_string());
565 let sensitive = is_sensitive_header(&name_string);
566 let (value, replacements) = if sensitive {
567 ("[REDACTED]".to_string(), 1)
568 } else {
569 let redacted = redact_text(&raw_value);
570 (redacted.value, redacted.replacements)
571 };
572 redacted_values += replacements;
573 entries.push(HeaderEntry {
574 name: name_string,
575 value,
576 redacted: sensitive || replacements > 0,
577 });
578 }
579
580 entries.sort_by(|left, right| {
581 left.name
582 .cmp(&right.name)
583 .then_with(|| left.value.cmp(&right.value))
584 });
585
586 RedactedHeaders {
587 entries,
588 redacted_values,
589 }
590}
591
592fn is_sensitive_header(name: &str) -> bool {
593 matches!(
594 name,
595 "authorization"
596 | "cookie"
597 | "proxy-authorization"
598 | "set-cookie"
599 | "x-api-key"
600 | "x-auth-token"
601 | "x-csrf-token"
602 )
603}
604
605fn redact_url(url: &Url) -> RedactedString {
606 let mut safe = url.clone();
607 let mut replacements = 0usize;
608
609 if !safe.username().is_empty() && safe.set_username("[REDACTED]").is_ok() {
610 replacements += 1;
611 }
612 if safe.password().is_some() && safe.set_password(Some("[REDACTED]")).is_ok() {
613 replacements += 1;
614 }
615
616 let pairs: Vec<(String, String)> = safe
617 .query_pairs()
618 .map(|(key, value)| {
619 if is_sensitive_key(&key) {
620 replacements += 1;
621 (key.to_string(), "[REDACTED]".to_string())
622 } else {
623 let redacted = redact_text(&value);
624 replacements += redacted.replacements;
625 (key.to_string(), redacted.value)
626 }
627 })
628 .collect();
629
630 if !pairs.is_empty() {
631 safe.set_query(None);
632 {
633 let mut query = safe.query_pairs_mut();
634 for (key, value) in pairs {
635 query.append_pair(&key, &value);
636 }
637 }
638 }
639
640 RedactedString {
641 value: safe.to_string(),
642 replacements,
643 }
644}
645
646fn is_sensitive_key(key: &str) -> bool {
647 let lower = key.to_ascii_lowercase();
648 lower.contains("token")
649 || lower.contains("secret")
650 || lower.contains("password")
651 || lower.contains("api_key")
652 || lower.contains("api-key")
653 || lower.contains("apikey")
654 || lower.contains("authorization")
655 || lower.contains("auth")
656 || lower.contains("cookie")
657 || lower.contains("session")
658 || lower == "key"
659 || lower == "sig"
660 || lower == "signature"
661}
662
663fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
664 headers
665 .get(key)
666 .and_then(|value| value.to_str().ok())
667 .map(|value| redact_text(value).value)
668}
669
670fn is_text_like(content_type: Option<&str>) -> bool {
671 let Some(content_type) = content_type else {
672 return true;
673 };
674 let content_type = content_type.to_ascii_lowercase();
675 content_type.starts_with("text/")
676 || content_type.contains("json")
677 || content_type.contains("xml")
678 || content_type.contains("html")
679 || content_type.contains("javascript")
680 || content_type.contains("x-www-form-urlencoded")
681 || content_type.contains("svg")
682}
683
684fn classify_request_error(err: &reqwest::Error) -> &'static str {
685 let message = err.to_string().to_ascii_lowercase();
686 if err.is_timeout() || message.contains("timed out") || message.contains("timeout") {
687 "request-timeout"
688 } else if err.is_connect()
689 || message.contains("connection refused")
690 || message.contains("dns")
691 || message.contains("connect")
692 {
693 "network-connect-failed"
694 } else if err.is_redirect() || message.contains("redirect") {
695 "redirect-error"
696 } else if err.is_body() {
697 "body-read-failed"
698 } else {
699 "request-failed"
700 }
701}
702
703fn status_class(status: u16) -> &'static str {
704 match status {
705 100..=199 => "informational",
706 200..=299 => "success",
707 300..=399 => "redirect",
708 400..=499 => "client-error",
709 500..=599 => "server-error",
710 _ => "unknown",
711 }
712}
713
714fn summary_artifact_ref(out_dir: &Path) -> ArtifactRef {
715 ArtifactRef::new(SUMMARY_FILE, &out_dir.join(SUMMARY_FILE), "summary", true)
716}
717
718fn absolute_path(path: &Path) -> Result<PathBuf, CliError> {
719 if path.is_absolute() {
720 return Ok(normalize_absolute_path(path));
721 }
722
723 let current_dir = env::current_dir().map_err(|err| {
724 CliError::runtime(
725 "cwd-unavailable",
726 format!("failed to read current directory: {err}"),
727 None,
728 )
729 })?;
730 Ok(normalize_absolute_path(¤t_dir.join(path)))
731}
732
733fn unix_seconds_now() -> u64 {
734 SystemTime::now()
735 .duration_since(UNIX_EPOCH)
736 .unwrap_or_default()
737 .as_secs()
738}
739
740#[derive(Debug)]
741struct PreparedRequest {
742 url: Url,
743 safe_url: String,
744 url_redactions: usize,
745 out_dir: PathBuf,
746 label: Option<String>,
747 method: HttpMethod,
748 timeout_seconds: u64,
749 max_body_bytes: usize,
750 body_preview_bytes: usize,
751}
752
753#[derive(Debug)]
754struct CaptureOutcome {
755 result: Option<CaptureResult>,
756 error: Option<CliError>,
757}
758
759#[derive(Debug)]
760struct BodyArtifact {
761 ref_: ArtifactRef,
762 redaction_replacements: usize,
763}
764
765#[derive(Debug)]
766struct RedactedHeaders {
767 entries: Vec<HeaderEntry>,
768 redacted_values: usize,
769}
770
771#[derive(Debug, Serialize)]
772struct CaptureResult {
773 artifact_dir: String,
774 #[serde(skip_serializing_if = "Option::is_none")]
775 label: Option<String>,
776 method: String,
777 requested_url: String,
778 final_url: String,
779 status_code: u16,
780 status_class: String,
781 #[serde(skip_serializing_if = "Option::is_none")]
782 content_type: Option<String>,
783 #[serde(skip_serializing_if = "Option::is_none")]
784 content_length_header: Option<String>,
785 body_bytes_captured: usize,
786 body_truncated: bool,
787 artifacts: Vec<ArtifactRef>,
788 redaction: RedactionReport,
789}
790
791#[derive(Clone, Debug, Serialize)]
792struct ArtifactRef {
793 name: String,
794 path: String,
795 kind: String,
796 redacted: bool,
797}
798
799impl ArtifactRef {
800 fn new(name: &str, path: &Path, kind: &str, redacted: bool) -> Self {
801 Self {
802 name: name.to_string(),
803 path: display_path(path),
804 kind: kind.to_string(),
805 redacted,
806 }
807 }
808}
809
810#[derive(Clone, Debug, Serialize)]
811struct HeaderEntry {
812 name: String,
813 value: String,
814 redacted: bool,
815}
816
817#[derive(Debug, Serialize)]
818struct RedactionReport {
819 query_values_redacted: usize,
820 request_header_values_redacted: usize,
821 response_header_values_redacted: usize,
822 body_replacements: usize,
823}
824
825#[derive(Debug, Serialize)]
826struct HeaderArtifact<'a> {
827 schema_version: &'static str,
828 request: RequestHeaderSummary<'a>,
829 response: ResponseHeaderSummary<'a>,
830}
831
832#[derive(Debug, Serialize)]
833struct RequestHeaderSummary<'a> {
834 method: &'static str,
835 url: &'a str,
836 headers: Vec<HeaderEntry>,
837}
838
839#[derive(Debug, Serialize)]
840struct ResponseHeaderSummary<'a> {
841 status_code: u16,
842 final_url: &'a str,
843 headers: Vec<HeaderEntry>,
844}
845
846#[derive(Debug, Serialize)]
847struct SummaryDocument<'a, T: Serialize> {
848 schema_version: &'static str,
849 command: &'static str,
850 ok: bool,
851 captured_at_unix_seconds: u64,
852 #[serde(skip_serializing_if = "Option::is_none")]
853 result: Option<&'a T>,
854 #[serde(skip_serializing_if = "Option::is_none")]
855 error: Option<SummaryError>,
856 redaction_policy: RedactionPolicy,
857}
858
859#[derive(Debug, Serialize)]
860struct SummaryError {
861 code: &'static str,
862 message: String,
863 #[serde(skip_serializing_if = "Option::is_none")]
864 details: Option<Value>,
865}
866
867#[derive(Debug, Serialize)]
868struct RedactionPolicy {
869 url: &'static str,
870 headers: &'static str,
871 body_preview: &'static str,
872 raw_cookies_or_auth_headers_persisted: bool,
873 raw_network_logs_persisted: bool,
874}
875
876impl Default for RedactionPolicy {
877 fn default() -> Self {
878 Self {
879 url: "userinfo and sensitive query values are redacted",
880 headers: "authorization, cookie, set-cookie, proxy authorization, and token-like headers are redacted",
881 body_preview: "text previews are truncated and secret-like assignments/tokens are redacted; binary bodies are omitted",
882 raw_cookies_or_auth_headers_persisted: false,
883 raw_network_logs_persisted: false,
884 }
885 }
886}
887
888#[derive(Debug)]
889struct CliError {
890 code: &'static str,
891 message: String,
892 details: Option<Value>,
893 exit_code: i32,
894}
895
896impl CliError {
897 fn usage(code: &'static str, message: impl Into<String>, details: Option<Value>) -> Self {
898 Self {
899 code,
900 message: message.into(),
901 details,
902 exit_code: EXIT_USAGE,
903 }
904 }
905
906 fn runtime(code: &'static str, message: impl Into<String>, details: Option<Value>) -> Self {
907 Self {
908 code,
909 message: message.into(),
910 details,
911 exit_code: EXIT_RUNTIME,
912 }
913 }
914}
915
916struct CliErrorView<'a> {
917 code: &'static str,
918 message: &'a str,
919 details: Option<Value>,
920}
921
922#[derive(Serialize)]
923struct SuccessEnvelope<'a, T: Serialize> {
924 schema_version: &'static str,
925 command: &'static str,
926 ok: bool,
927 result: &'a T,
928}
929
930#[derive(Serialize)]
931struct ErrorEnvelope<'a> {
932 schema_version: &'static str,
933 command: &'static str,
934 ok: bool,
935 error: ErrorBody<'a>,
936}
937
938#[derive(Serialize)]
939struct ErrorBody<'a> {
940 code: &'static str,
941 message: &'a str,
942 #[serde(skip_serializing_if = "Option::is_none")]
943 details: Option<Value>,
944}
945
946#[cfg(test)]
947mod tests {
948 use super::{is_sensitive_key, redact_text, redact_url, status_class};
949 use reqwest::Url;
950
951 #[test]
952 fn redacts_sensitive_query_values_and_userinfo() {
953 let url = Url::parse("https://user:pass@example.test/path?token=abc&ok=1").unwrap();
954 let redacted = redact_url(&url);
955
956 assert_eq!(
957 redacted.value,
958 "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?token=%5BREDACTED%5D&ok=1"
959 );
960 assert_eq!(redacted.replacements, 3);
961 }
962
963 #[test]
964 fn redacts_secret_like_text() {
965 let redacted = redact_text("access_token=abc123 secret: sk-proj-abcdefghi");
966
967 assert!(redacted.value.contains("access_token=[REDACTED]"));
968 assert!(!redacted.value.contains("abc123"));
969 assert!(!redacted.value.contains("sk-proj-abcdefghi"));
970 assert!(redacted.replacements >= 2);
971 }
972
973 #[test]
974 fn sensitive_key_matching_covers_common_auth_names() {
975 assert!(is_sensitive_key("access_token"));
976 assert!(is_sensitive_key("X-API-Key"));
977 assert!(is_sensitive_key("signature"));
978 assert!(!is_sensitive_key("page"));
979 }
980
981 #[test]
982 fn status_class_is_stable() {
983 assert_eq!(status_class(200), "success");
984 assert_eq!(status_class(302), "redirect");
985 assert_eq!(status_class(404), "client-error");
986 assert_eq!(status_class(500), "server-error");
987 }
988}