MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 93.2% 436 / 0 / 468
Functions: 100.0% 24 / 0 / 24
Branches: 82.6% 593 / 0 / 718

service/MimeParser.cpp
Line Branch Exec Source
1 #include "util/CanonicalContentId.h"
2 #include "MimeParser.h"
3
4 #include <QHash>
5 #include <QLoggingCategory>
6 #include <QStringDecoder>
7 #include <QUrl>
8
9 #include "service/ImapResponseParser.h"
10 #include "util/AttachmentFileSecurity.h"
11
12
4/7
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 8 taken 10 times.
✓ Branch 4 → 5 taken 4 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 4 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
14 Q_LOGGING_CATEGORY(lcMime, "mailjd.mime")
13
14 namespace {
15 // S106-033: the media type is the token before the first ';' — "multipart/"
16 // inside a parameter value (name="multipart/x") made a text part a
17 // multipart container.
18 7077 bool isMultipartType(const QString &contentTypeLower) {
19
3/3
✓ Branch 5 → 6 taken 7077 times.
✓ Branch 6 → 7 taken 7077 times.
✓ Branch 10 → 11 taken 7077 times.
14154 return contentTypeLower.section(QLatin1Char(';'), 0, 0).trimmed().startsWith(QStringLiteral("multipart/"));
20 }
21
22 } // namespace
23
24 // --- Public API ---
25
26 447 MimeMessage MimeParser::parse(const QByteArray &rawMessage) {
27 447 MimeMessage result;
28
29
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 447 times.
447 if (rawMessage.isEmpty()) {
30 ✗ return result;
31 }
32
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 19 taken 445 times.
447 if (rawMessage.size() > kMaxRawMessageBytes) {
33 2 result.limitExceeded = true;
34
5/5
✓ Branch 7 → 8 taken 2 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 12 → 13 taken 2 times.
✓ Branch 17 → 9 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
4 qCWarning(lcMime) << "MIME raw-message budget exceeded"
35
1/1
✓ Branch 13 → 14 taken 2 times.
2 << kMaxRawMessageBytes;
36 2 return result;
37 }
38
39 // Split into headers and body
40
1/1
✓ Branch 19 → 20 taken 445 times.
445 auto [headerBlock, bodyBlock] = splitHeaderBody(rawMessage);
41
1/1
✓ Branch 22 → 23 taken 445 times.
445 auto headers = parseHeaders(headerBlock);
42
43 890 QString contentType = headers.value(QStringLiteral("content-type"),
44
1/1
✓ Branch 29 → 30 taken 445 times.
1335 QStringLiteral("text/plain"));
45 QString transferEncoding = headers.value(
46
1/1
✓ Branch 40 → 41 taken 445 times.
890 QStringLiteral("content-transfer-encoding"), QStringLiteral("7bit"));
47
48 // Check if this is a multipart message
49
1/1
✓ Branch 45 → 46 taken 445 times.
445 QString ctLower = contentType.toLower();
50
3/3
✓ Branch 46 → 47 taken 445 times.
✓ Branch 47 → 48 taken 142 times.
✓ Branch 47 → 119 taken 303 times.
445 if (isMultipartType(ctLower)) {
51
1/1
✓ Branch 48 → 49 taken 142 times.
142 QByteArray boundary = extractBoundary(contentType);
52
2/2
✓ Branch 50 → 51 taken 1 time.
✓ Branch 50 → 65 taken 141 times.
142 if (boundary.isEmpty()) {
53 // Malformed: no boundary → treat entire body as plain text
54
4/4
✓ Branch 51 → 52 taken 1 time.
✓ Branch 55 → 56 taken 1 time.
✓ Branch 60 → 53 taken 1 time.
✓ Branch 60 → 61 taken 1 time.
2 qCWarning(lcMime) << "Multipart message without boundary, falling back "
55
1/1
✓ Branch 56 → 57 taken 1 time.
1 "to plain text";
56
1/1
✓ Branch 61 → 62 taken 1 time.
1 result.textPlain = QString::fromUtf8(bodyBlock);
57 1 return result;
58 }
59
60 141 ParseBudget budget;
61 // F-07: the top level applies the same rule as nested parts — an
62 // alternative container keeps first-wins, everything else appends.
63 const bool alternative =
64
1/1
✓ Branch 68 → 69 taken 141 times.
141 ctLower.startsWith(QStringLiteral("multipart/alternative"));
65 auto parts =
66 splitMultipartBody(bodyBlock, boundary, remainingParts(budget),
67
3/3
✓ Branch 71 → 72 taken 141 times.
✓ Branch 72 → 73 taken 141 times.
✓ Branch 73 → 74 taken 141 times.
141 remainingMaterializedBytes(budget), &budget.limitExceeded);
68 // S106-036: a boundary that never appears yields no part — the body was
69 // silently not shown. Like a missing boundary, it is shown as text.
70
5/6
✓ Branch 75 → 76 taken 1 time.
✓ Branch 75 → 78 taken 140 times.
✓ Branch 76 → 77 taken 1 time.
✗ Branch 76 → 78 not taken.
✓ Branch 79 → 80 taken 1 time.
✓ Branch 79 → 94 taken 140 times.
141 if (parts.isEmpty() && !budget.limitExceeded) {
71
5/5
✓ Branch 80 → 81 taken 1 time.
✓ Branch 84 → 85 taken 1 time.
✓ Branch 85 → 86 taken 1 time.
✓ Branch 89 → 82 taken 1 time.
✓ Branch 89 → 90 taken 1 time.
2 qCWarning(lcMime) << "Multipart boundary never found, falling back to plain text";
72
1/1
✓ Branch 90 → 91 taken 1 time.
1 result.textPlain = QString::fromUtf8(bodyBlock);
73 1 return result;
74 }
75
4/4
✓ Branch 94 → 95 taken 140 times.
✓ Branch 95 → 96 taken 140 times.
✓ Branch 104 → 97 taken 5302 times.
✓ Branch 104 → 105 taken 140 times.
5442 for (const auto &part : parts) {
76
2/3
✓ Branch 98 → 99 taken 5302 times.
✗ Branch 99 → 100 not taken.
✓ Branch 99 → 101 taken 5302 times.
5302 if (!consumePartBudget(part, budget)) {
77 ✗ break;
78 }
79
1/1
✓ Branch 101 → 102 taken 5302 times.
5302 parsePart(part, result, budget, 0, alternative);
80 }
81
4/4
✓ Branch 105 → 106 taken 136 times.
✓ Branch 105 → 107 taken 4 times.
✓ Branch 106 → 107 taken 4 times.
✓ Branch 106 → 108 taken 132 times.
140 result.limitExceeded = result.limitExceeded || budget.limitExceeded;
82
4/4
✓ Branch 111 → 112 taken 140 times.
✓ Branch 111 → 113 taken 1 time.
✓ Branch 115 → 116 taken 140 times.
✓ Branch 115 → 118 taken 2 times.
143 } else {
83 // Single-part message
84
1/1
✓ Branch 119 → 120 taken 303 times.
303 QByteArray decoded = decodeTransferEncoding(bodyBlock, transferEncoding);
85
1/1
✓ Branch 120 → 121 taken 303 times.
303 QString charset = extractCharset(contentType);
86
87
3/3
✓ Branch 124 → 125 taken 303 times.
✓ Branch 127 → 128 taken 4 times.
✓ Branch 127 → 132 taken 299 times.
303 if (ctLower.startsWith(QStringLiteral("text/html"))) {
88
1/1
✓ Branch 128 → 129 taken 4 times.
4 result.textHtml = convertCharset(decoded, charset);
89
6/11
✓ Branch 135 → 136 taken 299 times.
✓ Branch 136 → 137 taken 291 times.
✓ Branch 136 → 143 taken 8 times.
✓ Branch 150 → 151 taken 299 times.
✗ Branch 150 → 152 not taken.
✓ Branch 152 → 153 taken 291 times.
✓ Branch 152 → 157 taken 8 times.
✗ Branch 307 → 308 not taken.
✗ Branch 307 → 309 not taken.
✗ Branch 311 → 312 not taken.
✗ Branch 311 → 313 not taken.
1188 } else if (ctLower.startsWith(QStringLiteral("text/")) &&
90
7/13
✓ Branch 140 → 141 taken 291 times.
✓ Branch 141 → 142 taken 291 times.
✗ Branch 141 → 143 not taken.
✓ Branch 144 → 145 taken 291 times.
✓ Branch 144 → 146 taken 8 times.
✓ Branch 146 → 147 taken 291 times.
✓ Branch 146 → 148 taken 8 times.
✓ Branch 148 → 149 taken 299 times.
✗ Branch 148 → 150 not taken.
✗ Branch 299 → 300 not taken.
✗ Branch 299 → 301 not taken.
✗ Branch 303 → 304 not taken.
✗ Branch 303 → 305 not taken.
590 !ctLower.startsWith(QStringLiteral("text/calendar"))) {
91
1/1
✓ Branch 153 → 154 taken 291 times.
291 result.textPlain = convertCharset(decoded, charset);
92 } else {
93 // Non-text single part (rare but possible)
94 8 MimePart part;
95
3/3
✓ Branch 159 → 160 taken 8 times.
✓ Branch 160 → 161 taken 8 times.
✓ Branch 161 → 162 taken 8 times.
8 part.contentType = contentType.section(';', 0, 0).trimmed().toLower();
96 8 part.body = decoded;
97 8 part.isAttachment = true;
98 const QString disposition =
99
1/1
✓ Branch 171 → 172 taken 8 times.
16 headers.value(QStringLiteral("content-disposition"));
100
1/1
✓ Branch 175 → 176 taken 8 times.
8 part.filename = decodeAndSanitizeFilename(disposition, contentType);
101
4/6
✓ Branch 179 → 180 taken 3 times.
✓ Branch 179 → 184 taken 5 times.
✗ Branch 182 → 183 not taken.
✓ Branch 182 → 184 taken 3 times.
✗ Branch 185 → 186 not taken.
✓ Branch 185 → 193 taken 8 times.
8 if (part.filename.isEmpty() && part.contentType == QLatin1String("text/calendar"))
102 ✗ part.filename = QStringLiteral("invite.ics");
103 part.contentId =
104
2/2
✓ Branch 197 → 198 taken 8 times.
✓ Branch 198 → 199 taken 8 times.
16 normalizeContentId(headers.value(QStringLiteral("content-id")));
105
2/2
✓ Branch 206 → 207 taken 3 times.
✓ Branch 206 → 223 taken 5 times.
8 if (!part.contentId.isEmpty()) {
106 3 part.presentation =
107
2/2
✓ Branch 207 → 208 taken 3 times.
✓ Branch 211 → 212 taken 3 times.
9 disposition.trimmed().startsWith(QStringLiteral("attachment"),
108 Qt::CaseInsensitive)
109
2/2
✓ Branch 212 → 213 taken 1 time.
✓ Branch 212 → 214 taken 2 times.
5 ? AttachmentPresentation::ExplicitAttachmentWithContentId
110
1/1
✓ Branch 214 → 215 taken 2 times.
2 : (isAllowedInlineImage(part.contentType, part.body)
111
2/2
✓ Branch 215 → 216 taken 1 time.
✓ Branch 215 → 217 taken 1 time.
2 ? AttachmentPresentation::InlineCandidate
112 : AttachmentPresentation::RejectedInline);
113 }
114
1/1
✓ Branch 223 → 224 taken 8 times.
8 result.attachments.append(part);
115 8 }
116 303 }
117
118
1/1
✓ Branch 230 → 231 taken 443 times.
443 finalizeInlineParts(result);
119 443 return result;
120 445 }
121
122 // --- Encoding utilities ---
123
124 397 QByteArray MimeParser::decodeQuotedPrintable(const QByteArray &input) {
125 397 QByteArray output;
126
1/1
✓ Branch 3 → 4 taken 397 times.
397 output.reserve(input.size());
127
128
2/2
✓ Branch 44 → 5 taken 6713693 times.
✓ Branch 44 → 45 taken 397 times.
6714090 for (int i = 0; i < input.size(); ++i) {
129 6713693 char c = input.at(i);
130
2/2
✓ Branch 6 → 7 taken 217710 times.
✓ Branch 6 → 41 taken 6495983 times.
6713693 if (c == '=') {
131 // Check for soft line break: =\r\n or =\n
132
3/6
✓ Branch 8 → 9 taken 217710 times.
✗ Branch 8 → 12 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 217710 times.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 217710 times.
217710 if (i + 1 < input.size() && input.at(i + 1) == '\n') {
133 ✗ i += 1; // skip \n
134 ✗ continue;
135 }
136
6/8
✓ Branch 16 → 17 taken 217710 times.
✗ Branch 16 → 22 not taken.
✓ Branch 18 → 19 taken 16747 times.
✓ Branch 18 → 22 taken 200963 times.
✓ Branch 20 → 21 taken 16747 times.
✗ Branch 20 → 22 not taken.
✓ Branch 23 → 24 taken 16747 times.
✓ Branch 23 → 25 taken 200963 times.
234457 if (i + 2 < input.size() && input.at(i + 1) == '\r' &&
137 16747 input.at(i + 2) == '\n') {
138 16747 i += 2; // skip \r\n
139 16747 continue;
140 }
141 // Hex-encoded byte: =XX
142
1/2
✓ Branch 26 → 27 taken 200963 times.
✗ Branch 26 → 40 not taken.
200963 if (i + 2 < input.size()) {
143 200963 char hi = input.at(i + 1);
144 200963 char lo = input.at(i + 2);
145 200963 bool okHi = false, okLo = false;
146
2/2
✓ Branch 29 → 30 taken 200963 times.
✓ Branch 30 → 31 taken 200963 times.
200963 int hiVal = QByteArray(1, hi).toInt(&okHi, 16);
147
2/2
✓ Branch 32 → 33 taken 200963 times.
✓ Branch 33 → 34 taken 200963 times.
200963 int loVal = QByteArray(1, lo).toInt(&okLo, 16);
148
3/4
✓ Branch 35 → 36 taken 200962 times.
✓ Branch 35 → 39 taken 1 time.
✓ Branch 36 → 37 taken 200962 times.
✗ Branch 36 → 39 not taken.
200963 if (okHi && okLo) {
149
1/1
✓ Branch 37 → 38 taken 200962 times.
200962 output.append(static_cast<char>((hiVal << 4) | loVal));
150 200962 i += 2;
151 200962 continue;
152 }
153 }
154 // Malformed =, pass through
155
1/1
✓ Branch 40 → 42 taken 1 time.
1 output.append(c);
156 } else {
157
1/1
✓ Branch 41 → 42 taken 6495983 times.
6495983 output.append(c);
158 }
159 }
160
161 397 return output;
162 ✗ }
163
164 6613 QByteArray MimeParser::decodeTransferEncoding(const QByteArray &data,
165 const QString &encoding) {
166
2/2
✓ Branch 2 → 3 taken 6613 times.
✓ Branch 3 → 4 taken 6613 times.
6613 QString enc = encoding.trimmed().toLower();
167
2/2
✓ Branch 11 → 12 taken 395 times.
✓ Branch 11 → 14 taken 6218 times.
6613 if (enc == QStringLiteral("quoted-printable")) {
168
1/1
✓ Branch 12 → 13 taken 395 times.
395 return decodeQuotedPrintable(data);
169 }
170
2/2
✓ Branch 20 → 21 taken 145 times.
✓ Branch 20 → 24 taken 6073 times.
6218 if (enc == QStringLiteral("base64")) {
171
1/1
✓ Branch 22 → 23 taken 145 times.
145 return QByteArray::fromBase64(data);
172 }
173 // 7bit, 8bit, binary → no transformation
174 6073 return data;
175 6613 }
176
177 470 QString MimeParser::convertCharset(const QByteArray &data,
178 const QString &charset) {
179
2/2
✓ Branch 3 → 4 taken 21 times.
✓ Branch 3 → 5 taken 449 times.
470 if (data.isEmpty()) {
180 21 return {};
181 }
182
183
2/2
✓ Branch 5 → 6 taken 449 times.
✓ Branch 6 → 7 taken 449 times.
449 QString cs = charset.trimmed().toLower();
184
7/8
✓ Branch 14 → 15 taken 13 times.
✓ Branch 14 → 25 taken 436 times.
✓ Branch 19 → 20 taken 8 times.
✓ Branch 19 → 25 taken 5 times.
✓ Branch 37 → 38 taken 449 times.
✗ Branch 37 → 39 not taken.
✓ Branch 39 → 40 taken 441 times.
✓ Branch 39 → 42 taken 8 times.
911 if (cs.isEmpty() || cs == QStringLiteral("utf-8") ||
185
11/14
✓ Branch 9 → 10 taken 449 times.
✗ Branch 9 → 25 not taken.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 8 times.
✓ Branch 27 → 28 taken 8 times.
✓ Branch 27 → 29 taken 441 times.
✓ Branch 29 → 30 taken 8 times.
✓ Branch 29 → 31 taken 441 times.
✓ Branch 31 → 32 taken 13 times.
✓ Branch 31 → 33 taken 436 times.
✓ Branch 33 → 34 taken 13 times.
✓ Branch 33 → 35 taken 436 times.
✓ Branch 35 → 36 taken 449 times.
✗ Branch 35 → 37 not taken.
911 cs == QStringLiteral("us-ascii") || cs == QStringLiteral("ascii")) {
186
1/1
✓ Branch 40 → 41 taken 441 times.
441 return QString::fromUtf8(data);
187 }
188
189 // Try QStringDecoder for the given charset
190
2/2
✓ Branch 43 → 44 taken 8 times.
✓ Branch 46 → 47 taken 8 times.
8 auto decoder = QStringDecoder(cs.toLatin1().constData());
191
2/2
✓ Branch 49 → 50 taken 7 times.
✓ Branch 49 → 54 taken 1 time.
8 if (decoder.isValid()) {
192
1/1
✓ Branch 51 → 52 taken 7 times.
7 return decoder.decode(data);
193 }
194
195 // Fallback: try Latin-1
196
6/6
✓ Branch 54 → 55 taken 1 time.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 59 → 60 taken 1 time.
✓ Branch 60 → 61 taken 1 time.
✓ Branch 65 → 56 taken 1 time.
✓ Branch 65 → 66 taken 1 time.
2 qCWarning(lcMime) << "Unknown charset" << charset
197
1/1
✓ Branch 61 → 62 taken 1 time.
1 << ", falling back to Latin-1";
198
1/1
✓ Branch 66 → 67 taken 1 time.
1 return QString::fromLatin1(data);
199 449 }
200
201 // --- Private helpers ---
202
203 QPair<QByteArray, QByteArray>
204 7077 MimeParser::splitHeaderBody(const QByteArray &raw) {
205 // Headers and body are separated by the FIRST blank line, whichever line
206 // ending it uses. S106-034: "\r\n\r\n" used to win over an earlier
207 // "\n\n", so body lines up to a later CRLF blank line were read as
208 // headers (body text could set Content-Type). messageHeaders() already
209 // took the earliest.
210
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 11 taken 7077 times.
7077 if (raw.startsWith("\r\n"))
211 ✗ return {{}, raw.mid(2)};
212
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 19 taken 7077 times.
7077 if (raw.startsWith('\n'))
213 ✗ return {{}, raw.mid(1)};
214
1/2
✓ Branch 54 → 20 taken 34481 times.
✗ Branch 54 → 55 not taken.
34481 for (qsizetype i = raw.indexOf('\n'); i >= 0; i = raw.indexOf('\n', i + 1)) {
215
3/4
✓ Branch 20 → 21 taken 34481 times.
✗ Branch 20 → 24 not taken.
✓ Branch 22 → 23 taken 34478 times.
✓ Branch 22 → 24 taken 3 times.
34481 const qsizetype headerEnd = i > 0 && raw.at(i - 1) == '\r' ? i - 1 : i;
216
5/6
✓ Branch 26 → 27 taken 34481 times.
✗ Branch 26 → 30 not taken.
✓ Branch 28 → 29 taken 2 times.
✓ Branch 28 → 30 taken 34479 times.
✓ Branch 31 → 32 taken 2 times.
✓ Branch 31 → 38 taken 34479 times.
34481 if (i + 1 < raw.size() && raw.at(i + 1) == '\n')
217
2/2
✓ Branch 32 → 33 taken 2 times.
✓ Branch 33 → 34 taken 2 times.
2 return {raw.left(headerEnd), raw.mid(i + 2)};
218
6/8
✓ Branch 39 → 40 taken 34479 times.
✗ Branch 39 → 45 not taken.
✓ Branch 41 → 42 taken 7075 times.
✓ Branch 41 → 45 taken 27404 times.
✓ Branch 43 → 44 taken 7075 times.
✗ Branch 43 → 45 not taken.
✓ Branch 46 → 47 taken 7075 times.
✓ Branch 46 → 53 taken 27404 times.
34479 if (i + 2 < raw.size() && raw.at(i + 1) == '\r' && raw.at(i + 2) == '\n')
219
2/2
✓ Branch 47 → 48 taken 7075 times.
✓ Branch 48 → 49 taken 7075 times.
7075 return {raw.left(headerEnd), raw.mid(i + 3)};
220 }
221
222 // No blank line → entire message is headers (no body)
223 ✗ return {raw, {}};
224 }
225
226 343 QMap<QString, QString> MimeParser::messageHeaders(const QByteArray &rawMessage) {
227
1/1
✓ Branch 4 → 5 taken 343 times.
343 const auto prefix = rawMessage.first(qMin(rawMessage.size(), MaximumHeaderBytes + 4));
228 343 auto end = prefix.indexOf("\r\n\r\n");
229 343 const auto lfEnd = prefix.indexOf("\n\n");
230
6/6
✓ Branch 9 → 10 taken 228 times.
✓ Branch 9 → 13 taken 115 times.
✓ Branch 10 → 11 taken 227 times.
✓ Branch 10 → 12 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 226 times.
343 if (lfEnd >= 0 && (end < 0 || lfEnd < end)) end = lfEnd;
231
3/4
✓ Branch 13 → 14 taken 330 times.
✓ Branch 13 → 15 taken 13 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 330 times.
343 if (end < 0 || end > MaximumHeaderBytes) return {};
232
2/2
✓ Branch 16 → 17 taken 330 times.
✓ Branch 17 → 18 taken 330 times.
330 return parseHeaders(prefix.first(end));
233 343 }
234
235 7407 QMap<QString, QString> MimeParser::parseHeaders(const QByteArray &headerBlock) {
236 7407 QMap<QString, QString> headers;
237
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 7407 times.
7407 if (headerBlock.isEmpty()) {
238 ✗ return headers;
239 }
240
241 7407 QString currentKey;
242 7407 QString currentValue;
243
244 // S106-035 (INV-14): lines are read one at a time and their number is
245 // bounded — splitting a 64 MB header-only message materialized tens of
246 // millions of line objects (plus a QString each) before any check. Lines
247 // past MaximumHeaderLines are ignored; a long folded field is still read
248 // whole (its size is bounded by the raw-message budget).
249 7407 qsizetype lineCount = 0;
250 7407 qsizetype lineStart = 0;
251
2/2
✓ Branch 87 → 6 taken 26468 times.
✓ Branch 87 → 88 taken 7406 times.
33874 while (lineStart < headerBlock.size()) {
252
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 20 taken 26467 times.
26468 if (++lineCount > MaximumHeaderLines) {
253
7/7
✓ Branch 7 → 8 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 18 → 9 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
2 qCWarning(lcMime) << "Header block has more than" << MaximumHeaderLines << "lines; the rest is ignored";
254 1 break;
255 }
256 26467 qsizetype lineEnd = headerBlock.indexOf('\n', lineStart);
257
2/2
✓ Branch 21 → 22 taken 7406 times.
✓ Branch 21 → 23 taken 19061 times.
26467 const qsizetype next = lineEnd < 0 ? headerBlock.size() : lineEnd + 1;
258
2/2
✓ Branch 24 → 25 taken 7406 times.
✓ Branch 24 → 26 taken 19061 times.
26467 if (lineEnd < 0) lineEnd = headerBlock.size();
259
5/6
✓ Branch 26 → 27 taken 26467 times.
✗ Branch 26 → 30 not taken.
✓ Branch 28 → 29 taken 19060 times.
✓ Branch 28 → 30 taken 7407 times.
✓ Branch 31 → 32 taken 19060 times.
✓ Branch 31 → 33 taken 7407 times.
26467 if (lineEnd > lineStart && headerBlock.at(lineEnd - 1) == '\r') --lineEnd;
260
1/1
✓ Branch 34 → 35 taken 26467 times.
26467 const QString line = QString::fromUtf8(headerBlock.constData() + lineStart, lineEnd - lineStart);
261 26467 lineStart = next;
262
263 // Continuation line: starts with space or tab
264
7/8
✓ Branch 36 → 37 taken 26467 times.
✗ Branch 36 → 46 not taken.
✓ Branch 40 → 41 taken 26037 times.
✓ Branch 40 → 45 taken 430 times.
✓ Branch 44 → 45 taken 4 times.
✓ Branch 44 → 46 taken 26033 times.
✓ Branch 47 → 48 taken 434 times.
✓ Branch 47 → 58 taken 26033 times.
26467 if (!line.isEmpty() && (line.at(0) == ' ' || line.at(0) == '\t')) {
265
1/2
✓ Branch 49 → 50 taken 434 times.
✗ Branch 49 → 57 not taken.
434 if (!currentKey.isEmpty()) {
266
3/3
✓ Branch 50 → 51 taken 434 times.
✓ Branch 52 → 53 taken 434 times.
✓ Branch 53 → 54 taken 434 times.
434 currentValue += ' ' + line.trimmed();
267 }
268 434 continue;
269 }
270
271 // Save previous header
272
2/2
✓ Branch 59 → 60 taken 18626 times.
✓ Branch 59 → 61 taken 7407 times.
26033 if (!currentKey.isEmpty()) {
273
1/1
✓ Branch 60 → 61 taken 18626 times.
18626 headers.insert(currentKey, currentValue);
274 }
275
276 // Parse new header line: "Key: Value"
277 26033 int colonPos = line.indexOf(':');
278
1/2
✓ Branch 63 → 64 taken 26033 times.
✗ Branch 63 → 77 not taken.
26033 if (colonPos > 0) {
279
3/3
✓ Branch 64 → 65 taken 26033 times.
✓ Branch 65 → 66 taken 26033 times.
✓ Branch 66 → 67 taken 26033 times.
26033 currentKey = line.left(colonPos).trimmed().toLower();
280
2/2
✓ Branch 71 → 72 taken 26033 times.
✓ Branch 72 → 73 taken 26033 times.
26033 currentValue = line.mid(colonPos + 1).trimmed();
281 } else {
282 ✗ currentKey.clear();
283 ✗ currentValue.clear();
284 }
285 26467 }
286
287 // Save last header
288
1/2
✓ Branch 89 → 90 taken 7407 times.
✗ Branch 89 → 91 not taken.
7407 if (!currentKey.isEmpty()) {
289
1/1
✓ Branch 90 → 91 taken 7407 times.
7407 headers.insert(currentKey, currentValue);
290 }
291
292 7407 return headers;
293 7407 }
294
295 464 QByteArray MimeParser::extractBoundary(const QString &contentType) {
296
1/1
✓ Branch 5 → 6 taken 464 times.
464 QString boundary = extractParam(contentType, QStringLiteral("boundary"));
297
1/1
✓ Branch 8 → 9 taken 464 times.
928 return boundary.toUtf8();
298 464 }
299
300 6613 QString MimeParser::extractCharset(const QString &contentType) {
301
1/1
✓ Branch 5 → 6 taken 6613 times.
6613 QString charset = extractParam(contentType, QStringLiteral("charset"));
302
2/2
✓ Branch 9 → 10 taken 6161 times.
✓ Branch 9 → 15 taken 452 times.
6613 if (charset.isEmpty()) {
303 6161 return QStringLiteral("utf-8"); // Default
304 }
305 // Remove surrounding quotes
306
3/8
✓ Branch 16 → 17 taken 452 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 22 taken 452 times.
✗ Branch 19 → 20 not taken.
✗ Branch 20 → 21 not taken.
✗ Branch 20 → 22 not taken.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 29 taken 452 times.
452 if (charset.startsWith('"') && charset.endsWith('"')) {
307 ✗ charset = charset.mid(1, charset.length() - 2);
308 }
309 452 return charset;
310 6613 }
311
312 13413 QString MimeParser::extractParam(const QString &headerValue,
313 const QString &param) {
314 13413 QMap<QString, QString> parameters;
315 13413 qsizetype position = 0;
316 13413 const qsizetype firstSeparator = headerValue.indexOf(QLatin1Char(';'));
317 13413 const qsizetype firstEquals = headerValue.indexOf(QLatin1Char('='));
318 // Be tolerant of a bare parameter string ("boundary=value"), which is
319 // useful for isolated header fragments. A regular media/disposition token
320 // has no '=' before its first ';' and is skipped.
321
4/4
✓ Branch 8 → 9 taken 5295 times.
✓ Branch 8 → 11 taken 8118 times.
✓ Branch 9 → 10 taken 5294 times.
✓ Branch 9 → 14 taken 1 time.
13413 if (firstEquals < 0 ||
322
1/2
✓ Branch 10 → 11 taken 5294 times.
✗ Branch 10 → 14 not taken.
5294 (firstSeparator >= 0 && firstEquals > firstSeparator)) {
323
2/2
✓ Branch 11 → 12 taken 8118 times.
✓ Branch 11 → 13 taken 5294 times.
13412 if (firstSeparator < 0)
324 8118 return {};
325 5294 position = firstSeparator + 1;
326 }
327
328
2/2
✓ Branch 121 → 15 taken 5375 times.
✓ Branch 121 → 122 taken 5295 times.
10670 while (position < headerValue.size()) {
329
3/4
✓ Branch 18 → 19 taken 10745 times.
✗ Branch 18 → 28 not taken.
✓ Branch 21 → 22 taken 5375 times.
✓ Branch 21 → 27 taken 5370 times.
26865 while (position < headerValue.size() &&
330
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 5375 times.
16120 (headerValue.at(position).isSpace() ||
331
2/2
✓ Branch 29 → 16 taken 5370 times.
✓ Branch 29 → 30 taken 5375 times.
16120 headerValue.at(position) == QLatin1Char(';')))
332 5370 ++position;
333 5375 const qsizetype equals = headerValue.indexOf(QLatin1Char('='), position);
334
1/2
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 5375 times.
5375 if (equals < 0)
335 ✗ break;
336 const qsizetype separator =
337 5375 headerValue.indexOf(QLatin1Char(';'), position);
338
3/4
✓ Branch 38 → 39 taken 80 times.
✓ Branch 38 → 41 taken 5295 times.
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 80 times.
5375 if (separator >= 0 && separator < equals) {
339 ✗ position = separator + 1;
340 ✗ continue;
341 }
342 const QString name =
343
3/3
✓ Branch 41 → 42 taken 5375 times.
✓ Branch 42 → 43 taken 5375 times.
✓ Branch 43 → 44 taken 5375 times.
5375 headerValue.mid(position, equals - position).trimmed().toLower();
344 5375 position = equals + 1;
345
346 5375 QString value;
347
3/4
✓ Branch 47 → 48 taken 5375 times.
✗ Branch 47 → 54 not taken.
✓ Branch 52 → 53 taken 1527 times.
✓ Branch 52 → 54 taken 3848 times.
10750 if (position < headerValue.size() &&
348
2/2
✓ Branch 55 → 56 taken 1527 times.
✓ Branch 55 → 87 taken 3848 times.
10750 headerValue.at(position) == QLatin1Char('"')) {
349 1527 ++position;
350 1527 bool escaped = false;
351
1/2
✓ Branch 74 → 57 taken 20821 times.
✗ Branch 74 → 75 not taken.
20821 while (position < headerValue.size()) {
352 20821 const QChar character = headerValue.at(position++);
353
2/2
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 61 taken 20820 times.
20821 if (escaped) {
354
1/1
✓ Branch 59 → 60 taken 1 time.
1 value.append(character);
355 1 escaped = false;
356
2/2
✓ Branch 64 → 65 taken 1 time.
✓ Branch 64 → 66 taken 20819 times.
20820 } else if (character == QLatin1Char('\\')) {
357 1 escaped = true;
358
2/2
✓ Branch 69 → 70 taken 1527 times.
✓ Branch 69 → 71 taken 19292 times.
20819 } else if (character == QLatin1Char('"')) {
359 1527 break;
360 } else {
361
1/1
✓ Branch 71 → 72 taken 19292 times.
19292 value.append(character);
362 }
363 }
364
2/2
✓ Branch 78 → 79 taken 12 times.
✓ Branch 78 → 85 taken 1515 times.
1539 while (position < headerValue.size() &&
365
2/4
✗ Branch 83 → 84 not taken.
✓ Branch 83 → 85 taken 12 times.
✗ Branch 86 → 76 not taken.
✓ Branch 86 → 98 taken 1527 times.
1539 headerValue.at(position) != QLatin1Char(';'))
366 ✗ ++position;
367 } else {
368 3848 qsizetype end = headerValue.indexOf(QLatin1Char(';'), position);
369
2/2
✓ Branch 90 → 91 taken 3780 times.
✓ Branch 90 → 92 taken 68 times.
3848 if (end < 0)
370 3780 end = headerValue.size();
371
2/2
✓ Branch 92 → 93 taken 3848 times.
✓ Branch 93 → 94 taken 3848 times.
3848 value = headerValue.mid(position, end - position).trimmed();
372 3848 position = end;
373 }
374
4/7
✓ Branch 99 → 100 taken 5375 times.
✗ Branch 99 → 103 not taken.
✓ Branch 100 → 101 taken 5375 times.
✓ Branch 101 → 102 taken 5375 times.
✗ Branch 101 → 103 not taken.
✓ Branch 104 → 105 taken 5375 times.
✗ Branch 104 → 106 not taken.
5375 if (!name.isEmpty() && !parameters.contains(name))
375
1/1
✓ Branch 105 → 106 taken 5375 times.
5375 parameters.insert(name, value);
376
3/4
✓ Branch 107 → 108 taken 80 times.
✓ Branch 107 → 114 taken 5295 times.
✓ Branch 112 → 113 taken 80 times.
✗ Branch 112 → 114 not taken.
5455 if (position < headerValue.size() &&
377
2/2
✓ Branch 115 → 116 taken 80 times.
✓ Branch 115 → 117 taken 5295 times.
5455 headerValue.at(position) == QLatin1Char(';'))
378 80 ++position;
379 5375 }
380
381
1/1
✓ Branch 122 → 123 taken 5295 times.
5295 const QString baseName = param.toLower();
382 // RFC 2231: an extended value (name* or name*N*) is charset'lang'%XX… —
383 // the charset/language prefix only in the first extended section — and is
384 // percent-decoded; a plain continuation (name*N) is literal text.
385 // S106-037: the sections were concatenated and then decoded as a whole,
386 // so a plain section with "%41" or "'" corrupted the name.
387 5295 QString charset;
388 5295 QByteArray bytes;
389 5295 bool any = false;
390 21 const auto extended = [&](const QString &value, bool first) {
391 21 QString payload = value;
392
2/2
✓ Branch 3 → 4 taken 9 times.
✓ Branch 3 → 19 taken 12 times.
21 if (first) {
393 9 const qsizetype firstTick = payload.indexOf(QLatin1Char('\''));
394 9 const qsizetype secondTick = payload.indexOf(QLatin1Char('\''), firstTick + 1);
395
2/4
✓ Branch 10 → 11 taken 9 times.
✗ Branch 10 → 19 not taken.
✓ Branch 11 → 12 taken 9 times.
✗ Branch 11 → 19 not taken.
9 if (firstTick >= 0 && secondTick > firstTick) {
396
1/1
✓ Branch 12 → 13 taken 9 times.
9 charset = payload.left(firstTick);
397
1/1
✓ Branch 15 → 16 taken 9 times.
9 payload = payload.mid(secondTick + 1);
398 }
399 }
400
3/3
✓ Branch 19 → 20 taken 21 times.
✓ Branch 20 → 21 taken 21 times.
✓ Branch 21 → 22 taken 21 times.
21 bytes += QByteArray::fromPercentEncoding(payload.toLatin1());
401 21 };
402
4/4
✓ Branch 125 → 126 taken 5295 times.
✓ Branch 126 → 127 taken 5295 times.
✓ Branch 128 → 129 taken 6 times.
✓ Branch 128 → 139 taken 5289 times.
5295 if (parameters.contains(baseName + QLatin1Char('*'))) {
403
3/3
✓ Branch 132 → 133 taken 6 times.
✓ Branch 133 → 134 taken 6 times.
✓ Branch 134 → 135 taken 6 times.
6 extended(parameters.value(baseName + QLatin1Char('*')), true);
404 6 any = true;
405 } else {
406 5289 for (int section = 0;; ++section) {
407 const QString plainKey =
408
3/3
✓ Branch 140 → 141 taken 5307 times.
✓ Branch 143 → 144 taken 5307 times.
✓ Branch 144 → 145 taken 5307 times.
5307 baseName + QLatin1Char('*') + QString::number(section);
409
1/1
✓ Branch 149 → 150 taken 5307 times.
5307 const QString encodedKey = plainKey + QLatin1Char('*');
410
3/3
✓ Branch 150 → 151 taken 5307 times.
✓ Branch 151 → 152 taken 15 times.
✓ Branch 151 → 158 taken 5292 times.
5307 if (parameters.contains(encodedKey))
411
2/2
✓ Branch 153 → 154 taken 15 times.
✓ Branch 154 → 155 taken 15 times.
15 extended(parameters.value(encodedKey), section == 0);
412
3/3
✓ Branch 158 → 159 taken 5292 times.
✓ Branch 159 → 160 taken 3 times.
✓ Branch 159 → 168 taken 5289 times.
5292 else if (parameters.contains(plainKey))
413
3/3
✓ Branch 161 → 162 taken 3 times.
✓ Branch 162 → 163 taken 3 times.
✓ Branch 163 → 164 taken 3 times.
3 bytes += parameters.value(plainKey).toUtf8();
414 else
415 5289 break;
416 18 any = true;
417 10614 }
418 }
419
420
2/2
✓ Branch 179 → 180 taken 10 times.
✓ Branch 179 → 192 taken 5285 times.
5295 if (any)
421
5/7
✓ Branch 181 → 182 taken 1 time.
✓ Branch 181 → 185 taken 9 times.
✓ Branch 186 → 187 taken 10 times.
✓ Branch 189 → 190 taken 1 time.
✓ Branch 189 → 191 taken 9 times.
✗ Branch 267 → 268 not taken.
✗ Branch 267 → 269 not taken.
11 return convertCharset(bytes, charset.isEmpty() ? QStringLiteral("utf-8") : charset);
422
1/1
✓ Branch 193 → 194 taken 5285 times.
5285 return parameters.value(baseName);
423 13413 }
424
425 6318 QString MimeParser::extractFilename(const QString &contentDisposition,
426 const QString &contentType) {
427 6318 QString fn;
428 // Try Content-Disposition first
429
2/2
✓ Branch 3 → 4 taken 4121 times.
✓ Branch 3 → 13 taken 2197 times.
6318 if (!contentDisposition.isEmpty()) {
430
1/1
✓ Branch 7 → 8 taken 4121 times.
4121 fn = extractParam(contentDisposition, QStringLiteral("filename"));
431 }
432
433 // Fallback: Content-Type name= parameter
434
5/6
✓ Branch 14 → 15 taken 2206 times.
✓ Branch 14 → 18 taken 4112 times.
✓ Branch 16 → 17 taken 2206 times.
✗ Branch 16 → 18 not taken.
✓ Branch 19 → 20 taken 2206 times.
✓ Branch 19 → 29 taken 4112 times.
6318 if (fn.isEmpty() && !contentType.isEmpty()) {
435
1/1
✓ Branch 23 → 24 taken 2206 times.
2206 fn = extractParam(contentType, QStringLiteral("name"));
436 }
437
438 6318 return fn;
439 ✗ }
440
441 6318 QString MimeParser::decodeAndSanitizeFilename(
442 const QString &contentDisposition, const QString &contentType) {
443 const QString decoded = ImapResponseParser::decodeRfc2047(
444
2/2
✓ Branch 2 → 3 taken 6318 times.
✓ Branch 3 → 4 taken 6318 times.
6318 extractFilename(contentDisposition, contentType));
445
2/2
✓ Branch 6 → 7 taken 2204 times.
✓ Branch 6 → 8 taken 4114 times.
6318 if (decoded.isEmpty())
446 2204 return {};
447
1/1
✓ Branch 8 → 9 taken 4114 times.
4114 return AttachmentFileSecurity::normalizedFileName(decoded);
448 6318 }
449
450 463 int MimeParser::remainingParts(const ParseBudget &budget) {
451 463 return qMax(0, kMaxTotalParts - budget.totalParts);
452 }
453
454 463 qint64 MimeParser::remainingMaterializedBytes(const ParseBudget &budget) {
455 926 return qMax<qint64>(0,
456 926 kMaxMaterializedPartBytes -
457 463 budget.materializedPartBytes);
458 }
459
460 6636 bool MimeParser::consumePartBudget(const QByteArray &partData,
461 ParseBudget &budget) {
462
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 16 taken 6636 times.
6636 if (budget.totalParts >= kMaxTotalParts) {
463 ✗ budget.limitExceeded = true;
464 ✗ qCWarning(lcMime) << "MIME total parts limit exceeded" << kMaxTotalParts
465 ✗ << "- skipping remaining";
466 ✗ return false;
467 }
468
469
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 31 taken 6636 times.
6636 if (budget.materializedPartBytes + partData.size() >
470 kMaxMaterializedPartBytes) {
471 ✗ budget.limitExceeded = true;
472 ✗ qCWarning(lcMime) << "MIME materialized part byte limit exceeded"
473 ✗ << kMaxMaterializedPartBytes << "- skipping remaining";
474 ✗ return false;
475 }
476
477 6636 ++budget.totalParts;
478 6636 budget.materializedPartBytes += partData.size();
479 6636 return true;
480 }
481
482 463 QList<QByteArray> MimeParser::splitMultipartBody(const QByteArray &body,
483 const QByteArray &boundary,
484 int maxParts,
485 qint64 maxPartBytes,
486 bool *limitExceeded) {
487 463 QList<QByteArray> parts;
488
2/4
✓ Branch 2 → 3 taken 463 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 12 taken 463 times.
463 if (maxParts == 0 || maxPartBytes == 0) {
489 ✗ if (limitExceeded && !body.isEmpty())
490 ✗ *limitExceeded = true;
491 ✗ return parts;
492 }
493
494 463 qint64 materializedBytes = 0;
495
496 6640 auto appendPart = [&](const QByteArray &part) {
497
5/6
✓ Branch 2 → 3 taken 6640 times.
✗ Branch 2 → 6 not taken.
✓ Branch 4 → 5 taken 3 times.
✓ Branch 4 → 6 taken 6637 times.
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 23 taken 6637 times.
6640 if (maxParts >= 0 && parts.size() >= maxParts) {
498
1/2
✓ Branch 8 → 9 taken 3 times.
✗ Branch 8 → 10 not taken.
3 if (limitExceeded)
499 3 *limitExceeded = true;
500
5/5
✓ Branch 10 → 11 taken 3 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 15 → 16 taken 3 times.
✓ Branch 21 → 12 taken 3 times.
✓ Branch 21 → 22 taken 3 times.
6 qCWarning(lcMime) << "MIME multipart split part limit reached"
501
2/2
✓ Branch 16 → 17 taken 3 times.
✓ Branch 17 → 18 taken 3 times.
3 << maxParts << "- skipping remaining";
502 3 return false;
503 }
504
5/6
✓ Branch 23 → 24 taken 6637 times.
✗ Branch 23 → 27 not taken.
✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 27 taken 6636 times.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 44 taken 6636 times.
6637 if (maxPartBytes >= 0 && materializedBytes + part.size() > maxPartBytes) {
505
1/2
✓ Branch 29 → 30 taken 1 time.
✗ Branch 29 → 31 not taken.
1 if (limitExceeded)
506 1 *limitExceeded = true;
507
5/5
✓ Branch 31 → 32 taken 1 time.
✓ Branch 35 → 36 taken 1 time.
✓ Branch 36 → 37 taken 1 time.
✓ Branch 42 → 33 taken 1 time.
✓ Branch 42 → 43 taken 1 time.
2 qCWarning(lcMime) << "MIME multipart split byte limit reached"
508
2/2
✓ Branch 37 → 38 taken 1 time.
✓ Branch 38 → 39 taken 1 time.
1 << maxPartBytes << "- skipping remaining";
509 1 return false;
510 }
511
512 6636 materializedBytes += part.size();
513 6636 parts.append(part);
514 6636 return true;
515 463 };
516
517
1/1
✓ Branch 12 → 13 taken 463 times.
463 const QByteArray delimiter = "--" + boundary;
518 463 qsizetype partStart = -1;
519 463 qsizetype lineStart = 0;
520
1/2
✓ Branch 86 → 14 taken 896549 times.
✗ Branch 86 → 87 not taken.
896549 while (lineStart <= body.size()) {
521 896549 const qsizetype newline = body.indexOf('\n', lineStart);
522
2/2
✓ Branch 15 → 16 taken 76 times.
✓ Branch 15 → 17 taken 896473 times.
896549 const qsizetype nextLine = newline < 0 ? body.size() : newline + 1;
523
2/2
✓ Branch 18 → 19 taken 76 times.
✓ Branch 18 → 20 taken 896473 times.
896549 qsizetype lineEnd = newline < 0 ? body.size() : newline;
524
6/6
✓ Branch 21 → 22 taken 896547 times.
✓ Branch 21 → 25 taken 2 times.
✓ Branch 23 → 24 taken 896467 times.
✓ Branch 23 → 25 taken 80 times.
✓ Branch 26 → 27 taken 896467 times.
✓ Branch 26 → 28 taken 82 times.
896549 if (lineEnd > lineStart && body.at(lineEnd - 1) == '\r')
525 896467 --lineEnd;
526 1793098 const QByteArrayView line(body.constData() + lineStart,
527 896549 lineEnd - lineStart);
528
529 896549 bool closing = false;
530 896549 bool legalBoundary = false;
531
2/2
✓ Branch 32 → 33 taken 7682 times.
✓ Branch 32 → 47 taken 888867 times.
896549 if (line.startsWith(delimiter)) {
532
1/1
✓ Branch 34 → 35 taken 7682 times.
7682 QByteArrayView suffix = line.sliced(delimiter.size());
533
2/2
✓ Branch 37 → 38 taken 460 times.
✓ Branch 37 → 39 taken 7222 times.
7682 if (suffix.startsWith(QByteArrayView("--"))) {
534 460 closing = true;
535
1/1
✓ Branch 38 → 39 taken 460 times.
460 suffix = suffix.sliced(2);
536 }
537 7682 legalBoundary = true;
538
2/2
✓ Branch 45 → 41 taken 585 times.
✓ Branch 45 → 46 taken 7101 times.
7686 for (const char character : suffix) {
539
4/4
✓ Branch 41 → 42 taken 583 times.
✓ Branch 41 → 44 taken 2 times.
✓ Branch 42 → 43 taken 581 times.
✓ Branch 42 → 44 taken 2 times.
585 if (character != ' ' && character != '\t') {
540 581 legalBoundary = false;
541 581 break;
542 }
543 }
544 }
545
546
2/2
✓ Branch 47 → 48 taken 7101 times.
✓ Branch 47 → 71 taken 889448 times.
896549 if (legalBoundary) {
547
2/2
✓ Branch 48 → 49 taken 6639 times.
✓ Branch 48 → 68 taken 462 times.
7101 if (partStart >= 0) {
548 6639 qsizetype partEnd = lineStart;
549
3/6
✓ Branch 49 → 50 taken 6639 times.
✗ Branch 49 → 53 not taken.
✓ Branch 51 → 52 taken 6639 times.
✗ Branch 51 → 53 not taken.
✓ Branch 54 → 55 taken 6639 times.
✗ Branch 54 → 56 not taken.
6639 if (partEnd > partStart && body.at(partEnd - 1) == '\n')
550 6639 --partEnd;
551
5/6
✓ Branch 56 → 57 taken 6639 times.
✗ Branch 56 → 60 not taken.
✓ Branch 58 → 59 taken 6638 times.
✓ Branch 58 → 60 taken 1 time.
✓ Branch 61 → 62 taken 6638 times.
✓ Branch 61 → 63 taken 1 time.
6639 if (partEnd > partStart && body.at(partEnd - 1) == '\r')
552 6638 --partEnd;
553
4/4
✓ Branch 63 → 64 taken 6639 times.
✓ Branch 64 → 65 taken 6639 times.
✓ Branch 66 → 67 taken 4 times.
✓ Branch 66 → 68 taken 6635 times.
6639 if (!appendPart(body.mid(partStart, partEnd - partStart)))
554 463 break;
555 }
556
2/2
✓ Branch 68 → 69 taken 457 times.
✓ Branch 68 → 70 taken 6640 times.
7097 if (closing)
557 457 break;
558 6640 partStart = nextLine;
559 }
560
561
2/2
✓ Branch 71 → 72 taken 2 times.
✓ Branch 71 → 83 taken 896086 times.
896088 if (newline < 0) {
562
5/6
✓ Branch 72 → 73 taken 1 time.
✓ Branch 72 → 76 taken 1 time.
✓ Branch 74 → 75 taken 1 time.
✗ Branch 74 → 76 not taken.
✓ Branch 77 → 78 taken 1 time.
✓ Branch 77 → 82 taken 1 time.
2 if (partStart >= 0 && partStart < body.size())
563
2/2
✓ Branch 78 → 79 taken 1 time.
✓ Branch 79 → 80 taken 1 time.
1 appendPart(body.mid(partStart));
564 2 break;
565 }
566 896086 lineStart = nextLine;
567 }
568
569 463 return parts;
570 463 }
571
572 6636 void MimeParser::parsePart(const QByteArray &partData, MimeMessage &result,
573 ParseBudget &budget, int depth, bool alternative) {
574
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 6636 times.
6636 if (partData.isEmpty()) {
575 490 return;
576 }
577
578 // T-405/Bug 19: Prevent stack overflow from deeply nested MIME
579
2/2
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 19 taken 6632 times.
6636 if (depth >= kMaxMimeDepth) {
580 4 result.limitExceeded = true;
581
6/6
✓ Branch 6 → 7 taken 4 times.
✓ Branch 10 → 11 taken 4 times.
✓ Branch 11 → 12 taken 4 times.
✓ Branch 12 → 13 taken 4 times.
✓ Branch 17 → 8 taken 4 times.
✓ Branch 17 → 18 taken 4 times.
8 qCWarning(lcMime) << "MIME nesting depth exceeded" << kMaxMimeDepth
582
1/1
✓ Branch 13 → 14 taken 4 times.
4 << "— skipping further parts";
583 4 return;
584 }
585
586
1/1
✓ Branch 19 → 20 taken 6632 times.
6632 auto [headerBlock, bodyBlock] = splitHeaderBody(partData);
587
1/1
✓ Branch 22 → 23 taken 6632 times.
6632 auto headers = parseHeaders(headerBlock);
588
589 13264 QString contentType = headers.value(QStringLiteral("content-type"),
590
1/1
✓ Branch 29 → 30 taken 6632 times.
19896 QStringLiteral("text/plain"));
591 QString transferEncoding = headers.value(
592
1/1
✓ Branch 40 → 41 taken 6632 times.
13264 QStringLiteral("content-transfer-encoding"), QStringLiteral("7bit"));
593 QString contentDisposition =
594
1/1
✓ Branch 49 → 50 taken 6632 times.
13264 headers.value(QStringLiteral("content-disposition"));
595 QString contentId =
596
2/2
✓ Branch 57 → 58 taken 6632 times.
✓ Branch 58 → 59 taken 6632 times.
13264 normalizeContentId(headers.value(QStringLiteral("content-id")));
597
598
1/1
✓ Branch 63 → 64 taken 6632 times.
6632 QString ctLower = contentType.toLower();
599
600 // Recursive: this part is itself multipart
601
3/3
✓ Branch 64 → 65 taken 6632 times.
✓ Branch 65 → 66 taken 322 times.
✓ Branch 65 → 93 taken 6310 times.
6632 if (isMultipartType(ctLower)) {
602
1/1
✓ Branch 66 → 67 taken 322 times.
322 QByteArray boundary = extractBoundary(contentType);
603
1/2
✓ Branch 68 → 69 taken 322 times.
✗ Branch 68 → 91 not taken.
322 if (!boundary.isEmpty()) {
604 // F-07 (SPRINT-104): inside multipart/alternative only the first
605 // part of a type is kept (the alternatives show the same text);
606 // every other multipart — mixed above all — has its parts follow
607 // each other: Apple Mail writes text, inline image, more text, and
608 // mailing lists append a footer part. Those texts are appended.
609 const bool alternative =
610
1/1
✓ Branch 72 → 73 taken 322 times.
322 ctLower.startsWith(QStringLiteral("multipart/alternative"));
611 auto subParts =
612 splitMultipartBody(bodyBlock, boundary, remainingParts(budget),
613
3/3
✓ Branch 75 → 76 taken 322 times.
✓ Branch 76 → 77 taken 322 times.
✓ Branch 77 → 78 taken 322 times.
322 remainingMaterializedBytes(budget), &budget.limitExceeded);
614
4/4
✓ Branch 78 → 79 taken 322 times.
✓ Branch 79 → 80 taken 322 times.
✓ Branch 88 → 81 taken 1334 times.
✓ Branch 88 → 89 taken 322 times.
1656 for (const auto &sub : subParts) {
615
2/3
✓ Branch 82 → 83 taken 1334 times.
✗ Branch 83 → 84 not taken.
✓ Branch 83 → 85 taken 1334 times.
1334 if (!consumePartBudget(sub, budget)) {
616 ✗ break;
617 }
618
1/1
✓ Branch 85 → 86 taken 1334 times.
1334 parsePart(sub, result, budget, depth + 1, alternative);
619 }
620 322 }
621 322 return;
622 322 }
623
624 // Decode the body
625
1/1
✓ Branch 93 → 94 taken 6310 times.
6310 QByteArray decoded = decodeTransferEncoding(bodyBlock, transferEncoding);
626
1/1
✓ Branch 94 → 95 taken 6310 times.
6310 QString charset = extractCharset(contentType);
627 QString filename =
628
1/1
✓ Branch 95 → 96 taken 6310 times.
6310 decodeAndSanitizeFilename(contentDisposition, contentType);
629
630 // Determine if this is an attachment.
631 // Note: Content-ID alone does NOT mean attachment for text/* parts.
632 // Many mailers (e.g. LinkedIn) set Content-ID on body parts.
633
2/2
✓ Branch 96 → 97 taken 6310 times.
✓ Branch 100 → 101 taken 6310 times.
12620 const bool isExplicitAttachment = contentDisposition.trimmed().startsWith(
634 12620 QStringLiteral("attachment"), Qt::CaseInsensitive);
635 6310 const bool hasAttachmentFilename = !filename.isEmpty();
636
637 // Sprint 93 S93.5.2: an iMIP text/calendar body part is kept as an
638 // attachment so the reader can show the invitation card.
639
1/1
✓ Branch 108 → 109 taken 6310 times.
6310 const bool isCalendar = ctLower.startsWith(QStringLiteral("text/calendar"));
640
641 // Text parts (not explicitly marked as attachment)
642
5/10
✓ Branch 116 → 117 taken 172 times.
✓ Branch 116 → 120 taken 21 times.
✓ Branch 123 → 124 taken 6310 times.
✗ Branch 123 → 125 not taken.
✓ Branch 125 → 126 taken 164 times.
✓ Branch 125 → 162 taken 6146 times.
✗ Branch 320 → 321 not taken.
✗ Branch 320 → 322 not taken.
✗ Branch 324 → 325 not taken.
✗ Branch 324 → 326 not taken.
12813 if (ctLower.startsWith(QStringLiteral("text/")) && !isCalendar &&
643
7/9
✓ Branch 114 → 115 taken 6310 times.
✓ Branch 115 → 116 taken 193 times.
✓ Branch 115 → 120 taken 6117 times.
✓ Branch 117 → 118 taken 164 times.
✓ Branch 117 → 120 taken 8 times.
✓ Branch 118 → 119 taken 164 times.
✗ Branch 118 → 120 not taken.
✓ Branch 121 → 122 taken 6310 times.
✗ Branch 121 → 123 not taken.
12813 !isExplicitAttachment && !hasAttachmentFilename) {
644
1/1
✓ Branch 126 → 127 taken 164 times.
164 QString text = convertCharset(decoded, charset);
645
3/3
✓ Branch 130 → 131 taken 164 times.
✓ Branch 133 → 134 taken 44 times.
✓ Branch 133 → 147 taken 120 times.
164 if (ctLower.startsWith(QStringLiteral("text/html"))) {
646 // Keep first HTML part (or best one from multipart/alternative)
647
1/2
✓ Branch 135 → 136 taken 44 times.
✗ Branch 135 → 137 not taken.
44 if (result.textHtml.isEmpty()) {
648 44 result.textHtml = text;
649 ✗ } else if (!alternative) {
650 // F-07: a later HTML part of a mixed chain follows the first.
651 ✗ result.textHtml += QStringLiteral("\n<hr>\n") + text;
652 }
653 } else {
654 // text/plain or other text types
655
2/2
✓ Branch 148 → 149 taken 118 times.
✓ Branch 148 → 150 taken 2 times.
120 if (result.textPlain.isEmpty()) {
656 118 result.textPlain = text;
657
2/2
✓ Branch 150 → 151 taken 1 time.
✓ Branch 150 → 160 taken 1 time.
2 } else if (!alternative) {
658 // F-07: a later plain-text part of a mixed chain follows the
659 // first (text, inline image, more text; list footers).
660
2/2
✓ Branch 154 → 155 taken 1 time.
✓ Branch 155 → 156 taken 1 time.
1 result.textPlain += QStringLiteral("\n\n") + text;
661 }
662 }
663 164 return;
664 164 }
665
666 // Everything else is an attachment
667 6146 MimePart part;
668
2/2
✓ Branch 164 → 165 taken 6146 times.
✓ Branch 165 → 166 taken 6146 times.
6146 part.contentType = ctLower.section(';', 0, 0).trimmed();
669 6146 part.charset = charset;
670
2/2
✓ Branch 170 → 171 taken 6146 times.
✓ Branch 171 → 172 taken 6146 times.
6146 part.transferEncoding = transferEncoding.trimmed().toLower();
671 6146 part.body = decoded;
672
6/6
✓ Branch 177 → 178 taken 2037 times.
✓ Branch 177 → 182 taken 4109 times.
✓ Branch 178 → 179 taken 21 times.
✓ Branch 178 → 182 taken 2016 times.
✓ Branch 185 → 186 taken 21 times.
✓ Branch 185 → 187 taken 6125 times.
6167 part.filename = filename.isEmpty() && isCalendar ? QStringLiteral("invite.ics") : filename;
673 6146 part.contentId = contentId;
674 6146 part.isAttachment = true;
675
2/2
✓ Branch 189 → 190 taken 27 times.
✓ Branch 189 → 197 taken 6119 times.
6146 if (!contentId.isEmpty()) {
676
2/2
✓ Branch 190 → 191 taken 2 times.
✓ Branch 190 → 192 taken 25 times.
27 if (isExplicitAttachment) {
677 2 part.presentation =
678 AttachmentPresentation::ExplicitAttachmentWithContentId;
679 } else {
680
1/1
✓ Branch 192 → 193 taken 25 times.
50 part.presentation = isAllowedInlineImage(part.contentType, part.body)
681
2/2
✓ Branch 193 → 194 taken 20 times.
✓ Branch 193 → 195 taken 5 times.
25 ? AttachmentPresentation::InlineCandidate
682 : AttachmentPresentation::RejectedInline;
683 }
684 }
685
686
1/1
✓ Branch 197 → 198 taken 6146 times.
6146 result.attachments.append(part);
687 10040 }
688
689 12813 QString MimeParser::normalizeContentId(const QString &contentId) {
690 12813 return canonicalContentId(contentId);
691 }
692
693 63 bool MimeParser::isAllowedInlineImage(const QString &contentType,
694 const QByteArray &body) {
695
6/6
✓ Branch 3 → 4 taken 61 times.
✓ Branch 3 → 6 taken 2 times.
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 7 taken 59 times.
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 10 taken 59 times.
63 if (body.isEmpty() || body.size() > MaxInlineImageBytes)
696 4 return false;
697
698
3/3
✓ Branch 12 → 13 taken 59 times.
✓ Branch 13 → 14 taken 59 times.
✓ Branch 14 → 15 taken 59 times.
59 const QString type = contentType.section(';', 0, 0).trimmed().toLower();
699
2/2
✓ Branch 19 → 20 taken 42 times.
✓ Branch 19 → 23 taken 17 times.
59 if (type == QLatin1String("image/png"))
700 42 return body.startsWith("\x89PNG\r\n\x1a\n");
701
2/2
✓ Branch 25 → 26 taken 5 times.
✓ Branch 25 → 37 taken 12 times.
17 if (type == QLatin1String("image/jpeg"))
702
4/4
✓ Branch 29 → 30 taken 3 times.
✓ Branch 29 → 35 taken 1 time.
✓ Branch 31 → 32 taken 2 times.
✓ Branch 31 → 35 taken 1 time.
12 return body.size() >= 3 && static_cast<uchar>(body.at(0)) == 0xFF &&
703
4/4
✓ Branch 27 → 28 taken 4 times.
✓ Branch 27 → 35 taken 1 time.
✓ Branch 33 → 34 taken 1 time.
✓ Branch 33 → 35 taken 1 time.
12 static_cast<uchar>(body.at(1)) == 0xD8 &&
704 7 static_cast<uchar>(body.at(2)) == 0xFF;
705
2/2
✓ Branch 39 → 40 taken 3 times.
✓ Branch 39 → 50 taken 9 times.
12 if (type == QLatin1String("image/gif"))
706
4/4
✓ Branch 42 → 43 taken 2 times.
✓ Branch 42 → 46 taken 1 time.
✓ Branch 45 → 46 taken 1 time.
✓ Branch 45 → 47 taken 1 time.
3 return body.startsWith("GIF87a") || body.startsWith("GIF89a");
707
2/2
✓ Branch 52 → 53 taken 4 times.
✓ Branch 52 → 72 taken 5 times.
9 if (type == QLatin1String("image/webp"))
708
6/6
✓ Branch 54 → 55 taken 3 times.
✓ Branch 54 → 64 taken 1 time.
✓ Branch 57 → 58 taken 2 times.
✓ Branch 57 → 64 taken 1 time.
✓ Branch 62 → 63 taken 1 time.
✓ Branch 62 → 64 taken 1 time.
6 return body.size() >= 12 && body.startsWith("RIFF") &&
709
7/13
✓ Branch 60 → 61 taken 2 times.
✓ Branch 65 → 66 taken 2 times.
✓ Branch 65 → 67 taken 2 times.
✓ Branch 67 → 68 taken 2 times.
✓ Branch 67 → 69 taken 2 times.
✓ Branch 69 → 70 taken 2 times.
✓ Branch 69 → 71 taken 2 times.
✗ Branch 90 → 91 not taken.
✗ Branch 90 → 92 not taken.
✗ Branch 94 → 95 not taken.
✗ Branch 94 → 96 not taken.
✗ Branch 98 → 99 not taken.
✗ Branch 98 → 100 not taken.
6 body.mid(8, 4) == QByteArrayLiteral("WEBP");
710
2/2
✓ Branch 74 → 75 taken 2 times.
✓ Branch 74 → 78 taken 3 times.
5 if (type == QLatin1String("image/bmp"))
711 2 return body.startsWith("BM");
712 3 return false;
713 59 }
714
715 443 void MimeParser::finalizeInlineParts(MimeMessage &result) {
716 443 QHash<QString, int> contentIdCounts;
717
4/4
✓ Branch 2 → 3 taken 443 times.
✓ Branch 3 → 4 taken 443 times.
✓ Branch 15 → 5 taken 6154 times.
✓ Branch 15 → 16 taken 443 times.
6597 for (MimePart &part : result.attachments) {
718
1/1
✓ Branch 6 → 7 taken 6154 times.
6154 part.contentId = normalizeContentId(part.contentId);
719
2/2
✓ Branch 10 → 11 taken 30 times.
✓ Branch 10 → 13 taken 6124 times.
6154 if (!part.contentId.isEmpty())
720
1/1
✓ Branch 11 → 12 taken 30 times.
30 ++contentIdCounts[part.contentId];
721 }
722
723 443 qint64 aggregateBytes = 0;
724
4/4
✓ Branch 16 → 17 taken 443 times.
✓ Branch 17 → 18 taken 443 times.
✓ Branch 34 → 19 taken 6154 times.
✓ Branch 34 → 35 taken 443 times.
6597 for (MimePart &part : result.attachments) {
725
2/2
✓ Branch 20 → 21 taken 6133 times.
✓ Branch 20 → 22 taken 21 times.
6154 if (part.presentation != AttachmentPresentation::InlineCandidate)
726 6133 continue;
727
4/4
✓ Branch 23 → 24 taken 17 times.
✓ Branch 23 → 26 taken 4 times.
✓ Branch 28 → 29 taken 5 times.
✓ Branch 28 → 30 taken 16 times.
38 if (contentIdCounts.value(part.contentId) != 1 ||
728 17 part.body.size() >
729
2/2
✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 27 taken 16 times.
17 MaxInlineImageAggregateBytes - aggregateBytes) {
730 5 part.presentation = AttachmentPresentation::RejectedInline;
731 5 continue;
732 }
733 16 aggregateBytes += part.body.size();
734 }
735 443 }
736