Skip to main content

grit_lib/
mailinfo.rs

1//! Email parsing for `mailinfo` (aligned with Git's `mailinfo.c` for `t5100-mailinfo`).
2//!
3//! The [`mailinfo`] function reads one raw RFC822/MIME message and writes the commit message
4//! body, extracted patch, and metadata summary the way Git's plumbing does.
5
6use std::io::{self, Write};
7
8use crate::commit_encoding::{decode_bytes, reencode_utf8_to_label};
9use crate::config::ConfigSet;
10
11/// Quoted-printable / base64 body: what to do with decoded CRLF when not format=flowed.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum QuotedCrAction {
14    #[default]
15    Warn,
16    NoWarn,
17    Strip,
18}
19
20impl QuotedCrAction {
21    #[must_use]
22    pub fn parse(s: &str) -> Option<Self> {
23        match s {
24            "warn" => Some(Self::Warn),
25            "nowarn" => Some(Self::NoWarn),
26            "strip" => Some(Self::Strip),
27            _ => None,
28        }
29    }
30}
31
32/// Options controlling subject cleanup, scissors, in-body headers, charset handling, and
33/// quoted-printable CRLF warnings — mirrors Git's `struct mailinfo` / CLI flags.
34#[derive(Debug, Clone)]
35pub struct MailinfoOptions {
36    pub keep_subject: bool,
37    pub keep_non_patch_brackets_in_subject: bool,
38    pub add_message_id: bool,
39    pub metainfo_charset: Option<String>,
40    pub use_scissors: bool,
41    pub use_inbody_headers: bool,
42    pub quoted_cr: QuotedCrAction,
43}
44
45impl Default for MailinfoOptions {
46    fn default() -> Self {
47        Self {
48            keep_subject: false,
49            keep_non_patch_brackets_in_subject: false,
50            add_message_id: false,
51            metainfo_charset: Some("utf-8".to_string()),
52            use_scissors: false,
53            use_inbody_headers: true,
54            quoted_cr: QuotedCrAction::Warn,
55        }
56    }
57}
58
59/// Reads `mailinfo.scissors` and `mailinfo.quotedcr` (or `mailinfo.quotedCr`) from `cfg`.
60pub fn apply_mailinfo_config(cfg: &ConfigSet, opts: &mut MailinfoOptions) {
61    if let Some(v) = cfg.get("mailinfo.scissors") {
62        if let Ok(b) = parse_bool_loose(v.as_str()) {
63            opts.use_scissors = b;
64        }
65    }
66    if let Some(v) = cfg
67        .get("mailinfo.quotedcr")
68        .or_else(|| cfg.get("mailinfo.quotedCr"))
69    {
70        if let Some(a) = QuotedCrAction::parse(v.trim()) {
71            opts.quoted_cr = a;
72        }
73    }
74}
75
76fn parse_bool_loose(s: &str) -> Result<bool, ()> {
77    match s.trim().to_ascii_lowercase().as_str() {
78        "true" | "yes" | "on" | "1" => Ok(true),
79        "false" | "no" | "off" | "0" => Ok(false),
80        _ => Err(()),
81    }
82}
83
84struct Input<'a> {
85    bytes: &'a [u8],
86    pos: usize,
87}
88
89impl<'a> Input<'a> {
90    fn new(bytes: &'a [u8]) -> Self {
91        Self { bytes, pos: 0 }
92    }
93
94    fn eof(&self) -> bool {
95        self.pos >= self.bytes.len()
96    }
97
98    fn skip_leading_ws(&mut self) {
99        while self
100            .bytes
101            .get(self.pos)
102            .is_some_and(|b| b.is_ascii_whitespace())
103        {
104            self.pos += 1;
105        }
106    }
107
108    fn peek(&self) -> Option<u8> {
109        self.bytes.get(self.pos).copied()
110    }
111
112    /// Read one LF-terminated line (or rest of file); strip trailing CR/LF from buffer.
113    fn read_line(&mut self, out: &mut Vec<u8>) -> io::Result<bool> {
114        out.clear();
115        if self.pos >= self.bytes.len() {
116            return Ok(false);
117        }
118        let start = self.pos;
119        while self.pos < self.bytes.len() {
120            let b = self.bytes[self.pos];
121            self.pos += 1;
122            out.push(b);
123            if b == b'\n' {
124                break;
125            }
126        }
127        trim_end_crlf(out);
128        Ok(self.pos > start)
129    }
130
131    /// Like [`Self::read_line`] but keep a single trailing `\n` (only normalize CRLF → LF).
132    fn read_line_keep_lf(&mut self, out: &mut Vec<u8>) -> io::Result<bool> {
133        out.clear();
134        if self.pos >= self.bytes.len() {
135            return Ok(false);
136        }
137        let start = self.pos;
138        while self.pos < self.bytes.len() {
139            let b = self.bytes[self.pos];
140            self.pos += 1;
141            out.push(b);
142            if b == b'\n' {
143                break;
144            }
145        }
146        if out.len() >= 2 && out[out.len() - 2] == b'\r' && out[out.len() - 1] == b'\n' {
147            out.remove(out.len() - 2);
148        }
149        Ok(self.pos > start)
150    }
151}
152
153fn trim_end_crlf(line: &mut Vec<u8>) {
154    while line.last() == Some(&b'\r') || line.last() == Some(&b'\n') {
155        line.pop();
156    }
157}
158
159fn is_rfc2822_header(line: &[u8]) -> bool {
160    if line.starts_with(b"From ") || line.starts_with(b">From ") {
161        return true;
162    }
163    let mut i = 0;
164    while i < line.len() {
165        let ch = line[i];
166        if ch == b':' {
167            return true;
168        }
169        if (33..=57).contains(&ch) || (59..=126).contains(&ch) {
170            i += 1;
171            continue;
172        }
173        break;
174    }
175    false
176}
177
178fn read_header_line(inp: &mut Input<'_>, line: &mut Vec<u8>) -> io::Result<bool> {
179    line.clear();
180    if !inp.read_line(line)? {
181        return Ok(false);
182    }
183    if line.is_empty() || !is_rfc2822_header(line) {
184        line.push(b'\n');
185        return Ok(false);
186    }
187    loop {
188        match inp.peek() {
189            Some(b' ') | Some(b'\t') => {
190                inp.pos += 1;
191                let mut cont = Vec::new();
192                if !inp.read_line(&mut cont)? {
193                    break;
194                }
195                line.push(b' ');
196                line.extend_from_slice(&cont);
197            }
198            _ => break,
199        }
200    }
201    Ok(true)
202}
203
204#[derive(Default, Clone)]
205struct OptHdr(Option<String>);
206
207impl OptHdr {
208    fn set_if_empty(&mut self, v: String) {
209        if self.0.is_none() {
210            self.0 = Some(v);
211        }
212    }
213    fn clear(&mut self) {
214        self.0 = None;
215    }
216    fn as_opt(&self) -> Option<&str> {
217        self.0.as_deref()
218    }
219}
220
221#[derive(Clone, Copy, PartialEq, Eq, Default)]
222enum TE {
223    #[default]
224    DontCare,
225    Qp,
226    Base64,
227}
228
229struct Mime {
230    boundaries: Vec<Vec<u8>>,
231    charset: String,
232    te: TE,
233    format_flowed: bool,
234    delsp: bool,
235}
236
237impl Default for Mime {
238    fn default() -> Self {
239        Self {
240            boundaries: Vec::new(),
241            charset: String::new(),
242            te: TE::DontCare,
243            format_flowed: false,
244            delsp: false,
245        }
246    }
247}
248
249/// Extract author/subject/date and split message vs patch.
250///
251/// # Parameters
252///
253/// - `input` — full message bytes (may contain NULs in bodies).
254/// - `opts` — behaviour flags and optional UTF-8 metadata re-encoding target.
255/// - `msg_out` — commit message body (before the patch).
256/// - `patch_out` — diff text; binary-identical to Git where tests require it.
257/// - `info_out` — `Author:` / `Email:` / `Subject:` / `Date:` block.
258/// - `stderr` — receives `warning: quoted CRLF detected` when applicable.
259///
260/// # Errors
261///
262/// Returns an I/O error with kind [`io::ErrorKind::InvalidData`] for empty input or malformed
263/// RFC2047 (matching Git's `input_error` behaviour).
264pub fn mailinfo(
265    input: &[u8],
266    opts: &MailinfoOptions,
267    mut msg_out: impl Write,
268    mut patch_out: impl Write,
269    mut info_out: impl Write,
270    mut stderr: impl Write,
271) -> io::Result<()> {
272    let mut inp = Input::new(input);
273    inp.skip_leading_ws();
274    if inp.eof() {
275        return Err(io::Error::new(io::ErrorKind::InvalidData, "empty patch"));
276    }
277
278    let mut p_from = OptHdr::default();
279    let mut p_subj = OptHdr::default();
280    let mut p_date = OptHdr::default();
281    let mut message_id: Option<String> = None;
282    let mut mime = Mime::default();
283    let mut header_err = false;
284
285    let mut hl = Vec::new();
286    while read_header_line(&mut inp, &mut hl)? {
287        let ls = String::from_utf8_lossy(&hl).into_owned();
288        if !parse_rfc_header(
289            &ls,
290            opts,
291            &mut p_from,
292            &mut p_subj,
293            &mut p_date,
294            &mut mime,
295            &mut message_id,
296        ) {
297            header_err = true;
298        }
299    }
300    if header_err {
301        return Err(io::Error::new(
302            io::ErrorKind::InvalidData,
303            "mailinfo: bad header",
304        ));
305    }
306
307    let mut s_from = OptHdr::default();
308    let mut s_subj = OptHdr::default();
309    let mut s_date = OptHdr::default();
310    let mut log = Vec::new();
311    let mut patch_lines: u64 = 0;
312    let mut have_quoted_cr = false;
313    let mut body_err = false;
314
315    let mut st = BodyState {
316        filter_stage: 0,
317        header_stage: true,
318        inbody_accum: String::new(),
319        qp_carry: Vec::new(),
320        flowed_prev: Vec::new(),
321        body_done: false,
322    };
323
324    let mut line = Vec::new();
325    let mut need_first_boundary = !mime.boundaries.is_empty();
326    loop {
327        if need_first_boundary {
328            need_first_boundary = false;
329            if !find_boundary_line(&mut inp, &mime, &mut line)? {
330                flush_inbody_accum(
331                    &mut st.inbody_accum,
332                    opts,
333                    &mut s_from,
334                    &mut s_subj,
335                    &mut s_date,
336                );
337                break;
338            }
339        } else if !inp.read_line_keep_lf(&mut line)? {
340            break;
341        }
342        process_body_raw_line(
343            &mut inp,
344            &mut line,
345            opts,
346            &mut mime,
347            &mut st,
348            &mut s_from,
349            &mut s_subj,
350            &mut s_date,
351            &mut message_id,
352            &mut log,
353            &mut patch_out,
354            &mut patch_lines,
355            &mut have_quoted_cr,
356            &mut body_err,
357        )?;
358        if body_err {
359            break;
360        }
361        if st.body_done {
362            break;
363        }
364    }
365
366    flush_qp_tail(
367        opts,
368        &mime,
369        &mut st,
370        &mut s_from,
371        &mut s_subj,
372        &mut s_date,
373        &mut message_id,
374        &mut log,
375        &mut patch_out,
376        &mut patch_lines,
377        &mut have_quoted_cr,
378    )?;
379    if !st.flowed_prev.is_empty() {
380        let p = std::mem::take(&mut st.flowed_prev);
381        handle_filter(
382            opts,
383            &p,
384            &mut s_from,
385            &mut s_subj,
386            &mut s_date,
387            &mut message_id,
388            &mut log,
389            &mut patch_out,
390            &mut patch_lines,
391            &mut st.filter_stage,
392            &mut st.header_stage,
393            &mut st.inbody_accum,
394            &mime,
395        )?;
396    }
397    flush_inbody_accum(
398        &mut st.inbody_accum,
399        opts,
400        &mut s_from,
401        &mut s_subj,
402        &mut s_date,
403    );
404
405    if have_quoted_cr && opts.quoted_cr == QuotedCrAction::Warn {
406        writeln!(stderr, "warning: quoted CRLF detected")?;
407    }
408    if body_err {
409        return Err(io::Error::new(
410            io::ErrorKind::InvalidData,
411            "mailinfo: bad body",
412        ));
413    }
414
415    msg_out.write_all(&log)?;
416    write_info(
417        opts,
418        patch_lines,
419        &p_from,
420        &p_subj,
421        &p_date,
422        &s_from,
423        &s_subj,
424        &s_date,
425        &mut info_out,
426    )?;
427    Ok(())
428}
429
430struct BodyState {
431    filter_stage: u8,
432    header_stage: bool,
433    inbody_accum: String,
434    qp_carry: Vec<u8>,
435    flowed_prev: Vec<u8>,
436    /// Set when Git would leave `handle_body` (no more MIME body to read).
437    body_done: bool,
438}
439
440fn parse_rfc_header(
441    line: &str,
442    opts: &MailinfoOptions,
443    p_from: &mut OptHdr,
444    p_subj: &mut OptHdr,
445    p_date: &mut OptHdr,
446    mime: &mut Mime,
447    message_id: &mut Option<String>,
448) -> bool {
449    let Some(colon) = line.find(':') else {
450        return true;
451    };
452    let name = line[..colon].trim();
453    let val = line[colon + 1..].trim_start();
454
455    if name.eq_ignore_ascii_case("From") {
456        let mut v = val.to_string();
457        if decode_rfc2047(opts, &mut v).is_err() {
458            return false;
459        }
460        p_from.set_if_empty(v);
461        return true;
462    }
463    if name.eq_ignore_ascii_case("Subject") {
464        let mut v = val.to_string();
465        if decode_rfc2047(opts, &mut v).is_err() {
466            return false;
467        }
468        p_subj.set_if_empty(v);
469        return true;
470    }
471    if name.eq_ignore_ascii_case("Date") {
472        let mut v = val.to_string();
473        if decode_rfc2047(opts, &mut v).is_err() {
474            return false;
475        }
476        p_date.set_if_empty(v);
477        return true;
478    }
479    if name.eq_ignore_ascii_case("Content-Type") {
480        mime.format_flowed = has_attr_ci(line, "format=", "flowed");
481        mime.delsp = has_attr_ci(line, "delsp=", "yes");
482        if let Some(b) = slurp_attr_ci(line, "boundary=") {
483            let mut full = vec![b'-', b'-'];
484            full.extend_from_slice(b.as_bytes());
485            if mime.boundaries.len() < 5 {
486                mime.boundaries.push(full);
487            }
488        }
489        if let Some(cs) = slurp_attr_ci(line, "charset=") {
490            mime.charset = cs;
491        }
492        return true;
493    }
494    if name.eq_ignore_ascii_case("Content-Transfer-Encoding") {
495        let lower = val.to_ascii_lowercase();
496        mime.te = if lower.contains("base64") {
497            TE::Base64
498        } else if lower.contains("quoted-printable") {
499            TE::Qp
500        } else {
501            TE::DontCare
502        };
503        return true;
504    }
505    if opts.add_message_id
506        && (name.eq_ignore_ascii_case("Message-ID") || name.eq_ignore_ascii_case("Message-Id"))
507    {
508        let mut v = val.to_string();
509        if decode_rfc2047(opts, &mut v).is_err() {
510            return false;
511        }
512        *message_id = Some(v);
513    }
514    true
515}
516
517fn slurp_attr_ci(line: &str, name: &str) -> Option<String> {
518    let lower = line.to_ascii_lowercase();
519    let needle = name.to_ascii_lowercase();
520    let pos = lower.find(&needle)?;
521    let mut ap = &line[pos + needle.len()..];
522    if ap.starts_with('"') {
523        ap = &ap[1..];
524        let end = ap.find('"')?;
525        Some(ap[..end].to_string())
526    } else {
527        let end = ap.find([';', ' ', '\t']).unwrap_or(ap.len());
528        let s = ap[..end].trim();
529        (!s.is_empty()).then(|| s.to_string())
530    }
531}
532
533fn has_attr_ci(line: &str, name: &str, value: &str) -> bool {
534    slurp_attr_ci(line, name).is_some_and(|s| s.eq_ignore_ascii_case(value))
535}
536
537fn decode_rfc2047(opts: &MailinfoOptions, s: &mut String) -> Result<(), ()> {
538    let Some(out) = decode_rfc2047_inner(s, opts.metainfo_charset.as_deref()) else {
539        return Err(());
540    };
541    *s = out;
542    Ok(())
543}
544
545fn decode_rfc2047_inner(input: &str, metainfo: Option<&str>) -> Option<String> {
546    let mut out = String::new();
547    let mut pos = 0usize;
548    while let Some(rel) = input[pos..].find("=?") {
549        let ep = pos + rel;
550        let before = &input[pos..ep];
551        if !before.is_empty() {
552            let only_ws = before.chars().all(|c| c.is_whitespace());
553            if !only_ws || pos == 0 {
554                out.push_str(before);
555            }
556        }
557        let rest = &input[ep + 2..];
558        let d1 = rest.find('?')?;
559        let charset = &rest[..d1];
560        let after = &rest[d1 + 1..];
561        let d2 = after.find('?')?;
562        let enc = after[..d2].to_ascii_lowercase();
563        let after_enc = &after[d2 + 1..];
564        let end = after_enc.find("?=")?;
565        let payload = &after_enc[..end];
566        pos = ep + 2 + d1 + 1 + d2 + 1 + end + 2;
567        let bytes = match enc.as_str() {
568            "q" => decode_qp(payload, true),
569            "b" => base64_decode(payload),
570            _ => return None,
571        };
572        let mut piece = decode_bytes(Some(charset), &bytes);
573        if let Some(target) = metainfo {
574            if let Some(raw) = reencode_utf8_to_label(target, &piece) {
575                piece = decode_bytes(Some(target), &raw);
576            }
577        }
578        out.push_str(&piece);
579    }
580    out.push_str(&input[pos..]);
581    Some(out)
582}
583
584fn decode_qp(input: &str, rfc2047: bool) -> Vec<u8> {
585    let mut out = Vec::new();
586    let bytes = input.as_bytes();
587    let mut i = 0usize;
588    while i < bytes.len() {
589        let b = bytes[i];
590        if rfc2047 && b == b'_' {
591            out.push(b' ');
592            i += 1;
593            continue;
594        }
595        if b == b'=' {
596            let d1 = bytes.get(i + 1).copied();
597            if d1 == Some(b'\n') {
598                i += 2;
599                continue;
600            }
601            if d1 == Some(b'\r') && bytes.get(i + 2) == Some(&b'\n') {
602                i += 3;
603                continue;
604            }
605            if d1.is_none() || d1 == Some(b'\n') {
606                break;
607            }
608            let h1 = d1;
609            let h2 = bytes.get(i + 2).copied();
610            if let (Some(a), Some(c)) = (h1, h2) {
611                if let (Some(hi), Some(lo)) = (hex_nibble(a), hex_nibble(c)) {
612                    out.push((hi << 4) | lo);
613                    i += 3;
614                    continue;
615                }
616            }
617            out.push(b'=');
618            i += 1;
619            continue;
620        }
621        out.push(b);
622        i += 1;
623    }
624    out
625}
626
627fn hex_nibble(b: u8) -> Option<u8> {
628    match b {
629        b'0'..=b'9' => Some(b - b'0'),
630        b'a'..=b'f' => Some(b - b'a' + 10),
631        b'A'..=b'F' => Some(b - b'A' + 10),
632        _ => None,
633    }
634}
635
636fn base64_decode(input: &str) -> Vec<u8> {
637    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
638    let mut o = Vec::new();
639    let mut buf: u32 = 0;
640    let mut bits: u32 = 0;
641    for &byte in input.as_bytes() {
642        if byte == b'=' {
643            break;
644        }
645        if byte.is_ascii_whitespace() {
646            continue;
647        }
648        let Some(v) = T.iter().position(|&c| c == byte) else {
649            continue;
650        };
651        buf = (buf << 6) | v as u32;
652        bits += 6;
653        if bits >= 8 {
654            bits -= 8;
655            o.push((buf >> bits) as u8);
656            buf &= (1 << bits) - 1;
657        }
658    }
659    o
660}
661
662#[allow(clippy::too_many_arguments)]
663fn process_body_raw_line(
664    inp: &mut Input<'_>,
665    raw: &mut Vec<u8>,
666    opts: &MailinfoOptions,
667    mime: &mut Mime,
668    st: &mut BodyState,
669    s_from: &mut OptHdr,
670    s_subj: &mut OptHdr,
671    s_date: &mut OptHdr,
672    message_id: &mut Option<String>,
673    log: &mut Vec<u8>,
674    patch_out: &mut impl Write,
675    patch_lines: &mut u64,
676    have_quoted_cr: &mut bool,
677    body_err: &mut bool,
678) -> io::Result<()> {
679    if let Some(boundary) = mime.boundaries.last().cloned() {
680        if raw.len() >= boundary.len() && raw[..boundary.len()] == boundary[..] {
681            let after = &raw[boundary.len()..];
682            let is_closing = after.starts_with(b"--");
683            if is_closing {
684                mime.boundaries.pop();
685                if mime.boundaries.is_empty() {
686                    handle_filter(
687                        opts,
688                        b"\n",
689                        s_from,
690                        s_subj,
691                        s_date,
692                        message_id,
693                        log,
694                        patch_out,
695                        patch_lines,
696                        &mut st.filter_stage,
697                        &mut st.header_stage,
698                        &mut st.inbody_accum,
699                        mime,
700                    )?;
701                }
702                loop {
703                    if !find_boundary_line(inp, mime, raw)? {
704                        flush_inbody_accum(&mut st.inbody_accum, opts, s_from, s_subj, s_date);
705                        st.body_done = true;
706                        return Ok(());
707                    }
708                    let Some(b) = mime.boundaries.last() else {
709                        break;
710                    };
711                    if raw.len() >= b.len() + 2
712                        && raw[..b.len()] == b[..]
713                        && &raw[b.len()..b.len() + 2] == b"--"
714                    {
715                        mime.boundaries.pop();
716                        if mime.boundaries.is_empty() {
717                            handle_filter(
718                                opts,
719                                b"\n",
720                                s_from,
721                                s_subj,
722                                s_date,
723                                message_id,
724                                log,
725                                patch_out,
726                                patch_lines,
727                                &mut st.filter_stage,
728                                &mut st.header_stage,
729                                &mut st.inbody_accum,
730                                mime,
731                            )?;
732                        }
733                        continue;
734                    }
735                    break;
736                }
737            } else {
738                *have_quoted_cr = false;
739            }
740            mime.te = TE::DontCare;
741            mime.charset.clear();
742            mime.format_flowed = false;
743            mime.delsp = false;
744            read_part_headers(inp, mime, raw, opts, body_err)?;
745            if *body_err {
746                return Ok(());
747            }
748            if !inp.read_line_keep_lf(raw)? {
749                flush_inbody_accum(&mut st.inbody_accum, opts, s_from, s_subj, s_date);
750                st.body_done = true;
751                return Ok(());
752            }
753        }
754    }
755
756    if st.body_done {
757        return Ok(());
758    }
759
760    let mut decoded = raw.clone();
761    match mime.te {
762        TE::Qp => {
763            let s = String::from_utf8_lossy(&decoded).into_owned();
764            decoded = decode_qp(&s, false);
765        }
766        TE::Base64 => {
767            let s: String = String::from_utf8_lossy(&decoded)
768                .chars()
769                .filter(|c| !c.is_ascii_whitespace())
770                .collect();
771            decoded = base64_decode(&s);
772        }
773        TE::DontCare => {}
774    }
775
776    match mime.te {
777        TE::Qp | TE::Base64 => {
778            st.qp_carry.extend_from_slice(&decoded);
779            split_qp_carry(
780                opts,
781                mime,
782                st,
783                s_from,
784                s_subj,
785                s_date,
786                message_id,
787                log,
788                patch_out,
789                patch_lines,
790                have_quoted_cr,
791            )?;
792        }
793        TE::DontCare => {
794            if !decoded.ends_with(b"\n") && !decoded.is_empty() {
795                decoded.push(b'\n');
796            }
797            process_decoded_physical_line(
798                opts,
799                mime,
800                st,
801                &decoded,
802                s_from,
803                s_subj,
804                s_date,
805                message_id,
806                log,
807                patch_out,
808                patch_lines,
809                have_quoted_cr,
810            )?;
811        }
812    }
813    Ok(())
814}
815
816fn find_boundary_line(inp: &mut Input<'_>, mime: &Mime, line: &mut Vec<u8>) -> io::Result<bool> {
817    let Some(b) = mime.boundaries.last() else {
818        return Ok(false);
819    };
820    loop {
821        line.clear();
822        if !inp.read_line_keep_lf(line)? {
823            return Ok(false);
824        }
825        if line.len() >= b.len() && line[..b.len()] == b[..] {
826            return Ok(true);
827        }
828    }
829}
830
831fn read_part_headers(
832    inp: &mut Input<'_>,
833    mime: &mut Mime,
834    line: &mut Vec<u8>,
835    opts: &MailinfoOptions,
836    body_err: &mut bool,
837) -> io::Result<()> {
838    while read_header_line(inp, line)? {
839        let ls = String::from_utf8_lossy(line).into_owned();
840        let mut d1 = OptHdr::default();
841        let mut d2 = OptHdr::default();
842        let mut d3 = OptHdr::default();
843        if !parse_rfc_header(&ls, opts, &mut d1, &mut d2, &mut d3, mime, &mut None) {
844            *body_err = true;
845            return Ok(());
846        }
847    }
848    Ok(())
849}
850
851fn split_qp_carry(
852    opts: &MailinfoOptions,
853    mime: &Mime,
854    st: &mut BodyState,
855    s_from: &mut OptHdr,
856    s_subj: &mut OptHdr,
857    s_date: &mut OptHdr,
858    message_id: &mut Option<String>,
859    log: &mut Vec<u8>,
860    patch_out: &mut impl Write,
861    patch_lines: &mut u64,
862    have_quoted_cr: &mut bool,
863) -> io::Result<()> {
864    let mut start = 0;
865    let mut i = 0;
866    while i < st.qp_carry.len() {
867        if st.qp_carry[i] == b'\n' {
868            let chunk = st.qp_carry[start..=i].to_vec();
869            start = i + 1;
870            process_decoded_physical_line(
871                opts,
872                mime,
873                st,
874                &chunk,
875                s_from,
876                s_subj,
877                s_date,
878                message_id,
879                log,
880                patch_out,
881                patch_lines,
882                have_quoted_cr,
883            )?;
884        }
885        i += 1;
886    }
887    st.qp_carry.drain(..start);
888    Ok(())
889}
890
891fn flush_qp_tail(
892    opts: &MailinfoOptions,
893    mime: &Mime,
894    st: &mut BodyState,
895    s_from: &mut OptHdr,
896    s_subj: &mut OptHdr,
897    s_date: &mut OptHdr,
898    message_id: &mut Option<String>,
899    log: &mut Vec<u8>,
900    patch_out: &mut impl Write,
901    patch_lines: &mut u64,
902    have_quoted_cr: &mut bool,
903) -> io::Result<()> {
904    if st.qp_carry.is_empty() {
905        return Ok(());
906    }
907    let mut chunk = std::mem::take(&mut st.qp_carry);
908    if !chunk.ends_with(b"\n") {
909        chunk.push(b'\n');
910    }
911    process_decoded_physical_line(
912        opts,
913        mime,
914        st,
915        &chunk,
916        s_from,
917        s_subj,
918        s_date,
919        message_id,
920        log,
921        patch_out,
922        patch_lines,
923        have_quoted_cr,
924    )
925}
926
927fn process_decoded_physical_line(
928    opts: &MailinfoOptions,
929    mime: &Mime,
930    st: &mut BodyState,
931    line: &[u8],
932    s_from: &mut OptHdr,
933    s_subj: &mut OptHdr,
934    s_date: &mut OptHdr,
935    message_id: &mut Option<String>,
936    log: &mut Vec<u8>,
937    patch_out: &mut impl Write,
938    patch_lines: &mut u64,
939    have_quoted_cr: &mut bool,
940) -> io::Result<()> {
941    if mime.format_flowed {
942        handle_filter_flowed(
943            opts,
944            mime,
945            line,
946            st,
947            s_from,
948            s_subj,
949            s_date,
950            message_id,
951            log,
952            patch_out,
953            patch_lines,
954            have_quoted_cr,
955        )
956    } else {
957        let mut l = line.to_vec();
958        if l.len() >= 2 && l[l.len() - 2] == b'\r' && l[l.len() - 1] == b'\n' {
959            *have_quoted_cr = true;
960            if opts.quoted_cr == QuotedCrAction::Strip {
961                l.truncate(l.len() - 2);
962                l.push(b'\n');
963            }
964        }
965        handle_filter(
966            opts,
967            &l,
968            s_from,
969            s_subj,
970            s_date,
971            message_id,
972            log,
973            patch_out,
974            patch_lines,
975            &mut st.filter_stage,
976            &mut st.header_stage,
977            &mut st.inbody_accum,
978            mime,
979        )
980    }
981}
982
983fn bytes_to_log_text(line: &[u8], mime: &Mime) -> String {
984    let cs = (!mime.charset.trim().is_empty()).then_some(mime.charset.as_str());
985    decode_bytes(cs, line)
986}
987
988fn handle_filter_flowed(
989    opts: &MailinfoOptions,
990    mime: &Mime,
991    line: &[u8],
992    st: &mut BodyState,
993    s_from: &mut OptHdr,
994    s_subj: &mut OptHdr,
995    s_date: &mut OptHdr,
996    message_id: &mut Option<String>,
997    log: &mut Vec<u8>,
998    patch_out: &mut impl Write,
999    patch_lines: &mut u64,
1000    have_quoted_cr: &mut bool,
1001) -> io::Result<()> {
1002    let mut len = line.len();
1003    if len > 0 && line[len - 1] == b'\n' {
1004        len -= 1;
1005        if len > 0 && line[len - 1] == b'\r' {
1006            len -= 1;
1007        }
1008    }
1009
1010    if line.len() >= 3 && &line[..3] == b"-- " && len == 3 {
1011        if !st.flowed_prev.is_empty() {
1012            let p = std::mem::take(&mut st.flowed_prev);
1013            handle_filter(
1014                opts,
1015                &p,
1016                s_from,
1017                s_subj,
1018                s_date,
1019                message_id,
1020                log,
1021                patch_out,
1022                patch_lines,
1023                &mut st.filter_stage,
1024                &mut st.header_stage,
1025                &mut st.inbody_accum,
1026                mime,
1027            )?;
1028        }
1029        return handle_filter(
1030            opts,
1031            line,
1032            s_from,
1033            s_subj,
1034            s_date,
1035            message_id,
1036            log,
1037            patch_out,
1038            patch_lines,
1039            &mut st.filter_stage,
1040            &mut st.header_stage,
1041            &mut st.inbody_accum,
1042            mime,
1043        );
1044    }
1045
1046    if len > 0 && line[0] == b' ' {
1047        let mut l = line[1..].to_vec();
1048        if !l.ends_with(b"\n") {
1049            l.push(b'\n');
1050        }
1051        return process_decoded_physical_line(
1052            opts,
1053            &Mime {
1054                format_flowed: false,
1055                charset: mime.charset.clone(),
1056                ..Default::default()
1057            },
1058            st,
1059            &l,
1060            s_from,
1061            s_subj,
1062            s_date,
1063            message_id,
1064            log,
1065            patch_out,
1066            patch_lines,
1067            have_quoted_cr,
1068        );
1069    }
1070
1071    if len > 0 && line[len - 1] == b' ' {
1072        let take = len - usize::from(mime.delsp);
1073        st.flowed_prev.extend_from_slice(&line[..take]);
1074        return Ok(());
1075    }
1076
1077    let mut combined = Vec::new();
1078    combined.extend_from_slice(&st.flowed_prev);
1079    combined.extend_from_slice(&line[..len]);
1080    st.flowed_prev.clear();
1081    if !combined.ends_with(b"\n") {
1082        combined.push(b'\n');
1083    }
1084    process_decoded_physical_line(
1085        opts,
1086        &Mime {
1087            format_flowed: false,
1088            charset: mime.charset.clone(),
1089            ..Default::default()
1090        },
1091        st,
1092        &combined,
1093        s_from,
1094        s_subj,
1095        s_date,
1096        message_id,
1097        log,
1098        patch_out,
1099        patch_lines,
1100        have_quoted_cr,
1101    )
1102}
1103
1104fn handle_filter(
1105    opts: &MailinfoOptions,
1106    line: &[u8],
1107    s_from: &mut OptHdr,
1108    s_subj: &mut OptHdr,
1109    s_date: &mut OptHdr,
1110    message_id: &mut Option<String>,
1111    log: &mut Vec<u8>,
1112    patch_out: &mut impl Write,
1113    patch_lines: &mut u64,
1114    filter_stage: &mut u8,
1115    header_stage: &mut bool,
1116    inbody_accum: &mut String,
1117    mime: &Mime,
1118) -> io::Result<()> {
1119    match *filter_stage {
1120        0 => {
1121            if !handle_commit_msg(
1122                opts,
1123                line,
1124                mime,
1125                s_from,
1126                s_subj,
1127                s_date,
1128                message_id,
1129                log,
1130                header_stage,
1131                inbody_accum,
1132            )? {
1133                return Ok(());
1134            }
1135            *filter_stage = 1;
1136            patch_out.write_all(line)?;
1137            *patch_lines += 1;
1138            Ok(())
1139        }
1140        1 => {
1141            patch_out.write_all(line)?;
1142            *patch_lines += 1;
1143            Ok(())
1144        }
1145        _ => Ok(()),
1146    }
1147}
1148
1149fn handle_commit_msg(
1150    opts: &MailinfoOptions,
1151    line: &[u8],
1152    mime: &Mime,
1153    s_from: &mut OptHdr,
1154    s_subj: &mut OptHdr,
1155    s_date: &mut OptHdr,
1156    message_id: &mut Option<String>,
1157    log: &mut Vec<u8>,
1158    header_stage: &mut bool,
1159    inbody_accum: &mut String,
1160) -> io::Result<bool> {
1161    let text = bytes_to_log_text(line, mime);
1162
1163    if *header_stage {
1164        let only_ws = text.chars().all(|c| c.is_whitespace());
1165        if only_ws {
1166            if !inbody_accum.is_empty() {
1167                flush_inbody_accum(inbody_accum, opts, s_from, s_subj, s_date);
1168                *header_stage = false;
1169            }
1170            return Ok(false);
1171        }
1172    }
1173
1174    if opts.use_inbody_headers && *header_stage {
1175        *header_stage = check_inbody(opts, &text, inbody_accum, s_from, s_subj, s_date);
1176        if *header_stage {
1177            return Ok(false);
1178        }
1179    } else {
1180        *header_stage = false;
1181    }
1182
1183    if opts.use_scissors && is_scissors_line(&text) {
1184        log.clear();
1185        *header_stage = true;
1186        s_from.clear();
1187        s_subj.clear();
1188        s_date.clear();
1189        return Ok(false);
1190    }
1191
1192    if patchbreak(line) {
1193        if let Some(mid) = message_id.clone() {
1194            log.extend_from_slice(format!("Message-ID: {mid}\n").as_bytes());
1195        }
1196        return Ok(true);
1197    }
1198
1199    log.extend_from_slice(text.as_bytes());
1200    Ok(false)
1201}
1202
1203fn flush_inbody_accum(
1204    acc: &mut String,
1205    opts: &MailinfoOptions,
1206    s_from: &mut OptHdr,
1207    s_subj: &mut OptHdr,
1208    s_date: &mut OptHdr,
1209) {
1210    if acc.is_empty() {
1211        return;
1212    }
1213    let line = std::mem::take(acc);
1214    apply_inbody_line(&line, opts, s_from, s_subj, s_date);
1215}
1216
1217fn apply_inbody_line(
1218    line: &str,
1219    opts: &MailinfoOptions,
1220    s_from: &mut OptHdr,
1221    s_subj: &mut OptHdr,
1222    s_date: &mut OptHdr,
1223) {
1224    let Some(colon) = line.find(':') else {
1225        return;
1226    };
1227    let name = line[..colon].trim();
1228    let mut val = line[colon + 1..].trim_start().to_string();
1229    let _ = decode_rfc2047(opts, &mut val);
1230    if name.eq_ignore_ascii_case("From") && s_from.as_opt().is_none() {
1231        s_from.set_if_empty(val);
1232    } else if name.eq_ignore_ascii_case("Subject") && s_subj.as_opt().is_none() {
1233        s_subj.set_if_empty(val);
1234    } else if name.eq_ignore_ascii_case("Date") && s_date.as_opt().is_none() {
1235        s_date.set_if_empty(val);
1236    }
1237}
1238
1239fn inbody_header_candidate(line: &str, s_from: &OptHdr, s_subj: &OptHdr, s_date: &OptHdr) -> bool {
1240    let Some(colon) = line.find(':') else {
1241        return false;
1242    };
1243    let name = line[..colon].trim();
1244    (name.eq_ignore_ascii_case("From") && s_from.as_opt().is_none())
1245        || (name.eq_ignore_ascii_case("Subject") && s_subj.as_opt().is_none())
1246        || (name.eq_ignore_ascii_case("Date") && s_date.as_opt().is_none())
1247}
1248
1249fn check_inbody(
1250    opts: &MailinfoOptions,
1251    line: &str,
1252    accum: &mut String,
1253    s_from: &mut OptHdr,
1254    s_subj: &mut OptHdr,
1255    s_date: &mut OptHdr,
1256) -> bool {
1257    if !accum.is_empty() && (line.starts_with(' ') || line.starts_with('\t')) {
1258        if opts.use_scissors && is_scissors_line(line) {
1259            flush_inbody_accum(accum, opts, s_from, s_subj, s_date);
1260            return false;
1261        }
1262        while accum.ends_with('\n') {
1263            accum.pop();
1264        }
1265        accum.push_str(line);
1266        return true;
1267    }
1268    flush_inbody_accum(accum, opts, s_from, s_subj, s_date);
1269
1270    if line.starts_with(">From ") {
1271        let rest = &line[1..];
1272        if is_format_patch_sep(rest) {
1273            return true;
1274        }
1275    }
1276    if line.starts_with("[PATCH] ") {
1277        s_subj.set_if_empty(line.to_string());
1278        return true;
1279    }
1280    if inbody_header_candidate(line, s_from, s_subj, s_date) {
1281        accum.push_str(line);
1282        return true;
1283    }
1284    false
1285}
1286
1287fn is_format_patch_sep(line: &str) -> bool {
1288    const TAIL: &str = " Mon Sep 17 00:00:00 2001\n";
1289    if !line.starts_with("From ") || line.len() != 5 + 40 + TAIL.len() {
1290        return false;
1291    }
1292    let hex = &line[5..45];
1293    hex.chars().all(|c| c.is_ascii_hexdigit()) && &line[45..] == TAIL
1294}
1295
1296fn patchbreak(line: &[u8]) -> bool {
1297    if line.starts_with(b"diff -") || line.starts_with(b"Index: ") {
1298        return true;
1299    }
1300    if line.len() < 4 || !line.starts_with(b"---") {
1301        return false;
1302    }
1303    if line.len() > 3 && line[3] == b' ' {
1304        return line.len() > 4 && !line[4].is_ascii_whitespace();
1305    }
1306    for i in 3..line.len() {
1307        match line[i] {
1308            b'\n' => return true,
1309            b if !b.is_ascii_whitespace() => break,
1310            _ => {}
1311        }
1312    }
1313    false
1314}
1315
1316fn is_scissors_line(line: &str) -> bool {
1317    let mut scissors = 0;
1318    let mut gap = 0;
1319    let mut first_nb = None;
1320    let mut last_nb = None;
1321    let mut perforation: usize = 0;
1322    let mut in_perf = false;
1323    let c: Vec<char> = line.chars().collect();
1324    let mut i = 0;
1325    while i < c.len() {
1326        let ch = c[i];
1327        if ch.is_whitespace() {
1328            if in_perf {
1329                perforation += 1;
1330                gap += 1;
1331            }
1332            i += 1;
1333            continue;
1334        }
1335        last_nb = Some(i);
1336        if first_nb.is_none() {
1337            first_nb = Some(i);
1338        }
1339        if ch == '-' {
1340            in_perf = true;
1341            perforation += 1;
1342            i += 1;
1343            continue;
1344        }
1345        let rest: String = c[i..].iter().collect();
1346        if rest.starts_with(">8")
1347            || rest.starts_with("8<")
1348            || rest.starts_with(">%")
1349            || rest.starts_with("%<")
1350        {
1351            in_perf = true;
1352            perforation += 2;
1353            scissors += 2;
1354            i += 2;
1355            continue;
1356        }
1357        in_perf = false;
1358        i += 1;
1359    }
1360    let visible = match (first_nb, last_nb) {
1361        (Some(a), Some(b)) => b - a + 1,
1362        _ => 0,
1363    };
1364    scissors > 0 && visible >= 8 && visible < perforation.saturating_mul(3) && gap * 2 < perforation
1365}
1366
1367fn write_info(
1368    opts: &MailinfoOptions,
1369    patch_lines: u64,
1370    p_from: &OptHdr,
1371    p_subj: &OptHdr,
1372    p_date: &OptHdr,
1373    s_from: &OptHdr,
1374    s_subj: &OptHdr,
1375    s_date: &OptHdr,
1376    out: &mut impl Write,
1377) -> io::Result<()> {
1378    for (hdr, val) in [
1379        (
1380            "From",
1381            if patch_lines > 0 && s_from.as_opt().is_some() {
1382                s_from.as_opt()
1383            } else {
1384                p_from.as_opt()
1385            },
1386        ),
1387        (
1388            "Subject",
1389            if patch_lines > 0 && s_subj.as_opt().is_some() {
1390                s_subj.as_opt()
1391            } else {
1392                p_subj.as_opt()
1393            },
1394        ),
1395        (
1396            "Date",
1397            if patch_lines > 0 && s_date.as_opt().is_some() {
1398                s_date.as_opt()
1399            } else {
1400                p_date.as_opt()
1401            },
1402        ),
1403    ] {
1404        let Some(val) = val else { continue };
1405        if val.as_bytes().contains(&0) {
1406            return Err(io::Error::new(io::ErrorKind::InvalidData, "NUL in header"));
1407        }
1408        match hdr {
1409            "Subject" => {
1410                let mut subj = val.to_string();
1411                if !opts.keep_subject {
1412                    cleanup_subject(opts, &mut subj);
1413                    cleanup_space(&mut subj);
1414                }
1415                for part in subj.split('\n') {
1416                    writeln!(out, "Subject: {part}")?;
1417                }
1418            }
1419            "From" => {
1420                let mut f = val.to_string();
1421                cleanup_space(&mut f);
1422                let (name, email) = handle_from(&f);
1423                writeln!(out, "Author: {name}")?;
1424                writeln!(out, "Email: {email}")?;
1425            }
1426            _ => {
1427                let mut d = val.to_string();
1428                cleanup_space(&mut d);
1429                writeln!(out, "{hdr}: {d}")?;
1430            }
1431        }
1432    }
1433    writeln!(out)?;
1434    Ok(())
1435}
1436
1437fn cleanup_space(s: &mut String) {
1438    let chs: Vec<char> = s.chars().collect();
1439    let mut out = String::new();
1440    let mut i = 0;
1441    while i < chs.len() {
1442        if chs[i].is_whitespace() {
1443            out.push(' ');
1444            i += 1;
1445            while i < chs.len() && chs[i].is_whitespace() {
1446                i += 1;
1447            }
1448        } else {
1449            out.push(chs[i]);
1450            i += 1;
1451        }
1452    }
1453    *s = out;
1454}
1455
1456fn cleanup_subject(opts: &MailinfoOptions, subject: &mut String) {
1457    let mut at = 0;
1458    while at < subject.len() {
1459        let rest = &subject[at..];
1460        let Some(ch0) = rest.chars().next() else {
1461            break;
1462        };
1463        match ch0 {
1464            'r' | 'R' => {
1465                let b = rest.as_bytes();
1466                if rest.len() >= 3 && (b[1] == b'e' || b[1] == b'E') && b[2] == b':' {
1467                    subject.drain(at..at + 3);
1468                    continue;
1469                }
1470                at += ch0.len_utf8();
1471            }
1472            ' ' | '\t' | ':' => {
1473                subject.remove(at);
1474                continue;
1475            }
1476            '[' => {
1477                let Some(end_rel) = rest.find(']') else {
1478                    break;
1479                };
1480                let remove = end_rel + 1;
1481                let bracket = &rest[..remove];
1482                let strip = !opts.keep_non_patch_brackets_in_subject
1483                    || (remove >= 7 && bracket.to_ascii_uppercase().contains("PATCH"));
1484                if strip {
1485                    subject.drain(at..at + remove);
1486                } else {
1487                    at += remove;
1488                    if at < subject.len() && subject[at..].starts_with(|c: char| c.is_whitespace())
1489                    {
1490                        at += 1;
1491                    }
1492                }
1493                continue;
1494            }
1495            _ => break,
1496        }
1497    }
1498    *subject = subject.trim().to_string();
1499}
1500
1501fn handle_from(from: &str) -> (String, String) {
1502    let mut f = unquote_quoted_pair(from);
1503    let Some(mut at) = f.find('@') else {
1504        return parse_bogus_from(from);
1505    };
1506    if f[at + 1..].contains('@') {
1507        return (String::new(), String::new());
1508    }
1509    let mut bytes = std::mem::take(&mut f).into_bytes();
1510    while at > 0 {
1511        let prev = bytes[at - 1];
1512        if prev.is_ascii_whitespace() {
1513            break;
1514        }
1515        if prev == b'<' {
1516            bytes[at - 1] = b' ';
1517            break;
1518        }
1519        at -= 1;
1520    }
1521    let el = bytes[at..]
1522        .iter()
1523        .take_while(|&&b| !b.is_ascii_whitespace() && b != b'>')
1524        .count();
1525    let email = String::from_utf8_lossy(&bytes[at..at + el]).into_owned();
1526    let skip = bytes
1527        .get(at + el)
1528        .filter(|&&b| b == b'>')
1529        .map(|_| 1)
1530        .unwrap_or(0);
1531    let remove = el + skip;
1532    let mut name = String::new();
1533    name.push_str(&String::from_utf8_lossy(&bytes[..at]));
1534    name.push_str(&String::from_utf8_lossy(&bytes[at + remove..]));
1535    cleanup_space(&mut name);
1536    let mut name = name.trim().to_string();
1537    if name.starts_with('(') && name.len() > 1 && name.ends_with(')') {
1538        name = name[1..name.len() - 1].to_string();
1539    }
1540    let mut disp = name.clone();
1541    if disp.is_empty()
1542        || disp.len() > 60
1543        || disp.contains('@')
1544        || disp.contains('<')
1545        || disp.contains('>')
1546    {
1547        disp.clone_from(&email);
1548    }
1549    (disp, email)
1550}
1551
1552fn parse_bogus_from(line: &str) -> (String, String) {
1553    let Some(bra) = line.find('<') else {
1554        return (String::new(), String::new());
1555    };
1556    let Some(k) = line[bra + 1..].find('>') else {
1557        return (String::new(), String::new());
1558    };
1559    let ket = bra + 1 + k;
1560    let email = line[bra + 1..ket].to_string();
1561    let mut name = line[..bra].trim().trim_matches('"').to_string();
1562    if name.is_empty()
1563        || name.len() > 60
1564        || name.contains('@')
1565        || name.contains('<')
1566        || name.contains('>')
1567    {
1568        name.clone_from(&email);
1569    }
1570    (name, email)
1571}
1572
1573fn unquote_quoted_pair(input: &str) -> String {
1574    let mut out = String::new();
1575    let mut it = input.chars().peekable();
1576    while let Some(c) = it.next() {
1577        match c {
1578            '"' => {
1579                while let Some(d) = it.next() {
1580                    if d == '\\' {
1581                        if let Some(e) = it.next() {
1582                            out.push(e);
1583                        }
1584                    } else if d == '"' {
1585                        break;
1586                    } else {
1587                        out.push(d);
1588                    }
1589                }
1590            }
1591            '(' => {
1592                out.push('(');
1593                let mut depth = 1;
1594                let mut lit = false;
1595                for d in it.by_ref() {
1596                    if lit {
1597                        out.push(d);
1598                        lit = false;
1599                        continue;
1600                    }
1601                    if d == '\\' {
1602                        lit = true;
1603                        continue;
1604                    }
1605                    match d {
1606                        '(' => {
1607                            out.push('(');
1608                            depth += 1;
1609                        }
1610                        ')' => {
1611                            out.push(')');
1612                            depth -= 1;
1613                            if depth == 0 {
1614                                break;
1615                            }
1616                        }
1617                        _ => out.push(d),
1618                    }
1619                }
1620            }
1621            _ => out.push(c),
1622        }
1623    }
1624    out
1625}