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::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
30const 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 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!({
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
156pub(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
162pub(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 assert!(!cookie_matches(
303 &cookie("example.com", "session_id"),
304 &d,
305 &n
306 ));
307 assert!(!cookie_matches(&cookie("other.test", "session"), &d, &n));
309 assert!(cookie_matches(&cookie("example.com", "session"), &d, &n));
311 }
312
313 #[test]
314 fn cookie_matches_anchored_regex() {
315 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 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}