Merge Task 10 (gh sign-in inside a container) into feat/marketplace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<u8>,
|
||||
/// 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;
|
||||
|
||||
@@ -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 <host> 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<String> {
|
||||
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<T, C, Fut>(result: Result<T, String>, cleanup: C) -> Result<T, String>
|
||||
where
|
||||
C: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = ()>,
|
||||
{
|
||||
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<S, W, E>(
|
||||
mut output: S,
|
||||
mut input: W,
|
||||
cancel: &mut oneshot::Receiver<()>,
|
||||
deadline: tokio::time::Instant,
|
||||
account_id: &str,
|
||||
host: &str,
|
||||
mut emit: E,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
S: Stream<Item = Result<LogOutput, bollard::errors::Error>> + 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<String, String> {
|
||||
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<LogOutput, bollard::errors::Error>;
|
||||
|
||||
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<Mutex<Vec<Vec<u8>>>>,
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for Keys {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
_: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
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<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
fn poll_shutdown(
|
||||
self: Pin<&mut Self>,
|
||||
_: &mut Context<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
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<S>(
|
||||
frames: S,
|
||||
keys: Keys,
|
||||
cancel: &mut oneshot::Receiver<()>,
|
||||
deadline: tokio::time::Instant,
|
||||
) -> (
|
||||
Result<String, String>,
|
||||
Vec<(&'static str, serde_json::Value)>,
|
||||
)
|
||||
where
|
||||
S: futures_util::Stream<Item = Frame> + 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<String, String> = Ok("test-token-not-real".into());
|
||||
assert!(cleanup_on_error(ok, count).await.is_ok());
|
||||
assert_eq!(*runs.lock().unwrap(), 0);
|
||||
let err: Result<String, String> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user