agent-migrate-core 0.6.1

Core models and transactional filesystem engine for agent configuration migration
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::str::FromStr;

use walkdir::WalkDir;

use crate::{Bundle, BundleError, BundleRelativePath, Manifest, SecretRef};

const MANIFEST: &str = "manifest.yaml";
const SECRETS: &str = "secrets.ref.yaml";

impl Bundle {
    /// Writes a verified Bundle as an inspectable directory.
    ///
    /// # Errors
    ///
    /// Returns an error for an existing output, invalid Bundle, or filesystem failure.
    pub fn write_directory(&self, output: &Path) -> Result<(), BundleError> {
        self.verify()?;
        if output.exists() {
            return Err(BundleError::OutputExists(output.to_path_buf()));
        }
        let parent = usable_parent(output);
        fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
        let temporary = tempfile::tempdir_in(parent).map_err(|source| io_error(parent, source))?;
        write_file(
            &temporary.path().join(MANIFEST),
            serde_yaml::to_string(self.manifest())?.as_bytes(),
            0o600,
        )?;
        write_file(
            &temporary.path().join(SECRETS),
            serde_yaml::to_string(&self.manifest().secret_references)?.as_bytes(),
            0o600,
        )?;
        for (path, content) in self.files() {
            let record = self
                .manifest()
                .files
                .get(path)
                .ok_or_else(|| BundleError::MissingFile(path.clone()))?;
            write_file(&temporary.path().join(path.as_path()), content, record.mode)?;
        }
        let staged = temporary.keep();
        fs::rename(&staged, output).map_err(|source| io_error(output, source))
    }

    /// Loads and verifies an inspectable Bundle directory.
    ///
    /// # Errors
    ///
    /// Returns an error for unsafe paths, undeclared files, invalid metadata, or hash mismatch.
    pub fn from_directory(input: &Path) -> Result<Self, BundleError> {
        reject_root_symlink(input)?;
        let manifest: Manifest = serde_yaml::from_slice(&read(&input.join(MANIFEST))?)?;
        let references: Vec<SecretRef> = serde_yaml::from_slice(&read(&input.join(SECRETS))?)?;
        if references != manifest.secret_references {
            return Err(BundleError::SecretReferenceMismatch);
        }
        let mut content = BTreeMap::new();
        for (path, record) in &manifest.files {
            let disk_path = input.join(path.as_path());
            reject_components(input, path.as_path())?;
            verify_mode(&disk_path, record.mode)?;
            content.insert(path.clone(), read(&disk_path)?);
        }
        reject_undeclared(input, &manifest)?;
        Self::from_parts(manifest, content)
    }
}

fn write_file(path: &Path, content: &[u8], mode: u32) -> Result<(), BundleError> {
    let parent = path
        .parent()
        .ok_or_else(|| io_error(path, std::io::Error::other("missing parent")))?;
    fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
    fs::write(path, content).map_err(|source| io_error(path, source))?;
    set_mode(path, mode)
}

fn read(path: &Path) -> Result<Vec<u8>, BundleError> {
    fs::read(path).map_err(|source| io_error(path, source))
}

fn reject_root_symlink(input: &Path) -> Result<(), BundleError> {
    let metadata = fs::symlink_metadata(input).map_err(|source| io_error(input, source))?;
    if metadata.file_type().is_symlink() {
        return Err(BundleError::DirectorySymlink(input.to_path_buf()));
    }
    Ok(())
}

fn reject_components(root: &Path, relative: &Path) -> Result<(), BundleError> {
    let mut current = root.to_path_buf();
    for component in relative.components() {
        current.push(component);
        let metadata =
            fs::symlink_metadata(&current).map_err(|source| io_error(&current, source))?;
        if metadata.file_type().is_symlink() {
            return Err(BundleError::DirectorySymlink(current));
        }
    }
    Ok(())
}

fn reject_undeclared(root: &Path, manifest: &Manifest) -> Result<(), BundleError> {
    for item in WalkDir::new(root).follow_links(false).min_depth(1) {
        let entry = item.map_err(|error| {
            let path = error
                .path()
                .map_or_else(|| root.to_path_buf(), Path::to_path_buf);
            io_error(&path, std::io::Error::other(error.to_string()))
        })?;
        if entry.file_type().is_symlink() {
            return Err(BundleError::DirectorySymlink(entry.path().to_path_buf()));
        }
        if !entry.file_type().is_file() {
            continue;
        }
        let relative = entry
            .path()
            .strip_prefix(root)
            .map_err(|_| BundleError::UnexpectedDiskFile(entry.path().to_path_buf()))?;
        if matches!(relative.to_str(), Some(MANIFEST | SECRETS)) {
            continue;
        }
        let relative = BundleRelativePath::from_str(
            relative
                .to_str()
                .ok_or_else(|| BundleError::UnexpectedDiskFile(relative.to_path_buf()))?,
        )?;
        if !manifest.files.contains_key(&relative) {
            return Err(BundleError::UnexpectedDiskFile(entry.path().to_path_buf()));
        }
    }
    Ok(())
}

fn usable_parent(path: &Path) -> &Path {
    path.parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."))
}

fn io_error(path: &Path, source: std::io::Error) -> BundleError {
    BundleError::Io {
        path: path.to_path_buf(),
        source,
    }
}

#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) -> Result<(), BundleError> {
    use std::os::unix::fs::PermissionsExt;

    fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o777))
        .map_err(|source| io_error(path, source))
}

#[cfg(not(unix))]
fn set_mode(_path: &Path, _mode: u32) -> Result<(), BundleError> {
    Ok(())
}

#[cfg(unix)]
fn verify_mode(path: &Path, expected: u32) -> Result<(), BundleError> {
    use std::os::unix::fs::PermissionsExt;

    let actual = fs::metadata(path)
        .map_err(|source| io_error(path, source))?
        .permissions()
        .mode()
        & 0o777;
    if actual != expected & 0o777 {
        return Err(BundleError::ModeMismatch(path.to_path_buf()));
    }
    Ok(())
}

#[cfg(not(unix))]
fn verify_mode(_path: &Path, _expected: u32) -> Result<(), BundleError> {
    Ok(())
}