• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IJHack / QtPass / 25946036887

15 May 2026 11:20PM UTC coverage: 53.836% (+9.1%) from 44.781%
25946036887

push

github

web-flow
Merge pull request #1490 from IJHack/test/mainwindow-unit-tests

test(mainwindow): add MainWindow widget test suite

3621 of 6726 relevant lines covered (53.84%)

35.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

54.11
/src/imitatepass.cpp
1
// SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "imitatepass.h"
4
#include "executor.h"
5
#include "qtpasssettings.h"
6
#include "util.h"
7
#include <QDirIterator>
8
#include <QElapsedTimer>
9
#include <QPointer>
10
#include <QRegularExpression>
11
#include <QThread>
12
#include <utility>
13

14
#ifdef QT_DEBUG
15
#include "debughelper.h"
16
#endif
17

18
using Enums::CLIPBOARD_ALWAYS;
19
using Enums::CLIPBOARD_NEVER;
20
using Enums::CLIPBOARD_ON_DEMAND;
21
using Enums::GIT_ADD;
22
using Enums::GIT_COMMIT;
23
using Enums::GIT_COPY;
24
using Enums::GIT_INIT;
25
using Enums::GIT_MOVE;
26
using Enums::GIT_PULL;
27
using Enums::GIT_PUSH;
28
using Enums::GIT_RM;
29
using Enums::GPG_GENKEYS;
30
using Enums::INVALID;
31
using Enums::PASS_COPY;
32
using Enums::PASS_GREP;
33
using Enums::PASS_INIT;
34
using Enums::PASS_INSERT;
35
using Enums::PASS_MOVE;
36
using Enums::PASS_OTP_GENERATE;
37
using Enums::PASS_REMOVE;
38
using Enums::PASS_SHOW;
39
using Enums::PROCESS_COUNT;
40

41
/**
42
 * @brief ImitatePass::ImitatePass for situations when pass is not available
43
 * we imitate the behavior of pass https://www.passwordstore.org/
44
 */
45
ImitatePass::ImitatePass() = default;
76✔
46

47
ImitatePass::~ImitatePass() {
40✔
48
  static constexpr int kGrepThreadTimeoutMs = 5000;
49
  for (QThread *t : std::as_const(m_grepThreads))
38✔
50
    if (t && t->isRunning())
×
51
      t->requestInterruption();
×
52
  QElapsedTimer elapsed;
38✔
53
  elapsed.start();
38✔
54
  for (QThread *t : std::as_const(m_grepThreads)) {
38✔
55
    if (t && t->isRunning()) {
×
56
      const int remaining =
57
          kGrepThreadTimeoutMs - static_cast<int>(elapsed.elapsed());
×
58
      if (remaining > 0)
×
59
        t->wait(remaining);
×
60
    }
61
  }
62
}
40✔
63

64
static auto pgit(const QString &path) -> QString {
2✔
65
  if (!QtPassSettings::getGitExecutable().startsWith("wsl ")) {
6✔
66
    return path;
67
  }
68
  QString res = "$(wslpath " + path + ")";
×
69
  return res.replace('\\', '/');
×
70
}
71

72
static auto pgpg(const QString &path) -> QString {
46✔
73
  if (!QtPassSettings::getGpgExecutable().startsWith("wsl ")) {
138✔
74
    return path;
75
  }
76
  QString res = "$(wslpath " + path + ")";
×
77
  return res.replace('\\', '/');
×
78
}
79

80
/**
81
 * @brief ImitatePass::GitInit git init wrapper
82
 */
83
void ImitatePass::GitInit() {
×
84
  executeGit(GIT_INIT, {"init", pgit(QtPassSettings::getPassStore())});
×
85
}
×
86

87
/**
88
 * @brief ImitatePass::GitPull git pull wrapper
89
 */
90
void ImitatePass::GitPull() { executeGit(GIT_PULL, {"pull"}); }
×
91

92
/**
93
 * @brief ImitatePass::GitPull_b git pull wrapper
94
 */
95
void ImitatePass::GitPull_b() {
×
96
  Executor::executeBlocking(QtPassSettings::getGitExecutable(), {"pull"});
×
97
}
×
98

99
/**
100
 * @brief ImitatePass::GitPush git push wrapper
101
 */
102
void ImitatePass::GitPush() {
×
103
  if (QtPassSettings::isUseGit()) {
×
104
    executeGit(GIT_PUSH, {"push"});
×
105
  }
106
}
×
107

108
/**
109
 * @brief ImitatePass::Show shows content of file
110
 */
111
void ImitatePass::Show(QString file) {
10✔
112
  file = QtPassSettings::getPassStore() + file + ".gpg";
20✔
113
  QStringList args = {"-d",      "--quiet",     "--yes",   "--no-encrypt-to",
114
                      "--batch", "--use-agent", pgpg(file)};
80✔
115
  executeGpg(PASS_SHOW, args);
20✔
116
}
20✔
117

118
/**
119
 * @brief ImitatePass::OtpGenerate generates an otp code
120
 */
121
void ImitatePass::OtpGenerate(QString file) {
×
122
#ifdef QT_DEBUG
123
  dbg() << "No OTP generation code for fake pass yet, attempting for file: " +
124
               file;
125
#else
126
  Q_UNUSED(file)
127
#endif
128
}
×
129

130
/**
131
 * @brief ImitatePass::Insert create new file with encrypted content
132
 *
133
 * @param file      file to be created
134
 * @param newValue  value to be stored in file
135
 * @param overwrite whether to overwrite existing file
136
 */
