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