diff --git a/app/src-tauri/src/commands/auth_token_commands.rs b/app/src-tauri/src/commands/auth_token_commands.rs index 34c251b..c56545a 100644 --- a/app/src-tauri/src/commands/auth_token_commands.rs +++ b/app/src-tauri/src/commands/auth_token_commands.rs @@ -114,7 +114,7 @@ const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60); /// [`SETUP_TIMEOUT`]. Measured against 2.1.283 under a pty: 20 ms apart /// already submits reliably; this leaves headroom for the extra hops through /// Docker's exec socket, which can merge writes that arrive close together. -const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(250); +pub(crate) const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(250); /// Documented shape of a `setup-token` credential. const TOKEN_PREFIX: &str = "sk-ant-oat01-"; @@ -621,7 +621,7 @@ const MAX_ANSI_CARRY: usize = 64 * 1024; /// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete /// trailing sequence over to the next chunk. #[derive(Default)] -struct AnsiStripper { +pub(crate) struct AnsiStripper { carry: Vec, /// OSC 8 link targets seen since the last [`AnsiStripper::take_links`]. /// Kept out of the return value so every existing caller and test of @@ -630,7 +630,7 @@ struct AnsiStripper { } impl AnsiStripper { - fn push(&mut self, chunk: &[u8]) -> String { + pub(crate) fn push(&mut self, chunk: &[u8]) -> String { self.carry.extend_from_slice(chunk); let (mut out, links, consumed) = strip_ansi_prefix(&self.carry); self.record_links(links); @@ -644,7 +644,7 @@ impl AnsiStripper { // fresh chunk, which re-enters here. if self.carry.len() > MAX_ANSI_CARRY { log::warn!( - "`claude setup-token` emitted an unterminated control sequence \ + "the command emitted an unterminated control sequence \ longer than {} bytes — treating it as text", MAX_ANSI_CARRY ); @@ -734,7 +734,7 @@ const REJECTION_SCAN_WINDOW: usize = 4096; const CODE_REJECTED_MARKERS: &[&str] = &["invalid code", "press enter to retry"]; /// Append `chunk` to `buf`, keeping no more than `cap` bytes of the tail. -fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) { +pub(crate) fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) { buf.push_str(chunk); if buf.len() <= cap { return; diff --git a/app/src-tauri/src/marketplace/gh_login.rs b/app/src-tauri/src/marketplace/gh_login.rs new file mode 100644 index 0000000..22ca482 --- /dev/null +++ b/app/src-tauri/src/marketplace/gh_login.rs @@ -0,0 +1,764 @@ +//! 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()) +} + +/// gh prints `! First copy your one-time code: XXXX-XXXX`, then either a URL +/// or "Press Enter to open in your browser". Returns (code, url). +pub fn parse_device_prompt(output: &str, host: &str) -> Option<(String, String)> { + const LABEL: &str = "one-time code:"; + let at = output.find(LABEL)? + LABEL.len(); + let code: String = output[at..] + .trim_start() + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') + .collect(); + if code.len() < 6 || !code.contains('-') { + 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() + )) + ); + } + + #[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")); + } + } + + #[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" + ); + } + } +} diff --git a/app/src-tauri/src/marketplace/mod.rs b/app/src-tauri/src/marketplace/mod.rs index 30eae88..d73c21e 100644 --- a/app/src-tauri/src/marketplace/mod.rs +++ b/app/src-tauri/src/marketplace/mod.rs @@ -5,6 +5,7 @@ pub mod auth; pub mod catalog; pub mod diff; +pub mod gh_login; pub mod git; pub mod payload; pub mod sync;