Files
attune/crates/common/src/pack_registry/storage.rs
2026-03-04 22:42:23 -06:00

404 lines
11 KiB
Rust

//! Pack Storage Management
//!
//! This module provides utilities for managing pack storage, including:
//! - Checksum calculation (SHA256)
//! - Pack directory management
//! - Storage path resolution
//! - Pack content verification
use crate::error::{Error, Result};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
/// Pack storage manager
pub struct PackStorage {
base_dir: PathBuf,
}
impl PackStorage {
/// Create a new PackStorage instance
///
/// # Arguments
///
/// * `base_dir` - Base directory for pack storage (e.g., /opt/attune/packs)
pub fn new<P: Into<PathBuf>>(base_dir: P) -> Self {
Self {
base_dir: base_dir.into(),
}
}
/// Get the storage path for a pack
///
/// # Arguments
///
/// * `pack_ref` - Pack reference (e.g., "core", "my_pack")
/// * `version` - Optional version (e.g., "1.0.0")
///
/// # Returns
///
/// Path where the pack should be stored
pub fn get_pack_path(&self, pack_ref: &str, version: Option<&str>) -> PathBuf {
if let Some(v) = version {
self.base_dir.join(format!("{}-{}", pack_ref, v))
} else {
self.base_dir.join(pack_ref)
}
}
/// Ensure the base directory exists
pub fn ensure_base_dir(&self) -> Result<()> {
if !self.base_dir.exists() {
fs::create_dir_all(&self.base_dir).map_err(|e| {
Error::io(format!(
"Failed to create pack storage directory {}: {}",
self.base_dir.display(),
e
))
})?;
}
Ok(())
}
/// Move a pack from temporary location to permanent storage
///
/// # Arguments
///
/// * `source` - Source directory (temporary location)
/// * `pack_ref` - Pack reference
/// * `version` - Optional version
///
/// # Returns
///
/// The final storage path
pub fn install_pack<P: AsRef<Path>>(
&self,
source: P,
pack_ref: &str,
version: Option<&str>,
) -> Result<PathBuf> {
self.ensure_base_dir()?;
let dest = self.get_pack_path(pack_ref, version);
// Remove existing installation if present
if dest.exists() {
fs::remove_dir_all(&dest).map_err(|e| {
Error::io(format!(
"Failed to remove existing pack at {}: {}",
dest.display(),
e
))
})?;
}
// Copy the pack to permanent storage
copy_dir_all(source.as_ref(), &dest)?;
Ok(dest)
}
/// Remove a pack from storage
///
/// # Arguments
///
/// * `pack_ref` - Pack reference
/// * `version` - Optional version
pub fn uninstall_pack(&self, pack_ref: &str, version: Option<&str>) -> Result<()> {
let path = self.get_pack_path(pack_ref, version);
if path.exists() {
fs::remove_dir_all(&path).map_err(|e| {
Error::io(format!(
"Failed to remove pack at {}: {}",
path.display(),
e
))
})?;
}
Ok(())
}
/// Check if a pack is installed
pub fn is_installed(&self, pack_ref: &str, version: Option<&str>) -> bool {
let path = self.get_pack_path(pack_ref, version);
path.exists() && path.is_dir()
}
/// List all installed packs
pub fn list_installed(&self) -> Result<Vec<String>> {
if !self.base_dir.exists() {
return Ok(Vec::new());
}
let mut packs = Vec::new();
let entries = fs::read_dir(&self.base_dir).map_err(|e| {
Error::io(format!(
"Failed to read pack directory {}: {}",
self.base_dir.display(),
e
))
})?;
for entry in entries {
let entry =
entry.map_err(|e| Error::io(format!("Failed to read directory entry: {}", e)))?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
packs.push(name.to_string());
}
}
}
Ok(packs)
}
}
/// Calculate SHA256 checksum of a directory
///
/// This recursively hashes all files in the directory in a deterministic order
/// (sorted by path) to produce a consistent checksum.
///
/// # Arguments
///
/// * `path` - Path to the directory
///
/// # Returns
///
/// Hex-encoded SHA256 checksum
pub fn calculate_directory_checksum<P: AsRef<Path>>(path: P) -> Result<String> {
let path = path.as_ref();
if !path.exists() {
return Err(Error::io(format!(
"Path does not exist: {}",
path.display()
)));
}
if !path.is_dir() {
return Err(Error::validation(format!(
"Path is not a directory: {}",
path.display()
)));
}
let mut hasher = Sha256::new();
let mut files: Vec<PathBuf> = Vec::new();
// Collect all files in sorted order for deterministic hashing
for entry in WalkDir::new(path).sort_by_file_name().into_iter() {
let entry = entry.map_err(|e| Error::io(format!("Failed to walk directory: {}", e)))?;
if entry.file_type().is_file() {
files.push(entry.path().to_path_buf());
}
}
// Hash each file
for file_path in files {
// Include relative path in hash for structure integrity
let rel_path = file_path
.strip_prefix(path)
.map_err(|e| Error::io(format!("Failed to strip prefix: {}", e)))?;
hasher.update(rel_path.to_string_lossy().as_bytes());
// Hash file contents
let mut file = fs::File::open(&file_path).map_err(|e| {
Error::io(format!(
"Failed to open file {}: {}",
file_path.display(),
e
))
})?;
let mut buffer = [0u8; 8192];
loop {
let n = file.read(&mut buffer).map_err(|e| {
Error::io(format!(
"Failed to read file {}: {}",
file_path.display(),
e
))
})?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
}
let result = hasher.finalize();
Ok(format!("{:x}", result))
}
/// Calculate SHA256 checksum of a single file
///
/// # Arguments
///
/// * `path` - Path to the file
///
/// # Returns
///
/// Hex-encoded SHA256 checksum
pub fn calculate_file_checksum<P: AsRef<Path>>(path: P) -> Result<String> {
let path = path.as_ref();
if !path.exists() {
return Err(Error::io(format!(
"File does not exist: {}",
path.display()
)));
}
if !path.is_file() {
return Err(Error::validation(format!(
"Path is not a file: {}",
path.display()
)));
}
let mut hasher = Sha256::new();
let mut file = fs::File::open(path)
.map_err(|e| Error::io(format!("Failed to open file {}: {}", path.display(), e)))?;
let mut buffer = [0u8; 8192];
loop {
let n = file
.read(&mut buffer)
.map_err(|e| Error::io(format!("Failed to read file {}: {}", path.display(), e)))?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
let result = hasher.finalize();
Ok(format!("{:x}", result))
}
/// Copy a directory recursively
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst).map_err(|e| {
Error::io(format!(
"Failed to create destination directory {}: {}",
dst.display(),
e
))
})?;
for entry in fs::read_dir(src).map_err(|e| {
Error::io(format!(
"Failed to read source directory {}: {}",
src.display(),
e
))
})? {
let entry =
entry.map_err(|e| Error::io(format!("Failed to read directory entry: {}", e)))?;
let path = entry.path();
let file_name = entry.file_name();
let dest_path = dst.join(&file_name);
if path.is_dir() {
copy_dir_all(&path, &dest_path)?;
} else {
fs::copy(&path, &dest_path).map_err(|e| {
Error::io(format!(
"Failed to copy file {} to {}: {}",
path.display(),
dest_path.display(),
e
))
})?;
}
}
Ok(())
}
/// Verify a pack's checksum matches the expected value
///
/// # Arguments
///
/// * `pack_path` - Path to the pack directory
/// * `expected_checksum` - Expected SHA256 checksum (hex-encoded)
///
/// # Returns
///
/// `Ok(true)` if checksums match, `Ok(false)` if they don't match,
/// or `Err` on I/O errors
pub fn verify_checksum<P: AsRef<Path>>(pack_path: P, expected_checksum: &str) -> Result<bool> {
let actual = calculate_directory_checksum(pack_path)?;
Ok(actual.eq_ignore_ascii_case(expected_checksum))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_pack_storage_paths() {
let storage = PackStorage::new("/opt/attune/packs");
let path1 = storage.get_pack_path("core", None);
assert_eq!(path1, PathBuf::from("/opt/attune/packs/core"));
let path2 = storage.get_pack_path("core", Some("1.0.0"));
assert_eq!(path2, PathBuf::from("/opt/attune/packs/core-1.0.0"));
}
#[test]
fn test_calculate_file_checksum() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
let mut file = File::create(&file_path).unwrap();
file.write_all(b"Hello, world!").unwrap();
drop(file);
let checksum = calculate_file_checksum(&file_path).unwrap();
// Known SHA256 of "Hello, world!"
assert_eq!(
checksum,
"315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3"
);
}
#[test]
fn test_calculate_directory_checksum() {
let temp_dir = TempDir::new().unwrap();
// Create a simple directory structure
let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let file1 = temp_dir.path().join("file1.txt");
let mut f = File::create(&file1).unwrap();
f.write_all(b"content1").unwrap();
drop(f);
let file2 = subdir.join("file2.txt");
let mut f = File::create(&file2).unwrap();
f.write_all(b"content2").unwrap();
drop(f);
let checksum1 = calculate_directory_checksum(temp_dir.path()).unwrap();
// Calculate again - should be deterministic
let checksum2 = calculate_directory_checksum(temp_dir.path()).unwrap();
assert_eq!(checksum1, checksum2);
assert_eq!(checksum1.len(), 64); // SHA256 is 64 hex characters
}
}