service/ImapConnectionSetup.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #include "ImapConnectionSetup.h" | ||
| 2 | |||
| 3 | #include "ImapCommandTable.h" | ||
| 4 | #include "util/SecureUtil.h" | ||
| 5 | |||
| 6 | #include <QLoggingCategory> | ||
| 7 | #include <QRegularExpression> | ||
| 8 | #include <QSslSocket> | ||
| 9 | #include <QTimer> | ||
| 10 | |||
| 11 | #include <utility> | ||
| 12 | |||
| 13 | // T-720: TCP keepalive platform headers. | ||
| 14 | #ifdef Q_OS_LINUX | ||
| 15 | #include <sys/socket.h> | ||
| 16 | #include <netinet/in.h> | ||
| 17 | #include <netinet/tcp.h> | ||
| 18 | #endif | ||
| 19 | #ifdef Q_OS_WIN | ||
| 20 | #include <winsock2.h> | ||
| 21 | #include <ws2tcpip.h> | ||
| 22 | #include <mstcpip.h> | ||
| 23 | #endif | ||
| 24 | #ifdef Q_OS_MACOS | ||
| 25 | #include <sys/socket.h> | ||
| 26 | #include <netinet/in.h> | ||
| 27 | #include <netinet/tcp.h> | ||
| 28 | #endif | ||
| 29 | |||
| 30 | Q_DECLARE_LOGGING_CATEGORY(lcImap) | ||
| 31 | |||
| 32 | 1401 | ImapConnectionSetup::ImapConnectionSetup(QObject *owner, QSslSocket *socket, | |
| 33 | ImapCommandTable &commands, | ||
| 34 | 1401 | QStringList &capabilities, Deps deps) | |
| 35 | 1401 | : m_socket(socket), m_commands(commands), m_capabilities(capabilities), | |
| 36 |
4/7✓ Branch 4 → 5 taken 1401 times.
✓ Branch 5 → 6 taken 1401 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 1401 times.
✓ Branch 8 → 9 taken 1401 times.
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 13 not taken.
|
1401 | m_deps(std::move(deps)), m_timeoutTimer(new QTimer(owner)) {} |
| 37 | |||
| 38 | 111 | void ImapConnectionSetup::tuneKeepAlive() { | |
| 39 |
2/2✓ Branch 2 → 3 taken 51 times.
✓ Branch 2 → 4 taken 60 times.
|
111 | if (m_keepAliveTuned) |
| 40 | 53 | return; | |
| 41 | |||
| 42 | // The Qt-level option (set in the constructor) is assertable on an | ||
| 43 | // unconnected socket; here we tune the native intervals once the | ||
| 44 | // socket descriptor is valid. | ||
| 45 |
1/1✓ Branch 4 → 5 taken 60 times.
|
60 | qintptr fd = m_socket->socketDescriptor(); |
| 46 |
2/2✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 17 taken 58 times.
|
60 | if (fd < 0) { |
| 47 |
5/5✓ Branch 6 → 7 taken 2 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 15 → 8 taken 2 times.
✓ Branch 15 → 16 taken 2 times.
|
4 | qCDebug(lcImap) << "tuneKeepAlive: no valid socket descriptor yet"; |
| 48 | 2 | return; | |
| 49 | } | ||
| 50 | |||
| 51 | 58 | m_keepAliveTuned = true; | |
| 52 | |||
| 53 | #ifdef Q_OS_LINUX | ||
| 54 | // Linux: TCP_KEEPIDLE=60, TCP_KEEPINTVL=30, TCP_KEEPCNT=3. | ||
| 55 | // Worst-case detection ~ 60 + 3*30 = 150 s with zero app traffic. | ||
| 56 | 58 | const int idle = 60; | |
| 57 | 58 | const int intvl = 30; | |
| 58 | 58 | const int cnt = 3; | |
| 59 | 58 | ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPIDLE, &idle, | |
| 60 | sizeof(idle)); | ||
| 61 | 58 | ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPINTVL, &intvl, | |
| 62 | sizeof(intvl)); | ||
| 63 | 58 | ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPCNT, &cnt, | |
| 64 | sizeof(cnt)); | ||
| 65 |
5/5✓ Branch 20 → 21 taken 58 times.
✓ Branch 24 → 25 taken 58 times.
✓ Branch 25 → 26 taken 58 times.
✓ Branch 29 → 22 taken 58 times.
✓ Branch 29 → 30 taken 58 times.
|
116 | qCInfo(lcImap) << "TCP keepalive tuned (Linux): idle=60s intvl=30s cnt=3"; |
| 66 | #elif defined(Q_OS_MACOS) | ||
| 67 | // macOS: single TCP_KEEPALIVE interval (seconds). Mirror the Linux | ||
| 68 | // worst-case budget of ~150 s by setting an idle probe interval. | ||
| 69 | const int macIdle = 60; | ||
| 70 | ::setsockopt(static_cast<int>(fd), IPPROTO_TCP, TCP_KEEPALIVE, &macIdle, | ||
| 71 | sizeof(macIdle)); | ||
| 72 | qCInfo(lcImap) << "TCP keepalive tuned (macOS): interval=60s"; | ||
| 73 | #elif defined(Q_OS_WIN) | ||
| 74 | // Windows: SIO_KEEPALIVE_VALS ioctl. onoff=1, idle=60000ms, intvl=30000ms. | ||
| 75 | tcp_keepalive ka; | ||
| 76 | ka.onoff = 1; | ||
| 77 | ka.keepalivetime = 60000; | ||
| 78 | ka.keepaliveinterval = 30000; | ||
| 79 | DWORD bytesReturned = 0; | ||
| 80 | ::WSAIoctl(static_cast<SOCKET>(fd), SIO_KEEPALIVE_VALS, &ka, sizeof(ka), | ||
| 81 | nullptr, 0, &bytesReturned, nullptr, nullptr); | ||
| 82 | qCInfo(lcImap) << "TCP keepalive tuned (Windows): idle=60000ms intvl=30000ms"; | ||
| 83 | #else | ||
| 84 | qCInfo(lcImap) << "TCP keepalive tuning not implemented on this platform;" | ||
| 85 | << "relying on OS defaults"; | ||
| 86 | #endif | ||
| 87 | } | ||
| 88 | |||
| 89 | 2637 | void ImapConnectionSetup::clearCredentials() { | |
| 90 | 2637 | SecureUtil::zeroMemory(m_config.password); | |
| 91 | 2637 | m_config.username.clear(); | |
| 92 | 2637 | } | |
| 93 | |||
| 94 | 51 | bool ImapConnectionSetup::beginLogin() { | |
| 95 |
3/3✓ Branch 2 → 3 taken 51 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 21 taken 50 times.
|
51 | if (!m_socket->isEncrypted()) { |
| 96 |
5/5✓ Branch 4 → 5 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 13 → 6 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
|
2 | qCWarning(lcImap) << "Refusing IMAP LOGIN over unencrypted connection"; |
| 97 |
1/1✓ Branch 17 → 18 taken 1 time.
|
1 | failConnection(QStringLiteral("Connection is not encrypted")); |
| 98 | 1 | return false; | |
| 99 | } | ||
| 100 | // F-37 (SPRINT-104): LOGINDISABLED says the server refuses the LOGIN | ||
| 101 | // command on this connection — sending it would leak the credentials | ||
| 102 | // to a rejection (and some servers drop the connection). | ||
| 103 |
2/3✓ Branch 21 → 22 taken 50 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 42 taken 50 times.
|
50 | if (m_capabilities.contains("LOGINDISABLED", Qt::CaseInsensitive)) { |
| 104 | ✗ | qCWarning(lcImap) << "Server advertises LOGINDISABLED — refusing LOGIN"; | |
| 105 | ✗ | failConnection(QStringLiteral( | |
| 106 | "The server refuses login on this connection (LOGINDISABLED); " | ||
| 107 | "it requires an encrypted connection")); | ||
| 108 | ✗ | return false; | |
| 109 | } | ||
| 110 | |||
| 111 | // S106-007: LOGIN takes quoted strings — 7-bit text without CR, LF or | ||
| 112 | // NUL (RFC 3501 §4.3). quoteImapString() strips CR/LF/NUL, so such a | ||
| 113 | // password could never sign in, and non-ASCII depends on the server. | ||
| 114 | // Credentials outside plain ASCII go through AUTHENTICATE PLAIN (RFC 4616, | ||
| 115 | // UTF-8 by definition) with an initial response (SASL-IR) when offered; | ||
| 116 | // a password with CR/LF/NUL that no mechanism can carry is reported | ||
| 117 | // instead of being silently changed. | ||
| 118 |
1/1✓ Branch 42 → 43 taken 50 times.
|
50 | const QString password = QString::fromUtf8(m_config.password); |
| 119 | 99 | const auto plainAscii = [](const QString &text) { | |
| 120 |
2/2✓ Branch 13 → 4 taken 982 times.
✓ Branch 13 → 14 taken 97 times.
|
1079 | for (const QChar c : text) |
| 121 |
6/6✓ Branch 5 → 6 taken 981 times.
✓ Branch 5 → 8 taken 1 time.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 980 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 12 taken 980 times.
|
982 | if (c.unicode() < 0x20 || c.unicode() > 0x7e) return false; |
| 122 | 97 | return true; | |
| 123 | }; | ||
| 124 |
4/4✓ Branch 44 → 45 taken 49 times.
✓ Branch 44 → 48 taken 1 time.
✓ Branch 46 → 47 taken 48 times.
✓ Branch 46 → 48 taken 1 time.
|
50 | const bool loginCarries = plainAscii(m_config.username) && plainAscii(password); |
| 125 |
4/6✓ Branch 53 → 54 taken 1 time.
✓ Branch 53 → 60 taken 49 times.
✓ Branch 58 → 59 taken 1 time.
✗ Branch 58 → 60 not taken.
✓ Branch 67 → 68 taken 50 times.
✗ Branch 67 → 69 not taken.
|
151 | const bool saslPlain = m_capabilities.contains(QStringLiteral("AUTH=PLAIN"), Qt::CaseInsensitive) && |
| 126 |
5/6✓ Branch 61 → 62 taken 1 time.
✓ Branch 61 → 63 taken 49 times.
✓ Branch 63 → 64 taken 1 time.
✓ Branch 63 → 65 taken 49 times.
✓ Branch 65 → 66 taken 50 times.
✗ Branch 65 → 67 not taken.
|
52 | m_capabilities.contains(QStringLiteral("SASL-IR"), Qt::CaseInsensitive); |
| 127 |
4/6✓ Branch 70 → 71 taken 50 times.
✓ Branch 71 → 72 taken 50 times.
✗ Branch 71 → 74 not taken.
✓ Branch 72 → 73 taken 50 times.
✗ Branch 73 → 74 not taken.
✓ Branch 73 → 75 taken 50 times.
|
50 | const bool hasNul = m_config.username.contains(QChar(0)) || m_config.password.contains('\0'); |
| 128 |
1/1✓ Branch 78 → 79 taken 50 times.
|
50 | const bool hasLineBreak = m_config.username.contains(QLatin1Char('\r')) || |
| 129 |
2/3✓ Branch 82 → 83 taken 50 times.
✓ Branch 83 → 84 taken 50 times.
✗ Branch 83 → 88 not taken.
|
50 | m_config.username.contains(QLatin1Char('\n')) || |
| 130 |
7/8✓ Branch 79 → 80 taken 50 times.
✗ Branch 79 → 88 not taken.
✓ Branch 84 → 85 taken 50 times.
✓ Branch 85 → 86 taken 49 times.
✓ Branch 85 → 88 taken 1 time.
✓ Branch 86 → 87 taken 49 times.
✓ Branch 87 → 88 taken 1 time.
✓ Branch 87 → 89 taken 48 times.
|
100 | m_config.password.contains('\r') || m_config.password.contains('\n'); |
| 131 |
6/8✓ Branch 90 → 91 taken 2 times.
✓ Branch 90 → 101 taken 48 times.
✓ Branch 91 → 92 taken 2 times.
✗ Branch 91 → 94 not taken.
✓ Branch 92 → 93 taken 2 times.
✗ Branch 92 → 101 not taken.
✓ Branch 93 → 94 taken 1 time.
✓ Branch 93 → 101 taken 1 time.
|
50 | if (!loginCarries && (hasNul || (hasLineBreak && !saslPlain))) { |
| 132 |
1/1✓ Branch 97 → 98 taken 1 time.
|
1 | failConnection(QStringLiteral( |
| 133 | "The user name or password contains a character the server's login cannot carry")); | ||
| 134 | 1 | return false; | |
| 135 | } | ||
| 136 | |||
| 137 |
1/1✓ Branch 101 → 102 taken 49 times.
|
49 | setState(State::Authenticating); |
| 138 | // SEC-2026-07-21-12: Re-arm for the LOGIN response wait. | ||
| 139 |
1/1✓ Branch 102 → 103 taken 49 times.
|
49 | m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS); |
| 140 |
3/4✓ Branch 103 → 104 taken 1 time.
✓ Branch 103 → 130 taken 48 times.
✓ Branch 104 → 105 taken 1 time.
✗ Branch 104 → 130 not taken.
|
49 | if (!loginCarries && saslPlain) { |
| 141 | 1 | QByteArray initial; | |
| 142 |
1/1✓ Branch 105 → 106 taken 1 time.
|
1 | initial.append('\0'); |
| 143 |
2/2✓ Branch 106 → 107 taken 1 time.
✓ Branch 107 → 108 taken 1 time.
|
1 | initial.append(m_config.username.toUtf8()); |
| 144 |
1/1✓ Branch 109 → 110 taken 1 time.
|
1 | initial.append('\0'); |
| 145 |
1/1✓ Branch 110 → 111 taken 1 time.
|
1 | initial.append(m_config.password); |
| 146 | // The tagged answer is handled like LOGIN's (same command record type). | ||
| 147 |
2/2✓ Branch 118 → 119 taken 1 time.
✓ Branch 119 → 120 taken 1 time.
|
3 | m_commands.sendCommand("LOGIN", QStringLiteral("AUTHENTICATE PLAIN ") + |
| 148 |
3/3✓ Branch 112 → 113 taken 1 time.
✓ Branch 113 → 114 taken 1 time.
✓ Branch 117 → 118 taken 1 time.
|
3 | QString::fromLatin1(initial.toBase64())); |
| 149 |
1/1✓ Branch 127 → 128 taken 1 time.
|
1 | SecureUtil::zeroMemory(initial); |
| 150 | 1 | } else { | |
| 151 |
3/3✓ Branch 130 → 131 taken 48 times.
✓ Branch 134 → 135 taken 48 times.
✓ Branch 135 → 136 taken 48 times.
|
144 | m_commands.sendCommand("LOGIN", QString("LOGIN %1 %2") |
| 152 |
2/2✓ Branch 132 → 133 taken 48 times.
✓ Branch 133 → 134 taken 48 times.
|
144 | .arg(ImapService::quoteImapString(m_config.username), |
| 153 |
1/1✓ Branch 131 → 132 taken 48 times.
|
96 | ImapService::quoteImapString(password))); |
| 154 | } | ||
| 155 | // The socket owns any bytes still queued for transmission. The service does | ||
| 156 | // not need another plaintext credential copy while awaiting the response. | ||
| 157 |
1/1✓ Branch 143 → 144 taken 49 times.
|
49 | clearCredentials(); |
| 158 | 49 | return true; | |
| 159 | 50 | } | |
| 160 | |||
| 161 | 58 | void ImapConnectionSetup::onConnected() { | |
| 162 |
5/5✓ Branch 2 → 3 taken 58 times.
✓ Branch 6 → 7 taken 58 times.
✓ Branch 7 → 8 taken 58 times.
✓ Branch 11 → 4 taken 58 times.
✓ Branch 11 → 12 taken 58 times.
|
116 | qCInfo(lcImap) << "TCP connected"; |
| 163 | #ifdef MAILJD_E2E_TESTING | ||
| 164 |
1/1✓ Branch 15 → 16 taken 47 times.
|
47 | reportE2eTransportPhase(QStringLiteral("tcp-connected")); |
| 165 | #endif | ||
| 166 | // SEC-2026-07-21-12: Re-arm the timer for the greeting wait phase instead | ||
| 167 | // of stopping it. A silent server that accepts the TCP connection but | ||
| 168 | // never sends a greeting would otherwise stall indefinitely. | ||
| 169 | 58 | m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS); | |
| 170 | // T-720/T-72.1: Now that the socket descriptor is valid, tune the | ||
| 171 | // native TCP keepalive intervals. Idempotent — sets m_keepAliveTuned. | ||
| 172 | 58 | tuneKeepAlive(); | |
| 173 | |||
| 174 | // For STARTTLS — and S106-006: for any other configured value — wait for | ||
| 175 | // the greeting, then upgrade (F-37 prefers STARTTLS; LOGIN refuses an | ||
| 176 | // unencrypted connection). Only implicit SSL waits for onEncrypted. An | ||
| 177 | // unknown value used to never leave Connecting and timed out after 30 s. | ||
| 178 |
3/4✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 11 times.
✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 23 taken 44 times.
|
58 | if (m_config.security != "ssl") |
| 179 | 3 | setState(State::Connected); | |
| 180 | 58 | } | |
| 181 | |||
| 182 | 52 | void ImapConnectionSetup::onEncrypted() { | |
| 183 |
5/5✓ Branch 2 → 3 taken 52 times.
✓ Branch 6 → 7 taken 52 times.
✓ Branch 7 → 8 taken 52 times.
✓ Branch 11 → 4 taken 52 times.
✓ Branch 11 → 12 taken 52 times.
|
104 | qCInfo(lcImap) << "TLS established"; |
| 184 | #ifdef MAILJD_E2E_TESTING | ||
| 185 |
1/1✓ Branch 15 → 16 taken 42 times.
|
42 | reportE2eTransportPhase(QStringLiteral("tls-established")); |
| 186 | #endif | ||
| 187 | // SEC-2026-07-21-12: Re-arm for greeting wait (implicit-SSL path) or | ||
| 188 | // post-STARTTLS handshake — the latter is covered by this re-arm because | ||
| 189 | // onEncrypted fires for both implicit-SSL and STARTTLS upgrades. | ||
| 190 | 52 | m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS); | |
| 191 | // T-720/T-72.1: For implicit-SSL connections onConnected() may not have | ||
| 192 | // run (Qt emits encrypted directly). Tune here too — tuneKeepAlive() is | ||
| 193 | // idempotent via the m_keepAliveTuned flag. | ||
| 194 | 52 | tuneKeepAlive(); | |
| 195 | |||
| 196 |
3/4✗ Branch 14 → 15 not taken.
✓ Branch 14 → 31 taken 10 times.
✓ Branch 20 → 21 taken 3 times.
✓ Branch 20 → 37 taken 39 times.
|
52 | if (m_setupPhase == SetupPhase::StartingTls) { |
| 197 | // RFC 3501 §6.2.1: discard every feature learned on the unauthenticated | ||
| 198 | // plaintext channel. Only a complete post-handshake CAPABILITY exchange | ||
| 199 | // may influence authentication and the resulting session. | ||
| 200 | 3 | m_capabilities.clear(); | |
| 201 | 3 | m_pendingCapabilities.clear(); | |
| 202 | 3 | m_setupPhase = SetupPhase::PostTlsCapability; | |
| 203 | 3 | setState(State::Capability); | |
| 204 |
1/2✗ Branch 24 → 25 not taken.
✓ Branch 30 → 31 taken 3 times.
|
9 | m_commands.sendCommand(QStringLiteral("CAPABILITY"), QStringLiteral("CAPABILITY")); |
| 205 | 3 | return; | |
| 206 | } | ||
| 207 | |||
| 208 | 49 | setState(State::Connected); | |
| 209 | } | ||
| 210 | 54 | void ImapConnectionSetup::handleGreeting(const QString &data, bool preAuthenticated) { | |
| 211 | // Server greeting | ||
| 212 |
9/9✓ Branch 2 → 3 taken 54 times.
✓ Branch 6 → 7 taken 54 times.
✓ Branch 7 → 8 taken 54 times.
✓ Branch 8 → 9 taken 54 times.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 11 taken 52 times.
✓ Branch 12 → 13 taken 54 times.
✓ Branch 16 → 4 taken 54 times.
✓ Branch 16 → 17 taken 54 times.
|
108 | qCInfo(lcImap) << "Server greeting:" << data << (preAuthenticated ? "(PREAUTH)" : ""); |
| 213 | #ifdef MAILJD_E2E_TESTING | ||
| 214 |
1/1✓ Branch 20 → 21 taken 44 times.
|
44 | reportE2eTransportPhase(QStringLiteral("greeting-received")); |
| 215 | #endif | ||
| 216 | // S106-005: PREAUTH starts the session already authenticated (RFC 3501 | ||
| 217 | // §7.1.4) — LOGIN is invalid there, and its NO was misread as a rejected | ||
| 218 | // password. STARTTLS cannot follow (authenticated state), so a PREAUTH on | ||
| 219 | // an unencrypted connection is refused: it is also the STARTTLS-stripping | ||
| 220 | // pattern of a man in the middle. | ||
| 221 |
8/12✗ Branch 17 → 18 not taken.
✓ Branch 17 → 21 taken 10 times.
✗ Branch 19 → 20 not taken.
✗ Branch 19 → 21 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 30 taken 10 times.
✓ Branch 23 → 24 taken 2 times.
✓ Branch 23 → 27 taken 42 times.
✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 27 taken 1 time.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 36 taken 43 times.
|
54 | if (preAuthenticated && !m_socket->isEncrypted()) { |
| 222 |
1/2✗ Branch 26 → 27 not taken.
✓ Branch 32 → 33 taken 1 time.
|
1 | failConnection(QStringLiteral( |
| 223 | "The server pre-authenticated an unencrypted connection — refused")); | ||
| 224 | 1 | return; | |
| 225 | } | ||
| 226 | 53 | setState(State::Greeting); | |
| 227 | // SEC-2026-07-21-12: Re-arm for the CAPABILITY/STARTTLS phase. | ||
| 228 | 53 | m_timeoutTimer->start(SESSION_SETUP_TIMEOUT_MS); | |
| 229 | |||
| 230 | // Request capabilities | ||
| 231 |
3/4✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 10 times.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 40 taken 42 times.
|
53 | m_setupPhase = preAuthenticated ? SetupPhase::PreAuthCapability : SetupPhase::InitialCapability; |
| 232 | 53 | m_pendingCapabilities.clear(); | |
| 233 |
6/6✓ Branch 36 → 37 taken 10 times.
✓ Branch 37 → 38 taken 10 times.
✓ Branch 38 → 39 taken 10 times.
✓ Branch 42 → 43 taken 43 times.
✓ Branch 43 → 44 taken 43 times.
✓ Branch 44 → 45 taken 43 times.
|
53 | m_commands.sendCommand("CAPABILITY", "CAPABILITY"); |
| 234 | } | ||
| 235 | |||
| 236 | 58 | void ImapConnectionSetup::collectCapabilities(const QString &data) { | |
| 237 | // Also collected while LOGIN runs: Dovecot answers a successful LOGIN | ||
| 238 | // with an untagged post-login CAPABILITY instead of a response code. | ||
| 239 |
6/11✓ Branch 5 → 6 taken 58 times.
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 12 taken 56 times.
✓ Branch 20 → 21 taken 58 times.
✗ Branch 20 → 22 not taken.
✓ Branch 22 → 23 taken 57 times.
✓ Branch 22 → 49 taken 1 time.
✗ Branch 69 → 70 not taken.
✗ Branch 69 → 71 not taken.
✗ Branch 73 → 74 not taken.
✗ Branch 73 → 75 not taken.
|
176 | if (m_commands.hasCommandType(QStringLiteral("CAPABILITY")) || |
| 240 |
8/13✓ Branch 10 → 11 taken 2 times.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 1 time.
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 16 taken 56 times.
✓ Branch 16 → 17 taken 2 times.
✓ Branch 16 → 18 taken 56 times.
✓ Branch 18 → 19 taken 58 times.
✗ Branch 18 → 20 not taken.
✗ Branch 61 → 62 not taken.
✗ Branch 61 → 63 not taken.
✗ Branch 65 → 66 not taken.
✗ Branch 65 → 67 not taken.
|
62 | m_commands.hasCommandType(QStringLiteral("LOGIN"))) { |
| 241 |
1/1✓ Branch 23 → 24 taken 57 times.
|
57 | const QStringList parsed = ImapResponseParser::parseCapabilities(data); |
| 242 |
2/2✓ Branch 32 → 26 taken 109 times.
✓ Branch 32 → 33 taken 57 times.
|
166 | for (const QString &capability : parsed) { |
| 243 |
2/2✓ Branch 28 → 29 taken 108 times.
✓ Branch 28 → 30 taken 1 time.
|
109 | if (!m_pendingCapabilities.contains(capability, Qt::CaseInsensitive)) |
| 244 |
1/1✓ Branch 29 → 30 taken 108 times.
|
108 | m_pendingCapabilities.append(capability); |
| 245 | } | ||
| 246 |
6/6✓ Branch 33 → 34 taken 57 times.
✓ Branch 37 → 38 taken 57 times.
✓ Branch 38 → 39 taken 57 times.
✓ Branch 40 → 41 taken 57 times.
✓ Branch 46 → 35 taken 57 times.
✓ Branch 46 → 47 taken 57 times.
|
114 | qCInfo(lcImap) << "Pending capabilities:" << m_pendingCapabilities; |
| 247 | 57 | } else { | |
| 248 |
5/5✓ Branch 49 → 50 taken 1 time.
✓ Branch 53 → 54 taken 1 time.
✓ Branch 54 → 55 taken 1 time.
✓ Branch 58 → 51 taken 1 time.
✓ Branch 58 → 59 taken 1 time.
|
2 | qCWarning(lcImap) << "Ignoring unsolicited CAPABILITY outside negotiation"; |
| 249 | } | ||
| 250 | 58 | } | |
| 251 | |||
| 252 | 63 | void ImapConnectionSetup::handleTaggedCapability(const TaggedResponse &response) { | |
| 253 |
2/2✓ Branch 3 → 4 taken 60 times.
✓ Branch 3 → 113 taken 3 times.
|
63 | if (response.status == "OK") { |
| 254 |
2/2✓ Branch 4 → 5 taken 7 times.
✓ Branch 4 → 14 taken 53 times.
|
60 | if (m_setupPhase != SetupPhase::InitialCapability && |
| 255 |
2/2✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 14 taken 5 times.
|
7 | m_setupPhase != SetupPhase::PostTlsCapability && |
| 256 |
2/2✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 14 taken 1 time.
|
2 | m_setupPhase != SetupPhase::PreAuthCapability) { |
| 257 |
1/1✓ Branch 10 → 11 taken 1 time.
|
1 | failConnection(QStringLiteral( |
| 258 | "CAPABILITY completed outside the connection setup phase")); | ||
| 259 | 1 | return; | |
| 260 | } | ||
| 261 |
3/4✓ Branch 32 → 33 taken 59 times.
✗ Branch 32 → 34 not taken.
✓ Branch 34 → 35 taken 2 times.
✓ Branch 34 → 43 taken 57 times.
|
118 | if (!m_pendingCapabilities.contains(QStringLiteral("IMAP4rev1"), |
| 262 |
2/2✓ Branch 18 → 19 taken 3 times.
✓ Branch 18 → 25 taken 56 times.
|
121 | Qt::CaseInsensitive) && |
| 263 |
7/8✓ Branch 23 → 24 taken 2 times.
✓ Branch 23 → 25 taken 1 time.
✓ Branch 26 → 27 taken 3 times.
✓ Branch 26 → 28 taken 56 times.
✓ Branch 28 → 29 taken 3 times.
✓ Branch 28 → 30 taken 56 times.
✓ Branch 30 → 31 taken 59 times.
✗ Branch 30 → 32 not taken.
|
65 | !m_pendingCapabilities.contains(QStringLiteral("IMAP4rev2"), |
| 264 | Qt::CaseInsensitive)) { | ||
| 265 | 2 | m_pendingCapabilities.clear(); | |
| 266 |
1/1✓ Branch 39 → 40 taken 2 times.
|
2 | failConnection(QStringLiteral("Malformed IMAP CAPABILITY response")); |
| 267 | 2 | return; | |
| 268 | } | ||
| 269 | |||
| 270 | 57 | const SetupPhase completedPhase = m_setupPhase; | |
| 271 | 57 | m_capabilities = std::exchange(m_pendingCapabilities, {}); | |
| 272 | 57 | setState(State::Capability); | |
| 273 |
2/2✓ Branch 49 → 50 taken 1 time.
✓ Branch 49 → 54 taken 56 times.
|
57 | if (completedPhase == SetupPhase::PreAuthCapability) { |
| 274 | // S106-005: pre-authenticated on an encrypted connection — the | ||
| 275 | // session is complete without LOGIN; no credential is sent. | ||
| 276 | 1 | clearCredentials(); | |
| 277 | 1 | m_setupPhase = SetupPhase::Complete; | |
| 278 | 1 | setState(State::Authenticated); | |
| 279 | 1 | m_timeoutTimer->stop(); | |
| 280 | 1 | return; | |
| 281 | } | ||
| 282 | |||
| 283 | // F-37 (SPRINT-104, D-10): STARTTLS is preferred whenever the server | ||
| 284 | // offers it — including configurations set to no explicit encryption | ||
| 285 | // (the historical default path) — and a server that advertises | ||
| 286 | // LOGINDISABLED never sees a plaintext LOGIN attempt. | ||
| 287 |
7/7✓ Branch 54 → 55 taken 52 times.
✓ Branch 54 → 65 taken 4 times.
✓ Branch 55 → 56 taken 52 times.
✓ Branch 56 → 57 taken 4 times.
✓ Branch 56 → 65 taken 48 times.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 64 taken 3 times.
|
61 | if (completedPhase == SetupPhase::InitialCapability && !m_socket->isEncrypted() && |
| 288 | 4 | (m_config.security == "starttls" || | |
| 289 |
7/11✓ Branch 60 → 61 taken 1 time.
✗ Branch 60 → 65 not taken.
✓ Branch 61 → 62 taken 1 time.
✓ Branch 63 → 64 taken 1 time.
✗ Branch 63 → 65 not taken.
✓ Branch 66 → 67 taken 1 time.
✓ Branch 66 → 68 taken 55 times.
✓ Branch 68 → 69 taken 4 times.
✓ Branch 68 → 99 taken 52 times.
✗ Branch 130 → 131 not taken.
✗ Branch 130 → 132 not taken.
|
57 | (m_config.security != "ssl" && m_capabilities.contains("STARTTLS", Qt::CaseInsensitive)))) { |
| 290 |
3/3✓ Branch 69 → 70 taken 4 times.
✓ Branch 72 → 73 taken 2 times.
✓ Branch 72 → 81 taken 2 times.
|
4 | if (m_capabilities.contains("STARTTLS", Qt::CaseInsensitive)) { |
| 291 | // Need to upgrade to TLS | ||
| 292 | 2 | m_setupPhase = SetupPhase::StartingTls; | |
| 293 | 2 | setState(State::StartingTLS); | |
| 294 |
3/3✓ Branch 74 → 75 taken 2 times.
✓ Branch 75 → 76 taken 2 times.
✓ Branch 76 → 77 taken 2 times.
|
2 | m_commands.sendCommand("STARTTLS", "STARTTLS"); |
| 295 | } else { | ||
| 296 | // T-400/Bug 2: Refuse plaintext login when STARTTLS was requested | ||
| 297 |
5/5✓ Branch 81 → 82 taken 2 times.
✓ Branch 85 → 86 taken 2 times.
✓ Branch 86 → 87 taken 2 times.
✓ Branch 91 → 83 taken 2 times.
✓ Branch 91 → 92 taken 2 times.
|
4 | qCWarning(lcImap) << "STARTTLS requested but not offered by server" |
| 298 |
1/1✓ Branch 87 → 88 taken 2 times.
|
2 | << "— refusing to send credentials in plaintext"; |
| 299 |
1/1✓ Branch 95 → 96 taken 2 times.
|
2 | failConnection(QStringLiteral( |
| 300 | "Server does not support STARTTLS — login refused for security")); | ||
| 301 | } | ||
| 302 |
4/4✓ Branch 99 → 100 taken 4 times.
✓ Branch 99 → 103 taken 48 times.
✓ Branch 104 → 105 taken 2 times.
✓ Branch 104 → 112 taken 50 times.
|
56 | } else if (completedPhase == SetupPhase::PostTlsCapability && |
| 303 |
2/2✓ Branch 101 → 102 taken 2 times.
✓ Branch 101 → 103 taken 2 times.
|
4 | !m_socket->isEncrypted()) { |
| 304 |
1/1✓ Branch 108 → 109 taken 2 times.
|
2 | failConnection(QStringLiteral( |
| 305 | "Post-TLS CAPABILITY completed on an unencrypted connection")); | ||
| 306 | } else { | ||
| 307 | 50 | m_setupPhase = SetupPhase::Login; | |
| 308 | 50 | beginLogin(); | |
| 309 | } | ||
| 310 | } else { | ||
| 311 |
2/2✓ Branch 113 → 114 taken 3 times.
✓ Branch 114 → 115 taken 3 times.
|
3 | failConnection("CAPABILITY failed: " + response.message); |
| 312 | } | ||
| 313 | } | ||
| 314 | |||
| 315 | 3 | void ImapConnectionSetup::handleTaggedStartTls(const TaggedResponse &response) { | |
| 316 |
1/2✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 29 not taken.
|
3 | if (response.status == "OK") { |
| 317 |
5/6✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 8 taken 1 time.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 2 times.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 18 taken 2 times.
|
3 | if (!m_deps.readBufferEmpty() || m_socket->bytesAvailable() > 0) { |
| 318 |
1/1✓ Branch 14 → 15 taken 1 time.
|
1 | failConnection( |
| 319 | 2 | QStringLiteral("Unexpected data before TLS handshake")); | |
| 320 | 1 | return; | |
| 321 | } | ||
| 322 |
5/5✓ Branch 18 → 19 taken 2 times.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 23 → 24 taken 2 times.
✓ Branch 27 → 20 taken 2 times.
✓ Branch 27 → 28 taken 2 times.
|
4 | qCInfo(lcImap) << "STARTTLS accepted, starting TLS handshake..."; |
| 323 | 2 | m_socket->startClientEncryption(); | |
| 324 | } else { | ||
| 325 | ✗ | failConnection("STARTTLS failed: " + response.message); | |
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | 55 | void ImapConnectionSetup::handleTaggedLogin(const TaggedResponse &response) { | |
| 330 |
1/1✓ Branch 2 → 3 taken 55 times.
|
55 | clearCredentials(); |
| 331 |
2/2✓ Branch 4 → 5 taken 53 times.
✓ Branch 4 → 85 taken 2 times.
|
55 | if (response.status == "OK") { |
| 332 |
5/5✓ Branch 5 → 6 taken 53 times.
✓ Branch 9 → 10 taken 53 times.
✓ Branch 10 → 11 taken 53 times.
✓ Branch 14 → 7 taken 53 times.
✓ Branch 14 → 15 taken 53 times.
|
106 | qCInfo(lcImap) << "Login successful"; |
| 333 | // RFC 3501 7.1: the post-authentication capabilities come as a | ||
| 334 | // response code of the tagged OK or as an untagged CAPABILITY during | ||
| 335 | // LOGIN (Dovecot). Servers list CONDSTORE, MOVE, UIDPLUS or ESEARCH | ||
| 336 | // only there, not in the pre-login CAPABILITY response. | ||
| 337 | static const QRegularExpression capabilityCode( | ||
| 338 |
4/7✓ Branch 15 → 16 taken 5 times.
✓ Branch 15 → 28 taken 48 times.
✓ Branch 17 → 18 taken 5 times.
✗ Branch 17 → 28 not taken.
✓ Branch 22 → 23 taken 5 times.
✗ Branch 185 → 186 not taken.
✗ Branch 185 → 187 not taken.
|
58 | QStringLiteral(R"(^\[CAPABILITY\s+([^\]]*)\])"), QRegularExpression::CaseInsensitiveOption); |
| 339 |
2/2✓ Branch 29 → 30 taken 53 times.
✓ Branch 30 → 31 taken 53 times.
|
53 | const auto match = capabilityCode.match(response.message.trimmed()); |
| 340 |
1/1✓ Branch 32 → 33 taken 53 times.
|
53 | const QStringList authenticated = match.hasMatch() |
| 341 |
1/3✓ Branch 34 → 35 taken 18 times.
✗ Branch 196 → 197 not taken.
✗ Branch 196 → 198 not taken.
|
36 | ? ImapResponseParser::parseCapabilities(match.captured(1)) |
| 342 |
7/9✓ Branch 33 → 34 taken 18 times.
✓ Branch 33 → 36 taken 35 times.
✓ Branch 35 → 38 taken 18 times.
✓ Branch 38 → 39 taken 35 times.
✓ Branch 38 → 40 taken 18 times.
✓ Branch 40 → 41 taken 18 times.
✓ Branch 40 → 42 taken 35 times.
✗ Branch 192 → 193 not taken.
✗ Branch 192 → 194 not taken.
|
71 | : std::exchange(m_pendingCapabilities, {}); |
| 343 |
6/8✓ Branch 46 → 47 taken 34 times.
✓ Branch 46 → 52 taken 19 times.
✗ Branch 51 → 52 not taken.
✓ Branch 51 → 53 taken 34 times.
✓ Branch 60 → 61 taken 53 times.
✗ Branch 60 → 62 not taken.
✓ Branch 62 → 63 taken 19 times.
✓ Branch 62 → 79 taken 34 times.
|
193 | if (authenticated.contains(QStringLiteral("IMAP4rev1"), Qt::CaseInsensitive) || |
| 344 |
5/6✓ Branch 54 → 55 taken 34 times.
✓ Branch 54 → 56 taken 19 times.
✓ Branch 56 → 57 taken 34 times.
✓ Branch 56 → 58 taken 19 times.
✓ Branch 58 → 59 taken 53 times.
✗ Branch 58 → 60 not taken.
|
87 | authenticated.contains(QStringLiteral("IMAP4rev2"), Qt::CaseInsensitive)) { |
| 345 | 19 | m_capabilities = authenticated; | |
| 346 |
6/6✓ Branch 64 → 65 taken 19 times.
✓ Branch 68 → 69 taken 19 times.
✓ Branch 69 → 70 taken 19 times.
✓ Branch 71 → 72 taken 19 times.
✓ Branch 77 → 66 taken 19 times.
✓ Branch 77 → 78 taken 19 times.
|
38 | qCInfo(lcImap) << "Post-login capabilities:" << m_capabilities; |
| 347 | } | ||
| 348 |
1/1✓ Branch 79 → 80 taken 53 times.
|
53 | m_pendingCapabilities.clear(); |
| 349 | 53 | m_setupPhase = SetupPhase::Complete; | |
| 350 |
1/1✓ Branch 80 → 81 taken 53 times.
|
53 | setState(State::Authenticated); |
| 351 | // SEC-2026-07-21-12: Session setup complete — stop the setup timer. | ||
| 352 | // Command timeout/deadline timers take over from here. | ||
| 353 |
1/1✓ Branch 81 → 82 taken 53 times.
|
53 | m_timeoutTimer->stop(); |
| 354 | 53 | return; | |
| 355 | 53 | } | |
| 356 | // F-33 (SPRINT-104): a temporary refusal is not a rejected password. | ||
| 357 | // UNAVAILABLE (user database briefly down), LIMIT/TOOMANY (too many | ||
| 358 | // connections), SERVERBUG and CONTACTADMIN answer NO without the login | ||
| 359 | // being wrong — classifying them as AuthenticationRejected ended | ||
| 360 | // reconnecting and left MailJD offline until the user acted. These keep | ||
| 361 | // the ordinary disconnect path (the health monitor retries with | ||
| 362 | // backoff); only a real authentication failure stops the ladder. | ||
| 363 | // S106-007: BAD is a command the server could not parse (a credential | ||
| 364 | // form it does not accept) — not a rejected password; the connection is | ||
| 365 | // retried like any other failure instead of stopping as "wrong password". | ||
| 366 |
1/2✗ Branch 87 → 88 not taken.
✓ Branch 87 → 92 taken 2 times.
|
2 | if (response.status == QLatin1String("BAD")) { |
| 367 | ✗ | failConnection("Login command not accepted by the server: " + response.message); | |
| 368 | ✗ | return; | |
| 369 | } | ||
| 370 | 2 | const QString message = response.message; | |
| 371 |
1/6✓ Branch 147 → 148 taken 2 times.
✗ Branch 147 → 149 not taken.
✗ Branch 257 → 258 not taken.
✗ Branch 257 → 259 not taken.
✗ Branch 261 → 262 not taken.
✗ Branch 261 → 263 not taken.
|
4 | const bool temporary = message.contains(QStringLiteral("[UNAVAILABLE]"), Qt::CaseInsensitive) || |
| 372 |
5/11✓ Branch 101 → 102 taken 1 time.
✓ Branch 102 → 103 taken 1 time.
✗ Branch 102 → 123 not taken.
✓ Branch 143 → 144 taken 1 time.
✓ Branch 143 → 145 taken 1 time.
✓ Branch 145 → 146 taken 2 times.
✗ Branch 145 → 147 not taken.
✗ Branch 249 → 250 not taken.
✗ Branch 249 → 251 not taken.
✗ Branch 253 → 254 not taken.
✗ Branch 253 → 255 not taken.
|
3 | message.contains(QStringLiteral("[LIMIT]"), Qt::CaseInsensitive) || |
| 373 |
6/11✓ Branch 106 → 107 taken 1 time.
✓ Branch 107 → 108 taken 1 time.
✗ Branch 107 → 123 not taken.
✓ Branch 139 → 140 taken 1 time.
✓ Branch 139 → 141 taken 1 time.
✓ Branch 141 → 142 taken 1 time.
✓ Branch 141 → 143 taken 1 time.
✗ Branch 241 → 242 not taken.
✗ Branch 241 → 243 not taken.
✗ Branch 245 → 246 not taken.
✗ Branch 245 → 247 not taken.
|
3 | message.contains(QStringLiteral("[TOOMANY]"), Qt::CaseInsensitive) || |
| 374 |
6/11✓ Branch 111 → 112 taken 1 time.
✓ Branch 112 → 113 taken 1 time.
✗ Branch 112 → 123 not taken.
✓ Branch 135 → 136 taken 1 time.
✓ Branch 135 → 137 taken 1 time.
✓ Branch 137 → 138 taken 1 time.
✓ Branch 137 → 139 taken 1 time.
✗ Branch 233 → 234 not taken.
✗ Branch 233 → 235 not taken.
✗ Branch 237 → 238 not taken.
✗ Branch 237 → 239 not taken.
|
3 | message.contains(QStringLiteral("[SERVERBUG]"), Qt::CaseInsensitive) || |
| 375 |
9/14✓ Branch 96 → 97 taken 2 times.
✓ Branch 97 → 98 taken 1 time.
✓ Branch 97 → 123 taken 1 time.
✓ Branch 116 → 117 taken 1 time.
✓ Branch 117 → 118 taken 1 time.
✗ Branch 117 → 123 not taken.
✓ Branch 131 → 132 taken 1 time.
✓ Branch 131 → 133 taken 1 time.
✓ Branch 133 → 134 taken 1 time.
✓ Branch 133 → 135 taken 1 time.
✗ Branch 225 → 226 not taken.
✗ Branch 225 → 227 not taken.
✗ Branch 229 → 230 not taken.
✗ Branch 229 → 231 not taken.
|
8 | message.contains(QStringLiteral("[CONTACTADMIN]"), Qt::CaseInsensitive) || |
| 376 |
8/13✓ Branch 121 → 122 taken 1 time.
✗ Branch 122 → 123 not taken.
✓ Branch 122 → 124 taken 1 time.
✓ Branch 125 → 126 taken 1 time.
✓ Branch 125 → 127 taken 1 time.
✓ Branch 127 → 128 taken 1 time.
✓ Branch 127 → 129 taken 1 time.
✓ Branch 129 → 130 taken 1 time.
✓ Branch 129 → 131 taken 1 time.
✗ Branch 217 → 218 not taken.
✗ Branch 217 → 219 not taken.
✗ Branch 221 → 222 not taken.
✗ Branch 221 → 223 not taken.
|
3 | message.contains(QStringLiteral("try again"), Qt::CaseInsensitive); |
| 377 |
2/2✓ Branch 149 → 150 taken 1 time.
✓ Branch 149 → 165 taken 1 time.
|
2 | if (temporary) { |
| 378 |
6/6✓ Branch 150 → 151 taken 1 time.
✓ Branch 154 → 155 taken 1 time.
✓ Branch 155 → 156 taken 1 time.
✓ Branch 156 → 157 taken 1 time.
✓ Branch 160 → 152 taken 1 time.
✓ Branch 160 → 161 taken 1 time.
|
2 | qCWarning(lcImap) << "Login temporarily refused:" << message; |
| 379 |
2/2✓ Branch 161 → 162 taken 1 time.
✓ Branch 162 → 163 taken 1 time.
|
1 | failConnection("Login temporarily refused: " + message); |
| 380 | } else { | ||
| 381 |
2/2✓ Branch 165 → 166 taken 1 time.
✓ Branch 166 → 167 taken 1 time.
|
1 | failConnection("Authentication failed: " + message, |
| 382 | DisconnectReason::AuthenticationRejected); | ||
| 383 | } | ||
| 384 | 2 | } | |
| 385 |