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