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