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