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 ssh: Option<Ssh>,
#[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 = "BTreeMap::is_empty")]
pub deploy: BTreeMap<String, DeployTarget>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DeployTarget {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Credential {
pub server: String,
pub kind: String,
pub login: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Ssh {
pub host: String,
#[serde(default = "default_ssh_port")]
pub port: u16,
pub user: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
}
fn default_ssh_port() -> u16 {
22
}
#[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_ssh(spec: &str) -> Result<Ssh> {
let (user, rest) = spec
.split_once('@')
.ok_or_else(|| anyhow::anyhow!("invalid SSH spec '{spec}': expected user@host[:port]"))?;
let (host, port) = match rest.split_once(':') {
Some((host, port)) => (host, port.parse().map_err(|_| anyhow::anyhow!("invalid SSH port in '{spec}'"))?),
None => (rest, default_ssh_port()),
};
if user.is_empty() || host.is_empty() {
bail!("invalid SSH spec '{spec}': expected user@host[:port]");
}
Ok(Ssh {
host: host.to_string(),
port,
user: user.to_string(),
key: None,
})
}
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 ssh_specs() {
let ssh = parse_ssh("deploy@staging.example.com").unwrap();
assert_eq!((ssh.user.as_str(), ssh.host.as_str(), ssh.port), ("deploy", "staging.example.com", 22));
let ssh = parse_ssh("root@10.0.0.1:2222").unwrap();
assert_eq!(ssh.port, 2222);
assert!(parse_ssh("nohost").is_err());
assert!(parse_ssh("user@host:notaport").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());
}
}