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