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