137
void ImitatePass::Insert(QString file, QString newValue, bool overwrite) {
19✔
138
  file = file + ".gpg";
19✔
139
  QString gpgIdPath = Pass::getGpgIdPath(file);
19✔
140
  if (!verifyGpgIdFile(gpgIdPath)) {
19✔
141
    emit critical(tr("Check .gpg-id file signature!"),
×
142
                  tr("Signature for %1 is invalid.").arg(gpgIdPath));
×
143
    return;
×
144
  }
145
  transactionHelper trans(this, PASS_INSERT);
19✔
146
  QStringList recipients = Pass::getRecipientList(file);
19✔
147
  if (recipients.isEmpty()) {
19✔
148
    // Already emit critical signal to notify user of error - no need to throw
149
    emit critical(tr("Can not edit"),
×
150
                  tr("Could not read encryption key to use, .gpg-id "
×
151
                     "file missing or invalid."));
152
    return;
153
  }
154
  QStringList args = {"--batch", "--status-fd", "2",
155
                      "-eq",     "--output",    pgpg(file)};
133✔
156
  for (auto &r : recipients) {
38✔
157
    args.append("-r");
38✔
158
    args.append(r);
159
  }
160
  if (overwrite) {
19✔
161
    args.append("--yes");
2✔
162
  }
163
  args.append("-");
38✔
164
  executeGpg(PASS_INSERT, args, newValue);
38✔
165
  if (!QtPassSettings::isUseWebDav() && QtPassSettings::isUseGit()) {
19✔
166
    // Git is used when enabled - this is the standard pass workflow
167
    if (!overwrite) {
1✔
168
      executeGit(GIT_ADD, {"add", pgit(file)});
4✔
169
    }
170
    QString path = QDir(QtPassSettings::getPassStore()).relativeFilePath(file);
2✔
171
    path.replace(Util::endsWithGpg(), "");
1✔
172
    QString msg =
173
        QString(overwrite ? "Edit" : "Add") + " for " + path + " using QtPass.";
2✔
174
    gitCommit(file, msg);
1✔
175
  }
176
}
20✔
177

178
/**
179
 * @brief ImitatePass::gitCommit commit a file to git with an appropriate commit
180
 * message
181
 * @param file
182
 * @param msg
183
 */
184
void ImitatePass::gitCommit(const QString &file, const QString &msg) {
1✔
185
  if (file.isEmpty()) {
1✔
186
    executeGit(GIT_COMMIT, {"commit", "-m", msg});
×
187
  } else {
188
    executeGit(GIT_COMMIT, {"commit", "-m", msg, "--", pgit(file)});
7✔
189
  }
190
}
3✔
191

192
/**
193
 * @brief ImitatePass::Remove custom implementation of "pass remove"
194
 */
195
void ImitatePass::Remove(QString file, bool isDir) {
1✔
196
  file = QtPassSettings::getPassStore() + file;
2✔
197
  transactionHelper trans(this, PASS_REMOVE);
1✔
198
  if (!isDir) {
1✔
199
    file += ".gpg";
1✔
200
  }
201
  if (QtPassSettings::isUseGit()) {
1✔
202
    executeGit(GIT_RM, {"rm", (isDir ? "-rf" : "-f"), pgit(file)});
×
203
    // Normalize path the same way as add/edit operations
204
    QString path = QDir(QtPassSettings::getPassStore()).relativeFilePath(file);
×
205
    path.replace(Util::endsWithGpg(), "");
×
206
    gitCommit(file, "Remove for " + path + " using QtPass.");
×
207
  } else {
208
    if (isDir) {
1✔
209
      QDir dir(file);
×
210
      dir.removeRecursively();
×
211
    } else {
×
212
      QFile(file).remove();
1✔
213
    }
214
  }
215
}
1✔
216

217
/**
218
 * @brief ImitatePass::Init initialize pass repository
219
 *
220
 * @param path      path in which new password-store will be created
221
 * @param users     list of users who shall be able to decrypt passwords in
222
 * path
223
 */
224
auto ImitatePass::checkSigningKeys(const QStringList &signingKeys) -> bool {
×
225
  QString out;
×
226
  QStringList args =
227
      QStringList{"--status-fd=1", "--list-secret-keys"} + signingKeys;
×
228
  int result =
229
      Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args, &out);
×
230
  if (result != 0) {
×
231
#ifdef QT_DEBUG
232
    dbg() << "GPG list-secret-keys failed with code:" << result;
233
#endif
234
    return false;
235
  }
236
  for (auto &key : signingKeys) {
×
237
    if (out.contains("[GNUPG:] KEY_CONSIDERED " + key)) {
×
238
      return true;
239
    }
240
  }
241
  return false;
242
}
×
243

244
/**
245
 * @brief Writes the selected users' GPG key IDs to a .gpg-id file.
246
 * @details Opens the specified file for writing, stores the key ID of each
247
 * enabled user on a separate line, and warns if none of the selected users has
248
 * a secret key available.
249
 *
250
 * @param QString &gpgIdFile - Path to the .gpg-id file to be written.
251
 * @param QList<UserInfo> &users - List of users to evaluate and write to the
252
 * file.
253
 * @return void - This function does not return a value.
254
 *
255
 */
256
void ImitatePass::writeGpgIdFile(const QString &gpgIdFile,
1✔
257
                                 const QList<UserInfo> &users) {
258
  QFile gpgId(gpgIdFile);
1✔
259
  if (!gpgId.open(QIODevice::WriteOnly | QIODevice::Text)) {
1✔
260
    emit critical(tr("Cannot update"),
×
261
                  tr("Failed to open .gpg-id for writing."));
×
262
    return;
263
  }
264
  bool secret_selected = false;
265
  for (const UserInfo &user : users) {
2✔
266
    if (user.enabled) {
1✔
267
      gpgId.write((user.key_id + "\n").toUtf8());
2✔
268
      secret_selected |= user.have_secret;
1✔
269
    }
270
  }
271
  gpgId.close();
1✔
272
  // Lock the file to owner-only access. The .gpg-id leaks which keys the
273
  // store is encrypted to; while the typical ~/.password-store is 0700,
274
  // users may relocate the store onto NFS/SMB/USB where the parent dir
275
  // perms are more lax. On platforms where setPermissions is a no-op
276
  // (Windows), this is silently best-effort.
277
  QFile::setPermissions(gpgIdFile, QFile::ReadOwner | QFile::WriteOwner);
1✔
278
  if (!secret_selected) {
1✔
279
    emit critical(
×
280
        tr("Check selected users!"),
×
281
        tr("None of the selected keys have a secret key available.\n"
×
282
           "You will not be able to decrypt any newly added passwords!"));
283
  }
284
}
1✔
285

