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