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 `Network.getAllCookies` / `storage.getCookies` at a
8//! fixed interval until the 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 uses `Network.getAllCookies` / `storage.getCookies`
50        // which are 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!({ "url": url, "method": "GET" }).to_string();
125    let expr = format!("({})({})", FETCH_JS, serde_json::to_string(&args).unwrap());
126
127    trace.route("attach-for-origin");
128    let value = evaluate_for_origin_with_recover_once(
129        &resolved.endpoint,
130        resolved.engine,
131        url,
132        &expr,
133        true,
134        VALIDATE_TIMEOUT,
135        max_age,
136    )
137    .await?;
138
139    let json_str = value.as_str().ok_or_else(|| {
140        anyhow::anyhow!("validate-url: page returned non-string from fetch script")
141    })?;
142    let parsed: Value = serde_json::from_str(json_str)
143        .context("validate-url: failed to parse fetch response envelope")?;
144    let status = parsed
145        .get("status")
146        .and_then(|v| v.as_i64())
147        .ok_or_else(|| anyhow::anyhow!("validate-url: missing `status` in fetch response"))?;
148    validate_status(status)
149}
150
151/// Returns true when both regexes match the cookie's domain and name. Both
152/// regexes are unanchored (`Regex::is_match` semantics).
153pub(crate) fn cookie_matches(c: &NormalCookie, domain_re: &Regex, name_re: &Regex) -> bool {
154    domain_re.is_match(&c.domain) && name_re.is_match(&c.name)
155}
156
157/// Require a 2xx status; otherwise produce an error.
158pub(crate) fn validate_status(status: i64) -> Result<()> {
159    if (200..=299).contains(&status) {
160        Ok(())
161    } else {
162        bail!("validate-url failed: status {status}");
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::cli::env_resolver::{ResolvedBrowser, Source};
170    use crate::detect::Engine;
171    use futures_util::{SinkExt, StreamExt};
172    use serde_json::json;
173    use std::sync::Arc;
174    use tokio::sync::Mutex;
175    use tokio_tungstenite::tungstenite::Message;
176
177    fn cookie(domain: &str, name: &str) -> NormalCookie {
178        NormalCookie {
179            domain: domain.to_string(),
180            name: name.to_string(),
181            value: "v".to_string(),
182            path: "/".to_string(),
183            secure: false,
184            http_only: false,
185            same_site: None,
186            expires: None,
187        }
188    }
189
190    async fn spawn_validate_cdp_mock() -> (String, Arc<Mutex<Vec<String>>>) {
191        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
192        let addr = listener.local_addr().unwrap();
193        let created_urls = Arc::new(Mutex::new(Vec::new()));
194        tokio::spawn({
195            let created_urls = created_urls.clone();
196            async move {
197                let (stream, _) = listener.accept().await.unwrap();
198                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
199                while let Some(Ok(Message::Text(t))) = ws.next().await {
200                    let req: Value = serde_json::from_str(&t).unwrap();
201                    let id = req["id"].as_u64().unwrap();
202                    let method = req["method"].as_str().unwrap_or("");
203                    let result = match method {
204                        "Target.getTargets" => json!({
205                            "targetInfos": [{
206                                "targetId": "OTHER",
207                                "type": "page",
208                                "url": "https://other.test/",
209                            }]
210                        }),
211                        "Target.createTarget" => {
212                            let url = req
213                                .pointer("/params/url")
214                                .and_then(|v| v.as_str())
215                                .unwrap_or("")
216                                .to_string();
217                            created_urls.lock().await.push(url);
218                            json!({"targetId": "NEW"})
219                        }
220                        "Target.attachToTarget" => json!({"sessionId": "S1"}),
221                        "Target.detachFromTarget" => json!({}),
222                        "Inspector.enable" => json!({}),
223                        "Runtime.evaluate" => {
224                            let expression = req
225                                .pointer("/params/expression")
226                                .and_then(|v| v.as_str())
227                                .unwrap_or("");
228                            let value = if expression == freshness::PAGE_FRESHNESS_EXPR {
229                                json!({
230                                    "href": "https://example.com/",
231                                    "ageMs": 0.0,
232                                    "readyState": "complete"
233                                })
234                            } else if expression == freshness::READY_STATE_EXPR {
235                                json!("complete")
236                            } else {
237                                json!(json!({"status": 204}).to_string())
238                            };
239                            json!({"result": {"value": value}})
240                        }
241                        _ => json!({}),
242                    };
243                    let resp = json!({"id": id, "result": result});
244                    ws.send(Message::Text(resp.to_string())).await.unwrap();
245                }
246            }
247        });
248        (format!("ws://{addr}"), created_urls)
249    }
250
251    #[tokio::test]
252    async fn validate_url_registered_browser_uses_origin_tab_not_scratch() {
253        let (endpoint, created_urls) = spawn_validate_cdp_mock().await;
254        let resolved = ResolvedBrowser {
255            endpoint,
256            engine: Engine::Cdp,
257            source: Source::Registered {
258                name: "chrome-test".to_string(),
259            },
260        };
261        let mut trace = CommandTrace::new("wait-for-cookie");
262        run_validate_url(
263            &resolved,
264            "https://example.com/api/check",
265            freshness::DEFAULT_MAX_AGE,
266            &mut trace,
267        )
268        .await
269        .unwrap();
270        assert_eq!(
271            *created_urls.lock().await,
272            vec!["https://example.com/".to_string()]
273        );
274    }
275
276    #[test]
277    fn cookie_matches_unanchored_domain_and_name() {
278        let d = Regex::new(r"example\.com").unwrap();
279        let n = Regex::new(r"session").unwrap();
280        assert!(cookie_matches(
281            &cookie("www.example.com", "session_id"),
282            &d,
283            &n
284        ));
285        assert!(cookie_matches(
286            &cookie(".example.com", "my_session"),
287            &d,
288            &n
289        ));
290    }
291
292    #[test]
293    fn cookie_matches_requires_both() {
294        let d = Regex::new(r"example\.com").unwrap();
295        let n = Regex::new(r"^session$").unwrap();
296        // wrong name
297        assert!(!cookie_matches(
298            &cookie("example.com", "session_id"),
299            &d,
300            &n
301        ));
302        // wrong domain
303        assert!(!cookie_matches(&cookie("other.test", "session"), &d, &n));
304        // both ok
305        assert!(cookie_matches(&cookie("example.com", "session"), &d, &n));
306    }
307
308    #[test]
309    fn cookie_matches_anchored_regex() {
310        // `^csrf$` strictly matches the literal name `csrf`.
311        let d = Regex::new(r".*").unwrap();
312        let n = Regex::new(r"^csrf$").unwrap();
313        assert!(cookie_matches(&cookie("a.test", "csrf"), &d, &n));
314        assert!(!cookie_matches(&cookie("a.test", "csrf_token"), &d, &n));
315    }
316
317    #[test]
318    fn validate_status_2xx_passes() {
319        assert!(validate_status(200).is_ok());
320        assert!(validate_status(204).is_ok());
321        assert!(validate_status(299).is_ok());
322    }
323
324    #[test]
325    fn validate_status_non_2xx_fails() {
326        assert!(validate_status(199).is_err());
327        assert!(validate_status(300).is_err());
328        assert!(validate_status(404).is_err());
329        assert!(validate_status(500).is_err());
330        let err = validate_status(403).unwrap_err().to_string();
331        assert!(err.contains("403"), "error should mention status: {err}");
332    }
333
334    /// Pure poll-loop helper mirroring `run`'s timing logic, parameterised
335    /// over a synchronous fetch closure so it can be tested without a browser.
336    async fn wait_loop<F>(
337        mut fetch: F,
338        domain_re: &Regex,
339        name_re: &Regex,
340        timeout: Duration,
341        interval: Duration,
342    ) -> Result<NormalCookie>
343    where
344        F: FnMut() -> Vec<NormalCookie>,
345    {
346        let deadline = Instant::now() + timeout;
347        loop {
348            let cookies = fetch();
349            if let Some(c) = cookies
350                .into_iter()
351                .find(|c| cookie_matches(c, domain_re, name_re))
352            {
353                return Ok(c);
354            }
355            if Instant::now() >= deadline {
356                bail!("timed out waiting for cookie");
357            }
358            let remaining = deadline.saturating_duration_since(Instant::now());
359            let nap = std::cmp::min(interval, remaining);
360            if nap.is_zero() {
361                bail!("timed out waiting for cookie");
362            }
363            sleep(nap).await;
364        }
365    }
366
367    #[tokio::test(start_paused = true)]
368    async fn wait_loop_times_out_when_cookie_never_appears() {
369        let d = Regex::new(r"example\.com").unwrap();
370        let n = Regex::new(r"^sid$").unwrap();
371        let err = wait_loop(
372            Vec::new,
373            &d,
374            &n,
375            Duration::from_secs(3),
376            Duration::from_secs(1),
377        )
378        .await
379        .unwrap_err();
380        assert!(err.to_string().contains("timed out"));
381    }
382
383    #[tokio::test(start_paused = true)]
384    async fn wait_loop_returns_first_match() {
385        let d = Regex::new(r"example\.com").unwrap();
386        let n = Regex::new(r"^sid$").unwrap();
387        let mut calls = 0;
388        let fetch = move || {
389            calls += 1;
390            if calls >= 2 {
391                vec![cookie("www.example.com", "sid")]
392            } else {
393                vec![cookie("www.example.com", "other")]
394            }
395        };
396        let got = wait_loop(
397            fetch,
398            &d,
399            &n,
400            Duration::from_secs(10),
401            Duration::from_secs(1),
402        )
403        .await
404        .unwrap();
405        assert_eq!(got.name, "sid");
406        assert_eq!(got.domain, "www.example.com");
407    }
408}