Skip to main content

imessage_database/tables/
attachment.rs

1/*!
2 Attachment table rows and attachment file path helpers.
3*/
4
5use plist::Value;
6use rusqlite::{CachedStatement, Connection, Error, Result, Row};
7use sha1::{Digest, Sha1};
8
9use std::{
10    borrow::Cow,
11    fs::File,
12    io::Read,
13    path::{Path, PathBuf},
14};
15
16use crate::{
17    error::{attachment::AttachmentError, table::TableError},
18    message_types::sticker::{StickerDecoration, StickerEffect, StickerSource, get_sticker_effect},
19    tables::{
20        capabilities::Capabilities,
21        diagnostic::AttachmentDiagnostic,
22        messages::Message,
23        table::{
24            ATTACHMENT, ATTRIBUTION_INFO, CHAT_MESSAGE_JOIN, MESSAGE, MESSAGE_ATTACHMENT_JOIN,
25            STICKER_USER_INFO, Table,
26        },
27    },
28    util::{
29        bundle_id::parse_balloon_bundle_id,
30        dirs::home,
31        platform::Platform,
32        plist::{get_owned_string_from_dict, get_value_from_dict, plist_as_dictionary},
33        query_context::QueryContext,
34        size::format_file_size,
35    },
36};
37
38// MARK: Constants
39/// Default macOS Messages root used by attachment paths.
40pub const DEFAULT_MESSAGES_ROOT: &str = "~/Library/Messages";
41/// Alternate root directory used by a jailbroken iOS device's `sms.db`
42///
43/// The `sms.db` database uses the same schema and path conventions as a macOS `chat.db`,
44/// but attachment paths are rooted under `~/Library/SMS` instead of `~/Library/Messages`.
45pub const DEFAULT_SMS_ROOT: &str = "~/Library/SMS";
46/// Default macOS attachment root.
47pub const DEFAULT_ATTACHMENT_ROOT: &str = "~/Library/Messages/Attachments";
48/// Default macOS sticker cache root.
49pub const DEFAULT_STICKER_CACHE_ROOT: &str = "~/Library/Messages/StickerCache";
50/// Recognized `attachment` columns in canonical projection order.
51pub(crate) const ATTACHMENT_COLUMNS: [&str; 10] = [
52    "rowid",
53    "guid",
54    "filename",
55    "uti",
56    "mime_type",
57    "transfer_name",
58    "total_bytes",
59    "is_sticker",
60    "hide_attachment",
61    "emoji_image_short_description",
62];
63
64// MARK: MediaType
65/// Represents the [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_Types) of a message's attachment data
66///
67/// The borrowed string stores the subtype for known categories, e.g. `x-m4a`
68/// for `audio/x-m4a`.
69#[derive(Debug, PartialEq, Eq)]
70pub enum MediaType<'a> {
71    /// Image MIME type, such as `"image/png"` or `"image/jpeg"`.
72    Image(&'a str),
73    /// Video MIME type, such as `"video/mp4"` or `"video/quicktime"`.
74    Video(&'a str),
75    /// Audio MIME type, such as `"audio/mp3"` or `"audio/x-m4a"`.
76    Audio(&'a str),
77    /// Text MIME type, such as `"text/plain"` or `"text/html"`.
78    Text(&'a str),
79    /// Application MIME type, such as `"application/pdf"` or `"application/json"`.
80    Application(&'a str),
81    /// Other MIME type stored as the complete MIME string.
82    Other(&'a str),
83    /// MIME type could not be determined.
84    Unknown,
85}
86
87impl MediaType<'_> {
88    /// Given a [`MediaType`], generate the corresponding MIME type string
89    ///
90    /// # Example
91    ///
92    /// ```rust
93    /// use imessage_database::tables::attachment::MediaType;
94    ///
95    /// println!("{:?}", MediaType::Image("png").as_mime_type()); // "image/png"
96    /// ```
97    #[must_use]
98    pub fn as_mime_type(&self) -> String {
99        match self {
100            MediaType::Image(subtype) => format!("image/{subtype}"),
101            MediaType::Video(subtype) => format!("video/{subtype}"),
102            MediaType::Audio(subtype) => format!("audio/{subtype}"),
103            MediaType::Text(subtype) => format!("text/{subtype}"),
104            MediaType::Application(subtype) => format!("application/{subtype}"),
105            MediaType::Other(mime) => (*mime).to_string(),
106            MediaType::Unknown => String::new(),
107        }
108    }
109}
110
111// MARK: AttachmentAttribution
112/// Attribution metadata parsed from the [`ATTRIBUTION_INFO`] column.
113#[derive(Debug, PartialEq, Eq)]
114pub struct AttachmentAttribution {
115    /// Bundle ID stored under `bundle-id`.
116    pub bundle_id: String,
117    /// Display name stored under `name`.
118    pub name: Option<String>,
119    /// App Store ID stored under `adam-id`.
120    pub adam_id: Option<i64>,
121}
122
123impl AttachmentAttribution {
124    /// Parse an attribution from a deserialized [`ATTRIBUTION_INFO`] property list.
125    ///
126    /// Returns `None` unless the root is a dictionary containing a non-empty
127    /// string under `bundle-id`.
128    #[must_use]
129    pub fn from_plist(payload: &Value) -> Option<Self> {
130        Some(Self {
131            bundle_id: get_owned_string_from_dict(payload, "bundle-id")?,
132            name: get_owned_string_from_dict(payload, "name"),
133            adam_id: get_value_from_dict(payload, "adam-id").and_then(Value::as_signed_integer),
134        })
135    }
136
137    /// Return the stored bundle ID when it contains no `:` separators;
138    /// otherwise, return its third `:`-delimited component.
139    #[must_use]
140    pub fn app_bundle_id(&self) -> Option<&str> {
141        parse_balloon_bundle_id(Some(&self.bundle_id))
142    }
143
144    /// Whether the app bundle ID identifies the Messages drawing board.
145    #[must_use]
146    pub fn is_drawing(&self) -> bool {
147        self.app_bundle_id() == Some("com.apple.PaperKit.MessagesDrawingBoard")
148    }
149}
150
151/// Row from the `attachment` table.
152#[derive(Debug)]
153pub struct Attachment {
154    /// Attachment row ID.
155    pub rowid: i32,
156    /// The attachment's GUID. Matches the `__kIMFileTransferGUIDAttributeName`
157    /// carried on the message body's attachment ranges, letting the exporter
158    /// pair a body placeholder with its resolved attachment by identity rather
159    /// than by position.
160    pub guid: Option<String>,
161    /// Path to the file on disk as stored in the database.
162    pub filename: Option<String>,
163    /// The [Uniform Type Identifier](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/understanding_utis/understand_utis_intro/understand_utis_intro.html).
164    pub uti: Option<String>,
165    /// MIME type string.
166    pub mime_type: Option<String>,
167    /// Original transfer filename.
168    pub transfer_name: Option<String>,
169    /// Total bytes recorded by Messages.
170    pub total_bytes: i64,
171    /// `true` when the attachment is a sticker.
172    pub is_sticker: bool,
173    /// Messages UI hide flag.
174    pub hide_attachment: i32,
175    /// Prompt used to generate a Genmoji.
176    pub emoji_description: Option<String>,
177    /// Auxiliary data to denote that an attachment has been copied or converted.
178    pub copied_path: Option<PathBuf>,
179}
180
181// MARK: Table
182impl Table for Attachment {
183    fn from_row(row: &Row) -> Result<Attachment> {
184        Ok(Attachment {
185            rowid: row.get("rowid")?,
186            guid: row.get("guid").unwrap_or(None),
187            filename: row.get("filename").unwrap_or(None),
188            uti: row.get("uti").unwrap_or(None),
189            mime_type: row.get("mime_type").unwrap_or(None),
190            transfer_name: row.get("transfer_name").unwrap_or(None),
191            total_bytes: row.get("total_bytes").unwrap_or_default(),
192            is_sticker: row.get("is_sticker").unwrap_or(false),
193            hide_attachment: row.get("hide_attachment").unwrap_or(0),
194            emoji_description: row.get("emoji_image_short_description").unwrap_or(None),
195            copied_path: None,
196        })
197    }
198
199    fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
200        Ok(db.prepare_cached(&format!("SELECT * from {ATTACHMENT}"))?)
201    }
202}
203
204// MARK: Impl
205impl Attachment {
206    /// Load attachments associated with one message.
207    ///
208    /// Rows are returned in the `message_attachment_join` table's order, which is
209    /// not guaranteed to align with the order of the attachment
210    /// [`AttributedRange`](crate::tables::messages::models::AttributedRange)s
211    /// (those whose [`attachment`](crate::tables::messages::models::AttributedRange::attachment)
212    /// is `Some`) in the message's
213    /// [`attributed_body()`](crate::tables::messages::message::Message::attributed_body).
214    /// Callers pairing body ranges to rows should match on the file-transfer GUID
215    /// rather than relying on position.
216    pub fn from_message(
217        db: &Connection,
218        msg: &Message,
219        capabilities: &Capabilities,
220    ) -> Result<Vec<Attachment>, TableError> {
221        let mut out_l = vec![];
222        if msg.has_attachments() {
223            let projection = capabilities
224                .attachment_columns()
225                .iter()
226                .map(|column| format!("a.{column}"))
227                .collect::<Vec<String>>()
228                .join(", ");
229
230            let mut statement = db.prepare_cached(&format!(
231                "
232                    SELECT {projection}
233                    FROM {MESSAGE_ATTACHMENT_JOIN} j
234                    LEFT JOIN {ATTACHMENT} a ON j.attachment_id = a.ROWID
235                    WHERE j.message_id = ?1
236                ",
237            ))?;
238
239            for attachment in Attachment::rows(&mut statement, [msg.rowid])? {
240                out_l.push(attachment?);
241            }
242        }
243        Ok(out_l)
244    }
245
246    /// Classify this attachment's MIME type.
247    #[must_use]
248    pub fn mime_type(&'_ self) -> MediaType<'_> {
249        match &self.mime_type {
250            Some(mime) => {
251                let mut mime_parts = mime.split('/');
252                if let (Some(category), Some(subtype)) = (mime_parts.next(), mime_parts.next()) {
253                    match category {
254                        "image" => MediaType::Image(subtype),
255                        "video" => MediaType::Video(subtype),
256                        "audio" => MediaType::Audio(subtype),
257                        "text" => MediaType::Text(subtype),
258                        "application" => MediaType::Application(subtype),
259                        _ => MediaType::Other(mime),
260                    }
261                } else {
262                    MediaType::Other(mime)
263                }
264            }
265            None => {
266                if let Some(uti) = &self.uti {
267                    match uti.as_str() {
268                        // Audio messages are stored as Core Audio Format files.
269                        "com.apple.coreaudio-format" => MediaType::Audio("x-caf; codecs=opus"),
270                        _ => MediaType::Unknown,
271                    }
272                } else {
273                    MediaType::Unknown
274                }
275            }
276        }
277    }
278
279    /// `true` when this sticker's underlying file is animated: a HEIC
280    /// sequence (`image/heics`, `image/heic-sequence`) or a video
281    /// (`video/*`). Static stickers (HEIC, PNG, etc.) return `false`.
282    /// Renderer presentation decisions are the caller's concern.
283    #[must_use]
284    pub fn is_animated_sticker(&self) -> bool {
285        self.is_sticker
286            && matches!(
287                self.mime_type(),
288                MediaType::Image("heics" | "HEICS" | "heic-sequence") | MediaType::Video(_),
289            )
290    }
291
292    /// Read the attachment file into memory.
293    ///
294    /// `db_path` is the path to the root of the backup directory.
295    /// This is the same path used by [`get_connection()`](crate::tables::table::get_connection).
296    pub fn as_bytes(
297        &self,
298        platform: &Platform,
299        db_path: &Path,
300        custom_attachment_root: Option<&str>,
301    ) -> Result<Option<Vec<u8>>, AttachmentError> {
302        if let Some(file_path) =
303            self.resolved_attachment_path(platform, db_path, custom_attachment_root)
304        {
305            let mut file = File::open(&file_path)
306                .map_err(|err| AttachmentError::Unreadable(file_path.clone(), err))?;
307            let mut bytes = vec![];
308            file.read_to_end(&mut bytes)
309                .map_err(|err| AttachmentError::Unreadable(file_path.clone(), err))?;
310
311            return Ok(Some(bytes));
312        }
313        Ok(None)
314    }
315
316    /// Parse the [`StickerEffect`] for a sticker attachment.
317    ///
318    /// `db_path` is the path to the root of the backup directory.
319    /// This is the same path used by [`get_connection()`](crate::tables::table::get_connection).
320    pub fn get_sticker_effect(
321        &self,
322        platform: &Platform,
323        db_path: &Path,
324        custom_attachment_root: Option<&str>,
325    ) -> Result<Option<StickerEffect>, AttachmentError> {
326        // Handle the non-sticker case
327        if !self.is_sticker {
328            return Ok(None);
329        }
330
331        // Try to parse the HEIC data
332        if let Some(data) = self.as_bytes(platform, db_path, custom_attachment_root)? {
333            return Ok(Some(get_sticker_effect(&data)));
334        }
335
336        // Default if the attachment is a sticker and cannot be parsed/read
337        Ok(Some(StickerEffect::default()))
338    }
339
340    /// Return the stored attachment path.
341    #[must_use]
342    pub fn path(&self) -> Option<&Path> {
343        match &self.filename {
344            Some(name) => Some(Path::new(name)),
345            None => None,
346        }
347    }
348
349    /// Return the stored attachment path extension.
350    #[must_use]
351    pub fn extension(&self) -> Option<&str> {
352        match self.path() {
353            Some(path) => match path.extension() {
354                Some(ext) => ext.to_str(),
355                None => None,
356            },
357            None => None,
358        }
359    }
360
361    /// Return the transfer filename, falling back to the stored path.
362    ///
363    /// [`transfer_name`](Self::transfer_name) is the display filename when present; [`filename`](Self::filename) is the
364    /// database path.
365    #[must_use]
366    pub fn filename(&self) -> Option<&str> {
367        self.transfer_name.as_deref().or(self.filename.as_deref())
368    }
369
370    /// Return [`total_bytes`](Self::total_bytes) as a human-readable size.
371    #[must_use]
372    pub fn file_size(&self) -> String {
373        format_file_size(u64::try_from(self.total_bytes).unwrap_or(0))
374    }
375
376    /// Sum on-disk bytes for attachments the export would copy under this [`QueryContext`].
377    ///
378    /// # Errors
379    ///
380    /// Returns [`TableError`] if the query fails.
381    pub fn get_total_attachment_bytes(
382        db: &Connection,
383        context: &QueryContext,
384    ) -> Result<u64, TableError> {
385        let statement = if context.has_filters() {
386            format!(
387                "SELECT IFNULL(SUM(a.total_bytes), 0) FROM {ATTACHMENT} a \
388             WHERE a.ROWID IN ( \
389                 SELECT maj.attachment_id \
390                 FROM {MESSAGE_ATTACHMENT_JOIN} maj \
391                 JOIN {MESSAGE} m ON m.ROWID = maj.message_id \
392                 LEFT JOIN {CHAT_MESSAGE_JOIN} c ON c.message_id = m.ROWID \
393                 {} \
394             )",
395                Message::generate_filter_statement(context, false)
396            )
397        } else {
398            format!("SELECT IFNULL(SUM(total_bytes), 0) FROM {ATTACHMENT}")
399        };
400
401        let mut bytes_query = db.prepare(&statement)?;
402        Ok(bytes_query
403            .query_row([], |r| -> Result<i64> { r.get(0) })
404            .map(|res: i64| u64::try_from(res).unwrap_or(0))?)
405    }
406
407    /// Resolve the on-disk path for this attachment.
408    ///
409    /// For macOS, `db_path` is unused. For iOS, `db_path` is the path to the root of the backup directory.
410    /// This is the same path used by [`get_connection()`](crate::tables::table::get_connection).
411    ///
412    /// On encrypted iOS backups, file names are derived from the SHA-1 hash of
413    /// `MediaDomain-` plus the relative [`filename`](Self::filename). Read more [here](https://theapplewiki.com/index.php?title=ITunes_Backup).
414    ///
415    /// Use the optional `custom_attachment_root` parameter when attachment data is stored under a
416    /// different Messages root than the database expects. This replaces the leading Messages root,
417    /// not just the `Attachments` directory, so it affects both [`DEFAULT_ATTACHMENT_ROOT`] and
418    /// [`DEFAULT_STICKER_CACHE_ROOT`].
419    ///
420    /// For example, a custom attachment root like `/custom/path` will rewrite
421    /// `~/Library/Messages/Attachments/3d/...` to `/custom/path/Attachments/3d/...` and
422    /// `~/Library/Messages/StickerCache/ab/...` to `/custom/path/StickerCache/ab/...`.
423    ///
424    /// For a jailbroken iOS `sms.db`, attachment paths start with [`DEFAULT_SMS_ROOT`] (`~/Library/SMS`)
425    /// instead of [`DEFAULT_MESSAGES_ROOT`]. These databases behave like macOS databases and should
426    /// use [`Platform::macOS`], not [`Platform::iOS`], which is reserved for encrypted Finder/Apple Devices/iTunes backups.
427    #[must_use]
428    pub fn resolved_attachment_path(
429        &self,
430        platform: &Platform,
431        db_path: &Path,
432        custom_attachment_root: Option<&str>,
433    ) -> Option<String> {
434        let mut path_str = self.filename.clone()?;
435
436        // Apply custom attachment path, if provided
437        if matches!(platform, Platform::macOS)
438            && let Some(custom_attachment_path) = custom_attachment_root
439        {
440            path_str =
441                Attachment::apply_custom_root(&path_str, custom_attachment_path).into_owned();
442        }
443
444        match platform {
445            Platform::macOS => Some(Attachment::gen_macos_attachment(&path_str)),
446            Platform::iOS => Attachment::gen_ios_attachment(&path_str, db_path),
447        }
448    }
449
450    /// Compute diagnostic data for the `attachment` table.
451    ///
452    /// Counts the number of attachments that are missing, either because the path is missing from the
453    /// table or the path does not point to a file.
454    ///
455    /// # Example:
456    ///
457    /// ```no_run
458    /// use imessage_database::util::{dirs::default_db_path, platform::Platform};
459    /// use imessage_database::tables::table::get_connection;
460    /// use imessage_database::tables::attachment::Attachment;
461    ///
462    /// let db_path = default_db_path();
463    /// let conn = get_connection(&db_path).unwrap();
464    /// Attachment::run_diagnostic(&conn, &db_path, &Platform::macOS, None);
465    /// ```
466    ///
467    /// `db_path` is the path to the root of the backup directory.
468    /// This is the same path used by [`get_connection()`](crate::tables::table::get_connection).
469    pub fn run_diagnostic(
470        db: &Connection,
471        db_path: &Path,
472        platform: &Platform,
473        custom_attachment_root: Option<&str>,
474    ) -> Result<AttachmentDiagnostic, TableError> {
475        let mut total_attachments = 0usize;
476        let mut no_path_provided = 0usize;
477        let mut total_bytes_on_disk: u64 = 0;
478        let mut statement_paths = db.prepare(&format!("SELECT filename FROM {ATTACHMENT}"))?;
479        let paths = statement_paths.query_map([], |r| Ok(r.get(0)))?;
480
481        let missing_files = paths
482            .filter_map(Result::ok)
483            .filter(|path: &Result<String, Error>| {
484                // Keep track of the number of attachments in the table
485                total_attachments += 1;
486                if let Ok(filepath) = path {
487                    match platform {
488                        Platform::macOS => {
489                            let path = match custom_attachment_root {
490                                Some(custom_root) => Attachment::gen_macos_attachment(
491                                    &Attachment::apply_custom_root(filepath, custom_root),
492                                ),
493                                None => Attachment::gen_macos_attachment(filepath),
494                            };
495                            let file = Path::new(&path);
496                            match file.metadata() {
497                                Ok(metadata) => {
498                                    total_bytes_on_disk += metadata.len();
499                                    false
500                                }
501                                Err(_) => true,
502                            }
503                        }
504                        Platform::iOS => {
505                            if let Some(parsed_path) =
506                                Attachment::gen_ios_attachment(filepath, db_path)
507                            {
508                                let file = Path::new(&parsed_path);
509                                return match file.metadata() {
510                                    Ok(metadata) => {
511                                        total_bytes_on_disk += metadata.len();
512                                        false
513                                    }
514                                    Err(_) => true,
515                                };
516                            }
517                            // This hits if the attachment path doesn't get generated
518                            true
519                        }
520                    }
521                } else {
522                    // This hits if there is no path provided for the current attachment
523                    no_path_provided += 1;
524                    true
525                }
526            })
527            .count();
528
529        let total_bytes_referenced =
530            Attachment::get_total_attachment_bytes(db, &QueryContext::default()).unwrap_or(0);
531
532        Ok(AttachmentDiagnostic {
533            total_attachments,
534            total_bytes_referenced,
535            total_bytes_on_disk,
536            missing_files,
537            no_path_provided,
538        })
539    }
540
541    /// Replace the default Messages or SMS root prefix with a custom attachment root.
542    fn apply_custom_root<'a>(path: &'a str, custom_root: &str) -> Cow<'a, str> {
543        let prefix = if path.starts_with(DEFAULT_MESSAGES_ROOT) {
544            Some(DEFAULT_MESSAGES_ROOT)
545        } else if path.starts_with(DEFAULT_SMS_ROOT) {
546            Some(DEFAULT_SMS_ROOT)
547        } else {
548            None
549        };
550        match prefix {
551            Some(old) => Cow::Owned(path.replacen(old, custom_root, 1)),
552            None => Cow::Borrowed(path),
553        }
554    }
555
556    /// Resolve a macOS attachment path.
557    fn gen_macos_attachment(path: &str) -> String {
558        if path.starts_with('~') {
559            return path.replacen('~', &home(), 1);
560        }
561        path.to_string()
562    }
563
564    /// Resolve an encrypted iOS backup attachment path.
565    fn gen_ios_attachment(file_path: &str, db_path: &Path) -> Option<String> {
566        let input = file_path.get(2..)?;
567        let digest = Sha1::digest(format!("MediaDomain-{input}").as_bytes());
568        let filename = digest
569            .iter()
570            .map(|byte| format!("{:02x}", byte))
571            .collect::<String>();
572        let directory = filename.get(0..2)?;
573
574        Some(format!("{}/{directory}/{filename}", db_path.display()))
575    }
576
577    /// Parse the [`STICKER_USER_INFO`] `BLOB` column as a property list.
578    ///
579    /// Calling this reads a `BLOB` from the database.
580    ///
581    /// This column contains data used for sticker attachments.
582    fn sticker_info(&self, db: &Connection) -> Option<Value> {
583        Value::from_reader(self.get_blob(db, ATTACHMENT, STICKER_USER_INFO, self.rowid.into())?)
584            .ok()
585    }
586
587    /// Parse the [`ATTRIBUTION_INFO`] `BLOB` column as a property list.
588    ///
589    /// Calling this reads a `BLOB` from the database.
590    fn attribution_info(&self, db: &Connection) -> Option<Value> {
591        Value::from_reader(self.get_blob(db, ATTACHMENT, ATTRIBUTION_INFO, self.rowid.into())?).ok()
592    }
593
594    /// Parse a sticker's source from the bundle ID in [`STICKER_USER_INFO`].
595    ///
596    /// Calling this reads a `BLOB` from the database.
597    pub fn get_sticker_source(&self, db: &Connection) -> Option<StickerSource> {
598        if let Some(sticker_info) = self.sticker_info(db) {
599            let plist = plist_as_dictionary(&sticker_info).ok()?;
600            let bundle_id = plist.get("pid")?.as_string()?;
601            return StickerSource::from_bundle_id(bundle_id);
602        }
603        None
604    }
605
606    /// Parse this attachment's attribution from [`ATTRIBUTION_INFO`].
607    ///
608    /// Calling this reads a `BLOB` from the database.
609    pub fn get_attribution(&self, db: &Connection) -> Option<AttachmentAttribution> {
610        AttachmentAttribution::from_plist(&self.attribution_info(db)?)
611    }
612
613    /// Return a sticker's attributed application name from [`ATTRIBUTION_INFO`].
614    ///
615    /// Returns `None` unless the property list contains non-empty `bundle-id`
616    /// and `name` strings.
617    ///
618    /// Calling this reads a `BLOB` from the database.
619    pub fn get_sticker_source_application_name(&self, db: &Connection) -> Option<String> {
620        self.get_attribution(db)?.name
621    }
622
623    /// Resolve a sticker's [`StickerSource`] into a [`StickerDecoration`].
624    /// Combines [`get_sticker_source`](Self::get_sticker_source),
625    /// [`get_sticker_source_application_name`](Self::get_sticker_source_application_name),
626    /// and [`get_sticker_effect`](Self::get_sticker_effect) so
627    /// consumers don't have to re-derive the dispatch.
628    ///
629    /// Returns `None` in three cases:
630    /// - The sticker has no readable source (missing [`STICKER_USER_INFO`],
631    ///   malformed plist, or unrecognized bundle id).
632    /// - The source is [`StickerSource::Genmoji`] but `emoji_description` is
633    ///   unset.
634    /// - The source is [`StickerSource::UserGenerated`] but the effect blob
635    ///   is missing or unreadable.
636    ///
637    /// [`StickerSource::Memoji`] and [`StickerSource::App`] always yield
638    /// `Some`.
639    pub fn get_sticker_decoration(
640        &self,
641        db: &Connection,
642        platform: &Platform,
643        db_path: &Path,
644        attachment_root: Option<&str>,
645    ) -> Option<StickerDecoration> {
646        let source = self.get_sticker_source(db)?;
647        match source {
648            StickerSource::Genmoji => self
649                .emoji_description
650                .as_deref()
651                .map(|prompt| StickerDecoration::GenmojiPrompt(prompt.to_string())),
652            StickerSource::Memoji => Some(StickerDecoration::Memoji),
653            StickerSource::UserGenerated => self
654                .get_sticker_effect(platform, db_path, attachment_root)
655                .ok()
656                .flatten()
657                .map(StickerDecoration::Effect),
658            StickerSource::App(bundle_id) => Some(StickerDecoration::AppName(
659                self.get_sticker_source_application_name(db)
660                    .unwrap_or(bundle_id),
661            )),
662        }
663    }
664}
665
666// MARK: Tests
667#[cfg(test)]
668mod tests {
669    use crate::{
670        tables::{
671            attachment::{
672                Attachment, AttachmentAttribution, DEFAULT_ATTACHMENT_ROOT, DEFAULT_SMS_ROOT,
673                DEFAULT_STICKER_CACHE_ROOT, MediaType,
674            },
675            table::get_connection,
676        },
677        util::{platform::Platform, query_context::QueryContext},
678    };
679
680    use plist::{Dictionary, Value};
681
682    use std::{
683        collections::BTreeSet,
684        env::current_dir,
685        fs::File,
686        path::{Path, PathBuf},
687    };
688
689    fn sample_attachment() -> Attachment {
690        Attachment {
691            rowid: 1,
692            guid: None,
693            filename: Some("a/b/c.png".to_string()),
694            uti: Some("public.png".to_string()),
695            mime_type: Some("image/png".to_string()),
696            transfer_name: Some("c.png".to_string()),
697            total_bytes: 100,
698            is_sticker: false,
699            hide_attachment: 0,
700            emoji_description: None,
701            copied_path: None,
702        }
703    }
704
705    #[test]
706    fn can_get_path() {
707        let attachment = sample_attachment();
708        assert_eq!(attachment.path(), Some(Path::new("a/b/c.png")));
709    }
710
711    #[test]
712    fn cant_get_path_missing() {
713        let mut attachment = sample_attachment();
714        attachment.filename = None;
715        assert_eq!(attachment.path(), None);
716    }
717
718    #[test]
719    fn can_get_extension() {
720        let attachment = sample_attachment();
721        assert_eq!(attachment.extension(), Some("png"));
722    }
723
724    #[test]
725    fn cant_get_extension_missing() {
726        let mut attachment = sample_attachment();
727        attachment.filename = None;
728        assert_eq!(attachment.extension(), None);
729    }
730
731    #[test]
732    fn can_get_mime_type_png() {
733        let attachment = sample_attachment();
734        assert_eq!(attachment.mime_type(), MediaType::Image("png"));
735    }
736
737    #[test]
738    fn can_get_mime_type_heic() {
739        let mut attachment = sample_attachment();
740        attachment.mime_type = Some("image/heic".to_string());
741        assert_eq!(attachment.mime_type(), MediaType::Image("heic"));
742    }
743
744    #[test]
745    fn can_get_mime_type_fake() {
746        let mut attachment = sample_attachment();
747        attachment.mime_type = Some("fake/bloop".to_string());
748        assert_eq!(attachment.mime_type(), MediaType::Other("fake/bloop"));
749    }
750
751    #[test]
752    fn can_get_mime_type_missing() {
753        let mut attachment = sample_attachment();
754        attachment.mime_type = None;
755        assert_eq!(attachment.mime_type(), MediaType::Unknown);
756    }
757
758    #[test]
759    fn is_animated_sticker_static_heic() {
760        let mut attachment = sample_attachment();
761        attachment.is_sticker = true;
762        attachment.mime_type = Some("image/heic".to_string());
763        assert!(!attachment.is_animated_sticker());
764    }
765
766    #[test]
767    fn is_animated_sticker_heic_sequence() {
768        let mut attachment = sample_attachment();
769        attachment.is_sticker = true;
770        attachment.mime_type = Some("image/heic-sequence".to_string());
771        assert!(attachment.is_animated_sticker());
772    }
773
774    #[test]
775    fn is_animated_sticker_video_memoji() {
776        let mut attachment = sample_attachment();
777        attachment.is_sticker = true;
778        attachment.mime_type = Some("video/quicktime".to_string());
779        assert!(attachment.is_animated_sticker());
780    }
781
782    #[test]
783    fn is_animated_sticker_requires_sticker_flag() {
784        let mut attachment = sample_attachment();
785        attachment.is_sticker = false;
786        attachment.mime_type = Some("video/quicktime".to_string());
787        assert!(!attachment.is_animated_sticker());
788    }
789
790    #[test]
791    fn can_get_filename() {
792        let attachment = sample_attachment();
793        assert_eq!(attachment.filename(), Some("c.png"));
794    }
795
796    #[test]
797    fn can_get_filename_no_transfer_name() {
798        let mut attachment = sample_attachment();
799        attachment.transfer_name = None;
800        assert_eq!(attachment.filename(), Some("a/b/c.png"));
801    }
802
803    #[test]
804    fn can_get_filename_no_filename() {
805        let mut attachment = sample_attachment();
806        attachment.filename = None;
807        assert_eq!(attachment.filename(), Some("c.png"));
808    }
809
810    #[test]
811    fn can_get_filename_no_meta() {
812        let mut attachment = sample_attachment();
813        attachment.transfer_name = None;
814        attachment.filename = None;
815        assert_eq!(attachment.filename(), None);
816    }
817
818    #[test]
819    fn can_get_resolved_path_macos() {
820        let db_path = PathBuf::from("fake_root");
821        let attachment = sample_attachment();
822
823        assert_eq!(
824            attachment.resolved_attachment_path(&Platform::macOS, &db_path, None),
825            Some("a/b/c.png".to_string())
826        );
827    }
828
829    #[test]
830    fn can_get_resolved_path_macos_custom() {
831        let db_path = PathBuf::from("fake_root");
832        let mut attachment = sample_attachment();
833        // Sample path like `~/Library/Messages/Attachments/0a/10/.../image.jpeg`
834        attachment.filename = Some(format!("{DEFAULT_ATTACHMENT_ROOT}/a/b/c.png"));
835
836        assert_eq!(
837            attachment.resolved_attachment_path(&Platform::macOS, &db_path, Some("custom/root")),
838            Some("custom/root/Attachments/a/b/c.png".to_string())
839        );
840    }
841
842    #[test]
843    fn can_get_resolved_path_macos_custom_sticker() {
844        let db_path = PathBuf::from("fake_root");
845        let mut attachment = sample_attachment();
846        // Sample path like `~/Library/Messages/StickerCache/0a/10/.../image.jpeg`
847        attachment.filename = Some(format!("{DEFAULT_STICKER_CACHE_ROOT}/a/b/c.png"));
848
849        assert_eq!(
850            attachment.resolved_attachment_path(&Platform::macOS, &db_path, Some("custom/root")),
851            Some("custom/root/StickerCache/a/b/c.png".to_string())
852        );
853    }
854
855    #[test]
856    fn can_get_resolved_path_macos_raw() {
857        let db_path = PathBuf::from("fake_root");
858        let mut attachment = sample_attachment();
859        attachment.filename = Some("~/a/b/c.png".to_string());
860
861        assert!(
862            attachment
863                .resolved_attachment_path(&Platform::macOS, &db_path, None)
864                .unwrap()
865                .len()
866                > attachment.filename.unwrap().len()
867        );
868    }
869
870    #[test]
871    fn can_get_resolved_path_macos_raw_tilde() {
872        let db_path = PathBuf::from("fake_root");
873        let mut attachment = sample_attachment();
874        attachment.filename = Some("~/a/b/c~d.png".to_string());
875
876        assert!(
877            attachment
878                .resolved_attachment_path(&Platform::macOS, &db_path, None)
879                .unwrap()
880                .ends_with("c~d.png")
881        );
882    }
883
884    #[test]
885    fn can_get_resolved_path_ios() {
886        let db_path = PathBuf::from("fake_root");
887        let attachment = sample_attachment();
888
889        assert_eq!(
890            attachment.resolved_attachment_path(&Platform::iOS, &db_path, None),
891            Some("fake_root/41/41746ffc65924078eae42725c979305626f57cca".to_string())
892        );
893    }
894
895    #[test]
896    fn can_get_resolved_path_ios_custom() {
897        let db_path = PathBuf::from("fake_root");
898        let attachment = sample_attachment();
899
900        // iOS Backups store attachments at the same level as the database file, so if the backup
901        // is intact, the custom root is not relevant
902        assert_eq!(
903            attachment.resolved_attachment_path(&Platform::iOS, &db_path, Some("custom/root")),
904            Some("fake_root/41/41746ffc65924078eae42725c979305626f57cca".to_string())
905        );
906    }
907
908    #[test]
909    fn can_get_resolved_path_ios_custom_ignores_prefixed_path() {
910        let db_path = PathBuf::from("fake_root");
911        let mut attachment = sample_attachment();
912        attachment.filename = Some(format!("{DEFAULT_ATTACHMENT_ROOT}/a/b/c.png"));
913        let expected = attachment.resolved_attachment_path(&Platform::iOS, &db_path, None);
914
915        // Custom attachment roots do not apply to iOS backups, even when the stored filename
916        // resembles a macOS-style attachment path.
917        assert_eq!(
918            attachment.resolved_attachment_path(&Platform::iOS, &db_path, Some("/custom/root")),
919            expected
920        );
921    }
922
923    #[test]
924    fn can_get_resolved_path_ios_smsdb() {
925        let db_path = PathBuf::from("fake_root");
926        let mut attachment = sample_attachment();
927        attachment.filename = Some(format!("{DEFAULT_SMS_ROOT}/Attachments/a/b/c.png"));
928
929        assert_eq!(
930            attachment.resolved_attachment_path(
931                // A jailbroken iOS sms.db uses `Platform::macOS` conventions, not `Platform::iOS`,
932                // since the attachments are stored in direct filesystem paths, not SHA-1 hashed backup paths
933                &Platform::macOS,
934                &db_path,
935                Some("/custom/path"),
936            ),
937            Some("/custom/path/Attachments/a/b/c.png".to_string())
938        );
939    }
940
941    #[test]
942    fn cant_get_missing_resolved_path_macos() {
943        let db_path = PathBuf::from("fake_root");
944        let mut attachment = sample_attachment();
945        attachment.filename = None;
946
947        assert_eq!(
948            attachment.resolved_attachment_path(&Platform::macOS, &db_path, None),
949            None
950        );
951    }
952
953    #[test]
954    fn cant_get_missing_resolved_path_ios() {
955        let db_path = PathBuf::from("fake_root");
956        let mut attachment = sample_attachment();
957        attachment.filename = None;
958
959        assert_eq!(
960            attachment.resolved_attachment_path(&Platform::iOS, &db_path, None),
961            None
962        );
963    }
964
965    #[test]
966    fn can_get_attachment_bytes_no_filter() {
967        let db_path = current_dir()
968            .unwrap()
969            .parent()
970            .unwrap()
971            .join("imessage-database/test_data/db/test.db");
972        let connection = get_connection(&db_path).unwrap();
973
974        let context = QueryContext::default();
975
976        assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
977    }
978
979    #[test]
980    fn can_get_attachment_bytes_start_filter() {
981        let db_path = current_dir()
982            .unwrap()
983            .parent()
984            .unwrap()
985            .join("imessage-database/test_data/db/test.db");
986        let connection = get_connection(&db_path).unwrap();
987
988        let mut context = QueryContext::default();
989        context.set_start("2020-01-01").unwrap();
990
991        assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
992    }
993
994    #[test]
995    fn can_get_attachment_bytes_end_filter() {
996        let db_path = current_dir()
997            .unwrap()
998            .parent()
999            .unwrap()
1000            .join("imessage-database/test_data/db/test.db");
1001        let connection = get_connection(&db_path).unwrap();
1002
1003        let mut context = QueryContext::default();
1004        context.set_end("2020-01-01").unwrap();
1005
1006        assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
1007    }
1008
1009    #[test]
1010    fn can_get_attachment_bytes_start_end_filter() {
1011        let db_path = current_dir()
1012            .unwrap()
1013            .parent()
1014            .unwrap()
1015            .join("imessage-database/test_data/db/test.db");
1016        let connection = get_connection(&db_path).unwrap();
1017
1018        let mut context = QueryContext::default();
1019        context.set_start("2020-01-01").unwrap();
1020        context.set_end("2021-01-01").unwrap();
1021
1022        assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
1023    }
1024
1025    #[test]
1026    fn can_get_attachment_bytes_contact_filter() {
1027        let db_path = current_dir()
1028            .unwrap()
1029            .parent()
1030            .unwrap()
1031            .join("imessage-database/test_data/db/test.db");
1032        let connection = get_connection(&db_path).unwrap();
1033
1034        let mut context = QueryContext::default();
1035        context.set_selected_chat_ids(BTreeSet::from([1, 2, 3]));
1036        context.set_selected_handle_ids(BTreeSet::from([1, 2, 3]));
1037
1038        assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
1039    }
1040
1041    #[test]
1042    fn can_get_attachment_bytes_contact_date_filter() {
1043        let db_path = current_dir()
1044            .unwrap()
1045            .parent()
1046            .unwrap()
1047            .join("imessage-database/test_data/db/test.db");
1048        let connection = get_connection(&db_path).unwrap();
1049
1050        let mut context = QueryContext::default();
1051        context.set_start("2020-01-01").unwrap();
1052        context.set_end("2021-01-01").unwrap();
1053        context.set_selected_chat_ids(BTreeSet::from([1, 2, 3]));
1054        context.set_selected_handle_ids(BTreeSet::from([1, 2, 3]));
1055
1056        assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
1057    }
1058
1059    #[test]
1060    fn can_get_file_size_bytes() {
1061        let attachment = sample_attachment();
1062
1063        assert_eq!(attachment.file_size(), String::from("100.00 B"));
1064    }
1065
1066    #[test]
1067    fn can_get_file_size_kb() {
1068        let mut attachment = sample_attachment();
1069        attachment.total_bytes = 2300;
1070
1071        assert_eq!(attachment.file_size(), String::from("2.25 KB"));
1072    }
1073
1074    #[test]
1075    fn can_get_file_size_mb() {
1076        let mut attachment = sample_attachment();
1077        attachment.total_bytes = 5612000;
1078
1079        assert_eq!(attachment.file_size(), String::from("5.35 MB"));
1080    }
1081
1082    #[test]
1083    fn can_get_file_size_gb() {
1084        let mut attachment: Attachment = sample_attachment();
1085        attachment.total_bytes = 9234712394;
1086
1087        assert_eq!(attachment.file_size(), String::from("8.60 GB"));
1088    }
1089
1090    #[test]
1091    fn can_get_file_size_cap() {
1092        let mut attachment: Attachment = sample_attachment();
1093        attachment.total_bytes = i64::MAX;
1094
1095        assert_eq!(attachment.file_size(), String::from("8388608.00 TB"));
1096    }
1097
1098    fn attribution_fixture(name: &str) -> Value {
1099        let plist_path = current_dir()
1100            .unwrap()
1101            .parent()
1102            .unwrap()
1103            .join(format!("imessage-database/test_data/attribution/{name}"));
1104        Value::from_reader(File::open(plist_path).unwrap()).unwrap()
1105    }
1106
1107    fn test_db() -> rusqlite::Connection {
1108        let db_path = current_dir()
1109            .unwrap()
1110            .parent()
1111            .unwrap()
1112            .join("imessage-database/test_data/db/test.db");
1113        get_connection(&db_path).unwrap()
1114    }
1115
1116    #[test]
1117    fn can_parse_drawing_attribution() {
1118        let attribution = AttachmentAttribution::from_plist(&attribution_fixture("Drawing.plist"))
1119            .expect("Drawing.plist names a source app");
1120
1121        assert_eq!(
1122            attribution.bundle_id,
1123            "com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.PaperKit.MessagesDrawingBoard"
1124        );
1125        assert_eq!(attribution.name.as_deref(), Some("Drawing"));
1126        assert_eq!(attribution.adam_id, None);
1127        assert_eq!(
1128            attribution.app_bundle_id(),
1129            Some("com.apple.PaperKit.MessagesDrawingBoard")
1130        );
1131        assert!(attribution.is_drawing());
1132    }
1133
1134    #[test]
1135    fn can_parse_sticker_attribution() {
1136        let attribution = AttachmentAttribution::from_plist(&attribution_fixture("Sticker.plist"))
1137            .expect("Sticker.plist names a source app");
1138
1139        assert_eq!(attribution.name.as_deref(), Some("Free People"));
1140        assert_eq!(attribution.adam_id, Some(659_532_790));
1141        assert_eq!(
1142            attribution.app_bundle_id(),
1143            Some("com.freepeople.iosapp-production.stickers")
1144        );
1145        assert!(!attribution.is_drawing());
1146    }
1147
1148    #[test]
1149    fn cant_parse_attribution_without_bundle_id() {
1150        let mut payload = Dictionary::new();
1151        payload.insert("pgensh".to_string(), Value::Integer(1024.into()));
1152        payload.insert("pgensw".to_string(), Value::Integer(1024.into()));
1153
1154        assert_eq!(
1155            AttachmentAttribution::from_plist(&Value::Dictionary(payload)),
1156            None
1157        );
1158    }
1159
1160    #[test]
1161    fn can_get_drawing_attribution_from_db() {
1162        let db = test_db();
1163        let mut attachment = sample_attachment();
1164        attachment.rowid = 4;
1165
1166        let attribution = attachment
1167            .get_attribution(&db)
1168            .expect("attachment 4 is a drawing");
1169
1170        assert!(attribution.is_drawing());
1171        assert_eq!(attribution.name.as_deref(), Some("Drawing"));
1172    }
1173
1174    #[test]
1175    fn can_get_sticker_attribution_from_db() {
1176        let db = test_db();
1177        let attachment = sample_attachment();
1178
1179        let attribution = attachment
1180            .get_attribution(&db)
1181            .expect("attachment 1 is an app sticker");
1182
1183        assert!(!attribution.is_drawing());
1184        assert_eq!(attribution.adam_id, Some(659_532_790));
1185        assert_eq!(
1186            attachment.get_sticker_source_application_name(&db),
1187            Some("Free People".to_string())
1188        );
1189    }
1190
1191    #[test]
1192    fn cant_get_attribution_from_db_without_column() {
1193        let db = test_db();
1194        let mut attachment = sample_attachment();
1195        attachment.rowid = 2;
1196
1197        assert_eq!(attachment.get_attribution(&db), None);
1198        assert_eq!(attachment.get_sticker_source_application_name(&db), None);
1199    }
1200}