1use plist::Value;
6use rusqlite::{Connection, Error, Result, Row};
7use sha1::{Digest, Sha1};
8
9use std::{borrow::Cow, fs::File, io::Read, path::Path};
10
11use crate::{
12 error::{attachment::AttachmentError, table::TableError},
13 message_types::sticker::{StickerDecoration, StickerEffect, StickerSource, get_sticker_effect},
14 tables::{
15 capabilities::Capabilities,
16 diagnostic::AttachmentDiagnostic,
17 messages::Message,
18 table::{
19 ATTACHMENT, ATTRIBUTION_INFO, CHAT_MESSAGE_JOIN, MESSAGE, MESSAGE_ATTACHMENT_JOIN,
20 STICKER_USER_INFO, Table, open_blob,
21 },
22 },
23 util::{
24 bundle_id::parse_balloon_bundle_id,
25 dirs::expand_home,
26 platform::Platform,
27 plist::{get_owned_string_from_dict, get_value_from_dict, plist_as_dictionary},
28 query_context::QueryContext,
29 size::format_file_size,
30 },
31};
32
33pub const DEFAULT_MESSAGES_ROOT: &str = "~/Library/Messages";
36pub const DEFAULT_SMS_ROOT: &str = "~/Library/SMS";
41pub const DEFAULT_ATTACHMENT_ROOT: &str = "~/Library/Messages/Attachments";
43pub const DEFAULT_STICKER_CACHE_ROOT: &str = "~/Library/Messages/StickerCache";
45pub(crate) const ATTACHMENT_COLUMNS: [&str; 10] = [
47 "rowid",
48 "guid",
49 "filename",
50 "uti",
51 "mime_type",
52 "transfer_name",
53 "total_bytes",
54 "is_sticker",
55 "hide_attachment",
56 "emoji_image_short_description",
57];
58
59#[derive(Debug, PartialEq, Eq)]
65pub enum MediaType<'a> {
66 Image(&'a str),
68 Video(&'a str),
70 Audio(&'a str),
72 Text(&'a str),
74 Application(&'a str),
76 Other(&'a str),
78 Unknown,
80}
81
82impl MediaType<'_> {
83 #[must_use]
93 pub fn as_mime_type(&self) -> String {
94 match self {
95 MediaType::Image(subtype) => format!("image/{subtype}"),
96 MediaType::Video(subtype) => format!("video/{subtype}"),
97 MediaType::Audio(subtype) => format!("audio/{subtype}"),
98 MediaType::Text(subtype) => format!("text/{subtype}"),
99 MediaType::Application(subtype) => format!("application/{subtype}"),
100 MediaType::Other(mime) => (*mime).to_string(),
101 MediaType::Unknown => String::new(),
102 }
103 }
104}
105
106#[derive(Debug, PartialEq, Eq)]
109pub struct AttachmentAttribution {
110 pub bundle_id: String,
112 pub name: Option<String>,
114 pub adam_id: Option<i64>,
116}
117
118impl AttachmentAttribution {
119 #[must_use]
124 pub fn from_plist(payload: &Value) -> Option<Self> {
125 Some(Self {
126 bundle_id: get_owned_string_from_dict(payload, "bundle-id")?,
127 name: get_owned_string_from_dict(payload, "name"),
128 adam_id: get_value_from_dict(payload, "adam-id").and_then(Value::as_signed_integer),
129 })
130 }
131
132 #[must_use]
135 pub fn app_bundle_id(&self) -> Option<&str> {
136 parse_balloon_bundle_id(Some(&self.bundle_id))
137 }
138
139 #[must_use]
141 pub fn is_drawing(&self) -> bool {
142 self.app_bundle_id() == Some("com.apple.PaperKit.MessagesDrawingBoard")
143 }
144}
145
146#[derive(Debug)]
148pub struct Attachment {
149 pub rowid: i32,
151 pub guid: Option<String>,
156 pub filename: Option<String>,
158 pub uti: Option<String>,
160 pub mime_type: Option<String>,
162 pub transfer_name: Option<String>,
164 pub total_bytes: i64,
166 pub is_sticker: bool,
168 pub hide_attachment: i32,
170 pub emoji_description: Option<String>,
172}
173
174impl Table for Attachment {
176 fn from_row(row: &Row) -> Result<Attachment> {
177 Ok(Attachment {
178 rowid: row.get("rowid")?,
179 guid: row.get("guid").unwrap_or(None),
180 filename: row.get("filename").unwrap_or(None),
181 uti: row.get("uti").unwrap_or(None),
182 mime_type: row.get("mime_type").unwrap_or(None),
183 transfer_name: row.get("transfer_name").unwrap_or(None),
184 total_bytes: row.get("total_bytes").unwrap_or_default(),
185 is_sticker: row.get("is_sticker").unwrap_or(false),
186 hide_attachment: row.get("hide_attachment").unwrap_or(0),
187 emoji_description: row.get("emoji_image_short_description").unwrap_or(None),
188 })
189 }
190}
191
192impl Attachment {
194 pub fn from_message(
205 db: &Connection,
206 msg: &Message,
207 capabilities: &Capabilities,
208 ) -> Result<Vec<Attachment>, TableError> {
209 let mut out_l = vec![];
210 if msg.has_attachments() {
211 let projection = capabilities
212 .attachment_columns()
213 .iter()
214 .map(|column| format!("a.{column}"))
215 .collect::<Vec<String>>()
216 .join(", ");
217
218 let mut statement = db.prepare_cached(&format!(
219 "
220 SELECT {projection}
221 FROM {MESSAGE_ATTACHMENT_JOIN} j
222 LEFT JOIN {ATTACHMENT} a ON j.attachment_id = a.ROWID
223 WHERE j.message_id = ?1
224 ",
225 ))?;
226
227 for attachment in Attachment::rows(&mut statement, [msg.rowid])? {
228 out_l.push(attachment?);
229 }
230 }
231 Ok(out_l)
232 }
233
234 #[must_use]
236 pub fn mime_type(&'_ self) -> MediaType<'_> {
237 match &self.mime_type {
238 Some(mime) => {
239 let mut mime_parts = mime.split('/');
240 if let (Some(category), Some(subtype)) = (mime_parts.next(), mime_parts.next()) {
241 match category {
242 "image" => MediaType::Image(subtype),
243 "video" => MediaType::Video(subtype),
244 "audio" => MediaType::Audio(subtype),
245 "text" => MediaType::Text(subtype),
246 "application" => MediaType::Application(subtype),
247 _ => MediaType::Other(mime),
248 }
249 } else {
250 MediaType::Other(mime)
251 }
252 }
253 None => {
254 if let Some(uti) = &self.uti {
255 match uti.as_str() {
256 "com.apple.coreaudio-format" => MediaType::Audio("x-caf; codecs=opus"),
258 _ => MediaType::Unknown,
259 }
260 } else {
261 MediaType::Unknown
262 }
263 }
264 }
265 }
266
267 #[must_use]
272 pub fn is_animated_sticker(&self) -> bool {
273 self.is_sticker
274 && matches!(
275 self.mime_type(),
276 MediaType::Image("heics" | "HEICS" | "heic-sequence") | MediaType::Video(_),
277 )
278 }
279
280 pub fn as_bytes(
285 &self,
286 platform: &Platform,
287 db_path: &Path,
288 custom_attachment_root: Option<&str>,
289 ) -> Result<Option<Vec<u8>>, AttachmentError> {
290 if let Some(file_path) =
291 self.resolved_attachment_path(platform, db_path, custom_attachment_root)
292 {
293 let mut file = File::open(&file_path)
294 .map_err(|err| AttachmentError::Unreadable(file_path.clone(), err))?;
295 let mut bytes = vec![];
296 file.read_to_end(&mut bytes)
297 .map_err(|err| AttachmentError::Unreadable(file_path.clone(), err))?;
298
299 return Ok(Some(bytes));
300 }
301 Ok(None)
302 }
303
304 pub fn get_sticker_effect(
309 &self,
310 platform: &Platform,
311 db_path: &Path,
312 custom_attachment_root: Option<&str>,
313 ) -> Result<Option<StickerEffect>, AttachmentError> {
314 if !self.is_sticker {
316 return Ok(None);
317 }
318
319 if let Some(data) = self.as_bytes(platform, db_path, custom_attachment_root)? {
321 return Ok(Some(get_sticker_effect(&data)));
322 }
323
324 Ok(Some(StickerEffect::default()))
326 }
327
328 #[must_use]
330 pub fn path(&self) -> Option<&Path> {
331 match &self.filename {
332 Some(name) => Some(Path::new(name)),
333 None => None,
334 }
335 }
336
337 #[must_use]
339 pub fn extension(&self) -> Option<&str> {
340 match self.path() {
341 Some(path) => match path.extension() {
342 Some(ext) => ext.to_str(),
343 None => None,
344 },
345 None => None,
346 }
347 }
348
349 #[must_use]
354 pub fn filename(&self) -> Option<&str> {
355 self.transfer_name.as_deref().or(self.filename.as_deref())
356 }
357
358 #[must_use]
360 pub fn file_size(&self) -> String {
361 format_file_size(u64::try_from(self.total_bytes).unwrap_or(0))
362 }
363
364 pub fn get_total_attachment_bytes(
370 db: &Connection,
371 context: &QueryContext,
372 ) -> Result<u64, TableError> {
373 let statement = if context.has_filters() {
374 format!(
375 "SELECT IFNULL(SUM(a.total_bytes), 0) FROM {ATTACHMENT} a \
376 WHERE a.ROWID IN ( \
377 SELECT maj.attachment_id \
378 FROM {MESSAGE_ATTACHMENT_JOIN} maj \
379 JOIN {MESSAGE} m ON m.ROWID = maj.message_id \
380 LEFT JOIN {CHAT_MESSAGE_JOIN} c ON c.message_id = m.ROWID \
381 {} \
382 )",
383 Message::generate_filter_statement(context, false)
384 )
385 } else {
386 format!("SELECT IFNULL(SUM(total_bytes), 0) FROM {ATTACHMENT}")
387 };
388
389 let mut bytes_query = db.prepare(&statement)?;
390 Ok(bytes_query
391 .query_row([], |r| -> Result<i64> { r.get(0) })
392 .map(|res: i64| u64::try_from(res).unwrap_or(0))?)
393 }
394
395 #[must_use]
416 pub fn resolved_attachment_path(
417 &self,
418 platform: &Platform,
419 db_path: &Path,
420 custom_attachment_root: Option<&str>,
421 ) -> Option<String> {
422 let mut path_str = self.filename.clone()?;
423
424 if matches!(platform, Platform::macOS)
426 && let Some(custom_attachment_path) = custom_attachment_root
427 {
428 path_str =
429 Attachment::apply_custom_root(&path_str, custom_attachment_path).into_owned();
430 }
431
432 match platform {
433 Platform::macOS => Some(Attachment::gen_macos_attachment(&path_str)),
434 Platform::iOS => Attachment::gen_ios_attachment(&path_str, db_path),
435 }
436 }
437
438 pub fn run_diagnostic(
458 db: &Connection,
459 db_path: &Path,
460 platform: &Platform,
461 custom_attachment_root: Option<&str>,
462 ) -> Result<AttachmentDiagnostic, TableError> {
463 let mut total_attachments = 0usize;
464 let mut no_path_provided = 0usize;
465 let mut total_bytes_on_disk: u64 = 0;
466 let mut statement_paths = db.prepare(&format!("SELECT filename FROM {ATTACHMENT}"))?;
467 let paths = statement_paths.query_map([], |r| Ok(r.get(0)))?;
468
469 let missing_files = paths
470 .filter_map(Result::ok)
471 .filter(|path: &Result<String, Error>| {
472 total_attachments += 1;
474 if let Ok(filepath) = path {
475 match platform {
476 Platform::macOS => {
477 let path = match custom_attachment_root {
478 Some(custom_root) => Attachment::gen_macos_attachment(
479 &Attachment::apply_custom_root(filepath, custom_root),
480 ),
481 None => Attachment::gen_macos_attachment(filepath),
482 };
483 let file = Path::new(&path);
484 match file.metadata() {
485 Ok(metadata) => {
486 total_bytes_on_disk += metadata.len();
487 false
488 }
489 Err(_) => true,
490 }
491 }
492 Platform::iOS => {
493 if let Some(parsed_path) =
494 Attachment::gen_ios_attachment(filepath, db_path)
495 {
496 let file = Path::new(&parsed_path);
497 return match file.metadata() {
498 Ok(metadata) => {
499 total_bytes_on_disk += metadata.len();
500 false
501 }
502 Err(_) => true,
503 };
504 }
505 true
507 }
508 }
509 } else {
510 no_path_provided += 1;
512 true
513 }
514 })
515 .count();
516
517 let total_bytes_referenced =
518 Attachment::get_total_attachment_bytes(db, &QueryContext::default()).unwrap_or(0);
519
520 Ok(AttachmentDiagnostic {
521 total_attachments,
522 total_bytes_referenced,
523 total_bytes_on_disk,
524 missing_files,
525 no_path_provided,
526 })
527 }
528
529 fn apply_custom_root<'a>(path: &'a str, custom_root: &str) -> Cow<'a, str> {
531 let prefix = if path.starts_with(DEFAULT_MESSAGES_ROOT) {
532 Some(DEFAULT_MESSAGES_ROOT)
533 } else if path.starts_with(DEFAULT_SMS_ROOT) {
534 Some(DEFAULT_SMS_ROOT)
535 } else {
536 None
537 };
538 match prefix {
539 Some(old) => Cow::Owned(path.replacen(old, custom_root, 1)),
540 None => Cow::Borrowed(path),
541 }
542 }
543
544 fn gen_macos_attachment(path: &str) -> String {
546 expand_home(path).into_owned()
547 }
548
549 fn gen_ios_attachment(file_path: &str, db_path: &Path) -> Option<String> {
551 let input = file_path.get(2..)?;
552 let digest = Sha1::digest(format!("MediaDomain-{input}").as_bytes());
553 let filename = digest
554 .iter()
555 .map(|byte| format!("{:02x}", byte))
556 .collect::<String>();
557 let directory = filename.get(0..2)?;
558
559 Some(format!("{}/{directory}/{filename}", db_path.display()))
560 }
561
562 fn sticker_info(&self, db: &Connection) -> Option<Value> {
568 Value::from_reader(open_blob(
569 db,
570 ATTACHMENT,
571 STICKER_USER_INFO,
572 self.rowid.into(),
573 )?)
574 .ok()
575 }
576
577 fn attribution_info(&self, db: &Connection) -> Option<Value> {
581 Value::from_reader(open_blob(
582 db,
583 ATTACHMENT,
584 ATTRIBUTION_INFO,
585 self.rowid.into(),
586 )?)
587 .ok()
588 }
589
590 pub fn get_sticker_source(&self, db: &Connection) -> Option<StickerSource> {
594 if let Some(sticker_info) = self.sticker_info(db) {
595 let plist = plist_as_dictionary(&sticker_info).ok()?;
596 let bundle_id = plist.get("pid")?.as_string()?;
597 return StickerSource::from_bundle_id(bundle_id);
598 }
599 None
600 }
601
602 pub fn get_attribution(&self, db: &Connection) -> Option<AttachmentAttribution> {
606 AttachmentAttribution::from_plist(&self.attribution_info(db)?)
607 }
608
609 pub fn get_sticker_source_application_name(&self, db: &Connection) -> Option<String> {
616 self.get_attribution(db)?.name
617 }
618
619 pub fn get_sticker_decoration(
636 &self,
637 db: &Connection,
638 platform: &Platform,
639 db_path: &Path,
640 attachment_root: Option<&str>,
641 ) -> Option<StickerDecoration> {
642 let source = self.get_sticker_source(db)?;
643 match source {
644 StickerSource::Genmoji => self
645 .emoji_description
646 .as_deref()
647 .map(|prompt| StickerDecoration::GenmojiPrompt(prompt.to_string())),
648 StickerSource::Memoji => Some(StickerDecoration::Memoji),
649 StickerSource::UserGenerated => self
650 .get_sticker_effect(platform, db_path, attachment_root)
651 .ok()
652 .flatten()
653 .map(StickerDecoration::Effect),
654 StickerSource::App(bundle_id) => Some(StickerDecoration::AppName(
655 self.get_sticker_source_application_name(db)
656 .unwrap_or(bundle_id),
657 )),
658 }
659 }
660}
661
662#[cfg(test)]
664mod tests {
665 use chrono::Utc;
666
667 use crate::{
668 tables::{
669 attachment::{
670 Attachment, AttachmentAttribution, DEFAULT_ATTACHMENT_ROOT, DEFAULT_SMS_ROOT,
671 DEFAULT_STICKER_CACHE_ROOT, MediaType,
672 },
673 table::get_connection,
674 },
675 util::{platform::Platform, query_context::QueryContext},
676 };
677
678 use plist::{Dictionary, Value};
679
680 use std::{
681 collections::BTreeSet,
682 env::current_dir,
683 fs::File,
684 path::{Path, PathBuf},
685 };
686
687 fn sample_attachment() -> Attachment {
688 Attachment {
689 rowid: 1,
690 guid: None,
691 filename: Some("a/b/c.png".to_string()),
692 uti: Some("public.png".to_string()),
693 mime_type: Some("image/png".to_string()),
694 transfer_name: Some("c.png".to_string()),
695 total_bytes: 100,
696 is_sticker: false,
697 hide_attachment: 0,
698 emoji_description: None,
699 }
700 }
701
702 #[test]
703 fn can_get_path() {
704 let attachment = sample_attachment();
705 assert_eq!(attachment.path(), Some(Path::new("a/b/c.png")));
706 }
707
708 #[test]
709 fn cant_get_path_missing() {
710 let mut attachment = sample_attachment();
711 attachment.filename = None;
712 assert_eq!(attachment.path(), None);
713 }
714
715 #[test]
716 fn can_get_extension() {
717 let attachment = sample_attachment();
718 assert_eq!(attachment.extension(), Some("png"));
719 }
720
721 #[test]
722 fn cant_get_extension_missing() {
723 let mut attachment = sample_attachment();
724 attachment.filename = None;
725 assert_eq!(attachment.extension(), None);
726 }
727
728 #[test]
729 fn can_get_mime_type_png() {
730 let attachment = sample_attachment();
731 assert_eq!(attachment.mime_type(), MediaType::Image("png"));
732 }
733
734 #[test]
735 fn can_get_mime_type_heic() {
736 let mut attachment = sample_attachment();
737 attachment.mime_type = Some("image/heic".to_string());
738 assert_eq!(attachment.mime_type(), MediaType::Image("heic"));
739 }
740
741 #[test]
742 fn can_get_mime_type_fake() {
743 let mut attachment = sample_attachment();
744 attachment.mime_type = Some("fake/bloop".to_string());
745 assert_eq!(attachment.mime_type(), MediaType::Other("fake/bloop"));
746 }
747
748 #[test]
749 fn can_get_mime_type_missing() {
750 let mut attachment = sample_attachment();
751 attachment.mime_type = None;
752 assert_eq!(attachment.mime_type(), MediaType::Unknown);
753 }
754
755 #[test]
756 fn is_animated_sticker_static_heic() {
757 let mut attachment = sample_attachment();
758 attachment.is_sticker = true;
759 attachment.mime_type = Some("image/heic".to_string());
760 assert!(!attachment.is_animated_sticker());
761 }
762
763 #[test]
764 fn is_animated_sticker_heic_sequence() {
765 let mut attachment = sample_attachment();
766 attachment.is_sticker = true;
767 attachment.mime_type = Some("image/heic-sequence".to_string());
768 assert!(attachment.is_animated_sticker());
769 }
770
771 #[test]
772 fn is_animated_sticker_video_memoji() {
773 let mut attachment = sample_attachment();
774 attachment.is_sticker = true;
775 attachment.mime_type = Some("video/quicktime".to_string());
776 assert!(attachment.is_animated_sticker());
777 }
778
779 #[test]
780 fn is_animated_sticker_requires_sticker_flag() {
781 let mut attachment = sample_attachment();
782 attachment.is_sticker = false;
783 attachment.mime_type = Some("video/quicktime".to_string());
784 assert!(!attachment.is_animated_sticker());
785 }
786
787 #[test]
788 fn can_get_filename() {
789 let attachment = sample_attachment();
790 assert_eq!(attachment.filename(), Some("c.png"));
791 }
792
793 #[test]
794 fn can_get_filename_no_transfer_name() {
795 let mut attachment = sample_attachment();
796 attachment.transfer_name = None;
797 assert_eq!(attachment.filename(), Some("a/b/c.png"));
798 }
799
800 #[test]
801 fn can_get_filename_no_filename() {
802 let mut attachment = sample_attachment();
803 attachment.filename = None;
804 assert_eq!(attachment.filename(), Some("c.png"));
805 }
806
807 #[test]
808 fn can_get_filename_no_meta() {
809 let mut attachment = sample_attachment();
810 attachment.transfer_name = None;
811 attachment.filename = None;
812 assert_eq!(attachment.filename(), None);
813 }
814
815 #[test]
816 fn can_get_resolved_path_macos() {
817 let db_path = PathBuf::from("fake_root");
818 let attachment = sample_attachment();
819
820 assert_eq!(
821 attachment.resolved_attachment_path(&Platform::macOS, &db_path, None),
822 Some("a/b/c.png".to_string())
823 );
824 }
825
826 #[test]
827 fn can_get_resolved_path_macos_custom() {
828 let db_path = PathBuf::from("fake_root");
829 let mut attachment = sample_attachment();
830 attachment.filename = Some(format!("{DEFAULT_ATTACHMENT_ROOT}/a/b/c.png"));
832
833 assert_eq!(
834 attachment.resolved_attachment_path(&Platform::macOS, &db_path, Some("custom/root")),
835 Some("custom/root/Attachments/a/b/c.png".to_string())
836 );
837 }
838
839 #[test]
840 fn can_get_resolved_path_macos_custom_sticker() {
841 let db_path = PathBuf::from("fake_root");
842 let mut attachment = sample_attachment();
843 attachment.filename = Some(format!("{DEFAULT_STICKER_CACHE_ROOT}/a/b/c.png"));
845
846 assert_eq!(
847 attachment.resolved_attachment_path(&Platform::macOS, &db_path, Some("custom/root")),
848 Some("custom/root/StickerCache/a/b/c.png".to_string())
849 );
850 }
851
852 #[test]
853 fn can_get_resolved_path_macos_raw() {
854 let db_path = PathBuf::from("fake_root");
855 let mut attachment = sample_attachment();
856 attachment.filename = Some("~/a/b/c.png".to_string());
857
858 assert!(
859 attachment
860 .resolved_attachment_path(&Platform::macOS, &db_path, None)
861 .unwrap()
862 .len()
863 > attachment.filename.unwrap().len()
864 );
865 }
866
867 #[test]
868 fn can_get_resolved_path_macos_raw_tilde() {
869 let db_path = PathBuf::from("fake_root");
870 let mut attachment = sample_attachment();
871 attachment.filename = Some("~/a/b/c~d.png".to_string());
872
873 assert!(
874 attachment
875 .resolved_attachment_path(&Platform::macOS, &db_path, None)
876 .unwrap()
877 .ends_with("c~d.png")
878 );
879 }
880
881 #[test]
882 fn can_get_resolved_path_ios() {
883 let db_path = PathBuf::from("fake_root");
884 let attachment = sample_attachment();
885
886 assert_eq!(
887 attachment.resolved_attachment_path(&Platform::iOS, &db_path, None),
888 Some("fake_root/41/41746ffc65924078eae42725c979305626f57cca".to_string())
889 );
890 }
891
892 #[test]
893 fn can_get_resolved_path_ios_custom_ignores_prefixed_path() {
894 let db_path = PathBuf::from("fake_root");
895 let mut attachment = sample_attachment();
896 attachment.filename = Some(format!("{DEFAULT_ATTACHMENT_ROOT}/a/b/c.png"));
897 let expected = attachment.resolved_attachment_path(&Platform::iOS, &db_path, None);
898
899 assert_eq!(
902 attachment.resolved_attachment_path(&Platform::iOS, &db_path, Some("/custom/root")),
903 expected
904 );
905 }
906
907 #[test]
908 fn can_get_resolved_path_ios_smsdb() {
909 let db_path = PathBuf::from("fake_root");
910 let mut attachment = sample_attachment();
911 attachment.filename = Some(format!("{DEFAULT_SMS_ROOT}/Attachments/a/b/c.png"));
912
913 assert_eq!(
914 attachment.resolved_attachment_path(
915 &Platform::macOS,
918 &db_path,
919 Some("/custom/path"),
920 ),
921 Some("/custom/path/Attachments/a/b/c.png".to_string())
922 );
923 }
924
925 #[test]
926 fn cant_get_missing_resolved_path_macos() {
927 let db_path = PathBuf::from("fake_root");
928 let mut attachment = sample_attachment();
929 attachment.filename = None;
930
931 assert_eq!(
932 attachment.resolved_attachment_path(&Platform::macOS, &db_path, None),
933 None
934 );
935 }
936
937 #[test]
938 fn can_get_attachment_bytes_no_filter() {
939 let db_path = current_dir()
940 .unwrap()
941 .parent()
942 .unwrap()
943 .join("imessage-database/test_data/db/test.db");
944 let connection = get_connection(&db_path).unwrap();
945
946 let context = QueryContext::default();
947
948 assert_eq!(
951 Attachment::get_total_attachment_bytes(&connection, &context).unwrap(),
952 715766
953 );
954 }
955
956 #[test]
957 fn can_get_attachment_bytes_contact_filter() {
958 let db_path = current_dir()
959 .unwrap()
960 .parent()
961 .unwrap()
962 .join("imessage-database/test_data/db/test.db");
963 let connection = get_connection(&db_path).unwrap();
964
965 let mut context = QueryContext::default();
966 context.set_selected_chat_ids(BTreeSet::from([1, 2, 3]));
967 context.set_selected_handle_ids(BTreeSet::from([1, 2, 3]));
968
969 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
970 }
971
972 #[test]
973 fn can_get_attachment_bytes_contact_date_filter() {
974 let db_path = current_dir()
975 .unwrap()
976 .parent()
977 .unwrap()
978 .join("imessage-database/test_data/db/test.db");
979 let connection = get_connection(&db_path).unwrap();
980
981 let mut context = QueryContext::default();
982 context.set_start("2020-01-01", &Utc).unwrap();
983 context.set_end("2021-01-01", &Utc).unwrap();
984 context.set_selected_chat_ids(BTreeSet::from([1, 2, 3]));
985 context.set_selected_handle_ids(BTreeSet::from([1, 2, 3]));
986
987 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
988 }
989
990 #[test]
991 fn can_get_file_size_cap() {
992 let mut attachment: Attachment = sample_attachment();
993 attachment.total_bytes = i64::MAX;
994
995 assert_eq!(attachment.file_size(), String::from("8388608.00 TB"));
996 }
997
998 fn attribution_fixture(name: &str) -> Value {
999 let plist_path = current_dir()
1000 .unwrap()
1001 .parent()
1002 .unwrap()
1003 .join(format!("imessage-database/test_data/attribution/{name}"));
1004 Value::from_reader(File::open(plist_path).unwrap()).unwrap()
1005 }
1006
1007 fn test_db() -> rusqlite::Connection {
1008 let db_path = current_dir()
1009 .unwrap()
1010 .parent()
1011 .unwrap()
1012 .join("imessage-database/test_data/db/test.db");
1013 get_connection(&db_path).unwrap()
1014 }
1015
1016 #[test]
1017 fn can_parse_drawing_attribution() {
1018 let attribution = AttachmentAttribution::from_plist(&attribution_fixture("Drawing.plist"))
1019 .expect("Drawing.plist names a source app");
1020
1021 assert_eq!(
1022 attribution.bundle_id,
1023 "com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.PaperKit.MessagesDrawingBoard"
1024 );
1025 assert_eq!(attribution.name.as_deref(), Some("Drawing"));
1026 assert_eq!(attribution.adam_id, None);
1027 assert_eq!(
1028 attribution.app_bundle_id(),
1029 Some("com.apple.PaperKit.MessagesDrawingBoard")
1030 );
1031 assert!(attribution.is_drawing());
1032 }
1033
1034 #[test]
1035 fn can_parse_sticker_attribution() {
1036 let attribution = AttachmentAttribution::from_plist(&attribution_fixture("Sticker.plist"))
1037 .expect("Sticker.plist names a source app");
1038
1039 assert_eq!(attribution.name.as_deref(), Some("Free People"));
1040 assert_eq!(attribution.adam_id, Some(659_532_790));
1041 assert_eq!(
1042 attribution.app_bundle_id(),
1043 Some("com.freepeople.iosapp-production.stickers")
1044 );
1045 assert!(!attribution.is_drawing());
1046 }
1047
1048 #[test]
1049 fn cant_parse_attribution_without_bundle_id() {
1050 let mut payload = Dictionary::new();
1051 payload.insert("pgensh".to_string(), Value::Integer(1024.into()));
1052 payload.insert("pgensw".to_string(), Value::Integer(1024.into()));
1053
1054 assert_eq!(
1055 AttachmentAttribution::from_plist(&Value::Dictionary(payload)),
1056 None
1057 );
1058 }
1059
1060 #[test]
1061 fn can_get_drawing_attribution_from_db() {
1062 let db = test_db();
1063 let mut attachment = sample_attachment();
1064 attachment.rowid = 4;
1065
1066 let attribution = attachment
1067 .get_attribution(&db)
1068 .expect("attachment 4 is a drawing");
1069
1070 assert!(attribution.is_drawing());
1071 assert_eq!(attribution.name.as_deref(), Some("Drawing"));
1072 }
1073
1074 #[test]
1075 fn can_get_sticker_attribution_from_db() {
1076 let db = test_db();
1077 let attachment = sample_attachment();
1078
1079 let attribution = attachment
1080 .get_attribution(&db)
1081 .expect("attachment 1 is an app sticker");
1082
1083 assert!(!attribution.is_drawing());
1084 assert_eq!(attribution.adam_id, Some(659_532_790));
1085 assert_eq!(
1086 attachment.get_sticker_source_application_name(&db),
1087 Some("Free People".to_string())
1088 );
1089 }
1090
1091 #[test]
1092 fn cant_get_attribution_from_db_without_column() {
1093 let db = test_db();
1094 let mut attachment = sample_attachment();
1095 attachment.rowid = 2;
1096
1097 assert_eq!(attachment.get_attribution(&db), None);
1098 assert_eq!(attachment.get_sticker_source_application_name(&db), None);
1099 }
1100}