Skip to main content

imessage_database/tables/
attachment.rs

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