mithril_common/entities/
signable_manifest.rs

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/// Stores a map of files and their hashes, with an optional signature to verify the integrity of the
12/// signed data.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct SignableManifest<TKey: Ord, TValue> {
15    /// The data stored in the manifest
16    pub data: BTreeMap<TKey, TValue>,
17    /// The signature of the manifest
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub signature: Option<ManifestSignature>,
20}
21
22/// Alias of [SignableManifest] for Ancillary files
23pub type AncillaryFilesManifest = SignableManifest<PathBuf, String>;
24
25/// Errors that can occur when verifying the integrity of the data in the manifest
26#[derive(Error, Debug)]
27pub enum AncillaryFilesManifestVerifyError {
28    /// The hash of a file does not match the hash in the manifest
29    #[error("File `{file_path}` hash does not match expected hash, expected: '{expected_hash}', actual: '{actual_hash}'")]
30    FileHashMismatch {
31        /// Path of the file that has a hash mismatch
32        file_path: PathBuf,
33        /// Expected hash of the file according to the manifest
34        expected_hash: String,
35        /// Actual hash of the file
36        actual_hash: String,
37    },
38    /// An error occurred while computing the hash of a file
39    #[error("Failed to compute hash for file `{file_path}`")]
40    HashCompute {
41        /// Path of the file
42        file_path: PathBuf,
43        /// Source of the error
44        source: StdError,
45    },
46}
47
48impl AncillaryFilesManifest {
49    /// The file name used to serialize and deserialize `AncillaryFilesManifest` files in JSON format
50    pub const ANCILLARY_MANIFEST_FILE_NAME: &str = "ancillary_manifest.json";
51
52    cfg_fs! {
53        /// Creates a new manifest, without signature, from the files in the provided paths
54        ///
55        /// The hash of each file will be computed and stored in the manifest
56        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        /// Verifies the integrity of the data in the manifest
78        ///
79        /// Checks if the files in the manifest are present in the base directory and have the same hash
80        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                // No more data to read
115                if len == 0 {
116                    break;
117                }
118
119                hasher.update(&data[..len]);
120            }
121
122            Ok(hex::encode(hasher.finalize()))
123        }
124    }
125
126    /// Aggregates the hashes of all the keys and values of the manifest
127    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            // Order does not matter
183            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 not included in the manifest should not be considered
284                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}