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
|
/// [`SETUP_TIMEOUT`]. Measured against 2.1.283 under a pty: 20 ms apart
|
||||||
/// already submits reliably; this leaves headroom for the extra hops through
|
/// already submits reliably; this leaves headroom for the extra hops through
|
||||||
/// Docker's exec socket, which can merge writes that arrive close together.
|
/// 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.
|
/// Documented shape of a `setup-token` credential.
|
||||||
const TOKEN_PREFIX: &str = "sk-ant-oat01-";
|
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
|
/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete
|
||||||
/// trailing sequence over to the next chunk.
|
/// trailing sequence over to the next chunk.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct AnsiStripper {
|
pub(crate) struct AnsiStripper {
|
||||||
carry: Vec<u8>,
|
carry: Vec<u8>,
|
||||||
/// OSC 8 link targets seen since the last [`AnsiStripper::take_links`].
|
/// OSC 8 link targets seen since the last [`AnsiStripper::take_links`].
|
||||||
/// Kept out of the return value so every existing caller and test of
|
/// Kept out of the return value so every existing caller and test of
|
||||||
@@ -630,7 +630,7 @@ struct AnsiStripper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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);
|
self.carry.extend_from_slice(chunk);
|
||||||
let (mut out, links, consumed) = strip_ansi_prefix(&self.carry);
|
let (mut out, links, consumed) = strip_ansi_prefix(&self.carry);
|
||||||
self.record_links(links);
|
self.record_links(links);
|
||||||
@@ -644,7 +644,7 @@ impl AnsiStripper {
|
|||||||
// fresh chunk, which re-enters here.
|
// fresh chunk, which re-enters here.
|
||||||
if self.carry.len() > MAX_ANSI_CARRY {
|
if self.carry.len() > MAX_ANSI_CARRY {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"`claude setup-token` emitted an unterminated control sequence \
|
"the command emitted an unterminated control sequence \
|
||||||
longer than {} bytes — treating it as text",
|
longer than {} bytes — treating it as text",
|
||||||
MAX_ANSI_CARRY
|
MAX_ANSI_CARRY
|
||||||
);
|
);
|
||||||
@@ -734,7 +734,7 @@ const REJECTION_SCAN_WINDOW: usize = 4096;
|
|||||||
const CODE_REJECTED_MARKERS: &[&str] = &["invalid code", "press enter to retry"];
|
const CODE_REJECTED_MARKERS: &[&str] = &["invalid code", "press enter to retry"];
|
||||||
|
|
||||||
/// Append `chunk` to `buf`, keeping no more than `cap` bytes of the tail.
|
/// 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);
|
buf.push_str(chunk);
|
||||||
if buf.len() <= cap {
|
if buf.len() <= cap {
|
||||||
return;
|
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 auth;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
|
pub mod gh_login;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod payload;
|
pub mod payload;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|||||||
Reference in New Issue
Block a user