use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Result, bail};
use crate::model::{App, Credential, Server};
use crate::shell::{self, Dialect};
use crate::ssh::Session;
use crate::store;
pub struct Resolved {
pub app: App,
pub server: Server,
pub credential: Credential,
pub path: crate::model::Path,
pub target: Option<String>,
pub address: String,
pub health: Option<crate::model::HealthCheck>,
pub unchecked: Option<String>,
pub keep_backups: u32,
}
impl Resolved {
pub fn connection_label(&self) -> String {
format!("{}@{}:{}", self.credential.user, self.server.ssh_host(), self.server.port)
}
}
pub struct Host {
pub server: Server,
pub credential: Credential,
pub dir: Option<String>,
}
impl Host {
pub fn label(&self) -> String {
format!("{}@{}:{}", self.credential.user, self.server.ssh_host(), self.server.port)
}
}
pub fn resolve_host(name: Option<String>, credential: Option<String>) -> Result<Host> {
if let Some(name) = &name {
if store::load_targets()?.iter().any(|t| &t.name == name) {
let resolved = resolve(
Some(name.clone()),
Overrides {
credential,
..Default::default()
},
)?;
return Ok(Host {
server: resolved.server,
credential: resolved.credential,
dir: Some(resolved.path.dir),
});
}
if let Some(server) = store::load_servers()?.into_iter().find(|s| &s.name == name) {
let credential_name = credential.or_else(|| server.credential.clone()).ok_or_else(|| {
anyhow::anyhow!("server '{name}' has no credential - pass --credential NAME, or set one with `turnout server edit {name} --credential NAME`")
})?;
let credential = store::load_credentials()?
.into_iter()
.find(|c| c.name == credential_name)
.ok_or_else(|| anyhow::anyhow!("no credential named '{credential_name}' - see `turnout credential list`"))?;
return Ok(Host { server, credential, dir: None });
}
}
let resolved = resolve(
name,
Overrides {
credential,
..Default::default()
},
)?;
Ok(Host {
server: resolved.server,
credential: resolved.credential,
dir: Some(resolved.path.dir),
})
}
#[derive(Default)]
pub struct Overrides {
pub server: Option<String>,
pub credential: Option<String>,
pub path: Option<String>,
}
impl Overrides {
fn any(&self) -> bool {
self.server.is_some() || self.credential.is_some() || self.path.is_some()
}
}
pub fn resolve(name: Option<String>, overrides: Overrides) -> Result<Resolved> {
let targets = store::load_targets()?;
if let Some(name) = &name
&& let Some(target) = targets.iter().find(|t| &t.name == name)
{
return from_target(target.clone(), overrides);
}
let apps = store::load_apps()?;
let app = crate::commands::exec::resolve(&apps, name)?.clone();
let server_name = match &overrides.server {
Some(name) => name.clone(),
None => store::load_state()?.bindings.get(&app.name).cloned().ok_or_else(|| {
anyhow::anyhow!(
"nowhere to deploy '{0}': name a target (see `turnout target list`), pass --server, \
or bind one with `turnout use {0} SERVER`",
app.name
)
})?,
};
let mut matching = targets.iter().filter(|t| t.app == app.name && t.server == server_name);
let found = match (matching.next(), matching.next()) {
(Some(only), None) => Some(only.clone()),
(Some(first), Some(second)) => bail!(
"'{}' has more than one target on '{server_name}' ('{}', '{}') - name the one to deploy",
app.name,
first.name,
second.name
),
(None, _) => None,
};
match found {
Some(target) => from_target(target, overrides),
None => assemble(app, server_name, overrides),
}
}
fn from_target(target: crate::model::Target, overrides: Overrides) -> Result<Resolved> {
let overridden = overrides.any();
let app = store::load_apps()?
.into_iter()
.find(|a| a.name == target.app)
.ok_or_else(|| dangling(&target.name, "app", &target.app, "turnout app list"))?;
let server_name = overrides.server.unwrap_or_else(|| target.server.clone());
let server = store::load_servers()?
.into_iter()
.find(|s| s.name == server_name)
.ok_or_else(|| dangling(&target.name, "server", &server_name, "turnout server list"))?;
app.check_allowed(&server.name)?;
let credential_name = overrides.credential.unwrap_or_else(|| target.credential.clone());
let credential = store::load_credentials()?
.into_iter()
.find(|c| c.name == credential_name)
.ok_or_else(|| dangling(&target.name, "credential", &credential_name, "turnout credential list"))?;
let path_name = overrides.path.unwrap_or_else(|| target.path.clone());
let path = store::load_paths()?
.into_iter()
.find(|p| p.name == path_name)
.ok_or_else(|| dangling(&target.name, "path", &path_name, "turnout path list"))?;
check_deploy_dir(&path)?;
let same_stand = server.name == target.server && path.name == target.path;
let unchecked = match &target.health {
Some(health) if !same_stand => Some(format!(
"this run lands somewhere else than target '{}' does, so its health check ({}) is not asked",
target.name,
health.describe(&target.url.clone().unwrap_or_else(|| server.url.clone()))
)),
_ => None,
};
let (address, health, keep_backups) = if same_stand {
(
target.address(&server),
target.health.clone(),
target.keep_backups.unwrap_or(DEFAULT_KEEP_BACKUPS),
)
} else {
(server.url.clone(), None, DEFAULT_KEEP_BACKUPS)
};
Ok(Resolved {
app,
server,
credential,
path,
target: (!overridden).then_some(target.name),
address,
health,
unchecked,
keep_backups,
})
}
fn dangling(target: &str, kind: &str, name: &str, list: &str) -> anyhow::Error {
anyhow::anyhow!("target '{target}' names {kind} '{name}', which is not in the catalog - see `{list}`")
}
fn assemble(app: App, server_name: String, overrides: Overrides) -> Result<Resolved> {
let server = store::load_servers()?
.into_iter()
.find(|s| s.name == server_name)
.ok_or_else(|| anyhow::anyhow!("no server named '{server_name}' - see `turnout server list`"))?;
app.check_allowed(&server.name)?;
let credential_name = overrides.credential.or_else(|| server.credential.clone()).ok_or_else(|| {
anyhow::anyhow!(
"server '{0}' has no credential - set one with `turnout server edit {0} --credential NAME`, \
or pass --credential for this command only",
server.name
)
})?;
let credential = store::load_credentials()?
.into_iter()
.find(|c| c.name == credential_name)
.ok_or_else(|| anyhow::anyhow!("no credential named '{credential_name}' - see `turnout credential list`"))?;
let paths = store::load_paths()?;
let path_name = match overrides.path {
Some(name) => name,
None => {
crate::pick::ensure_interactive(&format!(
"no target for '{}' on '{}': create one with `turnout target add`, or pass --path",
app.name, server.name
))?;
eprintln!("No target for '{}' on '{}' yet.", app.name, server.name);
crate::pick::path(&paths, "Deploy into which path")?
}
};
let path = paths
.into_iter()
.find(|p| p.name == path_name)
.ok_or_else(|| anyhow::anyhow!("no path named '{path_name}' - see `turnout path list`"))?;
check_deploy_dir(&path)?;
Ok(Resolved {
address: server.url.clone(),
app,
server,
credential,
path,
target: None,
health: None,
unchecked: None,
keep_backups: DEFAULT_KEEP_BACKUPS,
})
}
fn check_deploy_dir(path: &crate::model::Path) -> Result<()> {
crate::model::refuse_filesystem_root(&path.dir)
.map_err(|err| anyhow::anyhow!("path '{0}': {err:#}\nChange it with `turnout path edit {0} --dir DIR`.", path.name))
}
pub fn connect(server: &Server, credential: &Credential) -> Result<Session> {
Session::connect(server, credential)
}
pub fn exec(session: &Session, command: &str) -> Result<String> {
session.exec(command)
}
pub fn dialect(session: &Session, server: &Server) -> Dialect {
if let Some(known) = server.shell {
return known;
}
let Ok(reply) = exec(session, shell::PROBE) else {
return Dialect::default();
};
let learned = shell::read_probe(&reply);
remember_dialect(&server.name, learned);
learned
}
fn remember_dialect(server_name: &str, dialect: Dialect) {
let Ok(mut servers) = store::load_servers() else {
return;
};
let Some(entry) = servers.iter_mut().find(|s| s.name == server_name) else {
return;
};
entry.shell = Some(dialect);
let _ = store::save_servers(&servers);
}
pub fn check_quotable(dialect: Dialect, values: &[&str]) -> Result<()> {
for value in values {
if let Err(reason) = dialect.reject_unquotable(value) {
bail!("cannot run this on a Windows server: {reason}");
}
}
Ok(())
}
pub fn backups_dir(deploy_path: &str) -> String {
format!("{}.backups", deploy_path.trim_end_matches(['/', '\\']))
}
pub fn join_remote(dir: &str, name: &str) -> String {
let separator = if dir.contains('\\') && !dir.contains('/') { '\\' } else { '/' };
format!("{}{separator}{name}", dir.trim_end_matches(['/', '\\']))
}
pub fn backup_name() -> String {
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let (year, month, day) = crate::utils::civil_from_days((secs / 86_400) as i64);
let rest = secs % 86_400;
format!("{year:04}{month:02}{day:02}-{:02}{:02}{:02}.tar.gz", rest / 3600, (rest % 3600) / 60, rest % 60)
}
pub fn free_backup_name(stamp: &str, taken: &[String]) -> String {
let stem = stamp.trim_end_matches(".tar.gz");
std::iter::once(format!("{stem}.tar.gz"))
.chain((2..).map(|n| format!("{stem}_{n:02}.tar.gz")))
.find(|name| !taken.contains(name))
.expect("an unbounded sequence has a free name")
}
pub fn backup_command(dialect: Dialect, deploy_path: &str, archive_name: &str) -> String {
let dir = deploy_path.trim_end_matches(['/', '\\']);
let backups = backups_dir(deploy_path);
let archive = join_remote(&backups, archive_name);
let partial = join_remote(&backups, &format!("{archive_name}.{}.part", std::process::id()));
let (b, a, p, d) = (dialect.quote(&backups), dialect.quote(&archive), dialect.quote(&partial), dialect.quote(dir));
match dialect {
Dialect::Posix => {
let taken = "echo 'a backup by this name is already there' >&2; exit 1";
let script = format!(
"mkdir -p {b} || exit 1; if [ -e {a} ]; then {taken}; fi; \
tar czf {p} -C {d} .; s=$?; \
if [ $s -eq 1 ] && tar --version 2>/dev/null | grep -q 'GNU tar'; then s=0; fi; \
if [ $s -ne 0 ]; then rm -f {p}; exit $s; fi; \
if [ -e {a} ]; then rm -f {p}; {taken}; fi; \
mv {p} {a}"
);
format!("sh -c {}", dialect.quote(&script))
}
Dialect::Windows => [
dialect.mkdir_p(&backups),
dialect.nothing_at(&archive),
format!("({} || (del /f /q {p} >nul 2>&1 & exit /b 1))", dialect.tar_czf(&partial, dir)),
format!("(if exist {a} (del /f /q {p} >nul 2>&1 & exit /b 1))"),
format!("move {p} {a} >nul"),
]
.into_iter()
.reduce(|chain, next| dialect.and_then(&chain, &next))
.expect("the chain has links"),
}
}
pub fn run_backup(session: &Session, dialect: Dialect, deploy_path: &str) -> Result<String> {
check_quotable(dialect, &[deploy_path, &backups_dir(deploy_path)])?;
let name = free_backup_name(&backup_name(), &list_backups(session, dialect, deploy_path)?);
exec(session, &backup_command(dialect, deploy_path, &name)).map(|_| name).map_err(|err| {
if mentions_permission_denial(&err) {
anyhow::anyhow!(permission_hint(dialect, deploy_path))
} else {
err
}
})
}
pub fn restore_backup(session: &Session, dialect: Dialect, deploy_dir: &str, name: &str) -> Result<Vec<String>> {
let backups = backups_dir(deploy_dir);
check_quotable(dialect, &[deploy_dir, &backups, name])?;
let archive = join_remote(&backups, name);
let dir = deploy_dir.trim_end_matches(['/', '\\']);
let left = exec(
session,
&dialect.and_then(
&dialect.file_exists(&archive),
&dialect.and_then(&dialect.clear_dir_listing_the_rest(dir), &dialect.untar(&archive, dir)),
),
)?;
Ok(left.lines().map(str::trim).filter(|line| !line.is_empty()).map(str::to_string).collect())
}
pub fn not_removed_note(dir: &str, left: &[String]) -> String {
let plural = if left.len() == 1 { "y" } else { "ies" };
format!(
"{} entr{plural} in {dir} could not be removed before the backup was unpacked, and stay beside its files: {}",
left.len(),
left.join(", ")
)
}
pub fn remove_backup(session: &Session, dialect: Dialect, deploy_dir: &str, name: &str) -> Result<()> {
exec(session, &dialect.remove_file(&join_remote(&backups_dir(deploy_dir), name))).map(|_| ())
}
pub const DEFAULT_KEEP_BACKUPS: u32 = 5;
pub fn is_backup_name(entry: &str) -> bool {
let Some(stem) = entry.strip_suffix(".tar.gz") else {
return false;
};
let (stamp, sequence) = match stem.split_once('_') {
Some((stamp, sequence)) => (stamp, Some(sequence)),
None => (stem, None),
};
let stamp_ok = stamp.len() == 15 && stamp.chars().enumerate().all(|(i, c)| if i == 8 { c == '-' } else { c.is_ascii_digit() });
let sequence_ok = sequence.is_none_or(|n| n.len() == 2 && n.chars().all(|c| c.is_ascii_digit()));
stamp_ok && sequence_ok
}
pub fn backups_in(listing: &str) -> Vec<String> {
let mut names: Vec<String> = listing.lines().map(str::trim).filter(|line| is_backup_name(line)).map(str::to_string).collect();
names.sort_unstable_by(|a, b| b.cmp(a));
names
}
pub fn list_backups(session: &Session, dialect: Dialect, deploy_dir: &str) -> Result<Vec<String>> {
let backups = backups_dir(deploy_dir);
check_quotable(dialect, &[&backups])?;
Ok(backups_in(&exec(session, &dialect.list_dir(&backups))?))
}
pub struct Pruned {
pub removed: Vec<String>,
pub kept: usize,
}
pub fn prune_backups(session: &Session, dialect: Dialect, deploy_dir: &str, keep: u32) -> Result<Pruned> {
let before = list_backups(session, dialect, deploy_dir)?;
let doomed = before.get(keep as usize..).unwrap_or_default();
if doomed.is_empty() {
return Ok(Pruned {
removed: Vec::new(),
kept: before.len(),
});
}
let backups = backups_dir(deploy_dir);
let paths: Vec<String> = doomed.iter().map(|name| join_remote(&backups, name)).collect();
remove_paths(session, dialect, &paths)?;
let after = list_backups(session, dialect, deploy_dir)?;
let removed: Vec<String> = before.iter().filter(|name| !after.contains(name)).cloned().collect();
if let Some(stuck) = doomed.iter().find(|name| after.contains(name)) {
bail!(
"could not remove the backup {stuck} from {backups} - removed {} of {}",
removed.len(),
doomed.len()
);
}
Ok(Pruned { removed, kept: after.len() })
}
const REMOVE_LINE: usize = 6_000;
pub fn remove_paths(session: &Session, dialect: Dialect, paths: &[String]) -> Result<()> {
let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
check_quotable(dialect, &refs)?;
let mut chunk: Vec<&str> = Vec::new();
let mut length = 0;
for path in refs {
if !chunk.is_empty() && length + path.len() + 3 > REMOVE_LINE {
exec(session, &dialect.remove_files(&chunk))?;
chunk.clear();
length = 0;
}
length += path.len() + 3;
chunk.push(path);
}
if !chunk.is_empty() {
exec(session, &dialect.remove_files(&chunk))?;
}
Ok(())
}
pub fn dir_exists(session: &Session, dialect: Dialect, dir: &str) -> bool {
exec(session, &dialect.dir_exists(dir)).is_ok()
}
pub fn restart(
session: &mut Session,
dialect: Dialect,
path: &crate::model::Path,
restart: &str,
connect: &dyn Fn() -> Result<Session>,
progress: impl FnMut(&str),
) -> Result<String> {
check_quotable(dialect, &[&path.dir])?;
crate::detached::run(session, dialect, &path.dir, restart, connect, progress)
}
fn permission_hint(dialect: Dialect, deploy_path: &str) -> String {
let backups = backups_dir(deploy_path);
let parent = parent_dir(&backups);
let fix = match dialect {
Dialect::Posix => format!("sudo mkdir -p {backups} && sudo chown $USER {backups}"),
Dialect::Windows => format!("mkdir \"{backups}\" (in an elevated prompt, if {parent} needs it)"),
};
format!(
"cannot write backups to {backups}: permission denied.\n\
Backups are kept next to the deploy directory, so this needs write access to {parent} - \
being able to upload into the deploy directory itself is not enough.\n\
Create it once on the server with the right owner, e.g.\n {fix}"
)
}
fn mentions_permission_denial(err: &anyhow::Error) -> bool {
let text = format!("{err:#}").to_lowercase();
text.contains("permission denied") || text.contains("cannot create directory")
}
fn parent_dir(path: &str) -> &str {
match path.trim_end_matches(['/', '\\']).rsplit_once(['/', '\\']) {
Some(("", _)) | None => "/",
Some((parent, _)) => parent,
}
}
#[cfg(test)]
mod tests {
use super::{backup_command, backup_name, backups_dir, free_backup_name, join_remote, mentions_permission_denial, parent_dir};
use crate::shell::Dialect;
#[test]
fn backups_sit_beside_the_deploy_directory() {
assert_eq!(backups_dir("/var/www/myapp"), "/var/www/myapp.backups");
assert_eq!(backups_dir("/var/www/myapp/"), "/var/www/myapp.backups");
}
#[test]
fn backups_follow_the_separator_of_the_path() {
assert_eq!(backups_dir("C:\\inetpub\\site"), "C:\\inetpub\\site.backups");
assert_eq!(backups_dir("C:\\inetpub\\site\\"), "C:\\inetpub\\site.backups");
assert_eq!(join_remote("C:\\site.backups", "a.tar.gz"), "C:\\site.backups\\a.tar.gz");
assert_eq!(join_remote("/var/www/site.backups", "a.tar.gz"), "/var/www/site.backups/a.tar.gz");
}
#[test]
fn parent_of_a_backups_dir() {
assert_eq!(parent_dir("/var/www/myapp.backups"), "/var/www");
assert_eq!(parent_dir("/srv"), "/");
assert_eq!(parent_dir("/"), "/");
assert_eq!(parent_dir("C:\\inetpub\\site.backups"), "C:\\inetpub");
}
#[test]
fn recognizes_permission_failures_only() {
let denied = anyhow::anyhow!("remote command 'mkdir -p /var/www/x.backups' exited with 1: mkdir: cannot create directory: Permission denied");
assert!(mentions_permission_denial(&denied));
let other = anyhow::anyhow!("remote command 'tar czf ...' exited with 2: tar: not found");
assert!(!mentions_permission_denial(&other), "unrelated failures must keep their own message");
}
#[test]
fn permission_hint_names_the_parent_and_the_fix() {
let hint = super::permission_hint(Dialect::Posix, "/var/www/myapp");
assert!(hint.contains("/var/www/myapp.backups"), "{hint}");
assert!(hint.contains("write access to /var/www"), "{hint}");
assert!(hint.contains("sudo mkdir -p /var/www/myapp.backups"), "{hint}");
let windows = super::permission_hint(Dialect::Windows, "C:\\inetpub\\site");
assert!(windows.contains("mkdir \"C:\\inetpub\\site.backups\""), "{windows}");
assert!(!windows.contains("sudo"), "sudo means nothing on Windows: {windows}");
}
#[test]
fn the_backup_command_carries_no_posix_only_syntax() {
let pid = std::process::id();
let posix = backup_command(Dialect::Posix, "/var/www/site", "20260812-181500.tar.gz");
assert!(posix.starts_with("sh -c '"), "{posix}");
assert!(
posix.contains(&format!(
"tar czf '\\''/var/www/site.backups/20260812-181500.tar.gz.{pid}.part'\\'' -C '\\''/var/www/site'\\'' ."
)),
"tar writes aside: {posix}"
);
assert!(
posix.ends_with(&format!(
"mv '\\''/var/www/site.backups/20260812-181500.tar.gz.{pid}.part'\\'' '\\''/var/www/site.backups/20260812-181500.tar.gz'\\'''"
)),
"and the archive is renamed into place last: {posix}"
);
let windows = backup_command(Dialect::Windows, "C:\\inetpub\\site", "20260812-181500.tar.gz");
assert!(!windows.contains("$("), "command substitution does not exist in cmd.exe: {windows}");
assert!(!windows.contains("mkdir -p"), "cmd.exe mkdir has no -p: {windows}");
assert!(windows.contains("if not exist"), "{windows}");
assert!(
windows.contains(&format!(
"tar czf \"C:\\inetpub\\site.backups\\20260812-181500.tar.gz.{pid}.part\" -C \"C:\\inetpub\\site\" ."
)),
"{windows}"
);
assert!(
windows.ends_with(&format!(
"move \"C:\\inetpub\\site.backups\\20260812-181500.tar.gz.{pid}.part\" \"C:\\inetpub\\site.backups\\20260812-181500.tar.gz\" >nul"
)),
"{windows}"
);
assert!(!super::is_backup_name(&format!("20260812-181500.tar.gz.{pid}.part")));
}
#[test]
fn a_failed_backup_leaves_no_archive() {
#[cfg(unix)]
if unsafe { libc::geteuid() } == 0 {
return;
}
let stand = crate::ssh::tests::Stand::spawn_shell();
let session = stand.session();
let dialect = crate::shell::read_probe(&session.exec(crate::shell::PROBE).unwrap());
let root = tempfile::tempdir().unwrap();
let site = root.path().join("site");
std::fs::create_dir_all(&site).unwrap();
std::fs::write(site.join("index.html"), "v1").unwrap();
let unreadable = site.join("unreadable.txt");
std::fs::write(&unreadable, "secret").unwrap();
#[cfg(windows)]
let held = {
use std::os::windows::fs::OpenOptionsExt;
std::fs::OpenOptions::new().read(true).share_mode(0).open(&unreadable).unwrap()
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap();
}
let dir = site.display().to_string();
let outcome = super::run_backup(&session, dialect, &dir);
#[cfg(windows)]
drop(held);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o644)).unwrap();
}
assert!(outcome.is_err(), "a tar that failed is a failed backup: {outcome:?}");
let backups = std::path::PathBuf::from(super::backups_dir(&dir));
let left: Vec<String> = std::fs::read_dir(&backups)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert!(left.is_empty(), "nothing of the failed backup stays: {left:?}");
}
#[cfg(unix)]
#[test]
fn gnu_tar_s_changed_file_is_a_backup_and_nothing_else_s_exit_1_is() {
let stand = crate::ssh::tests::Stand::spawn_shell();
let session = stand.session();
let root = tempfile::tempdir().unwrap();
let site = root.path().join("site");
std::fs::create_dir_all(&site).unwrap();
let dir = site.display().to_string();
for (version, kept) in [("tar (GNU tar) 1.34", true), ("bsdtar 3.7.2 - libarchive 3.7.2", false)] {
let fake = root.path().join(if kept { "gnu" } else { "bsd" });
let script = format!("#!/bin/sh\nif [ \"$1\" = --version ]; then echo '{version}'; exit 0; fi\necho archive > \"$2\"\nexit 1\n");
let fake_q = Dialect::Posix.quote(&fake.display().to_string());
session
.exec(&format!(
"mkdir -p {fake_q} && printf '%s' {} > {fake_q}/tar && chmod +x {fake_q}/tar",
Dialect::Posix.quote(&script)
))
.unwrap();
let name = format!("20261030-12000{}.tar.gz", u8::from(kept));
let command = backup_command(Dialect::Posix, &dir, &name);
let outcome = session.exec(&format!("PATH={fake_q}:\"$PATH\" {command}"));
let archive = std::path::PathBuf::from(super::backups_dir(&dir)).join(&name);
assert_eq!(outcome.is_ok(), kept, "{version}: {outcome:?}");
assert_eq!(archive.is_file(), kept, "{version}");
}
}
#[test]
fn the_chains_hold_in_a_real_shell() {
let stand = crate::ssh::tests::Stand::spawn_shell();
let session = stand.session();
let dialect = crate::shell::read_probe(&session.exec(crate::shell::PROBE).unwrap());
let root = tempfile::tempdir().unwrap();
let site = root.path().join("site");
std::fs::create_dir_all(site.join("assets")).unwrap();
std::fs::write(site.join("index.html"), "v1").unwrap();
std::fs::write(site.join("assets").join("app.js"), "one").unwrap();
let dir = site.display().to_string();
assert!(super::dir_exists(&session, dialect, &dir));
assert!(!super::dir_exists(&session, dialect, &format!("{dir}-not-there")));
std::fs::write(root.path().join("a-file"), "x").unwrap();
assert!(
!super::dir_exists(&session, dialect, &root.path().join("a-file").display().to_string()),
"a file is not a directory"
);
let first = super::run_backup(&session, dialect, &dir).unwrap();
std::fs::write(site.join("index.html"), "v2").unwrap();
std::fs::write(site.join("extra.txt"), "only in v2").unwrap();
let second = super::run_backup(&session, dialect, &dir).unwrap();
assert_ne!(first, second);
let backups = std::path::PathBuf::from(super::backups_dir(&dir));
assert!(backups.join(&first).is_file() && backups.join(&second).is_file(), "both backups exist");
let again = super::backup_command(dialect, &dir, &first);
assert!(super::exec(&session, &again).is_err(), "an existing archive must stop the backup");
super::restore_backup(&session, dialect, &dir, &first).unwrap();
assert_eq!(std::fs::read_to_string(site.join("index.html")).unwrap(), "v1", "the restore put v1 back");
assert!(!site.join("extra.txt").exists(), "and emptied what v1 did not have");
assert_eq!(std::fs::read_to_string(site.join("assets").join("app.js")).unwrap(), "one");
assert!(
super::restore_backup(&session, dialect, &dir, "20000101-000000.tar.gz").is_err(),
"no such backup"
);
assert!(site.join("index.html").exists(), "a missing backup empties nothing");
let broken = site.join(".turnout-upload.tar.gz");
std::fs::write(&broken, "not an archive").unwrap();
let archive = broken.display().to_string();
let unpack = dialect.and_then(&dialect.untar(&archive, &dir), &dialect.remove_file(&archive));
assert!(super::exec(&session, &unpack).is_err(), "a failed unpack must fail the chain");
super::remove_backup(&session, dialect, &dir, &second).unwrap();
assert!(!backups.join(&second).exists());
}
#[test]
fn a_restore_unpacks_past_what_it_could_not_remove() {
#[cfg(unix)]
if unsafe { libc::geteuid() } == 0 {
return;
}
let stand = crate::ssh::tests::Stand::spawn_shell();
let session = stand.session();
let dialect = crate::shell::read_probe(&session.exec(crate::shell::PROBE).unwrap());
let root = tempfile::tempdir().unwrap();
let site = root.path().join("site");
std::fs::create_dir_all(&site).unwrap();
std::fs::write(site.join("index.html"), "v1").unwrap();
let dir = site.display().to_string();
let backup = super::run_backup(&session, dialect, &dir).unwrap();
std::fs::write(site.join("index.html"), "v2").unwrap();
let stuck = site.join("stuck");
std::fs::create_dir_all(&stuck).unwrap();
std::fs::write(stuck.join("held.txt"), "x").unwrap();
#[cfg(windows)]
let held = {
use std::os::windows::fs::OpenOptionsExt;
std::fs::OpenOptions::new().read(true).share_mode(0).open(stuck.join("held.txt")).unwrap()
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)).unwrap();
}
let left = super::restore_backup(&session, dialect, &dir, &backup);
#[cfg(windows)]
drop(held);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let left = left.expect("the restore goes on past what it could not remove");
assert_eq!(std::fs::read_to_string(site.join("index.html")).unwrap(), "v1", "the backup was unpacked");
assert_eq!(left, ["stuck"], "and what stayed is named");
let note = super::not_removed_note(&dir, &left);
assert!(note.contains("1 entry") && note.contains("stuck"), "{note}");
}
#[test]
fn a_second_backup_in_one_second_takes_the_next_name() {
let taken = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
assert_eq!(free_backup_name("20261030-120000.tar.gz", &[]), "20261030-120000.tar.gz");
assert_eq!(
free_backup_name("20261030-120000.tar.gz", &taken(&["20261030-120000.tar.gz"])),
"20261030-120000_02.tar.gz"
);
assert_eq!(
free_backup_name("20261030-120000.tar.gz", &taken(&["20261030-120000.tar.gz", "20261030-120000_02.tar.gz"])),
"20261030-120000_03.tar.gz"
);
let listing = "20261030-120001.tar.gz\n20261030-120000_02.tar.gz\n20261030-120000.tar.gz\n20261030-115959.tar.gz\n";
assert_eq!(
super::backups_in(listing),
[
"20261030-120001.tar.gz",
"20261030-120000_02.tar.gz",
"20261030-120000.tar.gz",
"20261030-115959.tar.gz"
]
);
assert!(super::is_backup_name("20261030-120000_02.tar.gz"));
assert!(!super::is_backup_name("20261030-120000_2.tar.gz"), "two digits");
assert!(!super::is_backup_name("20261030-120000_ab.tar.gz"));
assert!(!super::is_backup_name("20261030-120000_.tar.gz"));
}
#[test]
fn backup_names_sort_chronologically() {
let name = backup_name();
assert!(name.ends_with(".tar.gz"), "{name}");
let stem = name.trim_end_matches(".tar.gz");
assert_eq!(stem.len(), 15, "YYYYMMDD-HHMMSS: {name}");
assert_eq!(stem.as_bytes()[8], b'-', "{name}");
assert!(stem.chars().filter(|c| *c != '-').all(|c| c.is_ascii_digit()), "{name}");
assert!("20260812-000000" < "20260812-181500", "the format has to order lexicographically");
}
}