//! GitHub sign-in through `gh auth login --web` inside a running container, for //! hosts that have no `gh` of their own. The token is read back through the //! exec, returned to the caller for the keychain, and never emitted, logged or //! left behind in the container. use std::time::Duration; use bollard::container::LogOutput; use futures_util::{Stream, StreamExt}; use tauri::{AppHandle, Emitter}; use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio::sync::oneshot; use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY}; use crate::docker::exec::{ create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec, }; pub const CODE_EVENT: &str = "marketplace-gh-login-code"; pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output"; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); const TOKEN_BEGIN: &str = "__TRIPLEC_TOKEN_BEGIN__"; const TOKEN_END: &str = "__TRIPLEC_TOKEN_END__"; /// Common prefix of both markers: any line containing it is never shown. const TOKEN_MARKER: &str = "__TRIPLEC_TOKEN"; const MAX_TRANSCRIPT: usize = 64 * 1024; const MAX_PENDING_LINE: usize = 4096; /// Pre-flight N9: on cancel or timeout the attach is dropped, but `gh auth /// login` would keep polling in the container. This matches both it and the /// script around it (whose text contains the same words); errors are ignored. const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"]; /// Constant script; the host is `$1` (argv, never interpolated), because /// `create_attached_exec_as` takes no env. /// /// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit, /// so the container is never left logged in. The `HUP INT TERM` trap turns a /// signal (the pty closing, or the cancel `pkill`) into a normal exit so the /// `EXIT` trap still runs — `sh` skips it when killed outright. /// * `--git-protocol ssh --skip-ssh-key` avoids gh's "Authenticate Git with /// your GitHub credentials?" prompt, which `https` triggers and which would /// write a credential helper into the git config. /// * `BROWSER=true` makes gh's "open the browser" step a no-op. const GH_LOGIN_SCRIPT: &str = r#"set -eu host="$1" case "$host" in '' | -* | *[!A-Za-z0-9.-]*) echo "invalid host" >&2; exit 2 ;; esac export HOME=/home/claude d=$(mktemp -d) trap 'rm -rf "$d"' EXIT trap 'exit 130' HUP INT TERM export GH_CONFIG_DIR="$d" GIT_CONFIG_GLOBAL="$d/gitconfig" BROWSER=true gh auth login --hostname "$host" --web --git-protocol ssh --skip-ssh-key --scopes repo t=$(gh auth token --hostname "$host") printf '\n%s%s%s\n' __TRIPLEC_TOKEN_BEGIN__ "$t" __TRIPLEC_TOKEN_END__ "#; /// Pre-flight F13: the shared host rule, minus ports — `gh auth login /// --hostname` takes a bare name. pub fn valid_host(host: &str) -> bool { crate::marketplace::auth::valid_host(host) && !host.contains(':') } /// Remove terminal control sequences and carriage returns from one complete /// piece of text. An unterminated sequence at the end is dropped. The login /// itself uses a streaming [`AnsiStripper`], which carries a sequence split /// across chunks instead. pub fn strip_ansi(s: &str) -> String { AnsiStripper::default().push(s.as_bytes()) } /// Read gh's device code and URL. Returns (code, url). /// /// Two wordings are known: /// * older gh: `! First copy your one-time code: XXXX-XXXX`, then either a /// URL or "Press Enter to open in your browser"; /// * gh 2.101 (the image's): `! One-time code (XXXX-XXXX) copied to /// clipboard`, then "Press Enter to open https:///login/device in your /// browser...". /// /// The URL is the first `https://…/login/device` word, else /// `https:///login/device`. pub fn parse_device_prompt(output: &str, host: &str) -> Option<(String, String)> { const LABEL: &str = "one-time code"; // ASCII lowercasing keeps byte offsets, so `at` indexes `output` too. let at = output.to_ascii_lowercase().find(LABEL)? + LABEL.len(); let rest = output[at..].trim_start_matches(|c: char| c == ':' || c == '(' || c.is_whitespace()); let code: String = rest .chars() .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') .collect(); // Something must follow the code (`)` or a line break): a code at the // very end may still be growing in the next frame. if code.len() < 6 || !code.contains('-') || rest.len() == code.len() { return None; } let url = output .split_whitespace() .find(|w| w.starts_with("https://") && w.contains("/login/device")) .map(|w| { w.trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '/') .to_string() }) .unwrap_or_else(|| format!("https://{host}/login/device")); Some((code, url)) } pub fn extract_token(text: &str) -> Option { let start = text.find(TOKEN_BEGIN)? + TOKEN_BEGIN.len(); let end = start + text[start..].find(TOKEN_END)?; let token = text[start..end].trim(); if token.is_empty() || token.chars().any(|c| c.is_whitespace() || c.is_control()) { return None; } Some(token.to_string()) } /// Append `chunk` and hand back the complete lines, minus any line carrying the /// token markers. A partial line waits in `pending` (so a marker split across /// chunks is never shown), and is dropped if it grows past a bound. pub fn take_display_lines(pending: &mut String, chunk: &str) -> String { pending.push_str(chunk); let Some(last_nl) = pending.rfind('\n') else { if pending.len() > MAX_PENDING_LINE { pending.clear(); } return String::new(); }; let complete: String = pending.drain(..=last_nl).collect(); complete .lines() .filter(|l| !l.contains(TOKEN_MARKER)) .map(|l| format!("{l}\n")) .collect() } /// What to show when the login ends without a token: the last few lines, with /// any marker line removed. fn failure_tail(transcript: &str) -> String { let lines: Vec<&str> = transcript .lines() .filter(|l| !l.contains(TOKEN_MARKER) && !l.trim().is_empty()) .collect(); lines[lines.len().saturating_sub(5)..].join("\n") } /// Pre-flight N9 / review fix 1: stop the in-container login after any /// failed attempt. async fn kill_container_login(container_id: &str) { let cmd = CANCEL_PKILL.iter().map(|s| s.to_string()).collect(); let _ = exec_oneshot_as(container_id, "claude", cmd, vec![]).await; } /// Hand `result` back, running `cleanup` first when it is a failure. /// /// Review fix 1: a tty exec keeps running after its attach is dropped, so any /// login that ends without a token — cancel, timeout, a lost stream, a failed /// write, gh exiting without one — must stop the in-container login, or gh /// keeps polling and its temp `GH_CONFIG_DIR` (which receives the token if the /// user finishes in the browser) outlives the attempt. async fn cleanup_on_error(result: Result, cleanup: C) -> Result where C: FnOnce() -> Fut, Fut: std::future::Future, { if result.is_err() { cleanup().await; } result } /// Pump gh's output until the exec ends, emitting code and display events and /// pressing Enter at gh's prompt. `Ok` is the transcript of a stream that ended /// normally; every other ending is `Err`. Takes the attach halves by value, so /// they are closed by the time this returns. async fn drive_login( mut output: S, mut input: W, cancel: &mut oneshot::Receiver<()>, deadline: tokio::time::Instant, account_id: &str, host: &str, mut emit: E, ) -> Result where S: Stream> + Unpin, W: AsyncWrite + Unpin, E: FnMut(&'static str, serde_json::Value), { let mut stripper = AnsiStripper::default(); let mut transcript = String::new(); let mut pending = String::new(); let mut code_sent = false; let mut enter_sent = false; loop { let next = tokio::select! { _ = &mut *cancel => { return Err("GitHub sign-in cancelled. Nothing was stored.".to_string()); } next = tokio::time::timeout_at(deadline, output.next()) => match next { Ok(next) => next, Err(_) => { return Err(format!( "Timed out after {} minutes waiting for the GitHub sign-in. Nothing was stored.", LOGIN_TIMEOUT.as_secs() / 60 )); } }, }; let frame = match next { Some(Ok(frame)) => frame, Some(Err(e)) => { return Err(format!( "Lost the connection to gh: {e}. Nothing was stored." )) } None => return Ok(transcript), }; let text = stripper.push(&frame.into_bytes()); push_capped_tail(&mut transcript, &text, MAX_TRANSCRIPT); let shown = take_display_lines(&mut pending, &text); if !shown.is_empty() { emit( OUTPUT_EVENT, serde_json::json!({ "account_id": account_id, "chunk": shown }), ); } if !code_sent { if let Some((code, url)) = parse_device_prompt(&transcript, host) { emit( CODE_EVENT, serde_json::json!({ "account_id": account_id, "code": code, "url": url }), ); code_sent = true; } } if code_sent && !enter_sent && transcript.contains("Press Enter") { // The Enter is its own write, after a pause (PR #64): arriving with // other bytes it can be read as part of a paste and swallowed. tokio::time::sleep(SUBMIT_ENTER_DELAY).await; input .write_all(b"\r") .await .map_err(|e| format!("Could not answer gh's prompt: {e}. Nothing was stored."))?; let _ = input.flush().await; enter_sent = true; } } } /// Run `gh auth login --web` in the container and return the token it minted. /// /// Once the exec exists there is exactly one way out: the result of the inner /// block goes through [`cleanup_on_error`], so only a token read back skips /// the in-container kill. pub async fn run_gh_container_login( app: &AppHandle, account_id: &str, container_id: &str, host: &str, mut cancel: oneshot::Receiver<()>, ) -> Result { if !valid_host(host) { return Err(format!("{host:?} is not a valid host name.")); } let AttachedExec { exec_id, output, input, } = create_attached_exec_as( container_id, vec![ "sh".to_string(), "-c".to_string(), GH_LOGIN_SCRIPT.to_string(), "triple-c-gh-login".to_string(), host.to_string(), ], true, "claude", "/home/claude", ) .await?; let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT; let result = async { let transcript = drive_login( output, input, &mut cancel, deadline, account_id, host, |event, payload| { let _ = app.emit(event, payload); }, ) .await?; if let Some(token) = extract_token(&transcript) { return Ok(token); } let status = wait_for_exec_exit(&exec_id).await; Err(format!( "gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}", status .map(|c| c.to_string()) .unwrap_or_else(|| "unknown".to_string()), failure_tail(&transcript) )) } .await; cleanup_on_error(result, || kill_container_login(container_id)).await } #[cfg(test)] mod tests { use super::*; const GH_PROMPT: &str = "! First copy your one-time code: 4F2A-9C1B\nPress Enter to open github.com in your browser... "; #[test] fn the_device_code_is_read_and_the_url_defaults_to_the_host() { assert_eq!( parse_device_prompt(GH_PROMPT, "github.com"), Some(( "4F2A-9C1B".to_string(), "https://github.com/login/device".to_string() )) ); } #[test] fn an_explicit_device_url_wins() { let out = "! First copy your one-time code: AB12-CD34\nOpen this URL to continue in your web browser: https://ghe.example.com/login/device\n"; assert_eq!( parse_device_prompt(out, "ghe.example.com"), Some(( "AB12-CD34".to_string(), "https://ghe.example.com/login/device".to_string() )) ); } /// gh 2.101.0 (the image's gh, integration report check 6), after ANSI /// stripping: the code is in parentheses and the URL is on the Enter line. const GH_2_101_PROMPT: &str = "! One-time code (4F2A-9C1B) copied to clipboard\nPress Enter to open https://github.com/login/device in your browser... "; #[test] fn the_gh_2_101_wording_is_read() { assert_eq!( parse_device_prompt(GH_2_101_PROMPT, "github.com"), Some(( "4F2A-9C1B".to_string(), "https://github.com/login/device".to_string() )) ); } #[test] fn the_url_comes_from_the_press_enter_line() { let out = "! One-time code (AB12-CD34) copied to clipboard\nPress Enter to open https://ghe.example.com/login/device in your browser... "; assert_eq!( parse_device_prompt(out, "github.com"), Some(( "AB12-CD34".to_string(), "https://ghe.example.com/login/device".to_string() )) ); } #[test] fn the_gh_2_101_wording_without_a_code_is_no_prompt() { assert_eq!(parse_device_prompt("! One-time code (", "github.com"), None); assert_eq!( parse_device_prompt("! One-time code (4F2A", "github.com"), None ); } #[test] fn a_code_cut_by_a_frame_boundary_is_not_a_code_yet() { assert_eq!( parse_device_prompt("! One-time code (4F2A-9C", "github.com"), None ); assert_eq!( parse_device_prompt("! First copy your one-time code: 4F2A-9C", "github.com"), None ); } #[test] fn no_code_yet_means_no_prompt() { assert_eq!( parse_device_prompt("! First copy your one-time", "github.com"), None ); assert_eq!(parse_device_prompt("", "github.com"), None); } #[test] fn the_token_is_taken_from_between_the_markers() { let out = "✓ Logged in\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\n"; assert_eq!(extract_token(out), Some("test-token-not-real".to_string())); assert_eq!( extract_token("__TRIPLEC_TOKEN_BEGIN__test-token-not-real"), None, "unterminated" ); assert_eq!( extract_token("__TRIPLEC_TOKEN_BEGIN____TRIPLEC_TOKEN_END__"), None, "empty" ); assert_eq!( extract_token("__TRIPLEC_TOKEN_BEGIN__a b__TRIPLEC_TOKEN_END__"), None, "whitespace" ); } #[test] fn only_complete_lines_are_shown_and_the_token_line_never_is() { let mut pending = String::new(); assert_eq!( take_display_lines(&mut pending, "! First copy your one-"), "" ); assert_eq!( take_display_lines(&mut pending, "time code: 4F2A-9C1B\nPress"), "! First copy your one-time code: 4F2A-9C1B\n" ); assert_eq!(pending, "Press"); let shown = take_display_lines( &mut pending, " Enter\n__TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\ndone\n", ); assert_eq!(shown, "Press Enter\ndone\n"); assert!(!shown.contains("test-token-not-real")); } #[test] fn escape_sequences_and_carriage_returns_are_removed() { assert_eq!(strip_ansi("\u{1b}[1;32m✓\u{1b}[0m done\r\n"), "✓ done\n"); assert_eq!( strip_ansi("a\u{1b}]8;;https://x\u{7}link\u{1b}]8;;\u{7}b"), "alinkb" ); assert_eq!(strip_ansi("cut\u{1b}["), "cut"); } #[test] fn hosts_are_plain_names() { assert!(valid_host("github.com")); assert!(valid_host("ghe.corp-1.example")); for bad in ["", "-x", "a b", "a;b", "a/b", "$(id)"] { assert!(!valid_host(bad), "{bad:?}"); } } /// Pre-flight F13: the shared `auth::valid_host` accepts `host:port`, but /// `gh auth login --hostname` takes a bare name, so a port is refused here. #[test] fn hosts_with_a_port_are_refused() { assert!(crate::marketplace::auth::valid_host("ghe.corp:8443")); assert!(!valid_host("ghe.corp:8443")); assert!(!valid_host("ghe.corp:")); } #[test] fn the_failure_tail_never_carries_the_token() { let transcript = "! First copy your one-time code: 4F2A-9C1B\n\ __TRIPLEC_TOKEN_BEGIN__test-token-not-real__TRIPLEC_TOKEN_END__\n\ error: something odd\n"; let tail = failure_tail(transcript); assert!(!tail.contains("test-token-not-real")); assert!(tail.contains("error: something odd")); } /// Pre-flight N9: the cancel/timeout `pkill -f` pattern has to match the /// `gh` command line the script runs. #[test] fn the_cancel_pattern_matches_the_script() { assert_eq!(CANCEL_PKILL[0], "pkill"); assert_eq!(CANCEL_PKILL[1], "-f"); assert!(GH_LOGIN_SCRIPT.contains(CANCEL_PKILL[2])); } /// Review fix 1: every failed login tears the container side down, and a /// successful one does not. mod teardown { use super::super::*; use bollard::container::LogOutput; use futures_util::stream; use std::pin::Pin; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; type Frame = Result; fn out(s: &'static str) -> Frame { Ok(LogOutput::StdOut { message: s.into() }) } fn lost() -> Frame { Err(bollard::errors::Error::DockerResponseServerError { status_code: 500, message: "connection reset".to_string(), }) } /// Records every write separately; or fails every write. #[derive(Clone, Default)] struct Keys { writes: Arc>>>, broken: bool, } impl tokio::io::AsyncWrite for Keys { fn poll_write( self: Pin<&mut Self>, _: &mut Context<'_>, buf: &[u8], ) -> Poll> { if self.broken { return Poll::Ready(Err(std::io::Error::other("pipe closed"))); } self.writes.lock().unwrap().push(buf.to_vec()); Poll::Ready(Ok(buf.len())) } fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { Poll::Ready(Ok(())) } fn poll_shutdown( self: Pin<&mut Self>, _: &mut Context<'_>, ) -> Poll> { Poll::Ready(Ok(())) } } const PROMPT: &str = "! First copy your one-time code: 4F2A-9C1B\r\nPress Enter to open github.com in your browser... "; async fn drive( frames: S, keys: Keys, cancel: &mut oneshot::Receiver<()>, deadline: tokio::time::Instant, ) -> ( Result, Vec<(&'static str, serde_json::Value)>, ) where S: futures_util::Stream + Unpin, { let mut events = Vec::new(); let r = drive_login( frames, keys, cancel, deadline, "acct-1", "github.com", |e, p| events.push((e, p)), ) .await; (r, events) } fn far() -> tokio::time::Instant { tokio::time::Instant::now() + LOGIN_TIMEOUT } #[tokio::test] async fn cleanup_runs_on_every_failure_and_never_on_success() { let runs = Arc::new(Mutex::new(0)); let count = || { let runs = runs.clone(); async move { *runs.lock().unwrap() += 1 } }; let ok: Result = Ok("test-token-not-real".into()); assert!(cleanup_on_error(ok, count).await.is_ok()); assert_eq!(*runs.lock().unwrap(), 0); let err: Result = Err("boom".into()); assert_eq!(cleanup_on_error(err, count).await, Err("boom".into())); assert_eq!(*runs.lock().unwrap(), 1); } #[tokio::test(start_paused = true)] async fn a_complete_login_returns_the_transcript_and_presses_enter_alone() { let keys = Keys::default(); let (_tx, mut cancel) = oneshot::channel(); let frames = stream::iter(vec![ out(PROMPT), out("\r\n\u{2713} Logged in\r\n"), out("__TRIPLEC_TOKEN_BEGIN__test-token-"), out("not-real__TRIPLEC_TOKEN_END__\r\n"), ]); let (r, events) = drive(frames, keys.clone(), &mut cancel, far()).await; let transcript = r.unwrap(); assert_eq!( extract_token(&transcript), Some("test-token-not-real".into()) ); assert_eq!(*keys.writes.lock().unwrap(), vec![b"\r".to_vec()]); assert!(events.contains(&( CODE_EVENT, serde_json::json!({ "account_id": "acct-1", "code": "4F2A-9C1B", "url": "https://github.com/login/device" }) ))); for (_, payload) in &events { assert!(!payload.to_string().contains("test-token-not-real")); } } /// The raw bytes gh 2.101.0 prints under a tty (integration report /// check 6), with a fake code: the code event goes out and Enter is /// pressed, or gh never starts polling. #[tokio::test(start_paused = true)] async fn gh_2_101_gets_its_code_event_and_its_enter() { let keys = Keys::default(); let (_tx, mut cancel) = oneshot::channel(); let frames = stream::iter(vec![ out("\u{1b}]11;?\u{1b}\\\u{1b}[6n"), out("\r\n"), out("\u{1b}]52;c;NEYyQS05QzFC\u{7}\u{1b}[0;33m!\u{1b}[0m One-time code (\u{1b}[0;1;39m4F2A-9C1B\u{1b}[0m) copied to clipboard\r\n\u{1b}[0;1;39mPress Enter\u{1b}[0m to open https://github.com/login/device in your browser... "), ]); let (r, events) = drive(frames, keys.clone(), &mut cancel, far()).await; assert!(r.is_ok()); assert_eq!(*keys.writes.lock().unwrap(), vec![b"\r".to_vec()]); assert!(events.contains(&( CODE_EVENT, serde_json::json!({ "account_id": "acct-1", "code": "4F2A-9C1B", "url": "https://github.com/login/device" }) ))); } #[tokio::test] async fn a_lost_stream_is_a_failure() { let (_tx, mut cancel) = oneshot::channel(); let frames = stream::iter(vec![out(PROMPT), lost()]); let (r, _) = drive(frames, Keys::default(), &mut cancel, far()).await; assert!(r.unwrap_err().contains("Lost the connection")); } #[tokio::test(start_paused = true)] async fn a_failed_enter_is_a_failure() { let keys = Keys { broken: true, ..Default::default() }; let (_tx, mut cancel) = oneshot::channel(); let frames = stream::iter(vec![out(PROMPT)]); let (r, _) = drive(frames, keys, &mut cancel, far()).await; assert!(r.unwrap_err().contains("Could not answer")); } #[tokio::test] async fn a_cancel_is_a_failure() { let (tx, mut cancel) = oneshot::channel(); tx.send(()).unwrap(); let (r, _) = drive(stream::pending(), Keys::default(), &mut cancel, far()).await; assert!(r.unwrap_err().contains("cancelled")); } #[tokio::test(start_paused = true)] async fn a_timeout_is_a_failure() { let (_tx, mut cancel) = oneshot::channel(); let deadline = tokio::time::Instant::now() + Duration::from_secs(1); let (r, _) = drive(stream::pending(), Keys::default(), &mut cancel, deadline).await; assert!(r.unwrap_err().contains("Timed out")); } } /// The script end to end against a stand-in `gh`, as a login would run it /// inside the container (minus Docker). #[cfg(unix)] mod script { use super::super::*; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; /// A fake `gh` that records its environment into `log_dir` and prints /// the fixture token for `auth token`. `login_body` runs for `auth login`. fn fake_gh(dir: &Path, log_dir: &Path, login_body: &str) -> PathBuf { let bin = dir.join("bin"); std::fs::create_dir_all(&bin).unwrap(); let gh = bin.join("gh"); std::fs::write( &gh, format!( "#!/bin/sh\n\ log='{log}'\n\ case \"$1 $2\" in\n\ 'auth login')\n\ printf '%s\\n' \"$GH_CONFIG_DIR\" > \"$log/config_dir\"\n\ printf '%s\\n' \"$GIT_CONFIG_GLOBAL\" > \"$log/git_config\"\n\ printf '%s\\n' \"$BROWSER\" > \"$log/browser\"\n\ printf '%s\\n' \"$*\" > \"$log/args\"\n\ echo 'token-in-config' > \"$GH_CONFIG_DIR/hosts.yml\"\n\ {login}\n\ ;;\n\ 'auth token') echo test-token-not-real ;;\n\ *) exit 9 ;;\n\ esac\n", log = log_dir.display(), login = login_body, ), ) .unwrap(); std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o755)).unwrap(); bin } fn script_command(bin: &Path, tmp: &Path, host: &str) -> Command { let mut cmd = Command::new("sh"); cmd.arg("-c") .arg(GH_LOGIN_SCRIPT) .arg("triple-c-gh-login") .arg(host) .env( "PATH", format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()), ) .env("TMPDIR", tmp); cmd } fn read(p: PathBuf) -> String { std::fs::read_to_string(p).unwrap().trim().to_string() } #[test] fn the_token_comes_back_and_the_temp_config_is_gone() { let root = tempfile::tempdir().unwrap(); let log = root.path().join("log"); let tmp = root.path().join("tmp"); std::fs::create_dir_all(&log).unwrap(); std::fs::create_dir_all(&tmp).unwrap(); let bin = fake_gh(root.path(), &log, "echo '✓ Logged in'"); let out = script_command(&bin, &tmp, "github.com").output().unwrap(); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8_lossy(&out.stdout); assert_eq!( extract_token(&stdout), Some("test-token-not-real".to_string()) ); let config_dir = read(log.join("config_dir")); assert!( config_dir.starts_with(tmp.to_str().unwrap()), "{config_dir}" ); assert!( !Path::new(&config_dir).exists(), "temp GH_CONFIG_DIR left behind" ); assert_eq!( read(log.join("git_config")), format!("{config_dir}/gitconfig") ); assert_eq!(read(log.join("browser")), "true"); assert_eq!( read(log.join("args")), "auth login --hostname github.com --web --git-protocol ssh --skip-ssh-key --scopes repo" ); assert_eq!(std::fs::read_dir(&tmp).unwrap().count(), 0); } #[test] fn the_script_refuses_a_bad_host_on_its_own() { let root = tempfile::tempdir().unwrap(); let log = root.path().join("log"); std::fs::create_dir_all(&log).unwrap(); let bin = fake_gh(root.path(), &log, "true"); for bad in ["", "-x", "a;b", "$(id)", "a:1"] { let out = script_command(&bin, root.path(), bad).output().unwrap(); assert_eq!(out.status.code(), Some(2), "{bad:?}"); assert!(!log.join("args").exists(), "gh ran for {bad:?}"); } } /// Pre-flight N9: a cancel `pkill`s the login; the temp config must /// still be removed when the script dies by signal. #[test] fn a_killed_login_still_removes_the_temp_config() { use std::os::unix::process::CommandExt; let root = tempfile::tempdir().unwrap(); let log = root.path().join("log"); let tmp = root.path().join("tmp"); std::fs::create_dir_all(&log).unwrap(); std::fs::create_dir_all(&tmp).unwrap(); let bin = fake_gh(root.path(), &log, "touch \"$log/started\"; sleep 30"); let mut child = script_command(&bin, &tmp, "github.com") .stdout(Stdio::null()) .stderr(Stdio::null()) .process_group(0) .spawn() .unwrap(); let started = log.join("started"); for _ in 0..200 { if started.exists() { break; } std::thread::sleep(std::time::Duration::from_millis(25)); } assert!(started.exists(), "fake gh never started"); let config_dir = read(log.join("config_dir")); assert!(Path::new(&config_dir).exists()); // Like `pkill -f`, which matches both the script and gh. let pgid = child.id().to_string(); let killed = Command::new("kill") .args(["-s", "TERM", "--", &format!("-{pgid}")]) .status() .unwrap(); assert!(killed.success(), "kill failed"); let sent = std::time::Instant::now(); child.wait().unwrap(); assert!( sent.elapsed() < std::time::Duration::from_secs(10), "the script outlived the signal" ); assert!( !Path::new(&config_dir).exists(), "temp GH_CONFIG_DIR left behind" ); } } }