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