use std::collections::BTreeMap;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct App {
pub name: String,
pub path: String,
#[serde(default)]
pub commands: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dist_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway_port: Option<u16>,
#[serde(default)]
pub servers: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Server {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default = "default_ssh_port")]
pub port: u16,
#[serde(default)]
pub accept_invalid_certs: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<crate::shell::Dialect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub deploy: BTreeMap<String, String>,
}
impl Server {
pub fn ssh_host(&self) -> String {
self.host.clone().unwrap_or_else(|| url_host(&self.url).to_string())
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Credential {
pub name: String,
pub user: String,
#[serde(default)]
pub auth: Auth,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Auth {
#[default]
Password,
Key,
}
impl Auth {
pub fn as_str(self) -> &'static str {
match self {
Auth::Password => "password",
Auth::Key => "key",
}
}
}
impl std::fmt::Display for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Auth {
type Err = anyhow::Error;
fn from_str(text: &str) -> Result<Self> {
match text {
"password" => Ok(Auth::Password),
"key" => Ok(Auth::Key),
other => bail!("unknown auth kind '{other}': expected 'password' or 'key'"),
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Path {
pub name: String,
pub dir: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart: Option<String>,
}
fn default_ssh_port() -> u16 {
22
}
fn url_host(url: &str) -> &str {
let rest = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host = authority.rsplit_once('@').map(|(_, host)| host).unwrap_or(authority);
match host.rsplit_once(':') {
Some((before, after)) if !after.is_empty() && after.chars().all(|c| c.is_ascii_digit()) => before,
_ => host,
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Group {
pub name: String,
pub apps: Vec<String>,
}
#[derive(Serialize, Deserialize, Default)]
pub struct State {
#[serde(default)]
pub bindings: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<Gateway>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Gateway {
pub pid: u32,
pub ports: BTreeMap<u16, String>,
}
pub fn validate_name(name: &str) -> Result<()> {
let ok =
!name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') && !name.starts_with('-') && !name.ends_with('-');
if !ok {
bail!("invalid name '{name}': use lowercase letters, digits and dashes");
}
Ok(())
}
pub fn parse_host(spec: &str) -> Result<(String, u16)> {
let spec = spec.trim();
if let Some((user, rest)) = spec.split_once('@') {
bail!(
"'{spec}' names a user: since v0.9.0 the login lives in a credential, not the server.\n\
Use the host alone (`{rest}`) and `turnout credential add --user {user}` for who logs in."
);
}
let (host, port) = match spec.rsplit_once(':') {
Some((host, port)) => (host, port.parse().map_err(|_| anyhow::anyhow!("invalid SSH port in '{spec}'"))?),
None => (spec, default_ssh_port()),
};
if host.is_empty() {
bail!("invalid host '{spec}': expected host[:port]");
}
Ok((host.to_string(), port))
}
pub fn validate_url(url: &str) -> Result<()> {
if !(url.starts_with("http://") || url.starts_with("https://")) {
bail!("invalid URL '{url}': must start with http:// or https://");
}
Ok(())
}
pub fn normalize_remote_path(path: &str) -> String {
let path = path.trim();
match path.strip_prefix("//") {
Some(rest) if !rest.starts_with('/') => format!("/{rest}"),
_ => path.to_string(),
}
}
pub fn validate_remote_path(path: &str) -> Result<()> {
if path.trim().is_empty() {
bail!("remote path is empty: pass an absolute path on the server, e.g. /var/www/myapp");
}
if let Some(original) = looks_rewritten_by_git_bash(path) {
bail!(
"'{path}' looks like Git Bash rewrote '{original}' into a local path before turnout saw it.\n\
Prefix the command with MSYS_NO_PATHCONV=1, double the leading slash (/{original}), or use PowerShell.\n\
If you really meant this directory on a Windows server, pass it with backslashes."
);
}
if !is_absolute_remote(path) {
bail!("remote path '{path}' must be absolute, e.g. /var/www/myapp or C:\\inetpub\\myapp");
}
Ok(())
}
fn is_absolute_remote(path: &str) -> bool {
path.starts_with('/') || has_drive_letter(path) || path.starts_with("\\\\")
}
fn looks_rewritten_by_git_bash(path: &str) -> Option<&str> {
if !has_drive_letter(path) {
return None;
}
let normalized = path.replace('\\', "/");
const MSYS_ROOTS: [&str; 4] = ["/Program Files/Git/", "/Program Files (x86)/Git/", "/git/", "/msys64/"];
let rest_index = MSYS_ROOTS.iter().find_map(|root| normalized.find(root).map(|at| at + root.len()))?;
Some(&path[rest_index..])
}
fn has_drive_letter(path: &str) -> bool {
let mut chars = path.chars();
matches!((chars.next(), chars.next(), chars.next()), (Some(c), Some(':'), Some('/' | '\\')) if c.is_ascii_alphabetic())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names() {
assert!(validate_name("myapp").is_ok());
assert!(validate_name("my-app-2").is_ok());
assert!(validate_name("").is_err());
assert!(validate_name("My App").is_err());
assert!(validate_name("-x").is_err());
}
#[test]
fn host_specs() {
assert_eq!(parse_host("staging.example.com").unwrap(), ("staging.example.com".to_string(), 22));
assert_eq!(parse_host("10.0.0.1:2222").unwrap(), ("10.0.0.1".to_string(), 2222));
assert!(parse_host("").is_err());
assert!(parse_host("host:notaport").is_err());
}
#[test]
fn a_user_at_host_spec_explains_where_the_user_went() {
let err = parse_host("deploy@staging.example.com").unwrap_err().to_string();
assert!(err.contains("credential"), "{err}");
assert!(err.contains("--user deploy"), "the error must carry the name forward: {err}");
assert!(err.contains("staging.example.com"), "and the host to use instead: {err}");
}
#[test]
fn the_ssh_host_falls_back_to_the_url() {
assert_eq!(url_host("https://staging.example.com"), "staging.example.com");
assert_eq!(url_host("http://staging.example.com:8081/app"), "staging.example.com");
assert_eq!(url_host("https://user@host.example.com/x"), "host.example.com");
assert_eq!(url_host("http://[::1]:8080"), "[::1]");
assert_eq!(url_host("example.com"), "example.com");
}
#[test]
fn auth_kinds_round_trip() {
assert_eq!("password".parse::<Auth>().unwrap(), Auth::Password);
assert_eq!("key".parse::<Auth>().unwrap(), Auth::Key);
assert_eq!(Auth::Key.to_string(), "key");
assert!("agent".parse::<Auth>().is_err());
}
#[test]
fn remote_paths_must_be_absolute() {
assert!(validate_remote_path("/var/www/myapp").is_ok());
assert!(validate_remote_path("/srv/app with spaces").is_ok());
assert!(validate_remote_path("").is_err());
assert!(validate_remote_path(" ").is_err());
assert!(validate_remote_path("var/www/myapp").is_err(), "relative paths are not a server directory");
assert!(validate_remote_path("inetpub\\myapp").is_err(), "relative is relative on Windows too");
}
#[test]
fn windows_server_directories_are_accepted() {
assert!(validate_remote_path("C:\\inetpub\\wwwroot\\myapp").is_ok());
assert!(validate_remote_path("D:/sites/myapp").is_ok());
assert!(validate_remote_path("C:\\sites\\my app").is_ok(), "spaces are ordinary in Windows paths");
assert!(
validate_remote_path("\\\\fileserver\\share\\myapp").is_ok(),
"a UNC share is a real destination"
);
}
#[test]
fn remote_paths_drop_the_escaping_slash() {
assert_eq!(normalize_remote_path("//var/www/myapp"), "/var/www/myapp");
assert_eq!(normalize_remote_path("/var/www/myapp"), "/var/www/myapp");
assert_eq!(normalize_remote_path(" /srv/app "), "/srv/app");
assert_eq!(normalize_remote_path("///odd"), "///odd");
assert_eq!(normalize_remote_path("\\\\fileserver\\share"), "\\\\fileserver\\share");
}
#[test]
fn a_git_bash_rewrite_is_caught_and_explained() {
let mangled = validate_remote_path("C:/Program Files/Git/var/www/myapp").unwrap_err().to_string();
assert!(mangled.contains("MSYS_NO_PATHCONV"), "the error must say how to get past it: {mangled}");
assert!(mangled.contains("var/www/myapp"), "it must name the path the user meant: {mangled}");
assert!(validate_remote_path("C:\\Program Files\\Git\\var\\www\\myapp").is_err(), "backslashes too");
assert!(validate_remote_path("C:/msys64/var/www/myapp").is_err());
}
#[test]
fn a_windows_path_that_mentions_git_is_not_a_rewrite() {
assert!(validate_remote_path("C:\\sites\\gitlab-runner").is_ok());
assert!(validate_remote_path("D:\\git-repos\\myapp").is_ok());
}
}