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 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 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 (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 (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 (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#[derive(Debug, Clone, PartialEq)]
168struct FetchEnvelope {
169 status: u16,
170 status_text: String,
171 headers: Vec<(String, String)>,
172 body: String,
173}
174
175#[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 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
209fn 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
230fn 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 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 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 let prefix = format!("({FETCH_JS})(");
323 let inner = expr
324 .strip_prefix(&prefix)
325 .unwrap()
326 .strip_suffix(')')
327 .unwrap();
328 assert!(!inner.contains('\n'));
332 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 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}