1use std::path::PathBuf;
16use std::process::Stdio;
17use std::sync::Arc;
18
19use serde::Serialize;
20use tokio::io::{AsyncBufReadExt, BufReader};
21use tokio::process::{Child, Command};
22use tokio::sync::Mutex;
23
24use crate::browser::Session;
25use crate::browser::session::ApiError;
26
27const INSTRUCTIONS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
29
30const READY_MARKER: &str = "Sign in by hand";
32
33#[derive(Debug, Clone, Serialize, serde::Deserialize, schemars::JsonSchema)]
36#[serde(rename_all = "camelCase", default)]
37pub struct LoginProgress {
38 pub state: String,
40 pub detail: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub account: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
47 pub instructions: Option<String>,
48}
49
50impl Default for LoginProgress {
51 fn default() -> Self {
52 Self::new("notStarted", String::new())
53 }
54}
55
56impl LoginProgress {
57 fn new(state: &str, detail: impl Into<String>) -> Self {
58 Self {
59 state: state.to_string(),
60 detail: detail.into(),
61 account: None,
62 instructions: None,
63 }
64 }
65}
66
67#[async_trait::async_trait]
70pub trait LoginFlow: Send + Sync {
71 async fn start(&self, debug_port: u16) -> Result<LoginProgress, ApiError>;
73 async fn status(&self) -> Result<LoginProgress, ApiError>;
74 async fn cancel(&self) -> Result<LoginProgress, ApiError>;
75}
76
77struct Running {
78 child: Child,
79 output: Arc<Mutex<String>>,
81}
82
83pub struct ChildLogin {
84 session: Arc<Session>,
85 running: Mutex<Option<Running>>,
86 #[cfg(test)]
89 spawn_override: Option<(PathBuf, Vec<String>)>,
90}
91
92impl ChildLogin {
93 pub fn new(session: Arc<Session>) -> Self {
94 Self {
95 session,
96 running: Mutex::new(None),
97 #[cfg(test)]
98 spawn_override: None,
99 }
100 }
101
102 #[cfg(all(test, unix))]
104 fn with_command(session: Arc<Session>, program: &str, args: &[&str]) -> Self {
105 Self {
106 session,
107 running: Mutex::new(None),
108 spawn_override: Some((
109 PathBuf::from(program),
110 args.iter().map(|a| a.to_string()).collect(),
111 )),
112 }
113 }
114
115 pub async fn shutdown(&self) {
123 if let Some(mut running) = self.running.lock().await.take() {
124 terminate_group(&mut running.child).await;
125 }
126 }
127
128 fn spawn_target(&self, debug_port: u16) -> Result<(PathBuf, Vec<String>), ApiError> {
129 #[cfg(test)]
130 if let Some((program, args)) = &self.spawn_override {
131 return Ok((program.clone(), args.clone()));
132 }
133 let exe = std::env::current_exe().map_err(|e| {
134 ApiError::Other(anyhow::anyhow!(
135 "cannot find this executable to re-run it: {e}"
136 ))
137 })?;
138 Ok((
139 exe,
140 vec![
141 "login".to_string(),
142 "--port".to_string(),
143 debug_port.to_string(),
144 ],
145 ))
146 }
147}
148
149#[async_trait::async_trait]
150impl LoginFlow for ChildLogin {
151 async fn start(&self, debug_port: u16) -> Result<LoginProgress, ApiError> {
152 let mut slot = self.running.lock().await;
153 if let Some(running) = slot.as_mut() {
154 if running.child.try_wait().map_err(wrap)?.is_none() {
157 let output = running.output.lock().await.clone();
158 let mut progress = LoginProgress::new(
159 "waiting",
160 "A login is already in progress. Give the user these instructions.",
161 );
162 progress.instructions = Some(output);
163 return Ok(progress);
164 }
165 *slot = None;
170 self.session.resume_after_login().await;
171 }
172
173 self.session.release_for_login().await?;
176
177 let (exe, args) = self.spawn_target(debug_port)?;
178 let mut command = Command::new(&exe);
179 command
180 .args(&args)
181 .stdin(Stdio::null())
185 .stdout(Stdio::piped())
186 .stderr(Stdio::piped())
187 .kill_on_drop(true);
188 #[cfg(unix)]
193 command.process_group(0);
194 let spawned = command.spawn();
195
196 let mut child = match spawned {
197 Ok(child) => child,
198 Err(e) => {
199 self.session.resume_after_login().await;
200 return Err(ApiError::Other(anyhow::anyhow!(
201 "could not start `{} {}`: {e}",
202 exe.display(),
203 args.join(" ")
204 )));
205 }
206 };
207
208 let output = Arc::new(Mutex::new(String::new()));
209 for stream in [
210 child.stdout.take().map(Pipe::Out),
211 child.stderr.take().map(Pipe::Err),
212 ]
213 .into_iter()
214 .flatten()
215 {
216 let sink = Arc::clone(&output);
217 tokio::spawn(async move {
218 let mut lines = match stream {
219 Pipe::Out(s) => BufReader::new(Box::pin(s) as PinnedRead).lines(),
220 Pipe::Err(s) => BufReader::new(Box::pin(s) as PinnedRead).lines(),
221 };
222 while let Ok(Some(line)) = lines.next_line().await {
223 let mut buf = sink.lock().await;
224 buf.push_str(&line);
225 buf.push('\n');
226 }
227 });
228 }
229
230 if let Err(e) = wait_for_ready(&mut child, &output).await {
233 self.session.resume_after_login().await;
234 return Err(e);
235 }
236
237 let instructions = output.lock().await.clone();
238 *slot = Some(Running { child, output });
239
240 let mut progress = LoginProgress::new(
241 "waiting",
242 "A browser is open and waiting for the user to sign in. Give them the \
243 instructions verbatim, then poll login_status. Nothing here sees their \
244 credentials.",
245 );
246 progress.instructions = Some(instructions);
247 Ok(progress)
248 }
249
250 async fn status(&self) -> Result<LoginProgress, ApiError> {
251 let mut slot = self.running.lock().await;
252 let Some(running) = slot.as_mut() else {
253 return Ok(LoginProgress::new(
254 "notStarted",
255 "No login is in progress. Call start_login to begin one, or auth_status \
256 to check whether the stored session is already signed in.",
257 ));
258 };
259
260 let exited = running.child.try_wait().map_err(wrap)?;
261 let output = running.output.lock().await.clone();
262 let Some(status) = exited else {
263 let mut progress = LoginProgress::new(
264 "waiting",
265 "Still waiting for the user to finish signing in.",
266 );
267 progress.instructions = Some(output);
268 return Ok(progress);
269 };
270
271 *slot = None;
272 self.session.resume_after_login().await;
273
274 if status.success() {
275 let mut progress = LoginProgress::new(
276 "signedIn",
277 "Signed in. The session is stored in the browser profile and the cart \
278 tools will use it from now on.",
279 );
280 progress.account = signed_in_account(&output);
281 Ok(progress)
282 } else {
283 let mut progress = LoginProgress::new(
284 "failed",
285 "The login did not complete. The stored profile was left untouched, so \
286 any previous session is still there.",
287 );
288 progress.instructions = Some(output);
289 Ok(progress)
290 }
291 }
292
293 async fn cancel(&self) -> Result<LoginProgress, ApiError> {
294 let mut slot = self.running.lock().await;
295 let Some(mut running) = slot.take() else {
296 self.session.resume_after_login().await;
299 return Ok(LoginProgress::new(
300 "notStarted",
301 "No login was in progress.",
302 ));
303 };
304 terminate_group(&mut running.child).await;
308 self.session.resume_after_login().await;
309 Ok(LoginProgress::new(
310 "notStarted",
311 "Login cancelled and the browser closed. The cart tools work again.",
312 ))
313 }
314}
315
316fn signed_in_account(output: &str) -> Option<String> {
318 output
319 .lines()
320 .find_map(|l| l.trim().strip_prefix("Signed in as "))
321 .map(|who| who.trim_end_matches('.').to_string())
322}
323
324async fn terminate_group(child: &mut Child) {
326 #[cfg(windows)]
330 if let Some(pid) = child.id() {
331 let _ = Command::new("taskkill")
332 .args(["/PID", &pid.to_string(), "/T", "/F"])
333 .stdin(Stdio::null())
334 .stdout(Stdio::null())
335 .stderr(Stdio::null())
336 .status()
337 .await;
338 }
339 #[cfg(unix)]
340 if let Some(pid) = child.id() {
341 unsafe { libc::kill(-(pid as i32), libc::SIGTERM) };
345 for _ in 0..20 {
346 if matches!(child.try_wait(), Ok(Some(_))) {
347 return;
348 }
349 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
350 }
351 unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
352 }
353 let _ = child.start_kill();
354 let _ = child.wait().await;
355}
356
357async fn wait_for_ready(child: &mut Child, output: &Arc<Mutex<String>>) -> Result<(), ApiError> {
361 let deadline = tokio::time::Instant::now() + INSTRUCTIONS_TIMEOUT;
362 loop {
363 if output.lock().await.contains(READY_MARKER) {
364 return Ok(());
365 }
366 if child.try_wait().map_err(wrap)?.is_some() {
367 let detail = output.lock().await.clone();
368 return Err(ApiError::Other(anyhow::anyhow!(
369 "login exited before it was ready:\n{}",
370 detail.trim()
371 )));
372 }
373 if tokio::time::Instant::now() >= deadline {
374 return Ok(());
375 }
376 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
377 }
378}
379
380fn wrap(e: std::io::Error) -> ApiError {
381 ApiError::Other(anyhow::anyhow!("watching the login process: {e}"))
382}
383
384type PinnedRead = std::pin::Pin<Box<dyn tokio::io::AsyncRead + Send>>;
385
386enum Pipe {
387 Out(tokio::process::ChildStdout),
388 Err(tokio::process::ChildStderr),
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn scratch_session(name: &str) -> Arc<Session> {
398 let profile = std::env::temp_dir().join(format!("k-ruoka-login-flow-{name}"));
399 let _ = std::fs::remove_dir_all(&profile);
400 Arc::new(Session::new(&profile, crate::browser::LaunchMode::Headless).unwrap())
401 }
402
403 #[tokio::test]
409 async fn cancelling_frees_the_profile_even_with_no_child_left() {
410 let session = scratch_session("cancel");
411 session.release_for_login().await.unwrap();
412 assert!(
413 session.release_for_login().await.is_err(),
414 "a second login must be refused while one owns the profile"
415 );
416
417 ChildLogin::new(Arc::clone(&session))
418 .cancel()
419 .await
420 .unwrap();
421
422 session
423 .release_for_login()
424 .await
425 .expect("cancel_login must hand the profile back");
426 }
427
428 #[test]
429 fn the_account_is_read_out_of_logins_own_output() {
430 let output = "Opening a browser against /x\n\nSigned in as Niko Savola <a@b.c>.\n\
431 Session saved to /x.\n";
432 assert_eq!(
433 signed_in_account(output).as_deref(),
434 Some("Niko Savola <a@b.c>")
435 );
436 }
437
438 #[test]
439 fn no_account_line_is_not_an_account() {
440 assert_eq!(signed_in_account("timed out after 15 minutes\n"), None);
441 }
442
443 #[cfg(unix)]
448 async fn wait_for_pid_file(path: &std::path::Path) -> i32 {
449 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
450 loop {
451 if let Ok(contents) = std::fs::read_to_string(path)
452 && let Ok(pid) = contents.trim().parse::<i32>()
453 {
454 return pid;
455 }
456 assert!(
457 tokio::time::Instant::now() < deadline,
458 "grandchild never wrote its pid to {}",
459 path.display()
460 );
461 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
462 }
463 }
464
465 #[cfg(unix)]
467 fn process_alive(pid: i32) -> bool {
468 if unsafe { libc::kill(pid, 0) } != 0 {
469 return false;
470 }
471 if !cfg!(target_os = "linux") {
475 return true;
476 }
477 match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
478 Ok(stat) => !stat
479 .rsplit(')')
480 .next()
481 .is_some_and(|rest| rest.split_whitespace().next() == Some("Z")),
482 Err(_) => false,
483 }
484 }
485
486 #[cfg(unix)]
487 async fn wait_for_process_death(pid: i32) -> bool {
488 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
489 while tokio::time::Instant::now() < deadline {
490 if !process_alive(pid) {
491 return true;
492 }
493 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
494 }
495 false
496 }
497
498 #[cfg(unix)]
499 async fn poll_until_not_waiting(login: &ChildLogin) -> LoginProgress {
500 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
501 loop {
502 let progress = login.status().await.unwrap();
503 if progress.state != "waiting" {
504 return progress;
505 }
506 assert!(
507 tokio::time::Instant::now() < deadline,
508 "child never left the waiting state"
509 );
510 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
511 }
512 }
513
514 #[cfg(unix)]
515 #[tokio::test(flavor = "multi_thread")]
516 async fn a_child_that_dies_before_printing_the_marker_reports_why_and_frees_the_profile() {
517 let session = scratch_session("dies-before-ready");
518 let login = ChildLogin::with_command(
519 Arc::clone(&session),
520 "sh",
521 &["-c", "echo 'boom from child' >&2; sleep 1; exit 1"],
522 );
523
524 let err = login.start(0).await.unwrap_err();
525 assert!(
526 err.to_string().contains("boom from child"),
527 "error should surface what the dead child printed, got: {err}"
528 );
529
530 session
531 .release_for_login()
532 .await
533 .expect("a login that died before ready must hand the profile back");
534 }
535
536 #[cfg(unix)]
537 #[tokio::test(flavor = "multi_thread")]
538 async fn a_second_start_after_the_first_child_died_unobserved_spawns_a_fresh_one() {
539 let session = scratch_session("stale-slot");
540 let login = ChildLogin::with_command(
541 Arc::clone(&session),
542 "sh",
543 &["-c", "echo 'Sign in by hand'; sleep 1; exit 0"],
544 );
545
546 let first = login.start(0).await.unwrap();
547 assert_eq!(first.state, "waiting");
548
549 tokio::time::sleep(std::time::Duration::from_millis(1_300)).await;
551
552 let second = login.start(0).await.unwrap();
553 assert_eq!(
554 second.state, "waiting",
555 "a stale, unobserved dead child must not block a fresh login"
556 );
557
558 login.cancel().await.unwrap();
560 }
561
562 #[cfg(unix)]
564 #[tokio::test(flavor = "multi_thread")]
565 async fn a_child_that_only_prints_the_marker_stays_waiting_until_cancelled() {
566 let session = scratch_session("waiting-then-cancelled");
567 let pidfile = std::env::temp_dir().join(format!(
568 "k-ruoka-login-flow-grandchild-{}",
569 std::process::id()
570 ));
571 let _ = std::fs::remove_file(&pidfile);
572 let script = format!(
573 "echo 'Sign in by hand'; sleep 60 & echo $! > {}; wait",
574 pidfile.display()
575 );
576 let login = ChildLogin::with_command(Arc::clone(&session), "sh", &["-c", &script]);
577
578 let first = login.start(0).await.unwrap();
579 assert_eq!(first.state, "waiting");
580 assert!(first.instructions.unwrap().contains("Sign in by hand"));
581
582 let second = login.start(0).await.unwrap();
583 assert!(
584 second.detail.contains("already in progress"),
585 "a second start must not spawn another browser onto the same profile"
586 );
587
588 assert_eq!(login.status().await.unwrap().state, "waiting");
589
590 let grandchild = wait_for_pid_file(&pidfile).await;
591 assert!(
592 process_alive(grandchild),
593 "the grandchild should still be running before cancel"
594 );
595
596 let cancelled = login.cancel().await.unwrap();
597 assert_eq!(cancelled.state, "notStarted");
598 assert!(
599 wait_for_process_death(grandchild).await,
600 "cancel must reach the whole process group, including grandchildren, \
601 not just the direct child"
602 );
603
604 session
605 .release_for_login()
606 .await
607 .expect("cancel must free the profile");
608
609 let _ = std::fs::remove_file(&pidfile);
610 }
611
612 #[cfg(unix)]
613 #[tokio::test(flavor = "multi_thread")]
614 async fn a_child_that_signs_in_reports_the_account_once_it_exits() {
615 let session = scratch_session("signs-in");
616 let login = ChildLogin::with_command(
617 Arc::clone(&session),
618 "sh",
619 &[
620 "-c",
621 "echo 'Sign in by hand'; sleep 1; \
622 echo 'Signed in as Test User <test@example.com>.'; exit 0",
623 ],
624 );
625
626 let started = login.start(0).await.unwrap();
627 assert_eq!(started.state, "waiting");
628
629 let finished = poll_until_not_waiting(&login).await;
630 assert_eq!(finished.state, "signedIn");
631 assert_eq!(
632 finished.account.as_deref(),
633 Some("Test User <test@example.com>")
634 );
635 }
636}