use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
static ACTIVATION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const ACTIVATION_PREPARED: u8 = 0;
const ACTIVATION_SWITCHED: u8 = 1;
const ACTIVATION_RESTORED: u8 = 2;
const ACTIVATION_RESTORE_FAILED: u8 = 3;
use crate::error::ForgeError;
use crate::fsutil::{create_dir_all, replace_file};
pub(crate) struct ActivationProvider {
root: PathBuf,
}
pub(crate) struct PreparedActivation {
temporary: PathBuf,
destination: PathBuf,
previous: Option<PathBuf>,
state: AtomicU8,
}
impl Drop for PreparedActivation {
fn drop(&mut self) {
let _ = fs::remove_file(&self.temporary);
if self.state.load(Ordering::Relaxed) != ACTIVATION_RESTORE_FAILED
&& let Some(previous) = &self.previous
{
let _ = fs::remove_file(previous);
}
}
}
impl ActivationProvider {
pub(crate) fn new(root: PathBuf) -> Result<Self, ForgeError> {
create_dir_all(&root)?;
Ok(Self { root })
}
pub(crate) fn prepare(
&self,
name: &str,
executable: &Path,
) -> Result<PreparedActivation, ForgeError> {
validate_name(name)?;
if !executable.is_file() {
return Err(ForgeError::Config(format!(
"activation executable does not exist: {}",
executable.display()
)));
}
let destination = self.root.join(platform_binary_name(name));
let sequence = ACTIVATION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let suffix = format!("{}-{sequence}", std::process::id());
let temporary = self.root.join(format!(".{name}.new-{suffix}"));
let previous = destination
.exists()
.then(|| self.root.join(format!(".{name}.previous-{suffix}")));
create_activation(&temporary, executable)?;
Ok(PreparedActivation {
temporary,
destination,
previous,
state: AtomicU8::new(ACTIVATION_PREPARED),
})
}
}
impl PreparedActivation {
pub(crate) fn destination(&self) -> &Path {
&self.destination
}
pub(crate) fn switch(&self) -> Result<(), ForgeError> {
if let Some(previous) = &self.previous {
clone_activation(&self.destination, previous).map_err(|source| ForgeError::Io {
path: self.destination.clone(),
source,
})?;
}
if let Err(source) = replace_file(&self.temporary, &self.destination) {
return Err(ForgeError::Io {
path: self.destination.clone(),
source,
});
}
self.state.store(ACTIVATION_SWITCHED, Ordering::Relaxed);
Ok(())
}
pub(crate) fn restore(&self) -> Result<(), ForgeError> {
let result = if let Some(previous) = &self.previous {
if !previous.exists() {
Err(ForgeError::Config(format!(
"activation rollback is missing the previous version: {}",
previous.display()
)))
} else {
replace_file(previous, &self.destination).map_err(|source| ForgeError::Io {
path: self.destination.clone(),
source,
})
}
} else if self.destination.exists() {
fs::remove_file(&self.destination).map_err(|source| ForgeError::Io {
path: self.destination.clone(),
source,
})
} else {
Ok(())
};
if result.is_ok() {
self.state.store(ACTIVATION_RESTORED, Ordering::Relaxed);
} else {
self.state
.store(ACTIVATION_RESTORE_FAILED, Ordering::Relaxed);
}
result
}
}
#[cfg(unix)]
fn create_activation(path: &Path, executable: &Path) -> Result<(), ForgeError> {
std::os::unix::fs::symlink(executable, path).map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(unix)]
fn clone_activation(source: &Path, target: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(fs::read_link(source)?, target)
}
#[cfg(windows)]
fn clone_activation(source: &Path, target: &Path) -> std::io::Result<()> {
copy_windows_activation(source, target)
}
#[cfg(windows)]
fn create_activation(path: &Path, executable: &Path) -> Result<(), ForgeError> {
copy_windows_activation(executable, path).map_err(|source| ForgeError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(windows)]
#[allow(clippy::permissions_set_readonly_false)]
fn copy_windows_activation(source: &Path, target: &Path) -> std::io::Result<()> {
fs::copy(source, target)?;
let mut permissions = fs::metadata(target)?.permissions();
permissions.set_readonly(false);
fs::set_permissions(target, permissions)
}
fn validate_name(name: &str) -> Result<(), ForgeError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(ForgeError::Config(format!("invalid binary name: {name}")));
}
Ok(())
}
fn platform_binary_name(name: &str) -> String {
if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_string()
}
}
#[cfg(all(test, unix))]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::activation::ActivationProvider;
fn temporary(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("bot-forge-activation-{name}-{nonce}"))
}
#[test]
fn switch_and_restore_are_atomic_at_destination() {
let root = temporary("root");
let artifacts = temporary("artifacts");
fs::create_dir_all(&artifacts).unwrap();
let first = artifacts.join("first");
let second = artifacts.join("second");
fs::write(&first, "first").unwrap();
fs::write(&second, "second").unwrap();
let provider = ActivationProvider::new(root.clone()).unwrap();
provider.prepare("demo", &first).unwrap().switch().unwrap();
let replacement = provider.prepare("demo", &second).unwrap();
replacement.switch().unwrap();
assert_eq!(fs::read_link(root.join("demo")).unwrap(), second);
replacement.restore().unwrap();
assert_eq!(fs::read_link(root.join("demo")).unwrap(), first);
fs::remove_dir_all(root).unwrap();
fs::remove_dir_all(artifacts).unwrap();
}
#[test]
fn failed_restore_preserves_the_previous_activation_for_recovery() {
let root = temporary("restore-failure");
let artifacts = temporary("restore-failure-artifacts");
fs::create_dir_all(&artifacts).unwrap();
let first = artifacts.join("first");
let second = artifacts.join("second");
fs::write(&first, "first").unwrap();
fs::write(&second, "second").unwrap();
let provider = ActivationProvider::new(root.clone()).unwrap();
provider.prepare("demo", &first).unwrap().switch().unwrap();
let replacement = provider.prepare("demo", &second).unwrap();
replacement.switch().unwrap();
let previous = replacement.previous.clone().unwrap();
fs::remove_file(replacement.destination()).unwrap();
fs::create_dir_all(replacement.destination().join("blocker")).unwrap();
assert!(replacement.restore().is_err());
drop(replacement);
assert!(previous.exists());
fs::remove_dir_all(root).unwrap();
fs::remove_dir_all(artifacts).unwrap();
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use std::fs;
use crate::activation::ActivationProvider;
#[test]
fn windows_executable_switch_and_restore_are_atomic() {
let root = std::env::temp_dir().join(format!("bot-forge-exe-%{}", std::process::id()));
let artifacts = root.join("artifacts");
fs::create_dir_all(&artifacts).unwrap();
let first = artifacts.join("first.exe");
let second = artifacts.join("second.exe");
fs::write(&first, "first").unwrap();
fs::write(&second, "second").unwrap();
let provider = ActivationProvider::new(root.join("bin")).unwrap();
provider.prepare("demo", &first).unwrap().switch().unwrap();
let replacement = provider.prepare("demo", &second).unwrap();
replacement.switch().unwrap();
assert_eq!(fs::read(root.join("bin/demo.exe")).unwrap(), b"second");
replacement.restore().unwrap();
assert_eq!(fs::read(root.join("bin/demo.exe")).unwrap(), b"first");
fs::remove_dir_all(root).unwrap();
}
}