286
/**
287
 * @brief Signs a GPG ID file and verifies its signature.
288
 * @example
289
 * bool result = ImitatePass::signGpgIdFile(gpgIdFile, signingKeys);
290
 * std::cout << result << std::endl; // Expected output: true if signing and
291
 * verification succeed
292
 *
293
 * @param QString &gpgIdFile - Path to the .gpg-id file to be signed.
294
 * @param QStringList &signingKeys - List of signing keys; only the first key is
295
 * used.
296
 * @return bool - True if the file was signed and its signature verified
297
 * successfully; otherwise false.
298
 */
299
auto ImitatePass::signGpgIdFile(const QString &gpgIdFile,
×
300
                                const QStringList &signingKeys) -> bool {
301
  QStringList args;
×
302
  // Use only the first signing key; multiple --default-key options would
303
  // override each other and only the last one would take effect.
304
  if (!signingKeys.isEmpty()) {
×
305
#ifdef QT_DEBUG
306
    if (signingKeys.size() > 1) {
307
      dbg() << "Multiple signing keys configured; using only the first key:"
308
            << signingKeys.first();
309
    }
310
#endif
311
    args.append(QStringList{"--default-key", signingKeys.first()});
×
312
  }
313
  args.append(QStringList{"--yes", "--detach-sign", gpgIdFile});
×
314
  int result =
315
      Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args);
×
316
  if (result != 0) {
×
317
#ifdef QT_DEBUG
318
    dbg() << "GPG signing failed with code:" << result;
319
#endif
320
    emit critical(tr("GPG signing failed!"),
×
321
                  tr("Failed to sign %1.").arg(gpgIdFile));
×
322
    return false;
×
323
  }
324
  if (!verifyGpgIdFile(gpgIdFile)) {
×
325
    emit critical(tr("Check .gpg-id file signature!"),
×
326
                  tr("Signature for %1 is invalid.").arg(gpgIdFile));
×
327
    return false;
×
328
  }
329
  return true;
330
}
×
331

332
/**
333
 * @brief Adds a GPG ID file and optionally its signature file to git, then
334
 * creates corresponding commit(s).
335
 * @example
336
 * void result = ImitatePass::gitAddGpgId(gpgIdFile, gpgIdSigFile, true, true);
337
 *
338
 * @param const QString &gpgIdFile - Path to the GPG ID file to add and commit.
339
 * @param const QString &gpgIdSigFile - Path to the signature file associated
340
 * with the GPG ID file.
341
 * @param bool addFile - Whether to stage and commit the GPG ID file.
342
 * @param bool addSigFile - Whether to stage and commit the signature file.
343
 * @return void - This function does not return a value.
344
 */
345
void ImitatePass::gitAddGpgId(const QString &gpgIdFile,
×
346
                              const QString &gpgIdSigFile, bool addFile,
347
                              bool addSigFile) {
348
  if (addFile) {
×
349
    executeGit(GIT_ADD, {"add", pgit(gpgIdFile)});
×
350
  }
351
  QString commitPath = gpgIdFile;
352
  commitPath.replace(Util::endsWithGpg(), "");
×
353
  gitCommit(gpgIdFile, "Added " + commitPath + " using QtPass.");
×
354
  if (!addSigFile) {
×
355
    return;
356
  }
357
  executeGit(GIT_ADD, {"add", pgit(gpgIdSigFile)});
×
358
  commitPath = gpgIdSigFile;
×
359
  commitPath.replace(QRegularExpression("\\.gpg$"), "");
×
360
  gitCommit(gpgIdSigFile, "Added " + commitPath + " using QtPass.");
×
361
}
×
362

363
/**
364
 * @brief Initializes the pass entry by writing and optionally signing the GPG
365
 * ID files.
366
 *
367
 * @example
368
 * void result = ImitatePass::Init(path, users);
369
 *
370
 * @param QString path - Base path for the pass entry where ".gpg-id" and
371
 * optional signature files are created.
372
 * @param const QList<UserInfo> &users - List of users whose keys are written
373
 * into the GPG ID file.
374
 * @return void - No return value.
375
 */
376
void ImitatePass::Init(QString path, const QList<UserInfo> &users) {
×
377
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
378
  QStringList signingKeys =
379
      QtPassSettings::getPassSigningKey().split(" ", Qt::SkipEmptyParts);
×
380
#else
381
  QStringList signingKeys =
382
      QtPassSettings::getPassSigningKey().split(" ", QString::SkipEmptyParts);
383
#endif
384
  QString gpgIdSigFile = path + ".gpg-id.sig";
×
385
  bool addSigFile = false;
386
  if (!signingKeys.isEmpty()) {
×
387
    if (!checkSigningKeys(signingKeys)) {
×
388
      emit critical(tr("No signing key!"),
×
389
                    tr("None of the secret signing keys is available.\n"
×
390
                       "You will not be able to change the user list!"));
391
      return;
×
392
    }
393
    QFileInfo checkFile(gpgIdSigFile);
×
394
    if (!checkFile.exists() || !checkFile.isFile()) {
×
395
      addSigFile = true;
396
    }
397
  }
×
398

399
  QString gpgIdFile = path + ".gpg-id";
×
400
  bool addFile = false;
401
  transactionHelper trans(this, PASS_INIT);
×
402
  if (QtPassSettings::isAddGPGId(true)) {
×
403
    QFileInfo checkFile(gpgIdFile);
×
404
    if (!checkFile.exists() || !checkFile.isFile()) {
×
405
      addFile = true;
406
    }
407
  }
×
408
  writeGpgIdFile(gpgIdFile, users);
×
409

410
  if (!signingKeys.isEmpty()) {
×
411
    if (!signGpgIdFile(gpgIdFile, signingKeys)) {
×
412
      return;
413
    }
414
  }
415

416
  if (!QtPassSettings::isUseWebDav() && QtPassSettings::isUseGit() &&
×
417
      !QtPassSettings::getGitExecutable().isEmpty()) {
×
418
    gitAddGpgId(gpgIdFile, gpgIdSigFile, addFile, addSigFile);
×
419
  }
420
  reencryptPath(path);
×
421
}
422

423
/**
424
 * @brief ImitatePass::verifyGpgIdFile verify detached gpgid file signature.
425
 * @param file which gpgid file.
426
 * @return was verification successful?
427
 */
