Skip to main content

browser_control/cli/
wait_for_cookie.rs

1//! `browser-control wait-for-cookie` — block until a cookie appears.
2//!
3//! Browser-wide command — tab suffixes on `--browser` are rejected.
4//!
5//! v1 strategy: **polling only**. The plan envisions an event-driven path via
6//! CDP `Network.responseReceived` / BiDi `network.responseCompleted`, but for
7//! v1 simplicity we poll the browser cookie jar at a fixed interval until the
8//! matching cookie appears or the timeout elapses.
9//!
10//! After a match, an optional `--validate-url` performs a `fetch()` from the
11//! page context (credentials included) and requires a 2xx status.
12
13use std::time::{Duration, Instant};
14
15use anyhow::{bail, Context, Result};
16use regex::Regex;
17use serde_json::Value;
18use tokio::time::sleep;
19
20use crate::cli::cookies::{fetch_cookies, NormalCookie};
21use crate::cli::env_resolver;
22use crate::cli::mcp::{acquire_bidi_lock_if_needed, resolve_browser};
23use crate::cli::trace::CommandTrace;
24use crate::dom::scripts::FETCH_JS;
25use crate::registry::Registry;
26use crate::session::evaluate_for_origin_with_recover_once;
27use crate::session::freshness;
28
29/// Per-fetch timeout when `--validate-url` drives a `fetch()` from the
30/// page context. 30 s catches a wedged renderer; the outer polling loop
31/// reruns this periodically.
32const VALIDATE_TIMEOUT: Duration = Duration::from_secs(30);
33
34pub async fn run(
35    browser: Option<String>,
36    domain: String,
37    name: String,
38    timeout: u64,
39    poll_interval: u64,
40    validate_url: Option<String>,
41    max_age: String,
42) -> Result<()> {
43    let mut trace = CommandTrace::new("wait-for-cookie");
44    let result: Result<()> = async {
45        let domain_re = Regex::new(&domain).context("invalid --domain regex")?;
46        let name_re = Regex::new(&name).context("invalid --name regex")?;
47
48        // Reject `<browser>/<tab>` — `wait-for-cookie` is browser-wide.
49        // The cookie poll is browser-scoped; tabs don't apply.
50        let raw = browser.unwrap_or_default();
51        if !raw.is_empty() {
52            let parsed = env_resolver::parse_target(&raw)?;
53            if parsed.tab.is_some() {
54                bail!(
55                    "`wait-for-cookie` operates browser-wide; tab suffixes are not supported \
56                     (got `{raw}`). Use a bare browser selector instead."
57                );
58            }
59        }
60        let resolved = resolve_browser(if raw.is_empty() {
61            None
62        } else {
63            Some(raw.clone())
64        })
65        .await?;
66        trace.browser(&raw).engine(resolved.engine);
67
68        // Hold the Firefox BiDi single-session lock across the whole poll
69        // loop (and the optional validate-url leg). Each `fetch_cookies`
70        // call opens + closes a BiDi session, so without the lock two
71        // concurrent CLI processes would race on `session.new`. No-op on CDP.
72        let registry = Registry::open()?;
73        let _bidi_lock = acquire_bidi_lock_if_needed(&registry, &resolved)?;
74
75        let deadline = Instant::now() + Duration::from_secs(timeout);
76        let interval = Duration::from_secs(poll_interval.max(1));
77
78        let matched = loop {
79            let cookies = fetch_cookies(&resolved).await?;
80            if let Some(c) = cookies
81                .into_iter()
82                .find(|c| cookie_matches(c, &domain_re, &name_re))
83            {
84                break c;
85            }
86            if Instant::now() >= deadline {
87                bail!("timed out waiting for cookie");
88            }
89            let remaining = deadline.saturating_duration_since(Instant::now());
90            let nap = std::cmp::min(interval, remaining);
91            if nap.is_zero() {
92                bail!("timed out waiting for cookie");
93            }
94            sleep(nap).await;
95        };
96
97        eprintln!("cookie {} appeared on {}", matched.name, matched.domain);
98
99        if let Some(url) = validate_url {
100            let max_age = freshness::parse_max_age(&max_age)?;
101            run_validate_url(&resolved, &url, max_age, &mut trace).await?;
102        } else {
103            // No validate-url; only the cookie poll ran (browser-wide).
104            trace.route("poll");
105        }
106
107        println!("{}", matched.name);
108        Ok(())
109    }
110    .await;
111    trace.finish(result)
112}
113
114/// Drive the `--validate-url` fetch from the requested URL's origin, with
115/// recover-once by re-resolving that origin. This intentionally avoids scratch
116/// / `about:blank`, which would drop cookies or trip CORS.
117async fn run_validate_url(
118    resolved: &crate::cli::env_resolver::ResolvedBrowser,
119    url: &str,
120    max_age: Duration,
121    trace: &mut CommandTrace,
122) -> Result<()> {
123    let args = serde_json::json!({ "url": url, "method": "GET" }).to_string();
124    let expr = format!("({})({})", FETCH_JS, serde_json::to_string(&args).unwrap());
125
126    trace.route("attach-for-origin");
127    let value = evaluate_for_origin_with_recover_once(
128        &resolved.endpoint,
129        resolved.engine,
130        url,
131        &expr,
132        true,
133        VALIDATE_TIMEOUT,
134        max_age,
135    )
136    .await?;
137
138    let json_str = value.as_str().ok_or_else(|| {
139        anyhow::anyhow!("validate-url: page returned non-string from fetch script")
140    })?;
141    let parsed: Value = serde_json::from_str(json_str)
142        .context("validate-url: failed to parse fetch response envelope")?;
143    let status = parsed
144        .get("status")
145        .and_then(|v| v.as_i64())
146        .ok_or_else(|| anyhow::anyhow!("validate-url: missing `status` in fetch response"))?;
147    validate_status(status)
148}
149
150/// Returns true when both regexes match the cookie's domain and name. Both
151/// regexes are unanchored (`Regex::is_match` semantics).
152pub(crate) fn cookie_matches(c: &NormalCookie, domain_re: &Regex, name_re: &Regex) -> bool {
153    domain_re.is_match(&c.domain) && name_re.is_match(&c.name)
154}
155
156/// Require a 2xx status; otherwise produce an error.
157pub(crate) fn validate_status(status: i64) -> Result<()> {
158    if (200..=299).contains(&status) {
159        Ok(())
160    } else {
161        bail!("validate-url failed: status {status}");
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::cli::env_resolver::{ResolvedBrowser, Source};
169    use crate::detect::Engine;
170    use futures_util::{SinkExt, StreamExt};
171    use serde_json::json;
172    use std::sync::Arc;
173    use tokio::sync::Mutex;
174    use tokio_tungstenite::tungstenite::Message;
175
176    fn cookie(domain: &str, name: &str) -> NormalCookie {
177        NormalCookie {
178            domain: domain.to_string(),
179            name: name.to_string(),
180            value: "v".to_string(),
181            path: "/".to_string(),
182            secure: false,
183            http_only: false,
184            same_site: None,
185            expires: None,
186        }
187    }
188
189    async fn spawn_validate_cdp_mock() -> (String, Arc<Mutex<Vec<String>>>) {
190        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
191        let addr = listener.local_addr().unwrap();
192        let created_urls = Arc::new(Mutex::new(Vec::new()));
193        tokio::spawn({
194            let created_urls = created_urls.clone();
195            async move {
196                let (stream, _) = listener.accept().await.unwrap();
197                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
198                while let Some(Ok(Message::Text(t))) = ws.next().await {
199                    let req: Value = serde_json::from_str(&t).unwrap();
200                    let id = req["id"].as_u64().unwrap();
201                    let method = req["method"].as_str().unwrap_or("");
202                    let result = match method {
203                        "Target.getTargets" => json!({
204                            "targetInfos": [{
205                                "targetId": "OTHER",
206                                "type": "page",
207                                "url": "https://other.test/",
208                            }]
209                        }),
210                        "Target.createTarget" => {
211                            let url = req
212                                .pointer("/params/url")
213                                .and_then(|v| v.as_str())
214                                .unwrap_or("")
215                                .to_string();
216                            created_urls.lock().await.push(url);
217                            json!({"targetId": "NEW"})
218                        }
219                        "Target.attachToTarget" => json!({"sessionId": "S1"}),
220                        "Target.detachFromTarget" => json!({}),
221                        "Inspector.enable" => json!({}),
222                        "Runtime.evaluate" => {
223                            let expression = req
224                                .pointer("/params/expression")
225                                .and_then(|v| v.as_str())
226                                .unwrap_or("");
227                            let value = if expression == freshness::PAGE_FRESHNESS_EXPR {
228                                json!({
229                                    "href": "https://example.com/",
230                                    "ageMs": 0.0,
231                                    "readyState": "complete"
232                                })
233                            } else if expression == freshness::READY_STATE_EXPR {
234                                json!("complete")
235                            } else {
236                                json!(json!({"status": 204}).to_string())
237                            };
238                            json!({"result": {"value": value}})
239                        }
240                        _ => json!({}),
241                    };
242                    let resp = json!({"id": id, "result": result});
243                    ws.send(Message::Text(resp.to_string())).await.unwrap();
244                }
245            }
246        });
247        (format!("ws://{addr}"), created_urls)
248    }
249
250    #[tokio::test]
251    async fn validate_url_registered_browser_uses_origin_tab_not_scratch() {
252        let (endpoint, created_urls) = spawn_validate_cdp_mock().await;
253        let resolved = ResolvedBrowser {
254            endpoint,
255            engine: Engine::Cdp,
256            source: Source::Registered {
257                name: "chrome-test".to_string(),
258            },
259        };
260        let mut trace = CommandTrace::new("wait-for-cookie");
261        run_validate_url(
262            &resolved,
263            "https://example.com/api/check",
264            freshness::DEFAULT_MAX_AGE,
265            &mut trace,
266        )
267        .await
268        .unwrap();
269        assert_eq!(
270            *created_urls.lock().await,
271            vec!["https://example.com/".to_string()]
272        );
273    }
274
275    #[test]
276    fn cookie_matches_unanchored_domain_and_name() {
277        let d = Regex::new(r"example\.com").unwrap();
278        let n = Regex::new(r"session").unwrap();
279        assert!(cookie_matches(
280            &cookie("www.example.com", "session_id"),
281            &d,
282            &n
283        ));
284        assert!(cookie_matches(
285            &cookie(".example.com", "my_session"),
286            &d,
287            &n
288        ));
289    }
290
291    #[test]
292    fn cookie_matches_requires_both() {
293        let d = Regex::new(r"example\.com").unwrap();
294        let n = Regex::new(r"^session$").unwrap();
295        // wrong name
296        assert!(!cookie_matches(
297            &cookie("example.com", "session_id"),
298            &d,
299            &n
300        ));
301        // wrong domain
302        assert!(!cookie_matches(&cookie("other.test", "session"), &d, &n));
303        // both ok
304        assert!(cookie_matches(&cookie("example.com", "session"), &d, &n));
305    }
306
307    #[test]
308    fn cookie_matches_anchored_regex() {
309        // `^csrf$` strictly matches the literal name `csrf`.
310        let d = Regex::new(r".*").unwrap();
311        let n = Regex::new(r"^csrf$").unwrap();
312        assert!(cookie_matches(&cookie("a.test", "csrf"), &d, &n));
313        assert!(!cookie_matches(&cookie("a.test", "csrf_token"), &d, &n));
314    }
315
316    #[test]
317    fn validate_status_2xx_passes() {
318        assert!(validate_status(200).is_ok());
319        assert!(validate_status(204).is_ok());
320        assert!(validate_status(299).is_ok());
321    }
322
323    #[test]
324    fn validate_status_non_2xx_fails() {
325        assert!(validate_status(199).is_err());
326        assert!(validate_status(300).is_err());
327        assert!(validate_status(404).is_err());
328        assert!(validate_status(500).is_err());
329        let err = validate_status(403).unwrap_err().to_string();
330        assert!(err.contains("403"), "error should mention status: {err}");
331    }
332
333    /// Pure poll-loop helper mirroring `run`'s timing logic, parameterised
334    /// over a synchronous fetch closure so it can be tested without a browser.
335    async fn wait_loop<F>(
336        mut fetch: F,
337        domain_re: &Regex,
338        name_re: &Regex,
339        timeout: Duration,
340        interval: Duration,
341    ) -> Result<NormalCookie>
342    where
343        F: FnMut() -> Vec<NormalCookie>,
344    {
345        let deadline = Instant::now() + timeout;
346        loop {
347            let cookies = fetch();
348            if let Some(c) = cookies
349                .into_iter()
350                .find(|c| cookie_matches(c, domain_re, name_re))
351            {
352                return Ok(c);
353            }
354            if Instant::now() >= deadline {
355                bail!("timed out waiting for cookie");
356            }
357            let remaining = deadline.saturating_duration_since(Instant::now());
358            let nap = std::cmp::min(interval, remaining);
359            if nap.is_zero() {
360                bail!("timed out waiting for cookie");
361            }
362            sleep(nap).await;
363        }
364    }
365
366    #[tokio::test(start_paused = true)]
367    async fn wait_loop_times_out_when_cookie_never_appears() {
368        let d = Regex::new(r"example\.com").unwrap();
369        let n = Regex::new(r"^sid$").unwrap();
370        let err = wait_loop(
371            Vec::new,
372            &d,
373            &n,
374            Duration::from_secs(3),
375            Duration::from_secs(1),
376        )
377        .await
378        .unwrap_err();
379        assert!(err.to_string().contains("timed out"));
380    }
381
382    #[tokio::test(start_paused = true)]
383    async fn wait_loop_returns_first_match() {
384        let d = Regex::new(r"example\.com").unwrap();
385        let n = Regex::new(r"^sid$").unwrap();
386        let mut calls = 0;
387        let fetch = move || {
388            calls += 1;
389            if calls >= 2 {
390                vec![cookie("www.example.com", "sid")]
391            } else {
392                vec![cookie("www.example.com", "other")]
393            }
394        };
395        let got = wait_loop(
396            fetch,
397            &d,
398            &n,
399            Duration::from_secs(10),
400            Duration::from_secs(1),
401        )
402        .await
403        .unwrap();
404        assert_eq!(got.name, "sid");
405        assert_eq!(got.domain, "www.example.com");
406    }
407}