util/SpamHeaders.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | // F100-56: what a spam filter on the server wrote into a message's header — | ||
| 3 | // SpamAssassin (X-Spam-Status/-Flag/-Score/-Level) and Rspamd (X-Spam, | ||
| 4 | // X-Spamd-Result, X-Rspamd-Score/-Action). Header-only and pure: the display | ||
| 5 | // read extracts the relevant lines from a bounded raw prefix (worker thread), | ||
| 6 | // the reader turns them into a verdict. Never trusted for actions — display only. | ||
| 7 | |||
| 8 | #include <QByteArray> | ||
| 9 | #include <QRegularExpression> | ||
| 10 | #include <QString> | ||
| 11 | #include <QStringList> | ||
| 12 | #include <cmath> | ||
| 13 | #include <optional> | ||
| 14 | |||
| 15 | namespace SpamHeaders { | ||
| 16 | |||
| 17 | // Lower-case header names this module reads. | ||
| 18 | 611 | inline const QStringList &fieldNames() { | |
| 19 | static const QStringList names{"x-spam-status", "x-spam-flag", "x-spam-score", "x-spam-level", "x-spam", | ||
| 20 |
5/10✓ Branch 2 → 3 taken 11 times.
✓ Branch 2 → 20 taken 600 times.
✓ Branch 4 → 5 taken 11 times.
✗ Branch 4 → 20 not taken.
✓ Branch 17 → 18 taken 88 times.
✓ Branch 17 → 19 taken 11 times.
✗ Branch 23 → 24 not taken.
✗ Branch 23 → 25 not taken.
✗ Branch 32 → 33 not taken.
✗ Branch 32 → 34 not taken.
|
782 | "x-spamd-result", "x-rspamd-score", "x-rspamd-action"}; |
| 21 | 611 | return names; | |
| 22 | 11 | } | |
| 23 | |||
| 24 | // The relevant fields of a raw message prefix as unfolded "name: value" lines | ||
| 25 | // (name lower-case, value trimmed and at most 1024 characters). Reads only the | ||
| 26 | // header block of at most `maximumBytes`. | ||
| 27 | 933 | inline QString extract(const QByteArray &raw, qsizetype maximumBytes = 16384) { | |
| 28 |
1/1✓ Branch 4 → 5 taken 933 times.
|
933 | const QByteArray prefix = raw.first(qMin(raw.size(), maximumBytes)); |
| 29 | 933 | qsizetype end = prefix.indexOf("\r\n\r\n"); | |
| 30 | 933 | const qsizetype lfEnd = prefix.indexOf("\n\n"); | |
| 31 |
5/6✓ Branch 9 → 10 taken 79 times.
✓ Branch 9 → 13 taken 854 times.
✓ Branch 10 → 11 taken 45 times.
✓ Branch 10 → 12 taken 34 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 45 times.
|
933 | if (lfEnd >= 0 && (end < 0 || lfEnd < end)) end = lfEnd; |
| 32 |
3/3✓ Branch 13 → 14 taken 132 times.
✓ Branch 13 → 15 taken 801 times.
✓ Branch 14 → 16 taken 132 times.
|
933 | const QByteArray block = end >= 0 ? prefix.first(end) : prefix; |
| 33 | 933 | QStringList lines; | |
| 34 | 933 | QString name, value; | |
| 35 | 2666 | const auto flush = [&] { | |
| 36 | // Trimmed again after the cut: a value cut right after a blank would | ||
| 37 | // otherwise change when its stored line is read again (fuzz oracle). | ||
| 38 |
6/6✓ Branch 3 → 4 taken 611 times.
✓ Branch 3 → 8 taken 2055 times.
✓ Branch 6 → 7 taken 25 times.
✓ Branch 6 → 8 taken 586 times.
✓ Branch 9 → 10 taken 25 times.
✓ Branch 9 → 27 taken 2641 times.
|
2666 | if (!name.isEmpty() && fieldNames().contains(name)) |
| 39 |
6/6✓ Branch 10 → 11 taken 25 times.
✓ Branch 11 → 12 taken 25 times.
✓ Branch 12 → 13 taken 25 times.
✓ Branch 16 → 17 taken 25 times.
✓ Branch 17 → 18 taken 25 times.
✓ Branch 18 → 19 taken 25 times.
|
50 | lines << name + QStringLiteral(": ") + value.trimmed().left(1024).trimmed(); |
| 40 | 2666 | name.clear(); | |
| 41 | 2666 | value.clear(); | |
| 42 | 2666 | }; | |
| 43 |
5/5✓ Branch 16 → 17 taken 933 times.
✓ Branch 17 → 18 taken 933 times.
✓ Branch 18 → 19 taken 933 times.
✓ Branch 71 → 20 taken 1814 times.
✓ Branch 71 → 72 taken 933 times.
|
2747 | for (QByteArray line : block.split('\n')) { |
| 44 |
4/4✓ Branch 22 → 23 taken 1814 times.
✓ Branch 23 → 24 taken 514 times.
✓ Branch 23 → 25 taken 1300 times.
✓ Branch 24 → 25 taken 514 times.
|
1814 | if (line.endsWith('\r')) line.chop(1); |
| 45 |
8/8✓ Branch 26 → 27 taken 1035 times.
✓ Branch 26 → 32 taken 779 times.
✓ Branch 28 → 29 taken 959 times.
✓ Branch 28 → 31 taken 76 times.
✓ Branch 30 → 31 taken 5 times.
✓ Branch 30 → 32 taken 954 times.
✓ Branch 33 → 34 taken 81 times.
✓ Branch 33 → 47 taken 1733 times.
|
1814 | if (!line.isEmpty() && (line.at(0) == ' ' || line.at(0) == '\t')) { |
| 46 |
5/6✓ Branch 35 → 36 taken 81 times.
✗ Branch 35 → 46 not taken.
✓ Branch 36 → 37 taken 81 times.
✓ Branch 37 → 38 taken 81 times.
✓ Branch 40 → 41 taken 81 times.
✓ Branch 41 → 42 taken 81 times.
|
81 | if (!name.isEmpty()) value += QLatin1Char(' ') + QString::fromLatin1(line).trimmed(); |
| 47 | 81 | continue; | |
| 48 | } | ||
| 49 |
1/1✓ Branch 47 → 48 taken 1733 times.
|
1733 | flush(); |
| 50 | 1733 | const qsizetype colon = line.indexOf(':'); | |
| 51 |
2/2✓ Branch 49 → 50 taken 1122 times.
✓ Branch 49 → 51 taken 611 times.
|
1733 | if (colon <= 0) continue; |
| 52 |
4/4✓ Branch 51 → 52 taken 611 times.
✓ Branch 52 → 53 taken 611 times.
✓ Branch 53 → 54 taken 611 times.
✓ Branch 54 → 55 taken 611 times.
|
611 | name = QString::fromLatin1(line.first(colon)).trimmed().toLower(); |
| 53 |
2/2✓ Branch 60 → 61 taken 611 times.
✓ Branch 61 → 62 taken 611 times.
|
611 | value = QString::fromLatin1(line.mid(colon + 1)); |
| 54 | 2747 | } | |
| 55 |
1/1✓ Branch 73 → 74 taken 933 times.
|
933 | flush(); |
| 56 |
1/1✓ Branch 76 → 77 taken 933 times.
|
1866 | return lines.join(QLatin1Char('\n')); |
| 57 | 933 | } | |
| 58 | |||
| 59 | struct Verdict { | ||
| 60 | bool known = false; // a spam filter wrote something | ||
| 61 | bool spam = false; // it classified the message as spam | ||
| 62 | std::optional<double> score; | ||
| 63 | std::optional<double> threshold; | ||
| 64 | QString tool; // "SpamAssassin", "Rspamd" or empty | ||
| 65 | QStringList tests; // rule/symbol names, at most 40 | ||
| 66 | }; | ||
| 67 | |||
| 68 | // A finite number of the header ("nan", "inf" and overflow are no scores). | ||
| 69 | 21 | inline std::optional<double> number(const QString &text) { | |
| 70 | 21 | bool ok = false; | |
| 71 |
2/2✓ Branch 2 → 3 taken 21 times.
✓ Branch 3 → 4 taken 21 times.
|
21 | const double value = text.trimmed().toDouble(&ok); |
| 72 |
3/4✓ Branch 5 → 6 taken 20 times.
✓ Branch 5 → 9 taken 1 time.
✓ Branch 7 → 8 taken 20 times.
✗ Branch 7 → 9 not taken.
|
42 | return ok && std::isfinite(value) ? std::optional<double>(value) : std::nullopt; |
| 73 | } | ||
| 74 | |||
| 75 | // The verdict of extracted lines (see extract()). | ||
| 76 | 4010 | inline Verdict parse(const QString &lines) { | |
| 77 | 4010 | Verdict verdict; | |
| 78 |
5/5✓ Branch 5 → 6 taken 4010 times.
✓ Branch 6 → 7 taken 4010 times.
✓ Branch 7 → 8 taken 4010 times.
✓ Branch 274 → 9 taken 28 times.
✓ Branch 274 → 275 taken 4010 times.
|
4038 | for (const QString &line : lines.split(QLatin1Char('\n'), Qt::SkipEmptyParts)) { |
| 79 | 28 | const qsizetype colon = line.indexOf(QLatin1Char(':')); | |
| 80 |
1/2✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 28 times.
|
28 | if (colon <= 0) continue; |
| 81 |
1/1✓ Branch 15 → 16 taken 28 times.
|
28 | const QString name = line.left(colon); |
| 82 |
2/2✓ Branch 16 → 17 taken 28 times.
✓ Branch 17 → 18 taken 28 times.
|
28 | const QString value = line.mid(colon + 1).trimmed(); |
| 83 | 28 | verdict.known = true; | |
| 84 |
2/2✓ Branch 21 → 22 taken 9 times.
✓ Branch 21 → 118 taken 19 times.
|
28 | if (name == QLatin1String("x-spam-status")) { |
| 85 | // "Yes, score=7.2 required=5.0 tests=BAYES_99,URIBL_BLOCKED autolearn=no" | ||
| 86 | 9 | verdict.tool = QStringLiteral("SpamAssassin"); | |
| 87 |
5/5✓ Branch 28 → 29 taken 6 times.
✓ Branch 28 → 32 taken 3 times.
✓ Branch 30 → 31 taken 6 times.
✓ Branch 31 → 32 taken 4 times.
✓ Branch 31 → 33 taken 2 times.
|
9 | verdict.spam = verdict.spam || value.startsWith(QLatin1String("yes"), Qt::CaseInsensitive); |
| 88 |
4/7✓ Branch 34 → 35 taken 4 times.
✓ Branch 34 → 47 taken 5 times.
✓ Branch 36 → 37 taken 4 times.
✗ Branch 36 → 47 not taken.
✓ Branch 41 → 42 taken 4 times.
✗ Branch 292 → 293 not taken.
✗ Branch 292 → 294 not taken.
|
13 | static const QRegularExpression score(QStringLiteral(R"(\bscore=(-?[0-9.]+))")); |
| 89 |
4/7✓ Branch 47 → 48 taken 4 times.
✓ Branch 47 → 60 taken 5 times.
✓ Branch 49 → 50 taken 4 times.
✗ Branch 49 → 60 not taken.
✓ Branch 54 → 55 taken 4 times.
✗ Branch 302 → 303 not taken.
✗ Branch 302 → 304 not taken.
|
13 | static const QRegularExpression required(QStringLiteral(R"(\brequired=(-?[0-9.]+))")); |
| 90 |
4/7✓ Branch 60 → 61 taken 4 times.
✓ Branch 60 → 73 taken 5 times.
✓ Branch 62 → 63 taken 4 times.
✗ Branch 62 → 73 not taken.
✓ Branch 67 → 68 taken 4 times.
✗ Branch 312 → 313 not taken.
✗ Branch 312 → 314 not taken.
|
13 | static const QRegularExpression tests(QStringLiteral(R"(\btests=([A-Za-z0-9_,\s]+?)(?:\s+\w+=|$))")); |
| 91 |
6/6✓ Branch 74 → 75 taken 9 times.
✓ Branch 75 → 76 taken 9 times.
✓ Branch 76 → 77 taken 6 times.
✓ Branch 76 → 81 taken 3 times.
✓ Branch 77 → 78 taken 6 times.
✓ Branch 78 → 79 taken 6 times.
|
9 | if (const auto m = score.match(value); m.hasMatch()) verdict.score = number(m.captured(1)); |
| 92 |
6/6✓ Branch 83 → 84 taken 9 times.
✓ Branch 84 → 85 taken 9 times.
✓ Branch 85 → 86 taken 5 times.
✓ Branch 85 → 90 taken 4 times.
✓ Branch 86 → 87 taken 5 times.
✓ Branch 87 → 88 taken 5 times.
|
9 | if (const auto m = required.match(value); m.hasMatch()) verdict.threshold = number(m.captured(1)); |
| 93 |
4/4✓ Branch 92 → 93 taken 9 times.
✓ Branch 93 → 94 taken 9 times.
✓ Branch 94 → 95 taken 5 times.
✓ Branch 94 → 116 taken 4 times.
|
9 | if (const auto m = tests.match(value); m.hasMatch()) |
| 94 |
6/6✓ Branch 95 → 96 taken 5 times.
✓ Branch 99 → 100 taken 5 times.
✓ Branch 101 → 102 taken 5 times.
✓ Branch 102 → 103 taken 5 times.
✓ Branch 113 → 104 taken 6 times.
✓ Branch 113 → 114 taken 5 times.
|
11 | for (const QString &test : m.captured(1).split(QLatin1Char(','), Qt::SkipEmptyParts)) |
| 95 |
3/4✓ Branch 106 → 107 taken 6 times.
✗ Branch 106 → 111 not taken.
✓ Branch 107 → 108 taken 6 times.
✓ Branch 108 → 109 taken 6 times.
|
20 | if (verdict.tests.size() < 40) verdict.tests << test.trimmed(); |
| 96 |
6/6✓ Branch 120 → 121 taken 13 times.
✓ Branch 120 → 124 taken 6 times.
✓ Branch 123 → 124 taken 4 times.
✓ Branch 123 → 125 taken 9 times.
✓ Branch 126 → 127 taken 10 times.
✓ Branch 126 → 134 taken 9 times.
|
19 | } else if (name == QLatin1String("x-spam-flag") || name == QLatin1String("x-spam")) { |
| 97 |
4/5✓ Branch 127 → 128 taken 10 times.
✗ Branch 127 → 131 not taken.
✓ Branch 129 → 130 taken 10 times.
✓ Branch 130 → 131 taken 8 times.
✓ Branch 130 → 132 taken 2 times.
|
10 | verdict.spam = verdict.spam || value.startsWith(QLatin1String("yes"), Qt::CaseInsensitive); |
| 98 |
5/6✓ Branch 136 → 137 taken 2 times.
✓ Branch 136 → 140 taken 7 times.
✓ Branch 138 → 139 taken 2 times.
✗ Branch 138 → 140 not taken.
✓ Branch 141 → 142 taken 2 times.
✓ Branch 141 → 143 taken 7 times.
|
9 | } else if (name == QLatin1String("x-spam-score") && !verdict.score) { |
| 99 |
1/1✓ Branch 142 → 268 taken 2 times.
|
2 | verdict.score = number(value); |
| 100 |
5/8✓ Branch 145 → 146 taken 1 time.
✓ Branch 145 → 156 taken 6 times.
✓ Branch 147 → 148 taken 1 time.
✗ Branch 147 → 156 not taken.
✓ Branch 149 → 150 taken 1 time.
✗ Branch 149 → 156 not taken.
✓ Branch 154 → 155 taken 1 time.
✗ Branch 154 → 156 not taken.
|
8 | } else if (name == QLatin1String("x-spam-level") && !verdict.score && !value.isEmpty() && |
| 101 |
3/3✓ Branch 152 → 153 taken 1 time.
✓ Branch 157 → 158 taken 1 time.
✓ Branch 157 → 161 taken 6 times.
|
8 | value.count(QLatin1Char('*')) == value.size()) { |
| 102 | 1 | verdict.score = value.size(); | |
| 103 |
2/2✓ Branch 163 → 164 taken 4 times.
✓ Branch 163 → 232 taken 2 times.
|
6 | } else if (name == QLatin1String("x-spamd-result")) { |
| 104 | // "default: False [1.20 / 15.00]; R_SPF_ALLOW(-0.20)[+ip4]; MIME_GOOD(-0.10)[text/plain]" | ||
| 105 | 4 | verdict.tool = QStringLiteral("Rspamd"); | |
| 106 |
3/5✓ Branch 170 → 171 taken 4 times.
✗ Branch 170 → 174 not taken.
✓ Branch 172 → 173 taken 4 times.
✗ Branch 173 → 174 not taken.
✓ Branch 173 → 175 taken 4 times.
|
4 | verdict.spam = verdict.spam || value.contains(QLatin1String(": true"), Qt::CaseInsensitive); |
| 107 |
4/7✓ Branch 176 → 177 taken 2 times.
✓ Branch 176 → 189 taken 2 times.
✓ Branch 178 → 179 taken 2 times.
✗ Branch 178 → 189 not taken.
✓ Branch 183 → 184 taken 2 times.
✗ Branch 357 → 358 not taken.
✗ Branch 357 → 359 not taken.
|
6 | static const QRegularExpression range(QStringLiteral(R"(\[\s*(-?[0-9.]+)\s*/\s*(-?[0-9.]+)\s*\])")); |
| 108 |
3/4✓ Branch 190 → 191 taken 4 times.
✓ Branch 191 → 192 taken 4 times.
✓ Branch 192 → 193 taken 4 times.
✗ Branch 192 → 200 not taken.
|
4 | if (const auto m = range.match(value); m.hasMatch()) { |
| 109 |
2/2✓ Branch 193 → 194 taken 4 times.
✓ Branch 194 → 195 taken 4 times.
|
4 | verdict.score = number(m.captured(1)); |
| 110 |
2/2✓ Branch 196 → 197 taken 4 times.
✓ Branch 197 → 198 taken 4 times.
|
4 | verdict.threshold = number(m.captured(2)); |
| 111 | 4 | } | |
| 112 |
4/7✓ Branch 201 → 202 taken 2 times.
✓ Branch 201 → 214 taken 2 times.
✓ Branch 203 → 204 taken 2 times.
✗ Branch 203 → 214 not taken.
✓ Branch 208 → 209 taken 2 times.
✗ Branch 377 → 378 not taken.
✗ Branch 377 → 379 not taken.
|
6 | static const QRegularExpression symbol(QStringLiteral(R"(;\s*([A-Z0-9_]+)\()")); |
| 113 |
7/8✓ Branch 215 → 216 taken 4 times.
✓ Branch 223 → 224 taken 61 times.
✓ Branch 224 → 225 taken 57 times.
✓ Branch 224 → 228 taken 4 times.
✓ Branch 226 → 227 taken 57 times.
✗ Branch 226 → 228 not taken.
✓ Branch 229 → 217 taken 57 times.
✓ Branch 229 → 230 taken 4 times.
|
61 | for (auto it = symbol.globalMatch(value); it.hasNext() && verdict.tests.size() < 40;) |
| 114 |
3/3✓ Branch 217 → 218 taken 57 times.
✓ Branch 218 → 219 taken 57 times.
✓ Branch 219 → 220 taken 57 times.
|
61 | verdict.tests << it.next().captured(1); |
| 115 |
1/2✗ Branch 234 → 235 not taken.
✓ Branch 234 → 244 taken 2 times.
|
2 | } else if (name == QLatin1String("x-rspamd-score")) { |
| 116 | ✗ | verdict.tool = QStringLiteral("Rspamd"); | |
| 117 | ✗ | if (!verdict.score) verdict.score = number(value); | |
| 118 |
1/2✓ Branch 246 → 247 taken 2 times.
✗ Branch 246 → 268 not taken.
|
2 | } else if (name == QLatin1String("x-rspamd-action")) { |
| 119 | 2 | verdict.tool = QStringLiteral("Rspamd"); | |
| 120 |
1/1✓ Branch 253 → 254 taken 2 times.
|
2 | const QString action = value.toLower(); |
| 121 |
3/4✓ Branch 257 → 258 taken 1 time.
✓ Branch 257 → 264 taken 1 time.
✓ Branch 260 → 261 taken 1 time.
✗ Branch 260 → 264 not taken.
|
3 | verdict.spam = verdict.spam || action == QLatin1String("add header") || |
| 122 |
2/4✓ Branch 254 → 255 taken 2 times.
✗ Branch 254 → 264 not taken.
✗ Branch 263 → 264 not taken.
✓ Branch 263 → 265 taken 1 time.
|
5 | action == QLatin1String("rewrite subject") || action == QLatin1String("reject"); |
| 123 | 2 | } | |
| 124 | 4038 | } | |
| 125 | 4010 | return verdict; | |
| 126 | ✗ | } | |
| 127 | |||
| 128 | } // namespace SpamHeaders | ||
| 129 |