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