appcore_storage/
manifest.rs1use crate::storage::{StorageError, StorageResult};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::collections::HashMap;
17use std::fs;
18use std::io;
19use std::path::{Component, Path, PathBuf};
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct ManifestFileEntry {
24 pub hash: String,
26 pub size: u64,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct StorageManifest {
36 pub schema_version: String,
38 pub app_id: String,
40 pub node_id: String,
42 pub created_at_ms: u64,
44 pub runtime_version: String,
46 pub files: HashMap<String, ManifestFileEntry>,
48}
49
50impl StorageManifest {
51 pub fn generate(
53 app_id: &str,
54 node_id: &str,
55 runtime_version: &str,
56 created_at_ms: u64,
57 root_dir: &Path,
58 file_paths: &[&str],
59 ) -> StorageResult<Self> {
60 let mut files = HashMap::new();
61 for rel_path in file_paths {
62 let full_path = resolve_path(root_dir, rel_path)?;
63 if !full_path.exists() {
64 return Err(StorageError::RepositoryNotFound((*rel_path).to_string()));
65 }
66 let (hash, size) = compute_file_sha256(&full_path)
67 .map_err(|e| StorageError::TransactionFailed(e.to_string()))?;
68 files.insert((*rel_path).to_string(), ManifestFileEntry { hash, size });
69 }
70 Ok(Self {
71 schema_version: "1".to_string(),
72 app_id: app_id.to_string(),
73 node_id: node_id.to_string(),
74 created_at_ms,
75 runtime_version: runtime_version.to_string(),
76 files,
77 })
78 }
79
80 pub fn verify(&self, root_dir: &Path) -> StorageResult<()> {
82 if self.schema_version != "1" {
83 return Err(StorageError::MigrationFailed(format!(
84 "Incompatible schema version: expected 1, found {}",
85 self.schema_version
86 )));
87 }
88 for (rel_path, entry) in &self.files {
89 let full_path = resolve_path(root_dir, rel_path)?;
90 if !full_path.exists() {
91 return Err(StorageError::RepositoryNotFound(format!(
92 "File missing: {}",
93 rel_path
94 )));
95 }
96 let (actual_hash, actual_size) = compute_file_sha256(&full_path)
97 .map_err(|e| StorageError::TransactionFailed(e.to_string()))?;
98 if actual_size != entry.size {
99 return Err(StorageError::TransactionFailed(format!(
100 "File size mismatch: {}. Expected {}, got {}",
101 rel_path, entry.size, actual_size
102 )));
103 }
104 if actual_hash != entry.hash {
105 return Err(StorageError::TransactionFailed(format!(
106 "File hash mismatch: {}. Expected {}, got {}",
107 rel_path, entry.hash, actual_hash
108 )));
109 }
110 }
111 Ok(())
112 }
113}
114
115fn resolve_path(root: &Path, relative: &str) -> StorageResult<PathBuf> {
116 let rel = Path::new(relative);
117 if rel.is_absolute() {
118 return Err(StorageError::InvalidPath(relative.to_string()));
119 }
120 for component in rel.components() {
121 if matches!(
122 component,
123 Component::ParentDir | Component::RootDir | Component::Prefix(_)
124 ) {
125 return Err(StorageError::InvalidPath(relative.to_string()));
126 }
127 }
128 Ok(root.join(rel))
129}
130
131fn compute_file_sha256(path: &Path) -> Result<(String, u64), io::Error> {
132 let mut file = fs::File::open(path)?;
133 let mut hasher = Sha256::new();
134 let size = io::copy(&mut file, &mut hasher)?;
135 let hash = format!("{:x}", hasher.finalize());
136 Ok((hash, size))
137}
138
139#[cfg(test)]
140#[path = "manifest_tests.rs"]
141mod tests;