agent-migrate-core 0.6.1

Core models and transactional filesystem engine for agent configuration migration
Documentation
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::BundleRelativePath;
use crate::transaction::filesystem::FileSystem;
use crate::transaction::{SnapshotId, TransactionError, TransactionOptions, WritePlan};

#[derive(Debug)]
pub(crate) struct SnapshotStore<'a> {
    filesystem: FileSystem,
    options: &'a TransactionOptions,
}

impl<'a> SnapshotStore<'a> {
    pub(crate) const fn new(filesystem: FileSystem, options: &'a TransactionOptions) -> Self {
        Self {
            filesystem,
            options,
        }
    }

    pub(crate) fn create(
        &self,
        snapshot_id: SnapshotId,
        plan: &WritePlan,
    ) -> Result<(), TransactionError> {
        let directory = self.directory(snapshot_id);
        let files_root = directory.join("files");
        FileSystem::create_private_dir(&directory)?;
        FileSystem::create_private_dir(&files_root)?;
        let mut entries = Vec::with_capacity(plan.len());
        for write in plan.writes() {
            let target = self.options.root().join(write.path.as_path());
            let existed = target.is_file();
            let (mode, sha256) = if existed {
                let backup = files_root.join(write.path.as_path());
                if let Some(parent) = backup.parent() {
                    FileSystem::create_private_dir(parent)?;
                }
                FileSystem::copy(&target, &backup)?;
                let content = FileSystem::read(&backup)?;
                (
                    file_mode(&target)?,
                    Some(hex::encode(Sha256::digest(content))),
                )
            } else {
                (None, None)
            };
            entries.push(SnapshotEntry {
                path: write.path.clone(),
                existed,
                mode,
                sha256,
            });
        }
        let manifest = serde_json::to_vec_pretty(&SnapshotManifest { entries })
            .map_err(TransactionError::InvalidSnapshot)?;
        FileSystem::write(&directory.join("manifest.json"), &manifest)
    }

    pub(crate) fn restore(&self, snapshot_id: SnapshotId) -> Result<(), TransactionError> {
        let directory = self.directory(snapshot_id);
        let manifest_path = directory.join("manifest.json");
        if !manifest_path.is_file() {
            return Err(TransactionError::SnapshotNotFound(snapshot_id));
        }
        let manifest_bytes = FileSystem::read(&manifest_path)?;
        let manifest: SnapshotManifest =
            serde_json::from_slice(&manifest_bytes).map_err(TransactionError::InvalidSnapshot)?;
        for entry in manifest.entries {
            FileSystem::ensure_path_safe(self.options.root(), entry.path.as_path())?;
            let target = self.options.root().join(entry.path.as_path());
            if entry.existed {
                let backup = directory.join("files").join(entry.path.as_path());
                let content = FileSystem::read(&backup)?;
                if entry.sha256.as_deref() != Some(&hex::encode(Sha256::digest(&content))) {
                    return Err(TransactionError::SnapshotHashMismatch { path: backup });
                }
                self.filesystem.atomic_write(
                    self.options.root(),
                    &target,
                    &content,
                    entry.mode.unwrap_or(0o600),
                )?;
            } else if target.is_file() {
                FileSystem::remove_file(&target)?;
                if let Some(parent) = target.parent() {
                    FileSystem::remove_empty_parents(self.options.root(), parent)?;
                }
            }
        }
        Ok(())
    }

    fn directory(&self, snapshot_id: SnapshotId) -> PathBuf {
        self.options
            .state_root()
            .join("backups")
            .join(snapshot_id.to_string())
    }
}

#[derive(Debug, Serialize, Deserialize)]
struct SnapshotManifest {
    entries: Vec<SnapshotEntry>,
}

#[derive(Debug, Serialize, Deserialize)]
struct SnapshotEntry {
    path: BundleRelativePath,
    existed: bool,
    mode: Option<u32>,
    sha256: Option<String>,
}

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

    std::fs::metadata(path)
        .map(|metadata| Some(metadata.permissions().mode() & 0o777))
        .map_err(|source| TransactionError::Io {
            path: path.to_path_buf(),
            source,
        })
}

#[cfg(not(unix))]
fn file_mode(_path: &std::path::Path) -> Result<Option<u32>, TransactionError> {
    Ok(None)
}