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!(
"sh -c {}",
self.quote(&format!("[ ! -d {quoted} ] || 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\" & (call ))"),
}
}
pub fn clear_dir_listing_the_rest(&self, dir: &str) -> String {
let quoted = self.quote(dir);
match self {
Dialect::Posix => format!(
"sh -c {}",
self.quote(&format!(
"[ -d {quoted} ] || exit 0; find {quoted} -mindepth 1 -maxdepth 1 -exec rm -rf {{}} + 2>/dev/null; ls -A {quoted}; exit 0"
))
),
Dialect::Windows => {
format!("(del /f /q /s {quoted}\\* >nul 2>&1 & for /d %i in ({quoted}\\*) do @rd /s /q \"%i\" >nul 2>&1 & dir /b /a {quoted} 2>nul & (call ))")
}
}
}
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 & (call ))"),
}
}
pub fn remove_files(&self, paths: &[&str]) -> String {
match self {
Dialect::Posix => {
let quoted: Vec<String> = paths.iter().map(|path| self.quote(path)).collect();
format!("rm -f -- {}", quoted.join(" "))
}
Dialect::Windows => {
let quoted: Vec<String> = paths.iter().map(|path| self.quote(&path.replace('/', "\\"))).collect();
format!("(del /f /q {} >nul 2>&1 & (call ))", quoted.join(" "))
}
}
}
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 & (call ))"),
}
}
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 nothing_at(&self, path: &str) -> String {
let quoted = self.quote(path);
match self {
Dialect::Posix => format!("test ! -e {quoted}"),
Dialect::Windows => format!("(if exist {quoted} exit /b 1)"),
}
}
pub fn dir_exists(&self, dir: &str) -> String {
match self {
Dialect::Posix => format!("test -d {}", self.quote(dir)),
Dialect::Windows => format!("(if not exist \"{}\\\" exit /b 1)", dir.trim_end_matches(['\\', '/'])),
}
}
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 & (call ))"),
}
}
pub fn append_line(&self, path: &str, line: &str) -> String {
match self {
Dialect::Posix => {
let (file, line) = (self.quote(path), self.quote(line));
let script = format!("{{ [ ! -s {file} ] || [ -z \"$(tail -c 1 {file})\" ] || echo >> {file}; }} && printf '%s\\n' {line} >> {file}");
format!("sh -c {}", self.quote(&script))
}
Dialect::Windows => powershell(&format!(
"$ErrorActionPreference = 'Stop'\n\
$f = {}\n\
$line = {}\n\
try {{\n\
$lead = ''\n\
if (Test-Path -LiteralPath $f) {{ $bytes = [IO.File]::ReadAllBytes($f); if ($bytes.Length -gt 0 -and $bytes[-1] -ne 10) {{ $lead = \"`r`n\" }} }}\n\
[IO.File]::AppendAllText($f, $lead + $line + \"`r`n\")\n\
}} catch {{ [Console]::Error.WriteLine($_.Exception.Message); exit 1 }}\n",
powershell_literal(path),
powershell_literal(line)
)),
}
}
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!(
"for /f \"tokens=2 delims=,\" %s in ('%SystemRoot%\\System32\\whoami.exe /user /fo csv /nh') do @{}",
windows_grant_only(&self.quote(file), "*%~s:F")
),
}
}
pub fn restrict_to_administrators(&self, file: &str) -> String {
match self {
Dialect::Posix => format!("chmod 600 {}", self.quote(file)),
Dialect::Windows => windows_grant_only(&self.quote(file), &format!("*{ADMINISTRATORS_SID}:F")),
}
}
}
fn windows_grant_only(quoted: &str, grant: &str) -> String {
format!("(icacls {quoted} /reset >nul && icacls {quoted} /inheritance:r /grant {grant} /grant *{SYSTEM_SID}:F)")
}
const ADMINISTRATORS_SID: &str = "S-1-5-32-544";
const SYSTEM_SID: &str = "S-1-5-18";
pub fn powershell(script: &str) -> String {
let whole = format!("{POWERSHELL_PREAMBLE}{script}\nexit 0\n");
format!("powershell -NoProfile -NonInteractive -EncodedCommand {}", encoded(&whole))
}
pub fn powershell_literal(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('\'');
for c in value.chars() {
if matches!(c, '\'' | '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}') {
quoted.push(c);
}
quoted.push(c);
}
quoted.push('\'');
quoted
}
const POWERSHELL_PREAMBLE: &str = "$ErrorActionPreference = 'SilentlyContinue'\n$ProgressPreference = 'SilentlyContinue'\n";
pub const POWERSHELL_SCRIPT_CHARS: usize = 2_800;
fn encoded(script: &str) -> String {
let bytes: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect();
base64(&bytes)
}
fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let triple = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0));
for (index, shift) in [18, 12, 6, 0].into_iter().enumerate() {
if index <= chunk.len() {
out.push(ALPHABET[(triple >> shift) as usize & 63] as char);
} else {
out.push('=');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::{Dialect, PROBE, base64, encoded, read_probe};
#[test]
fn base64_matches_the_standard_vectors() {
for (plain, coded) in [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("fooba", "Zm9vYmE="),
("foobar", "Zm9vYmFy"),
] {
assert_eq!(base64(plain.as_bytes()), coded, "{plain}");
}
assert_eq!(encoded("a"), "YQA=");
}
#[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/deploy/.ssh/authorized_keys").contains("|| true"));
assert!(Dialect::Windows.read_file("C:\\ProgramData\\ssh\\x").contains("(call )"));
}
#[test]
fn a_key_line_is_appended_never_written_over() {
let posix = Dialect::Posix.append_line("/home/deploy/.ssh/authorized_keys", "ssh-ed25519 AAAAB turnout@desktop");
assert!(
posix.contains("printf '\\''%s\\n'\\'' '\\''ssh-ed25519 AAAAB turnout@desktop'\\'' >>"),
"{posix}"
);
assert!(!posix.contains("' > '"), "{posix}");
let windows = Dialect::Windows.append_line("C:\\ProgramData\\ssh\\administrators_authorized_keys", "ssh-ed25519 AAAAB x");
assert!(windows.starts_with("powershell "), "{windows}");
}
#[test]
fn a_powershell_literal_doubles_every_single_quote() {
assert_eq!(super::powershell_literal("C:\\Users\\O'Brien"), "'C:\\Users\\O''Brien'");
assert_eq!(super::powershell_literal("it\u{2019}s"), "'it\u{2019}\u{2019}s'");
assert_eq!(super::powershell_literal("$HOME `n & | < > %x% \"q\""), "'$HOME `n & | < > %x% \"q\"'");
}
#[test]
fn a_key_line_lands_on_its_own_line_in_a_real_shell() {
let stand = crate::ssh::tests::Stand::spawn_shell();
let session = stand.session();
let dialect = read_probe(&session.exec(PROBE).unwrap());
let dir = tempfile::tempdir().unwrap();
let lines = |file: &std::path::Path| -> Vec<String> { std::fs::read_to_string(file).unwrap().lines().map(str::to_string).collect() };
let line = "ssh-ed25519 AAAAB3Nz me & you | <laptop> 100%done \"home\" it's ^caret\\ (paren) !bang";
let notepad = dir.path().join("administrators_authorized_keys");
std::fs::write(¬epad, "ssh-ed25519 AAAAfirst first@key").unwrap();
session.exec(&dialect.append_line(¬epad.display().to_string(), line)).unwrap();
assert_eq!(lines(¬epad), ["ssh-ed25519 AAAAfirst first@key", line]);
assert!(std::fs::read_to_string(¬epad).unwrap().ends_with('\n'), "the file ends on a newline");
session
.exec(&dialect.append_line(¬epad.display().to_string(), "ssh-ed25519 AAAAthird"))
.unwrap();
assert_eq!(lines(¬epad), ["ssh-ed25519 AAAAfirst first@key", line, "ssh-ed25519 AAAAthird"]);
let fresh = dir.path().join("authorized_keys");
session.exec(&dialect.append_line(&fresh.display().to_string(), line)).unwrap();
assert_eq!(lines(&fresh), [line]);
let nowhere = dir.path().join("missing").join("authorized_keys");
assert!(session.exec(&dialect.append_line(&nowhere.display().to_string(), line)).is_err());
}
#[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/deploy/.ssh", "/home/deploy/.ssh/authorized_keys");
assert!(posix.contains("chmod 700 '/home/deploy/.ssh'"), "{posix}");
assert!(posix.contains("chmod 600 '/home/deploy/.ssh/authorized_keys'"), "{posix}");
let windows = Dialect::Windows.restrict_to_owner("C:\\Users\\deploy\\.ssh", "C:\\Users\\deploy\\.ssh\\authorized_keys");
assert!(windows.contains("/inheritance:r"), "{windows}");
assert!(windows.contains("/grant *%~s:F"), "the account by the SID whoami gives: {windows}");
assert!(windows.contains("/grant *S-1-5-18:F"), "SYSTEM by SID: {windows}");
}
#[cfg(windows)]
fn restricted_through_cmd(restrict: impl FnOnce(&str) -> String) -> (tempfile::TempDir, String) {
let stand = crate::ssh::tests::Stand::spawn_shell();
let session = stand.session();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("authorized_keys").display().to_string();
std::fs::write(&file, "ssh-ed25519 AAAA test\n").unwrap();
let quoted = Dialect::Windows.quote(&file);
session.exec(&format!("icacls {quoted} /grant *S-1-1-0:(R)")).unwrap();
session.exec(&restrict(&file)).unwrap();
let acl = session.exec(&format!("icacls {quoted}")).unwrap();
assert!(!acl.contains("(I)"), "nothing inherited may stay: {acl}");
assert!(!acl.contains("S-1-1-0"), "nor anything granted before: {acl}");
let grants = acl.lines().filter(|line| line.contains(":(")).count();
assert_eq!(grants, 2, "the one grant and SYSTEM, nothing else: {acl}");
(dir, acl)
}
#[cfg(windows)]
#[test]
fn the_key_file_restriction_holds_in_a_real_cmd() {
let (dir, acl) = restricted_through_cmd(|file| Dialect::Windows.restrict_to_owner("unused", file));
let user = std::env::var("USERNAME").unwrap().to_lowercase();
assert!(acl.to_lowercase().contains(&user), "the account itself is granted: {acl}");
let stand = crate::ssh::tests::Stand::spawn_shell();
let missing = dir.path().join("missing").join("authorized_keys").display().to_string();
assert!(
stand.session().exec(&Dialect::Windows.restrict_to_owner("unused", &missing)).is_err(),
"a failed icacls must fail the command"
);
}
#[cfg(windows)]
#[test]
fn the_shared_key_file_restriction_holds_in_a_real_cmd() {
restricted_through_cmd(|file| Dialect::Windows.restrict_to_administrators(file));
}
#[test]
fn the_shared_key_file_belongs_to_the_administrators() {
let windows = Dialect::Windows.restrict_to_administrators("C:\\ProgramData\\ssh\\administrators_authorized_keys");
let file = "\"C:\\ProgramData\\ssh\\administrators_authorized_keys\"";
assert_eq!(
windows,
format!("(icacls {file} /reset >nul && icacls {file} /inheritance:r /grant *S-1-5-32-544:F /grant *S-1-5-18:F)")
);
assert!(!windows.contains("%USERNAME%"), "{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}");
assert!(posix.starts_with("sh -c "), "the `||` stays inside one command: {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("(call )"));
assert!(Dialect::Windows.clear_dir("C:\\site").contains("(call )"));
assert!(Dialect::Windows.remove_file("C:\\x.tar.gz").contains("(call )"));
}
#[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"));
}
}