1use 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
34pub const DEFAULT_ATTACHMENT_ROOT: &str = "~/Library/Messages/Attachments";
37const 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";
38
39#[derive(Debug, PartialEq, Eq)]
44pub enum MediaType<'a> {
45 Image(&'a str),
47 Video(&'a str),
49 Audio(&'a str),
51 Text(&'a str),
53 Application(&'a str),
55 Other(&'a str),
57 Unknown,
59}
60
61impl MediaType<'_> {
62 #[must_use]
72 pub fn as_mime_type(&self) -> String {
73 match self {
74 MediaType::Image(subtype) => format!("image/{subtype}"),
75 MediaType::Video(subtype) => format!("video/{subtype}"),
76 MediaType::Audio(subtype) => format!("audio/{subtype}"),
77 MediaType::Text(subtype) => format!("text/{subtype}"),
78 MediaType::Application(subtype) => format!("application/{subtype}"),
79 MediaType::Other(mime) => (*mime).to_string(),
80 MediaType::Unknown => String::new(),
81 }
82 }
83}
84
85#[derive(Debug)]
87pub struct Attachment {
88 pub rowid: i32,
90 pub filename: Option<String>,
92 pub uti: Option<String>,
94 pub mime_type: Option<String>,
96 pub transfer_name: Option<String>,
98 pub total_bytes: i64,
100 pub is_sticker: bool,
102 pub hide_attachment: i32,
104 pub emoji_description: Option<String>,
106 pub copied_path: Option<PathBuf>,
108}
109
110impl Table for Attachment {
112 fn from_row(row: &Row) -> Result<Attachment> {
113 Ok(Attachment {
114 rowid: row.get("rowid")?,
115 filename: row.get("filename").unwrap_or(None),
116 uti: row.get("uti").unwrap_or(None),
117 mime_type: row.get("mime_type").unwrap_or(None),
118 transfer_name: row.get("transfer_name").unwrap_or(None),
119 total_bytes: row.get("total_bytes").unwrap_or_default(),
120 is_sticker: row.get("is_sticker").unwrap_or(false),
121 hide_attachment: row.get("hide_attachment").unwrap_or(0),
122 emoji_description: row.get("emoji_image_short_description").unwrap_or(None),
123 copied_path: None,
124 })
125 }
126
127 fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
128 Ok(db.prepare_cached(&format!("SELECT * from {ATTACHMENT}"))?)
129 }
130
131 fn extract(attachment: Result<Result<Self, Error>, Error>) -> Result<Self, TableError> {
132 match attachment {
133 Ok(Ok(attachment)) => Ok(attachment),
134 Err(why) | Ok(Err(why)) => Err(TableError::QueryError(why)),
135 }
136 }
137}
138
139impl Attachment {
141 pub fn from_message(db: &Connection, msg: &Message) -> Result<Vec<Attachment>, TableError> {
145 let mut out_l = vec![];
146 if msg.has_attachments() {
147 let mut statement = db
148 .prepare_cached(&format!(
149 "
150 SELECT {COLS}
151 FROM message_attachment_join j
152 LEFT JOIN {ATTACHMENT} a ON j.attachment_id = a.ROWID
153 WHERE j.message_id = ?1
154 ",
155 ))
156 .or_else(|_| {
157 db.prepare_cached(&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 = ?1
163 ",
164 ))
165 })?;
166
167 let iter = statement.query_map([msg.rowid], |row| Ok(Attachment::from_row(row)))?;
168
169 for attachment in iter {
170 let m = Attachment::extract(attachment)?;
171 out_l.push(m);
172 }
173 }
174 Ok(out_l)
175 }
176
177 #[must_use]
179 pub fn mime_type(&'_ self) -> MediaType<'_> {
180 match &self.mime_type {
181 Some(mime) => {
182 let mut mime_parts = mime.split('/');
183 if let (Some(category), Some(subtype)) = (mime_parts.next(), mime_parts.next()) {
184 match category {
185 "image" => MediaType::Image(subtype),
186 "video" => MediaType::Video(subtype),
187 "audio" => MediaType::Audio(subtype),
188 "text" => MediaType::Text(subtype),
189 "application" => MediaType::Application(subtype),
190 _ => MediaType::Other(mime),
191 }
192 } else {
193 MediaType::Other(mime)
194 }
195 }
196 None => {
197 if let Some(uti) = &self.uti {
199 match uti.as_str() {
200 "com.apple.coreaudio-format" => MediaType::Audio("x-caf; codecs=opus"),
203 _ => MediaType::Unknown,
204 }
205 } else {
206 MediaType::Unknown
207 }
208 }
209 }
210 }
211
212 pub fn as_bytes(
217 &self,
218 platform: &Platform,
219 db_path: &Path,
220 custom_attachment_root: Option<&str>,
221 ) -> Result<Option<Vec<u8>>, AttachmentError> {
222 if let Some(file_path) =
223 self.resolved_attachment_path(platform, db_path, custom_attachment_root)
224 {
225 let mut file = File::open(&file_path)
226 .map_err(|err| AttachmentError::Unreadable(file_path.clone(), err))?;
227 let mut bytes = vec![];
228 file.read_to_end(&mut bytes)
229 .map_err(|err| AttachmentError::Unreadable(file_path.clone(), err))?;
230
231 return Ok(Some(bytes));
232 }
233 Ok(None)
234 }
235
236 pub fn get_sticker_effect(
241 &self,
242 platform: &Platform,
243 db_path: &Path,
244 custom_attachment_root: Option<&str>,
245 ) -> Result<Option<StickerEffect>, AttachmentError> {
246 if !self.is_sticker {
248 return Ok(None);
249 }
250
251 if let Some(data) = self.as_bytes(platform, db_path, custom_attachment_root)? {
253 return Ok(Some(get_sticker_effect(data)));
254 }
255
256 Ok(Some(StickerEffect::default()))
258 }
259
260 #[must_use]
262 pub fn path(&self) -> Option<&Path> {
263 match &self.filename {
264 Some(name) => Some(Path::new(name)),
265 None => None,
266 }
267 }
268
269 #[must_use]
271 pub fn extension(&self) -> Option<&str> {
272 match self.path() {
273 Some(path) => match path.extension() {
274 Some(ext) => ext.to_str(),
275 None => None,
276 },
277 None => None,
278 }
279 }
280
281 #[must_use]
285 pub fn filename(&self) -> Option<&str> {
286 self.transfer_name.as_deref().or(self.filename.as_deref())
287 }
288
289 #[must_use]
291 pub fn file_size(&self) -> String {
292 format_file_size(u64::try_from(self.total_bytes).unwrap_or(0))
293 }
294
295 pub fn get_total_attachment_bytes(
297 db: &Connection,
298 context: &QueryContext,
299 ) -> Result<u64, TableError> {
300 let mut bytes_query = if context.start.is_some() || context.end.is_some() {
301 let mut statement = format!("SELECT IFNULL(SUM(total_bytes), 0) FROM {ATTACHMENT} a");
302
303 statement.push_str(" WHERE ");
304 if let Some(start) = context.start {
305 let _ = write!(
306 statement,
307 " a.created_date >= {}",
308 start / TIMESTAMP_FACTOR
309 );
310 }
311 if let Some(end) = context.end {
312 if context.start.is_some() {
313 statement.push_str(" AND ");
314 }
315 let _ = write!(
316 statement,
317 " a.created_date <= {}",
318 end / TIMESTAMP_FACTOR
319 );
320 }
321
322 db.prepare(&statement)?
323 } else {
324 db.prepare(&format!(
325 "SELECT IFNULL(SUM(total_bytes), 0) FROM {ATTACHMENT}"
326 ))?
327 };
328 Ok(bytes_query
329 .query_row([], |r| -> Result<i64> { r.get(0) })
330 .map(|res: i64| u64::try_from(res).unwrap_or(0))?)
331 }
332
333 #[must_use]
345 pub fn resolved_attachment_path(
346 &self,
347 platform: &Platform,
348 db_path: &Path,
349 custom_attachment_root: Option<&str>,
350 ) -> Option<String> {
351 if let Some(mut path_str) = self.filename.clone() {
352 if let Some(custom_attachment_path) = custom_attachment_root {
354 path_str = path_str.replace(DEFAULT_ATTACHMENT_ROOT, custom_attachment_path);
355 }
356 return match platform {
357 Platform::macOS => Some(Attachment::gen_macos_attachment(&path_str)),
358 Platform::iOS => Attachment::gen_ios_attachment(&path_str, db_path),
359 };
360 }
361 None
362 }
363
364 pub fn run_diagnostic(
386 db: &Connection,
387 db_path: &Path,
388 platform: &Platform,
389 ) -> Result<(), TableError> {
390 processing();
391 let mut total_attachments = 0;
392 let mut null_attachments = 0;
393 let mut size_on_disk: u64 = 0;
394 let mut statement_paths = db.prepare(&format!("SELECT filename FROM {ATTACHMENT}"))?;
395 let paths = statement_paths.query_map([], |r| Ok(r.get(0)))?;
396
397 let missing_files = paths
398 .filter_map(Result::ok)
399 .filter(|path: &Result<String, Error>| {
400 total_attachments += 1;
402 if let Ok(filepath) = path {
403 match platform {
404 Platform::macOS => {
405 let path = Attachment::gen_macos_attachment(filepath);
406 let file = Path::new(&path);
407 if let Ok(metadata) = file.metadata() {
408 size_on_disk += metadata.len();
409 }
410 !file.exists()
411 }
412 Platform::iOS => {
413 if let Some(parsed_path) =
414 Attachment::gen_ios_attachment(filepath, db_path)
415 {
416 let file = Path::new(&parsed_path);
417 if let Ok(metadata) = file.metadata() {
418 size_on_disk += metadata.len();
419 }
420 return !file.exists();
421 }
422 true
424 }
425 }
426 } else {
427 null_attachments += 1;
429 true
430 }
431 })
432 .count();
433
434 let total_bytes =
435 Attachment::get_total_attachment_bytes(db, &QueryContext::default()).unwrap_or(0);
436
437 done_processing();
438
439 if total_attachments > 0 {
440 println!("\rAttachment diagnostic data:");
441 println!(" Total attachments: {total_attachments}");
442 println!(
443 " Data referenced in table: {}",
444 format_file_size(total_bytes)
445 );
446 println!(
447 " Data present on disk: {}",
448 format_file_size(size_on_disk)
449 );
450 if missing_files > 0 && total_attachments > 0 {
451 println!(
452 " Missing files: {missing_files:?} ({:.0}%)",
453 (missing_files as f64 / f64::from(total_attachments)) * 100f64
454 );
455 println!(" No path provided: {null_attachments}");
456 println!(
457 " No file located: {}",
458 missing_files.saturating_sub(null_attachments)
459 );
460 }
461 }
462 Ok(())
463 }
464
465 fn gen_macos_attachment(path: &str) -> String {
467 if path.starts_with('~') {
468 return path.replacen('~', &home(), 1);
469 }
470 path.to_string()
471 }
472
473 fn gen_ios_attachment(file_path: &str, db_path: &Path) -> Option<String> {
475 let input = file_path.get(2..)?;
476 let filename = format!(
477 "{:x}",
478 Sha1::digest(format!("MediaDomain-{input}").as_bytes())
479 );
480 let directory = filename.get(0..2)?;
481
482 Some(format!("{}/{directory}/{filename}", db_path.display()))
483 }
484
485 fn sticker_info(&self, db: &Connection) -> Option<Value> {
492 Value::from_reader(self.get_blob(db, ATTACHMENT, STICKER_USER_INFO, self.rowid.into())?)
493 .ok()
494 }
495
496 fn attribution_info(&self, db: &Connection) -> Option<Value> {
503 Value::from_reader(self.get_blob(db, ATTACHMENT, ATTRIBUTION_INFO, self.rowid.into())?).ok()
504 }
505
506 pub fn get_sticker_source(&self, db: &Connection) -> Option<StickerSource> {
511 if let Some(sticker_info) = self.sticker_info(db) {
512 let plist = plist_as_dictionary(&sticker_info).ok()?;
513 let bundle_id = plist.get("pid")?.as_string()?;
514 return StickerSource::from_bundle_id(bundle_id);
515 }
516 None
517 }
518
519 pub fn get_sticker_source_application_name(&self, db: &Connection) -> Option<String> {
524 if let Some(attribution_info) = self.attribution_info(db) {
525 let plist = plist_as_dictionary(&attribution_info).ok()?;
526 return Some(plist.get("name")?.as_string()?.to_owned());
527 }
528 None
529 }
530}
531
532#[cfg(test)]
534mod tests {
535 use crate::{
536 tables::{
537 attachment::{Attachment, DEFAULT_ATTACHMENT_ROOT, MediaType},
538 table::get_connection,
539 },
540 util::{platform::Platform, query_context::QueryContext},
541 };
542
543 use std::{
544 collections::BTreeSet,
545 env::current_dir,
546 path::{Path, PathBuf},
547 };
548
549 fn sample_attachment() -> Attachment {
550 Attachment {
551 rowid: 1,
552 filename: Some("a/b/c.png".to_string()),
553 uti: Some("public.png".to_string()),
554 mime_type: Some("image/png".to_string()),
555 transfer_name: Some("c.png".to_string()),
556 total_bytes: 100,
557 is_sticker: false,
558 hide_attachment: 0,
559 emoji_description: None,
560 copied_path: None,
561 }
562 }
563
564 #[test]
565 fn can_get_path() {
566 let attachment = sample_attachment();
567 assert_eq!(attachment.path(), Some(Path::new("a/b/c.png")));
568 }
569
570 #[test]
571 fn cant_get_path_missing() {
572 let mut attachment = sample_attachment();
573 attachment.filename = None;
574 assert_eq!(attachment.path(), None);
575 }
576
577 #[test]
578 fn can_get_extension() {
579 let attachment = sample_attachment();
580 assert_eq!(attachment.extension(), Some("png"));
581 }
582
583 #[test]
584 fn cant_get_extension_missing() {
585 let mut attachment = sample_attachment();
586 attachment.filename = None;
587 assert_eq!(attachment.extension(), None);
588 }
589
590 #[test]
591 fn can_get_mime_type_png() {
592 let attachment = sample_attachment();
593 assert_eq!(attachment.mime_type(), MediaType::Image("png"));
594 }
595
596 #[test]
597 fn can_get_mime_type_heic() {
598 let mut attachment = sample_attachment();
599 attachment.mime_type = Some("image/heic".to_string());
600 assert_eq!(attachment.mime_type(), MediaType::Image("heic"));
601 }
602
603 #[test]
604 fn can_get_mime_type_fake() {
605 let mut attachment = sample_attachment();
606 attachment.mime_type = Some("fake/bloop".to_string());
607 assert_eq!(attachment.mime_type(), MediaType::Other("fake/bloop"));
608 }
609
610 #[test]
611 fn can_get_mime_type_missing() {
612 let mut attachment = sample_attachment();
613 attachment.mime_type = None;
614 assert_eq!(attachment.mime_type(), MediaType::Unknown);
615 }
616
617 #[test]
618 fn can_get_filename() {
619 let attachment = sample_attachment();
620 assert_eq!(attachment.filename(), Some("c.png"));
621 }
622
623 #[test]
624 fn can_get_filename_no_transfer_name() {
625 let mut attachment = sample_attachment();
626 attachment.transfer_name = None;
627 assert_eq!(attachment.filename(), Some("a/b/c.png"));
628 }
629
630 #[test]
631 fn can_get_filename_no_filename() {
632 let mut attachment = sample_attachment();
633 attachment.filename = None;
634 assert_eq!(attachment.filename(), Some("c.png"));
635 }
636
637 #[test]
638 fn can_get_filename_no_meta() {
639 let mut attachment = sample_attachment();
640 attachment.transfer_name = None;
641 attachment.filename = None;
642 assert_eq!(attachment.filename(), None);
643 }
644
645 #[test]
646 fn can_get_resolved_path_macos() {
647 let db_path = PathBuf::from("fake_root");
648 let attachment = sample_attachment();
649
650 assert_eq!(
651 attachment.resolved_attachment_path(&Platform::macOS, &db_path, None),
652 Some("a/b/c.png".to_string())
653 );
654 }
655
656 #[test]
657 fn can_get_resolved_path_macos_custom() {
658 let db_path = PathBuf::from("fake_root");
659 let mut attachment = sample_attachment();
660 attachment.filename = Some(format!("{DEFAULT_ATTACHMENT_ROOT}/a/b/c.png"));
662
663 assert_eq!(
664 attachment.resolved_attachment_path(&Platform::macOS, &db_path, Some("custom/root")),
665 Some("custom/root/a/b/c.png".to_string())
666 );
667 }
668
669 #[test]
670 fn can_get_resolved_path_macos_raw() {
671 let db_path = PathBuf::from("fake_root");
672 let mut attachment = sample_attachment();
673 attachment.filename = Some("~/a/b/c.png".to_string());
674
675 assert!(
676 attachment
677 .resolved_attachment_path(&Platform::macOS, &db_path, None)
678 .unwrap()
679 .len()
680 > attachment.filename.unwrap().len()
681 );
682 }
683
684 #[test]
685 fn can_get_resolved_path_macos_raw_tilde() {
686 let db_path = PathBuf::from("fake_root");
687 let mut attachment = sample_attachment();
688 attachment.filename = Some("~/a/b/c~d.png".to_string());
689
690 assert!(
691 attachment
692 .resolved_attachment_path(&Platform::macOS, &db_path, None)
693 .unwrap()
694 .ends_with("c~d.png")
695 );
696 }
697
698 #[test]
699 fn can_get_resolved_path_ios() {
700 let db_path = PathBuf::from("fake_root");
701 let attachment = sample_attachment();
702
703 assert_eq!(
704 attachment.resolved_attachment_path(&Platform::iOS, &db_path, None),
705 Some("fake_root/41/41746ffc65924078eae42725c979305626f57cca".to_string())
706 );
707 }
708
709 #[test]
710 fn can_get_resolved_path_ios_custom() {
711 let db_path = PathBuf::from("fake_root");
712 let attachment = sample_attachment();
713
714 assert_eq!(
717 attachment.resolved_attachment_path(&Platform::iOS, &db_path, Some("custom/root")),
718 Some("fake_root/41/41746ffc65924078eae42725c979305626f57cca".to_string())
719 );
720 }
721
722 #[test]
723 fn cant_get_missing_resolved_path_macos() {
724 let db_path = PathBuf::from("fake_root");
725 let mut attachment = sample_attachment();
726 attachment.filename = None;
727
728 assert_eq!(
729 attachment.resolved_attachment_path(&Platform::macOS, &db_path, None),
730 None
731 );
732 }
733
734 #[test]
735 fn cant_get_missing_resolved_path_ios() {
736 let db_path = PathBuf::from("fake_root");
737 let mut attachment = sample_attachment();
738 attachment.filename = None;
739
740 assert_eq!(
741 attachment.resolved_attachment_path(&Platform::iOS, &db_path, None),
742 None
743 );
744 }
745
746 #[test]
747 fn can_get_attachment_bytes_no_filter() {
748 let db_path = current_dir()
749 .unwrap()
750 .parent()
751 .unwrap()
752 .join("imessage-database/test_data/db/test.db");
753 let connection = get_connection(&db_path).unwrap();
754
755 let context = QueryContext::default();
756
757 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
758 }
759
760 #[test]
761 fn can_get_attachment_bytes_start_filter() {
762 let db_path = current_dir()
763 .unwrap()
764 .parent()
765 .unwrap()
766 .join("imessage-database/test_data/db/test.db");
767 let connection = get_connection(&db_path).unwrap();
768
769 let mut context = QueryContext::default();
770 context.set_start("2020-01-01").unwrap();
771
772 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
773 }
774
775 #[test]
776 fn can_get_attachment_bytes_end_filter() {
777 let db_path = current_dir()
778 .unwrap()
779 .parent()
780 .unwrap()
781 .join("imessage-database/test_data/db/test.db");
782 let connection = get_connection(&db_path).unwrap();
783
784 let mut context = QueryContext::default();
785 context.set_end("2020-01-01").unwrap();
786
787 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
788 }
789
790 #[test]
791 fn can_get_attachment_bytes_start_end_filter() {
792 let db_path = current_dir()
793 .unwrap()
794 .parent()
795 .unwrap()
796 .join("imessage-database/test_data/db/test.db");
797 let connection = get_connection(&db_path).unwrap();
798
799 let mut context = QueryContext::default();
800 context.set_start("2020-01-01").unwrap();
801 context.set_end("2021-01-01").unwrap();
802
803 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
804 }
805
806 #[test]
807 fn can_get_attachment_bytes_contact_filter() {
808 let db_path = current_dir()
809 .unwrap()
810 .parent()
811 .unwrap()
812 .join("imessage-database/test_data/db/test.db");
813 let connection = get_connection(&db_path).unwrap();
814
815 let mut context = QueryContext::default();
816 context.set_selected_chat_ids(BTreeSet::from([1, 2, 3]));
817 context.set_selected_handle_ids(BTreeSet::from([1, 2, 3]));
818
819 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
820 }
821
822 #[test]
823 fn can_get_attachment_bytes_contact_date_filter() {
824 let db_path = current_dir()
825 .unwrap()
826 .parent()
827 .unwrap()
828 .join("imessage-database/test_data/db/test.db");
829 let connection = get_connection(&db_path).unwrap();
830
831 let mut context = QueryContext::default();
832 context.set_start("2020-01-01").unwrap();
833 context.set_end("2021-01-01").unwrap();
834 context.set_selected_chat_ids(BTreeSet::from([1, 2, 3]));
835 context.set_selected_handle_ids(BTreeSet::from([1, 2, 3]));
836
837 assert!(Attachment::get_total_attachment_bytes(&connection, &context).is_ok());
838 }
839
840 #[test]
841 fn can_get_file_size_bytes() {
842 let attachment = sample_attachment();
843
844 assert_eq!(attachment.file_size(), String::from("100.00 B"));
845 }
846
847 #[test]
848 fn can_get_file_size_kb() {
849 let mut attachment = sample_attachment();
850 attachment.total_bytes = 2300;
851
852 assert_eq!(attachment.file_size(), String::from("2.25 KB"));
853 }
854
855 #[test]
856 fn can_get_file_size_mb() {
857 let mut attachment = sample_attachment();
858 attachment.total_bytes = 5612000;
859
860 assert_eq!(attachment.file_size(), String::from("5.35 MB"));
861 }
862
863 #[test]
864 fn can_get_file_size_gb() {
865 let mut attachment: Attachment = sample_attachment();
866 attachment.total_bytes = 9234712394;
867
868 assert_eq!(attachment.file_size(), String::from("8.60 GB"));
869 }
870
871 #[test]
872 fn can_get_file_size_cap() {
873 let mut attachment: Attachment = sample_attachment();
874 attachment.total_bytes = i64::MAX;
875
876 assert_eq!(attachment.file_size(), String::from("8388608.00 TB"));
877 }
878}