bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Atomic activation and rollback for executables below a managed root.
//!
//! Cargo and Git installation paths use this capability to switch verified artifacts into the
//! managed binary directory. Managed removal uses the same primitive to restore a previous
//! artifact, keeping activation lifecycle semantics independent of any one backend adapter.

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};

/// Factory for atomic executable activations below one managed root.
pub(crate) struct ActivationProvider {
    root: PathBuf,
}

/// Prepared replacement that can be switched atomically and restored to its prior state.
///
/// Dropping the value removes staging files. A failed restore preserves the backup for manual
/// recovery instead of deleting the last known previous activation.
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 {
    /// Create or open a managed activation root.
    ///
    /// # Errors
    ///
    /// Returns [`ForgeError`] when the root cannot be created.
    pub(crate) fn new(root: PathBuf) -> Result<Self, ForgeError> {
        create_dir_all(&root)?;
        Ok(Self { root })
    }

    /// Stage `executable` for an atomic activation under `name`.
    ///
    /// # Errors
    ///
    /// Returns [`ForgeError`] for an invalid name, a missing executable, or staging failure.
    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 {
    /// Return the final managed activation path.
    pub(crate) fn destination(&self) -> &Path {
        &self.destination
    }

    /// Atomically replace the destination while preserving its previous content when present.
    ///
    /// A successful switch moves the state to `switched`; a failed replacement leaves the staged
    /// value eligible for cleanup and does not claim activation success.
    ///
    /// # Errors
    ///
    /// Returns [`ForgeError`] when backup creation or atomic replacement fails.
    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(())
    }

    /// Restore the activation state that existed before [`switch`](Self::switch).
    ///
    /// A failed restore marks the backup as recovery data so [`Drop`] does not delete it.
    ///
    /// # Errors
    ///
    /// Returns [`ForgeError`] when the expected backup is absent or replacement/removal fails.
    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();
    }
}