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