use crate::util::{Error, Result};
use std::ffi::OsString;
use std::io::Read;
use std::path::{Path, PathBuf};
pub const PREVIOUS_SUFFIX: &str = ".previous";
pub fn previous_path(path: &Path) -> PathBuf {
suffixed(path, PREVIOUS_SUFFIX)
}
pub fn staging_path(path: &Path) -> PathBuf {
suffixed(path, &format!(".staging-{}", std::process::id()))
}
fn suffixed(path: &Path, suffix: &str) -> PathBuf {
let mut name: OsString = path.as_os_str().to_owned();
name.push(suffix);
PathBuf::from(name)
}
fn remove_any(path: &Path) -> std::io::Result<()> {
match std::fs::symlink_metadata(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
Ok(m) if m.is_dir() => std::fs::remove_dir_all(path),
Ok(_) => std::fs::remove_file(path),
}
}
pub fn swap_in_place(staging: &Path, dest: &Path) -> Result<()> {
let previous = previous_path(dest);
let moved_aside = std::fs::symlink_metadata(dest).is_ok();
let swapped = (|| -> std::io::Result<()> {
if moved_aside {
remove_any(&previous)?;
std::fs::rename(dest, &previous)?;
}
std::fs::rename(staging, dest)
})();
if let Err(e) = swapped {
if moved_aside && std::fs::symlink_metadata(dest).is_err() {
let _ = std::fs::rename(&previous, dest);
}
let _ = remove_any(staging);
return Err(Error(format!(
"cannot install {} in place: {e}",
dest.display()
)));
}
Ok(())
}
pub fn same_bytes(a: &Path, b: &Path) -> bool {
let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
return false;
};
if !ma.is_file() || !mb.is_file() || ma.len() != mb.len() {
return false;
}
let (Ok(mut fa), Ok(mut fb)) = (std::fs::File::open(a), std::fs::File::open(b)) else {
return false;
};
let (mut ba, mut bb) = (vec![0u8; 1 << 20], vec![0u8; 1 << 20]);
loop {
let (Ok(na), Ok(nb)) = (fa.read(&mut ba), fb.read(&mut bb)) else {
return false;
};
if na != nb || ba[..na] != bb[..nb] {
return false;
}
if na == 0 {
return true;
}
}
}
pub fn replace_file_in_place(from: &Path, to: &Path) -> Result<()> {
if same_bytes(from, to) {
return Ok(());
}
let staging = staging_path(to);
let _ = remove_any(&staging);
std::fs::copy(from, &staging).map_err(|e| {
let _ = remove_any(&staging);
Error(format!(
"cannot copy {} to {}: {e}",
from.display(),
staging.display()
))
})?;
swap_in_place(&staging, to)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("rkqa-inplace-{tag}-{}", crate::util::new_id()));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn paths_are_beside_the_fixed_path() {
assert_eq!(
previous_path(Path::new("/a/b/app.exe")),
Path::new("/a/b/app.exe.previous")
);
assert!(staging_path(Path::new("/a/b/app"))
.to_string_lossy()
.starts_with("/a/b/app.staging-"));
}
#[test]
fn replace_keeps_one_previous_and_the_same_path() {
let base = tmp("replace");
let to = base.join("app");
let v1 = base.join("v1");
let v2 = base.join("v2");
let v3 = base.join("v3");
std::fs::write(&v1, b"one").unwrap();
std::fs::write(&v2, b"two").unwrap();
std::fs::write(&v3, b"three").unwrap();
replace_file_in_place(&v1, &to).unwrap();
assert!(
!previous_path(&to).exists(),
"first install has no previous"
);
replace_file_in_place(&v2, &to).unwrap();
replace_file_in_place(&v2, &to).unwrap(); assert_eq!(std::fs::read(&to).unwrap(), b"two");
assert_eq!(std::fs::read(previous_path(&to)).unwrap(), b"one");
replace_file_in_place(&v3, &to).unwrap();
assert_eq!(std::fs::read(&to).unwrap(), b"three");
assert_eq!(std::fs::read(previous_path(&to)).unwrap(), b"two");
let mut names: Vec<_> = std::fs::read_dir(&base)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(names, ["app", "app.previous", "v1", "v2", "v3"]);
}
#[test]
fn swap_in_place_swaps_directories_and_restores_on_failure() {
let base = tmp("dirs");
let dest = base.join("App.app");
std::fs::create_dir_all(dest.join("Contents")).unwrap();
std::fs::write(dest.join("Contents/marker"), b"old").unwrap();
let staging = base.join("App.app.staging-1");
std::fs::create_dir_all(staging.join("Contents")).unwrap();
std::fs::write(staging.join("Contents/marker"), b"new").unwrap();
swap_in_place(&staging, &dest).unwrap();
assert_eq!(std::fs::read(dest.join("Contents/marker")).unwrap(), b"new");
assert_eq!(
std::fs::read(previous_path(&dest).join("Contents/marker")).unwrap(),
b"old"
);
let missing = base.join("App.app.staging-2");
assert!(swap_in_place(&missing, &dest).is_err());
assert_eq!(std::fs::read(dest.join("Contents/marker")).unwrap(), b"new");
}
}