use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Dialect {
#[default]
Posix,
Windows,
}
impl fmt::Display for Dialect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Dialect::Posix => "posix",
Dialect::Windows => "windows",
})
}
}
pub const PROBE: &str = "echo %COMSPEC%";
pub fn read_probe(reply: &str) -> Dialect {
let reply = reply.trim().to_ascii_lowercase();
if reply.contains("cmd.exe") { Dialect::Windows } else { Dialect::Posix }
}
impl Dialect {
pub fn quote(&self, value: &str) -> String {
match self {
Dialect::Posix => format!("'{}'", value.replace('\'', "'\\''")),
Dialect::Windows => format!("\"{value}\""),
}
}
pub fn reject_unquotable(&self, value: &str) -> Result<(), String> {
match self {
Dialect::Posix => Ok(()),
Dialect::Windows if value.contains('"') => Err(format!("'{value}' contains a double quote, which cmd.exe cannot quote")),
Dialect::Windows if value.contains('%') => Err(format!("'{value}' contains a percent sign, which cmd.exe would expand as a variable")),
Dialect::Windows => Ok(()),
}
}
pub fn mkdir_p(&self, dir: &str) -> String {
let quoted = self.quote(dir);
match self {
Dialect::Posix => format!("mkdir -p {quoted}"),
Dialect::Windows => format!("if not exist {quoted} mkdir {quoted}"),
}
}
pub fn clear_dir(&self, dir: &str) -> String {
let quoted = self.quote(dir);
match self {
Dialect::Posix => format!("find {quoted} -mindepth 1 -maxdepth 1 -exec rm -rf {{}} +"),
Dialect::Windows => format!("del /f /q /s {quoted}\\* >nul 2>&1 & for /d %i in ({quoted}\\*) do @rd /s /q \"%i\" & exit /b 0"),
}
}
pub fn remove_file(&self, path: &str) -> String {
let quoted = self.quote(path);
match self {
Dialect::Posix => format!("rm -f {quoted}"),
Dialect::Windows => format!("del /f /q {quoted} >nul 2>&1 & exit /b 0"),
}
}
pub fn untar(&self, archive: &str, into: &str) -> String {
format!("tar xzf {} -C {}", self.quote(archive), self.quote(into))
}
pub fn tar_czf(&self, archive: &str, from_dir: &str) -> String {
format!("tar czf {} -C {} .", self.quote(archive), self.quote(from_dir))
}
pub fn list_dir(&self, dir: &str) -> String {
let quoted = self.quote(dir);
match self {
Dialect::Posix => format!("ls -1 {quoted} 2>/dev/null || true"),
Dialect::Windows => format!("dir /b {quoted} 2>nul & exit /b 0"),
}
}
pub fn file_exists(&self, path: &str) -> String {
let quoted = self.quote(path);
match self {
Dialect::Posix => format!("test -f {quoted}"),
Dialect::Windows => format!("if not exist {quoted} exit /b 1"),
}
}
pub fn and_then(&self, first: &str, second: &str) -> String {
format!("{first} && {second}")
}
pub fn run_in(&self, dir: &str, command: &str) -> String {
let quoted = self.quote(dir);
match self {
Dialect::Posix => format!("cd {quoted} && {command}"),
Dialect::Windows => format!("cd /d {quoted} && {command}"),
}
}
pub fn read_file(&self, path: &str) -> String {
let quoted = self.quote(path);
match self {
Dialect::Posix => format!("cat {quoted} 2>/dev/null || true"),
Dialect::Windows => format!("type {quoted} 2>nul & exit /b 0"),
}
}
pub fn append_line(&self, path: &str, line: &str) -> String {
match self {
Dialect::Posix => format!("echo {} >> {}", self.quote(line), self.quote(path)),
Dialect::Windows => format!("echo {line}>>{}", self.quote(path)),
}
}
pub fn home_dir(&self) -> &'static str {
match self {
Dialect::Posix => "echo $HOME",
Dialect::Windows => "echo %USERPROFILE%",
}
}
pub fn restrict_to_owner(&self, dir: &str, file: &str) -> String {
match self {
Dialect::Posix => format!("chmod 700 {} && chmod 600 {}", self.quote(dir), self.quote(file)),
Dialect::Windows => format!("icacls {} /inheritance:r /grant \"%USERNAME%\":F /grant \"SYSTEM\":F", self.quote(file)),
}
}
}
#[cfg(test)]
mod tests {
use super::{Dialect, PROBE, read_probe};
#[test]
fn a_command_runs_in_the_directory_it_belongs_to() {
assert_eq!(
Dialect::Posix.run_in("/srv/app", "docker compose up -d"),
"cd '/srv/app' && docker compose up -d"
);
let windows = Dialect::Windows.run_in("C:\\site", "docker compose up -d");
assert_eq!(windows, "cd /d \"C:\\site\" && docker compose up -d");
assert!(windows.contains("/d"), "without /d cmd.exe does not change drive: {windows}");
}
#[test]
fn a_directory_with_a_space_stays_one_argument() {
assert_eq!(Dialect::Posix.run_in("/srv/my app", "ls"), "cd '/srv/my app' && ls");
assert_eq!(Dialect::Windows.run_in("C:\\my site", "dir"), "cd /d \"C:\\my site\" && dir");
}
#[test]
fn reading_a_missing_file_is_not_a_failure() {
assert!(Dialect::Posix.read_file("/home/pi/.ssh/authorized_keys").contains("|| true"));
assert!(Dialect::Windows.read_file("C:\\ProgramData\\ssh\\x").contains("exit /b 0"));
}
#[test]
fn a_key_line_is_appended_verbatim() {
let line = "ssh-ed25519 AAAAB turnout@desktop";
let posix = Dialect::Posix.append_line("/home/pi/.ssh/authorized_keys", line);
assert_eq!(posix, "echo 'ssh-ed25519 AAAAB turnout@desktop' >> '/home/pi/.ssh/authorized_keys'");
let windows = Dialect::Windows.append_line("C:\\ProgramData\\ssh\\administrators_authorized_keys", line);
assert!(windows.contains(line), "the line must not be quoted for cmd.exe: {windows}");
assert!(!windows.contains(&format!("'{line}'")), "{windows}");
assert!(windows.contains(">>"), "{windows}");
assert!(posix.contains(">>"), "{posix}");
}
#[test]
fn the_home_directory_is_asked_for_in_the_right_dialect() {
assert_eq!(Dialect::Posix.home_dir(), "echo $HOME");
assert_eq!(Dialect::Windows.home_dir(), "echo %USERPROFILE%");
}
#[test]
fn the_key_file_is_restricted_to_its_owner() {
let posix = Dialect::Posix.restrict_to_owner("/home/pi/.ssh", "/home/pi/.ssh/authorized_keys");
assert!(posix.contains("chmod 700 '/home/pi/.ssh'"), "{posix}");
assert!(posix.contains("chmod 600 '/home/pi/.ssh/authorized_keys'"), "{posix}");
let windows = Dialect::Windows.restrict_to_owner("C:\\ProgramData\\ssh", "C:\\ProgramData\\ssh\\administrators_authorized_keys");
assert!(windows.contains("/inheritance:r"), "{windows}");
assert!(windows.contains("SYSTEM"), "{windows}");
}
#[test]
fn the_probe_is_neutral() {
assert!(!PROBE.contains('\''), "single quotes are literal in cmd.exe: {PROBE}");
assert!(!PROBE.contains('>'), "redirection differs between the shells: {PROBE}");
assert!(!PROBE.contains("&&"), "keep the probe to a single command: {PROBE}");
}
#[test]
fn reads_the_replies_the_shells_actually_give() {
assert_eq!(read_probe("C:\\Windows\\system32\\cmd.exe"), Dialect::Windows);
assert_eq!(read_probe("%COMSPEC%"), Dialect::Posix);
}
#[test]
fn recognizes_cmd_whatever_the_case() {
assert_eq!(read_probe("C:\\WINDOWS\\SYSTEM32\\CMD.EXE"), Dialect::Windows);
}
#[test]
fn an_unrecognizable_reply_stays_posix() {
assert_eq!(read_probe(""), Dialect::Posix);
assert_eq!(read_probe("some login banner"), Dialect::Posix);
}
#[test]
fn quotes_per_dialect() {
assert_eq!(Dialect::Posix.quote("/var/www/my app"), "'/var/www/my app'");
assert_eq!(Dialect::Posix.quote("it's"), "'it'\\''s'");
assert_eq!(Dialect::Windows.quote("C:\\inetpub\\my site"), "\"C:\\inetpub\\my site\"");
}
#[test]
fn windows_refuses_what_it_cannot_quote() {
assert!(Dialect::Windows.reject_unquotable("C:\\ok\\path").is_ok());
assert!(Dialect::Windows.reject_unquotable("C:\\say \"hi\"").is_err());
assert!(Dialect::Windows.reject_unquotable("C:\\%TEMP%\\x").is_err());
assert!(Dialect::Posix.reject_unquotable("it's \"quoted\" 100%").is_ok());
}
#[test]
fn clearing_keeps_the_directory_itself() {
let posix = Dialect::Posix.clear_dir("/var/www/site");
assert!(posix.contains("-mindepth 1"), "{posix}");
assert!(!posix.contains("rm -rf '/var/www/site'"), "the directory itself must not be removed: {posix}");
let windows = Dialect::Windows.clear_dir("C:\\site");
assert!(windows.contains("\\*"), "only the contents are targeted: {windows}");
assert!(
!windows.contains("rd /s /q \"C:\\site\""),
"the directory itself must not be removed: {windows}"
);
}
#[test]
fn listing_and_clearing_tolerate_nothing_there() {
assert!(Dialect::Posix.list_dir("/backups").contains("|| true"));
assert!(Dialect::Windows.list_dir("C:\\backups").contains("exit /b 0"));
assert!(Dialect::Windows.clear_dir("C:\\site").contains("exit /b 0"));
assert!(Dialect::Windows.remove_file("C:\\x.tar.gz").contains("exit /b 0"));
}
#[test]
fn both_dialects_use_tar() {
assert!(Dialect::Posix.untar("/tmp/a.tar.gz", "/var/www").starts_with("tar xzf "));
assert!(Dialect::Windows.untar("C:\\a.tar.gz", "C:\\site").starts_with("tar xzf "));
assert_eq!(Dialect::Windows.untar("C:\\a.tar.gz", "C:\\site"), "tar xzf \"C:\\a.tar.gz\" -C \"C:\\site\"");
}
#[test]
fn paths_with_spaces_stay_one_argument() {
assert!(Dialect::Windows.mkdir_p("C:\\my site").contains("\"C:\\my site\""));
assert!(Dialect::Posix.mkdir_p("/var/my site").contains("'/var/my site'"));
assert!(Dialect::Windows.file_exists("C:\\my site\\a.txt").contains("\"C:\\my site\\a.txt\""));
}
#[test]
fn mkdir_tolerates_an_existing_directory() {
assert!(Dialect::Posix.mkdir_p("/var/www/site").starts_with("mkdir -p"));
assert!(Dialect::Windows.mkdir_p("C:\\site").starts_with("if not exist"));
}
}