428
auto ImitatePass::verifyGpgIdFile(const QString &file) -> bool {
20✔
429
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
430
  QStringList signingKeys =
431
      QtPassSettings::getPassSigningKey().split(" ", Qt::SkipEmptyParts);
60✔
432
#else
433
  QStringList signingKeys =
434
      QtPassSettings::getPassSigningKey().split(" ", QString::SkipEmptyParts);
435
#endif
436
  if (signingKeys.isEmpty()) {
20✔
437
    return true;
438
  }
439
  QString out;
×
440
  QStringList args =
441
      QStringList{"--verify", "--status-fd=1", pgpg(file) + ".sig", pgpg(file)};
×
442
  int result =
443
      Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args, &out);
×
444
  if (result != 0) {
×
445
#ifdef QT_DEBUG
446
    dbg() << "GPG verify failed with code:" << result;
447
#endif
448
    return false;
449
  }
450
  QRegularExpression re(
451
      R"(^\[GNUPG:\] VALIDSIG ([A-F0-9]{40}) .* ([A-F0-9]{40})\r?$)",
452
      QRegularExpression::MultilineOption);
×
453
  QRegularExpressionMatch m = re.match(out);
×
454
  if (!m.hasMatch()) {
×
455
    return false;
456
  }
457
  QStringList fingerprints = m.capturedTexts();
×
458
  fingerprints.removeFirst();
×
459
  for (auto &key : signingKeys) {
×
460
    if (fingerprints.contains(key)) {
×
461
      return true;
×
462
    }
463
  }
464
  return false;
×
465
}
×
466

467
/**
468
 * @brief ImitatePass::removeDir delete folder recursive.
469
 * @param dirName which folder.
470
 * @return was removal successful?
471
 */
472
auto ImitatePass::removeDir(const QString &dirName) -> bool {
1✔
473
  bool result = true;
474
  QDir dir(dirName);
1✔
475

476
  if (dir.exists(dirName)) {
1✔
477
    for (const QFileInfo &info :
478
         dir.entryInfoList(QDir::NoDotAndDotDot | QDir::System | QDir::Hidden |
479
                               QDir::AllDirs | QDir::Files,
480
                           QDir::DirsFirst)) {
1✔
481
      if (info.isDir()) {
×
482
        result = removeDir(info.absoluteFilePath());
×
483
      } else {
484
        result = QFile::remove(info.absoluteFilePath());
×
485
      }
486

487
      if (!result) {
×
488
        return result;
489
      }
490
    }
491
    result = dir.rmdir(dirName);
1✔
492
  }
493
  return result;
494
}
1✔
495

496
/**
497
 * @brief ImitatePass::reencryptPath reencrypt all files under the chosen
498
 * directory
499
 *
500
 * This is still quite experimental..
501
 * @param dir
502
 */
503
auto ImitatePass::verifyGpgIdForDir(const QString &file,
2✔
504
                                    QStringList &gpgIdFilesVerified,
505
                                    QStringList &gpgId) -> bool {
506
  QString gpgIdPath = Pass::getGpgIdPath(file);
2✔
507
  if (gpgIdFilesVerified.contains(gpgIdPath)) {
2✔
508
    return true;
509
  }
510
  if (!verifyGpgIdFile(gpgIdPath)) {
1✔
511
    emit critical(tr("Check .gpg-id file signature!"),
×
512
                  tr("Signature for %1 is invalid.").arg(gpgIdPath));
×
513
    return false;
×
514
  }
515
  gpgIdFilesVerified.append(gpgIdPath);
516
  gpgId = getRecipientList(file);
2✔
517
  gpgId.sort();
518
  return true;
519
}
520

521
/**
522
 * @brief Extracts and returns a sorted list of valid key IDs from a GPG key
523
 * listing file.
524
 * @example
525
 * QStringList result = ImitatePass::getKeysFromFile(fileName);
526
 * std::cout << result.join(", ").toStdString() << std::endl;
527
 *
528
 * @param fileName - Path to the file used to query and parse GPG key
529
 * information.
530
 * @return QStringList - A sorted list of 16-character key IDs found in the
531
 * file.
532
 */
533
auto ImitatePass::getKeysFromFile(const QString &fileName) -> QStringList {
2✔
534
  QStringList args = {
535
      "-v",          "--no-secmem-warning", "--no-permission-warning",
536
      "--list-only", "--keyid-format=long", pgpg(fileName)};
14✔
537
  QString keys;
2✔
538
  QString err;
2✔
539
  const int result = Executor::executeBlocking(
2✔
540
      QtPassSettings::getGpgExecutable(), args, &keys, &err);
4✔
541
  if (result != 0 && keys.isEmpty() && err.isEmpty()) {
2✔
542
    return {};
×
543
  }
544
  QStringList actualKeys;
2✔
545
  keys += err;
546
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
547
  QStringList key = keys.split(Util::newLinesRegex(), Qt::SkipEmptyParts);
2✔
548
#else
549
  QStringList key = keys.split(Util::newLinesRegex(), QString::SkipEmptyParts);
550
#endif
551
  QListIterator<QString> itr(key);
552
  while (itr.hasNext()) {
8✔
553
    QString current = itr.next();
554
    QStringList cur = current.split(" ");
12✔
555
    if (cur.length() > 4) {
6✔
556
      QString actualKey = cur.takeAt(4);
4✔
557
      if (actualKey.length() == 16) {
4✔
558
        actualKeys << actualKey;
559
      }
560
    }
561
  }
562
  actualKeys.sort();
563
  return actualKeys;
564
}
2✔
565

566
/**
567
 * @brief Re-encrypts a single encrypted file for a new set of recipients.
568
 * @example
569
 * bool result = ImitatePass::reencryptSingleFile(fileName, recipients);
570
 * std::cout << result << std::endl; // Expected output: true on success, false
571
 * on failure
572
 *
573
 * @param const QString &fileName - Path to the encrypted file to re-encrypt.
574
 * @param const QStringList &recipients - List of recipient keys to encrypt the
575
 * file to.
576
 * @return bool - True if the file was successfully decrypted, re-encrypted,
577
 * verified, and replaced; otherwise false.
578
 */
