filesystem_hashing/
snapshot.rs

1use crate::hasher::{hash_file, HashType};
2use anyhow::{anyhow, Error};
3use bytes::BytesMut;
4use chrono::Utc;
5use rand::{thread_rng, Rng};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fs::File;
9use std::io::Write;
10use std::path::Path;
11use std::sync::{Arc, Mutex};
12use std::thread::JoinHandle;
13use std::{env, fs, thread};
14use walkdir::DirEntry;
15
16#[derive(Debug, Clone)]
17pub struct Snapshot {
18    pub file_hashes: Arc<Mutex<HashMap<String, FileMetadata>>>,
19    pub black_list: Vec<String>,
20    pub root_path: String,
21    pub hash_type: HashType,
22    pub uuid: String,
23    pub date_created: i64,
24}
25#[derive(Debug, Clone, Deserialize, Serialize)]
26pub struct FileMetadata {
27    pub path: String,
28    pub check_sum: Vec<u8>,
29    pub size: u64,
30    pub ino: u64,
31    pub ctime: i64,
32    pub mtime: i64,
33}
34
35impl Default for FileMetadata {
36    fn default() -> Self {
37        FileMetadata {
38            path: "".to_string(),
39            check_sum: vec![],
40            size: 0,
41            ino: 0,
42            ctime: 0,
43            mtime: 0,
44        }
45    }
46}
47
48impl Snapshot {
49    pub fn new(
50        path: &Path,
51        hash_type: HashType,
52        black_list: Vec<String>,
53        verbose: bool,
54    ) -> Result<Snapshot, Error> {
55        let root_path = match path.to_str() {
56            None => "".to_string(),
57            Some(p) => p.to_string(),
58        };
59        let mut rand = thread_rng();
60        let uuid_int: u128 = rand.gen();
61        let uuid = uuid_int.to_string();
62        if verbose {
63            println!("Walking Directory: {}", path.to_str().unwrap());
64        }
65
66        let file_paths = walkdir::WalkDir::new(path).sort_by_file_name();
67        let file_hashes: Arc<Mutex<HashMap<String, FileMetadata>>> =
68            Arc::new(Mutex::new(HashMap::new()));
69        let mut pool: Vec<JoinHandle<()>> = vec![];
70
71        let mut paths: Vec<Option<DirEntry>> = vec![];
72        file_paths
73            .into_iter()
74            .flatten()
75            .for_each(|a| paths.push(Option::from(a)));
76
77        if verbose {
78            println!("Skipping (Blacklisted): {:?}", black_list);
79        }
80        
81        while !paths.is_empty() {
82            #[allow(clippy::collapsible_match)]
83            if let Some(p) = paths.pop() {
84                if let Some(p) = p {
85                    let mut blacklisted = false;
86                    black_list.iter().for_each(|bl| {
87                        if let Some(a) = p.path().to_str() {
88                            if a.starts_with(bl) {
89                                blacklisted = true;
90                            }
91                        }
92                    });
93
94                    if p.path().is_file() && !blacklisted {
95                        let bind = file_hashes.clone();
96                        let handle = thread::spawn(move || {
97                            let mut binding = bind.lock();
98                            let ht = binding.as_mut().expect("binding error");
99                            if let Err(e) = hash_file(p.path(), ht, hash_type, verbose) {
100                                println!("Warning: {e}")
101                            }
102                        });
103                        pool.push(handle);
104                        if pool.len() > 4 {
105                            if let Some(handle) = pool.pop() {
106                                handle.join().expect("could not join handle")
107                            }
108                        }
109                    }
110                }
111            }
112        }
113
114        for handle in pool {
115            handle.join().expect("could not join handle")
116        }
117
118        Ok(Snapshot {
119            file_hashes,
120            black_list,
121            root_path,
122            hash_type,
123            uuid,
124            date_created: Utc::now().timestamp(),
125        })
126    }
127}
128
129impl Default for Snapshot {
130    fn default() -> Self {
131        let black_list: Vec<String> = vec![];
132        Snapshot {
133            file_hashes: Arc::new(Mutex::new(HashMap::new())),
134            black_list,
135            root_path: "".to_string(),
136            hash_type: HashType::BLAKE3,
137            uuid: "".to_string(),
138            date_created: 0,
139        }
140    }
141}
142
143#[derive(Debug)]
144pub enum SnapshotChangeType {
145    None,
146    Created,
147    Deleted,
148    Changed,
149}
150
151#[derive(Debug)]
152pub struct SnapshotCompareResult {
153    pub created: Vec<String>,
154    pub deleted: Vec<String>,
155    pub changed: Vec<String>,
156}
157
158pub fn compare_hashes(
159    left: Snapshot,
160    right: Snapshot,
161    verbose: bool,
162) -> Option<(SnapshotChangeType, SnapshotCompareResult)> {
163    #[allow(unused)]
164    let success = true;
165    let mut created: Vec<String> = vec![];
166    let mut deleted: Vec<String> = vec![];
167    let mut changed: Vec<String> = vec![];
168
169    if let Ok(left_lock) = left.file_hashes.lock() {
170        // for each entry in the hash list
171        for left_entry in left_lock.iter() {
172            if let Ok(curr_lock) = right.file_hashes.lock() {
173                match curr_lock.get(left_entry.0) {
174                    // check for mis-matching checksum between L and R
175                    Some(right_entry) => {
176                        if !right_entry.check_sum.eq(&left_entry.1.check_sum) {
177                            changed.push(right_entry.path.to_string());
178                        }
179                    }
180                    // check for deletion == files that exist in L and missing from R
181                    None => {
182                        deleted.push(left_entry.0.to_string());
183                    }
184                }
185            }
186        }
187    }
188    // check for creation == check for files that exist in R but do not exist in L
189    if let Ok(e) = right.file_hashes.lock() {
190        for right_entry in e.iter() {
191            if left.file_hashes.lock().ok()?.get(right_entry.0).is_none() {
192                created.push(right_entry.0.to_string());
193            }
194        }
195    }
196
197    let mut return_type = SnapshotChangeType::None;
198    if !created.is_empty() {
199        return_type = SnapshotChangeType::Created;
200    }
201    if !deleted.is_empty() {
202        return_type = SnapshotChangeType::Deleted;
203    }
204    if !changed.is_empty() {
205        return_type = SnapshotChangeType::Changed;
206    }
207
208    Some((
209        return_type,
210        SnapshotCompareResult {
211            created,
212            deleted,
213            changed,
214        },
215    ))
216}
217
218pub fn compare_hashes_and_modify_date(
219    left: Snapshot,
220    right: Snapshot,
221) -> Option<(SnapshotChangeType, SnapshotCompareResult)> {
222    #[allow(unused)]
223    let success = true;
224    let mut created: Vec<String> = vec![];
225    let mut deleted: Vec<String> = vec![];
226    let mut changed: Vec<String> = vec![];
227
228    if let Ok(left_lock) = left.file_hashes.lock() {
229        // for each entry in the hash list
230        for left_entry in left_lock.iter() {
231            if let Ok(curr_lock) = right.file_hashes.lock() {
232                match curr_lock.get(left_entry.0) {
233                    // check for mis-matching checksum between L and R
234                    Some(right_entry) => {
235                        // compare hashsum
236                        if !right_entry.check_sum.eq(&left_entry.1.check_sum) {
237                            changed.push(right_entry.path.to_string());
238                        }
239                        // compare modify date
240                        if !right_entry.mtime.eq(&left_entry.1.mtime)
241                            || !right_entry.ctime.eq(&left_entry.1.ctime)
242                        {
243                            changed.push(right_entry.path.to_string());
244                        }
245                    }
246                    // check for deletion == files that exist in L and missing from R
247                    None => {
248                        deleted.push(left_entry.0.to_string());
249                    }
250                }
251            }
252        }
253    }
254
255    // check for creation == check for files that exist in R but do not exist in L
256    if let Ok(e) = right.file_hashes.lock() {
257        for right_entry in e.iter() {
258            if left.file_hashes.lock().ok()?.get(right_entry.0).is_none() {
259                created.push(right_entry.0.to_string());
260            }
261        }
262    }
263
264    let mut return_type = SnapshotChangeType::None;
265    if !created.is_empty() {
266        return_type = SnapshotChangeType::Created;
267    }
268    if !deleted.is_empty() {
269        return_type = SnapshotChangeType::Deleted;
270    }
271    if !changed.is_empty() {
272        return_type = SnapshotChangeType::Changed;
273    }
274
275    Some((
276        return_type,
277        SnapshotCompareResult {
278            created,
279            deleted,
280            changed,
281        },
282    ))
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286struct SerializableSnapshot {
287    pub file_hashes: Vec<FileMetadata>,
288    pub root_path: String,
289    pub hash_type: HashType,
290    pub uuid: String,
291    pub date_created: i64,
292}
293
294fn path_resolve(path: String) -> String {
295    #[allow(unused)]
296    let mut full_path = String::new();
297    if path.starts_with("./") {
298        let mut cur_dir: String = match env::current_dir() {
299            Ok(pb) => match pb.to_str() {
300                None => String::new(),
301                Some(str) => str.to_string(),
302            },
303            Err(_) => String::new(),
304        };
305        cur_dir.push('/');
306        full_path = path.replace("./", cur_dir.as_str());
307    } else {
308        full_path = path.to_string();
309    }
310    full_path
311}
312
313pub fn export(snapshot: Snapshot, path: String, overwrite: bool, verbose: bool) -> Result<(), Error> {
314    let full_path = path_resolve(path);
315
316    let mut fh: Vec<FileMetadata> = vec![];
317
318    if let Ok(unlocked) = snapshot.file_hashes.lock() {
319        for entry in unlocked.iter() {
320            fh.push(entry.1.clone())
321        }
322    }
323
324    let serializable = SerializableSnapshot {
325        file_hashes: fh,
326        root_path: snapshot.root_path,
327        hash_type: snapshot.hash_type,
328        uuid: snapshot.uuid,
329        date_created: snapshot.date_created,
330    };
331
332    let serialized = serde_json::to_string(&serializable)?;
333    // println!("{:#?}", serialized);
334    let filename = full_path
335        .split('/')
336        .last()
337        .expect("unable to get full path");
338    let path_only = full_path.replace(filename, "");
339    // println!("{}", full_path);
340    // println!("{}", path_only);
341
342    if Path::new(&full_path).exists() && overwrite {
343        fs::remove_file(&full_path)?;
344        write_to_file(path_only, full_path, serialized)?
345    } else if !Path::new(&full_path).exists() {
346        write_to_file(path_only, full_path, serialized)?
347    };
348    Ok(())
349}
350
351fn write_to_file(path_only: String, full_path: String, serialized: String) -> Result<(), Error> {
352    if fs::create_dir_all(&path_only).is_ok() {
353        if let Ok(mut file_handle) = File::create(full_path) {
354            Ok(file_handle.write_all(serialized.as_bytes())?)
355        } else {
356            Err(anyhow!("Unable to write to path: {}", path_only.clone()))
357        }
358    } else {
359        Err(anyhow!("Unable to write to path: {}", path_only.clone()))
360    }
361}
362
363pub fn import(path: String, verbose: bool) -> Result<Snapshot, Error> {
364    #[allow(unused)]
365    let buffer = BytesMut::new();
366    let full_path = path_resolve(path);
367    if let Ok(bytes) = fs::read(full_path) {
368        let snapshot = serde_json::from_slice::<SerializableSnapshot>(&bytes)?;
369
370        let mut fh: HashMap<String, FileMetadata> = HashMap::new();
371
372        if verbose {
373            for entry in snapshot.file_hashes {
374                if let Some(_res) = fh.insert(entry.path.clone(), entry.clone()) {
375                    println!("successfully imported: {}", entry.path);
376                }
377            }
378        }
379
380
381        let black_list: Vec<String> = vec![];
382        Ok(Snapshot {
383            file_hashes: Arc::new(Mutex::new(fh)),
384            black_list,
385            root_path: snapshot.root_path,
386            hash_type: snapshot.hash_type,
387            uuid: snapshot.uuid,
388            date_created: snapshot.date_created,
389        })
390    } else {
391        Ok(Snapshot::default())
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::{compare_snapshots, compare_snapshots_including_modify_date};
399    use std::fs;
400    use std::fs::File;
401    use std::path::Path;
402
403    #[test]
404    fn dangerous() {
405        let mut snap = Snapshot::new(
406            Path::new("/proc"),
407            HashType::BLAKE3,
408            vec![
409                "testkey".to_string(),
410                "/dev".to_string(),
411                "/proc".to_string(),
412                "/tmp".to_string(),
413            ],
414            true,
415        );
416        assert!(snap.is_ok());
417
418        let mut snap = Snapshot::new(
419            Path::new("/dev"),
420            HashType::BLAKE3,
421            vec![
422                "testkey".to_string(),
423                "/dev".to_string(),
424                "/proc".to_string(),
425                "/tmp".to_string(),
426            ],
427            true,
428        );
429        assert!(snap.is_ok());
430
431        let mut snap = Snapshot::new(
432            Path::new("/tmp"),
433            HashType::BLAKE3,
434            vec![
435                "testkey".to_string(),
436                "/dev".to_string(),
437                "/proc".to_string(),
438                "/tmp".to_string(),
439            ],
440            true,
441        );
442        assert!(snap.is_ok());
443    }
444
445    #[test]
446    fn blacklist() {
447        let mut snap = Snapshot::new(
448            Path::new("/etc"),
449            HashType::BLAKE3,
450            vec!["testkey".to_string()],
451            true,
452        )
453        .unwrap();
454        println!("{:#?}", snap.clone().black_list);
455        assert_eq!(snap.black_list.pop().unwrap(), "testkey".to_string());
456    }
457
458    #[test]
459    fn create_snapshot_blake3() {
460        let test_snap_b3 = Snapshot::new(Path::new("/etc"), HashType::BLAKE3, vec![], false);
461        assert!(test_snap_b3.unwrap().file_hashes.lock().unwrap().len() > 0);
462    }
463    #[test]
464    fn create_snapshot_md5() {
465        let test_snap_md5 = Snapshot::new(Path::new("/etc"), HashType::MD5, vec![], true);
466        assert!(test_snap_md5.unwrap().file_hashes.lock().unwrap().len() > 0);
467    }
468    #[test]
469    fn create_snapshot_sha3() {
470        let test_snap_sha3 = Snapshot::new(Path::new("/etc"), HashType::SHA3, vec![], true);
471        assert!(test_snap_sha3.unwrap().file_hashes.lock().unwrap().len() > 0);
472    }
473
474    #[test]
475    fn export_snapshot() {
476        assert!(!Path::new("./target/build/out.snapshot").exists());
477        let test_snap_export = Snapshot::new(Path::new("/etc"), HashType::BLAKE3, vec![], true);
478        let _ = export(
479            test_snap_export.unwrap().clone(),
480            "./target/build/out.snapshot".to_string(),
481            true,
482            true,
483        );
484        assert!(Path::new("./target/build/out.snapshot").exists());
485        fs::remove_file(Path::new("./target/build/out.snapshot")).unwrap();
486    }
487
488    #[test]
489    fn import_snapshot() {
490        let test_snap_import = Snapshot::new(Path::new("/etc"), HashType::BLAKE3, vec![], true);
491        let _ = export(
492            test_snap_import.unwrap(),
493            "./target/build/in.snapshot".to_string(),
494            true,
495            true,
496        );
497        let snapshot = import("./target/build/in.snapshot".to_string(), true);
498        assert!(snapshot.unwrap().file_hashes.lock().unwrap().len() > 0);
499        fs::remove_file(Path::new("./target/build/in.snapshot")).unwrap();
500    }
501
502    #[test]
503    fn creation_detection() {
504        assert!(!Path::new("./target/build/test_creation/").exists());
505        fs::create_dir_all(Path::new("./target/build/test_creation/")).unwrap();
506        let test_snap_creation_1 = Snapshot::new(
507            Path::new("./target/build/test_creation/"),
508            HashType::BLAKE3,
509            vec![],
510            true,
511        );
512        File::create(Path::new("./target/build/test_creation/test1")).unwrap();
513        File::create(Path::new("./target/build/test_creation/test2")).unwrap();
514        File::create(Path::new("./target/build/test_creation/test3")).unwrap();
515        let test_snap_creation_2 = Snapshot::new(
516            Path::new("./target/build/test_creation/"),
517            HashType::BLAKE3,
518            vec![],
519            true,
520        );
521        assert_eq!(
522            compare_snapshots(test_snap_creation_1.unwrap(), test_snap_creation_2.unwrap(), true)
523                .unwrap()
524                .1
525                .created
526                .len(),
527            3
528        );
529        fs::remove_dir_all(Path::new("./target/build/test_creation/")).unwrap();
530    }
531
532    #[test]
533    fn deletion_detection() {
534        assert!(!Path::new("./target/build/test_deletion/").exists());
535        fs::create_dir_all(Path::new("./target/build/test_deletion/")).unwrap();
536        let test_snap_deletion_1 = Snapshot::new(
537            Path::new("./target/build/test_deletion/"),
538            HashType::BLAKE3,
539            vec![],
540            true,
541        );
542        File::create(Path::new("./target/build/test_deletion/test1")).unwrap();
543        File::create(Path::new("./target/build/test_deletion/test2")).unwrap();
544        File::create(Path::new("./target/build/test_deletion/test3")).unwrap();
545        let test_snap_deletion_2 = Snapshot::new(
546            Path::new("./target/build/test_deletion/"),
547            HashType::BLAKE3,
548            vec![],
549            true,
550        );
551        assert_eq!(
552            compare_snapshots(test_snap_deletion_2.unwrap(), test_snap_deletion_1.unwrap(), true)
553                .unwrap()
554                .1
555                .deleted
556                .len(),
557            3
558        );
559        fs::remove_dir_all(Path::new("./target/build/test_deletion/")).unwrap();
560    }
561
562    #[test]
563    fn change_detection() {
564        assert!(!Path::new("./target/build/test_change/").exists());
565        fs::create_dir_all(Path::new("./target/build/test_change/")).unwrap();
566        let mut file1 = File::create(Path::new("./target/build/test_change/test1")).unwrap();
567        let mut file2 = File::create(Path::new("./target/build/test_change/test2")).unwrap();
568        let mut file3 = File::create(Path::new("./target/build/test_change/test3")).unwrap();
569        let test_snap_change_1 = Snapshot::new(
570            Path::new("./target/build/test_change/"),
571            HashType::BLAKE3,
572            vec![],
573            true,
574        );
575        file1.write_all("file1".as_bytes()).unwrap();
576        file2.write_all("file2".as_bytes()).unwrap();
577        file3.write_all("file3".as_bytes()).unwrap();
578        let test_snap_change_2 = Snapshot::new(
579            Path::new("./target/build/test_change/"),
580            HashType::BLAKE3,
581            vec![],
582            true,
583        );
584        assert_eq!(
585            compare_snapshots(test_snap_change_1.unwrap(), test_snap_change_2.unwrap(), true)
586                .unwrap()
587                .1
588                .changed
589                .len(),
590            3
591        );
592        fs::remove_dir_all(Path::new("./target/build/test_change/")).unwrap();
593    }
594
595    #[test]
596    fn change_detection_including_modify_date() {
597        assert!(!Path::new("./target/build/test_change_modify/").exists());
598        fs::create_dir_all(Path::new("./target/build/test_change_modify/")).unwrap();
599        let _ = File::create(Path::new("./target/build/test_change_modify/test1")).unwrap();
600        let _ = File::create(Path::new("./target/build/test_change_modify/test2")).unwrap();
601        let _ = File::create(Path::new("./target/build/test_change_modify/test3")).unwrap();
602        let test_snap_change_1 = Snapshot::new(
603            Path::new("./target/build/test_change_modify/"),
604            HashType::BLAKE3,
605            vec![],
606            true,
607        );
608        let _ = fs::remove_file(Path::new("./target/build/test_change_modify/test1")).unwrap();
609        let _ = fs::remove_file(Path::new("./target/build/test_change_modify/test2")).unwrap();
610        let _ = fs::remove_file(Path::new("./target/build/test_change_modify/test3")).unwrap();
611
612        let _ = File::create(Path::new("./target/build/test_change_modify/test1")).unwrap();
613        let _ = File::create(Path::new("./target/build/test_change_modify/test2")).unwrap();
614        let _ = File::create(Path::new("./target/build/test_change_modify/test3")).unwrap();
615        let test_snap_change_2 = Snapshot::new(
616            Path::new("./target/build/test_change_modify/"),
617            HashType::BLAKE3,
618            vec![],
619            true,
620        );
621        assert_ne!(
622            compare_snapshots_including_modify_date(
623                test_snap_change_1.unwrap(),
624                test_snap_change_2.unwrap(),
625                true
626            )
627            .unwrap()
628            .1
629            .changed
630            .len(),
631            3
632        );
633        fs::remove_dir_all(Path::new("./target/build/test_change_modify/")).unwrap();
634    }
635}