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