Skip to main content

mailrs_mail_builder/
strict.rs

1//! Strict-mode invariant linter.
2//!
3//! `MessageBuilder::strict_mode()` enables a set of pre-build
4//! invariant checks; `build_strict()` returns `Err` if any fail.
5//! The same invariants ship as a callable [`lint`] function so
6//! callers can audit messages built by other code paths.
7
8use std::fmt;
9
10/// One lint failure category.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum LintError {
13    /// No `From:` mailbox set. RFC 5322 §3.6 requires it.
14    MissingFrom,
15    /// Neither `To:`, `Cc:`, nor `Bcc:` set. The message has no
16    /// recipient.
17    MissingRecipient,
18    /// `Message-ID:` value is malformed (missing angle brackets).
19    BadMessageId(String),
20    /// A header value contains a bare LF or a lone CR (control
21    /// character that would split the message during parse). The
22    /// string is the offending header name.
23    ControlCharsInHeader(String),
24    /// An attachment filename contains a CR / LF / NUL (injection
25    /// vector).
26    BadAttachmentFilename(String),
27    /// A body line exceeds 998 octets (RFC 5322 §2.1.1 hard limit)
28    /// after the CTE was applied.
29    BodyLineTooLong {
30        /// 1-based line index in the body block.
31        line_no: usize,
32        /// Length of the offending line in octets.
33        len: usize,
34    },
35}
36
37impl fmt::Display for LintError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::MissingFrom => f.write_str("missing From: header"),
41            Self::MissingRecipient => f.write_str("missing recipient (no To:/Cc:/Bcc:)"),
42            Self::BadMessageId(s) => write!(f, "malformed Message-ID: {s:?}"),
43            Self::ControlCharsInHeader(name) => {
44                write!(f, "control characters in header {name:?}")
45            }
46            Self::BadAttachmentFilename(name) => {
47                write!(f, "control characters in attachment filename {name:?}")
48            }
49            Self::BodyLineTooLong { line_no, len } => {
50                write!(f, "body line {line_no} too long ({len} > 998 octets)")
51            }
52        }
53    }
54}
55
56impl std::error::Error for LintError {}
57
58/// Check a raw built message against the invariants. Returns the
59/// first failure or `Ok(())` if everything passes.
60pub fn lint(raw: &[u8]) -> Result<(), LintError> {
61    // bare LF in the header block
62    let (headers, body) = match find_header_terminator(raw) {
63        Some(idx) => (&raw[..idx], &raw[idx + 4..]),
64        None => (raw, &[][..]),
65    };
66    check_header_block(headers)?;
67    check_body_line_lengths(body)?;
68    Ok(())
69}
70
71fn find_header_terminator(raw: &[u8]) -> Option<usize> {
72    raw.windows(4).position(|w| w == b"\r\n\r\n")
73}
74
75fn check_header_block(headers: &[u8]) -> Result<(), LintError> {
76    // unfold first: continuation lines start with SP/HTAB
77    // we just iterate lines and check raw bytes — bare LF or lone
78    // CR anywhere in headers is a hard fail
79    let mut i = 0;
80    while i < headers.len() {
81        let b = headers[i];
82        if b == b'\n' && (i == 0 || headers[i - 1] != b'\r') {
83            // bare LF
84            return Err(LintError::ControlCharsInHeader("?".to_string()));
85        }
86        if b == b'\r' && (i + 1 >= headers.len() || headers[i + 1] != b'\n') {
87            // lone CR
88            return Err(LintError::ControlCharsInHeader("?".to_string()));
89        }
90        i += 1;
91    }
92    Ok(())
93}
94
95fn check_body_line_lengths(body: &[u8]) -> Result<(), LintError> {
96    let mut line_no = 1usize;
97    let mut cur = 0usize;
98    for &b in body {
99        if b == b'\n' {
100            if cur > 998 {
101                return Err(LintError::BodyLineTooLong { line_no, len: cur });
102            }
103            cur = 0;
104            line_no += 1;
105        } else if b != b'\r' {
106            cur += 1;
107        }
108    }
109    if cur > 998 {
110        return Err(LintError::BodyLineTooLong { line_no, len: cur });
111    }
112    Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn clean_message_passes() {
121        let raw = b"From: a@x\r\nTo: b@y\r\nSubject: s\r\n\r\nbody\r\n";
122        assert_eq!(lint(raw), Ok(()));
123    }
124
125    #[test]
126    fn bare_lf_in_headers_fails() {
127        let raw = b"From: a@x\nTo: b@y\r\n\r\nbody\r\n";
128        assert!(matches!(lint(raw), Err(LintError::ControlCharsInHeader(_))));
129    }
130
131    #[test]
132    fn lone_cr_in_headers_fails() {
133        let raw = b"From: a@x\rTo: b@y\r\n\r\nbody\r\n";
134        assert!(matches!(lint(raw), Err(LintError::ControlCharsInHeader(_))));
135    }
136
137    #[test]
138    fn body_line_999_chars_fails() {
139        let mut raw = b"From: a@x\r\n\r\n".to_vec();
140        raw.extend(std::iter::repeat_n(b'x', 999));
141        raw.extend_from_slice(b"\r\n");
142        assert!(matches!(
143            lint(&raw),
144            Err(LintError::BodyLineTooLong { len: 999, .. })
145        ));
146    }
147
148    #[test]
149    fn body_line_998_chars_passes() {
150        let mut raw = b"From: a@x\r\n\r\n".to_vec();
151        raw.extend(std::iter::repeat_n(b'x', 998));
152        raw.extend_from_slice(b"\r\n");
153        assert_eq!(lint(&raw), Ok(()));
154    }
155
156    #[test]
157    fn display_format_is_human_readable() {
158        let e = LintError::MissingFrom;
159        assert_eq!(e.to_string(), "missing From: header");
160        let e = LintError::BodyLineTooLong { line_no: 5, len: 1200 };
161        assert!(e.to_string().contains("line 5"));
162        assert!(e.to_string().contains("1200"));
163    }
164}