1use std::path::PathBuf;
4use std::time::Duration;
5
6use clap::Args as ClapArgs;
7use clap::ValueEnum;
8
9use crate::cli::cmd::argenums::{BrowserArg, RenderArg};
10use crate::cli::output;
11use crate::sdk::fetch::{
12 FetchCookie, NetworkBodies, RenderMode, Wait, DEFAULT_NETWORK_BODY_MAX_BYTES,
13};
14use crate::sdk::{Client, InlineConfig};
15use crate::shared::artifacts::Artifact;
16use crate::shared::error::{Error, ErrorCode};
17use crate::shared::ids::TabId;
18
19#[derive(ValueEnum, Debug, Clone, Copy, Default)]
20pub enum NetworkBodiesArg {
21 #[default]
22 Off,
23 Xhr,
24 All,
25}
26
27impl From<NetworkBodiesArg> for NetworkBodies {
28 fn from(v: NetworkBodiesArg) -> Self {
29 match v {
30 NetworkBodiesArg::Off => NetworkBodies::Off,
31 NetworkBodiesArg::Xhr => NetworkBodies::Xhr,
32 NetworkBodiesArg::All => NetworkBodies::All,
33 }
34 }
35}
36
37impl std::fmt::Display for NetworkBodiesArg {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 f.write_str(match self {
40 Self::Off => "off",
41 Self::Xhr => "xhr",
42 Self::All => "all",
43 })
44 }
45}
46
47#[derive(ClapArgs, Debug)]
48pub struct Args {
49 pub url: String,
51 #[arg(
55 long = "endpoint-url",
56 env = "AFHTTP_ENDPOINT_URL",
57 help_heading = "Connection"
58 )]
59 pub endpoint: Option<String>,
60 #[arg(
63 long = "token-secret",
64 env = "AFHTTP_TOKEN_SECRET",
65 help_heading = "Connection"
66 )]
67 pub token: Option<String>,
68 #[arg(long, default_value = "auto", help_heading = "Inline host")]
71 pub browser: BrowserArg,
72 #[arg(
75 long = "browser-bin",
76 value_name = "PATH",
77 help_heading = "Inline host"
78 )]
79 pub browser_bin: Option<PathBuf>,
80 #[arg(long, default_value = "auto", help_heading = "Rendering")]
83 pub render: RenderArg,
84 #[arg(
88 long,
89 default_value = "new",
90 value_name = "new|<id>",
91 help_heading = "Session"
92 )]
93 pub tab: String,
94 #[arg(long, help_heading = "Session")]
101 pub takeover: bool,
102 #[arg(long, help_heading = "Session")]
108 pub profile: Option<String>,
109 #[arg(long, default_value = "auto", help_heading = "Rendering")]
112 pub wait: String,
113 #[arg(long = "header", value_name = "NAME:VALUE", help_heading = "Request")]
116 pub headers: Vec<String>,
117 #[arg(long = "cookie", value_name = "NAME=VALUE", help_heading = "Request")]
119 pub cookies: Vec<String>,
120 #[arg(long, help_heading = "Request")]
122 pub user_agent: Option<String>,
123 #[arg(long, value_name = "JS", help_heading = "Rendering")]
126 pub evaluate_after_wait: Vec<String>,
127 #[arg(long, value_delimiter = ',', help_heading = "Rendering")]
133 pub want: Vec<String>,
134 #[arg(long, default_value = "GET", help_heading = "Request")]
136 pub method: String,
137 #[arg(long, value_name = "STRING|@FILE", help_heading = "Request")]
140 pub data: Option<String>,
141 #[arg(long = "form", value_name = "NAME=VALUE", help_heading = "Request")]
145 pub form: Vec<String>,
146 #[arg(long, default_value_t = NetworkBodiesArg::Off, help_heading = "Network capture")]
149 pub network_bodies: NetworkBodiesArg,
150 #[arg(long, default_value_t = DEFAULT_NETWORK_BODY_MAX_BYTES, help_heading = "Network capture")]
153 pub network_body_max_bytes: u64,
154 #[arg(long, default_value_t = 800, help_heading = "Readiness tuning")]
156 pub readiness_idle_ms: u64,
157 #[arg(long, default_value_t = 500, help_heading = "Readiness tuning")]
159 pub readiness_stable_ms: u64,
160 #[arg(long, default_value_t = 32, help_heading = "Readiness tuning")]
162 pub readiness_min_text_bytes: u64,
163 #[arg(long, help_heading = "Network capture")]
167 pub no_network_redact: bool,
168 #[arg(long, help_heading = "Output")]
171 pub out: Option<PathBuf>,
172 #[arg(long, help_heading = "Cookies")]
179 pub cookie_jar: Option<PathBuf>,
180 #[arg(long, help_heading = "Cookies")]
184 pub no_cookie_jar: bool,
185 #[arg(long, default_value_t = 500, help_heading = "Readiness tuning")]
188 pub observe_main_wait_ms: u64,
189 #[arg(long, default_value_t = 1_073_741_824, help_heading = "HTTP transport")]
196 pub max_response_bytes: u64,
197 #[arg(long, default_value_t = 0, help_heading = "Retry")]
203 pub retry: u32,
204 #[arg(long, default_value_t = 250, help_heading = "Retry")]
206 pub backoff_ms: u64,
207 #[arg(long = "proxy-url", help_heading = "HTTP transport")]
212 pub proxy: Option<String>,
213 #[arg(long, help_heading = "HTTP transport")]
217 pub ca_cert: Option<PathBuf>,
218 #[arg(long, help_heading = "HTTP transport")]
222 pub tls_insecure: bool,
223 #[arg(
226 long = "timeout-ms",
227 default_value_t = 30_000,
228 help_heading = "HTTP transport"
229 )]
230 pub timeout_ms: u64,
231 #[arg(long, help_heading = "Network capture")]
235 pub capture_ws: bool,
236 #[arg(long, help_heading = "Network capture")]
239 pub capture_sse: bool,
240}
241
242pub async fn run(args: Args) -> Result<(), Error> {
243 match run_inner(args).await {
244 Ok(()) => Ok(()),
245 Err(FetchRunError::Plain(err)) => {
246 let stdout = std::io::stdout();
247 let mut handle = stdout.lock();
248 let _ = crate::shared::envelope::emit_error(&mut handle, &err);
249 Err(err)
250 }
251 Err(FetchRunError::Emitted(err)) => Err(err),
252 }
253}
254
255async fn run_inner(mut args: Args) -> Result<(), FetchRunError> {
256 let render: RenderMode = args.render.into();
257 prepare_takeover_connection(&mut args, render, |token| async move {
258 crate::cli::cmd::container::discover_default_takeover_host(token.as_deref()).await
259 })
260 .await?;
261 let explicit_profile = args.profile.clone();
264 let resolved_profile: Option<String> = if let Some(p) = explicit_profile.clone() {
265 Some(p)
266 } else if args.takeover {
267 Some(default_profile_for_url(&args.url)?)
268 } else {
269 None
270 };
271 if resolved_profile.is_some() && args.endpoint.is_none() {
272 return Err(Error::new(
273 ErrorCode::InvalidArgument,
274 "--profile (and --takeover profile derivation) switch the host's active profile and require a host; pass --endpoint-url or set AFHTTP_ENDPOINT_URL",
275 )
276 .into());
277 }
278 let takeover = args.takeover;
279 let takeover_endpoint = args.endpoint.clone();
280 let takeover_token = args.token.clone();
281 let wait = Wait::parse(&args.wait)?;
282 let timeout = Duration::from_millis(args.timeout_ms);
283 let network_bodies = NetworkBodies::from(args.network_bodies);
284 let network_redact = !args.no_network_redact;
285
286 let body_bytes = resolve_body(&args).await?;
287 let want = resolve_want(&args.want)?;
288 let mut client = build_client(&args, render).await?;
289 if let Some(profile) = &resolved_profile {
290 client = client.with_profile(profile.clone());
291 }
292
293 let mut builder = client
294 .fetch(args.url.clone())
295 .render(render)
296 .wait(wait)
297 .timeout(timeout)
298 .want(want)
299 .network_bodies(network_bodies)
300 .network_body_max_bytes(args.network_body_max_bytes)
301 .readiness_idle_ms(args.readiness_idle_ms)
302 .readiness_stable_ms(args.readiness_stable_ms)
303 .readiness_min_text_bytes(args.readiness_min_text_bytes)
304 .network_redact(network_redact)
305 .method(args.method);
306 if let Some(bytes) = body_bytes {
307 builder = builder.body(bytes);
308 }
309 for raw in &args.form {
310 let (k, v) = raw.split_once('=').ok_or_else(|| {
311 Error::new(
312 ErrorCode::InvalidArgument,
313 format!("--form: expected key=value, got {raw:?}"),
314 )
315 })?;
316 builder = builder.form_field(k, v);
317 }
318 for raw in args.headers {
319 let (name, value) = parse_header_arg(&raw)?;
320 builder = builder.header(name, value);
321 }
322 for raw in args.cookies {
323 builder = builder.cookie_full(parse_cookie_arg(&raw)?);
324 }
325 if let Some(user_agent) = args.user_agent {
326 builder = builder.user_agent(user_agent);
327 }
328 for js in args.evaluate_after_wait {
329 builder = builder.evaluate_after_wait(js);
330 }
331 if args.tab != "new" {
332 builder = builder.tab(TabId::new(args.tab));
333 }
334 if takeover {
335 builder = builder.keep_tab_open(true);
337 }
338 if let Some(out) = args.out {
339 builder = builder.out_dir(out);
340 }
341 builder = builder.observe_main_wait_ms(args.observe_main_wait_ms);
342 builder = builder.max_response_bytes(args.max_response_bytes);
343 builder = builder.retry(args.retry).backoff_ms(args.backoff_ms);
344 if let Some(url) = args.proxy {
345 builder = builder.proxy(url);
346 }
347 if let Some(path) = args.ca_cert {
348 builder = builder.ca_cert(path);
349 }
350 if args.tls_insecure {
351 builder = builder.tls_insecure(true);
352 }
353 if args.capture_ws {
354 builder = builder.capture_ws(true);
355 }
356 if args.capture_sse {
357 builder = builder.capture_sse(true);
358 }
359 if args.no_cookie_jar {
360 builder = builder.no_cookie_jar();
361 } else {
362 let cookie_jar = args.cookie_jar.or_else(|| {
363 std::env::var_os("AFHTTP_COOKIE_JAR")
364 .filter(|v| !v.is_empty())
365 .map(PathBuf::from)
366 });
367 if let Some(jar) = cookie_jar {
368 builder = builder.cookie_jar(jar);
369 }
370 }
371
372 match builder.send_detailed().await {
373 Ok(mut result) => {
374 if takeover && result.next_action.is_some() {
375 if let Some(endpoint) = takeover_endpoint.as_deref() {
376 let mut handoff_client = Client::connect(endpoint)?;
377 if let Some(token) = takeover_token.as_deref() {
378 handoff_client = handoff_client.with_token(token);
379 }
380 let tab_id = result.tab_id.as_ref().map(|t| t.as_str().to_string());
381 let handoff = handoff_client
382 .takeover_handoff(None, tab_id.as_deref())
383 .await?;
384 result.attach_takeover_with_context(
385 handoff.takeover_url,
386 Some(handoff.takeover_url_expires_at_rfc3339),
387 Some(handoff.takeover_url_ttl_s),
388 Some(handoff.takeover_url_scope),
389 Some(endpoint),
390 explicit_profile.as_deref(),
391 );
392 }
393 }
394 Ok(output::emit("fetch", &result)?)
395 }
396 Err(err) => {
397 output::emit("error", &err)?;
398 Err(FetchRunError::Emitted(err.into_error()))
399 }
400 }
401}
402
403async fn prepare_takeover_connection<D, Fut>(
404 args: &mut Args,
405 render: RenderMode,
406 discover: D,
407) -> Result<(), Error>
408where
409 D: FnOnce(Option<String>) -> Fut,
410 Fut: std::future::Future<Output = Result<crate::cli::cmd::container::LocalTakeoverHost, Error>>,
411{
412 if !args.takeover {
413 return Ok(());
414 }
415 if matches!(render, RenderMode::None) {
416 return Err(Error::new(
417 ErrorCode::InvalidArgument,
418 "fetch --takeover needs a browser render; use --render auto or always",
419 ));
420 }
421 if args.endpoint.is_none() {
422 let discovered = discover(args.token.clone()).await?;
423 args.endpoint = Some(discovered.endpoint);
424 if args.token.is_none() {
425 args.token = discovered.token_secret;
426 }
427 }
428 Ok(())
429}
430
431fn default_profile_for_url(raw_url: &str) -> Result<String, Error> {
436 let parsed = url::Url::parse(raw_url).map_err(|e| {
437 Error::new(
438 ErrorCode::InvalidArgument,
439 format!(
440 "--takeover default profile needs a valid URL with a host; \
441 could not parse {raw_url:?}: {e}; pass --profile <name>"
442 ),
443 )
444 })?;
445 let host = parsed.host().ok_or_else(|| {
446 Error::new(
447 ErrorCode::InvalidArgument,
448 format!(
449 "--takeover default profile needs URL {raw_url:?} to include a host; \
450 pass --profile <name>"
451 ),
452 )
453 })?;
454 let (normalized_host, dns_name) = match host {
455 url::Host::Domain(domain) => (normalize_profile_host(domain), true),
456 url::Host::Ipv4(addr) => (addr.to_string(), false),
457 url::Host::Ipv6(addr) => (addr.to_string(), false),
458 };
459 let profile = if dns_name {
460 psl::domain_str(&normalized_host)
461 .unwrap_or(&normalized_host)
462 .to_string()
463 } else {
464 normalized_host.clone()
465 };
466 crate::sdk::profile::paths::validate_name(&profile).map_err(|e| {
467 Error::new(
468 e.error_code,
469 format!(
470 "derived --takeover profile {profile:?} from URL host {normalized_host:?} \
471 is invalid: {}; pass --profile <name>",
472 e.detail
473 ),
474 )
475 })?;
476 Ok(profile)
477}
478
479fn normalize_profile_host(host: &str) -> String {
480 host.trim_end_matches('.').to_ascii_lowercase()
481}
482
483enum FetchRunError {
484 Plain(Error),
485 Emitted(Error),
486}
487
488impl From<Error> for FetchRunError {
489 fn from(err: Error) -> Self {
490 Self::Plain(err)
491 }
492}
493
494async fn resolve_body(args: &Args) -> Result<Option<Vec<u8>>, Error> {
498 if args.data.is_some() && !args.form.is_empty() {
499 return Err(Error::new(
500 ErrorCode::InvalidArgument,
501 "--data and --form are mutually exclusive",
502 ));
503 }
504 if let Some(data) = &args.data {
505 if let Some(path) = data.strip_prefix('@') {
506 return Ok(Some(tokio::fs::read(path).await.map_err(|e| {
507 Error::new(ErrorCode::IoError, format!("--data @{path}: {e}"))
508 })?));
509 }
510 return Ok(Some(data.as_bytes().to_vec()));
511 }
512 Ok(None)
513}
514
515fn resolve_want(want: &[String]) -> Result<std::collections::BTreeSet<Artifact>, Error> {
518 if want.is_empty() {
519 return Ok(Artifact::ALL.iter().copied().collect());
520 }
521 want.iter().map(|t| parse_artifact(t)).collect()
522}
523
524async fn build_client(args: &Args, render: RenderMode) -> Result<Client, Error> {
528 match args.endpoint.as_deref() {
529 Some(ep) => {
530 let mut c = Client::connect(ep)?;
531 if let Some(t) = args.token.as_deref() {
532 c = c.with_token(t);
533 }
534 Ok(c)
535 }
536 None if matches!(render, RenderMode::None) => Client::http_only(),
537 None => {
538 let cfg = InlineConfig {
539 browser: args.browser.into(),
540 browser_bin: args.browser_bin.clone(),
541 };
542 if matches!(render, RenderMode::Auto) {
543 Client::inline_ephemeral_lazy(cfg).await
544 } else {
545 Client::inline_ephemeral_with(cfg).await
546 }
547 }
548 }
549}
550
551fn parse_artifact(token: &str) -> Result<Artifact, Error> {
552 Ok(match token {
553 "body" => Artifact::Body,
554 "rendered_html" => Artifact::RenderedHtml,
555 "text" => Artifact::Text,
556 "content" => Artifact::Content,
557 "content_json" => Artifact::ContentJson,
558 "screenshot" => Artifact::Screenshot,
559 "network" => Artifact::Network,
560 "console" => Artifact::Console,
561 "observation" => Artifact::Observation,
562 "storage" => Artifact::Storage,
563 other => {
564 return Err(Error::new(
565 ErrorCode::InvalidArgument,
566 format!("--want: unknown artifact {other:?}"),
567 ));
568 }
569 })
570}
571
572fn parse_header_arg(raw: &str) -> Result<(String, String), Error> {
573 let (name, value) = raw.split_once(':').ok_or_else(|| {
574 Error::new(
575 ErrorCode::InvalidArgument,
576 format!("--header: expected K:V, got {raw:?}"),
577 )
578 })?;
579 let name = name.trim();
580 if name.is_empty() {
581 return Err(Error::new(
582 ErrorCode::InvalidArgument,
583 format!("--header: header name must not be empty in {raw:?}"),
584 ));
585 }
586 Ok((name.to_string(), value.trim_start().to_string()))
587}
588
589fn parse_cookie_arg(raw: &str) -> Result<FetchCookie, Error> {
590 if !raw.contains('=') {
591 return Err(Error::new(
592 ErrorCode::InvalidArgument,
593 format!("--cookie: expected Set-Cookie style name=value, got {raw:?}"),
594 ));
595 }
596 let cookie = FetchCookie::parse(raw.to_string())
597 .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--cookie: {e}")))?
598 .into_owned();
599 if cookie.name().trim().is_empty() {
600 return Err(Error::new(
601 ErrorCode::InvalidArgument,
602 format!("--cookie: cookie name must not be empty in {raw:?}"),
603 ));
604 }
605 Ok(cookie)
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 fn base_args(url: &str) -> Args {
613 Args {
614 url: url.to_string(),
615 endpoint: None,
616 token: None,
617 browser: BrowserArg::Auto,
618 browser_bin: None,
619 render: RenderArg::Auto,
620 tab: "new".into(),
621 takeover: false,
622 profile: None,
623 wait: "auto".into(),
624 headers: Vec::new(),
625 cookies: Vec::new(),
626 user_agent: None,
627 evaluate_after_wait: Vec::new(),
628 want: Vec::new(),
629 method: "GET".into(),
630 data: None,
631 form: Vec::new(),
632 network_bodies: NetworkBodiesArg::Off,
633 network_body_max_bytes: DEFAULT_NETWORK_BODY_MAX_BYTES,
634 readiness_idle_ms: 800,
635 readiness_stable_ms: 500,
636 readiness_min_text_bytes: 32,
637 no_network_redact: false,
638 out: None,
639 cookie_jar: None,
640 no_cookie_jar: false,
641 observe_main_wait_ms: 500,
642 max_response_bytes: 1_073_741_824,
643 retry: 0,
644 backoff_ms: 250,
645 proxy: None,
646 ca_cert: None,
647 tls_insecure: false,
648 timeout_ms: 30_000,
649 capture_ws: false,
650 capture_sse: false,
651 }
652 }
653
654 #[tokio::test]
655 async fn takeover_autodiscovery_fills_missing_endpoint_and_token() {
656 let mut args = base_args("https://contabo.com");
657 args.takeover = true;
658 prepare_takeover_connection(&mut args, RenderMode::Auto, |token| async move {
659 assert_eq!(token, None);
660 Ok(crate::cli::cmd::container::LocalTakeoverHost {
661 endpoint: "ws://127.0.0.1:9222".into(),
662 token_secret: Some("secret".into()),
663 })
664 })
665 .await
666 .unwrap();
667
668 assert_eq!(args.endpoint.as_deref(), Some("ws://127.0.0.1:9222"));
669 assert_eq!(args.token.as_deref(), Some("secret"));
670 }
671
672 #[tokio::test]
673 async fn takeover_autodiscovery_preserves_existing_token() {
674 let mut args = base_args("https://contabo.com");
675 args.takeover = true;
676 args.token = Some("env-token".into());
677 prepare_takeover_connection(&mut args, RenderMode::Auto, |token| async move {
678 assert_eq!(token.as_deref(), Some("env-token"));
679 Ok(crate::cli::cmd::container::LocalTakeoverHost {
680 endpoint: "ws://127.0.0.1:9222".into(),
681 token_secret: Some("container-token".into()),
682 })
683 })
684 .await
685 .unwrap();
686
687 assert_eq!(args.endpoint.as_deref(), Some("ws://127.0.0.1:9222"));
688 assert_eq!(args.token.as_deref(), Some("env-token"));
689 }
690
691 #[tokio::test]
692 async fn takeover_autodiscovery_surfaces_failure() {
693 let mut args = base_args("https://contabo.com");
694 args.takeover = true;
695 let err = prepare_takeover_connection(&mut args, RenderMode::Auto, |_| async {
696 Err(Error::new(
697 ErrorCode::InvalidArgument,
698 "default local container `afhttp-host` is not running",
699 ))
700 })
701 .await
702 .err()
703 .unwrap();
704
705 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
706 assert!(err.detail.contains("afhttp-host"));
707 assert!(args.endpoint.is_none());
708 }
709
710 #[tokio::test]
711 async fn takeover_autodiscovery_rejects_render_none() {
712 let mut args = base_args("https://contabo.com");
713 args.takeover = true;
714 let err = prepare_takeover_connection(&mut args, RenderMode::None, |_| async {
715 Ok(crate::cli::cmd::container::LocalTakeoverHost {
716 endpoint: "ws://127.0.0.1:9222".into(),
717 token_secret: None,
718 })
719 })
720 .await
721 .err()
722 .unwrap();
723
724 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
725 assert!(err.detail.contains("browser render"));
726 }
727
728 #[test]
729 fn default_profile_uses_registrable_domain() {
730 assert_eq!(
731 default_profile_for_url("https://www.court.gov.cn/foo").unwrap(),
732 "court.gov.cn"
733 );
734 assert_eq!(
735 default_profile_for_url("https://accounts.google.com/foo").unwrap(),
736 "google.com"
737 );
738 assert_eq!(
739 default_profile_for_url("https://contabo.com").unwrap(),
740 "contabo.com"
741 );
742 }
743
744 #[test]
745 fn default_profile_keeps_public_suffix_tenants_isolated() {
746 assert_eq!(
747 default_profile_for_url("https://foo.github.io/x").unwrap(),
748 "foo.github.io"
749 );
750 assert_eq!(
751 default_profile_for_url("https://tenant.vercel.app/x").unwrap(),
752 "tenant.vercel.app"
753 );
754 }
755
756 #[test]
757 fn default_profile_normalizes_case_and_trailing_dot() {
758 assert_eq!(
759 default_profile_for_url("https://WWW.Example.COM./foo").unwrap(),
760 "example.com"
761 );
762 }
763
764 #[test]
765 fn default_profile_falls_back_to_full_host_for_psl_misses_and_ips() {
766 assert_eq!(
767 default_profile_for_url("http://localhost:8080/foo").unwrap(),
768 "localhost"
769 );
770 assert_eq!(
771 default_profile_for_url("http://127.0.0.1:8080/foo").unwrap(),
772 "127.0.0.1"
773 );
774 }
775
776 #[test]
777 fn default_profile_errors_when_host_is_missing() {
778 assert!(default_profile_for_url("file:///tmp/page.html").is_err());
779 }
780
781 #[test]
782 fn header_arg_accepts_colon_separator() {
783 assert_eq!(
784 parse_header_arg("X-Test: yes").unwrap(),
785 ("X-Test".to_string(), "yes".to_string())
786 );
787 }
788
789 #[test]
790 fn header_arg_rejects_missing_colon() {
791 let err = parse_header_arg("X-Test").err().unwrap();
792 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
793 }
794
795 #[test]
796 fn cookie_arg_accepts_equals_separator() {
797 let cookie = parse_cookie_arg("sid=abc=def").unwrap();
798 assert_eq!(cookie.name_value(), ("sid", "abc=def"));
799 }
800
801 #[test]
802 fn cookie_arg_accepts_full_set_cookie_attributes() {
803 let cookie = parse_cookie_arg("sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax").unwrap();
804 assert_eq!(cookie.name_value(), ("sid", "abc"));
805 assert_eq!(cookie.path(), Some("/"));
806 assert_eq!(cookie.secure(), Some(true));
807 assert_eq!(cookie.http_only(), Some(true));
808 assert_eq!(cookie.same_site(), Some(cookie::SameSite::Lax));
809 }
810
811 #[test]
812 fn cookie_arg_rejects_missing_equals() {
813 let err = parse_cookie_arg("sid").err().unwrap();
814 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
815 }
816}