mithril_client_cli/utils/
cardano_db_download_checker.rs

1use std::{
2    fs,
3    ops::Not,
4    path::{Path, PathBuf},
5};
6
7use anyhow::Context;
8use human_bytes::human_bytes;
9use thiserror::Error;
10
11use mithril_client::{MithrilError, MithrilResult, common::CompressionAlgorithm};
12
13/// Checks to apply before downloading a Cardano Db archive to a given directory.
14pub struct CardanoDbDownloadChecker;
15
16/// Errors tied with the [CardanoDbDownloadChecker].
17#[derive(Debug, Error)]
18pub enum CardanoDbDownloadCheckerError {
19    /// Not enough space on the disk. There should be at least the ratio given for the
20    /// used algorithm (see [CompressionAlgorithm::free_space_snapshot_ratio]) times
21    /// the size of the archive to download to ensure it could be unpacked safely.
22    #[error("There is only {} remaining in directory '{}' to store and unpack a {} large archive.", human_bytes(*left_space), pathdir.display(), human_bytes(*archive_size))]
23    NotEnoughSpaceForArchive {
24        /// Left space on device
25        left_space: f64,
26
27        /// Specified location
28        pathdir: PathBuf,
29
30        /// Packed cardano db size
31        archive_size: f64,
32    },
33
34    /// Not enough space on the disk. There should be at least the size of the uncompressed Cardano database.
35    #[error("There is only {} remaining in directory '{}' to store and unpack a {} Cardano database.", human_bytes(*left_space), pathdir.display(), human_bytes(*db_size))]
36    NotEnoughSpaceForUncompressedData {
37        /// Left space on device
38        left_space: f64,
39
40        /// Specified location
41        pathdir: PathBuf,
42
43        /// Uncompressed cardano db size
44        db_size: f64,
45    },
46
47    /// The directory where the files from cardano db are expanded is not empty.
48    /// An error is raised to let the user handle what it wants to do with those
49    /// files.
50    #[error("Unpack directory '{0}' is not empty, please clean up its content.")]
51    UnpackDirectoryNotEmpty(PathBuf),
52
53    /// Cannot write in the given directory.
54    #[error(
55        "Unpack directory '{0}' is not writable, please check own or parents' permissions and ownership."
56    )]
57    UnpackDirectoryIsNotWritable(PathBuf, #[source] MithrilError),
58}
59
60impl CardanoDbDownloadChecker {
61    /// Ensure that the given path exist, create it otherwise
62    pub fn ensure_dir_exist(pathdir: &Path) -> MithrilResult<()> {
63        if pathdir.exists().not() {
64            fs::create_dir_all(pathdir).map_err(|e| {
65                CardanoDbDownloadCheckerError::UnpackDirectoryIsNotWritable(
66                    pathdir.to_owned(),
67                    e.into(),
68                )
69            })?;
70        }
71
72        Ok(())
73    }
74
75    /// Check all prerequisites are met before starting to download and unpack
76    /// big cardano db archive.
77    pub fn check_prerequisites_for_archive(
78        pathdir: &Path,
79        size: u64,
80        compression_algorithm: CompressionAlgorithm,
81    ) -> MithrilResult<()> {
82        Self::check_path_is_an_empty_dir(pathdir)?;
83        Self::check_dir_writable(pathdir)?;
84        Self::check_disk_space_for_archive(pathdir, size, compression_algorithm)
85    }
86
87    /// Check all prerequisites are met before starting to download and unpack cardano db archives.
88    pub fn check_prerequisites_for_uncompressed_data(
89        pathdir: &Path,
90        total_size_uncompressed: u64,
91        allow_override: bool,
92    ) -> MithrilResult<()> {
93        if !allow_override {
94            Self::check_path_is_an_empty_dir(pathdir)?;
95        }
96        Self::check_dir_writable(pathdir)?;
97        Self::check_disk_space_for_uncompressed_data(pathdir, total_size_uncompressed)
98    }
99
100    fn check_path_is_an_empty_dir(pathdir: &Path) -> MithrilResult<()> {
101        if pathdir.is_dir().not() {
102            anyhow::bail!("Given path is not a directory: {}", pathdir.display());
103        }
104
105        if fs::read_dir(pathdir)
106            .with_context(|| {
107                format!(
108                    "Could not list directory `{}` to check if it's empty",
109                    pathdir.display()
110                )
111            })?
112            .next()
113            .is_some()
114        {
115            return Err(
116                CardanoDbDownloadCheckerError::UnpackDirectoryNotEmpty(pathdir.to_owned()).into(),
117            );
118        }
119
120        Ok(())
121    }
122
123    fn check_dir_writable(pathdir: &Path) -> MithrilResult<()> {
124        // Check if the directory is writable by creating a temporary file
125        let temp_file_path = pathdir.join("temp_file");
126        fs::File::create(&temp_file_path).map_err(|e| {
127            CardanoDbDownloadCheckerError::UnpackDirectoryIsNotWritable(
128                pathdir.to_owned(),
129                e.into(),
130            )
131        })?;
132
133        // Delete the temporary file
134        fs::remove_file(temp_file_path).map_err(|e| {
135            CardanoDbDownloadCheckerError::UnpackDirectoryIsNotWritable(
136                pathdir.to_owned(),
137                e.into(),
138            )
139        })?;
140
141        Ok(())
142    }
143
144    fn check_disk_space_for_archive(
145        pathdir: &Path,
146        size: u64,
147        compression_algorithm: CompressionAlgorithm,
148    ) -> MithrilResult<()> {
149        let free_space = fs2::available_space(pathdir)? as f64;
150        if free_space < compression_algorithm.free_space_snapshot_ratio() * size as f64 {
151            return Err(CardanoDbDownloadCheckerError::NotEnoughSpaceForArchive {
152                left_space: free_space,
153                pathdir: pathdir.to_owned(),
154                archive_size: size as f64,
155            }
156            .into());
157        }
158        Ok(())
159    }
160
161    fn check_disk_space_for_uncompressed_data(pathdir: &Path, size: u64) -> MithrilResult<()> {
162        let free_space = fs2::available_space(pathdir)?;
163        if free_space < size {
164            return Err(
165                CardanoDbDownloadCheckerError::NotEnoughSpaceForUncompressedData {
166                    left_space: free_space as f64,
167                    pathdir: pathdir.to_owned(),
168                    db_size: size as f64,
169                }
170                .into(),
171            );
172        }
173        Ok(())
174    }
175}
176
177#[cfg(test)]
178mod test {
179    use mithril_common::test_utils::TempDir;
180
181    use super::*;
182
183    fn create_temporary_empty_directory(name: &str) -> PathBuf {
184        TempDir::create("client-cli-unpacker", name)
185    }
186
187    #[test]
188    fn create_directory_if_it_doesnt_exist() {
189        let pathdir =
190            create_temporary_empty_directory("directory_does_not_exist").join("target_directory");
191
192        CardanoDbDownloadChecker::ensure_dir_exist(&pathdir)
193            .expect("ensure_dir_exist should not fail");
194
195        assert!(pathdir.exists());
196    }
197
198    #[test]
199    fn return_error_if_path_is_a_file() {
200        let pathdir =
201            create_temporary_empty_directory("fail_if_pathdir_is_file").join("target_directory");
202        fs::File::create(&pathdir).unwrap();
203
204        CardanoDbDownloadChecker::check_path_is_an_empty_dir(&pathdir)
205            .expect_err("check_path_is_an_empty_dir should fail");
206    }
207
208    #[test]
209    fn return_ok_if_directory_exist_and_empty() {
210        let pathdir =
211            create_temporary_empty_directory("existing_directory").join("target_directory");
212        fs::create_dir_all(&pathdir).unwrap();
213
214        CardanoDbDownloadChecker::check_path_is_an_empty_dir(&pathdir)
215            .expect("check_path_is_an_empty_dir should not fail");
216    }
217
218    #[test]
219    fn return_error_if_directory_exists_and_not_empty() {
220        let pathdir = create_temporary_empty_directory("existing_directory_not_empty");
221        fs::create_dir_all(&pathdir).unwrap();
222        fs::File::create(pathdir.join("file.txt")).unwrap();
223
224        let error = CardanoDbDownloadChecker::check_path_is_an_empty_dir(&pathdir)
225            .expect_err("check_path_is_an_empty_dir should fail");
226
227        assert!(
228            matches!(
229                error.downcast_ref::<CardanoDbDownloadCheckerError>(),
230                Some(CardanoDbDownloadCheckerError::UnpackDirectoryNotEmpty(_))
231            ),
232            "Unexpected error: {error:?}"
233        );
234    }
235
236    #[test]
237    fn return_error_if_not_enough_available_space_for_archive() {
238        let pathdir = create_temporary_empty_directory("not_enough_available_space_for_archive")
239            .join("target_directory");
240        fs::create_dir_all(&pathdir).unwrap();
241        let archive_size = u64::MAX;
242
243        let error = CardanoDbDownloadChecker::check_disk_space_for_archive(
244            &pathdir,
245            archive_size,
246            CompressionAlgorithm::default(),
247        )
248        .expect_err("check_disk_space_for_archive should fail");
249
250        assert!(
251            matches!(
252                error.downcast_ref::<CardanoDbDownloadCheckerError>(),
253                Some(CardanoDbDownloadCheckerError::NotEnoughSpaceForArchive {
254                    left_space: _,
255                    pathdir: _,
256                    archive_size: _
257                })
258            ),
259            "Unexpected error: {error:?}"
260        );
261    }
262
263    #[test]
264    fn return_ok_if_enough_available_space_for_archive() {
265        let pathdir = create_temporary_empty_directory("enough_available_space_for_archive")
266            .join("target_directory");
267        fs::create_dir_all(&pathdir).unwrap();
268        let archive_size = 3;
269
270        CardanoDbDownloadChecker::check_disk_space_for_archive(
271            &pathdir,
272            archive_size,
273            CompressionAlgorithm::default(),
274        )
275        .expect("check_disk_space_for_archive should not fail");
276    }
277
278    #[test]
279    fn check_disk_space_for_uncompressed_data_return_error_if_not_enough_available_space() {
280        let pathdir =
281            create_temporary_empty_directory("not_enough_available_space_for_uncompressed_data")
282                .join("target_directory");
283        fs::create_dir_all(&pathdir).unwrap();
284        let uncompressed_data_size = u64::MAX;
285
286        let error = CardanoDbDownloadChecker::check_disk_space_for_uncompressed_data(
287            &pathdir,
288            uncompressed_data_size,
289        )
290        .expect_err("check_disk_space_for_uncompressed_data should fail");
291
292        assert!(
293            matches!(
294                error.downcast_ref::<CardanoDbDownloadCheckerError>(),
295                Some(
296                    CardanoDbDownloadCheckerError::NotEnoughSpaceForUncompressedData {
297                        left_space: _,
298                        pathdir: _,
299                        db_size: _
300                    }
301                )
302            ),
303            "Unexpected error: {error:?}"
304        );
305    }
306
307    #[test]
308    fn return_ok_if_enough_available_space_for_uncompressed_data() {
309        let pathdir =
310            create_temporary_empty_directory("enough_available_space_for_uncompressed_data")
311                .join("target_directory");
312        fs::create_dir_all(&pathdir).unwrap();
313        let uncompressed_data_size = 3;
314
315        CardanoDbDownloadChecker::check_disk_space_for_uncompressed_data(
316            &pathdir,
317            uncompressed_data_size,
318        )
319        .expect("check_disk_space_for_uncompressed_data should not fail");
320    }
321
322    #[test]
323    fn check_prerequisites_for_uncompressed_data_return_error_without_allow_override_and_directory_not_empty()
324     {
325        let pathdir = create_temporary_empty_directory(
326            "return_error_without_allow_override_and_directory_not_empty",
327        );
328        fs::create_dir_all(&pathdir).unwrap();
329        fs::File::create(pathdir.join("file.txt")).unwrap();
330
331        let error =
332            CardanoDbDownloadChecker::check_prerequisites_for_uncompressed_data(&pathdir, 0, false)
333                .expect_err("check_prerequisites_for_uncompressed_data should fail");
334
335        assert!(
336            matches!(
337                error.downcast_ref::<CardanoDbDownloadCheckerError>(),
338                Some(CardanoDbDownloadCheckerError::UnpackDirectoryNotEmpty(_))
339            ),
340            "Unexpected error: {error:?}"
341        );
342    }
343
344    #[test]
345    fn check_prerequisites_for_uncompressed_data_do_not_return_error_without_allow_override_and_directory_not_empty()
346     {
347        let pathdir = create_temporary_empty_directory(
348            "do_not_return_error_without_allow_override_and_directory_not_empty",
349        );
350        fs::create_dir_all(&pathdir).unwrap();
351        fs::File::create(pathdir.join("file.txt")).unwrap();
352
353        CardanoDbDownloadChecker::check_prerequisites_for_uncompressed_data(&pathdir, 0, true)
354            .expect("check_prerequisites_for_uncompressed_data should not fail");
355    }
356
357    // Those test are not on Windows because `set_readonly` is ignored for directories on Windows 7+
358    // https://doc.rust-lang.org/std/fs/struct.Permissions.html#method.set_readonly
359    #[cfg(not(target_os = "windows"))]
360    mod unix_only {
361        use super::*;
362
363        fn make_readonly(path: &Path) {
364            let mut perms = fs::metadata(path).unwrap().permissions();
365            perms.set_readonly(true);
366            fs::set_permissions(path, perms).unwrap();
367        }
368
369        #[test]
370        fn return_error_if_directory_could_not_be_created() {
371            let pathdir = create_temporary_empty_directory("read_only_directory");
372            let targetdir = pathdir.join("target_directory");
373            make_readonly(&pathdir);
374
375            let error = CardanoDbDownloadChecker::ensure_dir_exist(&targetdir)
376                .expect_err("ensure_dir_exist should fail");
377
378            assert!(
379                matches!(
380                    error.downcast_ref::<CardanoDbDownloadCheckerError>(),
381                    Some(CardanoDbDownloadCheckerError::UnpackDirectoryIsNotWritable(
382                        _,
383                        _
384                    ))
385                ),
386                "Unexpected error: {error:?}"
387            );
388        }
389
390        // This test is not run on Windows because `set_readonly` is ignored for directory on Windows 7+
391        // https://doc.rust-lang.org/std/fs/struct.Permissions.html#method.set_readonly
392        #[test]
393        fn return_error_if_existing_directory_is_not_writable() {
394            let pathdir =
395                create_temporary_empty_directory("existing_directory_not_writable").join("db");
396            fs::create_dir(&pathdir).unwrap();
397            make_readonly(&pathdir);
398
399            let error = CardanoDbDownloadChecker::check_dir_writable(&pathdir)
400                .expect_err("check_dir_writable should fail");
401
402            assert!(
403                matches!(
404                    error.downcast_ref::<CardanoDbDownloadCheckerError>(),
405                    Some(CardanoDbDownloadCheckerError::UnpackDirectoryIsNotWritable(
406                        _,
407                        _
408                    ))
409                ),
410                "Unexpected error: {error:?}"
411            );
412        }
413    }
414}