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