imessage_database/tables/
attachment.rs

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