Repository navigation
RFC 5322 folding - #365
RFC 5322 folding#365jengelh wants to merge 15 commits into
Conversation
| if (utility::stringUtils::is7bit(buffer) && | ||
| buffer.find_first_of("\r\n") == string::npos) { |
There was a problem hiding this comment.
is7bit() is also false for =? and for a trailing = (see countASCIIchars()), so a plain ASCII local part like bounce-dGVzdA== gets encoded, down to the SMTP envelope. Checking for printable ASCII fixes that. It also keeps ISO-2022-JP (ESC sequences) and CR/LF on the encoded path; otherwise ISO-2022-JP would be written as raw ESC bytes:
| if (utility::stringUtils::is7bit(buffer) && | |
| buffer.find_first_of("\r\n") == string::npos) { | |
| // Not is7bit(), which is also false for "=?" and a trailing '='. | |
| // ESC (ISO-2022-JP), CR and LF are not printable. | |
| bool printable = true; | |
| for (size_t i = 0 ; printable && i < buffer.length() ; ++i) { | |
| printable = parserHelpers::isPrint(buffer[i]); | |
| } | |
| if (printable) { |
| VMIME_TEST(testParseCommentInDomainPart) | ||
| VMIME_TEST(testParseRFC2047EncodedLocalPart) | ||
| VMIME_TEST(testGenerateSpecialChars) | ||
| VMIME_TEST(testGenerateLongLocalPart) |
There was a problem hiding this comment.
Test for the emailAddress.cpp fix, see below.
| VMIME_TEST(testGenerateLongLocalPart) | |
| VMIME_TEST(testGenerateLongLocalPart) | |
| VMIME_TEST(testGenerateASCIILocalPart) |
|
|
||
| back.parse(out); | ||
| VASSERT_EQ("5", local2, back.getLocalName().getConvertedText(vmime::charsets::UTF_8)); | ||
| } |
There was a problem hiding this comment.
It fails without the is7bit() fix (=?utf-8?Q?bounce-dGVzdA=3D=3D?=@example.com).
| } | |
| } | |
| void testGenerateASCIILocalPart() { | |
| // Plain ASCII is not encoded, even with '=' (eg. VERP) or "=?" | |
| const char* locals[] = { "bounce-dGVzdA==", "abc=", "a=?b" }; | |
| for (const char* local : locals) { | |
| vmime::emailAddress e(local, "example.com"); | |
| VASSERT_EQ(local, std::string(local) + "@example.com", e.generate()); | |
| } | |
| vmime::emailAddress back; | |
| back.parse(vmime::emailAddress("bounce-dGVzdA==", "example.com").generate()); | |
| VASSERT_EQ("parse", "bounce-dGVzdA==", back.getLocalName().getBuffer()); | |
| // 7-bit, but with escape sequences: still encoded | |
| const vmime::word jp("\x1b$B$3$s$K$A$O\x1b(B", vmime::charset("iso-2022-jp")); | |
| const std::string out = | |
| vmime::emailAddress(jp, vmime::word("example.com", vmime::charsets::US_ASCII)).generate(); | |
| VASSERT("iso-2022-jp: " + out, out.compare(0, 16, "=?iso-2022-jp?B?") == 0); | |
| VASSERT_EQ("no ESC", std::string::npos, out.find('\x1b')); | |
| } |
| void setUp() { | ||
|
|
||
| // Set the global C and C++ locale to the user-configured locale. | ||
| // The locale should use UTF-8 encoding for these tests to run successfully. | ||
| try { | ||
| std::locale::global(std::locale("")); | ||
| } catch (std::exception &) { | ||
| std::setlocale(LC_ALL, ""); | ||
| } | ||
| } | ||
|
|
||
| void tearDown() { | ||
|
|
||
| // Restore default locale | ||
| std::locale::global(std::locale("C")); | ||
| } | ||
|
|
There was a problem hiding this comment.
This replaces the UTF-8 locale picked by setUTF8Locale() (tests/testUtils.cpp) with the environment one: "C" on the Cygwin runner, where LANG is unset. tearDown() resets it to "C" anyway. That's the Cygwin failure, and LC_ALL=C ./parser_textTest reproduces it on Linux (testWordGenerateQuote). No test needs it:
| void setUp() { | |
| // Set the global C and C++ locale to the user-configured locale. | |
| // The locale should use UTF-8 encoding for these tests to run successfully. | |
| try { | |
| std::locale::global(std::locale("")); | |
| } catch (std::exception &) { | |
| std::setlocale(LC_ALL, ""); | |
| } | |
| } | |
| void tearDown() { | |
| // Restore default locale | |
| std::locale::global(std::locale("C")); | |
| } |
| VASSERT_EQ( | ||
| "2", | ||
| "=?utf-8?Q?aaa=C3=A9?==?utf-8?Q?zzz?=", | ||
| "=?utf-8?Q?aaa?==?utf-8?Q?=C3=A9?==?utf-8?Q?zzz?=", | ||
| cleanGeneratedWords( | ||
| vmime::word("aaa\xc3\xa9zzz", vmime::charset("utf-8")).generate(17) | ||
| ) | ||
| ); |
There was a problem hiding this comment.
Case 2 now gives the same output as case 1, so it doesn't test much anymore. At 19, aaa=C3 would fit but not the whole character, and 22 brings back the old expectation:
| VASSERT_EQ( | |
| "2", | |
| "=?utf-8?Q?aaa=C3=A9?==?utf-8?Q?zzz?=", | |
| "=?utf-8?Q?aaa?==?utf-8?Q?=C3=A9?==?utf-8?Q?zzz?=", | |
| cleanGeneratedWords( | |
| vmime::word("aaa\xc3\xa9zzz", vmime::charset("utf-8")).generate(17) | |
| ) | |
| ); | |
| // "aaa=C3" fits, but not the whole character | |
| VASSERT_EQ( | |
| "2", | |
| "=?utf-8?Q?aaa?==?utf-8?Q?=C3=A9?==?utf-8?Q?zzz?=", | |
| cleanGeneratedWords( | |
| vmime::word("aaa\xc3\xa9zzz", vmime::charset("utf-8")).generate(19) | |
| ) | |
| ); | |
| VASSERT_EQ( | |
| "3", | |
| "=?utf-8?Q?aaa=C3=A9?==?utf-8?Q?zzz?=", | |
| cleanGeneratedWords( | |
| vmime::word("aaa\xc3\xa9zzz", vmime::charset("utf-8")).generate(22) | |
| ) | |
| ); |
| namespace { | ||
|
|
||
| enum QuoteResult { | ||
| QUOTE_NOT_DONE, // nothing written, fold the text unquoted | ||
| QUOTE_DONE, | ||
| QUOTE_NEEDS_ENCODING // nothing written, a run does not fit on a line | ||
| }; | ||
|
|
||
| } |
There was a problem hiding this comment.
Nit: FoldPoint below is declared without an anonymous namespace, like most file-local types in the codebase (e.g. CharsetEncodingEntry in charset.cpp), so maybe the same here:
| namespace { | |
| enum QuoteResult { | |
| QUOTE_NOT_DONE, // nothing written, fold the text unquoted | |
| QUOTE_DONE, | |
| QUOTE_NEEDS_ENCODING // nothing written, a run does not fit on a line | |
| }; | |
| } | |
| enum QuoteResult { | |
| QUOTE_NOT_DONE, // nothing written, fold the text unquoted | |
| QUOTE_DONE, | |
| QUOTE_NEEDS_ENCODING // nothing written, a run does not fit on a line | |
| }; |
|
Oops, summary of my review got lost, here it is: Thanks a lot for this, it fixes a bunch of long-standing folding issues! I built it and ran the tests (each commit builds and passes the parser tests on its own, excepot for Cygwin, see below). I also threw a quick differential fuzzer at it: random text / mailbox / mailbox list, master vs. this branch, 20k iterations, same inputs on both sides. Results look great: no round-trip regressions, and over-long encoded-words, broken display-name round-trips and broken list round-trips all drop to zero. I think 3 things need fixing before merging. I left a suggestion for each one inline, and tested them on top of your branch: all tests pass, also with
The Some questions
Smaller stuff
Thanks again! |
…ctly (kisli#365) The length was checked before adding a character and base64 output was estimated as len*4/3, so chunks could be several chars too long.
RFC 2047 limits an encoded-word to 75 chars. Only the 76 char line limit was applied, so a word starting at column 0 could be 76 characters long.
… limit (kisli#365) The limit of RFC 2047 §2 does not depend on the line length. Without one, the words are separated by a space instead of a fold.
…ut (kisli#365) prevWordIsEncoded remained set after a plain word, so the space before the next encoded-word was also encoded and decoded as two spaces.
A fold before an encoded-word stands for its leading space just like a separating space. An empty word writes nothing and keeps the state.
…ded-word (kisli#365) A separator was written despite leading whitespace, and it was not counted in the line length.
…li#365) A name containing '"' was written unquoted, and a '\' in a quoted name was read back as an escape. Both have to be quoted pairs.
…isli#365) A name which did not fit on the line lost its quotes, and a comma in it then split the mailbox in two. A quoted-string may be folded before whitespace, and unfolding restores it.
5f29d41 to
2af7db2
Compare
|
Thanks for the thorough review and the fuzzing as well as the kind words. I've updated the branch. each fix is squashed into the commit it belongs to, plus one new commit at the end. every commit builds and passes the parser tests, also with LC_ALL=C. The three blockers:
Regarding your questions:
Smaller stuff:
|
|
Thanks, that was quick! I went through the new version:
In the meantime, I discovered a specific case that still doesn't round-trip: a fold right before a tab. Your output is correct (CRLF + TAB), but VMime's own parser reads a fold plus the white-space after it as a single space ("abc\r\n\tdef" gives "abc def"). That's an old parser bug, not yours, I'll fix it separately. Two small thing in the tests, if you feel like it:
Don't worry about the new Thanks again for all the work on this! |
…pace run (kisli#365) Some parsers take backslash-newline as a continuation or collapse the white-space after a fold. Prefer a single space, else the last one of a run, else a tab, and fold after a backslash only if there is no other fold point.
A fold before "<addr>" was followed by another space, and a bare address was folded even if it did not fit on the new line either. The column after that fold was counted as 3 instead of 1.
…lists (kisli#365) ", " was written before the next address folded, leaving white-space at the end of the line and the address split across two lines. The same for the mailboxes of a group.
…isli#365) A local part of 75 chars or more was encoded and folded like text, which RFC 2047 §5 forbids and which split the address. Anything else than printable ASCII, e.g. ISO-2022-JP escape sequences, is written as a single encoded-word.
…sli#365) The space was written before the next id folded, leaving whitespace at the end of a line.
…kisli#365) A '(' in a quoted display name opened a comment which never ended, and the following commas no longer split the list. The same for a '"' in a comment.
…-word (kisli#365) Only a leading space was replaced by the separator after an unencoded word, so a tab was encoded and a space written before it.
2af7db2 to
2f2f654
Compare
|
sounds good - easy to do -> I've taken both test suggestions and squashed them in.
generatorState ABI hint: thanks. On our side, nothing uses it directly, so no biggie.
Thank you for taking care of the CRLF + TAB unfolding in the parser separately In other note (probably not the right place, forgive me): Big thanks to all of this here from us (grommunio) -> vmime is undoubtedly the best c++ mime parser there is. however, we think that it could be maintained maybe a bit more closely/frequently (also taking care of issues, etc.) . I have consulted our CTO and he is willing to offer substantial resources to this project if you are interested. not only code (like here), but also helping with issues and even more if it helps. We love vmime and we like to help. |
|
Thanks a lot for the kind words, and for the offer! It's great to hear that VMime is useful to you. :-) To be honest, I have a day job (and a life outside of it!) that keeps me pretty busy. I've managed to free up some time these last few weeks to come back to VMime, a project I really care about, but I can't promise I'll always be this available. I'll do what I can. In any case, contributions like this one are very welcome, and they make my life a lot easier. |
|
One last request before I merge: I'd like to use "Rebase and merge", so that your commits stay separate in the history rather than being squashed into one. Since GitHub doesn't let me edit the messages that way, could you reword the commit subjects to match the style of the history? Replace the "vmime:" prefix with "RFC 5322 Folding:" (or "Parser:" for the list-splitting one), and add "(#365)" at the end, eg. "RFC 5322 Folding: keep encoded-words within 75 chars (#365)". No need to touch the code. Thanks! |
2f2f654 to
fd99ad7
Compare
|
done. hope this works for you. |
With a finite line length, vmime produced headers that other parsers read back wrong. With an infinite one, encoded-words had no size limit.