Skip to main content

web_evidence/
lib.rs

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(&current_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}