1use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::cli::connect::{Connection, Resolved};
7use crate::cli::output;
8use crate::cli::token_source;
9use crate::host::bootstrap::BrowserChoice;
10use crate::sdk::fetch::{FetchCookie, NetworkBodies, RenderMode, Wait};
11use crate::sdk::{Client, InlineConfig};
12use crate::shared::artifacts::Artifact;
13use crate::shared::error::{Error, ErrorCode};
14use crate::shared::ids::TabId;
15
16#[derive(Debug)]
19pub struct Args {
20 pub url: String,
21 pub connection: Connection,
22 pub browser: BrowserChoice,
23 pub browser_bin: Option<PathBuf>,
24 pub render: RenderMode,
25 pub tab: String,
26 pub takeover: bool,
27 pub profile: Option<String>,
28 pub wait: String,
29 pub headers: Vec<String>,
30 pub cookies: Vec<String>,
31 pub user_agent: Option<String>,
32 pub evaluate_after_wait: Vec<String>,
33 pub want: Vec<String>,
34 pub method: String,
35 pub data: Option<String>,
36 pub form: Vec<String>,
37 pub network_bodies: NetworkBodies,
38 pub network_body_max_bytes: u64,
39 pub readiness_idle_ms: u64,
40 pub readiness_stable_ms: u64,
41 pub readiness_min_text_bytes: u64,
42 pub no_network_redact: bool,
43 pub out: Option<PathBuf>,
44 pub cookie_jar: Option<PathBuf>,
45 pub no_cookie_jar: bool,
46 pub observe_main_wait_ms: u64,
47 pub max_response_bytes: u64,
48 pub retry: u32,
49 pub backoff_ms: u64,
50 pub proxy: Option<String>,
51 pub ca_cert: Option<PathBuf>,
52 pub tls_insecure: bool,
53 pub timeout_ms: u64,
54 pub capture_ws: bool,
55 pub capture_sse: bool,
56}
57
58pub async fn run(args: Args) -> Result<(), Error> {
59 match run_inner(args).await {
60 Ok(()) => Ok(()),
61 Err(FetchRunError::Plain(err)) => {
62 let _ = crate::shared::afdata::emit_process_error(&err);
63 Err(err)
64 }
65 Err(FetchRunError::Emitted(err)) => Err(err),
66 }
67}
68
69async fn run_inner(args: Args) -> Result<(), FetchRunError> {
70 let render = args.render;
71 let endpoint_was_preconfigured = args.connection.is_explicit();
72 let explicit_profile = args.profile.clone();
75 let resolved_profile: Option<String> = if let Some(p) = explicit_profile.clone() {
76 Some(p)
77 } else if args.takeover {
78 Some(default_profile_for_url(&args.url)?)
79 } else {
80 None
81 };
82 let host = if args.takeover {
87 prepare_host(
88 &args.connection,
89 true,
90 resolved_profile.as_deref(),
91 |token| async move {
92 crate::cli::cmd::container::discover_default_takeover_host(token.as_deref()).await
93 },
94 )
95 .await?
96 } else {
97 prepare_host(
98 &args.connection,
99 false,
100 resolved_profile.as_deref(),
101 |token| async move {
102 crate::cli::cmd::container::discover_default_local_host(token.as_deref()).await
103 },
104 )
105 .await?
106 };
107 let takeover = args.takeover;
108 let takeover_endpoint = host.as_ref().map(|host| host.endpoint.clone());
109 let recommended_endpoint =
110 takeover_recommended_endpoint(takeover_endpoint.as_deref(), endpoint_was_preconfigured);
111 let takeover_token = host.as_ref().and_then(|host| host.token.clone());
112 let wait = Wait::parse(&args.wait)?;
113 let timeout = Duration::from_millis(args.timeout_ms);
114 let network_bodies = args.network_bodies;
115 let network_redact = !args.no_network_redact;
116
117 let body_bytes = resolve_body(&args).await?;
118 let want = resolve_want(&args.want);
119 let mut client = build_client(&args, host.as_ref(), render).await?;
120 if let Some(profile) = &resolved_profile {
121 client = client.with_profile(profile.clone());
122 }
123
124 let mut builder = client
125 .fetch(args.url.clone())
126 .render(render)
127 .wait(wait)
128 .timeout(timeout)
129 .network_bodies(network_bodies)
130 .network_body_max_bytes(args.network_body_max_bytes)
131 .readiness_idle_ms(args.readiness_idle_ms)
132 .readiness_stable_ms(args.readiness_stable_ms)
133 .readiness_min_text_bytes(args.readiness_min_text_bytes)
134 .network_redact(network_redact)
135 .method(args.method);
136 if let Some(want) = want {
137 builder = builder.want(want);
138 }
139 if let Some(bytes) = body_bytes {
140 builder = builder.body(bytes);
141 }
142 for raw in &args.form {
143 let (k, v) = raw.split_once('=').ok_or_else(|| {
144 Error::new(
145 ErrorCode::InvalidArgument,
146 format!("--form: expected key=value, got {raw:?}"),
147 )
148 })?;
149 builder = builder.form_field(k, v);
150 }
151 for raw in args.headers {
152 let (name, value) = parse_header_arg(&raw)?;
153 builder = builder.header(name, value);
154 }
155 for raw in args.cookies {
156 builder = builder.cookie_full(parse_cookie_arg(&raw)?);
157 }
158 if let Some(user_agent) = args.user_agent {
159 builder = builder.user_agent(user_agent);
160 }
161 for js in args.evaluate_after_wait {
162 builder = builder.evaluate_after_wait(js);
163 }
164 if args.tab != "new" {
165 builder = builder.tab(TabId::new(args.tab));
166 }
167 if takeover {
168 builder = builder.keep_tab_open(true);
170 }
171 if let Some(out) = args.out {
172 builder = builder.out_dir(out);
173 }
174 builder = builder.observe_main_wait_ms(args.observe_main_wait_ms);
175 builder = builder.max_response_bytes(args.max_response_bytes);
176 builder = builder.retry(args.retry).backoff_ms(args.backoff_ms);
177 if let Some(url) = args.proxy {
178 builder = builder.proxy(url);
179 }
180 if let Some(path) = args.ca_cert {
181 builder = builder.ca_cert(path);
182 }
183 if args.tls_insecure {
184 builder = builder.tls_insecure(true);
185 }
186 if args.capture_ws {
187 builder = builder.capture_ws(true);
188 }
189 if args.capture_sse {
190 builder = builder.capture_sse(true);
191 }
192 if args.no_cookie_jar {
193 builder = builder.no_cookie_jar();
194 } else {
195 let cookie_jar = args.cookie_jar.or_else(|| {
196 std::env::var_os("AFHTTP_COOKIE_JAR")
197 .filter(|v| !v.is_empty())
198 .map(PathBuf::from)
199 });
200 if let Some(jar) = cookie_jar {
201 builder = builder.cookie_jar(jar);
202 }
203 }
204
205 match builder.send_detailed().await {
206 Ok(mut result) => {
207 if takeover
208 && result.next_action.is_some()
209 && let Some(endpoint) = takeover_endpoint.as_deref()
210 {
211 let mut handoff_client = Client::connect(endpoint)?;
212 if let Some(token) = &takeover_token {
213 handoff_client = handoff_client.with_token(token.expose_secret());
214 }
215 let tab_id = result.tab_id.as_ref().map(|t| t.as_str().to_string());
216 let handoff = handoff_client
217 .takeover_handoff(None, tab_id.as_deref())
218 .await?;
219 result.attach_takeover_with_context(
220 handoff.takeover_url_secret,
221 Some(handoff.takeover_url_expires_at_rfc3339),
222 Some(handoff.takeover_url_ttl_s),
223 Some(handoff.takeover_url_scope),
224 recommended_endpoint,
225 explicit_profile.as_deref(),
226 );
227 }
228 if takeover {
229 Ok(output::emit_revealing_takeover("fetch", &result)?)
230 } else {
231 Ok(output::emit("fetch", &result)?)
232 }
233 }
234 Err(err) => {
235 let trace = serde_json::to_value(&err.trace).map_err(|e| {
236 Error::new(
237 ErrorCode::InternalError,
238 format!("serialize fetch error trace: {e}"),
239 )
240 })?;
241 let err = err.into_error();
242 crate::shared::afdata::emit_process_error_with(
243 err.error_code.as_str(),
244 &err.detail,
245 serde_json::json!({"retryable": err.retryable}),
246 trace,
247 )?;
248 Err(FetchRunError::Emitted(err))
249 }
250 }
251}
252
253async fn prepare_host<D, Fut>(
265 connection: &Connection,
266 takeover: bool,
267 profile: Option<&str>,
268 discover: D,
269) -> Result<Option<Resolved>, Error>
270where
271 D: FnOnce(Option<String>) -> Fut,
272 Fut: std::future::Future<Output = Result<crate::cli::cmd::container::LocalHost, Error>>,
273{
274 let token = connection
275 .token
276 .as_ref()
277 .map(token_source::read)
278 .transpose()?;
279 if let Some(endpoint) = connection.endpoint.clone() {
280 return Ok(Some(Resolved { endpoint, token }));
281 }
282 if !takeover && profile.is_none() {
283 return Ok(None);
284 }
285 let discovered = discover(
286 token
287 .as_ref()
288 .map(|token| token.expose_secret().to_string()),
289 )
290 .await?;
291 Ok(Some(Resolved {
292 endpoint: discovered.endpoint,
293 token: token.or(discovered.token_secret),
294 }))
295}
296
297fn takeover_recommended_endpoint(
298 endpoint: Option<&str>,
299 endpoint_was_preconfigured: bool,
300) -> Option<&str> {
301 if endpoint_was_preconfigured {
302 endpoint
303 } else {
304 None
305 }
306}
307
308fn default_profile_for_url(raw_url: &str) -> Result<String, Error> {
313 let parsed = url::Url::parse(raw_url).map_err(|e| {
314 Error::new(
315 ErrorCode::InvalidArgument,
316 format!(
317 "--takeover default profile needs a valid URL with a host; \
318 could not parse {raw_url:?}: {e}; pass --profile <name>"
319 ),
320 )
321 })?;
322 let host = parsed.host().ok_or_else(|| {
323 Error::new(
324 ErrorCode::InvalidArgument,
325 format!(
326 "--takeover default profile needs URL {raw_url:?} to include a host; \
327 pass --profile <name>"
328 ),
329 )
330 })?;
331 let (normalized_host, dns_name) = match host {
332 url::Host::Domain(domain) => (normalize_profile_host(domain), true),
333 url::Host::Ipv4(addr) => (addr.to_string(), false),
334 url::Host::Ipv6(addr) => (addr.to_string(), false),
335 };
336 let profile = if dns_name {
337 psl::domain_str(&normalized_host)
338 .unwrap_or(&normalized_host)
339 .to_string()
340 } else {
341 normalized_host.clone()
342 };
343 crate::sdk::profile::paths::validate_name(&profile).map_err(|e| {
344 Error::new(
345 e.error_code,
346 format!(
347 "derived --takeover profile {profile:?} from URL host {normalized_host:?} \
348 is invalid: {}; pass --profile <name>",
349 e.detail
350 ),
351 )
352 })?;
353 Ok(profile)
354}
355
356fn normalize_profile_host(host: &str) -> String {
357 host.trim_end_matches('.').to_ascii_lowercase()
358}
359
360enum FetchRunError {
361 Plain(Error),
362 Emitted(Error),
363}
364
365impl From<Error> for FetchRunError {
366 fn from(err: Error) -> Self {
367 Self::Plain(err)
368 }
369}
370
371async fn resolve_body(args: &Args) -> Result<Option<Vec<u8>>, Error> {
376 if let Some(data) = &args.data {
377 if let Some(path) = data.strip_prefix('@') {
378 return Ok(Some(tokio::fs::read(path).await.map_err(|e| {
379 Error::new(ErrorCode::IoError, format!("--data @{path}: {e}"))
380 })?));
381 }
382 return Ok(Some(data.as_bytes().to_vec()));
383 }
384 Ok(None)
385}
386
387fn resolve_want(want: &[String]) -> Option<std::collections::BTreeSet<Artifact>> {
391 if want.is_empty() {
392 return None;
393 }
394 Some(
395 want.iter()
396 .filter_map(|token| parse_artifact(token))
397 .collect(),
398 )
399}
400
401async fn build_client(
405 args: &Args,
406 host: Option<&Resolved>,
407 render: RenderMode,
408) -> Result<Client, Error> {
409 match host {
410 Some(host) => host.client(),
411 None if matches!(render, RenderMode::None) => Client::http_only(),
412 None => {
413 let cfg = InlineConfig {
414 browser: args.browser.clone(),
415 browser_bin: args.browser_bin.clone(),
416 };
417 if matches!(render, RenderMode::Auto) {
418 Client::inline_ephemeral_lazy(cfg).await
419 } else {
420 Client::inline_ephemeral_with(cfg).await
421 }
422 }
423 }
424}
425
426fn parse_artifact(token: &str) -> Option<Artifact> {
427 Some(match token {
428 "body" => Artifact::Body,
429 "rendered_html" => Artifact::RenderedHtml,
430 "text" => Artifact::Text,
431 "content" => Artifact::Content,
432 "content_json" => Artifact::ContentJson,
433 "screenshot" => Artifact::Screenshot,
434 "network" => Artifact::Network,
435 "console" => Artifact::Console,
436 "observation" => Artifact::Observation,
437 "storage" => Artifact::Storage,
438 _ => return None,
439 })
440}
441
442fn parse_header_arg(raw: &str) -> Result<(String, String), Error> {
443 let (name, value) = raw.split_once(':').ok_or_else(|| {
444 Error::new(
445 ErrorCode::InvalidArgument,
446 format!("--header: expected K:V, got {raw:?}"),
447 )
448 })?;
449 let name = name.trim();
450 if name.is_empty() {
451 return Err(Error::new(
452 ErrorCode::InvalidArgument,
453 format!("--header: header name must not be empty in {raw:?}"),
454 ));
455 }
456 Ok((name.to_string(), value.trim_start().to_string()))
457}
458
459fn parse_cookie_arg(raw: &str) -> Result<FetchCookie, Error> {
460 if !raw.contains('=') {
461 return Err(Error::new(
462 ErrorCode::InvalidArgument,
463 format!("--cookie: expected Set-Cookie style name=value, got {raw:?}"),
464 ));
465 }
466 let cookie = FetchCookie::parse(raw.to_string())
467 .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--cookie: {e}")))?
468 .into_owned();
469 if cookie.name().trim().is_empty() {
470 return Err(Error::new(
471 ErrorCode::InvalidArgument,
472 format!("--cookie: cookie name must not be empty in {raw:?}"),
473 ));
474 }
475 Ok(cookie)
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 fn exposed(token: &Option<agent_first_data::value_source::SecretString>) -> Option<&str> {
485 token
486 .as_ref()
487 .map(agent_first_data::value_source::SecretString::expose_secret)
488 }
489
490 fn discovered(token_secret: Option<&str>) -> crate::cli::cmd::container::LocalHost {
491 crate::cli::cmd::container::LocalHost {
492 endpoint: "ws://127.0.0.1:9222".into(),
493 token_secret: token_secret.map(agent_first_data::value_source::SecretString::new),
494 }
495 }
496
497 #[tokio::test]
500 async fn a_plain_fetch_needs_no_host_and_discovers_nothing() {
501 let host = prepare_host(&Connection::default(), false, None, |_| async {
502 panic!("a plain fetch must not discover a host");
503 })
504 .await
505 .expect("no host is not an error");
506 assert!(host.is_none());
507 }
508
509 #[tokio::test]
510 async fn autodiscovery_fills_missing_endpoint_and_token() {
511 let host = prepare_host(
512 &Connection::default(),
513 true,
514 Some("contabo.com"),
515 |token| async move {
516 assert_eq!(token, None);
517 Ok(discovered(Some("secret")))
518 },
519 )
520 .await
521 .expect("discovery succeeds")
522 .expect("takeover needs a host");
523
524 assert_eq!(host.endpoint, "ws://127.0.0.1:9222");
525 assert_eq!(exposed(&host.token), Some("secret"));
526 }
527
528 #[tokio::test]
532 async fn a_profile_alone_discovers_a_host() {
533 let host = prepare_host(&Connection::default(), false, Some("work"), |_| async {
534 Ok(discovered(Some("secret")))
535 })
536 .await
537 .expect("discovery succeeds")
538 .expect("a profile needs a host");
539 assert_eq!(host.endpoint, "ws://127.0.0.1:9222");
540 }
541
542 #[tokio::test]
543 async fn autodiscovery_preserves_an_explicit_token() {
544 let connection = Connection::new(
545 None,
546 Some(agent_first_data::ValueSource::Literal("argv-token".into())),
547 );
548 let host = prepare_host(&connection, true, Some("contabo.com"), |token| async move {
549 assert_eq!(token.as_deref(), Some("argv-token"));
550 Ok(discovered(Some("container-token")))
551 })
552 .await
553 .expect("discovery succeeds")
554 .expect("takeover needs a host");
555
556 assert_eq!(host.endpoint, "ws://127.0.0.1:9222");
557 assert_eq!(exposed(&host.token), Some("argv-token"));
558 }
559
560 #[tokio::test]
561 async fn autodiscovery_surfaces_failure() {
562 let err = prepare_host(
563 &Connection::default(),
564 true,
565 Some("contabo.com"),
566 |_| async {
567 Err(Error::new(
568 ErrorCode::InvalidArgument,
569 "default local container `afhttp-host` is not running",
570 ))
571 },
572 )
573 .await
574 .expect_err("a missing container is an error");
575
576 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
577 assert!(err.detail.contains("afhttp-host"));
578 }
579
580 #[tokio::test]
582 async fn a_named_endpoint_is_never_second_guessed() {
583 let connection = Connection::new(Some("ws://box:9222".into()), None);
584 let host = prepare_host(&connection, true, Some("contabo.com"), |_| async {
585 panic!("an explicit endpoint must not discover");
586 })
587 .await
588 .expect("explicit host resolves")
589 .expect("takeover needs a host");
590 assert_eq!(host.endpoint, "ws://box:9222");
591 assert!(host.token.is_none());
592 }
593
594 #[test]
597 fn every_registry_want_value_names_an_artifact() {
598 for token in crate::cli::spec::ARTIFACTS {
599 assert!(parse_artifact(token).is_some(), "{token}");
600 }
601 let wanted = resolve_want(&["body".to_string(), "network".to_string()])
602 .expect("an explicit want is a set");
603 assert!(wanted.contains(&Artifact::Body));
604 assert!(wanted.contains(&Artifact::Network));
605 assert!(resolve_want(&[]).is_none(), "no want means the default set");
606 }
607
608 #[test]
609 fn takeover_recommendation_omits_auto_discovered_endpoint() {
610 assert_eq!(
611 takeover_recommended_endpoint(Some("ws://127.0.0.1:9222"), false),
612 None
613 );
614 }
615
616 #[test]
617 fn takeover_recommendation_keeps_preconfigured_endpoint() {
618 assert_eq!(
619 takeover_recommended_endpoint(Some("ws://127.0.0.1:9222"), true),
620 Some("ws://127.0.0.1:9222")
621 );
622 }
623
624 #[test]
625 fn default_profile_uses_registrable_domain() {
626 assert_eq!(
627 default_profile_for_url("https://www.court.gov.cn/foo").unwrap(),
628 "court.gov.cn"
629 );
630 assert_eq!(
631 default_profile_for_url("https://accounts.google.com/foo").unwrap(),
632 "google.com"
633 );
634 assert_eq!(
635 default_profile_for_url("https://contabo.com").unwrap(),
636 "contabo.com"
637 );
638 }
639
640 #[test]
641 fn default_profile_keeps_public_suffix_tenants_isolated() {
642 assert_eq!(
643 default_profile_for_url("https://foo.github.io/x").unwrap(),
644 "foo.github.io"
645 );
646 assert_eq!(
647 default_profile_for_url("https://tenant.vercel.app/x").unwrap(),
648 "tenant.vercel.app"
649 );
650 }
651
652 #[test]
653 fn default_profile_normalizes_case_and_trailing_dot() {
654 assert_eq!(
655 default_profile_for_url("https://WWW.Example.COM./foo").unwrap(),
656 "example.com"
657 );
658 }
659
660 #[test]
661 fn default_profile_falls_back_to_full_host_for_psl_misses_and_ips() {
662 assert_eq!(
663 default_profile_for_url("http://localhost:8080/foo").unwrap(),
664 "localhost"
665 );
666 assert_eq!(
667 default_profile_for_url("http://127.0.0.1:8080/foo").unwrap(),
668 "127.0.0.1"
669 );
670 }
671
672 #[test]
673 fn default_profile_errors_when_host_is_missing() {
674 assert!(default_profile_for_url("file:///tmp/page.html").is_err());
675 }
676
677 #[test]
678 fn header_arg_accepts_colon_separator() {
679 assert_eq!(
680 parse_header_arg("X-Test: yes").unwrap(),
681 ("X-Test".to_string(), "yes".to_string())
682 );
683 }
684
685 #[test]
686 fn header_arg_rejects_missing_colon() {
687 let err = parse_header_arg("X-Test").err().unwrap();
688 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
689 }
690
691 #[test]
692 fn cookie_arg_accepts_equals_separator() {
693 let cookie = parse_cookie_arg("sid=abc=def").unwrap();
694 assert_eq!(cookie.name_value(), ("sid", "abc=def"));
695 }
696
697 #[test]
698 fn cookie_arg_accepts_full_set_cookie_attributes() {
699 let cookie = parse_cookie_arg("sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax").unwrap();
700 assert_eq!(cookie.name_value(), ("sid", "abc"));
701 assert_eq!(cookie.path(), Some("/"));
702 assert_eq!(cookie.secure(), Some(true));
703 assert_eq!(cookie.http_only(), Some(true));
704 assert_eq!(cookie.same_site(), Some(cookie::SameSite::Lax));
705 }
706
707 #[test]
708 fn cookie_arg_rejects_missing_equals() {
709 let err = parse_cookie_arg("sid").err().unwrap();
710 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
711 }
712}