browser_control/cli/
wait_for_cookie.rs1use std::time::{Duration, Instant};
12
13use anyhow::{bail, Context, Result};
14use regex::Regex;
15use serde_json::Value;
16use tokio::time::sleep;
17
18use crate::cli::cookies::{fetch_cookies, NormalCookie};
19use crate::cli::mcp::resolve_browser;
20use crate::dom::scripts::FETCH_JS;
21use crate::session::PageSession;
22
23pub async fn run(
24 browser: Option<String>,
25 domain: String,
26 name: String,
27 timeout: u64,
28 poll_interval: u64,
29 validate_url: Option<String>,
30) -> Result<()> {
31 let domain_re = Regex::new(&domain).context("invalid --domain regex")?;
32 let name_re = Regex::new(&name).context("invalid --name regex")?;
33
34 let resolved = resolve_browser(browser).await?;
35
36 let deadline = Instant::now() + Duration::from_secs(timeout);
37 let interval = Duration::from_secs(poll_interval.max(1));
38
39 let matched = loop {
40 let cookies = fetch_cookies(&resolved).await?;
41 if let Some(c) = cookies
42 .into_iter()
43 .find(|c| cookie_matches(c, &domain_re, &name_re))
44 {
45 break c;
46 }
47 if Instant::now() >= deadline {
48 bail!("timed out waiting for cookie");
49 }
50 let remaining = deadline.saturating_duration_since(Instant::now());
51 let nap = std::cmp::min(interval, remaining);
52 if nap.is_zero() {
53 bail!("timed out waiting for cookie");
54 }
55 sleep(nap).await;
56 };
57
58 eprintln!("cookie {} appeared on {}", matched.name, matched.domain);
59
60 if let Some(url) = validate_url {
61 let session = PageSession::attach(&resolved.endpoint, resolved.engine, None).await?;
62 let result = validate_via_page(&session, &url).await;
63 session.close().await;
64 result?;
65 }
66
67 println!("{}", matched.name);
68 Ok(())
69}
70
71pub(crate) fn cookie_matches(c: &NormalCookie, domain_re: &Regex, name_re: &Regex) -> bool {
74 domain_re.is_match(&c.domain) && name_re.is_match(&c.name)
75}
76
77async fn validate_via_page(session: &PageSession, url: &str) -> Result<()> {
78 let args = serde_json::json!({ "url": url, "method": "GET" }).to_string();
79 let expr = format!("({})({})", FETCH_JS, serde_json::to_string(&args).unwrap());
80 let value = session.evaluate(&expr, true).await?;
81 let json_str = value.as_str().ok_or_else(|| {
82 anyhow::anyhow!("validate-url: page returned non-string from fetch script")
83 })?;
84 let parsed: Value = serde_json::from_str(json_str)
85 .context("validate-url: failed to parse fetch response envelope")?;
86 let status = parsed
87 .get("status")
88 .and_then(|v| v.as_i64())
89 .ok_or_else(|| anyhow::anyhow!("validate-url: missing `status` in fetch response"))?;
90 validate_status(status)
91}
92
93pub(crate) fn validate_status(status: i64) -> Result<()> {
95 if (200..=299).contains(&status) {
96 Ok(())
97 } else {
98 bail!("validate-url failed: status {status}");
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 fn cookie(domain: &str, name: &str) -> NormalCookie {
107 NormalCookie {
108 domain: domain.to_string(),
109 name: name.to_string(),
110 value: "v".to_string(),
111 path: "/".to_string(),
112 secure: false,
113 http_only: false,
114 same_site: None,
115 expires: None,
116 }
117 }
118
119 #[test]
120 fn cookie_matches_unanchored_domain_and_name() {
121 let d = Regex::new(r"example\.com").unwrap();
122 let n = Regex::new(r"session").unwrap();
123 assert!(cookie_matches(
124 &cookie("www.example.com", "session_id"),
125 &d,
126 &n
127 ));
128 assert!(cookie_matches(
129 &cookie(".example.com", "my_session"),
130 &d,
131 &n
132 ));
133 }
134
135 #[test]
136 fn cookie_matches_requires_both() {
137 let d = Regex::new(r"example\.com").unwrap();
138 let n = Regex::new(r"^session$").unwrap();
139 assert!(!cookie_matches(
141 &cookie("example.com", "session_id"),
142 &d,
143 &n
144 ));
145 assert!(!cookie_matches(&cookie("other.test", "session"), &d, &n));
147 assert!(cookie_matches(&cookie("example.com", "session"), &d, &n));
149 }
150
151 #[test]
152 fn cookie_matches_anchored_regex() {
153 let d = Regex::new(r".*").unwrap();
155 let n = Regex::new(r"^csrf$").unwrap();
156 assert!(cookie_matches(&cookie("a.test", "csrf"), &d, &n));
157 assert!(!cookie_matches(&cookie("a.test", "csrf_token"), &d, &n));
158 }
159
160 #[test]
161 fn validate_status_2xx_passes() {
162 assert!(validate_status(200).is_ok());
163 assert!(validate_status(204).is_ok());
164 assert!(validate_status(299).is_ok());
165 }
166
167 #[test]
168 fn validate_status_non_2xx_fails() {
169 assert!(validate_status(199).is_err());
170 assert!(validate_status(300).is_err());
171 assert!(validate_status(404).is_err());
172 assert!(validate_status(500).is_err());
173 let err = validate_status(403).unwrap_err().to_string();
174 assert!(err.contains("403"), "error should mention status: {err}");
175 }
176
177 async fn wait_loop<F>(
180 mut fetch: F,
181 domain_re: &Regex,
182 name_re: &Regex,
183 timeout: Duration,
184 interval: Duration,
185 ) -> Result<NormalCookie>
186 where
187 F: FnMut() -> Vec<NormalCookie>,
188 {
189 let deadline = Instant::now() + timeout;
190 loop {
191 let cookies = fetch();
192 if let Some(c) = cookies
193 .into_iter()
194 .find(|c| cookie_matches(c, domain_re, name_re))
195 {
196 return Ok(c);
197 }
198 if Instant::now() >= deadline {
199 bail!("timed out waiting for cookie");
200 }
201 let remaining = deadline.saturating_duration_since(Instant::now());
202 let nap = std::cmp::min(interval, remaining);
203 if nap.is_zero() {
204 bail!("timed out waiting for cookie");
205 }
206 sleep(nap).await;
207 }
208 }
209
210 #[tokio::test(start_paused = true)]
211 async fn wait_loop_times_out_when_cookie_never_appears() {
212 let d = Regex::new(r"example\.com").unwrap();
213 let n = Regex::new(r"^sid$").unwrap();
214 let err = wait_loop(
215 Vec::new,
216 &d,
217 &n,
218 Duration::from_secs(3),
219 Duration::from_secs(1),
220 )
221 .await
222 .unwrap_err();
223 assert!(err.to_string().contains("timed out"));
224 }
225
226 #[tokio::test(start_paused = true)]
227 async fn wait_loop_returns_first_match() {
228 let d = Regex::new(r"example\.com").unwrap();
229 let n = Regex::new(r"^sid$").unwrap();
230 let mut calls = 0;
231 let fetch = move || {
232 calls += 1;
233 if calls >= 2 {
234 vec![cookie("www.example.com", "sid")]
235 } else {
236 vec![cookie("www.example.com", "other")]
237 }
238 };
239 let got = wait_loop(
240 fetch,
241 &d,
242 &n,
243 Duration::from_secs(10),
244 Duration::from_secs(1),
245 )
246 .await
247 .unwrap();
248 assert_eq!(got.name, "sid");
249 assert_eq!(got.domain, "www.example.com");
250 }
251}