mod openssh;
mod process;
mod terminal;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
pub use openssh::OpenSshOptions;
pub(crate) use process::Session;
pub use terminal::{ForegroundTerminal, StoppedTerminal, TerminalEvents};
use super::TransportControl;
use crate::Config;
use crate::remote::{Destination, Protocol};
#[non_exhaustive]
pub struct ApprovedSshCommand {
pub configured: Vec<u8>,
pub executable: PathBuf,
pub arguments: Vec<String>,
}
impl ApprovedSshCommand {
pub fn new(configured: Vec<u8>, executable: PathBuf, arguments: Vec<String>) -> Self {
Self {
configured,
executable,
arguments,
}
}
#[must_use]
pub fn with_configured(mut self, configured: Vec<u8>) -> Self {
self.configured = configured;
self
}
#[must_use]
pub fn with_executable(mut self, executable: PathBuf) -> Self {
self.executable = executable;
self
}
#[must_use]
pub fn with_arguments(mut self, arguments: Vec<String>) -> Self {
self.arguments = arguments;
self
}
}
impl std::fmt::Debug for ApprovedSshCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApprovedSshCommand").finish_non_exhaustive()
}
}
#[derive(Default)]
#[non_exhaustive]
pub struct SshEnvironment {
pub git_ssh_command: Option<Vec<u8>>,
pub git_ssh: Option<Vec<u8>>,
pub git_ssh_variant: Option<Vec<u8>>,
pub approved_command: Option<ApprovedSshCommand>,
pub default_executable: PathBuf,
pub config_file: PathBuf,
pub default_user: String,
pub agent_socket: Option<PathBuf>,
pub askpass: Option<PathBuf>,
pub path_environment: Option<OsString>,
}
impl SshEnvironment {
#[must_use]
pub fn with_git_ssh_command(mut self, git_ssh_command: Option<Vec<u8>>) -> Self {
self.git_ssh_command = git_ssh_command;
self
}
#[must_use]
pub fn with_git_ssh(mut self, git_ssh: Option<Vec<u8>>) -> Self {
self.git_ssh = git_ssh;
self
}
#[must_use]
pub fn with_git_ssh_variant(mut self, git_ssh_variant: Option<Vec<u8>>) -> Self {
self.git_ssh_variant = git_ssh_variant;
self
}
#[must_use]
pub fn with_approved_command(mut self, approved_command: Option<ApprovedSshCommand>) -> Self {
self.approved_command = approved_command;
self
}
#[must_use]
pub fn with_default_executable(mut self, default_executable: PathBuf) -> Self {
self.default_executable = default_executable;
self
}
#[must_use]
pub fn with_config_file(mut self, config_file: PathBuf) -> Self {
self.config_file = config_file;
self
}
#[must_use]
pub fn with_default_user(mut self, default_user: String) -> Self {
self.default_user = default_user;
self
}
#[must_use]
pub fn with_agent_socket(mut self, agent_socket: Option<PathBuf>) -> Self {
self.agent_socket = agent_socket;
self
}
#[must_use]
pub fn with_askpass(mut self, askpass: Option<PathBuf>) -> Self {
self.askpass = askpass;
self
}
#[must_use]
pub fn with_path_environment(mut self, path_environment: Option<OsString>) -> Self {
self.path_environment = path_environment;
self
}
}
impl std::fmt::Debug for SshEnvironment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SshEnvironment").finish_non_exhaustive()
}
}
pub struct SshRemote {
host: String,
user: Option<String>,
port: Option<u16>,
path: String,
executable: PathBuf,
arguments: Vec<String>,
policy: LaunchPolicy,
terminal: Option<std::sync::Arc<ForegroundTerminal>>,
protocol: super::RequestedProtocol,
}
enum LaunchPolicy {
Restricted {
config: PathBuf,
agent_socket: Option<PathBuf>,
askpass: Option<PathBuf>,
path_environment: Option<OsString>,
},
OpenSsh(std::collections::BTreeMap<OsString, OsString>),
}
impl std::fmt::Debug for SshRemote {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SshRemote").finish_non_exhaustive()
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SshError {
#[error("invalid SSH configuration: {0}")]
Configuration(&'static str),
#[error("SSH I/O failure: {0:?}")]
Io(std::io::ErrorKind),
#[error(
"SSH or remote service failed (exit {0:?}); check explicit trust and authentication configuration"
)]
Exit(Option<i32>),
#[error("invalid SSH service framing: {0}")]
Protocol(&'static str),
#[error("SSH response byte limit exceeded")]
Limit,
#[error("SSH cancelled")]
Cancelled,
#[error("SSH deadline expired")]
Deadline,
}
impl From<std::io::Error> for SshError {
fn from(error: std::io::Error) -> Self {
match super::interruption(&error) {
Some(super::Interruption::Cancelled) => Self::Cancelled,
Some(super::Interruption::Deadline) => Self::Deadline,
None => Self::Io(error.kind()),
}
}
}
impl SshRemote {
pub fn new(
host: &str,
user: &str,
port: u16,
path: &str,
executable: &Path,
config: &Path,
) -> Result<Self, SshError> {
Self::from_components(
host,
user,
port,
path,
executable,
config,
std::env::var_os("PATH"),
)
}
fn from_components(
host: &str,
user: &str,
port: u16,
path: &str,
executable: &Path,
config: &Path,
path_environment: Option<OsString>,
) -> Result<Self, SshError> {
validate_endpoint(host, Some(user), Some(port), path)?;
for file in [executable, config] {
if !file.is_absolute()
|| file.as_os_str().len() > 8192
|| file
.as_os_str()
.as_encoded_bytes()
.iter()
.any(|b| b.is_ascii_control())
{
return Err(SshError::Configuration("absolute executable/config file"));
}
}
Ok(Self {
host: host.into(),
user: Some(user.into()),
port: Some(port),
path: path.into(),
executable: executable.into(),
arguments: Vec::new(),
terminal: None,
protocol: super::RequestedProtocol::V0,
policy: LaunchPolicy::Restricted {
config: config.into(),
agent_socket: None,
askpass: None,
path_environment,
},
})
}
pub fn configured(
config: &Config,
destination: &Destination,
environment: SshEnvironment,
) -> Result<Self, SshError> {
if destination.protocol() != Protocol::Ssh {
return Err(SshError::Configuration("SSH destination"));
}
let variant = match environment.git_ssh_variant.as_deref() {
Some(value) => Some(value),
None => configured_value(config, "ssh", "variant")?,
};
if variant.is_some_and(|value| !value.eq_ignore_ascii_case(b"ssh")) {
return Err(SshError::Configuration("SSH variant"));
}
let selected = match environment.git_ssh_command.as_deref() {
Some(value) => Some(value),
None => {
configured_value(config, "core", "sshcommand")?.or(environment.git_ssh.as_deref())
}
};
let (executable, arguments) = approve_command(
selected,
environment.approved_command,
environment.default_executable,
)?;
let (host, user, port, path) =
parse_destination(destination.bytes(), &environment.default_user)?;
let mut remote = Self::from_components(
&host,
&user,
port,
&path,
&executable,
&environment.config_file,
environment.path_environment,
)?;
for file in [
environment.agent_socket.as_deref(),
environment.askpass.as_deref(),
]
.into_iter()
.flatten()
{
validate_file(file)?;
}
remote.arguments = arguments;
if let LaunchPolicy::Restricted {
agent_socket,
askpass,
..
} = &mut remote.policy
{
*agent_socket = environment.agent_socket;
*askpass = environment.askpass;
}
Ok(remote)
}
pub fn with_terminal(
mut self,
terminal: std::sync::Arc<ForegroundTerminal>,
) -> Result<Self, SshError> {
if !matches!(self.policy, LaunchPolicy::OpenSsh(_)) || self.terminal.is_some() {
return Err(SshError::Configuration(
"terminal requires ordinary OpenSSH policy",
));
}
self.terminal = Some(terminal);
Ok(self)
}
#[must_use]
pub fn with_protocol(mut self, protocol: super::RequestedProtocol) -> Self {
self.protocol = protocol;
self
}
pub fn protocol(&self) -> super::RequestedProtocol {
self.protocol
}
pub fn has_terminal(&self) -> bool {
self.terminal.is_some()
}
pub(crate) fn connect(
&self,
service: &str,
control: TransportControl<'_>,
) -> Result<Session, SshError> {
control.check()?;
Session::spawn_with_terminal(&mut self.command(service), self.terminal.as_deref())
}
fn command(&self, service: &str) -> Command {
let mut command = Command::new(&self.executable);
command.env_clear();
let version = self
.protocol
.parameter()
.filter(|_| service == "git-upload-pack");
match &self.policy {
LaunchPolicy::Restricted {
config,
agent_socket,
askpass,
path_environment,
} => {
if let Some(path) = &path_environment {
command.env("PATH", path);
}
if let Some(socket) = &agent_socket {
command.env("SSH_AUTH_SOCK", socket);
}
if let Some(askpass) = &askpass {
command.env("SSH_ASKPASS", askpass);
command.env("SSH_ASKPASS_REQUIRE", "force");
command.env("DISPLAY", "girt-askpass");
} else {
command.env("SSH_ASKPASS_REQUIRE", "never");
}
command.args(["-T", "-F"]).arg(config);
command.args([
"-o",
if askpass.is_some() {
"BatchMode=no"
} else {
"BatchMode=yes"
},
]);
command.args([
"-o",
if agent_socket.is_some() {
"IdentityAgent=SSH_AUTH_SOCK"
} else {
"IdentityAgent=none"
},
]);
for option in [
"StrictHostKeyChecking=yes",
"UpdateHostKeys=no",
"PasswordAuthentication=no",
"KbdInteractiveAuthentication=no",
"PreferredAuthentications=publickey",
"IdentityFile=none",
"IdentitiesOnly=yes",
"PKCS11Provider=none",
"SecurityKeyProvider=internal",
"ConnectionAttempts=1",
"Tunnel=no",
"ForwardAgent=no",
"ForwardX11=no",
"ClearAllForwardings=yes",
"PermitLocalCommand=no",
"ProxyCommand=none",
"ProxyJump=none",
"ControlMaster=no",
"ControlPath=none",
"ControlPersist=no",
"ForkAfterAuthentication=no",
"RequestTTY=no",
"RemoteCommand=none",
"SessionType=default",
"StdinNull=no",
"EscapeChar=none",
] {
command.args(["-o", option]);
}
if let Some(version) = version {
command.env("GIT_PROTOCOL", version);
command.args(["-o", "SendEnv=GIT_PROTOCOL"]);
}
command.args(&self.arguments);
command.args(["-p", &self.port.expect("restricted port").to_string(), "-l"]);
command.arg(self.user.as_deref().expect("restricted user"));
command.args(["--", &self.host]);
}
LaunchPolicy::OpenSsh(environment) => {
command.envs(environment);
command.args(&self.arguments);
if let Some(version) = version {
command.env("GIT_PROTOCOL", version);
command.args(["-o", "SendEnv=GIT_PROTOCOL"]);
}
if let Some(port) = self.port {
command.args(["-p", &port.to_string()]);
}
match &self.user {
Some(user) => {
command.arg(format!("{user}@{}", self.host));
}
None => {
command.arg(&self.host);
}
}
}
}
command.arg(format!("{service} '{}'", self.path.replace('\'', "'\\''")));
command
}
}
fn configured_value<'a>(
config: &'a Config,
section: &'a str,
key: &'a str,
) -> Result<Option<&'a [u8]>, SshError> {
match config.values(section, None, key).last() {
Some(None) => Err(SshError::Configuration(
"SSH configuration requires a value",
)),
value => Ok(value.flatten()),
}
}
fn safe_name(value: &str) -> bool {
!value.is_empty()
&& !value.starts_with('-')
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b))
}
fn validate_file(file: &Path) -> Result<(), SshError> {
if !file.is_absolute()
|| file.as_os_str().len() > 8192
|| file
.as_os_str()
.as_encoded_bytes()
.iter()
.any(|byte| byte.is_ascii_control())
{
return Err(SshError::Configuration("SSH authentication path"));
}
Ok(())
}
fn parse_destination(
raw: &[u8],
default_user: &str,
) -> Result<(String, String, u16, String), SshError> {
let (host, user, port, path) = parse_endpoint(raw)?;
Ok((
host,
user.unwrap_or_else(|| default_user.to_owned()),
port.unwrap_or(22),
path,
))
}
fn parse_endpoint(raw: &[u8]) -> Result<(String, Option<String>, Option<u16>, String), SshError> {
let raw =
std::str::from_utf8(raw).map_err(|_| SshError::Configuration("SSH endpoint encoding"))?;
let (authority, path, url) = if let Some(rest) = raw.strip_prefix("ssh://") {
let (authority, path) = rest
.split_once('/')
.ok_or(SshError::Configuration("SSH URL path"))?;
(authority, format!("/{path}"), true)
} else {
let (authority, path) = raw
.split_once(':')
.ok_or(SshError::Configuration("SSH scp endpoint"))?;
(authority, path.to_owned(), false)
};
let (user, host_port) = authority
.split_once('@')
.map_or((None, authority), |(user, host)| {
(Some(user.to_owned()), host)
});
if authority.matches('@').count() > 1 || raw.contains(['?', '#', '%']) {
return Err(SshError::Configuration("SSH endpoint syntax"));
}
let (host, port) = if url {
if let Some(bracketed) = host_port.strip_prefix('[') {
let (host, suffix) = bracketed
.split_once(']')
.ok_or(SshError::Configuration("SSH host"))?;
let port = if suffix.is_empty() {
None
} else {
Some(parse_port(
suffix
.strip_prefix(':')
.ok_or(SshError::Configuration("SSH host"))?,
)?)
};
(host, port)
} else if let Some((host, port)) = host_port.split_once(':') {
(host, Some(parse_port(port)?))
} else {
(host_port, None)
}
} else {
(host_port, None)
};
if host.contains(['[', ']', ':']) && host.parse::<std::net::Ipv6Addr>().is_err() {
return Err(SshError::Configuration("SSH host"));
}
Ok((host.to_owned(), user, port, path))
}
fn parse_port(port: &str) -> Result<u16, SshError> {
port.parse::<u16>()
.ok()
.filter(|port| *port > 0)
.ok_or(SshError::Configuration("SSH port"))
}
#[cfg(test)]
mod tests;
fn validate_endpoint(
host: &str,
user: Option<&str>,
port: Option<u16>,
path: &str,
) -> Result<(), SshError> {
let host_name = safe_name(host) && !host.contains('_');
if host.len() > 255 || !(host_name || host.parse::<std::net::Ipv6Addr>().is_ok()) {
return Err(SshError::Configuration("host"));
}
if user.is_some_and(|user| user.len() > 255 || !safe_name(user)) {
return Err(SshError::Configuration("user"));
}
if port == Some(0) {
return Err(SshError::Configuration("port"));
}
if path.is_empty()
|| path.len() > 8192
|| path.starts_with(['-', '~'])
|| path.chars().any(char::is_control)
{
return Err(SshError::Configuration("repository path"));
}
Ok(())
}
fn approve_command(
selected: Option<&[u8]>,
approved_command: Option<ApprovedSshCommand>,
default_executable: PathBuf,
) -> Result<(PathBuf, Vec<String>), SshError> {
match selected {
Some(value) => {
if value.is_empty() {
return Err(SshError::Configuration("empty SSH command"));
}
let approved = approved_command.ok_or(SshError::Configuration(
"SSH command requires application approval",
))?;
if approved.configured != value {
return Err(SshError::Configuration("SSH command approval mismatch"));
}
if approved
.arguments
.iter()
.any(|arg| arg.is_empty() || arg.len() > 8192 || arg.chars().any(char::is_control))
{
return Err(SshError::Configuration("SSH command argument"));
}
Ok((approved.executable, approved.arguments))
}
None => {
if approved_command.is_some() {
return Err(SshError::Configuration("unused SSH command approval"));
}
Ok((default_executable, Vec::new()))
}
}
}