Skip to main content

agent_first_http/sdk/fetch/artifacts/
network.rs

1//! Deep network capture (`network.json`). Aggregated from CDP `Network.*`
2//! events. Headers and credential-bearing URL components are redacted by
3//! default per [`crate::shared::redact`].
4//! This module defines the on-wire schema and the redaction helper used by tests.
5
6use std::collections::BTreeMap;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use crate::sdk::fetch::writer;
12use crate::shared::artifacts::{Artifact, ArtifactPaths};
13use crate::shared::error::{Error, ErrorCode};
14use crate::shared::redact;
15
16pub const NETWORK_SCHEMA_VERSION: u32 = 2;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct NetworkLog {
20    pub schema_version: u32,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub main_request_id: Option<String>,
23    pub entries: Vec<NetworkEntry>,
24    pub summary: NetworkSummary,
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct NetworkSummary {
29    pub requests_total: usize,
30    pub responses_total: usize,
31    pub finished_total: usize,
32    pub failed_total: usize,
33    pub incomplete_total: usize,
34    pub inflight_total_at_capture: usize,
35    pub pending_by_resource_type: BTreeMap<String, usize>,
36    pub captured_body_files: usize,
37    pub redacted: bool,
38}
39
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum NetworkEntryState {
43    #[default]
44    Pending,
45    Responded,
46    Finished,
47    Failed,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct NetworkEntry {
52    pub request_id: String,
53    pub state: NetworkEntryState,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub redirect_from_request_id: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub frame_id: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub loader_id: Option<String>,
60    pub resource_type: String,
61    #[serde(alias = "url")]
62    pub request_url: String,
63    pub method: String,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub initiator: Option<serde_json::Value>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub status: Option<u16>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub mime_type: Option<String>,
70    pub request_headers: BTreeMap<String, String>,
71    pub response_headers: BTreeMap<String, String>,
72    #[serde(default, skip_serializing_if = "is_false")]
73    pub request_post_data_present: bool,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub request_post_data_size_bytes: Option<usize>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub body_file: Option<PathBuf>,
78    pub timing: NetworkTiming,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub failure: Option<String>,
81    pub hints: BTreeMap<String, serde_json::Value>,
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct NetworkTiming {
86    #[serde(alias = "start_ms")]
87    pub start_monotonic_ms: u64,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    #[serde(alias = "end_ms")]
90    pub end_monotonic_ms: Option<u64>,
91}
92
93pub async fn write(paths: &ArtifactPaths, log: &NetworkLog) -> Result<PathBuf, Error> {
94    let target = paths.file_for(Artifact::Network);
95    let bytes = serde_json::to_vec_pretty(log).map_err(|e| {
96        Error::new(
97            ErrorCode::InternalError,
98            format!("serialize network log: {e}"),
99        )
100    })?;
101    writer::write_bytes(&target, &bytes).await?;
102    Ok(target)
103}
104
105/// Apply the default redaction policy to a header map in-place. Pass
106/// `enabled = false` for `--no-network-redact`.
107pub fn redact_headers(map: &mut BTreeMap<String, String>, enabled: bool) {
108    if !enabled {
109        return;
110    }
111    for (name, value) in map.iter_mut() {
112        if redact::should_redact(name) {
113            *value = redact::REDACTED_VALUE.to_string();
114        }
115    }
116}
117
118/// Apply the default URL redaction policy used by `network.json`. Pass
119/// `enabled = false` only for the explicit `--no-network-redact` opt-out.
120#[must_use]
121pub fn redact_request_url(url: &str, enabled: bool) -> String {
122    if enabled {
123        redact::redact_url(url)
124    } else {
125        url.to_string()
126    }
127}
128
129fn is_false(value: &bool) -> bool {
130    !*value
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn redact_replaces_credentials_only() {
139        let mut map = BTreeMap::new();
140        map.insert("Authorization".to_string(), "Bearer xyz".to_string());
141        map.insert("Cookie".to_string(), "session=abc".to_string());
142        map.insert("X-Api-Token".to_string(), "tok".to_string());
143        map.insert("Content-Type".to_string(), "application/json".to_string());
144        redact_headers(&mut map, true);
145        assert_eq!(map["Authorization"], "[redacted]");
146        assert_eq!(map["Cookie"], "[redacted]");
147        assert_eq!(map["X-Api-Token"], "[redacted]");
148        assert_eq!(map["Content-Type"], "application/json");
149    }
150
151    #[test]
152    fn redact_disabled_passes_everything_through() {
153        let mut map = BTreeMap::new();
154        map.insert("Authorization".to_string(), "Bearer xyz".to_string());
155        redact_headers(&mut map, false);
156        assert_eq!(map["Authorization"], "Bearer xyz");
157    }
158
159    #[test]
160    fn request_url_redaction_honors_explicit_opt_out() {
161        let url = "https://user:pass@example.test/?token=abc&safe=ok";
162        assert_eq!(
163            redact_request_url(url, true),
164            "https://user:***@example.test/?token=***&safe=ok"
165        );
166        assert_eq!(redact_request_url(url, false), url);
167    }
168}