1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
//! Split patch mailboxes without decoding or publishing their messages.
//!
//! [`split`] preserves each Unix `From ` envelope and all body bytes except CRLF line endings,
//! which become LF by default. Escaped `>From` lines stay escaped for the later mail decoder.
//! [`split_with`] also accepts a bare RFC message when requested; that mode returns the entire
//! bare input as one message. Leading spaces, tabs, CR and LF before the first message are
//! discarded. Filesystem traversal, Maildir ordering and output names remain caller-owned.
//!
//! ```
//! let input = b"From writer Mon Sep 17 00:00:00 2001\nSubject: Patch\n\nBody\n";
//! let messages = girt::mailbox::split(input)?;
//! assert_eq!(messages, vec![input.to_vec()]);
//! # Ok::<(), girt::mailbox::Error>(())
//! ```
use std::sync::atomic::{AtomicBool, Ordering};
/// How message boundaries and line endings are interpreted.
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct Options {
/// Accept a first message without a Unix envelope; its entire body stays one message.
pub allow_bare: bool,
/// Preserve CRLF instead of replacing each CRLF with LF.
pub keep_cr: bool,
}
impl Options {
/// Sets [`Self::allow_bare`].
#[must_use]
pub fn with_allow_bare(mut self, value: bool) -> Self {
self.allow_bare = value;
self
}
/// Sets [`Self::keep_cr`].
#[must_use]
pub fn with_keep_cr(mut self, value: bool) -> Self {
self.keep_cr = value;
self
}
}
/// Admission bounds for one split operation, independent of allocator failure or elapsed time.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Limits {
/// Entire supplied input, including discarded initial whitespace (default 64 MiB).
pub max_input_bytes: usize,
/// Returned messages (default 1000).
pub max_messages: usize,
/// Bytes in one normalized message (default 16 MiB).
pub max_message_bytes: usize,
/// Sum of normalized message bytes (default 64 MiB).
pub max_output_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_input_bytes: 64 * 1024 * 1024,
max_messages: 1000,
max_message_bytes: 16 * 1024 * 1024,
max_output_bytes: 64 * 1024 * 1024,
}
}
}
impl Limits {
/// Sets [`Self::max_input_bytes`].
#[must_use]
pub fn with_max_input_bytes(mut self, value: usize) -> Self {
self.max_input_bytes = value;
self
}
/// Sets [`Self::max_messages`].
#[must_use]
pub fn with_max_messages(mut self, value: usize) -> Self {
self.max_messages = value;
self
}
/// Sets [`Self::max_message_bytes`].
#[must_use]
pub fn with_max_message_bytes(mut self, value: usize) -> Self {
self.max_message_bytes = value;
self
}
/// Sets [`Self::max_output_bytes`].
#[must_use]
pub fn with_max_output_bytes(mut self, value: usize) -> Self {
self.max_output_bytes = value;
self
}
}
/// A mailbox cannot be split within the requested policy and bounds.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// No non-whitespace message bytes were supplied.
#[error("empty mailbox")]
Empty,
/// The first non-whitespace line is not an envelope and bare mode is disabled.
#[error("mailbox does not start with a Unix From envelope")]
MissingEnvelope,
/// A named admission bound was exceeded.
#[error("mailbox exceeds {0} limit")]
Limit(&'static str),
/// The caller requested cancellation.
#[error("mailbox splitting cancelled")]
Cancelled,
}
/// Splits an enveloped mailbox with default bounds and CRLF normalization.
///
/// # Errors
///
/// Returns [`Error::Empty`], [`Error::MissingEnvelope`] or [`Error::Limit`] without partial output.
pub fn split(input: &[u8]) -> Result<Vec<Vec<u8>>, Error> {
split_with(
input,
Options::default(),
Limits::default(),
&AtomicBool::new(false),
)
}
/// Splits mailbox bytes using explicit policy, bounds and cancellation.
///
/// Date-shaped Unix envelopes begin with `From ` and use Git's structural clock/year heuristic;
/// weekday/month names and calendar ranges are not validated. A recognized envelope in the body
/// starts another message; escaped `>From` lines do not. Bare mode only changes input lacking an
/// initial envelope. No MIME, header or transfer decoding occurs, and missing final newlines stay
/// missing. Work and retained output are linear in admitted input bytes; cancellation is checked
/// per line.
///
/// # Errors
///
/// Returns the same errors as [`split`], plus [`Error::Cancelled`]. Every failure discards the
/// accumulated messages. This function performs no filesystem operations.
pub fn split_with(
input: &[u8],
options: Options,
limits: Limits,
cancel: &AtomicBool,
) -> Result<Vec<Vec<u8>>, Error> {
check(cancel)?;
if input.len() > limits.max_input_bytes {
return Err(Error::Limit("input bytes"));
}
let start = input
.iter()
.position(|byte| !separator(byte))
.unwrap_or(input.len());
let input = &input[start..];
if input.is_empty() {
return Err(Error::Empty);
}
let first = input
.split_inclusive(|&byte| byte == b'\n')
.next()
.unwrap_or(input);
let bare = !envelope(first);
if bare && !options.allow_bare {
return Err(Error::MissingEnvelope);
}
let mut messages = Vec::new();
let mut message = Vec::new();
let mut total = 0usize;
for line in input.split_inclusive(|&byte| byte == b'\n') {
check(cancel)?;
if !bare && !message.is_empty() && envelope(line) {
messages.push(std::mem::take(&mut message));
}
if messages.len() >= limits.max_messages {
return Err(Error::Limit("messages"));
}
let normalized = if !options.keep_cr && line.ends_with(b"\r\n") {
&line[..line.len() - 2]
} else {
line
};
let newline = normalized.len() != line.len();
let size = normalized.len() + usize::from(newline);
if size > limits.max_message_bytes.saturating_sub(message.len()) {
return Err(Error::Limit("message bytes"));
}
if size > limits.max_output_bytes.saturating_sub(total) {
return Err(Error::Limit("output bytes"));
}
message.extend_from_slice(normalized);
if newline {
message.push(b'\n');
}
total += size;
}
check(cancel)?;
messages.push(message);
Ok(messages)
}
fn check(cancel: &AtomicBool) -> Result<(), Error> {
if cancel.load(Ordering::Relaxed) {
Err(Error::Cancelled)
} else {
Ok(())
}
}
fn separator(byte: &u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\r' | b'\n')
}
fn envelope(line: &[u8]) -> bool {
if line.len() < 20 || !line.starts_with(b"From ") {
return false;
}
let Some(colon) = line.iter().rposition(|&byte| byte == b':') else {
return false;
};
// The CLI recognizes the last clock fragment, not a calendar date. Its preceding
// separator need not be whitespace (for example 12x34:56 is accepted).
if colon < 4 || colon + 2 >= line.len() {
return false;
}
if ![colon - 4, colon - 2, colon - 1, colon + 1, colon + 2]
.into_iter()
.all(|index| line[index].is_ascii_digit())
{
return false;
}
let suffix = &line[colon + 3..];
let start = suffix
.iter()
.position(|byte| !byte.is_ascii_whitespace() && *byte != 0x0b)
.unwrap_or(suffix.len());
year(&suffix[start..])
}
fn year(field: &[u8]) -> bool {
let field = field.strip_prefix(b"+").unwrap_or(field);
let mut value = 0u8;
for byte in field.iter().take_while(|byte| byte.is_ascii_digit()) {
value = value.saturating_mul(10).saturating_add(byte - b'0');
if value > 90 {
return true;
}
}
false
}