1use plist::Value;
6use rusqlite::{CachedStatement, Connection, Error, Result, Row};
7use sha1::{Digest, Sha1};
8
9use std::{
10 borrow::Cow,
11 fs::File,
12 io::Read,
13 path::{Path, PathBuf},
14};
15
16use crate::{
17 error::{attachment::AttachmentError, table::TableError},
18 message_types::sticker::{StickerDecoration, StickerEffect, StickerSource, get_sticker_effect},
19 tables::{
20 capabilities::Capabilities,
21 diagnostic::AttachmentDiagnostic,
22 messages::Message,
23 table::{
24 ATTACHMENT, ATTRIBUTION_INFO, CHAT_MESSAGE_JOIN, MESSAGE, MESSAGE_ATTACHMENT_JOIN,
25 STICKER_USER_INFO, Table,
26 },
27 },
28 util::{
29 bundle_id::parse_balloon_bundle_id,
30 dirs::home,
31 platform::Platform,
32 plist::{get_owned_string_from_dict, get_value_from_dict, plist_as_dictionary},
33 query_context::QueryContext,
34 size::format_file_size,
35 },
36};
37
38pub const DEFAULT_MESSAGES_ROOT: &str = "~/Library/Messages";
41pub const DEFAULT_SMS_ROOT: &str = "~/Library/SMS";
46pub const DEFAULT_ATTACHMENT_ROOT: &str = "~/Library/Messages/Attachments";
48pub const DEFAULT_STICKER_CACHE_ROOT: &str = "~/Library/Messages/StickerCache";
50pub(crate) const ATTACHMENT_COLUMNS: [&str; 10] = [
52 "rowid",
53 "guid",
54 "filename",
55 "uti",
56 "mime_type",
57 "transfer_name",
58 "total_bytes",
59 "is_sticker",
60 "hide_attachment",
61 "emoji_image_short_description",
62];
63
64#[derive(Debug, PartialEq, Eq)]
70pub enum MediaType<'a> {
71 Image(&'a str),
73 Video(&'a str),
75 Audio(&'a str),
77 Text(&'a str),
79 Application(&'a str),
81 Other(&'a str),
83 Unknown,
85}
86
87impl MediaType<'_> {
88 #[must_use]
98 pub fn as_mime_type(&self) -> String {
99 match self {
100 MediaType::Image(subtype) => format!("image/{subtype}"),
101 MediaType::Video(subtype) => format!("video/{subtype}"),
102 MediaType::Audio(subtype) => format!("audio/{subtype}"),
103 MediaType::Text(subtype) => format!("text/{subtype}"),
104 MediaType::Application(subtype) => format!("application/{subtype}"),
105 MediaType::Other(mime) => (*mime).to_string(),
106 MediaType::Unknown => String::new(),
107 }
108 }
109}
110
111#[derive(Debug, PartialEq, Eq)]
114pub struct AttachmentAttribution {
115 pub bundle_id: String,
117 pub name: Option<String>,
119 pub adam_id: Option<i64>,
121}
122
123impl AttachmentAttribution {
124 #[must_use]
129 pub fn from_plist(payload: &Value) -> Option<Self> {
130 Some(Self {
131 bundle_id: get_owned_string_from_dict(payload, "bundle-id")?,
132 name: get_owned_string_from_dict(payload, "name"),
133 adam_id: get_value_from_dict(payload, "adam-id").and_then(Value::as_signed_integer),
134 })
135 }
136
137 #[must_use]
140 pub fn app_bundle_id(&self) -> Option<&str> {
141 parse_balloon_bundle_id(Some(&self.bundle_id))
142 }
143
144 #[must_use]
146 pub fn is_drawing(&self) -> bool {
147 self.app_bundle_id() == Some("com.apple.PaperKit.MessagesDrawingBoard")
148 }
149}
150
151#[derive(Debug)]
153pub struct Attachment {
154 pub rowid: i32,
156 pub guid: Option<String>,
161 pub filename: Option<String>,
163 pub uti: Option<String>,
165 pub mime_type: Option<String>,
167 pub transfer_name: Option<String>,
169 pub total_bytes: i64,
171 pub is_sticker: bool,
173 pub hide_attachment: i32,
175 pub emoji_description: Option<String>,
177 pub copied_path: Option<PathBuf>,
179}
180
181impl Table for Attachment {
183 fn from_row(row: &Row) -> Result<Attachment> {
184 Ok(Attachment {
185 rowid: row.get("rowid")?,
186 guid: row.get("guid").unwrap_or(None),
187 filename: row.get("filename").unwrap_or(None),
188 uti: row.get("uti").unwrap_or(None),
189 mime_type: row.get("mime_type").unwrap_or(None),
190 transfer_name: row.get("transfer_name").unwrap_or(None),
191 total_bytes: row.get("total_bytes").unwrap_or_default(),
192 is_sticker: row.get("is_sticker").unwrap_or(false),
193 hide_attachment: row.get("hide_attachment").unwrap_or(0),
194 emoji_description: row.get("emoji_image_short_description").unwrap_or(None),
195 copied_path: None,
196 })
197 }
198
199 fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
200 Ok(db.prepare_cached(&format!("SELECT * from {ATTACHMENT}"))?)
201 }
202}
203
204impl Attachment {
206 pub fn from_message(
217 db: &Connection,
218 msg: &Message,
219 capabilities: &Capabilities,
220 ) -> Result<Vec<Attachment>, TableError> {
221 let mut out_l = vec![];
222 if msg.has_attachments() {
223 let projection = capabilities
224 .attachment_columns()
225 .iter()
226 .map(|column| format!("a.{column}"))
227 .collect::<Vec<String>>()
228 .join(", ");
229
230 let mut statement = db.prepare_cached(&format!(
231 "
232 SELECT {projection}
233 FROM {MESSAGE_ATTACHMENT_JOIN} j
234 LEFT JOIN {ATTACHMENT} a ON j.attachment_id = a.ROWID
235 WHERE j.message_id = ?1
236 ",
237 ))?;
238
239 for attachment in Attachment::rows(&mut statement, [msg.rowid])? {
240 out_l.push(attachment?);
241 }
242 }
243 Ok(out_l)
244 }
245
246 #[must_use]
248 pub fn mime_type(&'_ self) -> MediaType<'_> {
249 match &self.mime_type {
250 Some(mime) => {
251 let mut mime_parts = mime.split('/');
252 if let (Some(category), Some(subtype)) = (mime_parts.next(), mime_parts.next()) {
253 match category {
254 "image" => MediaType::Image(subtype),
255 "video" => MediaType::Video(subtype),
256 "audio" => MediaType::Audio(subtype),
257 "text" => MediaType::Text(subtype),
258 "application" => MediaType::Application(subtype),
259 _ => MediaType::Other(mime),
260 }
261 } else {
262 MediaType::Other(mime)
263 }
264 }
265 None => {
266 if let Some(uti) = &self.uti {
267 match uti.as_str() {
268 "com.apple.coreaudio-format" => MediaType::Audio("x-caf; codecs=opus"),
270 _ => MediaType::Unknown,
271 }
272 } else {
273 MediaType::Unknown
274 }
275 }
276 }
277 }
278
279 #[must_use]
284 pub fn is_animated_sticker(&self) -> bool {
285 self.is_sticker
286 && matches!(
287 self.mime_type(),
288 MediaType::Image("heics" | "HEICS" | "heic-sequence") | MediaType::Video(_),
289 )
290 }
291
292 pub fn as_bytes(
297 &self,
298 platform: &Platform,
299 db_path: &Path,
300 custom_attachment_root: Option<&str>,
301 ) -> Result<Option<Vec<u8>>, AttachmentError> {
302 if let Some(file_path) =
303 self.resolved_attachment_path(platform, db_path, custom_attachment_root)
304 {
305 let mut file = File::open(&file_path)
306 .map_err(|err| AttachmentError::Unreadable(file_path.clone(), err))?;
307 let mut bytes = vec![];
308 file.read_to_end(&mut bytes)
309 .map_err(|err| AttachmentError::Unreadable(file_path.clone(), err))?;
310
311 return Ok(Some(bytes));
312 }
313 Ok(None)
314 }
315
316 pub fn get_sticker_effect(
321 &self,
322 platform: &Platform,
323 db_path: &Path,
324 custom_attachment_root: Option<&str>,
325 ) -> Result<Option<StickerEffect>, AttachmentError> {
326 if !self.is_sticker {
328 return Ok(None);
329 }
330
331 if let Some(data) = self.as_bytes(platform, db_path, custom_attachment_root)? {
333 return Ok(Some(get_sticker_effect(&data)));
334 }
335
336 Ok(Some(StickerEffect::default()))
338 }
339
340 #[must_use]
342 pub fn path(&self) -> Option<&Path> {
343 match &self.filename {
344 Some(name) => Some(Path::new(name)),
345 None => None,
346 }
347 }
348
349 #[must_use]
351 pub fn extension(&self) -> Option<&str> {
352 match self.path() {
353 Some(path) => match path.extension() {
354 Some(ext) => ext.to_str(),
355 None => None,
356 },
357 None => None,
358 }
359 }
360
361 #[must_use]
366 pub fn filename(&self) -> Option<&str> {
367 self.transfer_name.as_deref().or(self.filename.as_deref())
368 }
369
370 #[must_use]
372 pub fn file_size(&self) -> String {
373 format_file_size(u64::try_from(self.total_bytes).unwrap_or(0))
374 }
375
376 pub fn get_total_attachment_bytes(
382 db: &Connection,
383 context: &QueryContext,
384 ) -> Result<u64, TableError> {
385 let statement = if context.has_filters() {
386 format!(
387 "SELECT IFNULL(SUM(a.total_bytes), 0) FROM {ATTACHMENT} a \
388 WHERE a.ROWID IN ( \
389 SELECT maj.attachment_id \
390 FROM {MESSAGE_ATTACHMENT_JOIN} maj \
391 JOIN {MESSAGE} m ON m.ROWID = maj.message_id \
392 LEFT JOIN {CHAT_MESSAGE_JOIN} c ON c.message_id = m.ROWID \
393 {} \
394 )",
395 Message::generate_filter_statement(context, false)
396 )
397 } else {
398 format!("SELECT IFNULL(SUM(total_bytes), 0) FROM {ATTACHMENT}")
399 };
400
401 let mut bytes_query = db.prepare(&statement)?;
402 Ok(bytes_query
403 .query_row([], |r| -> Result<i64> { r.get(0) })
404 .map(|res: i64| u64::try_from(res).unwrap_or(0))?)
405 }
406
407 #[must_use]
428 pub fn resolved_attachment_path(
429 &self,
430 platform: &Platform,
431 db_path: &Path,
432 custom_attachment_root: Option<&str>,
433 ) -> Option<String> {
434 let mut path_str = self.filename.clone()?;
435
436 if matches!(platform, Platform::macOS)
438 && let Some(custom_attachment_path) = custom_attachment_root
439 {
440 path_str =
441 Attachment::apply_custom_root(&path_str, custom_attachment_path).into_owned();
442 }
443
444 match platform {
445 Platform::macOS => Some(Attachment::gen_macos_attachment(&path_str)),
446 Platform::iOS => Attachment::gen_ios_attachment(&path_str, db_path),
447 }
448 }
449
450 pub fn run_diagnostic(
470 db: &Connection,
471 db_path: &Path,
472 platform: &Platform,
473 custom_attachment_root: Option<&str>,
474 ) -> Result<AttachmentDiagnostic, TableError> {
475 let mut total_attachments = 0usize;
476 let mut no_path_provided = 0usize;
477 let mut total_bytes_on_disk: u64 = 0;
478 let mut statement_paths = db.prepare(&format!("SELECT filename FROM {ATTACHMENT}"))?;
479 let paths = statement_paths.query_map([], |r| Ok(r.get(0)))?;
480
481 let missing_files = paths
482 .filter_map(Result::ok)
483 .filter(|path: &Result<String, Error>| {
484 total_attachments += 1;
486 if let Ok(filepath) = path {
487 match platform {
488 Platform::macOS => {
489 let path = match custom_attachment_root {
490 Some(custom_root) => Attachment::gen_macos_attachment(
491 &Attachment::apply_custom_root(filepath, custom_root),
492 ),
493 None => Attachment::gen_macos_attachment(filepath),
494 };
495 let file = Path::new(&path);
496 match file.metadata() {
497 Ok(metadata) => {
498 total_bytes_on_disk += metadata.len();
499 false
500 }
501 Err(_) => true,
502 }
503 }
504 Platform::iOS => {
505 if let Some(parsed_path) =
506 Attachment::gen_ios_attachment(filepath, db_path)
507 {
508 let file = Path::new(&parsed_path);
509 return match file.metadata() {
510 Ok(metadata) => {
511 total_bytes_on_disk += metadata.len();
512 false
513 }
514 Err(_) => true,
515 };
516 }
517 true
519 }
520 }
521 } else {
522 no_path_provided += 1;
524 true
525 }
526 })
527 .count();
528
529 let total_bytes_referenced =
530 Attachment::get_total_attachment_bytes(db, &QueryContext::default()).unwrap_or(0);
531
532 Ok(AttachmentDiagnostic {
533 total_attachments,
534 total_bytes_referenced,
535 total_bytes_on_disk,
536 missing_files,
537 no_path_provided,
538 })
539 }
540
541 fn apply_custom_root<'a>(path: &'a str, custom_root: &str) -> Cow<'a, str> {
543 let prefix = if path.starts_with(DEFAULT_MESSAGES_ROOT) {
544 Some(DEFAULT_MESSAGES_ROOT)
545 } else if path.starts_with(DEFAULT_SMS_ROOT) {
546 Some(DEFAULT_SMS_ROOT)
547 } else {
548 None
549 };
550 match prefix {
551 Some(old) => Cow::Owned(path.replacen(old, custom_root, 1)),
552 None => Cow::Borrowed(path),
553 }
554 }
555
556 fn gen_macos_attachment(path: &str) -> String {
558 if path.starts_with('~') {
559 return path.replacen('~', &home(), 1);
560 }
561 path.to_string()
562 }
563
564 fn gen_ios_attachment(file_path: &str, db_path: &Path) -> Option<String> {
566 let input = file_path.get(2..)?;
567 let digest = Sha1::digest(format!("MediaDomain-{input}").as_bytes());
568 let filename = digest
569 .iter()
570 .map(|byte| format!("{:02x}", byte))
571 .collect::<String>();
572 let directory = filename.get(0..2)?;
573
574 Some(format!("{}/{directory}/{filename}", db_path.display()))
575 }
576
577 fn sticker_info(&self, db: &Connection) -> Option<Value> {
583 Value::from_reader(self.get_blob(db, ATTACHMENT, STICKER_USER_INFO, self.rowid.into())?)
584 .ok()
585 }
586
587 fn attribution_info(&self, db: &Connection) -> Option<Value> {
591 Value::from_reader(self.get_blob(db, ATTACHMENT, ATTRIBUTION_INFO, self.rowid.into())?).ok()
592 }
593
594 pub fn get_sticker_source(&self, db: &Connection) -> Option<StickerSource> {
598 if let Some(sticker_info) = self.sticker_info(db) {
599 let plist = plist_as_dictionary(&sticker_info).ok()?;
600 let bundle_id = plist.get("pid")?.as_string()?;
601 return StickerSource::from_bundle_id(bundle_id);
602 }
603 None
604 }
605
606 pub fn get_attribution(&self, db: &Connection) -> Option<AttachmentAttribution> {
610 AttachmentAttribution::from_plist(&self.attribution_info(db)?)
611 }
612
613 pub fn get_sticker_source_application_name(&self, db: &Connection) -> Option<String> {
620 self.get_attribution(db)?.name
621 }
622
623 pub fn get_sticker_decoration(
640 &self,
641 db: &Connection,
642 platform: &Platform,
643 db_path: &Path,
644 attachment_root: Option<&str>,
645 ) -> Option<StickerDecoration> {
646 let source = self.get_sticker_source(db)?;
647 match source {
648 StickerSource::Genmoji => self
649 .emoji_description
650 .as_deref()
651 .map(|prompt| StickerDecoration::GenmojiPrompt(prompt.to_string())),
652 StickerSource::Memoji => Some(StickerDecoration::Memoji),
653 StickerSource::UserGenerated => self
654 .get_sticker_effect(platform, db_path, attachment_root)
655 .ok()
656 .flatten()
657 .map(StickerDecoration::Effect),
658 StickerSource::App(bundle_id) => Some(StickerDecoration::AppName(
659 self.get_sticker_source_application_name(db)
660 .unwrap_or(bundle_id),
661 )),
662 }
663 }
664}
665
666#[cfg(test)]
668mod tests {
669 use crate::{
670 tables::{
671 attachment::{
672 Attachment, AttachmentAttribution, DEFAULT_ATTACHMENT_ROOT, DEFAULT_SMS_ROOT,
673 DEFAULT_STICKER_CACHE_ROOT, MediaType,
674 },
675 table::get_connection,
676 },
677 util::{platform::Platform, query_context::QueryContext},
678 };
679
680 use plist::{Dictionary, Value};
681
682 use std::{
683 collections::BTreeSet,
684 env::current_dir,
685 fs::File,
686 path::{Path, PathBuf},
687 };
688
689 fn sample_attachment() -> Attachment {
690 Attachment {
691 rowid: 1,
692 guid: None,
693 filename: Some("a/b/c.png".to_string()),
694 uti: Some("public.png".to_string()),
695 mime_type: Some("image/png".to_string()),
696 transfer_name: Some("c.png".to_string()),
697 total_bytes: 100,
698 is_sticker: false,
699 hide_attachment: 0,
700 emoji_description: None,
701 copied_path: None,
702 }
703 }
704
705 #[test]
706 fn can_get_path() {
707 let attachment = sample_attachment();
708 assert_eq!(attachment.path(), Some(Path::new("a/b/c.png")));
709 }
710
711 #[test]
712 fn cant_get_path_missing() {
713 let mut attachment = sample_attachment();
714 attachment.filename = None;
715 assert_eq!(attachment.path(), None);
716 }
717
718 #[test]
719 fn can_get_extension() {
720 let attachment = sample_attachment();
721 assert_eq!(attachment.extension(), Some("png"));
722 }
723
724 #[test]
725 fn cant_get_extension_missing() {
726 let mut attachment = sample_attachment();
727 attachment.filename = None;
728 assert_eq!(attachment.extension(), None);
729 }
730
731 #[test]
732 fn can_get_mime_type_png() {
733 let attachment = sample_attachment();
734 assert_eq!(attachment.mime_type(), MediaType::Image("png"));
735 }
736
737 #[test]
738 fn can_get_mime_type_heic() {
739 let mut attachment = sample_attachment();
740 attachment.mime_type = Some("image/heic".to_string());
741 assert_eq!(attachment.mime_type(), MediaType::Image("heic"));
742 }
743
744 #[test]
745 fn can_get_mime_type_fake() {
746 let mut attachment = sample_attachment();
747 attachment.mime_type = Some("fake/bloop".to_string());
748 assert_eq!(attachment.mime_type(), MediaType::Other("fake/bloop"));
749 }
750
751 #[test]
752 fn can_get_mime_type_missing() {
753 let mut attachment = sample_attachment();
754 attachment.mime_type = None;
755 assert_eq!(attachment.mime_type(), MediaType::Unknown);
756 }
757
758 #[test]
759 fn is_animated_sticker_static_heic() {
760 let mut attachment = sample_attachment();
761 attachment.is_sticker = true;
762 attachment.mime_type = Some("image/heic".to_string());
763 assert!(!attachment.is_animated_sticker());
764 }
765
766 #[test]
767 fn is_animated_sticker_heic_sequence() {
768 let mut attachment = sample_attachment();
769 attachment.is_sticker = true;
770 attachment.mime_type = Some("image/heic-sequence".to_string());
771 assert!(attachment.is_animated_sticker());
772 }
773
774 #[test]
775 fn is_animated_sticker_video_memoji() {
776 let mut attachment = sample_attachment();
777 attachment.is_sticker = true;
778 attachment.mime_type = Some("video/quicktime".to_string());
779 assert!(attachment.is_animated_sticker());
780 }
781
782 #[test]
783 fn is_animated_sticker_requires_sticker_flag() {
784 let mut attachment = sample_attachment();
785 attachment.is_sticker = false;
786 attachment.mime_type = Some("video/quicktime".to_string());
787 assert!(!attachment.is_animated_sticker());
788 }
789
790 #[test]
791 fn can_get_filename() {
792 let attachment = sample_attachment();
793 assert_eq!(attachment.filename(), Some("c.png"));
794 }
795
796 #[test]
797 fn can_get_filename_no_transfer_name() {
798 let mut attachment = sample_attachment();
799 attachment.transfer_name = None;
800 assert_eq!(attachment.filename(), Some("a/b/c.png"));
801 }
802
803 #[test]
804 fn can_get_filename_no_filename() {
805 let mut attachment = sample_attachment();
806 attachment.filename = None;
807 assert_eq!(attachment.filename(), Some("c.png"));
808 }
809
810 #[test]
811 fn can_get_filename_no_meta() {
812 let mut attachment = sample_attachment();
813 attachment.transfer_name = None;
814 attachment.filename = None;
815 assert_eq!(attachment.filename(), None);
816 }
817
818 #[test]
819 fn can_get_resolved_path_macos() {
820 let db_path = PathBuf::from("fake_root");
821 let attachment = sample_attachment();
822
823 assert_eq!(
824 attachment.resolved_attachment_path(&Platform::macOS, &db_path, None),
825 Some("a/b/c.png".to_string())
826 );
827 }
828
829 #[test]
830 fn can_get_resolved_path_macos_custom() {
831 let db_path = PathBuf::from("fake_root");
832 let mut attachment = sample_attachment();
833 attachment.filename = Some(format!("{DEFAULT_ATTACHMENT_ROOT}/a/b/c.png"));
835
836 assert_eq!(
837 attachment.resolved_attachment_path(&Platform::macOS, &db_path, Some("custom/root")),
838 Some("custom/root/Attachments/a/b/c.png".to_string())
839 );
840 }
841
842 #[test]
843 fn can_get_resolved_path_macos_custom_sticker() {
844 let db_path = PathBuf::from("fake_root");
845 let mut attachment = sample_attachment();
846 attachment.filename = Some(format!("{DEFAULT_STICKER_CACHE_ROOT}/a/b/c.png"));
848
849 assert_eq!(
850 attachment.resolved_attachment_path(&Platform::macOS, &db_path, Some("custom/root")),
851 Some("custom/root/StickerCache/a/b/c.png".to_string())
852 );
853 }
854
855 #[test]
856 fn can_get_resolved_path_macos_raw() {
857 let db_path = PathBuf::from("fake_root");
858 let mut attachment = sample_attachment();
859 attachment.filename = Some("~/a/b/c.png".to_string());
860
861 assert!(
862 attachment
863 .resolved_attachment_path(&Platform::macOS, &db_path, None)
864 .unwrap()
865 .len()
866 > attachment.filename.unwrap().len()
867 );
868 }
869
870 #[test]
871 fn can_get_resolved_path_macos_raw_tilde() {
872 let db_path = PathBuf::from("fake_root");
873 let mut attachment = sample_attachment();
874 attachment.filename = Some("~/a/b/c~d.png".to_string());
875
876 assert!(
877 attachment
878 .resolved_attachment_path(&Platform::macOS, &db_path, None)
879 .unwrap()
880 .ends_with("c~d.png")
881 );
882 }
883
884 #[test]
885 fn can_get_resolved_path_ios() {
886 let db_path = PathBuf::from("fake_root");
887 let attachment = sample_attachment();
888
889 assert_eq!(
890 attachment.resolved_attachment_path(&Platform::iOS, &db_path, None),
891 Some("fake_root/41/41746ffc65924078eae42725c979305626f57cca".to_string())
892 );
893 }
894
895 #[test]
896 fn can_get_resolved_path_ios_custom() {
897 let db_path = PathBuf::from("fake_root");
898 let attachment = sample_attachment();
899
900 assert_eq!(
903 attachment.resolved_attachment_path(&Platform::iOS, &db_path, Some("custom/root")),
904 Some("fake_root/41/41746ffc65924078eae42725c979305626f57cca".to_string())
905 );
906 }
907
908 #[test]
909 fn can_get_resolved_path_ios_custom_ignores_prefixed_path() {
910 let db_path = PathBuf::from("fake_root");
911 let mut attachment = sample_attachment();
912 attachment.filename = Some(format!("{DEFAULT_ATTACHMENT_ROOT}/a/b/c.png"));
913 let expected = attachment.resolved_attachment_path(&Platform::iOS, &db_path, None);
914
915 assert_eq!(
918 attachment.resolved_attachment_path(&Platform::iOS, &db_path, Some("/custom/root")),
919 expected
920 );
921 }
922
923 #[test]
924 fn can_get_resolved_path_ios_smsdb() {
925 let db_path = PathBuf::from("fake_root");
926 let mut attachment = sample_attachment();
927 attachment.filename = Some(format!("{DEFAULT_SMS_ROOT}/Attachments/a/b/c.png"));
928
929 assert_eq!(
930 attachment.resolved_attachment_path(
931 &Platform::macOS,
934 &db_path,
935 Some("/custom/path"),
936 ),
937 Some("/custom/path/Attachments/a/b/c.png".to_string())
938 );
939 }
940
941 #[test]
942 fn cant_get_missing_resolved_path_macos() {
943 let db_path = PathBuf::from("fake_root");
944 let mut attachment = sample_attachment();
945 attachment.filename = None;
946
947 assert_eq!(
948 attachment.resolved_attachment_path(&Platform::macOS, &db_path, None),
949 None
950 );
951 }
952
953 #[test]
954 fn cant_get_missing_resolved_path_ios() {
955 let db_path = PathBuf::from("fake_root");
956 let mut attachment = sample_attachment();
957 attachment.filename = None;
958
959 assert_eq!(
960 attachment.resolved_attachment_path(&Platform::iOS, &db_path, None),
961 None
962 );
963 }
964
965 #[test]
966 fn can_get_attachment_bytes_no_filter() {
967 let db_path = current_dir()
968 .unwrap()
969 .parent()
970 .unwrap()
971 .join("imessage-database/test_data/db/test.db");
972 let connection = get_connection(&db_path).unwrap();
973
974 let context = QueryContext::default();
975
976 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
977 }
978
979 #[test]
980 fn can_get_attachment_bytes_start_filter() {
981 let db_path = current_dir()
982 .unwrap()
983 .parent()
984 .unwrap()
985 .join("imessage-database/test_data/db/test.db");
986 let connection = get_connection(&db_path).unwrap();
987
988 let mut context = QueryContext::default();
989 context.set_start("2020-01-01").unwrap();
990
991 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
992 }
993
994 #[test]
995 fn can_get_attachment_bytes_end_filter() {
996 let db_path = current_dir()
997 .unwrap()
998 .parent()
999 .unwrap()
1000 .join("imessage-database/test_data/db/test.db");
1001 let connection = get_connection(&db_path).unwrap();
1002
1003 let mut context = QueryContext::default();
1004 context.set_end("2020-01-01").unwrap();
1005
1006 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
1007 }
1008
1009 #[test]
1010 fn can_get_attachment_bytes_start_end_filter() {
1011 let db_path = current_dir()
1012 .unwrap()
1013 .parent()
1014 .unwrap()
1015 .join("imessage-database/test_data/db/test.db");
1016 let connection = get_connection(&db_path).unwrap();
1017
1018 let mut context = QueryContext::default();
1019 context.set_start("2020-01-01").unwrap();
1020 context.set_end("2021-01-01").unwrap();
1021
1022 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
1023 }
1024
1025 #[test]
1026 fn can_get_attachment_bytes_contact_filter() {
1027 let db_path = current_dir()
1028 .unwrap()
1029 .parent()
1030 .unwrap()
1031 .join("imessage-database/test_data/db/test.db");
1032 let connection = get_connection(&db_path).unwrap();
1033
1034 let mut context = QueryContext::default();
1035 context.set_selected_chat_ids(BTreeSet::from([1, 2, 3]));
1036 context.set_selected_handle_ids(BTreeSet::from([1, 2, 3]));
1037
1038 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
1039 }
1040
1041 #[test]
1042 fn can_get_attachment_bytes_contact_date_filter() {
1043 let db_path = current_dir()
1044 .unwrap()
1045 .parent()
1046 .unwrap()
1047 .join("imessage-database/test_data/db/test.db");
1048 let connection = get_connection(&db_path).unwrap();
1049
1050 let mut context = QueryContext::default();
1051 context.set_start("2020-01-01").unwrap();
1052 context.set_end("2021-01-01").unwrap();
1053 context.set_selected_chat_ids(BTreeSet::from([1, 2, 3]));
1054 context.set_selected_handle_ids(BTreeSet::from([1, 2, 3]));
1055
1056 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
1057 }
1058
1059 #[test]
1060 fn can_get_file_size_bytes() {
1061 let attachment = sample_attachment();
1062
1063 assert_eq!(attachment.file_size(), String::from("100.00 B"));
1064 }
1065
1066 #[test]
1067 fn can_get_file_size_kb() {
1068 let mut attachment = sample_attachment();
1069 attachment.total_bytes = 2300;
1070
1071 assert_eq!(attachment.file_size(), String::from("2.25 KB"));
1072 }
1073
1074 #[test]
1075 fn can_get_file_size_mb() {
1076 let mut attachment = sample_attachment();
1077 attachment.total_bytes = 5612000;
1078
1079 assert_eq!(attachment.file_size(), String::from("5.35 MB"));
1080 }
1081
1082 #[test]
1083 fn can_get_file_size_gb() {
1084 let mut attachment: Attachment = sample_attachment();
1085 attachment.total_bytes = 9234712394;
1086
1087 assert_eq!(attachment.file_size(), String::from("8.60 GB"));
1088 }
1089
1090 #[test]
1091 fn can_get_file_size_cap() {
1092 let mut attachment: Attachment = sample_attachment();
1093 attachment.total_bytes = i64::MAX;
1094
1095 assert_eq!(attachment.file_size(), String::from("8388608.00 TB"));
1096 }
1097
1098 fn attribution_fixture(name: &str) -> Value {
1099 let plist_path = current_dir()
1100 .unwrap()
1101 .parent()
1102 .unwrap()
1103 .join(format!("imessage-database/test_data/attribution/{name}"));
1104 Value::from_reader(File::open(plist_path).unwrap()).unwrap()
1105 }
1106
1107 fn test_db() -> rusqlite::Connection {
1108 let db_path = current_dir()
1109 .unwrap()
1110 .parent()
1111 .unwrap()
1112 .join("imessage-database/test_data/db/test.db");
1113 get_connection(&db_path).unwrap()
1114 }
1115
1116 #[test]
1117 fn can_parse_drawing_attribution() {
1118 let attribution = AttachmentAttribution::from_plist(&attribution_fixture("Drawing.plist"))
1119 .expect("Drawing.plist names a source app");
1120
1121 assert_eq!(
1122 attribution.bundle_id,
1123 "com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.PaperKit.MessagesDrawingBoard"
1124 );
1125 assert_eq!(attribution.name.as_deref(), Some("Drawing"));
1126 assert_eq!(attribution.adam_id, None);
1127 assert_eq!(
1128 attribution.app_bundle_id(),
1129 Some("com.apple.PaperKit.MessagesDrawingBoard")
1130 );
1131 assert!(attribution.is_drawing());
1132 }
1133
1134 #[test]
1135 fn can_parse_sticker_attribution() {
1136 let attribution = AttachmentAttribution::from_plist(&attribution_fixture("Sticker.plist"))
1137 .expect("Sticker.plist names a source app");
1138
1139 assert_eq!(attribution.name.as_deref(), Some("Free People"));
1140 assert_eq!(attribution.adam_id, Some(659_532_790));
1141 assert_eq!(
1142 attribution.app_bundle_id(),
1143 Some("com.freepeople.iosapp-production.stickers")
1144 );
1145 assert!(!attribution.is_drawing());
1146 }
1147
1148 #[test]
1149 fn cant_parse_attribution_without_bundle_id() {
1150 let mut payload = Dictionary::new();
1151 payload.insert("pgensh".to_string(), Value::Integer(1024.into()));
1152 payload.insert("pgensw".to_string(), Value::Integer(1024.into()));
1153
1154 assert_eq!(
1155 AttachmentAttribution::from_plist(&Value::Dictionary(payload)),
1156 None
1157 );
1158 }
1159
1160 #[test]
1161 fn can_get_drawing_attribution_from_db() {
1162 let db = test_db();
1163 let mut attachment = sample_attachment();
1164 attachment.rowid = 4;
1165
1166 let attribution = attachment
1167 .get_attribution(&db)
1168 .expect("attachment 4 is a drawing");
1169
1170 assert!(attribution.is_drawing());
1171 assert_eq!(attribution.name.as_deref(), Some("Drawing"));
1172 }
1173
1174 #[test]
1175 fn can_get_sticker_attribution_from_db() {
1176 let db = test_db();
1177 let attachment = sample_attachment();
1178
1179 let attribution = attachment
1180 .get_attribution(&db)
1181 .expect("attachment 1 is an app sticker");
1182
1183 assert!(!attribution.is_drawing());
1184 assert_eq!(attribution.adam_id, Some(659_532_790));
1185 assert_eq!(
1186 attachment.get_sticker_source_application_name(&db),
1187 Some("Free People".to_string())
1188 );
1189 }
1190
1191 #[test]
1192 fn cant_get_attribution_from_db_without_column() {
1193 let db = test_db();
1194 let mut attachment = sample_attachment();
1195 attachment.rowid = 2;
1196
1197 assert_eq!(attachment.get_attribution(&db), None);
1198 assert_eq!(attachment.get_sticker_source_application_name(&db), None);
1199 }
1200}