Skip to main content

browser_control/cli/
wait_for_cookie.rs

1//! `browser-control wait-for-cookie` — block until a cookie appears.
2//!
3//! v1 strategy: **polling only**. The plan envisions an event-driven path via
4//! CDP `Network.responseReceived` / BiDi `network.responseCompleted`, but for
5//! v1 simplicity we poll `Network.getAllCookies` / `storage.getCookies` at a
6//! fixed interval until the matching cookie appears or the timeout elapses.
7//!
8//! After a match, an optional `--validate-url` performs a `fetch()` from the
9//! page context (credentials included) and requires a 2xx status.
10
11use 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
71/// Returns true when both regexes match the cookie's domain and name. Both
72/// regexes are unanchored (`Regex::is_match` semantics).
73pub(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
93/// Require a 2xx status; otherwise produce an error.
94pub(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        // wrong name
140        assert!(!cookie_matches(
141            &cookie("example.com", "session_id"),
142            &d,
143            &n
144        ));
145        // wrong domain
146        assert!(!cookie_matches(&cookie("other.test", "session"), &d, &n));
147        // both ok
148        assert!(cookie_matches(&cookie("example.com", "session"), &d, &n));
149    }
150
151    #[test]
152    fn cookie_matches_anchored_regex() {
153        // `^csrf$` strictly matches the literal name `csrf`.
154        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    /// Pure poll-loop helper mirroring `run`'s timing logic, parameterised
178    /// over a synchronous fetch closure so it can be tested without a browser.
179    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}