use std::{
ffi::OsString,
fmt::{Display, Formatter, Result as FmtResult},
fs,
io::{self, ErrorKind},
path::{Path, PathBuf},
};
use crate::{
tables::table::DEFAULT_PATH_IOS,
util::dirs::{default_backup_roots, default_db_path},
};
const OS_NAME: &str = if cfg!(target_os = "windows") {
"Windows"
} else if cfg!(target_os = "linux") {
"Linux"
} else {
std::env::consts::OS
};
const MANIFEST_DB: &str = "Manifest.db";
const MANIFEST_PLIST: &str = "Manifest.plist";
const INFO_PLIST: &str = "Info.plist";
const BACKUP_DIR: &str = "Backup";
const MACOS_DB: &str = "chat.db";
const SHELL_QUOTES: [char; 6] = ['"', '\'', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'];
#[derive(Debug, PartialEq, Eq)]
pub enum Presence {
Missing,
File,
Directory,
Other,
}
pub fn presence(path: &Path) -> io::Result<Presence> {
match fs::metadata(path) {
Ok(metadata) if metadata.is_file() => Ok(Presence::File),
Ok(metadata) if metadata.is_dir() => Ok(Presence::Directory),
Ok(_) => Ok(Presence::Other),
Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
Ok(Presence::Missing)
}
Err(error) => Err(error),
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum PathHint {
QuotedPath(char),
NoLocalDatabase,
BackupsFound(Vec<PathBuf>),
DatabaseFound(PathBuf),
BackupWithoutMessages(PathBuf),
LegacyBackupLayout(PathBuf),
MissingComponent {
ancestor: PathBuf,
component: OsString,
},
}
impl Display for PathHint {
fn fmt(&self, fmt: &mut Formatter<'_>) -> FmtResult {
match self {
PathHint::QuotedPath(quote) => write!(
fmt,
"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"
),
PathHint::NoLocalDatabase => write!(
fmt,
"{OS_NAME} has no local Messages database; pass the path to an iOS backup directory or a copy of a macOS `chat.db` instead"
),
PathHint::BackupsFound(paths) => {
write!(fmt, "iOS backups found; pass the path to one of them:")?;
for path in paths {
write!(fmt, "\n {}", path.display())?;
}
Ok(())
}
PathHint::DatabaseFound(path) => write!(
fmt,
"This directory contains a Messages database; pass the path to the file: {}",
path.display()
),
PathHint::BackupWithoutMessages(path) => write!(
fmt,
"`{}` is an iOS backup, but it contains no Messages database; ensure the backup completed successfully",
path.display()
),
PathHint::LegacyBackupLayout(path) => write!(
fmt,
"`{}` stores its files at the root, the layout of backups made before iOS 10; backups older than iOS 13 are not supported",
path.display()
),
PathHint::MissingComponent {
ancestor,
component,
} => write!(
fmt,
"`{}` exists, but it does not contain `{}`",
ancestor.display(),
component.display()
),
}
}
}
#[must_use]
pub fn diagnose(path: &Path) -> Vec<PathHint> {
let mut hints = Vec::new();
if !cfg!(target_os = "macos") && default_db_path().is_some_and(|default| default == path) {
hints.push(PathHint::NoLocalDatabase);
}
if let Some(quote) = shell_quote(path) {
hints.push(PathHint::QuotedPath(quote));
} else if let Some((ancestor, missing)) = nearest_existing(path) {
match classify_directory(&ancestor) {
Some(hint @ (PathHint::BackupsFound(_) | PathHint::DatabaseFound(_))) => {
hints.push(hint);
return hints;
}
Some(hint) => hints.push(hint),
None => {
if let Some(component) = missing {
hints.push(PathHint::MissingComponent {
ancestor,
component,
});
}
}
}
}
let on_host: Vec<PathBuf> = default_backup_roots()
.iter()
.flat_map(|root| list_backups(root))
.collect();
if !on_host.is_empty() {
hints.push(PathHint::BackupsFound(on_host));
}
hints
}
fn nearest_existing(path: &Path) -> Option<(PathBuf, Option<OsString>)> {
let mut missing = None;
for ancestor in path.ancestors() {
if ancestor.as_os_str().is_empty() {
return Some((std::env::current_dir().ok()?, missing));
}
match presence(ancestor) {
Ok(Presence::Missing) => missing = ancestor.file_name().map(OsString::from),
Ok(_) => return Some((ancestor.to_path_buf(), missing)),
Err(_) => return None,
}
}
None
}
fn shell_quote(path: &Path) -> Option<char> {
let text = path.to_string_lossy();
[text.chars().next(), text.chars().last()]
.into_iter()
.flatten()
.find(|end| SHELL_QUOTES.contains(end))
}
fn classify_directory(dir: &Path) -> Option<PathHint> {
if dir.join(DEFAULT_PATH_IOS).is_file() {
return None;
}
if has_backup_markers(dir) {
let flat_messages_db = Path::new(DEFAULT_PATH_IOS).file_name()?;
if dir.join(flat_messages_db).is_file() {
return Some(PathHint::LegacyBackupLayout(dir.to_path_buf()));
}
return Some(PathHint::BackupWithoutMessages(dir.to_path_buf()));
}
let database = dir.join(MACOS_DB);
if database.is_file() {
return Some(PathHint::DatabaseFound(database));
}
[dir.to_path_buf(), dir.join(BACKUP_DIR)]
.iter()
.map(|candidate| list_backups(candidate))
.find(|backups| !backups.is_empty())
.map(PathHint::BackupsFound)
}
#[must_use]
pub fn list_backups(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let mut backups: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir() && has_backup_markers(path))
.collect();
backups.sort();
backups
}
fn has_backup_markers(dir: &Path) -> bool {
[MANIFEST_DB, MANIFEST_PLIST, INFO_PLIST]
.iter()
.any(|marker| dir.join(marker).is_file())
}
#[cfg(test)]
mod tests {
use std::{
env::temp_dir,
fs::{File, create_dir_all, remove_dir_all},
path::{Path, PathBuf},
process,
};
use crate::{
tables::table::DEFAULT_PATH_IOS,
util::path_hint::{PathHint, Presence, diagnose, list_backups, presence},
};
fn scratch(label: &str) -> PathBuf {
let path = temp_dir().join(format!(
"imessage-database-path-hint-{}-{label}",
process::id()
));
let _ = remove_dir_all(&path);
create_dir_all(&path).unwrap();
path
}
fn touch(path: &Path) {
create_dir_all(path.parent().unwrap()).unwrap();
File::create(path).unwrap();
}
#[test]
fn presence_distinguishes_missing_file_and_directory() {
let dir = scratch("presence");
let file = dir.join("file");
touch(&file);
assert_eq!(presence(&dir).unwrap(), Presence::Directory);
assert_eq!(presence(&file).unwrap(), Presence::File);
assert_eq!(presence(&dir.join("absent")).unwrap(), Presence::Missing);
assert_eq!(
presence(&file.join("below-a-file")).unwrap(),
Presence::Missing
);
remove_dir_all(&dir).unwrap();
}
#[test]
fn lists_backups_under_parent() {
let dir = scratch("parent");
touch(&dir.join("A/Manifest.db"));
touch(&dir.join("B/Info.plist"));
create_dir_all(dir.join("C")).unwrap();
touch(&dir.join("D"));
assert_eq!(list_backups(&dir), vec![dir.join("A"), dir.join("B")]);
assert_eq!(
diagnose(&dir),
vec![PathHint::BackupsFound(vec![dir.join("A"), dir.join("B")])]
);
remove_dir_all(&dir).unwrap();
}
#[test]
fn descends_into_backup_subdirectory() {
let dir = scratch("mobilesync");
touch(&dir.join("Backup/A/Manifest.plist"));
assert_eq!(
diagnose(&dir),
vec![PathHint::BackupsFound(vec![dir.join("Backup/A")])]
);
remove_dir_all(&dir).unwrap();
}
#[test]
fn backup_root_without_messages_database() {
let dir = scratch("no-messages");
touch(&dir.join("Manifest.db"));
assert_eq!(
diagnose(&dir.join(DEFAULT_PATH_IOS)).first(),
Some(&PathHint::BackupWithoutMessages(dir.clone()))
);
remove_dir_all(&dir).unwrap();
}
#[test]
fn legacy_flat_backup_layout() {
let dir = scratch("legacy");
touch(&dir.join(Path::new(DEFAULT_PATH_IOS).file_name().unwrap()));
touch(&dir.join("Manifest.plist"));
assert_eq!(
diagnose(&dir).first(),
Some(&PathHint::LegacyBackupLayout(dir.clone()))
);
assert_eq!(
diagnose(&dir.join(DEFAULT_PATH_IOS)).first(),
Some(&PathHint::LegacyBackupLayout(dir.clone()))
);
remove_dir_all(&dir).unwrap();
}
#[test]
fn directory_holding_macos_database() {
let dir = scratch("messages-dir");
touch(&dir.join("chat.db"));
assert_eq!(
diagnose(&dir).first(),
Some(&PathHint::DatabaseFound(dir.join("chat.db")))
);
remove_dir_all(&dir).unwrap();
}
#[test]
fn names_first_missing_component() {
let dir = scratch("typo");
create_dir_all(dir.join("Library")).unwrap();
assert_eq!(
diagnose(&dir.join("Library/ApplicationSupport/MobileSync/Backup/X")).first(),
Some(&PathHint::MissingComponent {
ancestor: dir.join("Library"),
component: "ApplicationSupport".into(),
})
);
remove_dir_all(&dir).unwrap();
}
#[test]
fn quoted_path_is_called_out() {
let dir = scratch("quoted");
let quoted = format!("\u{201C}{}\u{201D}", dir.join("backup").display());
let hints = diagnose(Path::new("ed));
assert_eq!(hints.first(), Some(&PathHint::QuotedPath('\u{201C}')));
let hints = diagnose(&dir.join("MobileSync'"));
assert_eq!(hints.first(), Some(&PathHint::QuotedPath('\'')));
assert!(
!diagnose(&dir.join("Chris's backup"))
.iter()
.any(|hint| matches!(hint, PathHint::QuotedPath(_)))
);
remove_dir_all(&dir).unwrap();
}
#[test]
fn relative_path_resolves_against_current_dir() {
let hints = diagnose(Path::new("NoSuchBackup/chat.db"));
assert_eq!(
hints.first(),
Some(&PathHint::MissingComponent {
ancestor: std::env::current_dir().unwrap(),
component: "NoSuchBackup".into(),
})
);
}
#[test]
fn valid_backup_root_is_not_classified() {
let dir = scratch("valid-root");
touch(&dir.join(DEFAULT_PATH_IOS));
touch(&dir.join("Manifest.db"));
assert_eq!(
diagnose(&dir.join("3d/other")).first(),
Some(&PathHint::MissingComponent {
ancestor: dir.join("3d"),
component: "other".into(),
})
);
remove_dir_all(&dir).unwrap();
}
}