Skip to main content

browser_control/cli/
fetch.rs

1//! `browser-control fetch` — run an HTTP request from the page's context.
2//!
3//! The request is executed by injecting [`crate::dom::scripts::FETCH_JS`] into
4//! the active page via the engine-agnostic [`PageSession`] and parsing the
5//! `{status, statusText, headers, body}` envelope it returns.
6//!
7//! Output mirrors `curl`:
8//! - `--include` prepends `HTTP/1.1 <code> <text>\r\n` and response headers.
9//! - `--output PATH` writes the body to PATH (and `chmod 0600` on Unix).
10//! - Without `--output`, the body is written to stdout.
11//!
12//! Transport errors (script failure, attach failure) exit non-zero; HTTP
13//! status is reported verbatim and does not change the exit code.
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use anyhow::{anyhow, bail, Context, Result};
20use serde::Deserialize;
21use serde_json::{json, Map, Value};
22
23use crate::cli::route;
24use crate::cli::trace::CommandTrace;
25use crate::dom::scripts::FETCH_JS;
26use crate::session::freshness;
27use crate::session::{evaluate_for_origin_with_recover_once, PageSession};
28
29// The default fetch timeout (60 000 ms) is set on the CLI flag in
30// `main.rs`. Bounded so a wedged renderer fails fast instead of dragging
31// out the upstream protocol timeout; tunable per invocation via
32// `--timeout-ms` for slow-network workloads.
33
34#[allow(clippy::too_many_arguments)]
35pub async fn run(
36    browser: Option<String>,
37    url: String,
38    method: String,
39    headers: Vec<String>,
40    data: Option<String>,
41    target: Option<String>,
42    include: bool,
43    output: Option<PathBuf>,
44    timeout_ms: u64,
45    max_age: String,
46) -> Result<()> {
47    let mut trace = CommandTrace::new("fetch");
48    let result = run_inner(
49        browser, url, method, headers, data, target, include, output, timeout_ms, max_age,
50        &mut trace,
51    )
52    .await;
53    trace.finish(result)
54}
55
56#[allow(clippy::too_many_arguments)]
57async fn run_inner(
58    browser: Option<String>,
59    url: String,
60    method: String,
61    headers: Vec<String>,
62    data: Option<String>,
63    target: Option<String>,
64    include: bool,
65    output: Option<PathBuf>,
66    timeout_ms: u64,
67    max_age: String,
68    trace: &mut CommandTrace,
69) -> Result<()> {
70    let header_map = parse_headers(&headers)?;
71    let expr = build_fetch_expr(&url, &method, &header_map, data.as_deref())?;
72    let fetch_timeout = Duration::from_millis(timeout_ms);
73    let max_age = freshness::parse_max_age(&max_age)?;
74
75    // Path-syntax parsing: `<browser>` or `<browser>/<tab>` in the
76    // positional. `--target <regex>` and `/<tab>` are mutually exclusive.
77    // The preamble (parse, mutual-exclusion, resolve, registry, BiDi lock) is
78    // shared with `eval`/`storage`; see `crate::cli::route`.
79    let r = route::preamble(browser, target.as_deref(), trace).await?;
80    let resolved = &r.resolved;
81
82    let result = match (r.tab_name.clone(), target.as_deref()) {
83        // Path 1: <browser>/<tab> — fetch against the named tab.
84        // Engine-agnostic via TabBackend + recover-once on dead tabs.
85        (Some(name), None) => {
86            trace.route("named-tab").tab_name(&name);
87            let expr = expr.clone();
88            route::run_named_tab(
89                &r,
90                &name,
91                "named tabs (`<browser>/<name>`) require a registered browser",
92                move |b, target_id| {
93                    let expr = expr.clone();
94                    async move {
95                        b.ensure_fresh(&target_id, max_age).await?;
96                        b.evaluate(&target_id, &expr, true, fetch_timeout).await
97                    }
98                },
99            )
100            .await?
101        }
102        // Path 2: --target regex (legacy).
103        (None, Some(regex)) => {
104            trace.route("target-regex");
105            let session =
106                PageSession::attach(&resolved.endpoint, resolved.engine, Some(regex)).await?;
107            session.ensure_fresh(max_age).await?;
108            let value = session
109                .evaluate_with_timeout(&expr, true, Some(fetch_timeout))
110                .await;
111            session.close().await;
112            value?
113        }
114        // Path 3: bare browser → attach_for_origin (current default).
115        // Auth-inheritance: the request runs from the URL's origin, so
116        // cookies/credentials propagate. Scratch routing (which would use
117        // about:blank) would lose this; we deliberately keep
118        // attach_for_origin here instead.
119        //
120        // Recover-once lives in the shared session helper so CLI fetch and
121        // wait-for-cookie validation cannot drift on the origin-bound contract.
122        (None, None) => {
123            trace.route("attach-for-origin");
124            evaluate_for_origin_with_recover_once(
125                &resolved.endpoint,
126                resolved.engine,
127                &url,
128                &expr,
129                true,
130                fetch_timeout,
131                max_age,
132            )
133            .await?
134        }
135        _ => unreachable!("mutex was checked above"),
136    };
137
138    let envelope = parse_envelope(&result)?;
139
140    let mut bytes = Vec::new();
141    if include {
142        bytes.extend_from_slice(format_status_and_headers(&envelope).as_bytes());
143    }
144    bytes.extend_from_slice(envelope.body.as_bytes());
145
146    match output {
147        Some(path) => {
148            write_file(&path, &bytes)?;
149            tracing::info!(
150                target = "fetch",
151                "wrote {} bytes to {}",
152                bytes.len(),
153                path.display()
154            );
155            eprintln!("wrote {} bytes to {}", bytes.len(), path.display());
156        }
157        None => {
158            use std::io::Write;
159            let mut out = std::io::stdout().lock();
160            out.write_all(&bytes)?;
161        }
162    }
163    Ok(())
164}
165
166/// Parsed `{status, statusText, headers, body}` envelope from `FETCH_JS`.
167#[derive(Debug, Clone, PartialEq)]
168struct FetchEnvelope {
169    status: u16,
170    status_text: String,
171    headers: Vec<(String, String)>,
172    body: String,
173}
174
175/// Wire shape of the `FETCH_JS` envelope. Deserialized directly; only `status`
176/// is required. `headers` is a `BTreeMap` so iteration is key-sorted, matching
177/// the previous `serde_json::Map` iteration order.
178#[derive(Deserialize)]
179struct RawFetchEnvelope {
180    status: u16,
181    #[serde(default, rename = "statusText")]
182    status_text: String,
183    #[serde(default)]
184    headers: BTreeMap<String, String>,
185    #[serde(default)]
186    body: String,
187}
188
189fn parse_headers(headers: &[String]) -> Result<Map<String, Value>> {
190    let mut map = Map::new();
191    for raw in headers {
192        let (k, v) = raw
193            .split_once(':')
194            .ok_or_else(|| anyhow!("malformed header `{raw}`: expected `Key: Value`"))?;
195        let key = k.trim();
196        if key.is_empty() {
197            bail!("malformed header `{raw}`: empty key");
198        }
199        // Per RFC 7230, header names are tokens; reject whitespace/control in name.
200        if key.chars().any(|c| c.is_whitespace() || c.is_control()) {
201            bail!("malformed header `{raw}`: invalid character in name");
202        }
203        let value = v.trim();
204        map.insert(key.to_string(), Value::String(value.to_string()));
205    }
206    Ok(map)
207}
208
209/// Build the JS expression that invokes `FETCH_JS` with a JSON-encoded arg
210/// string. All user-controlled fields are JSON-encoded twice (once inside the
211/// args object, once when we embed the args string as a JS string literal)
212/// so the page can't be tricked into evaluating arbitrary expressions.
213fn build_fetch_expr(
214    url: &str,
215    method: &str,
216    headers: &Map<String, Value>,
217    body: Option<&str>,
218) -> Result<String> {
219    let args = json!({
220        "url": url,
221        "method": method,
222        "headers": Value::Object(headers.clone()),
223        "body": body,
224    });
225    let args_json = serde_json::to_string(&args)?;
226    let args_literal = serde_json::to_string(&args_json)?;
227    Ok(format!("({FETCH_JS})({args_literal})"))
228}
229
230/// `FETCH_JS` returns `JSON.stringify({...})`, so the evaluator hands us a
231/// JSON value of *type string*. Decode the inner JSON.
232fn parse_envelope(v: &Value) -> Result<FetchEnvelope> {
233    let s = v
234        .as_str()
235        .ok_or_else(|| anyhow!("fetch script returned non-string value: {v}"))?;
236    let raw: RawFetchEnvelope = serde_json::from_str(s)
237        .with_context(|| format!("fetch script returned invalid JSON: {s}"))?;
238    Ok(FetchEnvelope {
239        status: raw.status,
240        status_text: raw.status_text,
241        headers: raw.headers.into_iter().collect(),
242        body: raw.body,
243    })
244}
245
246fn format_status_and_headers(env: &FetchEnvelope) -> String {
247    let mut s = format!("HTTP/1.1 {} {}\r\n", env.status, env.status_text);
248    for (k, v) in &env.headers {
249        s.push_str(&format!("{k}: {v}\r\n"));
250    }
251    s.push_str("\r\n");
252    s
253}
254
255fn write_file(path: &Path, body: &[u8]) -> Result<()> {
256    std::fs::write(path, body).with_context(|| format!("failed to write {}", path.display()))?;
257    #[cfg(unix)]
258    {
259        use std::os::unix::fs::PermissionsExt;
260        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
261            .with_context(|| format!("failed to chmod 600 {}", path.display()))?;
262    }
263    Ok(())
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn parse_headers_basic() {
272        let m = parse_headers(&[
273            "Accept: application/json".to_string(),
274            "X-Token: abc".to_string(),
275        ])
276        .unwrap();
277        assert_eq!(m.get("Accept").unwrap(), &json!("application/json"));
278        assert_eq!(m.get("X-Token").unwrap(), &json!("abc"));
279    }
280
281    #[test]
282    fn parse_headers_trims_extra_spaces() {
283        let m = parse_headers(&["  Accept   :   text/plain  ".to_string()]).unwrap();
284        assert_eq!(m.get("Accept").unwrap(), &json!("text/plain"));
285    }
286
287    #[test]
288    fn parse_headers_value_with_colon_kept_intact() {
289        // Only the first `:` separates key/value; the value may contain colons.
290        let m = parse_headers(&["Authorization: Bearer a:b:c".to_string()]).unwrap();
291        assert_eq!(m.get("Authorization").unwrap(), &json!("Bearer a:b:c"));
292    }
293
294    #[test]
295    fn parse_headers_rejects_missing_colon() {
296        let err = parse_headers(&["NoColonHere".to_string()]).unwrap_err();
297        assert!(err.to_string().contains("malformed header"));
298    }
299
300    #[test]
301    fn parse_headers_rejects_empty_key() {
302        let err = parse_headers(&[": value".to_string()]).unwrap_err();
303        assert!(err.to_string().contains("empty key"));
304    }
305
306    #[test]
307    fn parse_headers_rejects_whitespace_in_name() {
308        let err = parse_headers(&["bad name: v".to_string()]).unwrap_err();
309        assert!(err.to_string().contains("invalid character"));
310    }
311
312    #[test]
313    fn build_expr_json_escapes_url_and_body() {
314        let mut h = Map::new();
315        h.insert("X".to_string(), json!("y"));
316        // Body and URL contain quotes / backslashes / newlines that would
317        // break naive string interpolation.
318        let url = "https://x.test/?q=\"hi\"";
319        let body = "line1\n\"line2\"\\end";
320        let expr = build_fetch_expr(url, "POST", &h, Some(body)).unwrap();
321        // The expression must wrap a single JSON-encoded string argument.
322        let prefix = format!("({FETCH_JS})(");
323        let inner = expr
324            .strip_prefix(&prefix)
325            .unwrap()
326            .strip_suffix(')')
327            .unwrap();
328        // No raw user-controlled quote or newline can appear unescaped at the
329        // top level — the literal is JSON, so quotes inside are `\"` and the
330        // string contains no real newline byte.
331        assert!(!inner.contains('\n'));
332        // Decode the literal back twice and confirm round-trip equality.
333        let args_str: String = serde_json::from_str(inner).unwrap();
334        let args: Value = serde_json::from_str(&args_str).unwrap();
335        assert_eq!(args["url"], url);
336        assert_eq!(args["body"], body);
337        assert_eq!(args["method"], "POST");
338    }
339
340    #[test]
341    fn build_expr_method_and_headers_round_trip() {
342        let mut h = Map::new();
343        h.insert("Accept".to_string(), json!("*/*"));
344        let expr = build_fetch_expr("https://x.test/", "GET", &h, None).unwrap();
345        // Extract the JSON-string literal argument and decode twice.
346        let prefix = format!("({FETCH_JS})(");
347        let inner = expr
348            .strip_prefix(&prefix)
349            .unwrap()
350            .strip_suffix(')')
351            .unwrap();
352        let args_str: String = serde_json::from_str(inner).unwrap();
353        let args: Value = serde_json::from_str(&args_str).unwrap();
354        assert_eq!(args["url"], "https://x.test/");
355        assert_eq!(args["method"], "GET");
356        assert_eq!(args["headers"]["Accept"], "*/*");
357        assert!(args["body"].is_null());
358    }
359
360    #[test]
361    fn parse_envelope_decodes_inner_json() {
362        let inner = json!({
363            "status": 200,
364            "statusText": "OK",
365            "headers": {"content-type": "text/plain"},
366            "body": "hello"
367        });
368        let v = Value::String(inner.to_string());
369        let env = parse_envelope(&v).unwrap();
370        assert_eq!(env.status, 200);
371        assert_eq!(env.status_text, "OK");
372        assert_eq!(env.body, "hello");
373        assert_eq!(
374            env.headers,
375            vec![("content-type".to_string(), "text/plain".to_string())]
376        );
377    }
378
379    #[test]
380    fn parse_envelope_rejects_non_string() {
381        let v = json!({"status": 200});
382        assert!(parse_envelope(&v).is_err());
383    }
384
385    #[test]
386    fn format_include_emits_status_and_headers() {
387        let env = FetchEnvelope {
388            status: 404,
389            status_text: "Not Found".to_string(),
390            headers: vec![
391                ("content-type".to_string(), "text/plain".to_string()),
392                ("x-trace".to_string(), "abc".to_string()),
393            ],
394            body: "missing".to_string(),
395        };
396        let s = format_status_and_headers(&env);
397        assert_eq!(
398            s,
399            "HTTP/1.1 404 Not Found\r\n\
400             content-type: text/plain\r\n\
401             x-trace: abc\r\n\
402             \r\n"
403        );
404    }
405
406    #[test]
407    fn write_file_chmods_0600_on_unix() {
408        let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
409            .join("target")
410            .join("fetch-test-scratch");
411        std::fs::create_dir_all(&dir).unwrap();
412        let p = dir.join(format!("out-{}.bin", std::process::id()));
413        write_file(&p, b"hello").unwrap();
414        assert_eq!(std::fs::read(&p).unwrap(), b"hello");
415        #[cfg(unix)]
416        {
417            use std::os::unix::fs::PermissionsExt;
418            let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
419            assert_eq!(mode, 0o600);
420        }
421        let _ = std::fs::remove_file(&p);
422        let _ = std::fs::remove_dir(&dir);
423    }
424}