Skip to main content

imessage_database/util/
path_hint.rs

1/*!
2 Diagnose why a database path cannot be opened by inspecting the filesystem around it.
3*/
4
5use std::{
6    ffi::OsString,
7    fmt::{Display, Formatter, Result as FmtResult},
8    fs,
9    io::{self, ErrorKind},
10    path::{Path, PathBuf},
11};
12
13use crate::{
14    tables::table::DEFAULT_PATH_IOS,
15    util::dirs::{default_backup_roots, default_db_path},
16};
17
18/// Host OS name for diagnostics.
19const OS_NAME: &str = if cfg!(target_os = "windows") {
20    "Windows"
21} else if cfg!(target_os = "linux") {
22    "Linux"
23} else {
24    std::env::consts::OS
25};
26
27/// Backup file index, present in every backup made since iOS 10, encrypted or not.
28const MANIFEST_DB: &str = "Manifest.db";
29/// Backup metadata written beside `Manifest.db`.
30const MANIFEST_PLIST: &str = "Manifest.plist";
31/// Device metadata written at the backup root.
32const INFO_PLIST: &str = "Info.plist";
33/// Subdirectory for per-device backups from Finder, iTunes, and Apple Devices.
34const BACKUP_DIR: &str = "Backup";
35/// macOS Messages database file name.
36const MACOS_DB: &str = "chat.db";
37
38/// Quote characters to recognize in path arguments, including literal single quotes from
39/// `cmd.exe` and curly quotes from word processors.
40const SHELL_QUOTES: [char; 6] = ['"', '\'', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'];
41
42/// Filesystem state of a path.
43#[derive(Debug, PartialEq, Eq)]
44pub enum Presence {
45    /// No entry at the path
46    Missing,
47    /// A regular file
48    File,
49    /// A directory
50    Directory,
51    /// Neither a regular file nor a directory
52    Other,
53}
54
55/// Distinguish a missing path from a filesystem access error.
56///
57/// With `Path::exists()`, you receive `false` for both a missing path and an unreadable path,
58/// including a backup under `~/Library` without Full Disk Access. Return
59/// [`Presence::Missing`] only for `NotFound` and `NotADirectory`; propagate all other errors.
60pub fn presence(path: &Path) -> io::Result<Presence> {
61    match fs::metadata(path) {
62        Ok(metadata) if metadata.is_file() => Ok(Presence::File),
63        Ok(metadata) if metadata.is_dir() => Ok(Presence::Directory),
64        Ok(_) => Ok(Presence::Other),
65        Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
66            Ok(Presence::Missing)
67        }
68        Err(error) => Err(error),
69    }
70}
71
72/// Diagnostic hints inferred from the filesystem around a failed path.
73#[derive(Debug, PartialEq, Eq)]
74pub enum PathHint {
75    /// Literal quote character at either end of the path
76    QuotedPath(char),
77    /// Default macOS database path on a host without a Messages app
78    NoLocalDatabase,
79    /// iOS backup directories found at these paths
80    BackupsFound(Vec<PathBuf>),
81    /// macOS Messages database found at this path
82    DatabaseFound(PathBuf),
83    /// iOS backup root without a Messages database
84    BackupWithoutMessages(PathBuf),
85    /// Messages database at the backup root: the flat layout used before iOS 10
86    LegacyBackupLayout(PathBuf),
87    /// Nearest existing ancestor and the first missing component beneath it
88    MissingComponent {
89        /// Deepest existing path prefix
90        ancestor: PathBuf,
91        /// First missing entry below `ancestor`
92        component: OsString,
93    },
94}
95
96impl Display for PathHint {
97    fn fmt(&self, fmt: &mut Formatter<'_>) -> FmtResult {
98        match self {
99            PathHint::QuotedPath(quote) => write!(
100                fmt,
101                "The path contains the quote character `{quote}`; the shell passed it through as part of the path. Wrap the path in straight double quotes (\") instead"
102            ),
103            PathHint::NoLocalDatabase => write!(
104                fmt,
105                "{OS_NAME} has no local Messages database; pass the path to an iOS backup directory or a copy of a macOS `chat.db` instead"
106            ),
107            PathHint::BackupsFound(paths) => {
108                write!(fmt, "iOS backups found; pass the path to one of them:")?;
109                for path in paths {
110                    write!(fmt, "\n  {}", path.display())?;
111                }
112                Ok(())
113            }
114            PathHint::DatabaseFound(path) => write!(
115                fmt,
116                "This directory contains a Messages database; pass the path to the file: {}",
117                path.display()
118            ),
119            PathHint::BackupWithoutMessages(path) => write!(
120                fmt,
121                "`{}` is an iOS backup, but it contains no Messages database; ensure the backup completed successfully",
122                path.display()
123            ),
124            PathHint::LegacyBackupLayout(path) => write!(
125                fmt,
126                "`{}` stores its files at the root, the layout of backups made before iOS 10; backups older than iOS 13 are not supported",
127                path.display()
128            ),
129            PathHint::MissingComponent {
130                ancestor,
131                component,
132            } => write!(
133                fmt,
134                "`{}` exists, but it does not contain `{}`",
135                ancestor.display(),
136                component.display()
137            ),
138        }
139    }
140}
141
142/// Diagnose a failed database or backup path from the surrounding filesystem.
143///
144/// Inspect the nearest existing ancestor, then check the host's default backup locations.
145/// Stop at the first inaccessible ancestor; do not infer contents you cannot inspect.
146#[must_use]
147pub fn diagnose(path: &Path) -> Vec<PathHint> {
148    let mut hints = Vec::new();
149    if !cfg!(target_os = "macos") && default_db_path().is_some_and(|default| default == path) {
150        hints.push(PathHint::NoLocalDatabase);
151    }
152
153    // Report the quote itself instead of treating it as part of a missing path component.
154    if let Some(quote) = shell_quote(path) {
155        hints.push(PathHint::QuotedPath(quote));
156    } else if let Some((ancestor, missing)) = nearest_existing(path) {
157        match classify_directory(&ancestor) {
158            // Stop after finding a database or backup nearby; skip unrelated backups elsewhere.
159            Some(hint @ (PathHint::BackupsFound(_) | PathHint::DatabaseFound(_))) => {
160                hints.push(hint);
161                return hints;
162            }
163            Some(hint) => hints.push(hint),
164            None => {
165                if let Some(component) = missing {
166                    hints.push(PathHint::MissingComponent {
167                        ancestor,
168                        component,
169                    });
170                }
171            }
172        }
173    }
174
175    let on_host: Vec<PathBuf> = default_backup_roots()
176        .iter()
177        .flat_map(|root| list_backups(root))
178        .collect();
179    if !on_host.is_empty() {
180        hints.push(PathHint::BackupsFound(on_host));
181    }
182    hints
183}
184
185/// Walk up to the nearest existing ancestor and name the first missing component beneath it.
186///
187/// For a relative path, use the current directory after reaching an empty parent.
188/// Stop with `None` if an ancestor cannot be inspected.
189fn nearest_existing(path: &Path) -> Option<(PathBuf, Option<OsString>)> {
190    let mut missing = None;
191    for ancestor in path.ancestors() {
192        if ancestor.as_os_str().is_empty() {
193            return Some((std::env::current_dir().ok()?, missing));
194        }
195        match presence(ancestor) {
196            Ok(Presence::Missing) => missing = ancestor.file_name().map(OsString::from),
197            Ok(_) => return Some((ancestor.to_path_buf(), missing)),
198            Err(_) => return None,
199        }
200    }
201    None
202}
203
204/// Return the quote character at either end of the path, if any.
205fn shell_quote(path: &Path) -> Option<char> {
206    let text = path.to_string_lossy();
207    [text.chars().next(), text.chars().last()]
208        .into_iter()
209        .flatten()
210        .find(|end| SHELL_QUOTES.contains(end))
211}
212
213/// Identify an existing directory from its Apple backup markers and database files.
214fn classify_directory(dir: &Path) -> Option<PathHint> {
215    if dir.join(DEFAULT_PATH_IOS).is_file() {
216        return None;
217    }
218    // Require a backup marker before reporting the pre-iOS 10 flat layout: in a modern
219    // backup, you can find the same Messages filename inside its hash subdirectory.
220    if has_backup_markers(dir) {
221        let flat_messages_db = Path::new(DEFAULT_PATH_IOS).file_name()?;
222        if dir.join(flat_messages_db).is_file() {
223            return Some(PathHint::LegacyBackupLayout(dir.to_path_buf()));
224        }
225        return Some(PathHint::BackupWithoutMessages(dir.to_path_buf()));
226    }
227    let database = dir.join(MACOS_DB);
228    if database.is_file() {
229        return Some(PathHint::DatabaseFound(database));
230    }
231    [dir.to_path_buf(), dir.join(BACKUP_DIR)]
232        .iter()
233        .map(|candidate| list_backups(candidate))
234        .find(|backups| !backups.is_empty())
235        .map(PathHint::BackupsFound)
236}
237
238/// List immediate subdirectories of `dir` with `Manifest.db`, `Manifest.plist`, or `Info.plist`.
239/// Return an empty list if `dir` cannot be read.
240#[must_use]
241pub fn list_backups(dir: &Path) -> Vec<PathBuf> {
242    let Ok(entries) = fs::read_dir(dir) else {
243        return Vec::new();
244    };
245    let mut backups: Vec<PathBuf> = entries
246        .flatten()
247        .map(|entry| entry.path())
248        .filter(|path| path.is_dir() && has_backup_markers(path))
249        .collect();
250    backups.sort();
251    backups
252}
253
254/// Look for metadata files at the backup root: `Info.plist` and `Manifest.plist` in every
255/// layout, plus `Manifest.db` since iOS 10.
256fn has_backup_markers(dir: &Path) -> bool {
257    [MANIFEST_DB, MANIFEST_PLIST, INFO_PLIST]
258        .iter()
259        .any(|marker| dir.join(marker).is_file())
260}
261
262#[cfg(test)]
263mod tests {
264    use std::{
265        env::temp_dir,
266        fs::{File, create_dir_all, remove_dir_all},
267        path::{Path, PathBuf},
268        process,
269    };
270
271    use crate::{
272        tables::table::DEFAULT_PATH_IOS,
273        util::path_hint::{PathHint, Presence, diagnose, list_backups, presence},
274    };
275
276    /// Create a fresh directory per test, with the process ID to separate parallel test binaries.
277    fn scratch(label: &str) -> PathBuf {
278        let path = temp_dir().join(format!(
279            "imessage-database-path-hint-{}-{label}",
280            process::id()
281        ));
282        let _ = remove_dir_all(&path);
283        create_dir_all(&path).unwrap();
284        path
285    }
286
287    fn touch(path: &Path) {
288        create_dir_all(path.parent().unwrap()).unwrap();
289        File::create(path).unwrap();
290    }
291
292    #[test]
293    fn presence_distinguishes_missing_file_and_directory() {
294        let dir = scratch("presence");
295        let file = dir.join("file");
296        touch(&file);
297
298        assert_eq!(presence(&dir).unwrap(), Presence::Directory);
299        assert_eq!(presence(&file).unwrap(), Presence::File);
300        assert_eq!(presence(&dir.join("absent")).unwrap(), Presence::Missing);
301        assert_eq!(
302            presence(&file.join("below-a-file")).unwrap(),
303            Presence::Missing
304        );
305
306        remove_dir_all(&dir).unwrap();
307    }
308
309    #[test]
310    fn lists_backups_under_parent() {
311        let dir = scratch("parent");
312        touch(&dir.join("A/Manifest.db"));
313        touch(&dir.join("B/Info.plist"));
314        create_dir_all(dir.join("C")).unwrap();
315        touch(&dir.join("D"));
316
317        assert_eq!(list_backups(&dir), vec![dir.join("A"), dir.join("B")]);
318        assert_eq!(
319            diagnose(&dir),
320            vec![PathHint::BackupsFound(vec![dir.join("A"), dir.join("B")])]
321        );
322
323        remove_dir_all(&dir).unwrap();
324    }
325
326    #[test]
327    fn descends_into_backup_subdirectory() {
328        let dir = scratch("mobilesync");
329        touch(&dir.join("Backup/A/Manifest.plist"));
330
331        assert_eq!(
332            diagnose(&dir),
333            vec![PathHint::BackupsFound(vec![dir.join("Backup/A")])]
334        );
335
336        remove_dir_all(&dir).unwrap();
337    }
338
339    #[test]
340    fn backup_root_without_messages_database() {
341        let dir = scratch("no-messages");
342        touch(&dir.join("Manifest.db"));
343
344        assert_eq!(
345            diagnose(&dir.join(DEFAULT_PATH_IOS)).first(),
346            Some(&PathHint::BackupWithoutMessages(dir.clone()))
347        );
348
349        remove_dir_all(&dir).unwrap();
350    }
351
352    #[test]
353    fn legacy_flat_backup_layout() {
354        let dir = scratch("legacy");
355        touch(&dir.join(Path::new(DEFAULT_PATH_IOS).file_name().unwrap()));
356        touch(&dir.join("Manifest.plist"));
357
358        assert_eq!(
359            diagnose(&dir).first(),
360            Some(&PathHint::LegacyBackupLayout(dir.clone()))
361        );
362        assert_eq!(
363            diagnose(&dir.join(DEFAULT_PATH_IOS)).first(),
364            Some(&PathHint::LegacyBackupLayout(dir.clone()))
365        );
366
367        remove_dir_all(&dir).unwrap();
368    }
369
370    #[test]
371    fn directory_holding_macos_database() {
372        let dir = scratch("messages-dir");
373        touch(&dir.join("chat.db"));
374
375        assert_eq!(
376            diagnose(&dir).first(),
377            Some(&PathHint::DatabaseFound(dir.join("chat.db")))
378        );
379
380        remove_dir_all(&dir).unwrap();
381    }
382
383    #[test]
384    fn names_first_missing_component() {
385        let dir = scratch("typo");
386        create_dir_all(dir.join("Library")).unwrap();
387
388        assert_eq!(
389            diagnose(&dir.join("Library/ApplicationSupport/MobileSync/Backup/X")).first(),
390            Some(&PathHint::MissingComponent {
391                ancestor: dir.join("Library"),
392                component: "ApplicationSupport".into(),
393            })
394        );
395
396        remove_dir_all(&dir).unwrap();
397    }
398
399    #[test]
400    fn quoted_path_is_called_out() {
401        let dir = scratch("quoted");
402
403        let quoted = format!("\u{201C}{}\u{201D}", dir.join("backup").display());
404        let hints = diagnose(Path::new(&quoted));
405        assert_eq!(hints.first(), Some(&PathHint::QuotedPath('\u{201C}')));
406
407        let hints = diagnose(&dir.join("MobileSync'"));
408        assert_eq!(hints.first(), Some(&PathHint::QuotedPath('\'')));
409
410        assert!(
411            !diagnose(&dir.join("Chris's backup"))
412                .iter()
413                .any(|hint| matches!(hint, PathHint::QuotedPath(_)))
414        );
415
416        remove_dir_all(&dir).unwrap();
417    }
418
419    #[test]
420    fn relative_path_resolves_against_current_dir() {
421        let hints = diagnose(Path::new("NoSuchBackup/chat.db"));
422
423        assert_eq!(
424            hints.first(),
425            Some(&PathHint::MissingComponent {
426                ancestor: std::env::current_dir().unwrap(),
427                component: "NoSuchBackup".into(),
428            })
429        );
430    }
431
432    #[test]
433    fn valid_backup_root_is_not_classified() {
434        let dir = scratch("valid-root");
435        touch(&dir.join(DEFAULT_PATH_IOS));
436        touch(&dir.join("Manifest.db"));
437
438        assert_eq!(
439            diagnose(&dir.join("3d/other")).first(),
440            Some(&PathHint::MissingComponent {
441                ancestor: dir.join("3d"),
442                component: "other".into(),
443            })
444        );
445
446        remove_dir_all(&dir).unwrap();
447    }
448}