579
auto ImitatePass::reencryptSingleFile(const QString &fileName,
2✔
580
                                      const QStringList &recipients) -> bool {
581
#ifdef QT_DEBUG
582
  dbg() << "reencrypt " << fileName << " for " << recipients;
583
#endif
584
  QString local_lastDecrypt;
2✔
585
  QStringList args = {
586
      "-d",      "--quiet",     "--yes",       "--no-encrypt-to",
587
      "--batch", "--use-agent", pgpg(fileName)};
16✔
588
  int result = Executor::executeBlocking(QtPassSettings::getGpgExecutable(),
4✔
589
                                         args, &local_lastDecrypt);
590

591
  if (result != 0 || local_lastDecrypt.isEmpty()) {
2✔
592
#ifdef QT_DEBUG
593
    dbg() << "Decrypt error on re-encrypt for:" << fileName;
594
#endif
595
    return false;
596
  }
597

598
  if (local_lastDecrypt.right(1) != "\n") {
4✔
599
    local_lastDecrypt += "\n";
×
600
  }
601

602
  // Use passed recipients instead of re-reading from file
603
  if (recipients.isEmpty()) {
2✔
604
    emit critical(tr("Can not edit"),
×
605
                  tr("Could not read encryption key to use, .gpg-id "
×
606
                     "file missing or invalid."));
607
    return false;
×
608
  }
609

610
  // Encrypt to temporary file for atomic replacement
611
  QString tempPath = fileName + ".reencrypt.tmp";
2✔
612
  args = QStringList{"--yes", "--batch", "-eq", "--output", pgpg(tempPath)};
14✔
613
  for (const auto &i : recipients) {
4✔
614
    args.append("-r");
4✔
615
    args.append(i);
616
  }
617
  args.append("-");
4✔
618
  result = Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args,
4✔
619
                                     local_lastDecrypt);
620

621
  if (result != 0) {
2✔
622
#ifdef QT_DEBUG
623
    dbg() << "Encrypt error on re-encrypt for:" << fileName;
624
#endif
625
    QFile::remove(tempPath);
×
626
    return false;
627
  }
628

629
  // Verify encryption worked by attempting to decrypt the temp file
630
  QString verifyOutput;
2✔
631
  args = QStringList{"-d", "--quiet", "--batch", "--use-agent", pgpg(tempPath)};
14✔
632
  result = Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args,
4✔
633
                                     &verifyOutput);
634
  if (result != 0 || verifyOutput.isEmpty()) {
2✔
635
#ifdef QT_DEBUG
636
    dbg() << "Verification failed for:" << tempPath;
637
#endif
638
    QFile::remove(tempPath);
×
639
    return false;
640
  }
641
  // Verify content matches original decrypted content (defense in depth)
642
  if (verifyOutput.trimmed() != local_lastDecrypt.trimmed()) {
2✔
643
#ifdef QT_DEBUG
644
    dbg() << "Verification content mismatch for:" << tempPath;
645
#endif
646
    QFile::remove(tempPath);
×
647
    return false;
648
  }
649

650
  // Atomic replace with backup: rename original to .bak, rename temp to
651
  // original, then remove backup
652
  QString backupPath = fileName + ".reencrypt.bak";
2✔
653
  if (!QFile::rename(fileName, backupPath)) {
2✔
654
#ifdef QT_DEBUG
655
    dbg() << "Failed to backup original file:" << fileName;
656
#endif
657
    QFile::remove(tempPath);
×
658
    return false;
659
  }
660
  if (!QFile::rename(tempPath, fileName)) {
2✔
661
#ifdef QT_DEBUG
662
    dbg() << "Failed to rename temp file to:" << fileName;
663
#endif
664
    // Restore backup and clean up temp file
665
    QFile::rename(backupPath, fileName);
×
666
    QFile::remove(tempPath);
×
667
    emit critical(
×
668
        tr("Re-encryption failed"),
×
669
        tr("Failed to replace %1. Original has been restored.").arg(fileName));
×
670
    return false;
×
671
  }
672
  // Success - remove backup
673
  QFile::remove(backupPath);
2✔
674

675
  if (!QtPassSettings::isUseWebDav() && QtPassSettings::isUseGit()) {
2✔
676
    Executor::executeBlocking(QtPassSettings::getGitExecutable(),
×
677
                              {"add", pgit(fileName)});
678
    QString path =
679
        QDir(QtPassSettings::getPassStore()).relativeFilePath(fileName);
×
680
    path.replace(Util::endsWithGpg(), "");
×
681
    Executor::executeBlocking(QtPassSettings::getGitExecutable(),
×
682
                              {"commit", pgit(fileName), "-m",
683
                               "Re-encrypt for " + path + " using QtPass."});
×
684
  }
685

686
  return true;
687
}
6✔
688

689
/**
690
 * @brief Create git backup commit before re-encryption.
691
 * @return true if backup created or not needed, false if backup failed.
692
 */
693
auto ImitatePass::createBackupCommit() -> bool {
1✔
694
  if (!QtPassSettings::isUseGit() ||
2✔
695
      QtPassSettings::getGitExecutable().isEmpty()) {
1✔
696
    return true;
697
  }
698
  emit statusMsg(tr("Creating backup commit"), 2000);
×
699
  const QString git = QtPassSettings::getGitExecutable();
×
700
  QString statusOut;
×
701
  if (Executor::executeBlocking(git, {"status", "--porcelain"}, &statusOut) !=
×
702
      0) {
703
    emit critical(
×
704
        tr("Backup commit failed"),
×
705
        tr("Could not inspect git status. Re-encryption was aborted."));
×
706
    return false;
×
707
  }
708
  if (!statusOut.trimmed().isEmpty()) {
×
709
    if (Executor::executeBlocking(git, {"add", "-A"}) != 0 ||
×
710
        Executor::executeBlocking(
×
711
            git, {"commit", "-m", "Backup before re-encryption"}) != 0) {
712
      emit critical(tr("Backup commit failed"),
×
713
                    tr("Re-encryption was aborted because a git backup could "
×
714
                       "not be created."));
715
      return false;
×
716
    }
717
  }
718
  return true;
719
}
×
720

721
/**
722
 * @brief Re-encrypts all `.gpg` files under the given directory using the
723
 *        verified GPG key configuration for each folder.
724
 *
725
 * This method optionally pulls the latest changes before starting, creates a
726
 * backup commit, verifies `.gpg-id` files per directory, and re-encrypts files
727
 * whose current recipients do not match the expected keys. It emits progress,
728
 * status, and error signals throughout the process, and optionally pushes the
729
 * updated password-store when finished.
730
 *
731
 * @param dir - Root directory to scan recursively for `.gpg` files.
732
 * @return void
733
 */
