mithril_cardano_node_internal_database/entities/
ancillary_files_manifest.rs

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