Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions src/addrparse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@ impl SingleInfo {
}
}

/// Escape a display name for use inside a quoted-string. Backslash must go
/// first, or the backslashes added for the quotes get escaped in turn.
fn escape_quoted(name: &str) -> String {
name.replace('\\', r"\\").replace('"', r#"\""#)
}

impl fmt::Display for SingleInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(name) = &self.display_name {
write!(f, r#""{}" <{}>"#, name.replace('"', r#"\""#), self.addr)
write!(f, r#""{}" <{}>"#, escape_quoted(name), self.addr)
} else {
write!(f, "{}", self.addr)
}
Expand All @@ -55,7 +61,7 @@ impl GroupInfo {

impl fmt::Display for GroupInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, r#""{}":"#, self.group_name.replace('"', r#"\""#))?;
write!(f, r#""{}":"#, escape_quoted(&self.group_name))?;
for (i, addr) in self.addrs.iter().enumerate() {
if i == 0 {
write!(f, " ")?;
Expand Down Expand Up @@ -91,6 +97,7 @@ enum AddrParseState {
Unquoted,
NameWithEncodedWord,
Comment,
CommentEscapedChar,
}

/// A simple wrapper around `Vec<MailAddr>`. This is primarily here so we can
Expand Down Expand Up @@ -620,10 +627,16 @@ fn addrparse_inner(
}
}
}
AddrParseState::CommentEscapedChar => {
// The escaped item is discarded along with the rest of the comment.
state = AddrParseState::Comment;
}
AddrParseState::Comment => {
match hti {
HeaderTokenItem::Char(c) => {
if c == ')' {
if c == '\\' {
state = AddrParseState::CommentEscapedChar;
} else if c == ')' {
state = comment_return.take().unwrap();
}
}
Expand Down Expand Up @@ -656,6 +669,7 @@ fn addrparse_inner(
| AddrParseState::AfterQuotedName
| AddrParseState::BracketedAddr
| AddrParseState::Comment
| AddrParseState::CommentEscapedChar
| AddrParseState::NameWithEncodedWord => Err(MailParseError::Generic(
"Address string unexpectedly terminated",
)),
Expand Down Expand Up @@ -918,6 +932,21 @@ mod tests {
MailAddrList(vec![MailAddr::Single(tc)])
);

let tc =
SingleInfo::new(Some(r"John \ Doe".to_string()), "john@doe.com".to_string()).unwrap();
assert_eq!(tc.to_string(), r#""John \\ Doe" <john@doe.com>"#);
assert_eq!(
addrparse(&tc.to_string()).unwrap(),
MailAddrList(vec![MailAddr::Single(tc)])
);

let tc = SingleInfo::new(Some(r#"a\b"c"#.to_string()), "john@doe.com".to_string()).unwrap();
assert_eq!(tc.to_string(), r#""a\\b\"c" <john@doe.com>"#);
assert_eq!(
addrparse(&tc.to_string()).unwrap(),
MailAddrList(vec![MailAddr::Single(tc)])
);

let tc = SingleInfo::new(None, "foo@bar.com".to_string()).unwrap();
assert_eq!(tc.to_string(), r#"foo@bar.com"#);
assert_eq!(
Expand Down Expand Up @@ -954,6 +983,38 @@ mod tests {
addrparse(&tc.to_string()).unwrap(),
MailAddrList(vec![MailAddr::Group(tc)])
);

let tc = GroupInfo::new(r"group-with\backslash".to_string(), vec![]);
assert_eq!(tc.to_string(), r#""group-with\\backslash":;"#);
assert_eq!(
addrparse(&tc.to_string()).unwrap(),
MailAddrList(vec![MailAddr::Group(tc)])
);
}

#[test]
fn parse_escaped_comment() {
// A quoted-pair inside a comment does not end it (RFC 5322 3.2.2).
for header in [
r"x@y.com (a\)b)",
r"x@y.com (a\(b)",
r"x@y.com (a\\b)",
r"x@y.com (plain)",
r"x@y.com (a\)b) (c\)d)",
] {
assert_eq!(
addrparse(header).unwrap(),
MailAddrList(vec![MailAddr::Single(
SingleInfo::new(None, "x@y.com".to_string()).unwrap()
)]),
"header: {}",
header
);
}

// An unterminated comment is still an error, not a silent truncation.
assert!(addrparse(r"x@y.com (a\)b").is_err());
assert!(addrparse(r"x@y.com (ab\)").is_err());
}

#[test]
Expand Down
72 changes: 67 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,27 @@ fn split_semicolons_unquoted(content: &str) -> Vec<&str> {
tokens
}

/// Strip the quotes from a quoted parameter value and undo the `\"` and `\\`
/// quoted-pairs inside it (RFC 2045 Section 5.1). Any other `\x` is kept
/// verbatim, since unescaped Windows paths are common in real `filename`
/// parameters; Python's `email` and Go's `mime` draw the line in the same place.
fn unquote_param_value(value: &str) -> String {
if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') {
return value.to_string();
}
let inner = &value[1..value.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && matches!(chars.peek(), Some('"') | Some('\\')) {
out.push(chars.next().unwrap());
} else {
out.push(c);
}
}
Comment on lines +1088 to +1097

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If value ends with \" then this is going to leave the trailing backslash (because chars.peek() will be None), is that desirable? e.g.

assert_eq!(unquote_param_value(r#""foo\""#), r#"foo\"#.to_string());

should this produce a parsing error instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — that trailing backslash was wrong either way. Rather than erroring (this fn isn't fallible and I didn't want to change the public signature), I made it detect that case: if the closing " is preceded by an odd run of backslashes, it's the escaped half of a \" pair, not a real delimiter, so the value is an unterminated quoted-string. Now it's left completely untouched instead of having its last backslash silently eaten:

assert_eq!(unquote_param_value(r#""foo\""#), r#""foo\""#.to_string());

Added a test for it. Existing well-formed cases (e.g. "a\\" -> a\) are unaffected since the parity check only fires on the pathological unterminated case.

out
}

/// Split a continuation segment key such as `name*3` into its base name and
/// index. Returns `None` for keys without a trailing `*<number>`.
fn split_continuation_index(key: &str) -> Option<(&str, usize)> {
Expand All @@ -1086,11 +1107,7 @@ fn parse_param_content(content: &str) -> ParamContent {
.filter_map(|kv| {
kv.find('=').map(|idx| {
let key = kv[0..idx].trim().to_lowercase();
let mut value = kv[idx + 1..].trim();
if value.starts_with('"') && value.ends_with('"') && value.len() > 1 {
value = &value[1..value.len() - 1];
}
(key, value.to_string())
(key, unquote_param_value(kv[idx + 1..].trim()))
})
})
.collect();
Expand Down Expand Up @@ -1817,6 +1834,51 @@ mod tests {
assert_eq!(parsed.params["name"], "\"");
}

#[test]
fn test_parameter_quoted_pairs() {
// (parameter list, expected value of the `filename` parameter)
let cases = [
// `\"` and `\\` are quoted-pairs and lose their backslash.
(r#"attachment; filename="a\"b.txt""#, r#"a"b.txt"#),
(r#"attachment; filename="a\\b.txt""#, r"a\b.txt"),
(r#"attachment; filename="a\\b\"c""#, r#"a\b"c"#),
(r#"attachment; filename="a\\""#, r"a\"),
(r#"attachment; filename="\\\\""#, r"\\"),
// A backslash before anything else is data, not an escape, so
// Windows paths written by older mailers survive intact.
(
r#"attachment; filename="C:\dev\go\foo.txt""#,
r"C:\dev\go\foo.txt",
),
(r#"attachment; filename="a\;b.txt""#, r"a\;b.txt"),
// Outside a quoted-string a backslash is never an escape.
(r"attachment; filename=a\b.txt", r"a\b.txt"),
(r#"attachment; filename=a\"b.txt"#, r#"a\"b.txt"#),
// Unrelated values are untouched.
(r#"attachment; filename="plain.txt""#, "plain.txt"),
("attachment; filename=plain.txt", "plain.txt"),
];
for (input, expected) in cases {
assert_eq!(
parse_param_content(input).params["filename"],
expected,
"input: {}",
input
);
}

// Same values reached through the two public accessors.
let cd = parse_content_disposition(r#"attachment; filename="a\"b.txt""#);
assert_eq!(cd.params["filename"], r#"a"b.txt"#);
let ct = parse_content_type(r#"text/plain; name="a\\b"; boundary="x\"y""#);
assert_eq!(ct.params["name"], r"a\b");
assert_eq!(ct.params["boundary"], r#"x"y"#);

// A quoted-pair also survives the RFC 2231 continuation join.
let parsed = parse_param_content(r#"attachment; filename*0="a\\"; filename*1="b.txt""#);
assert_eq!(parsed.params["filename"], r"a\b.txt");
}

#[test]
fn test_parameter_value_continuations() {
let parsed =
Expand Down
Loading