1use 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
18const 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
27const MANIFEST_DB: &str = "Manifest.db";
29const MANIFEST_PLIST: &str = "Manifest.plist";
31const INFO_PLIST: &str = "Info.plist";
33const BACKUP_DIR: &str = "Backup";
35const MACOS_DB: &str = "chat.db";
37
38const SHELL_QUOTES: [char; 6] = ['"', '\'', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'];
41
42#[derive(Debug, PartialEq, Eq)]
44pub enum Presence {
45 Missing,
47 File,
49 Directory,
51 Other,
53}
54
55pub 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#[derive(Debug, PartialEq, Eq)]
74pub enum PathHint {
75 QuotedPath(char),
77 NoLocalDatabase,
79 BackupsFound(Vec<PathBuf>),
81 DatabaseFound(PathBuf),
83 BackupWithoutMessages(PathBuf),
85 LegacyBackupLayout(PathBuf),
87 MissingComponent {
89 ancestor: PathBuf,
91 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#[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 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 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
185fn 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
204fn 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
213fn classify_directory(dir: &Path) -> Option<PathHint> {
215 if dir.join(DEFAULT_PATH_IOS).is_file() {
216 return None;
217 }
218 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#[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
254fn 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 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("ed));
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}