//! 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>(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>( &self, source: P, pack_ref: &str, version: Option<&str>, ) -> Result { 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> { 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>(path: P) -> Result { 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 = 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>(path: P) -> Result { 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>(pack_path: P, expected_checksum: &str) -> Result { 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 } }