1use 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#[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 fetch_timeout = Duration::from_millis(timeout_ms);
72 let script_timeout_ms = script_fetch_timeout_ms(fetch_timeout);
73 let expr = build_fetch_expr(
74 &url,
75 &method,
76 &header_map,
77 data.as_deref(),
78 script_timeout_ms,
79 )?;
80 let max_age = freshness::parse_max_age(&max_age)?;
81
82 let r = route::preamble(browser, target.as_deref(), trace).await?;
87 let resolved = &r.resolved;
88
89 let result = match (r.tab_name.clone(), target.as_deref()) {
90 (Some(name), None) => {
93 trace.route("named-tab").tab_name(&name);
94 let expr = expr.clone();
95 route::run_named_tab(
96 &r,
97 &name,
98 "named tabs (`<browser>/<name>`) require a registered browser",
99 move |b, target_id| {
100 let expr = expr.clone();
101 async move {
102 b.ensure_fresh(&target_id, max_age).await?;
103 b.evaluate(&target_id, &expr, true, fetch_timeout).await
104 }
105 },
106 )
107 .await?
108 }
109 (None, Some(regex)) => {
111 trace.route("target-regex");
112 let session =
113 PageSession::attach(&resolved.endpoint, resolved.engine, Some(regex)).await?;
114 session.ensure_fresh(max_age).await?;
115 let value = session
116 .evaluate_with_timeout(&expr, true, Some(fetch_timeout))
117 .await;
118 session.close().await;
119 value?
120 }
121 (None, None) => {
130 trace.route("attach-for-origin");
131 evaluate_for_origin_with_recover_once(
132 &resolved.endpoint,
133 resolved.engine,
134 &url,
135 &expr,
136 true,
137 fetch_timeout,
138 max_age,
139 )
140 .await?
141 }
142 _ => unreachable!("mutex was checked above"),
143 };
144
145 let envelope = parse_envelope(&result)?;
146
147 let mut bytes = Vec::new();
148 if include {
149 bytes.extend_from_slice(format_status_and_headers(&envelope).as_bytes());
150 }
151 bytes.extend_from_slice(envelope.body.as_bytes());
152
153 match output {
154 Some(path) => {
155 write_file(&path, &bytes)?;
156 tracing::info!(
157 target = "fetch",
158 "wrote {} bytes to {}",
159 bytes.len(),
160 path.display()
161 );
162 eprintln!("wrote {} bytes to {}", bytes.len(), path.display());
163 }
164 None => {
165 use std::io::Write;
166 let mut out = std::io::stdout().lock();
167 out.write_all(&bytes)?;
168 }
169 }
170 Ok(())
171}
172
173#[derive(Debug, Clone, PartialEq)]
175struct FetchEnvelope {
176 status: u16,
177 status_text: String,
178 headers: Vec<(String, String)>,
179 body: String,
180}
181
182#[derive(Deserialize)]
186struct RawFetchEnvelope {
187 #[serde(default)]
188 ok: Option<bool>,
189 #[serde(default)]
190 status: Option<u16>,
191 #[serde(default, rename = "statusText")]
192 status_text: String,
193 #[serde(default)]
194 headers: BTreeMap<String, String>,
195 #[serde(default)]
196 body: String,
197 #[serde(default)]
198 error: Option<String>,
199 #[serde(default, rename = "errorName")]
200 error_name: Option<String>,
201}
202
203fn parse_headers(headers: &[String]) -> Result<Map<String, Value>> {
204 let mut map = Map::new();
205 for raw in headers {
206 let (k, v) = raw
207 .split_once(':')
208 .ok_or_else(|| anyhow!("malformed header `{raw}`: expected `Key: Value`"))?;
209 let key = k.trim();
210 if key.is_empty() {
211 bail!("malformed header `{raw}`: empty key");
212 }
213 if key.chars().any(|c| c.is_whitespace() || c.is_control()) {
215 bail!("malformed header `{raw}`: invalid character in name");
216 }
217 let value = v.trim();
218 map.insert(key.to_string(), Value::String(value.to_string()));
219 }
220 Ok(map)
221}
222
223fn build_fetch_expr(
228 url: &str,
229 method: &str,
230 headers: &Map<String, Value>,
231 body: Option<&str>,
232 timeout_ms: u64,
233) -> Result<String> {
234 let args = json!({
235 "url": url,
236 "method": method,
237 "headers": Value::Object(headers.clone()),
238 "body": body,
239 "timeoutMs": timeout_ms,
240 });
241 let args_json = serde_json::to_string(&args)?;
242 let args_literal = serde_json::to_string(&args_json)?;
243 Ok(format!("({FETCH_JS})({args_literal})"))
244}
245
246pub(crate) fn script_fetch_timeout_ms(timeout: Duration) -> u64 {
247 let ms = u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX);
248 ms.saturating_sub(1_000).max(1)
249}
250
251fn parse_envelope(v: &Value) -> Result<FetchEnvelope> {
254 let s = v
255 .as_str()
256 .ok_or_else(|| anyhow!("fetch script returned non-string value: {v}"))?;
257 let raw: RawFetchEnvelope = serde_json::from_str(s)
258 .with_context(|| format!("fetch script returned invalid JSON: {s}"))?;
259 if raw.ok == Some(false) {
260 let mut msg = raw.error.unwrap_or_else(|| "fetch failed".to_string());
261 if let Some(name) = raw.error_name {
262 if !name.is_empty() {
263 msg.push_str(&format!(" ({name})"));
264 }
265 }
266 bail!(msg);
267 }
268 let status = raw
269 .status
270 .ok_or_else(|| anyhow!("fetch script response missing status: {s}"))?;
271 Ok(FetchEnvelope {
272 status,
273 status_text: raw.status_text,
274 headers: raw.headers.into_iter().collect(),
275 body: raw.body,
276 })
277}
278
279fn format_status_and_headers(env: &FetchEnvelope) -> String {
280 let mut s = format!("HTTP/1.1 {} {}\r\n", env.status, env.status_text);
281 for (k, v) in &env.headers {
282 s.push_str(&format!("{k}: {v}\r\n"));
283 }
284 s.push_str("\r\n");
285 s
286}
287
288fn write_file(path: &Path, body: &[u8]) -> Result<()> {
289 std::fs::write(path, body).with_context(|| format!("failed to write {}", path.display()))?;
290 #[cfg(unix)]
291 {
292 use std::os::unix::fs::PermissionsExt;
293 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
294 .with_context(|| format!("failed to chmod 600 {}", path.display()))?;
295 }
296 Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn parse_headers_basic() {
305 let m = parse_headers(&[
306 "Accept: application/json".to_string(),
307 "X-Token: abc".to_string(),
308 ])
309 .unwrap();
310 assert_eq!(m.get("Accept").unwrap(), &json!("application/json"));
311 assert_eq!(m.get("X-Token").unwrap(), &json!("abc"));
312 }
313
314 #[test]
315 fn parse_headers_trims_extra_spaces() {
316 let m = parse_headers(&[" Accept : text/plain ".to_string()]).unwrap();
317 assert_eq!(m.get("Accept").unwrap(), &json!("text/plain"));
318 }
319
320 #[test]
321 fn parse_headers_value_with_colon_kept_intact() {
322 let m = parse_headers(&["Authorization: Bearer a:b:c".to_string()]).unwrap();
324 assert_eq!(m.get("Authorization").unwrap(), &json!("Bearer a:b:c"));
325 }
326
327 #[test]
328 fn parse_headers_rejects_missing_colon() {
329 let err = parse_headers(&["NoColonHere".to_string()]).unwrap_err();
330 assert!(err.to_string().contains("malformed header"));
331 }
332
333 #[test]
334 fn parse_headers_rejects_empty_key() {
335 let err = parse_headers(&[": value".to_string()]).unwrap_err();
336 assert!(err.to_string().contains("empty key"));
337 }
338
339 #[test]
340 fn parse_headers_rejects_whitespace_in_name() {
341 let err = parse_headers(&["bad name: v".to_string()]).unwrap_err();
342 assert!(err.to_string().contains("invalid character"));
343 }
344
345 #[test]
346 fn build_expr_json_escapes_url_and_body() {
347 let mut h = Map::new();
348 h.insert("X".to_string(), json!("y"));
349 let url = "https://x.test/?q=\"hi\"";
352 let body = "line1\n\"line2\"\\end";
353 let expr = build_fetch_expr(url, "POST", &h, Some(body), 59_750).unwrap();
354 let prefix = format!("({FETCH_JS})(");
356 let inner = expr
357 .strip_prefix(&prefix)
358 .unwrap()
359 .strip_suffix(')')
360 .unwrap();
361 assert!(!inner.contains('\n'));
365 let args_str: String = serde_json::from_str(inner).unwrap();
367 let args: Value = serde_json::from_str(&args_str).unwrap();
368 assert_eq!(args["url"], url);
369 assert_eq!(args["body"], body);
370 assert_eq!(args["method"], "POST");
371 assert_eq!(args["timeoutMs"], 59_750);
372 }
373
374 #[test]
375 fn build_expr_method_and_headers_round_trip() {
376 let mut h = Map::new();
377 h.insert("Accept".to_string(), json!("*/*"));
378 let expr = build_fetch_expr("https://x.test/", "GET", &h, None, 59_750).unwrap();
379 let prefix = format!("({FETCH_JS})(");
381 let inner = expr
382 .strip_prefix(&prefix)
383 .unwrap()
384 .strip_suffix(')')
385 .unwrap();
386 let args_str: String = serde_json::from_str(inner).unwrap();
387 let args: Value = serde_json::from_str(&args_str).unwrap();
388 assert_eq!(args["url"], "https://x.test/");
389 assert_eq!(args["method"], "GET");
390 assert_eq!(args["headers"]["Accept"], "*/*");
391 assert!(args["body"].is_null());
392 assert_eq!(args["timeoutMs"], 59_750);
393 }
394
395 #[test]
396 fn parse_envelope_decodes_inner_json() {
397 let inner = json!({
398 "ok": true,
399 "status": 200,
400 "statusText": "OK",
401 "headers": {"content-type": "text/plain"},
402 "body": "hello"
403 });
404 let v = Value::String(inner.to_string());
405 let env = parse_envelope(&v).unwrap();
406 assert_eq!(env.status, 200);
407 assert_eq!(env.status_text, "OK");
408 assert_eq!(env.body, "hello");
409 assert_eq!(
410 env.headers,
411 vec![("content-type".to_string(), "text/plain".to_string())]
412 );
413 }
414
415 #[test]
416 fn parse_envelope_rejects_non_string() {
417 let v = json!({"status": 200});
418 assert!(parse_envelope(&v).is_err());
419 }
420
421 #[test]
422 fn parse_envelope_reports_fetch_error() {
423 let inner = json!({
424 "ok": false,
425 "error": "fetch timed out after 9750ms",
426 "errorName": "AbortError"
427 });
428 let v = Value::String(inner.to_string());
429 let err = parse_envelope(&v).unwrap_err();
430 assert!(err.to_string().contains("fetch timed out after 9750ms"));
431 assert!(err.to_string().contains("AbortError"));
432 }
433
434 #[test]
435 fn script_timeout_leaves_outer_timeout_margin() {
436 assert_eq!(
437 script_fetch_timeout_ms(Duration::from_millis(10_000)),
438 9_000
439 );
440 assert_eq!(script_fetch_timeout_ms(Duration::from_millis(100)), 1);
441 }
442
443 #[test]
444 fn format_include_emits_status_and_headers() {
445 let env = FetchEnvelope {
446 status: 404,
447 status_text: "Not Found".to_string(),
448 headers: vec![
449 ("content-type".to_string(), "text/plain".to_string()),
450 ("x-trace".to_string(), "abc".to_string()),
451 ],
452 body: "missing".to_string(),
453 };
454 let s = format_status_and_headers(&env);
455 assert_eq!(
456 s,
457 "HTTP/1.1 404 Not Found\r\n\
458 content-type: text/plain\r\n\
459 x-trace: abc\r\n\
460 \r\n"
461 );
462 }
463
464 #[test]
465 fn write_file_chmods_0600_on_unix() {
466 let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
467 .join("target")
468 .join("fetch-test-scratch");
469 std::fs::create_dir_all(&dir).unwrap();
470 let p = dir.join(format!("out-{}.bin", std::process::id()));
471 write_file(&p, b"hello").unwrap();
472 assert_eq!(std::fs::read(&p).unwrap(), b"hello");
473 #[cfg(unix)]
474 {
475 use std::os::unix::fs::PermissionsExt;
476 let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
477 assert_eq!(mode, 0o600);
478 }
479 let _ = std::fs::remove_file(&p);
480 let _ = std::fs::remove_dir(&dir);
481 }
482}