734
void ImitatePass::reencryptPath(const QString &dir) {
1✔
735
  emit statusMsg(tr("Re-encrypting from folder %1").arg(dir), 3000);
2✔
736
  emit startReencryptPath();
1✔
737
  if (QtPassSettings::isAutoPull()) {
1✔
738
    emit statusMsg(tr("Updating password-store"), 2000);
×
739
    GitPull_b();
×
740
  }
741

742
  // Create backup before re-encryption - abort if it fails
743
  if (!createBackupCommit()) {
1✔
744
    emit endReencryptPath();
×
745
    return;
×
746
  }
747

748
  QDir currentDir;
2✔
749
  QDirIterator gpgFiles(dir, QStringList() << "*.gpg", QDir::Files,
2✔
750
                        QDirIterator::Subdirectories);
1✔
751
  QStringList gpgIdFilesVerified;
1✔
752
  QStringList gpgId;
1✔
753
  int successCount = 0;
754
  int failCount = 0;
755
  while (gpgFiles.hasNext()) {
3✔
756
    QString fileName = gpgFiles.next();
2✔
757
    if (gpgFiles.fileInfo().path() != currentDir.path()) {
4✔
758
      if (!verifyGpgIdForDir(fileName, gpgIdFilesVerified, gpgId)) {
2✔
759
        emit endReencryptPath();
×
760
        return;
761
      }
762
      if (gpgId.isEmpty() && !gpgIdFilesVerified.isEmpty()) {
2✔
763
        emit critical(tr("GPG ID verification failed"),
×
764
                      tr("Could not verify .gpg-id for directory."));
×
765
        emit endReencryptPath();
×
766
        return;
767
      }
768
    }
769
    QStringList actualKeys = getKeysFromFile(fileName);
2✔
770
    if (actualKeys != gpgId) {
2✔
771
      if (reencryptSingleFile(fileName, gpgId)) {
2✔
772
        successCount++;
2✔
773
      } else {
774
        failCount++;
×
775
        emit critical(tr("Re-encryption failed"),
×
776
                      tr("Failed to re-encrypt %1").arg(fileName));
×
777
      }
778
    }
779
  }
780

781
  if (failCount > 0) {
1✔
782
    emit statusMsg(tr("Re-encryption completed: %1 succeeded, %2 failed")
×
783
                       .arg(successCount)
×
784
                       .arg(failCount),
×
785
                   5000);
786
  } else {
787
    emit statusMsg(
1✔
788
        tr("Re-encryption completed: %1 files re-encrypted").arg(successCount),
2✔
789
        3000);
790
  }
791

792
  if (QtPassSettings::isAutoPush()) {
1✔
793
    emit statusMsg(tr("Updating password-store"), 2000);
×
794
    GitPush();
×
795
  }
796
  emit endReencryptPath();
1✔
797
}
1✔
798

799
/**
800
 * @brief Resolves the final destination path for moving a file or directory,
801
 * applying .gpg handling for files.
802
 * @example
803
 * QString result = ImitatePass::resolveMoveDestination("/tmp/source.txt",
804
 * "/backup", false); std::cout << result.toStdString() << std::endl; //
805
 * Expected output sample: "/backup/source.txt.gpg"
806
 *
807
 * @param src - Source path to the file or directory.
808
 * @param dest - Requested destination path, which may be a file or directory.
809
 * @param force - When true, allows overwriting an existing destination file.
810
 * @return QString - Resolved destination path, or an empty QString if the
811
 * source/destination is invalid or conflicts occur.
812
 */
813
auto ImitatePass::resolveMoveDestination(const QString &src,
6✔
814
                                         const QString &dest, bool force)
815
    -> QString {
816
  QFileInfo srcFileInfo(src);
6✔
817
  QFileInfo destFileInfo(dest);
6✔
818
  QString destFile;
6✔
819
  QString srcFileBaseName = srcFileInfo.fileName();
6✔
820

821
  if (srcFileInfo.isFile()) {
6✔
822
    if (destFileInfo.isFile()) {
5✔
823
      if (!force) {
2✔
824
#ifdef QT_DEBUG
825
        dbg() << "Destination file already exists";
826
#endif
827
        return {};
828
      }
829
      destFile = dest;
1✔
830
    } else if (destFileInfo.isDir()) {
3✔
831
      destFile = QDir(dest).filePath(srcFileBaseName);
2✔
832
    } else {
833
      destFile = dest;
2✔
834
    }
835

836
    if (destFile.endsWith(".gpg", Qt::CaseInsensitive)) {
8✔
837
      destFile.chop(4);
4✔
838
    }
839
    destFile.append(".gpg");
4✔
840
  } else if (srcFileInfo.isDir()) {
1✔
841
    if (destFileInfo.isDir()) {
×
842
      destFile = QDir(dest).filePath(srcFileBaseName);
×
843
    } else if (destFileInfo.isFile()) {
×
844
#ifdef QT_DEBUG
845
      dbg() << "Destination is a file";
846
#endif
847
      return {};
848
    } else {
849
      destFile = dest;
×
850
    }
851
  } else {
852
#ifdef QT_DEBUG
853
    dbg() << "Source file does not exist";
854
#endif
855
    return {};
856
  }
857
  return destFile;
858
}
6✔
859

860
/**
861
 * @brief Moves a password store item in the Git repository and commits the
862
 * change.
863
 * @example
864
 * void result = className.executeMoveGit(src, destFile, force);
865
 *
866
 * @param const QString &src - Source path of the item to move.
867
 * @param const QString &destFile - Destination path of the item after the move.
868
 * @param bool force - Whether to force the move using Git's -f option.
869
 * @return void - This method does not return a value.
870
 */
