agent-migrate-core 0.6.1

Core models and transactional filesystem engine for agent configuration migration
Documentation
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

use fs2::FileExt;
use sha2::{Digest, Sha256};
use tempfile::NamedTempFile;

use crate::transaction::{TransactionError, TransactionOptions};

#[derive(Debug, Clone, Copy)]
pub(crate) struct FileSystem {
    private_mode: u32,
}

impl FileSystem {
    pub(crate) const fn new(private_mode: u32) -> Self {
        Self { private_mode }
    }

    pub(crate) fn prepare_roots(options: &TransactionOptions) -> Result<(), TransactionError> {
        Self::create_dir(options.root())?;
        Self::create_private_dir(options.state_root())?;
        Self::create_private_dir(&options.state_root().join("locks"))?;
        Self::create_private_dir(&options.state_root().join("backups"))
    }

    pub(crate) fn acquire_lock(options: &TransactionOptions) -> Result<File, TransactionError> {
        let key = hex::encode(Sha256::digest(
            options.root().as_os_str().as_encoded_bytes(),
        ));
        let path = options
            .state_root()
            .join("locks")
            .join(format!("{key}.lock"));
        let file = OpenOptions::new()
            .create(true)
            .truncate(false)
            .read(true)
            .write(true)
            .open(&path)
            .map_err(|source| TransactionError::Io {
                path: path.clone(),
                source,
            })?;
        file.try_lock_exclusive()
            .map_err(|_| TransactionError::LockBusy)?;
        Ok(file)
    }

    pub(crate) fn atomic_write(
        self,
        root: &Path,
        target: &Path,
        content: &[u8],
        mode: u32,
    ) -> Result<(), TransactionError> {
        let relative = target
            .strip_prefix(root)
            .map_err(|_| TransactionError::Symlink {
                path: target.to_path_buf(),
            })?;
        Self::ensure_path_safe(root, relative)?;
        let parent = target.parent().ok_or_else(|| TransactionError::Symlink {
            path: target.to_path_buf(),
        })?;
        Self::create_dir(parent)?;
        let mut temporary =
            NamedTempFile::new_in(parent).map_err(|source| TransactionError::Io {
                path: parent.to_path_buf(),
                source,
            })?;
        temporary
            .write_all(content)
            .and_then(|()| temporary.as_file().sync_all())
            .map_err(|source| TransactionError::Io {
                path: target.to_path_buf(),
                source,
            })?;
        let output_mode = if mode == 0 {
            self.private_mode
        } else {
            mode & 0o777
        };
        set_private_mode(temporary.as_file(), output_mode)?;
        temporary
            .persist(target)
            .map_err(|error| TransactionError::Io {
                path: target.to_path_buf(),
                source: error.error,
            })?;
        sync_directory(parent)?;
        Ok(())
    }

    pub(crate) fn ensure_path_safe(root: &Path, relative: &Path) -> Result<(), TransactionError> {
        let mut current = root.to_path_buf();
        for component in relative.components() {
            current.push(component);
            if let Ok(metadata) = fs::symlink_metadata(&current) {
                if metadata.file_type().is_symlink() {
                    return Err(TransactionError::Symlink { path: current });
                }
            }
        }
        Ok(())
    }

    pub(crate) fn create_dir(path: &Path) -> Result<(), TransactionError> {
        fs::create_dir_all(path).map_err(|source| TransactionError::Io {
            path: path.to_path_buf(),
            source,
        })
    }

    pub(crate) fn create_private_dir(path: &Path) -> Result<(), TransactionError> {
        Self::create_dir(path)?;
        set_directory_mode(path, 0o700)
    }

    pub(crate) fn read(path: &Path) -> Result<Vec<u8>, TransactionError> {
        fs::read(path).map_err(|source| TransactionError::Io {
            path: path.to_path_buf(),
            source,
        })
    }

    pub(crate) fn write(path: &Path, content: &[u8]) -> Result<(), TransactionError> {
        fs::write(path, content).map_err(|source| TransactionError::Io {
            path: path.to_path_buf(),
            source,
        })?;
        set_path_mode(path, 0o600)
    }

    pub(crate) fn copy(source_path: &Path, target: &Path) -> Result<(), TransactionError> {
        fs::copy(source_path, target)
            .map(|_bytes| ())
            .map_err(|source| TransactionError::Io {
                path: source_path.to_path_buf(),
                source,
            })?;
        set_path_mode(target, 0o600)
    }

    pub(crate) fn remove_file(path: &Path) -> Result<(), TransactionError> {
        fs::remove_file(path).map_err(|source| TransactionError::Io {
            path: path.to_path_buf(),
            source,
        })
    }

    pub(crate) fn remove_empty_parents(root: &Path, path: &Path) -> Result<(), TransactionError> {
        let mut current = path.to_path_buf();
        while current != root && current.starts_with(root) {
            match fs::remove_dir(&current) {
                Ok(()) => {}
                Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
                Err(source) => {
                    return Err(TransactionError::Io {
                        path: current,
                        source,
                    });
                }
            }
            let Some(parent) = current.parent() else {
                break;
            };
            current = parent.to_path_buf();
        }
        Ok(())
    }
}

#[cfg(unix)]
fn set_private_mode(file: &File, mode: u32) -> Result<(), TransactionError> {
    use std::os::unix::fs::PermissionsExt;

    file.set_permissions(fs::Permissions::from_mode(mode))
        .map_err(|source| TransactionError::Io {
            path: PathBuf::from("<staged-file>"),
            source,
        })
}

#[cfg(not(unix))]
fn set_private_mode(_file: &File, _mode: u32) -> Result<(), TransactionError> {
    Ok(())
}

#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<(), TransactionError> {
    File::open(path)
        .and_then(|directory| directory.sync_all())
        .map_err(|source| TransactionError::Io {
            path: path.to_path_buf(),
            source,
        })
}

#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<(), TransactionError> {
    Ok(())
}

#[cfg(unix)]
fn set_directory_mode(path: &Path, mode: u32) -> Result<(), TransactionError> {
    set_path_mode(path, mode)
}

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

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

    fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|source| {
        TransactionError::Io {
            path: path.to_path_buf(),
            source,
        }
    })
}

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