1use 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
29const 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 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 let registry = Registry::open()?;
73 let _bidi_lock = acquire_bidi_lock_if_needed(®istry, &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 trace.route("poll");
105 }
106
107 println!("{}", matched.name);
108 Ok(())
109 }
110 .await;
111 trace.finish(result)
112}
113
114async 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
150pub(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
156pub(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 assert!(!cookie_matches(
297 &cookie("example.com", "session_id"),
298 &d,
299 &n
300 ));
301 assert!(!cookie_matches(&cookie("other.test", "session"), &d, &n));
303 assert!(cookie_matches(&cookie("example.com", "session"), &d, &n));
305 }
306
307 #[test]
308 fn cookie_matches_anchored_regex() {
309 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 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}