imessage_database/tables/
attachment.rs

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