1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use thiserror::Error;
7
8use crate::crypto_helper::ManifestSignature;
9use crate::StdError;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct SignableManifest<TKey: Ord, TValue> {
15 pub data: BTreeMap<TKey, TValue>,
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub signature: Option<ManifestSignature>,
20}
21
22pub type AncillaryFilesManifest = SignableManifest<PathBuf, String>;
24
25#[derive(Error, Debug)]
27pub enum AncillaryFilesManifestVerifyError {
28 #[error("File `{file_path}` hash does not match expected hash, expected: '{expected_hash}', actual: '{actual_hash}'")]
30 FileHashMismatch {
31 file_path: PathBuf,
33 expected_hash: String,
35 actual_hash: String,
37 },
38 #[error("Failed to compute hash for file `{file_path}`")]
40 HashCompute {
41 file_path: PathBuf,
43 source: StdError,
45 },
46}
47
48impl AncillaryFilesManifest {
49 pub const ANCILLARY_MANIFEST_FILE_NAME: &str = "ancillary_manifest.json";
51
52 cfg_fs! {
53 pub async fn from_paths(
57 base_directory: &std::path::Path,
58 paths: Vec<PathBuf>,
59 ) -> crate::StdResult<Self> {
60 use anyhow::Context;
61 let mut data = BTreeMap::new();
62
63 for path in paths {
64 let file_path = base_directory.join(&path);
65 let hash = Self::compute_file_hash(&file_path).await.with_context(|| {
66 format!("Failed to compute hash for file `{}`", file_path.display())
67 })?;
68 data.insert(path, hash);
69 }
70
71 Ok(Self {
72 data,
73 signature: None,
74 })
75 }
76
77 pub async fn verify_data(
81 &self,
82 base_directory: &std::path::Path,
83 ) -> Result<(), AncillaryFilesManifestVerifyError> {
84 for (file_path, expected_hash) in &self.data {
85 let file_path = base_directory.join(file_path);
86 let actual_hash = Self::compute_file_hash(&file_path)
87 .await
88 .map_err(|source| AncillaryFilesManifestVerifyError::HashCompute {
89 file_path: file_path.clone(),
90 source,
91 })?;
92
93 if actual_hash != *expected_hash {
94 return Err(AncillaryFilesManifestVerifyError::FileHashMismatch {
95 file_path,
96 expected_hash: expected_hash.clone(),
97 actual_hash,
98 });
99 }
100 }
101
102 Ok(())
103 }
104
105 async fn compute_file_hash(file_path: &std::path::Path) -> crate::StdResult<String> {
106 use tokio::io::AsyncReadExt;
107
108 let mut file = tokio::fs::File::open(&file_path).await?;
109 let mut hasher = Sha256::new();
110
111 let mut data = vec![0; 64 * 1024];
112 loop {
113 let len = file.read(&mut data).await?;
114 if len == 0 {
116 break;
117 }
118
119 hasher.update(&data[..len]);
120 }
121
122 Ok(hex::encode(hasher.finalize()))
123 }
124 }
125
126 pub fn compute_hash(&self) -> Vec<u8> {
128 let mut hasher = Sha256::new();
129 for (key, value) in &self.data {
130 hasher.update(key.to_string_lossy().as_bytes());
131 hasher.update(value.as_bytes());
132 }
133 hasher.finalize().to_vec()
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 #[cfg(feature = "fs")]
140 use std::{fs::File, io::Write};
141
142 #[cfg(feature = "fs")]
143 use crate::test_utils::temp_dir_create;
144
145 use super::*;
146
147 #[cfg(feature = "fs")]
148 fn compute_sha256_hash(data: impl AsRef<[u8]>) -> String {
149 hex::encode(Sha256::digest(data))
150 }
151
152 mod ancillary_files_manifest {
153 use super::*;
154
155 #[test]
156 fn compute_hash_when_data_is_empty() {
157 let manifest = AncillaryFilesManifest {
158 data: BTreeMap::new(),
159 signature: None,
160 };
161 let hash_of_empty_data = Sha256::digest(Vec::<u8>::new()).to_vec();
162
163 assert_eq!(hash_of_empty_data, manifest.compute_hash());
164 }
165
166 #[test]
167 fn compute_hash() {
168 let expected: Vec<u8> = vec![
169 123, 150, 146, 219, 108, 23, 117, 210, 8, 3, 126, 211, 68, 93, 169, 200, 177, 115,
170 169, 219, 87, 2, 238, 52, 209, 37, 214, 207, 21, 188, 246, 127,
171 ];
172
173 let manifest = AncillaryFilesManifest {
174 data: BTreeMap::from([
175 (PathBuf::from("file1"), "hash1".to_string()),
176 (PathBuf::from("file2"), "hash2".to_string()),
177 ]),
178 signature: None,
179 };
180
181 assert_eq!(expected, manifest.compute_hash());
182 assert_eq!(
184 expected,
185 AncillaryFilesManifest {
186 data: BTreeMap::from([
187 (PathBuf::from("file2"), "hash2".to_string()),
188 (PathBuf::from("file1"), "hash1".to_string()),
189 ]),
190 ..manifest.clone()
191 }
192 .compute_hash()
193 );
194 assert_ne!(
195 expected,
196 AncillaryFilesManifest {
197 data: BTreeMap::from([
198 (PathBuf::from("file1"), "hash1".to_string()),
199 (PathBuf::from("file3"), "hash3".to_string()),
200 ]),
201 ..manifest.clone()
202 }
203 .compute_hash()
204 );
205 assert_ne!(
206 expected,
207 AncillaryFilesManifest {
208 data: BTreeMap::from([(PathBuf::from("file1"), "hash1".to_string()),]),
209 ..manifest.clone()
210 }
211 .compute_hash()
212 );
213 }
214
215 #[test]
216 fn signature_is_not_included_in_compute_hash() {
217 const TEST_SIGNATURE: &str =
218 "b5690fe641ee240248d1335092392fefe2399fb11a4bfaddffc790676f4d48a9c34ec648699a3e3b0ba0de8c8bcde5855f16b88eb644d12a9ba1044b5ba91b07";
219 let manifest = AncillaryFilesManifest {
220 data: BTreeMap::from([
221 (PathBuf::from("file1"), "hash1".to_string()),
222 (PathBuf::from("file2"), "hash2".to_string()),
223 ]),
224 signature: Some(TEST_SIGNATURE.try_into().unwrap()),
225 };
226
227 assert_eq!(
228 AncillaryFilesManifest {
229 signature: None,
230 ..manifest.clone()
231 }
232 .compute_hash(),
233 manifest.compute_hash()
234 );
235 }
236
237 #[cfg(feature = "fs")]
238 #[tokio::test]
239 async fn from_paths() {
240 let test_dir = temp_dir_create!();
241 std::fs::create_dir(test_dir.join("sub_folder")).unwrap();
242
243 let file1_path = PathBuf::from("file1.txt");
244 let file2_path = PathBuf::from("sub_folder/file1.txt");
245
246 let mut file1 = File::create(test_dir.join(&file1_path)).unwrap();
247 write!(&mut file1, "file1 content").unwrap();
248
249 let mut file2 = File::create(test_dir.join(&file2_path)).unwrap();
250 write!(&mut file2, "file2 content").unwrap();
251
252 let manifest = AncillaryFilesManifest::from_paths(
253 &test_dir,
254 vec![file1_path.clone(), file2_path.clone()],
255 )
256 .await
257 .expect("Manifest creation should succeed");
258
259 assert_eq!(
260 AncillaryFilesManifest {
261 data: BTreeMap::from([
262 (file1_path, compute_sha256_hash("file1 content".as_bytes()),),
263 (file2_path, compute_sha256_hash("file2 content".as_bytes()),),
264 ]),
265 signature: None,
266 },
267 manifest
268 );
269 }
270
271 #[cfg(feature = "fs")]
272 mod verify_data {
273 use super::*;
274
275 #[tokio::test]
276 async fn verify_data_succeed_when_files_hashes_in_target_directory_match() {
277 let test_dir = temp_dir_create!();
278 std::fs::create_dir(test_dir.join("sub_folder")).unwrap();
279
280 let file1_path = PathBuf::from("file1.txt");
281 let file2_path = PathBuf::from("sub_folder/file1.txt");
282
283 File::create(test_dir.join("random_not_included_file.txt")).unwrap();
285
286 let mut file1 = File::create(test_dir.join(&file1_path)).unwrap();
287 write!(&mut file1, "file1 content").unwrap();
288
289 let mut file2 = File::create(test_dir.join(&file2_path)).unwrap();
290 write!(&mut file2, "file2 content").unwrap();
291
292 let manifest = AncillaryFilesManifest {
293 data: BTreeMap::from([
294 (file1_path, compute_sha256_hash("file1 content".as_bytes())),
295 (file2_path, compute_sha256_hash("file2 content".as_bytes())),
296 ]),
297 signature: None,
298 };
299
300 manifest
301 .verify_data(&test_dir)
302 .await
303 .expect("Verification should succeed when files exists and hashes match");
304 }
305
306 #[tokio::test]
307 async fn verify_data_fail_when_a_file_in_missing_in_target_directory() {
308 let test_dir = temp_dir_create!();
309 let file_path = PathBuf::from("file1.txt");
310
311 let manifest = AncillaryFilesManifest {
312 data: BTreeMap::from([(
313 file_path.clone(),
314 compute_sha256_hash("non existent file content".as_bytes()),
315 )]),
316 signature: None,
317 };
318
319 let result = manifest.verify_data(&test_dir).await;
320 assert!(
321 matches!(
322 result,
323 Err(AncillaryFilesManifestVerifyError::HashCompute { .. }),
324 ),
325 "Expected HashCompute error, got: {result:?}",
326 );
327 }
328
329 #[tokio::test]
330 async fn verify_data_fail_when_a_file_hash_does_not_match_in_target_directory() {
331 let test_dir = temp_dir_create!();
332
333 let file_path = PathBuf::from("file1.txt");
334
335 let mut file = File::create(test_dir.join(&file_path)).unwrap();
336 write!(&mut file, "file content").unwrap();
337
338 let manifest = AncillaryFilesManifest {
339 data: BTreeMap::from([(
340 file_path.clone(),
341 "This is not the file content hash".to_string(),
342 )]),
343 signature: None,
344 };
345
346 let result = manifest.verify_data(&test_dir).await;
347 assert!(
348 matches!(
349 result,
350 Err(AncillaryFilesManifestVerifyError::FileHashMismatch { .. }),
351 ),
352 "Expected FileHashMismatch error, got: {result:?}",
353 );
354 }
355 }
356 }
357}