1use sioul_core::mms;
36use sioul_core::texts as rules;
37use std::io::Read;
38use std::path::{Path, PathBuf};
39
40pub(crate) const FIRST_SIDE: u32 = 1600;
42pub(crate) const LAST_SIDE: u32 = 480;
44const FIRST_QUALITY: u8 = 85;
45const LAST_QUALITY: u8 = 50;
46pub(crate) const MIN_SHARE: u64 = 16 * 1024;
48pub(crate) const WORDS_RESERVE: usize = rules::WRITE_MAX * 4;
51
52pub(crate) fn fit(first: u32, share: u64, write: &mut dyn FnMut(u32, u8) -> Option<u64>) -> Option<(u32, u8)> {
60 let fits = |size: Option<u64>| size.is_some_and(|size| size <= share);
61 let mut side = first;
62 loop {
63 if fits(write(side, FIRST_QUALITY)) {
64 return Some((side, FIRST_QUALITY));
65 }
66 if fits(write(side, LAST_QUALITY)) {
67 let (mut good, mut over) = (LAST_QUALITY, FIRST_QUALITY);
68 for _ in 0..4 {
69 if over - good <= 1 {
70 break;
71 }
72 let middle = good + (over - good) / 2;
73 if fits(write(side, middle)) {
74 good = middle;
75 } else {
76 over = middle;
77 }
78 }
79 return Some((side, good));
80 }
81 let smaller = (f64::from(side) * 0.75).round() as u32;
82 if smaller < LAST_SIDE {
83 return None;
84 }
85 side = smaller;
86 }
87}
88
89pub(crate) fn share(room: u64, fixed: u64, pictures: u64) -> Option<u64> {
93 let left = room.checked_sub(fixed)?;
94 if pictures == 0 {
95 return Some(left);
96 }
97 let each = left / pictures;
98 (each >= MIN_SHARE).then_some(each)
99}
100
101pub(crate) fn sniff(head: &[u8]) -> Option<&'static str> {
105 let at = |from: usize, bytes: &[u8]| head.get(from..from + bytes.len()) == Some(bytes);
106 if at(0, &[0xFF, 0xD8, 0xFF]) {
107 return Some("jpeg");
108 }
109 if at(0, b"\x89PNG\r\n\x1a\n") {
110 return Some("png");
111 }
112 if at(0, b"GIF87a") || at(0, b"GIF89a") {
113 return Some("gif");
114 }
115 if at(0, b"RIFF") && at(8, b"WEBP") {
116 return Some("webp");
117 }
118 if at(0, b"II*\0") || at(0, b"MM\0*") {
119 return Some("tiff");
120 }
121 if at(0, b"BM") && head.len() >= 26 {
122 return Some("bmp");
123 }
124 if at(0, &[0xFF, 0x0A]) || at(0, b"\0\0\0\x0cJXL \r\n\x87\n") {
125 return Some("jxl");
126 }
127 if at(4, b"ftyp") {
129 let length = head.get(0..4).map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize).min(head.len());
130 let brands: Vec<&[u8]> = std::iter::once(head.get(8..12).unwrap_or_default()).chain(head.get(16..length).unwrap_or_default().chunks_exact(4)).collect();
131 if brands.iter().any(|b| matches!(*b, b"avif" | b"avis")) {
132 return Some("avif");
133 }
134 if brands.iter().any(|b| matches!(*b, b"heic" | b"heix" | b"heim" | b"heis" | b"hevc" | b"hevx" | b"mif1" | b"msf1")) {
135 return Some("heif");
136 }
137 }
138 None
139}
140
141pub(crate) fn format_words(format: &str) -> &'static str {
143 match format {
144 "jpeg" => "JPEG",
145 "png" => "PNG",
146 "gif" => "GIF",
147 "webp" => "WebP",
148 "tiff" => "TIFF",
149 "bmp" => "BMP",
150 "heif" => "HEIC",
151 "avif" => "AVIF",
152 "jxl" => "JPEG XL",
153 _ => "",
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub(crate) enum Kind {
160 Picture,
162 Animated,
164 AsIs(&'static str),
166 Refused(&'static str),
169}
170
171pub(crate) fn sniff_medium(head: &[u8], name: &str) -> Option<&'static str> {
178 let at = |from: usize, bytes: &[u8]| head.get(from..from + bytes.len()) == Some(bytes);
179 if at(4, b"ftyp") {
180 let brand = head.get(8..12)?;
181 let sound = name.rsplit_once('.').is_some_and(|(_, e)| e.eq_ignore_ascii_case("m4a"));
182 return match brand {
183 b"qt " => Some("video/quicktime"),
184 b"M4A " | b"M4B " | b"M4P " => Some("audio/mp4"),
185 b if [b"3gp".as_slice(), b"3ge", b"3gg", b"3gs", b"3gr", b"3g2"].iter().any(|start| b.starts_with(start)) => Some("video/3gpp"),
186 b"isom" | b"iso2" | b"iso3" | b"iso4" | b"iso5" | b"iso6" | b"mp41" | b"mp42" | b"avc1" | b"M4V " | b"M4VH" | b"M4VP" | b"MSNV" | b"dash" | b"mmp4" => Some(if sound { "audio/mp4" } else { "video/mp4" }),
187 _ => None,
188 };
189 }
190 if at(0, &[0x1A, 0x45, 0xDF, 0xA3]) {
191 return webm(head).then_some("video/webm");
192 }
193 if at(0, b"#!AMR") {
194 return Some("audio/amr");
195 }
196 if at(0, b"OggS") {
197 return Some("audio/ogg");
198 }
199 if at(0, b"RIFF") && at(8, b"WAVE") {
200 return Some("audio/wav");
201 }
202 if at(0, b"ID3") && head.get(3).is_some_and(|v| (2..=4).contains(v)) && head.get(6..10).is_some_and(|size| size.iter().all(|b| *b < 0x80)) {
204 return Some("audio/mpeg");
205 }
206 let followed = |length: Option<usize>, next: &dyn Fn(&[u8]) -> Option<usize>| length.is_some_and(|length| head.get(length..).is_some_and(|rest| next(rest).is_some()));
209 if followed(adts_frame(head), &adts_frame) {
210 return Some("audio/aac");
211 }
212 if followed(mpeg_frame(head), &|rest: &[u8]| mpeg_frame(rest).filter(|_| rest.get(1).map(|b| b & 0xFE) == head.get(1).map(|b| b & 0xFE))) {
213 return Some("audio/mpeg");
214 }
215 let text = String::from_utf8_lossy(head.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(head)).trim_start().to_ascii_uppercase();
217 if text.starts_with("BEGIN:VCARD") {
218 return Some("text/x-vCard");
219 }
220 if text.starts_with("BEGIN:VCALENDAR") {
221 return Some(if text.contains("VERSION:1.0") { "text/x-vCalendar" } else { "text/calendar" });
222 }
223 None
224}
225
226fn mpeg_frame(header: &[u8]) -> Option<usize> {
230 let [first, second, third, _] = *header.get(..4)? else { return None };
231 if first != 0xFF || second & 0xE0 != 0xE0 {
232 return None;
233 }
234 let (version, layer) = ((second >> 3) & 3, (second >> 1) & 3);
236 let (bitrate, rate) = (usize::from(third >> 4), usize::from((third >> 2) & 3));
237 if version == 1 || layer == 0 || bitrate == 0 || bitrate == 15 || rate == 3 {
238 return None;
239 }
240 const MPEG1: [[u32; 15]; 3] = [
241 [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448],
242 [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384],
243 [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
244 ];
245 const MPEG2: [[u32; 15]; 2] = [[0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256], [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160]];
246 let kbps = match (version, layer) {
247 (3, 3) => MPEG1[0][bitrate],
248 (3, 2) => MPEG1[1][bitrate],
249 (3, _) => MPEG1[2][bitrate],
250 (_, 3) => MPEG2[0][bitrate],
251 _ => MPEG2[1][bitrate],
252 };
253 let hertz = [[11_025u32, 12_000, 8_000], [0, 0, 0], [22_050, 24_000, 16_000], [44_100, 48_000, 32_000]][usize::from(version)][rate];
254 let (bits, padding) = (kbps * 1000, u32::from((third >> 1) & 1));
255 let length = match layer {
256 3 => (12 * bits / hertz + padding) * 4,
257 1 if version != 3 => 72 * bits / hertz + padding,
258 _ => 144 * bits / hertz + padding,
259 };
260 Some(length as usize)
261}
262
263fn adts_frame(header: &[u8]) -> Option<usize> {
266 let header = header.get(..7)?;
267 if header[0] != 0xFF || header[1] & 0xF6 != 0xF0 || (header[2] >> 2) & 0x0F >= 13 {
268 return None;
269 }
270 let length = (usize::from(header[3] & 0x03) << 11) | (usize::from(header[4]) << 3) | usize::from(header[5] >> 5);
271 (length >= 7).then_some(length)
272}
273
274fn webm(head: &[u8]) -> bool {
277 let size = |at: usize| -> Option<(usize, usize)> {
279 let first = *head.get(at)?;
280 let length = first.leading_zeros() as usize + 1;
281 if length > 8 {
282 return None;
283 }
284 let mut value = u64::from(first) & (0xFF >> length);
285 for i in 1..length {
286 value = (value << 8) | u64::from(*head.get(at + i)?);
287 }
288 Some((usize::try_from(value).ok()?, length))
289 };
290 let Some((header, n)) = size(4) else { return false };
291 let end = (4 + n).saturating_add(header).min(head.len());
292 let mut at = 4 + n;
293 while at < end {
294 let id_length = head[at].leading_zeros() as usize + 1;
296 let (Some(id), Some((length, n))) = (head.get(at..at + id_length).filter(|_| id_length <= 4), size(at + id_length)) else { return false };
297 let body = at + id_length + n;
298 if id == [0x42, 0x82] {
299 return head.get(body..body.saturating_add(length)) == Some(b"webm");
300 }
301 at = body.saturating_add(length);
302 }
303 false
304}
305
306pub(crate) fn kind_of(picture: Option<&str>, reads: bool, frames: u32, medium: Option<&'static str>) -> Kind {
312 match (picture, medium) {
313 (Some(_), _) if !reads => Kind::Refused("kind"),
314 (Some("gif"), _) if frames > 1 => Kind::Animated,
315 (Some(_), _) => Kind::Picture,
316 (None, Some(ct)) => Kind::AsIs(ct),
317 (None, None) => Kind::Refused("file"),
318 }
319}
320
321pub(crate) fn gif_pictures_alone(bytes: &[u8]) -> Option<Vec<u8>> {
332 let blocks_end = |mut at: usize| -> Option<usize> {
334 loop {
335 let length = usize::from(*bytes.get(at)?);
336 at += 1;
337 if length == 0 {
338 return Some(at);
339 }
340 at += length;
341 if at > bytes.len() {
342 return None;
343 }
344 }
345 };
346 let table = |packed: u8| if packed & 0x80 != 0 { 3 * (1usize << ((packed & 0x07) + 1)) } else { 0 };
347 let screen = bytes.get(..13)?;
348 if !(screen.starts_with(b"GIF87a") || screen.starts_with(b"GIF89a")) {
349 return None;
350 }
351 let mut at = 13 + table(screen[10]);
352 let mut out = bytes.get(..at)?.to_vec();
353 loop {
354 let Some(&block) = bytes.get(at) else {
356 out.push(0x3B);
357 return Some(out);
358 };
359 match block {
360 0x3B => {
361 out.push(0x3B);
362 return Some(out);
363 }
364 0x2C => {
365 let descriptor = bytes.get(at..at + 10)?;
366 let end = blocks_end(at + 10 + table(descriptor[9]) + 1)?;
368 out.extend_from_slice(&bytes[at..end]);
369 at = end;
370 }
371 0x21 => {
372 let label = *bytes.get(at + 1)?;
373 let end = blocks_end(at + 2)?;
374 if label == 0xF9 && bytes.get(at + 2) == Some(&4) {
376 out.extend_from_slice(&[0x21, 0xF9, 4]);
377 out.extend_from_slice(bytes.get(at + 3..at + 7)?);
378 out.push(0);
379 }
380 let looping = label == 0xFF && bytes.get(at + 2) == Some(&11) && matches!(bytes.get(at + 3..at + 14), Some(b"NETSCAPE2.0" | b"ANIMEXTS1.0"));
382 if looping && bytes.get(at + 14..at + 16) == Some(&[3u8, 1][..]) {
383 out.extend_from_slice(bytes.get(at..at + 18)?);
384 out.push(0);
385 }
386 at = end;
387 }
388 _ => return None,
389 }
390 }
391}
392
393fn extension_of(ct: &str) -> &'static str {
395 match ct {
396 "image/jpeg" => "jpg",
397 "image/gif" => "gif",
398 "video/mp4" => "mp4",
399 "video/quicktime" => "mov",
400 "video/3gpp" => "3gp",
401 "video/webm" => "webm",
402 "audio/mp4" => "m4a",
403 "audio/amr" => "amr",
404 "audio/aac" => "aac",
405 "audio/mpeg" => "mp3",
406 "audio/ogg" => "ogg",
407 "audio/wav" => "wav",
408 "text/x-vCard" => "vcf",
409 "text/x-vCalendar" => "vcs",
410 "text/calendar" => "ics",
411 _ => "dat",
412 }
413}
414
415#[cfg(not(target_os = "android"))]
418pub(crate) struct Picture {
419 picture: *mut std::ffi::c_void,
420 pub(crate) width: u32,
421 pub(crate) height: u32,
422 pub(crate) frames: u32,
423}
424
425#[cfg(not(target_os = "android"))]
426unsafe extern "C" {
427 fn sioul_picture_reads(format: *const std::ffi::c_char) -> std::ffi::c_int;
429 fn sioul_picture_open(path: *const std::ffi::c_char, format: *const std::ffi::c_char, largest: std::ffi::c_int, width: *mut std::ffi::c_int, height: *mut std::ffi::c_int, frames: *mut std::ffi::c_int) -> *mut std::ffi::c_void;
430 fn sioul_picture_jpeg(picture: *const std::ffi::c_void, long_side: std::ffi::c_int, quality: std::ffi::c_int, out: *const std::ffi::c_char) -> std::ffi::c_longlong;
431 fn sioul_picture_close(picture: *mut std::ffi::c_void);
432}
433
434#[cfg(not(target_os = "android"))]
435impl Picture {
436 pub(crate) fn reads(format: &str) -> bool {
438 let Ok(format) = std::ffi::CString::new(format) else { return false };
439 unsafe { sioul_picture_reads(format.as_ptr()) != 0 }
441 }
442
443 pub(crate) fn open(path: &Path, format: &str, largest: u32) -> Option<Picture> {
446 let path = std::ffi::CString::new(path.to_str()?).ok()?;
447 let format = std::ffi::CString::new(format).ok()?;
448 let (mut width, mut height, mut frames) = (0, 0, 0);
449 let largest = std::ffi::c_int::try_from(largest).unwrap_or(std::ffi::c_int::MAX);
450 let picture = unsafe { sioul_picture_open(path.as_ptr(), format.as_ptr(), largest, &mut width, &mut height, &mut frames) };
452 if picture.is_null() {
453 return None;
454 }
455 Some(Picture { picture, width: u32::try_from(width).unwrap_or(0), height: u32::try_from(height).unwrap_or(0), frames: u32::try_from(frames).unwrap_or(0) })
456 }
457
458 pub(crate) fn longest(&self) -> u32 {
460 self.width.max(self.height)
461 }
462
463 pub(crate) fn jpeg(&self, long_side: u32, quality: u8, out: &Path) -> Option<u64> {
466 let out = std::ffi::CString::new(out.to_str()?).ok()?;
467 let long_side = std::ffi::c_int::try_from(long_side).unwrap_or(std::ffi::c_int::MAX);
468 let size = unsafe { sioul_picture_jpeg(self.picture, long_side, std::ffi::c_int::from(quality), out.as_ptr()) };
470 u64::try_from(size).ok()
471 }
472}
473
474#[cfg(not(target_os = "android"))]
475impl Drop for Picture {
476 fn drop(&mut self) {
477 unsafe { sioul_picture_close(self.picture) };
479 }
480}
481
482pub(crate) fn head_of(path: &Path) -> Option<Vec<u8>> {
484 let mut head = Vec::with_capacity(HEAD);
485 std::fs::File::open(path).ok()?.take(HEAD as u64).read_to_end(&mut head).ok()?;
486 Some(head)
487}
488
489const HEAD: usize = 16 * 1024;
492
493#[cfg(not(target_os = "android"))]
497pub(crate) fn reduce(source: &Path, share: u64, advised: u32, out: &Path) -> Option<(u32, u8, u64)> {
498 let format = sniff(&head_of(source)?)?;
499 reduce_picture(&Picture::open(source, format, FIRST_SIDE)?, share, advised, out)
500}
501
502#[cfg(not(target_os = "android"))]
505fn first_side(picture: &Picture, advised: u32) -> u32 {
506 FIRST_SIDE.min(if advised > 0 { advised } else { FIRST_SIDE }).min(picture.longest()).max(1)
507}
508
509#[cfg(not(target_os = "android"))]
511fn reduce_picture(picture: &Picture, share: u64, advised: u32, out: &Path) -> Option<(u32, u8, u64)> {
512 let first = first_side(picture, advised);
513 let trying = out.with_extension("trying.jpg");
514 let found = fit(first, share, &mut |side, quality| picture.jpeg(side, quality, &trying));
515 let _ = std::fs::remove_file(&trying);
516 let (side, quality) = found?;
517 let size = picture.jpeg(side, quality, out)?;
518 (size <= share).then_some((side, quality, size))
519}
520
521#[derive(Debug, Clone, PartialEq, Eq)]
525pub(crate) struct Attached {
526 pub(crate) source: String,
528 pub(crate) kind: String,
530 pub(crate) ct: String,
531 pub(crate) path: String,
532 pub(crate) size: u64,
533}
534
535#[derive(Debug, Clone, PartialEq, Eq)]
540pub(crate) struct Left {
541 pub(crate) source: String,
542 pub(crate) why: &'static str,
543 pub(crate) size: u64,
544 pub(crate) format: &'static str,
545}
546
547fn kind_word(ct: &str) -> &'static str {
549 match ct.split('/').next().unwrap_or("") {
550 "video" => "video",
551 "audio" => "sound",
552 _ => "card",
553 }
554}
555
556fn ct_of(kind: &Kind) -> &'static str {
558 match kind {
559 Kind::Picture => "image/jpeg",
560 Kind::Animated => "image/gif",
561 Kind::AsIs(ct) => ct,
562 Kind::Refused(_) => "",
563 }
564}
565
566fn room_for(cts: &[&str], limit: u64) -> u64 {
571 let media: Vec<mms::Medium> = cts.iter().map(|ct| mms::Medium { ct, bytes: &[] }).collect();
572 let reserve = "x".repeat(WORDS_RESERVE);
573 let key = "0".repeat(32);
574 let message = mms::Message { key: &key, to: &[mms::LONGEST_NUMBER], date: jiff::Timestamp::now().as_second(), words: &reserve, media: &media };
575 mms::room(&message, usize::try_from(limit).unwrap_or(usize::MAX)).map_or(0, |room| room as u64)
576}
577
578#[cfg(not(target_os = "android"))]
586pub(crate) fn make(sources: &[PathBuf], limit: u64, advised: u32, folder: &Path) -> (Vec<Attached>, Vec<Left>) {
587 let mut left = Vec::new();
588 let mut chosen: Vec<(PathBuf, Kind, u64, Option<Picture>)> = Vec::new();
590 let refused = |source: &Path, why: &'static str, size: u64, format: &'static str| Left { source: source.display().to_string(), why, size, format };
591 for (i, source) in sources.iter().enumerate() {
592 if i >= rules::MEDIA_MAX {
593 left.push(refused(source, "too-many", 0, ""));
594 continue;
595 }
596 let Some(size) = std::fs::metadata(source).ok().filter(std::fs::Metadata::is_file).map(|m| m.len()).filter(|&size| size > 0) else {
597 left.push(refused(source, "unreadable", 0, ""));
598 continue;
599 };
600 let Some(head) = head_of(source) else {
601 left.push(refused(source, "unreadable", size, ""));
602 continue;
603 };
604 let name = source.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
605 let sniffed = sniff(&head);
606 let reads = sniffed.is_some_and(Picture::reads);
607 let picture = sniffed.filter(|_| reads).and_then(|format| Picture::open(source, format, FIRST_SIDE));
608 if reads && picture.is_none() {
609 left.push(refused(source, "unreadable", size, ""));
610 continue;
611 }
612 let medium = if sniffed.is_none() { sniff_medium(&head, &name) } else { None };
613 match kind_of(sniffed, reads, picture.as_ref().map_or(0, |p| p.frames), medium) {
614 Kind::Refused(why) => left.push(refused(source, why, size, sniffed.map_or("", format_words))),
615 Kind::Picture => chosen.push((source.clone(), Kind::Picture, size, picture)),
616 kind => chosen.push((source.clone(), kind, size, None)),
617 }
618 }
619 let mut keep: Vec<bool> = vec![true; chosen.len()];
620 let room = |keep: &[bool]| room_for(&chosen.iter().zip(keep).filter(|(_, k)| **k).map(|((_, kind, _, _), _)| ct_of(kind)).collect::<Vec<_>>(), limit);
621 let mut fixed = 0u64;
623 for i in 0..chosen.len() {
624 let (source, kind, size, _) = &chosen[i];
625 if matches!(kind, Kind::AsIs(_) | Kind::Animated) {
626 if fixed + size > room(&keep) {
627 keep[i] = false;
628 left.push(refused(source, if *kind == Kind::Animated { "gif-too-large" } else { "too-large" }, *size, ""));
629 } else {
630 fixed += size;
631 }
632 }
633 }
634 let pictures = |keep: &[bool]| chosen.iter().zip(keep).filter(|((_, kind, _, _), k)| **k && *kind == Kind::Picture).count() as u64;
636 while pictures(&keep) > 0 && share(room(&keep), fixed, pictures(&keep)).is_none() {
637 let Some(last) = chosen.iter().zip(&keep).rposition(|((_, kind, _, _), k)| *k && *kind == Kind::Picture) else { break };
638 keep[last] = false;
639 left.push(refused(&chosen[last].0, "too-many-pictures", chosen[last].2, ""));
640 }
641 let each = share(room(&keep), fixed, pictures(&keep)).unwrap_or(0);
642 let _ = std::fs::create_dir_all(folder);
644 #[cfg(unix)]
645 let _ = std::fs::set_permissions(folder, std::os::unix::fs::PermissionsExt::from_mode(0o700));
646 let stamp = jiff::Timestamp::now().as_millisecond();
647 let out_of = |i: usize, extension: &str| folder.join(format!("{stamp}-{i}.{extension}"));
648 let mut reduced: Vec<Option<(u32, u8, u64)>> = vec![None; chosen.len()];
652 let kept_pictures: Vec<usize> = (0..chosen.len()).filter(|&i| keep[i] && chosen[i].1 == Kind::Picture).collect();
653 for &i in &kept_pictures {
654 reduced[i] = chosen[i].3.as_ref().and_then(|picture| reduce_picture(picture, each, advised, &out_of(i, "jpg")));
655 }
656 let at_best = |i: usize| match (&chosen[i].3, reduced[i]) {
657 (Some(picture), Some((side, quality, _))) => side == first_side(picture, advised) && quality == FIRST_QUALITY,
658 _ => false,
659 };
660 let smaller: Vec<usize> = kept_pictures.iter().copied().filter(|&i| reduced[i].is_some() && !at_best(i)).collect();
661 let unused: u64 = kept_pictures.iter().filter(|&&i| !smaller.contains(&i)).map(|&i| reduced[i].map_or(each, |(_, _, size)| each.saturating_sub(size))).sum();
662 if !smaller.is_empty() && unused > 0 {
663 let more = each + unused / smaller.len() as u64;
664 for &i in &smaller {
665 let again = out_of(i, "again.jpg");
666 if let Some(better) = chosen[i].3.as_ref().and_then(|picture| reduce_picture(picture, more, advised, &again))
667 && std::fs::rename(&again, out_of(i, "jpg")).is_ok()
668 {
669 reduced[i] = Some(better);
670 }
671 let _ = std::fs::remove_file(&again);
672 }
673 }
674 let mut made = Vec::new();
675 for (i, ((source, kind, size, _), k)) in chosen.iter().zip(&keep).enumerate() {
676 if !k {
677 continue;
678 }
679 let shown = source.display().to_string();
680 match kind {
681 Kind::Picture => match reduced[i] {
682 Some((_, _, written)) => made.push(Attached { source: shown, kind: "picture".into(), ct: "image/jpeg".into(), path: out_of(i, "jpg").display().to_string(), size: written }),
683 None => {
684 let _ = std::fs::remove_file(out_of(i, "jpg"));
685 left.push(refused(source, "picture-too-large", *size, ""));
686 }
687 },
688 Kind::Animated | Kind::AsIs(_) => {
689 let ct = ct_of(kind);
690 let out = out_of(i, extension_of(ct));
691 let mut bytes = Vec::new();
693 let read = std::fs::File::open(source).and_then(|from| from.take(size + 1).read_to_end(&mut bytes));
694 let bytes = if *kind == Kind::Animated { gif_pictures_alone(&bytes) } else { Some(bytes) };
696 match (read, bytes) {
697 (Ok(length), _) if length as u64 != *size => left.push(refused(source, "too-large", *size, "")),
698 (Ok(_), Some(bytes)) if std::fs::write(&out, &bytes).is_ok() => made.push(Attached { source: shown, kind: if *kind == Kind::Animated { "picture".into() } else { kind_word(ct).into() }, ct: ct.into(), path: out.display().to_string(), size: bytes.len() as u64 }),
699 _ => {
700 let _ = std::fs::remove_file(&out);
701 left.push(refused(source, "unreadable", *size, ""));
702 }
703 }
704 }
705 Kind::Refused(_) => {}
706 }
707 }
708 (made, left)
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 fn sized(side: u32, quality: u8) -> u64 {
717 let pixels = u64::from(side) * u64::from(side) * 3 / 4;
718 pixels * u64::from(quality) / 200
719 }
720
721 #[test]
724 fn a_picture_is_made_small_in_its_steps() {
725 let mut tried = Vec::new();
726 let mut write = |side: u32, quality: u8| {
727 tried.push((side, quality));
728 Some(sized(side, quality))
729 };
730 assert_eq!(fit(1600, sized(1600, 85), &mut write), Some((1600, 85)), "fits at once");
731 assert_eq!(tried, [(1600, 85)]);
732 tried.clear();
733 let mut write = |side: u32, quality: u8| {
734 tried.push((side, quality));
735 Some(sized(side, quality))
736 };
737 let (side, quality) = fit(1600, sized(1600, 70), &mut write).unwrap();
738 assert_eq!(side, 1600);
739 assert!((66..=70).contains(&quality), "the best quality found within four halvings: {quality}");
740 assert!(tried.len() <= 6 && tried[..2] == [(1600, 85), (1600, 50)], "{tried:?}");
741 tried.clear();
742 let mut write = |side: u32, quality: u8| {
743 tried.push((side, quality));
744 Some(sized(side, quality))
745 };
746 let (side, _) = fit(1600, sized(900, 60), &mut write).unwrap();
747 assert_eq!(side, 900, "a quarter smaller, twice: {tried:?}");
748 assert!(tried.iter().all(|(side, _)| [1600, 1200, 900].contains(side)));
749 let mut write = |side: u32, quality: u8| Some(sized(side, quality));
750 assert_eq!(fit(1600, sized(480, 50) - 1, &mut write), None, "never under 480 pixels");
751 assert!(fit(1600, sized(506, 50), &mut |side, quality| Some(sized(side, quality))).is_some_and(|(side, _)| side == 506), "1600, 1200, 900, 675, 506");
752 assert_eq!(fit(1600, u64::MAX, &mut |_, _| None), None, "not written");
753 }
754
755 #[test]
757 fn pictures_share_what_the_others_leave() {
758 assert_eq!(share(600_000, 0, 1), Some(600_000));
759 assert_eq!(share(600_000, 100_000, 5), Some(100_000));
760 assert_eq!(share(600_000, 700_000, 1), None, "the others over the room");
761 assert_eq!(share(80_000, 0, 5), None, "under 16 KB each");
762 assert_eq!(share(80_000, 0, 4), Some(20_000));
763 assert_eq!(share(5_000, 1_000, 0), Some(4_000), "no picture: what is left");
764 }
765
766 #[test]
768 fn a_picture_is_told_by_its_first_bytes() {
769 assert_eq!(sniff(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10]), Some("jpeg"));
770 assert_eq!(sniff(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"), Some("png"));
771 assert_eq!(sniff(b"GIF89a\x01\0\x01\0"), Some("gif"));
772 assert_eq!(sniff(b"RIFF\x24\0\0\0WEBPVP8L"), Some("webp"));
773 assert_eq!(sniff(b"II*\0\x08\0\0\0"), Some("tiff"));
774 assert_eq!(sniff(b"MM\0*\0\0\0\x08"), Some("tiff"));
775 assert_eq!(sniff(b"BM\x46\0\0\0\0\0\0\0\x36\0\0\0\x28\0\0\0\x02\0\0\0\x02\0\0\0"), Some("bmp"));
776 assert_eq!(sniff(b"BM"), None, "two letters are no bitmap");
777 assert_eq!(sniff(&[0xFF, 0x0A, 0xFA]), Some("jxl"));
778 assert_eq!(sniff(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic"), Some("heif"));
779 assert_eq!(sniff(b"\0\0\0\x1cftypmif1\0\0\0\0mif1avifmiaf"), Some("avif"), "an AVIF whose major brand is mif1");
780 assert_eq!(sniff(b"\0\0\0\x18ftypavif\0\0\0\0avifmif1"), Some("avif"));
781 assert_eq!(sniff(b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2"), None, "a video");
782 assert_eq!(sniff(b"%PDF-1.7\n"), None);
783 assert_eq!(sniff(b"<svg xmlns="), None);
784 assert_eq!(sniff(b"%!PS-Adobe-3.0 EPSF"), None);
785 assert_eq!(sniff(b""), None);
786 }
787
788 #[test]
791 fn a_file_is_known_by_what_it_is() {
792 assert_eq!(kind_of(Some("jpeg"), true, 1, None), Kind::Picture);
793 assert_eq!(kind_of(Some("gif"), true, 1, None), Kind::Picture, "a still GIF, a picture");
794 assert_eq!(kind_of(Some("gif"), true, 12, None), Kind::Animated);
795 assert_eq!(kind_of(Some("heif"), false, 0, None), Kind::Refused("kind"), "a HEIC Qt cannot read here");
796 assert_eq!(kind_of(Some("heif"), true, 1, None), Kind::Picture, "one Qt reads (KDE's plugins, Apple's)");
797 assert_eq!(kind_of(None, false, 0, Some("video/mp4")), Kind::AsIs("video/mp4"));
798 assert_eq!(kind_of(None, false, 0, None), Kind::Refused("file"));
799 let medium = |head: &[u8], name: &str| sniff_medium(head, name);
800 assert_eq!(medium(b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2", "Clip.MP4"), Some("video/mp4"));
801 assert_eq!(medium(b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2", "voice.m4a"), Some("audio/mp4"), "a sound in an MP4");
802 assert_eq!(medium(b"\0\0\0\x18ftypM4A \0\0\x02\0M4A mp42", "voice"), Some("audio/mp4"));
803 assert_eq!(medium(b"\0\0\0\x14ftypqt \0\0\x02\0qt ", "IMG_0001.MOV"), Some("video/quicktime"));
804 assert_eq!(medium(b"\0\0\0\x14ftyp3gp4\0\0\x02\03gp4", "v.3gp"), Some("video/3gpp"));
805 assert_eq!(medium(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic", "x.mp4"), None, "a picture's box is no video");
806 assert_eq!(medium(b"\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\x82\x84webm", "clip.webm"), Some("video/webm"));
807 assert_eq!(medium(b"\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\x82\x88matroska", "film.mkv"), None, "Matroska, not WebM");
808 assert_eq!(medium(b"\x1a\x45\xdf\xa3\x8b\x42\x82\x88matroska\x15\x49\xa9\x66\x84webm", "film.webm"), None, "webm in a later element: its DocType says Matroska");
809 assert_eq!(medium(b"#!AMR\n\x3c", "voice.amr"), Some("audio/amr"));
810 assert_eq!(medium(b"OggS\0\x02", "song.opus"), Some("audio/ogg"));
811 assert_eq!(medium(b"RIFF\x24\0\0\0WAVEfmt ", "a.wav"), Some("audio/wav"));
812 assert_eq!(medium(b"ID3\x04\0\0\0\0\0\0", "a.mp3"), Some("audio/mpeg"));
813 let mp3 = [&[0xFF, 0xFB, 0x90, 0x64][..], &[0; 413], &[0xFF, 0xFB, 0x90, 0x64], &[0; 64]].concat();
815 assert_eq!(mpeg_frame(&mp3), Some(417), "MPEG-1 Layer III, 128 kb/s, 44.1 kHz");
816 assert_eq!(medium(&mp3, "a.mp3"), Some("audio/mpeg"));
817 assert_eq!(medium(&mp3[..417], "a.mp3"), None, "one frame, its next not there");
818 assert_eq!(medium(&[0xFF, 0xFB, 0x90, 0x64], "a.mp3"), None, "two bytes of sync are no sound");
819 let adts = [&[0xFF, 0xF1, 0x50, 0x80, 0x0C, 0x9F, 0xFC][..], &[0; 93], &[0xFF, 0xF1, 0x50, 0x80, 0x0C, 0x9F, 0xFC], &[0; 93]].concat();
820 assert_eq!(adts_frame(&adts), Some(100));
821 assert_eq!(medium(&adts, "a.aac"), Some("audio/aac"));
822 assert_eq!(medium(b"ID3\x04\0\0\x80\0\0\0", "a.mp3"), None, "an ID3 tag's size is seven bits a byte");
823 let utf16 = |text: &str| [&[0xFF, 0xFE][..], &text.encode_utf16().flat_map(u16::to_le_bytes).collect::<Vec<u8>>()].concat();
825 assert_eq!(medium(&utf16("Notes for Tuesday: the boiler, the rent, the dentist.\r\n".repeat(40).as_str()), "notes.txt"), None);
826 assert_eq!(medium(&utf16(&format!("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice Example\r\nTEL:01 99 00 12 34\r\nEND:VCARD\r\n{}", " ".repeat(800))), "alice.vcf"), None);
827 assert_eq!(medium(&utf16("Bonjour, à demain.".repeat(60).as_str()), "mot.txt"), None, "its first letter a valid frame's, no next frame");
828 assert_eq!(medium(b"\xEF\xBB\xBF\r\nbegin:vcard\r\nVERSION:2.1\r\n", "alice.vcf"), Some("text/x-vCard"));
829 assert_eq!(medium(b"BEGIN:VCALENDAR\r\nVERSION:1.0\r\n", "dinner.vcs"), Some("text/x-vCalendar"));
830 assert_eq!(medium(b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n", "dinner.ics"), Some("text/calendar"));
831 assert_eq!(medium(b"%PDF-1.7\n%\xe2\xe3", "lease.mp4"), None, "a PDF named .mp4: another file");
832 assert_eq!(medium(b"<svg xmlns=", "drawing.mp3"), None);
833 assert_eq!(medium(b"", "empty.mp4"), None);
834 for ct in ["image/jpeg", "image/gif", "video/mp4", "video/quicktime", "video/3gpp", "video/webm", "audio/mp4", "audio/amr", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wav", "text/x-vCard", "text/x-vCalendar", "text/calendar"] {
835 assert!(rules::sendable(ct), "{ct}: the phone sends it");
836 assert_ne!(extension_of(ct), "dat", "{ct}");
837 }
838 assert_eq!(format_words("heif"), "HEIC");
839 }
840
841 #[test]
845 fn an_animated_gif_goes_with_its_frames_alone() {
846 let frame = |colour: u8| [&[0x21, 0xF9, 4, 0, 10, 0, 0, 0][..], &[0x2C, 0, 0, 0, 0, 2, 0, 1, 0, 0], &[2, 2, 0x4C | colour, 0x01, 0]].concat();
847 let netscape = b"\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0\0".to_vec();
848 let comment = [&[0x21, 0xFE, 16][..], b"SIOUL-SECRET-GIF", &[0]].concat();
849 let mut xmp = b"\x21\xFF\x0BXMP DataXMP".to_vec();
850 xmp.extend_from_slice(b"<x:xmpmeta>SIOUL-SECRET-XMP</x:xmpmeta>");
851 xmp.push(0x01);
853 xmp.extend((0..=255u8).rev());
854 xmp.push(0);
855 let text = [&[0x21, 0x01, 12][..], &[0; 12], &[5][..], b"SECRE", &[0]].concat();
856 let screen = [b"GIF89a".as_slice(), &[2, 0, 1, 0, 0x80, 0, 0], &[255, 0, 0, 0, 0, 255]].concat();
857 let gif = [screen.clone(), netscape.clone(), comment, xmp, frame(0), text, frame(1), vec![0x3B], b"SIOUL-SECRET-AFTER".to_vec()].concat();
858 let alone = gif_pictures_alone(&gif).unwrap();
859 assert_eq!(alone, [screen, netscape, frame(0), frame(1), vec![0x3B]].concat());
860 assert!(!alone.windows(5).any(|w| w == b"SIOUL" || w == b"SECRE"), "no comment, XMP, text or tail");
861 assert_eq!(gif_pictures_alone(&alone).unwrap(), alone, "written again, the same");
862 assert_eq!(gif_pictures_alone(&gif[..gif.len() - 30]), None, "cut short: not a whole GIF");
863 let screen_again = [b"GIF89a".as_slice(), &[2, 0, 1, 0, 0x80, 0, 0], &[255, 0, 0, 0, 0, 255]].concat();
865 let open = [screen_again.clone(), b"\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0\0".to_vec(), frame(0), frame(1)].concat();
866 assert_eq!(gif_pictures_alone(&open).unwrap(), [open.clone(), vec![0x3B]].concat());
867 let timing_with_more = [&[0x21, 0xF9, 4, 0, 10, 0, 0, 5][..], b"SECRE", &[0]].concat();
869 let timing_too_long = [&[0x21, 0xF9, 20][..], b"SECRET-OF-TWENTY-B..", &[0]].concat();
870 let loop_with_more = [b"\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0".as_slice(), &[5], b"SECRE", &[0]].concat();
871 let animexts_with_more = [b"\x21\xFF\x0BANIMEXTS1.0\x03\x01\x02\0".as_slice(), &[5], b"SECRE", &[0]].concat();
872 let frame_of = |timing: &[u8], colour: u8| [timing, &[0x2C, 0, 0, 0, 0, 2, 0, 1, 0, 0], &[2, 2, 0x4C | colour, 0x01, 0]].concat();
873 let crafted = [screen_again.clone(), loop_with_more, animexts_with_more, frame_of(&timing_with_more, 0), frame_of(&timing_too_long, 1), vec![0x3B]].concat();
874 let alone = gif_pictures_alone(&crafted).unwrap();
875 assert!(!alone.windows(5).any(|w| w == b"SECRE"), "{:?}", String::from_utf8_lossy(&alone));
876 let image = |colour: u8| [&[0x2C, 0, 0, 0, 0, 2, 0, 1, 0, 0][..], &[2, 2, 0x4C | colour, 0x01, 0]].concat();
877 let expected = [screen_again, b"\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0\0".to_vec(), b"\x21\xFF\x0BANIMEXTS1.0\x03\x01\x02\0\0".to_vec(), vec![0x21, 0xF9, 4, 0, 10, 0, 0, 0], image(0), image(1), vec![0x3B]].concat();
878 assert_eq!(alone, expected, "the timing's four bytes and the loop's count alone; a timing block of 20 bytes dropped");
879 assert_eq!(gif_pictures_alone(b"GIF89a"), None);
880 assert_eq!(gif_pictures_alone(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"), None);
881 }
882
883 #[test]
886 fn the_room_holds_any_number_and_any_words() {
887 let limit = 614_400;
888 let cts = ["image/jpeg", "image/jpeg", "video/mp4"];
889 let room = room_for(&cts, limit) as usize;
890 let bytes: Vec<Vec<u8>> = vec![vec![1; room / 3], vec![2; room / 3], vec![3; room - 2 * (room / 3)]];
891 let media: Vec<mms::Medium> = cts.iter().zip(&bytes).map(|(ct, bytes)| mms::Medium { ct, bytes }).collect();
892 let key = "1".repeat(32);
893 let longest_words = "😀".repeat(rules::WRITE_MAX);
894 for words in ["", "À demain.", longest_words.as_str()] {
895 for to in ["0199001234", "+33199001234", mms::LONGEST_NUMBER] {
896 let message = mms::Message { key: &key, to: &[to], date: 1_791_446_400, words, media: &media };
897 let size = mms::size(&message).unwrap();
898 assert!(size as u64 <= limit, "{to}, {} characters: {size}", words.chars().count());
899 }
900 }
901 assert_eq!(room_for(&cts, 1_000), 0, "a limit the words' reserve fills leaves none");
902 }
903}