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