macos_unifiedlogs/
filesystem.rs

1use crate::dsc::SharedCacheStrings;
2use crate::traits::{FileProvider, SourceFile};
3use crate::uuidtext::UUIDText;
4use log::error;
5use std::collections::HashMap;
6use std::fs::File;
7use std::io::{Error, ErrorKind};
8use std::path::{Component, Path, PathBuf};
9use walkdir::WalkDir;
10
11pub struct LocalFile {
12    reader: File,
13    source: String,
14}
15
16impl LocalFile {
17    fn new(path: &Path) -> std::io::Result<Self> {
18        Ok(Self {
19            reader: File::open(path)?,
20            source: path.as_os_str().to_string_lossy().to_string(),
21        })
22    }
23}
24
25impl SourceFile for LocalFile {
26    fn reader(&mut self) -> Box<&mut dyn std::io::Read> {
27        Box::new(&mut self.reader)
28    }
29
30    fn source_path(&self) -> &str {
31        self.source.as_str()
32    }
33}
34
35/// Provides an implementation of [`FileProvider`] that enumerates the
36/// required files at the correct paths on a live macOS system. These files are only present on
37/// macOS Sierra (10.12) and above. The implemented methods emit error log messages if any are
38/// encountered while enumerating files or creating readers, but are otherwise infallible.
39/// # Example
40/// ```rust
41///    use macos_unifiedlogs::filesystem::LiveSystemProvider;
42///    let provider = LiveSystemProvider::default();
43/// ```
44#[derive(Default, Debug)]
45pub struct LiveSystemProvider {
46    pub(crate) uuidtext_cache: HashMap<String, UUIDText>,
47    pub(crate) dsc_cache: HashMap<String, SharedCacheStrings>,
48}
49
50impl LiveSystemProvider {
51    pub fn new() -> Self {
52        Self {
53            uuidtext_cache: HashMap::new(),
54            dsc_cache: HashMap::new(),
55        }
56    }
57}
58
59static TRACE_FOLDERS: &[&str] = &["HighVolume", "Special", "Signpost", "Persist"];
60
61#[derive(Debug, PartialEq)]
62pub enum LogFileType {
63    TraceV3,
64    UUIDText,
65    Dsc,
66    Timesync,
67    Invalid,
68}
69
70fn only_hex_chars(val: &str) -> bool {
71    val.chars().all(|c| c.is_ascii_hexdigit())
72}
73
74impl From<&Path> for LogFileType {
75    fn from(path: &Path) -> Self {
76        let components = path.components().collect::<Vec<Component<'_>>>();
77        let n = components.len();
78
79        if let (Some(&Component::Normal(parent)), Some(&Component::Normal(filename))) =
80            (components.get(n - 2), components.get(n - 1))
81        {
82            let parent_s = parent.to_str().unwrap_or_default();
83            let filename_s = filename.to_str().unwrap_or_default();
84
85            if filename_s == "logdata.LiveData.tracev3"
86                || (filename_s.ends_with(".tracev3") && TRACE_FOLDERS.contains(&parent_s))
87            {
88                return Self::TraceV3;
89            }
90
91            if filename_s.len() == 30
92                && only_hex_chars(filename_s)
93                && parent_s.len() == 2
94                && only_hex_chars(parent_s)
95            {
96                return Self::UUIDText;
97            }
98
99            if filename_s.len() == 32 && only_hex_chars(filename_s) && parent_s == "dsc" {
100                return Self::Dsc;
101            }
102
103            if filename_s.ends_with(".timesync") && parent_s == "timesync" {
104                return Self::Timesync;
105            }
106        }
107
108        Self::Invalid
109    }
110}
111
112impl FileProvider for LiveSystemProvider {
113    fn tracev3_files(&self) -> Box<dyn Iterator<Item = Box<dyn SourceFile>>> {
114        let path = PathBuf::from("/private/var/db/diagnostics");
115        Box::new(
116            WalkDir::new(path)
117                .sort_by(|a, b| a.file_name().cmp(b.file_name()))
118                .into_iter()
119                .filter_map(|entry| entry.ok())
120                .filter(|entry| matches!(LogFileType::from(entry.path()), LogFileType::TraceV3))
121                .filter_map(|entry| {
122                    Some(Box::new(LocalFile::new(entry.path()).ok()?) as Box<dyn SourceFile>)
123                }),
124        )
125    }
126
127    fn uuidtext_files(&self) -> Box<dyn Iterator<Item = Box<dyn SourceFile>>> {
128        let path = PathBuf::from("/private/var/db/uuidtext");
129        Box::new(
130            WalkDir::new(path)
131                .into_iter()
132                .filter_map(|entry| entry.ok())
133                .filter(|entry| matches!(LogFileType::from(entry.path()), LogFileType::UUIDText))
134                .filter_map(|entry| {
135                    Some(Box::new(LocalFile::new(entry.path()).ok()?) as Box<dyn SourceFile>)
136                }),
137        )
138    }
139
140    fn read_uuidtext(&self, uuid: &str) -> Result<UUIDText, Error> {
141        let uuid_len = 32;
142        let uuid = if uuid.len() == uuid_len - 1 {
143            // UUID starts with 0 which was not included in the string
144            &format!("0{uuid}")
145        } else if uuid.len() == uuid_len - 2 {
146            // UUID starts with 00 which was not included in the string
147            &format!("00{uuid}")
148        } else if uuid.len() == uuid_len {
149            uuid
150        } else {
151            return Err(Error::new(
152                ErrorKind::NotFound,
153                format!("uuid length not correct: {uuid}"),
154            ));
155        };
156
157        let dir_name = format!("{}{}", &uuid[0..1], &uuid[1..2]);
158        let filename = &uuid[2..];
159
160        let mut path = PathBuf::from("/private/var/db/uuidtext");
161
162        path.push(dir_name);
163        path.push(filename);
164
165        let mut buf = Vec::new();
166        let mut file = LocalFile::new(&path)?;
167        file.reader().read_to_end(&mut buf)?;
168
169        let uuid_text = match UUIDText::parse_uuidtext(&buf) {
170            Ok((_, results)) => results,
171            Err(err) => {
172                error!(
173                    "[macos-unifiedlogs] Failed to parse UUID file {}: {err:?}",
174                    path.to_str().unwrap_or_default()
175                );
176                return Err(Error::new(
177                    ErrorKind::InvalidData,
178                    format!("failed to read: {uuid}"),
179                ));
180            }
181        };
182
183        Ok(uuid_text)
184    }
185
186    fn cached_uuidtext(&self, uuid: &str) -> Option<&UUIDText> {
187        self.uuidtext_cache.get(uuid)
188    }
189
190    fn update_uuid(&mut self, uuid: &str, uuid2: &str) {
191        let status = match self.read_uuidtext(uuid) {
192            Ok(result) => result,
193            Err(_err) => return,
194        };
195        // Keep a cache of 30 UUIDText files
196        if self.uuidtext_cache.len() > 30 {
197            for key in self
198                .uuidtext_cache
199                .keys()
200                .take(5)
201                .cloned()
202                .collect::<Vec<String>>()
203            {
204                if key == uuid || key == uuid2 {
205                    continue;
206                }
207                let key = key.clone();
208                self.uuidtext_cache.remove(&key);
209            }
210        }
211        self.uuidtext_cache.insert(uuid.to_string(), status);
212    }
213
214    fn update_dsc(&mut self, uuid: &str, uuid2: &str) {
215        let status = match self.read_dsc_uuid(uuid) {
216            Ok(result) => result,
217            Err(_err) => return,
218        };
219        // Keep a cache of 2 DSC UUID files. These files are larger than typical UUID files. ~30MB - ~150MB
220        // However, there are only a few of them. ~5 - 6
221        while self.dsc_cache.len() > 2 {
222            if let Some(key) = self.dsc_cache.keys().next() {
223                if key == uuid || key == uuid2 {
224                    continue;
225                }
226                let key = key.clone();
227                self.dsc_cache.remove(&key);
228            }
229        }
230        self.dsc_cache.insert(uuid.to_string(), status);
231    }
232
233    fn cached_dsc(&self, uuid: &str) -> Option<&SharedCacheStrings> {
234        self.dsc_cache.get(uuid)
235    }
236
237    fn read_dsc_uuid(&self, uuid: &str) -> Result<SharedCacheStrings, Error> {
238        let uuid_len = 32;
239        let uuid = if uuid.len() == uuid_len - 1 {
240            // UUID starts with 0 which was not included in the string
241            &format!("0{uuid}")
242        } else if uuid.len() == uuid_len - 2 {
243            // UUID starts with 00 which was not included in the string
244            &format!("00{uuid}")
245        } else if uuid.len() == uuid_len {
246            uuid
247        } else {
248            return Err(Error::new(
249                ErrorKind::NotFound,
250                format!("uuid length not correct: {uuid}"),
251            ));
252        };
253
254        let mut path = PathBuf::from("/private/var/db/uuidtext/dsc");
255        path.push(uuid);
256
257        let mut buf = Vec::new();
258        let mut file = LocalFile::new(&path)?;
259        file.reader().read_to_end(&mut buf)?;
260
261        let uuid_text = match SharedCacheStrings::parse_dsc(&buf) {
262            Ok((_, results)) => results,
263            Err(err) => {
264                error!(
265                    "[macos-unifiedlogs] Failed to parse dsc UUID file {}: {err:?}",
266                    path.to_str().unwrap_or_default(),
267                );
268                return Err(Error::new(
269                    ErrorKind::InvalidData,
270                    format!("failed to read: {uuid}"),
271                ));
272            }
273        };
274
275        Ok(uuid_text)
276    }
277
278    fn dsc_files(&self) -> Box<dyn Iterator<Item = Box<dyn SourceFile>>> {
279        let path = PathBuf::from("/private/var/db/uuidtext/dsc");
280        Box::new(WalkDir::new(path).into_iter().filter_map(|entry| {
281            if !matches!(
282                LogFileType::from(entry.as_ref().ok()?.path()),
283                LogFileType::Dsc
284            ) {
285                return None;
286            }
287            Some(Box::new(LocalFile::new(entry.ok()?.path()).ok()?) as Box<dyn SourceFile>)
288        }))
289    }
290
291    fn timesync_files(&self) -> Box<dyn Iterator<Item = Box<dyn SourceFile>>> {
292        let path = PathBuf::from("/private/var/db/diagnostics/timesync");
293        Box::new(
294            WalkDir::new(path)
295                .into_iter()
296                .filter_map(|entry| entry.ok())
297                .filter(|entry| matches!(LogFileType::from(entry.path()), LogFileType::Timesync))
298                .filter_map(|entry| {
299                    Some(Box::new(LocalFile::new(entry.path()).ok()?) as Box<dyn SourceFile>)
300                }),
301        )
302    }
303}
304
305/// Provides an implementation of [`FileProvider`] that enumerates the
306/// required files at the correct paths on a from a provided logarchive.
307/// # Example
308/// ```rust
309///    use macos_unifiedlogs::filesystem::LogarchiveProvider;
310///    use std::path::PathBuf;
311///
312///    let mut test_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
313///    test_path.push("tests/test_data/system_logs_big_sur.logarchive");
314///    let provider = LogarchiveProvider::new(test_path.as_path());
315/// ```
316pub struct LogarchiveProvider {
317    base: PathBuf,
318    pub(crate) uuidtext_cache: HashMap<String, UUIDText>,
319    pub(crate) dsc_cache: HashMap<String, SharedCacheStrings>,
320}
321
322impl LogarchiveProvider {
323    pub fn new(path: &Path) -> Self {
324        Self {
325            base: path.to_path_buf(),
326            uuidtext_cache: HashMap::new(),
327            dsc_cache: HashMap::new(),
328        }
329    }
330}
331
332impl FileProvider for LogarchiveProvider {
333    /// Provide iterator for tracev3 files
334    /// # Example
335    /// ```rust
336    ///    use macos_unifiedlogs::filesystem::LogarchiveProvider;
337    ///    use macos_unifiedlogs::traits::FileProvider;
338    ///    use macos_unifiedlogs::parser::collect_timesync;
339    ///    use std::path::PathBuf;
340    ///
341    ///    let mut test_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
342    ///    test_path.push("tests/test_data/system_logs_big_sur.logarchive");
343    ///    let provider = LogarchiveProvider::new(test_path.as_path());
344    ///    for mut entry in provider.tracev3_files() {
345    ///      println!("TraceV3 file: {}", entry.source_path());
346    ///    }
347    /// ```
348    fn tracev3_files(&self) -> Box<dyn Iterator<Item = Box<dyn SourceFile>>> {
349        Box::new(
350            WalkDir::new(&self.base)
351                .sort_by(|a, b| a.file_name().cmp(b.file_name()))
352                .into_iter()
353                .filter_map(|entry| entry.ok())
354                .filter(|entry| matches!(LogFileType::from(entry.path()), LogFileType::TraceV3))
355                .filter_map(|entry| {
356                    Some(Box::new(LocalFile::new(entry.path()).ok()?) as Box<dyn SourceFile>)
357                }),
358        )
359    }
360
361    fn uuidtext_files(&self) -> Box<dyn Iterator<Item = Box<dyn SourceFile>>> {
362        Box::new(
363            WalkDir::new(&self.base)
364                .into_iter()
365                .filter_map(|entry| entry.ok())
366                .filter(|entry| matches!(LogFileType::from(entry.path()), LogFileType::UUIDText))
367                .filter_map(|entry| {
368                    Some(Box::new(LocalFile::new(entry.path()).ok()?) as Box<dyn SourceFile>)
369                }),
370        )
371    }
372
373    fn read_uuidtext(&self, uuid: &str) -> Result<UUIDText, Error> {
374        let uuid_len = 32;
375        let uuid = if uuid.len() == uuid_len - 1 {
376            // UUID starts with 0 which was not included in the string
377            &format!("0{uuid}")
378        } else if uuid.len() == uuid_len - 2 {
379            // UUID starts with 00 which was not included in the string
380            &format!("00{uuid}")
381        } else if uuid.len() == uuid_len {
382            uuid
383        } else {
384            return Err(Error::new(
385                ErrorKind::NotFound,
386                format!("uuid length not correct: {uuid}"),
387            ));
388        };
389
390        let dir_name = format!("{}{}", &uuid[0..1], &uuid[1..2]);
391        let filename = &uuid[2..];
392
393        let mut base = self.base.clone();
394        base.push(dir_name);
395        base.push(filename);
396
397        let mut buf = Vec::new();
398        let mut file = LocalFile::new(&base)?;
399        file.reader().read_to_end(&mut buf)?;
400
401        let uuid_text = match UUIDText::parse_uuidtext(&buf) {
402            Ok((_, results)) => results,
403            Err(err) => {
404                error!(
405                    "[macos-unifiedlogs] Failed to parse UUID file {}: {err:?}",
406                    base.to_str().unwrap_or_default(),
407                );
408                return Err(Error::new(
409                    ErrorKind::InvalidData,
410                    format!("failed to read: {uuid}"),
411                ));
412            }
413        };
414
415        Ok(uuid_text)
416    }
417
418    fn read_dsc_uuid(&self, uuid: &str) -> Result<SharedCacheStrings, Error> {
419        let uuid_len = 32;
420        let uuid = if uuid.len() == uuid_len - 1 {
421            // UUID starts with 0 which was not included in the string
422            &format!("0{uuid}")
423        } else if uuid.len() == uuid_len - 2 {
424            // UUID starts with 00 which was not included in the string
425            &format!("00{uuid}")
426        } else if uuid.len() == uuid_len {
427            uuid
428        } else {
429            return Err(Error::new(
430                ErrorKind::NotFound,
431                format!("uuid length not correct: {uuid}"),
432            ));
433        };
434
435        let mut base = self.base.clone();
436        base.push("dsc");
437        base.push(uuid);
438
439        let mut buf = Vec::new();
440        let mut file = LocalFile::new(&base)?;
441        file.reader().read_to_end(&mut buf)?;
442
443        let uuid_text = match SharedCacheStrings::parse_dsc(&buf) {
444            Ok((_, results)) => results,
445            Err(err) => {
446                error!(
447                    "[macos-unifiedlogs] Failed to parse dsc UUID file {}: {err:?}",
448                    base.to_str().unwrap_or_default(),
449                );
450                return Err(Error::new(
451                    ErrorKind::InvalidData,
452                    format!("failed to read: {uuid}"),
453                ));
454            }
455        };
456
457        Ok(uuid_text)
458    }
459
460    fn cached_uuidtext(&self, uuid: &str) -> Option<&UUIDText> {
461        self.uuidtext_cache.get(uuid)
462    }
463
464    fn cached_dsc(&self, uuid: &str) -> Option<&SharedCacheStrings> {
465        self.dsc_cache.get(uuid)
466    }
467
468    fn dsc_files(&self) -> Box<dyn Iterator<Item = Box<dyn SourceFile>>> {
469        Box::new(
470            WalkDir::new(&self.base)
471                .into_iter()
472                .filter_map(|entry| entry.ok())
473                .filter(|entry| matches!(LogFileType::from(entry.path()), LogFileType::Dsc))
474                .filter_map(|entry| {
475                    Some(Box::new(LocalFile::new(entry.path()).ok()?) as Box<dyn SourceFile>)
476                }),
477        )
478    }
479
480    fn update_uuid(&mut self, uuid: &str, uuid2: &str) {
481        let status = match self.read_uuidtext(uuid) {
482            Ok(result) => result,
483            Err(_err) => return,
484        };
485        // Keep a cache of 30 UUIDText files
486        if self.uuidtext_cache.len() > 30 {
487            for key in self
488                .uuidtext_cache
489                .keys()
490                .take(5)
491                .cloned()
492                .collect::<Vec<String>>()
493            {
494                if key == uuid || key == uuid2 {
495                    continue;
496                }
497                let key = key.clone();
498                self.uuidtext_cache.remove(&key);
499            }
500        }
501        self.uuidtext_cache.insert(uuid.to_string(), status);
502    }
503
504    fn update_dsc(&mut self, uuid: &str, uuid2: &str) {
505        let status = match self.read_dsc_uuid(uuid) {
506            Ok(result) => result,
507            Err(_err) => return,
508        };
509        // Keep a cache of 2 DSC UUID files. These files are larger than typical UUID files. ~30MB - ~150MB
510        // However, there are only a few of them. ~5 - 6
511        while self.dsc_cache.len() > 2 {
512            if let Some(key) = self.dsc_cache.keys().next() {
513                if key == uuid || key == uuid2 {
514                    continue;
515                }
516                let key = key.clone();
517                self.dsc_cache.remove(&key);
518            }
519        }
520        self.dsc_cache.insert(uuid.to_string(), status);
521    }
522
523    fn timesync_files(&self) -> Box<dyn Iterator<Item = Box<dyn SourceFile>>> {
524        Box::new(
525            WalkDir::new(&self.base)
526                .into_iter()
527                .filter_map(|entry| entry.ok())
528                .filter(|entry| matches!(LogFileType::from(entry.path()), LogFileType::Timesync))
529                .filter_map(|entry| {
530                    Some(Box::new(LocalFile::new(entry.path()).ok()?) as Box<dyn SourceFile>)
531                }),
532        )
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::{LogFileType, LogarchiveProvider};
539    use crate::traits::FileProvider;
540    use std::path::PathBuf;
541
542    #[test]
543    fn test_only_hex() {
544        use super::only_hex_chars;
545
546        let cases = vec![
547            "A7563E1D7A043ED29587044987205172",
548            "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
549        ];
550
551        for case in cases {
552            assert!(only_hex_chars(case));
553        }
554    }
555
556    #[test]
557    fn test_validate_uuidtext_path() {
558        let valid_cases = vec![
559            "/private/var/db/uuidtext/dsc/A7563E1D7A043ED29587044987205172",
560            "/private/var/db/uuidtext/dsc/DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
561            "./dsc/A7563E1D7A043ED29587044987B05172",
562        ];
563
564        for case in valid_cases {
565            let path = PathBuf::from(case);
566            let file_type = LogFileType::from(path.as_path());
567            assert_eq!(file_type, LogFileType::Dsc);
568        }
569    }
570
571    #[test]
572    fn test_read_uuidtext() {
573        let mut test_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
574        test_path.push("tests/test_data/system_logs_big_sur.logarchive");
575        let provider = LogarchiveProvider::new(test_path.as_path());
576        let uuid = provider
577            .read_uuidtext("25A8CFC3A9C035F19DBDC16F994EA948")
578            .unwrap();
579        assert_eq!(uuid.entry_descriptors.len(), 2);
580        assert_eq!(uuid.uuid, "");
581        assert_eq!(uuid.footer_data.len(), 76544);
582        assert_eq!(uuid.signature, 1719109785);
583        assert_eq!(uuid.unknown_major_version, 2);
584        assert_eq!(uuid.unknown_minor_version, 1);
585        assert_eq!(uuid.number_entries, 2);
586    }
587
588    #[test]
589    fn test_read_dsc_uuid() {
590        let mut test_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
591        test_path.push("tests/test_data/system_logs_big_sur.logarchive");
592        let provider = LogarchiveProvider::new(test_path.as_path());
593        let uuid = provider
594            .read_dsc_uuid("80896B329EB13A10A7C5449B15305DE2")
595            .unwrap();
596        assert_eq!(uuid.dsc_uuid, "");
597        assert_eq!(uuid.major_version, 1);
598        assert_eq!(uuid.minor_version, 0);
599        assert_eq!(uuid.number_ranges, 2993);
600        assert_eq!(uuid.number_uuids, 1976);
601        assert_eq!(uuid.ranges.len(), 2993);
602        assert_eq!(uuid.uuids.len(), 1976);
603        assert_eq!(uuid.signature, 1685283688);
604    }
605
606    #[test]
607    fn test_validate_dsc_path() {}
608
609    #[test]
610    fn test_validate_timesync_path() {}
611
612    #[test]
613    fn test_validate_tracev3_path() {}
614}