871
void ImitatePass::executeMoveGit(const QString &src, const QString &destFile,
×
872
                                 bool force) {
873
  QStringList args;
×
874
  args << "mv";
×
875
  if (force) {
×
876
    args << "-f";
×
877
  }
878
  args << pgit(src);
×
879
  args << pgit(destFile);
×
880
  executeGit(GIT_MOVE, args);
×
881

882
  QString relSrc = QDir(QtPassSettings::getPassStore()).relativeFilePath(src);
×
883
  relSrc.replace(Util::endsWithGpg(), "");
×
884
  QString relDest =
885
      QDir(QtPassSettings::getPassStore()).relativeFilePath(destFile);
×
886
  relDest.replace(Util::endsWithGpg(), "");
×
887
  QString message = QString("Moved for %1 to %2 using QtPass.");
×
888
  message = message.arg(relSrc, relDest);
×
889
  gitCommit("", message);
×
890
}
×
891

892
/**
893
 * @brief Moves a password entry from the source path to the destination path.
894
 * @example
895
 * ImitatePass::Move(src, dest, true);
896
 *
897
 * @param const QString src - The source path or entry name to move.
898
 * @param const QString dest - The destination path or entry name.
899
 * @param const bool force - If true, overwrites an existing destination entry
900
 * when necessary.
901
 * @return void - This function does not return a value.
902
 */
903
void ImitatePass::Move(const QString src, const QString dest,
1✔
904
                       const bool force) {
905
  transactionHelper trans(this, PASS_MOVE);
1✔
906
  QString destFile = resolveMoveDestination(src, dest, force);
1✔
907
  if (destFile.isEmpty()) {
1✔
908
    return;
909
  }
910

911
#ifdef QT_DEBUG
912
  dbg() << "Move Source: " << src;
913
  dbg() << "Move Destination: " << destFile;
914
#endif
915

916
  if (QtPassSettings::isUseGit()) {
1✔
917
    executeMoveGit(src, destFile, force);
×
918
  } else {
919
    QDir qDir;
1✔
920
    if (force) {
1✔
921
      qDir.remove(destFile);
×
922
    }
923
    qDir.rename(src, destFile);
1✔
924
  }
1✔
925
}
926

927
/**
928
 * @brief Copies a file or directory from source to destination, optionally
929
 * forcing overwrite.
930
 * @example
931
 * void result = ImitatePass::Copy(src, dest, force);
932
 *
933
 * @param QString src - Source path to copy from.
934
 * @param QString dest - Destination path to copy to.
935
 * @param bool force - If true, overwrites the destination when it already
936
 * exists.
937
 * @return void - This function does not return a value.
938
 */
939
void ImitatePass::Copy(const QString src, const QString dest,
1✔
940
                       const bool force) {
941
  QFileInfo destFileInfo(dest);
1✔
942
  transactionHelper trans(this, PASS_COPY);
1✔
943
  if (QtPassSettings::isUseGit()) {
1✔
944
    QStringList args;
×
945
    args << "cp";
×
946
    if (force) {
×
947
      args << "-f";
×
948
    }
949
    args << pgit(src);
×
950
    args << pgit(dest);
×
951
    executeGit(GIT_COPY, args);
×
952

953
    QString message = QString("Copied from %1 to %2 using QtPass.");
×
954
    message = message.arg(src, dest);
×
955
    gitCommit("", message);
×
956
  } else {
957
    QDir qDir;
1✔
958
    if (force) {
1✔
959
      qDir.remove(dest);
×
960
    }
961
    QFile::copy(src, dest);
1✔
962
  }
1✔
963
  // reecrypt all files under the new folder
964
  if (destFileInfo.isDir()) {
1✔
965
    reencryptPath(destFileInfo.absoluteFilePath());
×
966
  } else if (destFileInfo.isFile()) {
1✔
967
    reencryptPath(destFileInfo.dir().path());
2✔
968
  }
969
}
1✔
970

971
/**
972
 * @brief ImitatePass::executeGpg easy wrapper for running gpg commands
973
 * @param args
974
 */
975
void ImitatePass::executeGpg(PROCESS id, const QStringList &args, QString input,
29✔
976
                             bool readStdout, bool readStderr) {
977
  executeWrapper(id, QtPassSettings::getGpgExecutable(), args, std::move(input),
58✔
978
                 readStdout, readStderr);
979
}
29✔
980

981
/**
982
 * @brief ImitatePass::executeGit easy wrapper for running git commands
983
 * @param args
984
 */
985
void ImitatePass::executeGit(PROCESS id, const QStringList &args, QString input,
2✔
986
                             bool readStdout, bool readStderr) {
987
  executeWrapper(id, QtPassSettings::getGitExecutable(), args, std::move(input),
4✔
988
                 readStdout, readStderr);
989
}
2✔
990

991
/**
992
 * @brief ImitatePass::finished this function is overloaded to ensure
993
 *                              identical behaviour to RealPass ie. only PASS_*
994
 *                              processes are visible inside Pass::finish, so
995
 *                              that interface-wise it all looks the same
996
 * @param id
997
 * @param exitCode
998
 * @param out
999
 * @param err
1000
 */
1001
void ImitatePass::finished(int id, int exitCode, const QString &out,
31✔
1002
                           const QString &err) {
1003
#ifdef QT_DEBUG
1004
  dbg() << "Imitate Pass";
1005
#endif
1006
  static QString transactionOutput;
31✔
1007
  PROCESS pid = transactionIsOver(static_cast<PROCESS>(id));
31✔
1008
  transactionOutput.append(out);
31✔
1009

1010
  if (exitCode == 0) {
31✔
1011
    if (pid == INVALID) {
31✔
1012
      return;
1013
    }
1014
  } else {
1015
    while (pid == INVALID) {
×
1016
      id = exec.cancelNext();
×
1017
      if (id == -1) {
×
1018
        //  this is probably irrecoverable and shall not happen
1019
#ifdef QT_DEBUG
1020
        dbg() << "No such transaction!";
1021
#endif
1022
        return;
1023
      }
1024
      pid = transactionIsOver(static_cast<PROCESS>(id));
×
1025
    }
1026
  }
1027
  Pass::finished(pid, exitCode, transactionOutput, err);
29✔
1028
  transactionOutput.clear();
29✔
1029
}
1030

1031
/**
1032
 * @brief executeWrapper    overridden so that every execution is a transaction
1033
 * @param id
1034
 * @param app
1035
 * @param args
1036
 * @param input
1037
 * @param readStdout
1038
 * @param readStderr
1039
 */
