#![forbid(unsafe_code)]
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
mod cli;
use std::process;
use clap::Parser as _;
use zeroize::Zeroizing;
use gitway_lib::auth::{IdentityResolution, find_identity};
#[cfg(unix)]
use gitway_lib::auth::connect_agent;
use gitway_lib::{GitwayConfig, GitwayError, GitwaySession};
use cli::{Cli, GitwaySubcommand, OutputFormat};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputMode {
Human,
Json,
}
fn detect_output_mode(cli: &Cli, check_tty: bool) -> OutputMode {
use std::io::IsTerminal as _;
if cli.json || cli.format == Some(OutputFormat::Json) {
return OutputMode::Json;
}
if is_agent_or_ci_env() {
return OutputMode::Json;
}
if check_tty && !std::io::stdout().is_terminal() {
return OutputMode::Json;
}
OutputMode::Human
}
fn is_agent_or_ci_env() -> bool {
std::env::var_os("AI_AGENT").is_some_and(|v| v == "1")
|| std::env::var_os("AGENT").is_some_and(|v| v == "1")
|| std::env::var("CI").is_ok_and(|v| v.eq_ignore_ascii_case("true"))
}
fn now_iso8601() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
epoch_secs_to_iso8601(secs)
}
#[expect(
clippy::similar_names,
reason = "doe/doy are the standard abbreviations in the Hinnant date algorithm"
)]
fn epoch_secs_to_iso8601(secs: u64) -> String {
let sec = secs % 60;
let mins = secs / 60;
let min = mins % 60;
let hours = mins / 60;
let hour = hours % 24;
let days = hours / 24;
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{hour:02}:{min:02}:{sec:02}Z")
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let log_level = if cli.verbose {
log::LevelFilter::Debug
} else {
log::LevelFilter::Warn
};
env_logger::Builder::new()
.filter_level(log_level)
.filter_module(
"russh",
if cli.verbose {
log::LevelFilter::Debug
} else {
log::LevelFilter::Off
},
)
.init();
let error_mode = detect_output_mode(&cli, false);
let invocation = std::env::args().collect::<Vec<_>>().join(" ");
let exit_code = match run(cli).await {
Ok(code) => code,
Err(ref e) => {
match error_mode {
OutputMode::Json => {
let json = serde_json::json!({
"error": {
"code": e.error_code(),
"exit_code": e.exit_code(),
"message": e.to_string(),
"hint": e.hint(),
"timestamp": now_iso8601(),
"command": invocation,
}
});
eprintln!("{json}");
}
OutputMode::Human => {
eprintln!("gitway: error: {e}");
}
}
e.exit_code()
}
};
#[expect(
clippy::cast_possible_wrap,
reason = "exit codes are bounded to 0-255 by POSIX; the cast is safe"
)]
process::exit(exit_code as i32);
}
async fn run(cli: Cli) -> Result<u32, GitwayError> {
if let Some(ref subcommand) = cli.subcommand {
return match subcommand {
GitwaySubcommand::Schema => Ok(run_schema()),
GitwaySubcommand::Describe => Ok(run_describe()),
};
}
if cli.install {
let mode = detect_output_mode(&cli, true);
return run_install(mode);
}
let raw_host = cli
.host
.clone()
.unwrap_or_else(|| gitway_lib::hostkey::DEFAULT_GITHUB_HOST.to_owned());
let host = parse_hostname(&raw_host);
let mut config_builder = GitwayConfig::builder(&host)
.port(cli.port)
.verbose(cli.verbose)
.skip_host_check(cli.insecure_skip_host_check);
if let Some(ref identity) = cli.identity {
config_builder = config_builder.identity_file(identity.clone());
}
if let Some(ref cert) = cli.cert {
config_builder = config_builder.cert_file(cert.clone());
}
let config = config_builder.build();
if cli.test {
let mode = detect_output_mode(&cli, true);
return run_test(&config, mode).await;
}
if cli.command.is_empty() {
return Err(GitwayError::invalid_config(
"no remote command specified; pass a git-upload-pack / git-receive-pack command",
));
}
run_exec(&config, &cli.command).await
}
async fn run_test(config: &GitwayConfig, mode: OutputMode) -> Result<u32, GitwayError> {
let pre_passphrase = maybe_collect_passphrase(config).await?;
if mode == OutputMode::Human {
eprintln!("gitway: connecting to {}:{}…", config.host, config.port);
}
let mut session = GitwaySession::connect(config).await?;
let fingerprint = session.verified_fingerprint();
if mode == OutputMode::Human {
eprintln!("gitway: host-key verified ✓");
}
let auth_result = if let Some((passphrase, path)) = pre_passphrase {
session.authenticate_with_passphrase(config, &path, &passphrase).await
} else {
authenticate_with_prompt(&mut session, config).await
};
let authenticated = auth_result.is_ok();
let no_key = auth_result.as_ref().is_err_and(GitwayError::is_no_key_found);
if mode == OutputMode::Human {
match &auth_result {
Ok(()) => {
eprintln!("gitway: authentication successful ✓");
if let Some(banner) = session.auth_banner() {
eprintln!("{banner}");
}
}
Err(e) if e.is_no_key_found() => {
eprintln!(
"gitway: no identity key found — \
use --identity to specify one, or ensure a key exists in ~/.ssh/"
);
}
Err(e) => {
let _ = session.close().await;
return Err(GitwayError::invalid_config(e.to_string()));
}
}
}
let banner = session.auth_banner();
session.close().await?;
if mode == OutputMode::Json {
let json = serde_json::json!({
"metadata": {
"tool": "gitway",
"version": env!("CARGO_PKG_VERSION"),
"command": format!("gitway --test --host {}", config.host),
"timestamp": now_iso8601(),
},
"data": {
"host": config.host,
"port": config.port,
"host_key_verified": fingerprint.is_some(),
"fingerprint": fingerprint,
"authenticated": authenticated,
"username": config.username,
"banner": banner,
}
});
println!("{json}");
if let Err(e) = auth_result {
if !no_key {
return Err(e);
}
}
}
Ok(0)
}
async fn run_exec(config: &GitwayConfig, command_parts: &[String]) -> Result<u32, GitwayError> {
let command = command_parts.join(" ");
let pre_passphrase = maybe_collect_passphrase(config).await?;
let mut session = GitwaySession::connect(config).await?;
if let Some((passphrase, path)) = pre_passphrase {
session.authenticate_with_passphrase(config, &path, &passphrase).await?;
} else {
authenticate_with_prompt(&mut session, config).await?;
}
let exit_code = session.exec(&command).await?;
session.close().await?;
Ok(exit_code)
}
fn run_install(mode: OutputMode) -> Result<u32, GitwayError> {
let status = std::process::Command::new("git")
.args(["config", "--global", "core.sshCommand", "gitway"])
.status()
.map_err(GitwayError::from)?;
if status.success() {
match mode {
OutputMode::Json => {
let json = serde_json::json!({
"metadata": {
"tool": "gitway",
"version": env!("CARGO_PKG_VERSION"),
"command": "gitway --install",
"timestamp": now_iso8601(),
},
"data": {
"configured": true,
"config_key": "core.sshCommand",
"config_value": "gitway",
"scope": "global",
}
});
println!("{json}");
}
OutputMode::Human => {
eprintln!("gitway: set core.sshCommand = gitway in global Git config ✓");
}
}
Ok(0)
} else {
Err(GitwayError::invalid_config(
"git config --global core.sshCommand failed",
))
}
}
fn run_schema() -> u32 {
let schema = serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/steelbore/gitway/schema/v1",
"title": "gitway",
"description": "Purpose-built SSH transport client for Git hosting services",
"version": env!("CARGO_PKG_VERSION"),
"commands": [
{
"name": "gitway <host> <command...>",
"description": "Relay a Git command over SSH to a hosting service",
"supports_json": false,
"idempotent": true,
"args": {
"host": {
"type": "string",
"description": "SSH hostname (e.g. github.com, gitlab.com, codeberg.org)",
},
"command": {
"type": "array",
"items": { "type": "string" },
"description": "Remote command tokens (e.g. git-upload-pack 'user/repo.git')",
}
}
},
{
"name": "gitway --test",
"description": "Verify SSH connectivity and authentication",
"supports_json": true,
"idempotent": true,
},
{
"name": "gitway --install",
"description": "Register gitway as git core.sshCommand globally",
"supports_json": true,
"idempotent": true,
},
{
"name": "gitway schema",
"description": "Emit full JSON Schema for all commands",
"supports_json": true,
"idempotent": true,
},
{
"name": "gitway describe",
"description": "Emit capability manifest for agent/CI discovery",
"supports_json": true,
"idempotent": true,
}
],
"global_flags": {
"--json": { "type": "boolean", "description": "Emit structured JSON output" },
"--format": { "type": "string", "enum": ["json"], "description": "Output format" },
"--no-color": { "type": "boolean", "description": "Disable colored output" },
"--verbose": { "type": "boolean", "description": "Enable debug logging to stderr" },
"--identity": { "type": "string", "description": "Path to SSH private key" },
"--cert": { "type": "string", "description": "Path to OpenSSH certificate" },
"--port": { "type": "integer", "minimum": 1, "maximum": 65535, "default": 22 },
"--insecure-skip-host-check": { "type": "boolean", "description": "Skip host-key verification (danger)" },
},
"exit_codes": {
"0": "Success",
"1": "General / unexpected error",
"2": "Usage error (bad arguments or configuration)",
"3": "Not found (no identity key, unknown host)",
"4": "Permission denied (authentication failure, host key mismatch)",
}
});
println!("{schema}");
0
}
fn run_describe() -> u32 {
let manifest = serde_json::json!({
"tool": "gitway",
"version": env!("CARGO_PKG_VERSION"),
"description": "Purpose-built SSH transport client for Git hosting services (GitHub, GitLab, Codeberg)",
"commands": [
{
"name": "gitway <host> <command...>",
"description": "Relay a Git command over SSH",
"supports_json": false,
"idempotent": true,
},
{
"name": "gitway --test",
"description": "Verify SSH connectivity and authentication",
"supports_json": true,
"idempotent": true,
},
{
"name": "gitway --install",
"description": "Register gitway as git core.sshCommand globally",
"supports_json": true,
"idempotent": true,
},
{
"name": "gitway schema",
"description": "Emit full JSON Schema for all commands",
"supports_json": true,
"idempotent": true,
},
{
"name": "gitway describe",
"description": "Emit capability manifest for agent/CI discovery",
"supports_json": true,
"idempotent": true,
}
],
"global_flags": ["--json", "--format", "--verbose", "--no-color",
"--insecure-skip-host-check", "--identity", "--cert", "--port"],
"output_formats": ["json"],
"mcp_available": false,
"providers": ["github.com", "gitlab.com", "codeberg.org"],
});
println!("{manifest}");
0
}
async fn maybe_collect_passphrase(
config: &GitwayConfig,
) -> Result<Option<(Zeroizing<String>, std::path::PathBuf)>, GitwayError> {
let IdentityResolution::Encrypted { path } = find_identity(config)? else {
return Ok(None);
};
#[cfg(unix)]
if matches!(connect_agent().await, Ok(Some(_))) {
return Ok(None);
}
log::debug!("auth: collecting passphrase before connecting to avoid inactivity timeout");
let passphrase = prompt_passphrase(&path)?;
Ok(Some((passphrase, path)))
}
async fn authenticate_with_prompt(
session: &mut GitwaySession,
config: &GitwayConfig,
) -> Result<(), GitwayError> {
match session.authenticate_best(config).await {
Ok(()) => return Ok(()),
Err(ref e) if e.is_key_encrypted() => {
}
Err(e) => return Err(e),
}
let IdentityResolution::Encrypted { path: encrypted_path } = find_identity(config)? else {
return Err(GitwayError::no_key_found());
};
let passphrase = prompt_passphrase(&encrypted_path)?;
session
.authenticate_with_passphrase(config, &encrypted_path, &passphrase)
.await
}
fn prompt_passphrase(path: &std::path::Path) -> Result<Zeroizing<String>, GitwayError> {
let prompt = format!("Enter passphrase for {}: ", path.display());
if let Some(passphrase) = try_askpass(&prompt)? {
return Ok(passphrase);
}
rpassword::prompt_password(&prompt)
.map(Zeroizing::new)
.map_err(|e| {
if e.raw_os_error() == Some(6) || e.kind() == std::io::ErrorKind::Other {
GitwayError::invalid_config(
"no terminal available for passphrase prompt — \
run `ssh-add` to load the key into the SSH agent, \
or set SSH_ASKPASS to a GUI passphrase helper \
(e.g. ksshaskpass, ssh-askpass-gnome)",
)
} else {
GitwayError::from(e)
}
})
}
fn try_askpass(prompt: &str) -> Result<Option<Zeroizing<String>>, GitwayError> {
use std::io::IsTerminal as _;
let Some(askpass) = std::env::var_os("SSH_ASKPASS") else {
return Ok(None);
};
if !std::path::Path::new(&askpass).is_absolute() {
return Err(GitwayError::invalid_config(format!(
"SSH_ASKPASS {askpass:?} must be an absolute path"
)));
}
let require = std::env::var("SSH_ASKPASS_REQUIRE")
.unwrap_or_default()
.to_ascii_lowercase();
let use_askpass = match require.as_str() {
"force" | "prefer" => true,
_ => {
let has_display = std::env::var_os("DISPLAY").is_some()
|| std::env::var_os("WAYLAND_DISPLAY").is_some();
let no_tty = !std::io::stderr().is_terminal();
has_display && no_tty
}
};
if !use_askpass {
return Ok(None);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
if let Ok(meta) = std::fs::metadata(std::path::Path::new(&askpass)) {
if meta.permissions().mode() & 0o002 != 0 {
return Err(GitwayError::invalid_config(format!(
"SSH_ASKPASS {askpass:?} is world-writable and \
cannot be trusted"
)));
}
}
}
log::debug!("auth: using SSH_ASKPASS program {askpass:?}");
let output = std::process::Command::new(&askpass)
.arg(prompt)
.output()
.map_err(|e| {
GitwayError::invalid_config(format!(
"SSH_ASKPASS program {askpass:?} could not be launched: {e}"
))
})?;
let status = output.status;
let mut stdout = output.stdout;
if !status.success() {
use zeroize::Zeroize as _;
stdout.zeroize();
return Err(GitwayError::invalid_config(format!(
"SSH_ASKPASS program {askpass:?} exited with status {status}"
)));
}
let passphrase = if let Ok(raw) = std::str::from_utf8(&stdout) {
raw.trim_end_matches('\n').to_owned()
} else {
use zeroize::Zeroize as _;
stdout.zeroize();
return Err(GitwayError::invalid_config(
"SSH_ASKPASS program returned non-UTF-8 output",
));
};
{
use zeroize::Zeroize as _;
stdout.zeroize()
};
if passphrase.is_empty() {
return Err(GitwayError::invalid_config(
"SSH_ASKPASS program returned an empty passphrase — \
the dialog was cancelled or the program does not support \
SSH key passphrase prompts; \
run `ssh-add ~/.ssh/id_ed25519` to load the key into the \
SSH agent, or set SSH_ASKPASS to a dedicated passphrase \
helper (e.g. ksshaskpass, ssh-askpass-gnome)",
));
}
Ok(Some(Zeroizing::new(passphrase)))
}
fn parse_hostname(raw: &str) -> String {
if let Some((_username, hostname)) = raw.split_once('@') {
hostname.to_owned()
} else {
raw.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_hostname_strips_username() {
assert_eq!(parse_hostname("git@github.com"), "github.com");
assert_eq!(parse_hostname("user@ghe.example.com"), "ghe.example.com");
}
#[test]
fn parse_hostname_handles_bare_hostname() {
assert_eq!(parse_hostname("github.com"), "github.com");
assert_eq!(parse_hostname("ghe.example.com"), "ghe.example.com");
}
#[test]
fn epoch_secs_to_iso8601_unix_epoch() {
assert_eq!(epoch_secs_to_iso8601(0), "1970-01-01T00:00:00Z");
}
#[test]
fn epoch_secs_to_iso8601_known_date() {
assert_eq!(epoch_secs_to_iso8601(1_775_952_000), "2026-04-12T00:00:00Z");
}
}