MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - data - AccountConfig.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 83.4 % 523 436
Test Date: 2026-07-27 17:53:44 Functions: 81.2 % 32 26
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 52.5 % 1399 735

             Branch data     Line data    Source code
       1                 :             : #include "AccountConfig.h"
       2                 :             : #include "CredentialStore.h"
       3                 :             : 
       4                 :             : #include <QDir>
       5                 :             : #include <QFile>
       6                 :             : #include <QFileInfo>
       7                 :             : #include <QJsonDocument>
       8                 :             : #include <QJsonObject>
       9                 :             : #include <QEventLoop>
      10                 :             : #include <QLoggingCategory>
      11                 :             : #include <QSaveFile>
      12                 :             : #include <QStandardPaths>
      13                 :             : #include <QTimer>
      14                 :             : #include <QUuid>
      15                 :             : 
      16   [ +  +  +  -  :         562 : Q_LOGGING_CATEGORY(lcAccountConfig, "mailjd.accountconfig")
             +  -  -  - ]
      17                 :             : 
      18                 :             : namespace {
      19                 :             : 
      20                 :         240 : QString credentialName(const AccountConfig &config) {
      21         [ +  + ]:         240 :   return config.id.isEmpty() ? config.name : config.id;
      22                 :             : }
      23                 :             : 
      24                 :          42 : bool readSecretBlocking(const QString &service, const QString &accountName,
      25                 :             :                         QByteArray *password) {
      26         [ +  - ]:          42 :   CredentialStore store;
      27         [ +  - ]:          42 :   QEventLoop loop;
      28         [ +  - ]:          42 :   QTimer timeout;
      29         [ +  - ]:          42 :   timeout.setSingleShot(true);
      30                 :             : 
      31                 :          42 :   bool completed = false;
      32                 :          42 :   bool success = false;
      33         [ +  - ]:          42 :   QObject::connect(&timeout, &QTimer::timeout, &loop, [&]() {
      34         [ #  # ]:           0 :     if (completed)
      35                 :           0 :       return;
      36                 :           0 :     completed = true;
      37                 :           0 :     loop.quit();
      38                 :             :   });
      39         [ +  - ]:          42 :   store.readPassword(service, accountName,
      40         [ +  - ]:          42 :                      [&](bool ok, const QByteArray &value) {
      41         [ -  + ]:          42 :                        if (completed)
      42                 :           0 :                          return;
      43                 :          42 :                        completed = true;
      44                 :          42 :                        success = ok;
      45   [ +  +  +  - ]:          42 :                        if (ok && password)
      46                 :           4 :                          *password = value;
      47                 :          42 :                        loop.quit();
      48                 :             :                      });
      49         [ +  - ]:          42 :   timeout.start(30000);
      50         [ +  - ]:          42 :   loop.exec();
      51                 :          42 :   return success;
      52                 :          42 : }
      53                 :             : 
      54                 :         123 : bool writeSecretBlocking(const QString &service, const QString &accountName,
      55                 :             :                          const QByteArray &password, QString *error) {
      56         [ +  + ]:         123 :   if (password.isEmpty())
      57                 :          43 :     return true;
      58                 :             : 
      59         [ +  - ]:          80 :   CredentialStore store;
      60         [ +  - ]:          80 :   QEventLoop loop;
      61         [ +  - ]:          80 :   QTimer timeout;
      62         [ +  - ]:          80 :   timeout.setSingleShot(true);
      63                 :             : 
      64                 :          80 :   bool completed = false;
      65                 :          80 :   bool success = false;
      66                 :          80 :   QString writeError;
      67         [ +  - ]:          80 :   QObject::connect(&timeout, &QTimer::timeout, &loop, [&]() {
      68         [ #  # ]:           0 :     if (completed)
      69                 :           0 :       return;
      70                 :           0 :     completed = true;
      71                 :           0 :     writeError = QStringLiteral("Timed out writing password to keyring");
      72                 :           0 :     loop.quit();
      73                 :             :   });
      74         [ +  - ]:          80 :   store.writePassword(service, accountName, password,
      75         [ +  - ]:          80 :                       [&](bool ok, const QString &message) {
      76         [ -  + ]:          80 :                         if (completed)
      77                 :           0 :                           return;
      78                 :          80 :                         completed = true;
      79                 :          80 :                         success = ok;
      80                 :          80 :                         writeError = message;
      81                 :          80 :                         loop.quit();
      82                 :             :                       });
      83         [ +  - ]:          80 :   timeout.start(30000);
      84         [ +  - ]:          80 :   loop.exec();
      85                 :             : 
      86   [ +  +  +  - ]:          80 :   if (!success && error) {
      87                 :          28 :     *error = writeError.isEmpty()
      88   [ -  +  -  + ]:          56 :                  ? QStringLiteral("Could not write password to keyring")
      89                 :          28 :                  : writeError;
      90                 :             :   }
      91                 :          80 :   return success;
      92                 :          80 : }
      93                 :             : 
      94                 :          48 : bool deleteSecretBlocking(const QString &service, const QString &accountName,
      95                 :             :                           QString *error) {
      96         [ +  - ]:          48 :   CredentialStore store;
      97         [ +  - ]:          48 :   QEventLoop loop;
      98         [ +  - ]:          48 :   QTimer timeout;
      99         [ +  - ]:          48 :   timeout.setSingleShot(true);
     100                 :             : 
     101                 :          48 :   bool completed = false;
     102                 :          48 :   bool success = false;
     103                 :             : 
     104         [ +  - ]:          48 :   QObject::connect(&timeout, &QTimer::timeout, &loop, [&]() {
     105         [ #  # ]:           0 :     if (completed)
     106                 :           0 :       return;
     107                 :           0 :     completed = true;
     108         [ #  # ]:           0 :     if (error)
     109                 :           0 :       *error = QStringLiteral("Timed out deleting password from keyring");
     110                 :           0 :     loop.quit();
     111                 :             :   });
     112                 :             : 
     113   [ +  -  +  - ]:          48 :   store.deletePassword(service, accountName, [&](bool ok) {
     114         [ -  + ]:          48 :     if (completed)
     115                 :           0 :       return;
     116                 :          48 :     completed = true;
     117                 :          48 :     success = ok;
     118                 :          48 :     loop.quit();
     119                 :             :   });
     120                 :             : 
     121         [ +  - ]:          48 :   timeout.start(30000);
     122         [ +  - ]:          48 :   loop.exec();
     123                 :             : 
     124   [ +  +  +  -  :          48 :   if (!success && error && error->isEmpty())
             +  -  +  + ]
     125                 :          42 :     *error = QStringLiteral("Could not delete password from keyring");
     126                 :          48 :   return success;
     127                 :          48 : }
     128                 :             : 
     129                 :             : } // namespace
     130                 :             : 
     131                 :         178 : QString AccountConfigLoader::defaultConfigDir() {
     132         [ +  - ]:         356 :   return QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) +
     133         [ +  - ]:         356 :          "/mailjd/accounts";
     134                 :             : }
     135                 :             : 
     136                 :          84 : std::vector<AccountConfig> AccountConfigLoader::loadAll() {
     137   [ +  -  +  - ]:          84 :   return loadAll(defaultConfigDir());
     138                 :             : }
     139                 :             : 
     140                 :             : std::vector<AccountConfig>
     141                 :         190 : AccountConfigLoader::loadAll(const QString &configDir) {
     142                 :         190 :   std::vector<AccountConfig> accounts;
     143         [ +  - ]:         190 :   QDir dir(configDir);
     144                 :             : 
     145   [ +  -  +  + ]:         190 :   if (!dir.exists()) {
     146   [ +  -  +  -  :          96 :     qCWarning(lcAccountConfig)
                   +  + ]
     147   [ +  -  +  - ]:          48 :         << "Config directory does not exist:" << configDir;
     148                 :          48 :     return accounts;
     149                 :             :   }
     150                 :             : 
     151   [ +  -  +  +  :         426 :   const auto files = dir.entryList({"*.json"}, QDir::Files, QDir::Name);
                   -  - ]
     152         [ +  + ]:         281 :   for (const auto &fileName : files) {
     153         [ +  - ]:         139 :     const auto path = dir.absoluteFilePath(fileName);
     154         [ +  - ]:         139 :     auto account = loadFromFile(path);
     155         [ +  + ]:         139 :     if (account.has_value()) {
     156   [ +  -  +  - ]:         137 :       auto errors = validate(account.value());
     157         [ +  + ]:         137 :       if (errors.isEmpty()) {
     158   [ +  -  +  -  :         272 :         qCInfo(lcAccountConfig)
                   +  + ]
     159   [ +  -  +  -  :         136 :             << "Loaded account:" << account->name << "from" << fileName;
             +  -  +  - ]
     160   [ +  -  +  - ]:         136 :         accounts.push_back(std::move(account.value()));
     161                 :             :       } else {
     162   [ +  -  +  -  :           2 :         qCWarning(lcAccountConfig)
                   +  + ]
     163   [ +  -  +  -  :           1 :             << "Invalid account config" << fileName << ":" << errors.join(", ");
          +  -  +  -  +  
                -  +  - ]
     164                 :             :       }
     165                 :         137 :     }
     166                 :         139 :   }
     167                 :             : 
     168   [ +  -  +  -  :         284 :   qCInfo(lcAccountConfig) << "Loaded" << accounts.size() << "accounts from"
          +  -  +  -  +  
                -  +  + ]
     169         [ +  - ]:         142 :                           << configDir;
     170                 :         142 :   return accounts;
     171   [ +  -  +  -  :         332 : }
             -  -  -  - ]
     172                 :             : 
     173                 :             : std::optional<AccountConfig>
     174                 :         181 : AccountConfigLoader::loadFromFile(const QString &path) {
     175         [ +  - ]:         181 :   QFile file(path);
     176   [ +  -  +  + ]:         181 :   if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
     177   [ +  -  +  -  :           4 :     qCWarning(lcAccountConfig)
                   +  + ]
     178   [ +  -  +  -  :           2 :         << "Cannot open config file:" << path << file.errorString();
             +  -  +  - ]
     179                 :           2 :     return std::nullopt;
     180                 :             :   }
     181                 :             : 
     182                 :         179 :   QJsonParseError parseError;
     183   [ +  -  +  - ]:         179 :   auto doc = QJsonDocument::fromJson(file.readAll(), &parseError);
     184   [ +  -  +  + ]:         179 :   if (doc.isNull()) {
     185   [ +  -  +  -  :           8 :     qCWarning(lcAccountConfig)
                   +  + ]
     186   [ +  -  +  -  :           4 :         << "JSON parse error in" << path << ":" << parseError.errorString();
          +  -  +  -  +  
                      - ]
     187                 :           4 :     return std::nullopt;
     188                 :             :   }
     189                 :             : 
     190   [ +  -  +  + ]:         175 :   if (!doc.isObject()) {
     191   [ +  -  +  -  :           2 :     qCWarning(lcAccountConfig) << "JSON root is not an object in" << path;
          +  -  +  -  +  
                      + ]
     192                 :           1 :     return std::nullopt;
     193                 :             :   }
     194                 :             : 
     195         [ +  - ]:         174 :   const auto root = doc.object();
     196         [ +  - ]:         174 :   AccountConfig config;
     197                 :             : 
     198   [ +  -  +  -  :         174 :   config.id = root.value("id").toString();
                   +  - ]
     199   [ +  -  +  -  :         174 :   config.name = root.value("name").toString();
                   +  - ]
     200   [ +  -  +  -  :         174 :   config.email = root.value("email").toString();
                   +  - ]
     201   [ +  -  +  -  :         174 :   config.legacyKeyringName = root.value("legacyKeyringName").toString();
                   +  - ]
     202                 :         174 :   bool metadataNeedsRewrite = false;
     203         [ +  + ]:         174 :   if (config.id.isEmpty()) {
     204   [ +  -  +  - ]:          49 :     config.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
     205                 :          49 :     config.legacyKeyringName = config.name;
     206                 :          49 :     metadataNeedsRewrite = true;
     207                 :             :   }
     208                 :             : 
     209                 :             :   // IMAP
     210   [ +  -  +  -  :         174 :   const auto imapObj = root.value("imap").toObject();
                   +  - ]
     211   [ +  -  +  -  :         174 :   config.imap.host = imapObj.value("host").toString();
                   +  - ]
     212   [ +  -  +  -  :         174 :   config.imap.port = static_cast<quint16>(imapObj.value("port").toInt(993));
                   +  - ]
     213   [ +  -  +  -  :         174 :   config.imap.security = imapObj.value("security").toString("ssl");
             +  -  +  - ]
     214   [ +  -  +  -  :         174 :   config.imap.username = imapObj.value("username").toString();
                   +  - ]
     215   [ +  -  +  -  :         174 :   config.imap.password = imapObj.value("password").toString().toUtf8();
             +  -  +  - ]
     216                 :             : 
     217                 :             :   // SMTP
     218   [ +  -  +  -  :         174 :   const auto smtpObj = root.value("smtp").toObject();
                   +  - ]
     219   [ +  -  +  -  :         174 :   config.smtp.host = smtpObj.value("host").toString();
                   +  - ]
     220   [ +  -  +  -  :         174 :   config.smtp.port = static_cast<quint16>(smtpObj.value("port").toInt(587));
                   +  - ]
     221   [ +  -  +  -  :         174 :   config.smtp.security = smtpObj.value("security").toString("starttls");
             +  -  +  - ]
     222   [ +  -  +  -  :         174 :   config.smtp.username = smtpObj.value("username").toString();
                   +  - ]
     223   [ +  -  +  -  :         174 :   config.smtp.password = smtpObj.value("password").toString().toUtf8();
             +  -  +  - ]
     224                 :             : 
     225                 :             :   const bool hasPlaintextPassword =
     226   [ +  +  +  + ]:         174 :       !config.imap.password.isEmpty() || !config.smtp.password.isEmpty();
     227                 :         174 :   bool imapLegacyReady = false;
     228                 :         174 :   bool smtpLegacyReady = false;
     229         [ +  + ]:         174 :   if (!config.legacyKeyringName.isEmpty()) {
     230                 :          49 :     imapLegacyReady = !config.imap.password.isEmpty();
     231                 :          49 :     smtpLegacyReady = !config.smtp.password.isEmpty();
     232                 :          49 :     QByteArray password;
     233         [ +  + ]:          49 :     if (!imapLegacyReady) {
     234                 :             :       const bool stableFound =
     235   [ +  -  -  +  :          30 :           readSecretBlocking(QStringLiteral("imap"), config.id, &password) &&
          +  -  -  -  -  
                      - ]
     236   [ -  -  +  - ]:          10 :           !password.isEmpty();
     237         [ -  + ]:          10 :       if (stableFound) {
     238                 :           0 :         config.imap.password = password;
     239                 :           0 :         imapLegacyReady = true;
     240                 :             :       } else {
     241         [ +  - ]:          10 :         password.clear();
     242                 :             :         const bool legacyFound =
     243   [ +  -  -  -  :          20 :             readSecretBlocking(QStringLiteral("imap"),
                   -  - ]
     244   [ +  -  +  + ]:          22 :                                config.legacyKeyringName, &password) &&
     245   [ +  -  +  - ]:          12 :             !password.isEmpty();
     246         [ +  + ]:          10 :         if (legacyFound) {
     247                 :           2 :           config.imap.password = password;
     248                 :           2 :           imapLegacyReady = true;
     249                 :             :         }
     250                 :             :       }
     251                 :             :     }
     252                 :             : 
     253         [ +  - ]:          49 :     password.clear();
     254         [ +  + ]:          49 :     if (!smtpLegacyReady) {
     255                 :             :       const bool stableFound =
     256   [ +  -  -  +  :          33 :           readSecretBlocking(QStringLiteral("smtp"), config.id, &password) &&
          +  -  -  -  -  
                      - ]
     257   [ -  -  +  - ]:          11 :           !password.isEmpty();
     258         [ -  + ]:          11 :       if (stableFound) {
     259                 :           0 :         config.smtp.password = password;
     260                 :           0 :         smtpLegacyReady = true;
     261                 :             :       } else {
     262         [ +  - ]:          11 :         password.clear();
     263                 :             :         const bool legacyFound =
     264   [ +  -  -  -  :          22 :             readSecretBlocking(QStringLiteral("smtp"),
                   -  - ]
     265   [ +  -  +  + ]:          24 :                                config.legacyKeyringName, &password) &&
     266   [ +  -  +  - ]:          13 :             !password.isEmpty();
     267         [ +  + ]:          11 :         if (legacyFound) {
     268                 :           2 :           config.smtp.password = password;
     269                 :           2 :           smtpLegacyReady = true;
     270                 :             :         }
     271                 :             :       }
     272                 :             :     }
     273                 :             : 
     274   [ +  +  +  + ]:          49 :     if (imapLegacyReady && smtpLegacyReady)
     275                 :          38 :       config.legacyKeyringName.clear();
     276                 :          49 :     metadataNeedsRewrite = true;
     277                 :          49 :   }
     278                 :             : 
     279   [ +  +  -  + ]:         174 :   if (metadataNeedsRewrite || hasPlaintextPassword) {
     280   [ +  -  +  + ]:          50 :     if (saveToFile(config, path)) {
     281   [ +  +  +  +  :          25 :       if (!config.legacyKeyringName.isEmpty() || imapLegacyReady ||
             -  +  +  + ]
     282                 :             :           smtpLegacyReady) {
     283                 :             :         const QString storedLegacyName =
     284   [ +  -  +  -  :          24 :             root.value("legacyKeyringName").toString();
                   +  - ]
     285                 :             :         const QString legacyName =
     286         [ +  + ]:          24 :             storedLegacyName.isEmpty() ? config.name : storedLegacyName;
     287                 :          24 :         QString deleteError;
     288         [ +  + ]:          24 :         if (imapLegacyReady)
     289         [ +  - ]:          16 :           deleteSecretBlocking(QStringLiteral("imap"), legacyName,
     290                 :             :                                &deleteError);
     291                 :          24 :         deleteError.clear();
     292         [ +  + ]:          24 :         if (smtpLegacyReady)
     293         [ +  - ]:          16 :           deleteSecretBlocking(QStringLiteral("smtp"), legacyName,
     294                 :             :                                &deleteError);
     295                 :          24 :       }
     296   [ +  -  +  -  :          50 :       qCInfo(lcAccountConfig) << "Migrated account credential metadata:"
             +  -  +  + ]
     297         [ +  - ]:          25 :                               << path;
     298                 :             :     } else {
     299   [ +  -  +  -  :          50 :       qCWarning(lcAccountConfig)
                   +  + ]
     300                 :             :           << "Could not migrate account credentials; retaining"
     301         [ +  - ]:          25 :              " the original config file:"
     302         [ +  - ]:          25 :           << path;
     303                 :             :     }
     304                 :             :   }
     305                 :             : 
     306                 :         174 :   return config;
     307                 :         181 : }
     308                 :             : 
     309                 :         177 : QStringList AccountConfigLoader::validate(const AccountConfig &config) {
     310                 :         177 :   QStringList errors;
     311                 :             : 
     312         [ +  + ]:         177 :   if (config.name.isEmpty())
     313   [ +  -  +  - ]:          13 :     errors << "Missing 'name'";
     314         [ +  + ]:         177 :   if (config.email.isEmpty())
     315   [ +  -  +  - ]:          16 :     errors << "Missing 'email'";
     316                 :             : 
     317                 :             :   // IMAP validation
     318         [ +  + ]:         177 :   if (config.imap.host.isEmpty())
     319   [ +  -  +  - ]:          14 :     errors << "Missing 'imap.host'";
     320         [ +  + ]:         177 :   if (config.imap.username.isEmpty())
     321   [ +  -  +  - ]:          13 :     errors << "Missing 'imap.username'";
     322         [ +  + ]:         177 :   if (config.imap.port == 0)
     323   [ +  -  +  - ]:           2 :     errors << "Invalid 'imap.port'";
     324   [ +  +  +  +  :         177 :   if (config.imap.security != "ssl" && config.imap.security != "starttls")
                   +  + ]
     325   [ +  -  +  - ]:           3 :     errors << "Invalid 'imap.security' (must be 'ssl' or 'starttls')";
     326                 :             : 
     327                 :             :   // SMTP validation
     328         [ +  + ]:         177 :   if (config.smtp.host.isEmpty())
     329   [ +  -  +  - ]:          13 :     errors << "Missing 'smtp.host'";
     330         [ +  + ]:         177 :   if (config.smtp.username.isEmpty())
     331   [ +  -  +  - ]:          13 :     errors << "Missing 'smtp.username'";
     332         [ +  + ]:         177 :   if (config.smtp.port == 0)
     333   [ +  -  +  - ]:           2 :     errors << "Invalid 'smtp.port'";
     334   [ +  +  +  +  :         177 :   if (config.smtp.security != "ssl" && config.smtp.security != "starttls")
                   +  + ]
     335   [ +  -  +  - ]:           2 :     errors << "Invalid 'smtp.security' (must be 'ssl' or 'starttls')";
     336                 :             : 
     337                 :         177 :   return errors;
     338                 :           0 : }
     339                 :             : 
     340                 :          26 : QString AccountConfigLoader::checkNameUniqueness(
     341                 :             :     const AccountConfig &config, const QString &configDir) {
     342         [ +  - ]:          26 :   const auto newSlug = slugify(config.name);
     343         [ +  - ]:          26 :   QDir dir(configDir);
     344   [ +  -  +  + ]:          26 :   if (!dir.exists())
     345                 :           2 :     return {};
     346   [ +  -  +  +  :          72 :   const auto files = dir.entryList({"*.json"}, QDir::Files, QDir::Name);
                   -  - ]
     347         [ +  + ]:          33 :   for (const auto &fileName : files) {
     348   [ +  -  +  - ]:          11 :     auto existing = loadFromFile(dir.absoluteFilePath(fileName));
     349         [ -  + ]:          11 :     if (!existing) {
     350   [ #  #  #  #  :           0 :       if (slugify(fileName.chopped(5)) == newSlug)
                   #  # ]
     351                 :           0 :         return QStringLiteral("Account slug '%1' is already in use")
     352         [ #  # ]:           0 :             .arg(newSlug);
     353                 :           0 :       continue;
     354                 :             :     }
     355   [ +  -  +  +  :          11 :     if (!config.id.isEmpty() && existing->id == config.id)
                   +  + ]
     356                 :           9 :       continue;
     357         [ +  + ]:           2 :     if (existing->name.compare(config.name, Qt::CaseInsensitive) == 0)
     358                 :           2 :       return QStringLiteral("Account name '%1' is already in use")
     359         [ +  - ]:           1 :           .arg(config.name);
     360   [ +  -  +  - ]:           1 :     if (slugify(existing->name) == newSlug)
     361                 :           2 :       return QStringLiteral("Account slug '%1' is already used by account "
     362                 :             :                             "'%2'")
     363         [ +  - ]:           1 :           .arg(newSlug, existing->name);
     364      [ -  +  + ]:          11 :   }
     365                 :          22 :   return {};
     366   [ +  -  +  -  :          50 : }
             -  -  -  - ]
     367                 :             : 
     368                 :          23 : bool AccountConfigLoader::save(AccountConfig &config, const QString &configDir) {
     369         [ +  - ]:          23 :   QDir dir(configDir);
     370   [ +  -  +  + ]:          23 :   if (!dir.exists()) {
     371   [ +  -  +  -  :           4 :     if (!dir.mkpath(".")) {
                   +  + ]
     372   [ +  -  +  -  :           2 :       qCWarning(lcAccountConfig)
                   +  + ]
     373   [ +  -  +  - ]:           1 :           << "Failed to create config directory:" << configDir;
     374                 :           1 :       return false;
     375                 :             :     }
     376                 :             :   }
     377                 :             : 
     378                 :          22 :   AccountConfig storedConfig = config;
     379         [ +  + ]:          22 :   if (storedConfig.id.isEmpty())
     380   [ +  -  +  - ]:          15 :     storedConfig.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
     381                 :             : 
     382                 :             :   const QString uniquenessError =
     383         [ +  - ]:          22 :       checkNameUniqueness(storedConfig, configDir);
     384         [ +  + ]:          22 :   if (!uniquenessError.isEmpty()) {
     385   [ +  -  +  -  :           4 :     qCWarning(lcAccountConfig) << "Refusing to save account:"
             +  -  +  + ]
     386         [ +  - ]:           2 :                                << uniquenessError;
     387                 :           2 :     return false;
     388                 :             :   }
     389                 :             : 
     390         [ +  - ]:          20 :   auto newSlug = slugify(storedConfig.name);
     391         [ +  - ]:          20 :   auto filename = newSlug + ".json";
     392         [ +  - ]:          20 :   auto path = dir.absoluteFilePath(filename);
     393                 :             : 
     394                 :          20 :   QStringList oldPaths;
     395                 :          20 :   bool targetHasSameId = false;
     396   [ +  -  +  + ]:          20 :   if (QFileInfo::exists(path)) {
     397         [ +  - ]:           4 :     const auto targetAccount = loadFromFile(path);
     398   [ +  -  +  - ]:           4 :     targetHasSameId = targetAccount && targetAccount->id == storedConfig.id;
     399                 :           4 :   }
     400   [ +  -  +  +  :          60 :   const auto files = dir.entryList({"*.json"}, QDir::Files, QDir::Name);
                   -  - ]
     401         [ +  + ]:          27 :   for (const auto &fileName : files) {
     402         [ +  - ]:           7 :     const auto oldPath = dir.absoluteFilePath(fileName);
     403         [ +  + ]:           7 :     if (oldPath == path)
     404                 :           4 :       continue;
     405         [ +  - ]:           3 :     auto oldAccount = loadFromFile(oldPath);
     406   [ +  -  +  -  :           3 :     if (oldAccount && oldAccount->id == storedConfig.id)
                   +  - ]
     407         [ +  - ]:           3 :       oldPaths.append(oldPath);
     408         [ +  + ]:           7 :   }
     409                 :             : 
     410   [ +  -  +  +  :          20 :   if (oldPaths.size() > 1 || (targetHasSameId && !oldPaths.isEmpty())) {
             +  +  +  + ]
     411   [ +  -  +  -  :           3 :     qCWarning(lcAccountConfig)
                   +  + ]
     412         [ +  - ]:           1 :         << "Refusing ambiguous account save: duplicate account ID"
     413   [ +  -  +  -  :           2 :         << storedConfig.id << "appears in" << oldPaths;
                   +  - ]
     414                 :           1 :     return false;
     415                 :             :   }
     416                 :             : 
     417   [ +  -  +  + ]:          19 :   if (!saveToFile(storedConfig, path))
     418                 :           1 :     return false;
     419                 :             : 
     420   [ +  -  +  -  :          19 :   for (const auto &oldPath : oldPaths) {
                   +  + ]
     421   [ +  -  -  + ]:           1 :     if (!QFile::remove(oldPath)) {
     422   [ #  #  #  #  :           0 :       qCWarning(lcAccountConfig)
                   #  # ]
     423   [ #  #  #  # ]:           0 :           << "Saved renamed account but could not remove old file:" << oldPath;
     424                 :             :     }
     425                 :             :   }
     426                 :          18 :   config.id = storedConfig.id;
     427                 :          18 :   return true;
     428   [ +  -  +  -  :          43 : }
             -  -  -  - ]
     429                 :             : 
     430                 :           1 : bool AccountConfigLoader::save(const AccountConfig &config,
     431                 :             :                                const QString &configDir) {
     432                 :           1 :   AccountConfig storedConfig = config;
     433         [ +  - ]:           2 :   return save(storedConfig, configDir);
     434                 :           1 : }
     435                 :             : 
     436                 :          75 : bool AccountConfigLoader::saveToFile(AccountConfig &config,
     437                 :             :                                      const QString &path) {
     438                 :          75 :   AccountConfig storedConfig = config;
     439         [ +  + ]:          75 :   if (storedConfig.id.isEmpty())
     440   [ +  -  +  - ]:           6 :     storedConfig.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
     441                 :             : 
     442                 :          75 :   QString keyringError;
     443         [ +  - ]:          75 :   if (!writeSecretBlocking(QStringLiteral("imap"),
     444         [ +  + ]:         150 :                            credentialName(storedConfig),
     445                 :             :                            storedConfig.imap.password, &keyringError)) {
     446   [ +  -  +  -  :          54 :     qCWarning(lcAccountConfig)
                   +  + ]
     447         [ +  - ]:          27 :         << "Refusing to save account after IMAP keyring write failed:"
     448         [ +  - ]:          27 :         << keyringError;
     449                 :          27 :     return false;
     450                 :             :   }
     451         [ +  - ]:          48 :   if (!writeSecretBlocking(QStringLiteral("smtp"),
     452         [ +  + ]:          96 :                            credentialName(storedConfig),
     453                 :             :                            storedConfig.smtp.password, &keyringError)) {
     454   [ +  -  +  -  :           2 :     qCWarning(lcAccountConfig)
                   +  + ]
     455         [ +  - ]:           1 :         << "Refusing to save account after SMTP keyring write failed:"
     456         [ +  - ]:           1 :         << keyringError;
     457                 :           1 :     return false;
     458                 :             :   }
     459                 :             : 
     460         [ +  - ]:          47 :   QJsonObject imapObj;
     461   [ +  -  +  -  :          47 :   imapObj["host"] = storedConfig.imap.host;
             +  -  +  - ]
     462   [ +  -  +  -  :          47 :   imapObj["port"] = storedConfig.imap.port;
             +  -  +  - ]
     463   [ +  -  +  -  :          47 :   imapObj["security"] = storedConfig.imap.security;
             +  -  +  - ]
     464   [ +  -  +  -  :          47 :   imapObj["username"] = storedConfig.imap.username;
             +  -  +  - ]
     465                 :             :   // Omit passwords from JSON (stored in keyring)
     466   [ +  -  +  -  :          47 :   imapObj["password"] = QString();
             +  -  +  - ]
     467                 :             : 
     468         [ +  - ]:          47 :   QJsonObject smtpObj;
     469   [ +  -  +  -  :          47 :   smtpObj["host"] = storedConfig.smtp.host;
             +  -  +  - ]
     470   [ +  -  +  -  :          47 :   smtpObj["port"] = storedConfig.smtp.port;
             +  -  +  - ]
     471   [ +  -  +  -  :          47 :   smtpObj["security"] = storedConfig.smtp.security;
             +  -  +  - ]
     472   [ +  -  +  -  :          47 :   smtpObj["username"] = storedConfig.smtp.username;
             +  -  +  - ]
     473   [ +  -  +  -  :          47 :   smtpObj["password"] = QString();
             +  -  +  - ]
     474                 :             : 
     475         [ +  - ]:          47 :   QJsonObject root;
     476   [ +  -  +  -  :          47 :   root["id"] = storedConfig.id;
             +  -  +  - ]
     477   [ +  -  +  -  :          47 :   root["name"] = storedConfig.name;
             +  -  +  - ]
     478   [ +  -  +  -  :          47 :   root["email"] = storedConfig.email;
             +  -  +  - ]
     479         [ +  + ]:          47 :   if (!storedConfig.legacyKeyringName.isEmpty())
     480   [ +  -  +  -  :          10 :     root["legacyKeyringName"] = storedConfig.legacyKeyringName;
             +  -  +  - ]
     481   [ +  -  +  -  :          47 :   root["imap"] = imapObj;
             +  -  +  - ]
     482   [ +  -  +  -  :          47 :   root["smtp"] = smtpObj;
             +  -  +  - ]
     483                 :             : 
     484         [ +  - ]:          47 :   QJsonDocument doc(root);
     485                 :             : 
     486                 :             :   // T-501: Use QSaveFile for atomic write — prevents TOCTOU permission race
     487         [ +  - ]:          47 :   QSaveFile file(path);
     488   [ +  -  +  + ]:          47 :   if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
     489   [ +  -  +  -  :           2 :     qCWarning(lcAccountConfig)
                   +  + ]
     490   [ +  -  +  -  :           1 :         << "Cannot write config file:" << path << file.errorString();
             +  -  +  - ]
     491                 :           1 :     return false;
     492                 :             :   }
     493                 :             : 
     494                 :             :   // Set restrictive permissions BEFORE writing (prevents TOCTOU race)
     495         [ +  - ]:          46 :   file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner);
     496                 :             : 
     497   [ +  -  +  - ]:          46 :   file.write(doc.toJson(QJsonDocument::Indented));
     498                 :             : 
     499   [ +  -  -  + ]:          46 :   if (!file.commit()) {
     500   [ #  #  #  #  :           0 :     qCWarning(lcAccountConfig)
                   #  # ]
     501   [ #  #  #  # ]:           0 :         << "Failed to commit config file:" << path;
     502                 :           0 :     return false;
     503                 :             :   }
     504                 :             : 
     505   [ +  -  +  -  :          92 :   qCInfo(lcAccountConfig) << "Saved account:" << storedConfig.name << "to"
          +  -  +  -  +  
                -  +  + ]
     506         [ +  - ]:          46 :                           << path;
     507                 :          46 :   config.id = storedConfig.id;
     508                 :          46 :   return true;
     509                 :          75 : }
     510                 :             : 
     511                 :           1 : bool AccountConfigLoader::saveToFile(const AccountConfig &config,
     512                 :             :                                      const QString &path) {
     513                 :           1 :   AccountConfig storedConfig = config;
     514         [ +  - ]:           2 :   return saveToFile(storedConfig, path);
     515                 :           1 : }
     516                 :             : 
     517                 :           8 : bool AccountConfigLoader::remove(const QString &accountName,
     518                 :             :                                  const QString &configDir) {
     519         [ +  - ]:           8 :   QDir dir(configDir);
     520   [ +  -  +  + ]:           8 :   if (!dir.exists()) {
     521   [ +  -  +  -  :           2 :     qCWarning(lcAccountConfig)
                   +  + ]
     522   [ +  -  +  - ]:           1 :         << "Config directory does not exist:" << configDir;
     523                 :           1 :     return false;
     524                 :             :   }
     525                 :             : 
     526                 :             :   // Strategy: find JSON file containing matching "name" field.
     527                 :             :   // Also check by slugified filename as fallback.
     528   [ +  -  +  +  :          21 :   const auto files = dir.entryList({"*.json"}, QDir::Files);
                   -  - ]
     529         [ +  + ]:          11 :   for (const auto &fileName : files) {
     530         [ +  - ]:           9 :     auto path = dir.absoluteFilePath(fileName);
     531         [ +  - ]:           9 :     auto account = loadFromFile(path);
     532   [ +  +  +  +  :           9 :     if (account.has_value() && account->name == accountName) {
                   +  + ]
     533   [ +  -  +  - ]:           5 :       if (QFile::remove(path)) {
     534                 :           5 :         QString keyringError;
     535         [ +  - ]:           5 :         if (!deleteSecretBlocking(QStringLiteral("imap"),
     536         [ +  + ]:          10 :                                   credentialName(*account),
     537                 :             :                                   &keyringError)) {
     538   [ +  -  +  -  :           8 :           qCWarning(lcAccountConfig)
                   +  + ]
     539         [ +  - ]:           4 :               << "Failed to delete IMAP keyring secret for"
     540   [ +  -  +  -  :           4 :               << accountName << ":" << keyringError;
                   +  - ]
     541                 :             :         }
     542                 :           5 :         keyringError.clear();
     543         [ +  - ]:           5 :         if (!deleteSecretBlocking(QStringLiteral("smtp"),
     544         [ +  + ]:          10 :                                   credentialName(*account),
     545                 :             :                                   &keyringError)) {
     546   [ +  -  +  -  :           8 :           qCWarning(lcAccountConfig)
                   +  + ]
     547         [ +  - ]:           4 :               << "Failed to delete SMTP keyring secret for"
     548   [ +  -  +  -  :           4 :               << accountName << ":" << keyringError;
                   +  - ]
     549                 :             :         }
     550         [ +  + ]:           5 :         if (!account->legacyKeyringName.isEmpty()) {
     551                 :           3 :           keyringError.clear();
     552         [ +  - ]:           3 :           deleteSecretBlocking(QStringLiteral("imap"),
     553                 :           3 :                                account->legacyKeyringName, &keyringError);
     554                 :           3 :           keyringError.clear();
     555         [ +  - ]:           3 :           deleteSecretBlocking(QStringLiteral("smtp"),
     556                 :           3 :                                account->legacyKeyringName, &keyringError);
     557                 :             :         }
     558   [ +  -  +  -  :          10 :         qCInfo(lcAccountConfig)
                   +  + ]
     559   [ +  -  +  -  :           5 :             << "Deleted account:" << accountName << "file:" << fileName;
             +  -  +  - ]
     560                 :           5 :         return true;
     561                 :           5 :       } else {
     562   [ #  #  #  #  :           0 :         qCWarning(lcAccountConfig) << "Failed to delete file:" << path;
          #  #  #  #  #  
                      # ]
     563                 :           0 :         return false;
     564                 :             :       }
     565                 :             :     }
     566   [ +  +  +  + ]:          14 :   }
     567                 :             : 
     568   [ +  -  +  -  :           4 :   qCWarning(lcAccountConfig)
                   +  + ]
     569   [ +  -  +  - ]:           2 :       << "No config file found for account:" << accountName;
     570                 :           2 :   return false;
     571   [ +  -  +  -  :          15 : }
             -  -  -  - ]
     572                 :             : 
     573                 :          75 : QString AccountConfigLoader::slugify(const QString &name) {
     574                 :          75 :   QString result;
     575         [ +  - ]:          75 :   result.reserve(name.size());
     576                 :             : 
     577                 :             :   // Normalize: lowercase, replace non-ASCII and special chars
     578   [ +  -  +  - ]:          75 :   auto normalized = name.toLower().normalized(QString::NormalizationForm_KD);
     579                 :             : 
     580   [ +  -  +  -  :         922 :   for (const auto &ch : normalized) {
                   +  + ]
     581   [ +  +  +  +  :         847 :     if (ch.isLetterOrNumber() && ch.unicode() < 128) {
                   +  + ]
     582         [ +  - ]:         795 :       result.append(ch);
     583   [ +  +  +  +  :          52 :     } else if (ch == ' ' || ch == '-' || ch == '_') {
             +  +  +  + ]
     584                 :             :       // Collapse consecutive separators
     585   [ +  +  +  -  :          44 :       if (!result.isEmpty() && result.back() != '_') {
             +  +  +  + ]
     586         [ +  - ]:          40 :         result.append('_');
     587                 :             :       }
     588                 :             :     }
     589                 :             :     // Skip all other characters (accents, special chars)
     590                 :             :   }
     591                 :             : 
     592                 :             :   // Remove trailing underscore
     593   [ +  -  +  + ]:          76 :   while (result.endsWith('_')) {
     594         [ +  - ]:           1 :     result.chop(1);
     595                 :             :   }
     596                 :             : 
     597                 :             :   // Limit length
     598         [ +  + ]:          75 :   if (result.size() > 50) {
     599         [ +  - ]:           3 :     result.truncate(50);
     600                 :             :     // Don't end on underscore after truncation
     601   [ +  -  +  + ]:           4 :     while (result.endsWith('_')) {
     602         [ +  - ]:           1 :       result.chop(1);
     603                 :             :     }
     604                 :             :   }
     605                 :             : 
     606                 :             :   // Fallback for empty result
     607         [ +  + ]:          75 :   if (result.isEmpty()) {
     608         [ +  - ]:           5 :     result = "account";
     609                 :             :   }
     610                 :             : 
     611                 :          75 :   return result;
     612                 :          75 : }
     613                 :             : 
     614                 :             : // SEC-01/02: Async keyring password resolution
     615                 :          69 : void AccountConfigLoader::resolvePasswords(
     616                 :             :     std::vector<AccountConfig> &accounts, CredentialStore *store,
     617                 :             :     std::function<void()> callback) {
     618   [ +  +  +  +  :          69 :   if (!store || accounts.empty()) {
                   +  + ]
     619   [ +  +  +  - ]:           3 :     if (callback) callback();
     620                 :          16 :     return;
     621                 :             :   }
     622                 :             : 
     623                 :             :   // Count how many passwords need resolving
     624                 :          66 :   int pending = 0;
     625         [ +  + ]:         133 :   for (const auto &acc : accounts) {
     626         [ +  + ]:          67 :     if (acc.imap.password.isEmpty()) ++pending;
     627         [ +  + ]:          67 :     if (acc.smtp.password.isEmpty()) ++pending;
     628                 :             :   }
     629                 :             : 
     630         [ +  + ]:          66 :   if (pending == 0) {
     631   [ +  +  +  - ]:          13 :     if (callback) callback();
     632                 :          13 :     return;
     633                 :             :   }
     634                 :             : 
     635                 :             :   // Shared counter for tracking completion
     636         [ +  - ]:          53 :   auto remaining = std::make_shared<int>(pending);
     637                 :             : 
     638         [ +  + ]:         107 :   for (size_t i = 0; i < accounts.size(); ++i) {
     639                 :          54 :     auto &acc = accounts[i];
     640                 :             : 
     641         [ +  + ]:          54 :     if (acc.imap.password.isEmpty()) {
     642         [ +  - ]:          53 :       store->readPassword(
     643                 :         159 :           QStringLiteral("imap"), credentialName(acc),
     644   [ +  -  +  -  :         106 :           [&acc, store, remaining, callback](bool success,
                   -  - ]
     645                 :             :                                              const QByteArray &pw) {
     646   [ +  -  +  +  :          42 :             if (success && !pw.isEmpty()) {
                   +  + ]
     647                 :          41 :               acc.imap.password = pw;
     648   [ +  -  +  -  :          82 :               qCInfo(lcAccountConfig)
                   +  + ]
     649   [ +  -  +  - ]:          41 :                   << "Resolved IMAP password from keyring for" << acc.name;
     650   [ -  +  -  -  :          41 :               if (--(*remaining) == 0 && callback)
                   -  + ]
     651                 :           0 :                 callback();
     652                 :          41 :               return;
     653                 :             :             }
     654         [ -  + ]:           1 :             if (!acc.legacyKeyringName.isEmpty()) {
     655                 :           0 :               const QString legacyName = acc.legacyKeyringName;
     656         [ #  # ]:           0 :               store->readPassword(
     657                 :           0 :                   QStringLiteral("imap"), legacyName,
     658   [ #  #  #  #  :           0 :                   [&acc, store, remaining, callback, legacyName](
             #  #  #  # ]
     659                 :             :                       bool legacySuccess, const QByteArray &legacyPassword) {
     660   [ #  #  #  #  :           0 :                     if (legacySuccess && !legacyPassword.isEmpty()) {
                   #  # ]
     661                 :           0 :                       acc.imap.password = legacyPassword;
     662         [ #  # ]:           0 :                       store->writePassword(QStringLiteral("imap"),
     663                 :           0 :                                            credentialName(acc), legacyPassword,
     664   [ #  #  #  #  :           0 :                                            [store, remaining, callback,
             #  #  #  # ]
     665                 :           0 :                                             legacyName](bool written,
     666                 :             :                                                         const QString &) {
     667         [ #  # ]:           0 :                         if (!written) {
     668   [ #  #  #  #  :           0 :                           if (--(*remaining) == 0 && callback)
                   #  # ]
     669                 :           0 :                             callback();
     670                 :           0 :                           return;
     671                 :             :                         }
     672         [ #  # ]:           0 :                         store->deletePassword(
     673                 :           0 :                             QStringLiteral("imap"), legacyName,
     674   [ #  #  #  #  :           0 :                             [remaining, callback](bool) {
                   #  # ]
     675   [ #  #  #  #  :           0 :                               if (--(*remaining) == 0 && callback)
                   #  # ]
     676                 :           0 :                                 callback();
     677                 :           0 :                             });
     678                 :             :                       });
     679                 :           0 :                       return;
     680                 :             :                     }
     681   [ #  #  #  #  :           0 :                     if (--(*remaining) == 0 && callback)
                   #  # ]
     682                 :           0 :                       callback();
     683                 :             :                   });
     684                 :           0 :               return;
     685                 :           0 :             }
     686   [ -  +  -  -  :           1 :             if (--(*remaining) == 0 && callback)
                   -  + ]
     687                 :           0 :               callback();
     688                 :             :           });
     689                 :             :     }
     690                 :             : 
     691         [ +  - ]:          54 :     if (acc.smtp.password.isEmpty()) {
     692         [ +  - ]:          54 :       store->readPassword(
     693                 :         162 :           QStringLiteral("smtp"), credentialName(acc),
     694   [ +  -  +  -  :         108 :           [&acc, store, remaining, callback](bool success,
                   -  - ]
     695                 :             :                                              const QByteArray &pw) {
     696   [ +  +  +  -  :          43 :             if (success && !pw.isEmpty()) {
                   +  + ]
     697                 :          42 :               acc.smtp.password = pw;
     698   [ +  -  +  -  :          84 :               qCInfo(lcAccountConfig)
                   +  + ]
     699   [ +  -  +  - ]:          42 :                   << "Resolved SMTP password from keyring for" << acc.name;
     700   [ +  -  +  +  :          42 :               if (--(*remaining) == 0 && callback)
                   +  + ]
     701                 :          41 :                 callback();
     702                 :          42 :               return;
     703                 :             :             }
     704         [ -  + ]:           1 :             if (!acc.legacyKeyringName.isEmpty()) {
     705                 :           0 :               const QString legacyName = acc.legacyKeyringName;
     706         [ #  # ]:           0 :               store->readPassword(
     707                 :           0 :                   QStringLiteral("smtp"), legacyName,
     708   [ #  #  #  #  :           0 :                   [&acc, store, remaining, callback, legacyName](
             #  #  #  # ]
     709                 :             :                       bool legacySuccess, const QByteArray &legacyPassword) {
     710   [ #  #  #  #  :           0 :                     if (legacySuccess && !legacyPassword.isEmpty()) {
                   #  # ]
     711                 :           0 :                       acc.smtp.password = legacyPassword;
     712         [ #  # ]:           0 :                       store->writePassword(QStringLiteral("smtp"),
     713                 :           0 :                                            credentialName(acc), legacyPassword,
     714   [ #  #  #  #  :           0 :                                            [store, remaining, callback,
             #  #  #  # ]
     715                 :           0 :                                             legacyName](bool written,
     716                 :             :                                                         const QString &) {
     717         [ #  # ]:           0 :                         if (!written) {
     718   [ #  #  #  #  :           0 :                           if (--(*remaining) == 0 && callback)
                   #  # ]
     719                 :           0 :                             callback();
     720                 :           0 :                           return;
     721                 :             :                         }
     722         [ #  # ]:           0 :                         store->deletePassword(
     723                 :           0 :                             QStringLiteral("smtp"), legacyName,
     724   [ #  #  #  #  :           0 :                             [remaining, callback](bool) {
                   #  # ]
     725   [ #  #  #  #  :           0 :                               if (--(*remaining) == 0 && callback)
                   #  # ]
     726                 :           0 :                                 callback();
     727                 :           0 :                             });
     728                 :             :                       });
     729                 :           0 :                       return;
     730                 :             :                     }
     731   [ #  #  #  #  :           0 :                     if (--(*remaining) == 0 && callback)
                   #  # ]
     732                 :           0 :                       callback();
     733                 :             :                   });
     734                 :           0 :               return;
     735                 :           0 :             }
     736   [ -  +  -  -  :           1 :             if (--(*remaining) == 0 && callback)
                   -  + ]
     737                 :           0 :               callback();
     738                 :             :           });
     739                 :             :     }
     740                 :             :   }
     741                 :          53 : }
        

Generated by: LCOV version 2.0-1