1040
void ImitatePass::executeWrapper(PROCESS id, const QString &app,
31✔
1041
                                 const QStringList &args, QString input,
1042
                                 bool readStdout, bool readStderr) {
1043
  transactionAdd(id);
31✔
1044
  Pass::executeWrapper(id, app, args, input, readStdout, readStderr);
31✔
1045
}
31✔
1046

1047
/**
1048
 * @brief Decrypt one .gpg file and return lines matching rx.
1049
 */
1050
auto ImitatePass::grepMatchFile(const QStringList &env, const QString &gpgExe,
9✔
1051
                                const QString &filePath,
1052
                                const QRegularExpression &rx) -> QStringList {
1053
  QString plaintext;
9✔
1054
  const int rc =
1055
      Executor::executeBlocking(env, gpgExe,
81✔
1056
                                {"-d", "--quiet", "--yes", "--no-encrypt-to",
1057
                                 "--batch", "--use-agent", pgpg(filePath)},
1058
                                &plaintext);
1059
  if (rc != 0 || plaintext.isEmpty())
9✔
1060
    return {};
3✔
1061
  QStringList matches;
6✔
1062
  for (const QString &line : plaintext.split('\n')) {
30✔
1063
    QString candidate = line;
1064
    if (candidate.endsWith('\r'))
18✔
1065
      candidate.chop(1);
×
1066
    const QString t = candidate.trimmed();
1067
    if (!t.isEmpty() && candidate.contains(rx))
18✔
1068
      matches << t;
1069
  }
1070
  return matches;
1071
}
9✔
1072

1073
/**
1074
 * @brief Walk the store, decrypt every .gpg file, collect matches.
1075
 */
1076
auto ImitatePass::grepScanStore(const QStringList &env, const QString &gpgExe,
5✔
1077
                                const QString &storeDir,
1078
                                const QRegularExpression &rx)
1079
    -> QList<QPair<QString, QStringList>> {
1080
  QList<QPair<QString, QStringList>> results;
5✔
1081
  QDirIterator it(storeDir, QStringList() << "*.gpg", QDir::Files,
10✔
1082
                  QDirIterator::Subdirectories);
5✔
1083
  while (it.hasNext()) {
13✔
1084
    if (QThread::currentThread()->isInterruptionRequested())
8✔
1085
      return {};
×
1086
    const QString filePath = it.next();
8✔
1087
    const QStringList matches = grepMatchFile(env, gpgExe, filePath, rx);
8✔
1088
    if (!matches.isEmpty()) {
8✔
1089
      QString entry = QDir(storeDir).relativeFilePath(filePath);
6✔
1090
      if (entry.endsWith(QLatin1String(".gpg")))
6✔
1091
        entry.chop(4);
6✔
1092
      results.append({entry, matches});
6✔
1093
    }
1094
  }
1095
  return results;
1096
}
5✔
1097

1098
/**
1099
 * @brief Search all password content by GPG-decrypting each .gpg file.
1100
 *
1101
 * Runs a background thread to avoid blocking the UI. Results are emitted on
1102
 * the main thread via QMetaObject::invokeMethod. A sequence counter discards
1103
 * results from superseded searches.
1104
 */
1105
void ImitatePass::Grep(QString pattern, bool caseInsensitive) {
5✔
1106
  for (QThread *t : std::as_const(m_grepThreads))
5✔
1107
    if (t && t->isRunning())
×
1108
      t->requestInterruption();
×
1109
  // No wait() — blocking the UI thread while GPG decrypts would freeze the
1110
  // interface. Stale results are discarded via the sequence counter.
1111

1112
  // Advance the sequence before any early return so in-flight workers from the
1113
  // previous query fail the seq check and cannot publish stale results.
1114
  const int seq = ++m_grepSeq;
5✔
1115

1116
  // Use trimmed() rather than isEmpty(): a whitespace-only string is a valid
1117
  // regex that matches every non-empty line, which is almost never intentional
1118
  // and would decrypt the entire store.
1119
  //
1120
  // Both early returns post finishedGrep via Qt::QueuedConnection so that the
1121
  // signal is always delivered asynchronously after Grep() returns, matching
1122
  // the contract of the threaded path.
1123
  if (pattern.trimmed().isEmpty()) {
5✔
1124
    QMetaObject::invokeMethod(
×
1125
        this,
1126
        [this, seq]() {
×
1127
          if (m_grepSeq == seq)
×
1128
            emit finishedGrep({});
×
1129
        },
×
1130
        Qt::QueuedConnection);
1131
    return;
1✔
1132
  }
1133

1134
  const QRegularExpression rx(
1135
      pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
1136
                               : QRegularExpression::PatternOptions{});
10✔
1137
  if (!rx.isValid()) {
5✔
1138
    QMetaObject::invokeMethod(
1✔
1139
        this,
1140
        [this, seq]() {
1✔
1141
          if (m_grepSeq == seq)
1✔
1142
            emit finishedGrep({});
2✔
1143
        },
1✔
1144
        Qt::QueuedConnection);
1145
    return;
1146
  }
1147
  const QString gpgExe = QtPassSettings::getGpgExecutable();
8✔
1148
  const QString storeDir = QtPassSettings::getPassStore();
8✔
1149
  const QStringList env = exec.environment();
4✔
1150
  QPointer<ImitatePass> self(this);
1151

1152
  auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
8✔
1153
    if (!self)
4✔
1154
      return;
1155
    QMetaObject::invokeMethod(
4✔
1156
        self,
1157
        [self, seq, results = std::move(results)]() {
12✔
1158
          if (self && self->m_grepSeq == seq)
8✔
1159
            emit self->finishedGrep(results);
4✔
1160
        },
4✔
1161
        Qt::QueuedConnection);
1162
  };
4✔
1163

1164
  QThread *thread = QThread::create(
4✔
1165
      [gpgExe, storeDir, env, rx, emitResults = std::move(emitResults)]() {
8✔
1166
        std::move(emitResults)(grepScanStore(env, gpgExe, storeDir, rx));
4✔
1167
      });
4✔
1168

1169
  m_grepThreads.append(thread);
4✔
1170
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
4✔
1171
  connect(thread, &QThread::finished, this,
4✔
1172
          [this, thread]() { m_grepThreads.removeOne(thread); });
8✔
1173
  thread->start();
4✔
1174
}
5✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc