use std::{
fs::{File, metadata},
io::{ErrorKind, Read},
path::Path,
};
use rusqlite::{Connection, Error, OpenFlags, Params, Result, Row, Statement, blob::Blob};
use crate::{
error::table::{TableConnectError, TableError},
util::path_hint::{Presence, diagnose, presence},
};
pub trait Table: Sized {
fn from_row(row: &Row) -> Result<Self>;
fn rows<'stmt, P: Params>(
stmt: &'stmt mut Statement<'_>,
params: P,
) -> Result<impl Iterator<Item = Result<Self, TableError>> + 'stmt, TableError>
where
Self: 'stmt,
{
let mapped = stmt.query_map(params, |row| Ok(Self::from_row(row)))?;
Ok(mapped.map(flatten_row))
}
fn row<P: Params>(stmt: &mut Statement<'_>, params: P) -> Result<Self, TableError> {
flatten_row(stmt.query_row(params, |row| Ok(Self::from_row(row))))
}
}
pub fn open_blob<'a>(
db: &'a Connection,
table: &str,
column: &str,
rowid: i64,
) -> Option<Blob<'a>> {
db.blob_open(rusqlite::MAIN_DB, table, column, rowid, true)
.ok()
}
pub(crate) fn flatten_row<T>(item: Result<Result<T, Error>, Error>) -> Result<T, TableError> {
match item {
Ok(Ok(row)) => Ok(row),
Err(why) | Ok(Err(why)) => Err(TableError::QueryError(why)),
}
}
pub fn get_connection(path: &Path) -> Result<Connection, TableError> {
let connect_error = match presence(path) {
Err(why) => TableConnectError::Inaccessible(path.to_path_buf(), why),
Ok(Presence::Missing) => {
TableConnectError::DoesNotExist(path.to_path_buf(), diagnose(path))
}
Ok(Presence::Directory) => {
TableConnectError::IsDirectory(path.to_path_buf(), diagnose(path))
}
Ok(Presence::Other) => TableConnectError::NotADatabase(path.to_path_buf()),
Ok(Presence::File) => {
if !has_sqlite_header(path)? {
return Err(TableError::CannotConnect(TableConnectError::NotADatabase(
path.to_path_buf(),
)));
}
return match Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(connection) => {
let _ = connection.pragma_update(None, "mmap_size", 8_589_934_592_i64); let _ = connection.pragma_update(None, "cache_size", -65_536_i64); let _ = connection.set_db_config(
rusqlite::config::DbConfig::SQLITE_DBCONFIG_ENABLE_QPSG,
true,
);
Ok(connection)
}
Err(why) => Err(TableError::CannotConnect(TableConnectError::Permissions(
why,
))),
};
}
};
Err(TableError::CannotConnect(connect_error))
}
const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0";
fn has_sqlite_header(path: &Path) -> Result<bool, TableError> {
let mut file = File::open(path).map_err(|why| match why.kind() {
ErrorKind::PermissionDenied => {
TableError::CannotConnect(TableConnectError::Inaccessible(path.to_path_buf(), why))
}
_ => TableError::CannotRead(why),
})?;
let mut header = [0u8; SQLITE_HEADER.len()];
match file.read_exact(&mut header) {
Ok(()) => Ok(&header == SQLITE_HEADER),
Err(why) if why.kind() == ErrorKind::UnexpectedEof => Ok(false),
Err(why) => Err(TableError::CannotRead(why)),
}
}
pub fn get_db_size(path: &Path) -> Result<u64, TableError> {
Ok(metadata(path)?.len())
}
pub const HANDLE: &str = "handle";
pub const MESSAGE: &str = "message";
pub const CHAT: &str = "chat";
pub const ATTACHMENT: &str = "attachment";
pub const CHAT_MESSAGE_JOIN: &str = "chat_message_join";
pub const MESSAGE_ATTACHMENT_JOIN: &str = "message_attachment_join";
pub const CHAT_HANDLE_JOIN: &str = "chat_handle_join";
pub const RECENTLY_DELETED: &str = "chat_recoverable_message_join";
pub const MESSAGE_PAYLOAD: &str = "payload_data";
pub const MESSAGE_SUMMARY_INFO: &str = "message_summary_info";
pub const STICKER_USER_INFO: &str = "sticker_user_info";
pub const ATTRIBUTION_INFO: &str = "attribution_info";
pub const PROPERTIES: &str = "properties";
pub const ME: &str = "Me";
pub const DEFAULT_PATH_MACOS: &str = "Library/Messages/chat.db";
pub const DEFAULT_PATH_IOS: &str = "3d/3d0d7e5fb2ce288813306e4d4636395e047a3d28";
pub const FITNESS_RECEIVER: &str = "$(kIMTranscriptPluginBreadcrumbTextReceiverIdentifier)";
#[cfg(test)]
mod tests {
use std::{
env::temp_dir,
fs::{create_dir_all, remove_dir_all, write},
path::PathBuf,
process,
};
use rusqlite::{Connection, Result, Row};
use crate::error::table::{TableConnectError, TableError};
use super::{Table, get_connection};
fn scratch(label: &str) -> PathBuf {
let path = temp_dir().join(format!("imessage-database-table-{}-{label}", process::id()));
let _ = remove_dir_all(&path);
create_dir_all(&path).unwrap();
path
}
#[test]
fn connection_rejects_directory() {
let dir = scratch("dir");
assert!(matches!(
get_connection(&dir),
Err(TableError::CannotConnect(TableConnectError::IsDirectory(
_,
_
)))
));
remove_dir_all(&dir).unwrap();
}
#[test]
fn connection_rejects_missing_file() {
let dir = scratch("missing");
assert!(matches!(
get_connection(&dir.join("chat.db")),
Err(TableError::CannotConnect(TableConnectError::DoesNotExist(
_,
_
)))
));
remove_dir_all(&dir).unwrap();
}
#[test]
fn connection_rejects_file_without_sqlite_header() {
let dir = scratch("header");
let text = dir.join("chat.db");
write(&text, b"<?xml version=\"1.0\"?>").unwrap();
let empty = dir.join("empty.db");
write(&empty, b"").unwrap();
assert!(matches!(
get_connection(&text),
Err(TableError::CannotConnect(TableConnectError::NotADatabase(
_
)))
));
assert!(matches!(
get_connection(&empty),
Err(TableError::CannotConnect(TableConnectError::NotADatabase(
_
)))
));
remove_dir_all(&dir).unwrap();
}
#[test]
fn connection_opens_sqlite_file() {
let dir = scratch("sqlite");
let db = dir.join("chat.db");
Connection::open(&db)
.unwrap()
.execute_batch("CREATE TABLE t (x)")
.unwrap();
assert!(get_connection(&db).is_ok());
remove_dir_all(&dir).unwrap();
}
struct TestRow(i64);
impl Table for TestRow {
fn from_row(row: &Row) -> Result<Self> {
Ok(Self(row.get(0)?))
}
}
#[test]
fn rows_decodes_every_row_in_order() {
let db = Connection::open_in_memory().unwrap();
let mut statement = db
.prepare("SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3")
.unwrap();
let seen: Vec<i64> = TestRow::rows(&mut statement, [])
.unwrap()
.map(|row| row.unwrap().0)
.collect();
assert_eq!(seen, vec![1, 2, 3]);
}
#[test]
fn row_without_a_result_is_a_query_error() {
let db = Connection::open_in_memory().unwrap();
let mut statement = db.prepare("SELECT 1 WHERE 0").unwrap();
let result = TestRow::row(&mut statement, []);
assert!(matches!(result, Err(TableError::QueryError(_))));
}
}