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 : }
|