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

IJHack / QtPass / 27660144570

17 Jun 2026 01:44AM UTC coverage: 57.523% (+0.05%) from 57.477%
27660144570

Pull #1551

github

web-flow
Merge bae10e9e5 into 4da97561f
Pull Request #1551: refactor(pass): inject AppSettings into Pass hierarchy (PR A of #1511)

42 of 72 new or added lines in 4 files covered. (58.33%)

11 existing lines in 6 files now uncovered.

3980 of 6919 relevant lines covered (57.52%)

23.94 hits per line

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

53.52
/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 "util.h"
6
#include <QDirIterator>
7
#include <QElapsedTimer>
8
#include <QPointer>
9
#include <QRegularExpression>
10
#include <QThread>
11
#include <utility>
12

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

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

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

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

63
auto ImitatePass::pgit(const QString &path) const -> QString {
2✔
64
  if (!m_settings.gitExecutable.startsWith(QStringLiteral("wsl ")))
4✔
65
    return path;
NEW
66
  QString res = QStringLiteral("$(wslpath ") + path + QLatin1Char(')');
×
67
  return res.replace('\\', '/');
×
68
}
69

70
auto ImitatePass::pgpg(const QString &path) const -> QString {
37✔
71
  if (!m_settings.gpgExecutable.startsWith(QStringLiteral("wsl ")))
74✔
72
    return path;
NEW
73
  QString res = QStringLiteral("$(wslpath ") + path + QLatin1Char(')');
×
74
  return res.replace('\\', '/');
×
75
}
76

77
/**
78
 * @brief ImitatePass::GitInit git init wrapper
79
 */
80
void ImitatePass::GitInit() {
×
NEW
81
  executeGit(GIT_INIT, {"init", pgit(m_settings.passStore)});
×
82
}
×
83

84
/**
85
 * @brief ImitatePass::GitPull git pull wrapper
86
 */
87
void ImitatePass::GitPull() { executeGit(GIT_PULL, {"pull"}); }
×
88

89
/**
90
 * @brief ImitatePass::GitPull_b git pull wrapper
91
 */
92
void ImitatePass::GitPull_b() {
×
NEW
93
  Executor::executeBlocking(m_settings.gitExecutable, {"pull"});
×
94
}
×
95

96
/**
97
 * @brief ImitatePass::GitPush git push wrapper
98
 */
99
void ImitatePass::GitPush() {
×
NEW
100
  if (m_settings.useGit) {
×
101
    executeGit(GIT_PUSH, {"push"});
×
102
  }
103
}
×
104

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

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

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

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

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

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

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

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

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

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

394
  QString gpgIdFile = path + ".gpg-id";
×
395
  bool addFile = false;
396
  transactionHelper trans(this, PASS_INIT);
×
NEW
397
  if (m_settings.addGPGId) {
×
398
    QFileInfo checkFile(gpgIdFile);
×
399
    if (!checkFile.exists() || !checkFile.isFile()) {
×
400
      addFile = true;
401
    }
402
  }
×
403
  writeGpgIdFile(gpgIdFile, users);
×
404

405
  if (!signingKeys.isEmpty()) {
×
406
    if (!signGpgIdFile(gpgIdFile, signingKeys)) {
×
407
      return;
408
    }
409
  }
410

NEW
411
  if (!m_settings.useWebDav && m_settings.useGit &&
×
412
      !m_settings.gitExecutable.isEmpty()) {
UNCOV
413
    gitAddGpgId(gpgIdFile, gpgIdSigFile, addFile, addSigFile);
×
414
  }
415
  reencryptPath(path);
×
416
}
417

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

461
/**
462
 * @brief ImitatePass::removeDir delete folder recursive.
463
 * @param dirName which folder.
464
 * @return was removal successful?
465
 */
466
auto ImitatePass::removeDir(const QString &dirName) -> bool {
1✔
467
  bool result = true;
468
  QDir dir(dirName);
1✔
469

470
  if (dir.exists(dirName)) {
1✔
471
    for (const QFileInfo &info :
472
         dir.entryInfoList(QDir::NoDotAndDotDot | QDir::System | QDir::Hidden |
473
                               QDir::AllDirs | QDir::Files,
474
                           QDir::DirsFirst)) {
1✔
475
      if (info.isDir()) {
×
476
        result = removeDir(info.absoluteFilePath());
×
477
      } else {
478
        result = QFile::remove(info.absoluteFilePath());
×
479
      }
480

481
      if (!result) {
×
482
        return result;
483
      }
484
    }
485
    result = dir.rmdir(dirName);
1✔
486
  }
487
  return result;
488
}
1✔
489

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

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

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

585
  if (result != 0 || local_lastDecrypt.isEmpty()) {
2✔
586
#ifdef QT_DEBUG
587
    dbg() << "Decrypt error on re-encrypt for:" << fileName;
588
#endif
589
    return false;
590
  }
591

592
  if (local_lastDecrypt.right(1) != "\n") {
4✔
593
    local_lastDecrypt += "\n";
×
594
  }
595

596
  // Use passed recipients instead of re-reading from file
597
  if (recipients.isEmpty()) {
2✔
598
    emit critical(tr("Can not edit"),
×
599
                  tr("Could not read encryption key to use, .gpg-id "
×
600
                     "file missing or invalid."));
601
    return false;
×
602
  }
603

604
  // Encrypt to temporary file for atomic replacement
605
  QString tempPath = fileName + ".reencrypt.tmp";
2✔
606
  args = QStringList{"--yes", "--batch", "-eq", "--output", pgpg(tempPath)};
14✔
607
  for (const auto &i : recipients) {
4✔
608
    args.append("-r");
4✔
609
    args.append(i);
610
  }
611
  args.append("-");
2✔
612
  result = Executor::executeBlocking(m_settings.gpgExecutable, args,
2✔
613
                                     local_lastDecrypt);
614

615
  if (result != 0) {
2✔
616
#ifdef QT_DEBUG
617
    dbg() << "Encrypt error on re-encrypt for:" << fileName;
618
#endif
619
    QFile::remove(tempPath);
×
620
    return false;
621
  }
622

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

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

669
  if (!m_settings.useWebDav && m_settings.useGit) {
2✔
NEW
670
    Executor::executeBlocking(m_settings.gitExecutable,
×
671
                              {"add", pgit(fileName)});
NEW
672
    QString path = QDir(m_settings.passStore).relativeFilePath(fileName);
×
673
    path.replace(Util::endsWithGpg(), "");
×
NEW
674
    Executor::executeBlocking(m_settings.gitExecutable,
×
675
                              {"commit", pgit(fileName), "-m",
676
                               "Re-encrypt for " + path + " using QtPass."});
×
677
  }
678

679
  return true;
680
}
6✔
681

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

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

734
  // Create backup before re-encryption - abort if it fails
735
  if (!createBackupCommit()) {
1✔
736
    emit endReencryptPath();
×
737
    return;
×
738
  }
739

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

773
  if (failCount > 0) {
1✔
774
    emit statusMsg(tr("Re-encryption completed: %1 succeeded, %2 failed")
×
775
                       .arg(successCount)
×
776
                       .arg(failCount),
×
777
                   5000);
778
  } else {
779
    emit statusMsg(
1✔
780
        tr("Re-encryption completed: %1 files re-encrypted").arg(successCount),
2✔
781
        3000);
782
  }
783

784
  if (m_settings.autoPush) {
1✔
785
    emit statusMsg(tr("Updating password-store"), 2000);
×
786
    GitPush();
×
787
  }
788
  emit endReencryptPath();
1✔
789
}
1✔
790

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

813
  if (srcFileInfo.isFile()) {
6✔
814
    if (destFileInfo.isFile()) {
5✔
815
      if (!force) {
2✔
816
#ifdef QT_DEBUG
817
        dbg() << "Destination file already exists";
818
#endif
819
        return {};
820
      }
821
      destFile = dest;
1✔
822
    } else if (destFileInfo.isDir()) {
3✔
823
      destFile = QDir(dest).filePath(srcFileBaseName);
2✔
824
    } else {
825
      destFile = dest;
2✔
826
    }
827

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

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

NEW
874
  QString relSrc = QDir(m_settings.passStore).relativeFilePath(src);
×
875
  relSrc.replace(Util::endsWithGpg(), "");
×
NEW
876
  QString relDest = QDir(m_settings.passStore).relativeFilePath(destFile);
×
877
  relDest.replace(Util::endsWithGpg(), "");
×
878
  QString message = QString("Moved for %1 to %2 using QtPass.");
×
879
  message = message.arg(relSrc, relDest);
×
880
  gitCommit("", message);
×
881
}
×
882

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

902
#ifdef QT_DEBUG
903
  dbg() << "Move Source: " << src;
904
  dbg() << "Move Destination: " << destFile;
905
#endif
906

907
  if (m_settings.useGit) {
1✔
908
    executeMoveGit(src, destFile, force);
×
909
  } else {
910
    QDir qDir;
1✔
911
    if (force) {
1✔
912
      qDir.remove(destFile);
×
913
    }
914
    qDir.rename(src, destFile);
1✔
915
  }
1✔
916
}
917

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

944
    QString message = QString("Copied from %1 to %2 using QtPass.");
×
945
    message = message.arg(src, dest);
×
946
    gitCommit("", message);
×
947
  } else {
948
    QDir qDir;
1✔
949
    if (force) {
1✔
950
      qDir.remove(dest);
×
951
    }
952
    QFile::copy(src, dest);
1✔
953
  }
1✔
954
  // reecrypt all files under the new folder
955
  if (destFileInfo.isDir()) {
1✔
956
    reencryptPath(destFileInfo.absoluteFilePath());
×
957
  } else if (destFileInfo.isFile()) {
1✔
958
    reencryptPath(destFileInfo.dir().path());
2✔
959
  }
960
}
1✔
961

962
/**
963
 * @brief ImitatePass::executeGpg easy wrapper for running gpg commands
964
 * @param args
965
 */
966
void ImitatePass::executeGpg(PROCESS id, const QStringList &args, QString input,
29✔
967
                             bool readStdout, bool readStderr) {
968
  executeWrapper(id, m_settings.gpgExecutable, args, std::move(input),
29✔
969
                 readStdout, readStderr);
970
}
29✔
971

972
/**
973
 * @brief ImitatePass::executeGit easy wrapper for running git commands
974
 * @param args
975
 */
976
void ImitatePass::executeGit(PROCESS id, const QStringList &args, QString input,
2✔
977
                             bool readStdout, bool readStderr) {
978
  executeWrapper(id, m_settings.gitExecutable, args, std::move(input),
2✔
979
                 readStdout, readStderr);
980
}
2✔
981

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

1001
  if (exitCode == 0) {
31✔
1002
    if (pid == INVALID) {
31✔
1003
      return;
1004
    }
1005
  } else {
1006
    while (pid == INVALID) {
×
1007
      id = exec.cancelNext();
×
1008
      if (id == -1) {
×
1009
        //  this is probably irrecoverable and shall not happen
1010
#ifdef QT_DEBUG
1011
        dbg() << "No such transaction!";
1012
#endif
1013
        return;
1014
      }
1015
      pid = transactionIsOver(static_cast<PROCESS>(id));
×
1016
    }
1017
  }
1018
  Pass::finished(pid, exitCode, transactionOutput, err);
29✔
1019
  transactionOutput.clear();
29✔
1020
}
1021

1022
/**
1023
 * @brief Register a transaction before each wrapped execution.
1024
 *
1025
 * Native mode treats every git/gpg invocation as a transaction; the base
1026
 * Pass::executeWrapper calls this hook just before dispatching.
1027
 * @param id Process identifier of the command about to run.
1028
 */
1029
void ImitatePass::beforeExecute(PROCESS id) { transactionAdd(id); }
31✔
1030

1031
/**
1032
 * @brief Decrypt one .gpg file and return lines matching rx.
1033
 */
1034
auto ImitatePass::grepMatchFile(const QProcessEnvironment &env,
9✔
1035
                                const QString &gpgExe, const QString &filePath,
1036
                                const QRegularExpression &rx) -> QStringList {
1037
  QString translatedPath = filePath;
1038
  if (gpgExe.startsWith(QStringLiteral("wsl "))) {
18✔
NEW
1039
    translatedPath = QStringLiteral("$(wslpath ") + filePath + QLatin1Char(')');
×
NEW
1040
    translatedPath.replace('\\', '/');
×
1041
  }
1042
  QString plaintext;
9✔
1043
  const int rc =
1044
      Executor::executeBlocking(env, gpgExe,
81✔
1045
                                {"-d", "--quiet", "--yes", "--no-encrypt-to",
1046
                                 "--batch", "--use-agent", translatedPath},
1047
                                &plaintext);
1048
  if (rc != 0 || plaintext.isEmpty())
9✔
1049
    return {};
3✔
1050
  QStringList matches;
6✔
1051
  for (const QString &line : plaintext.split('\n')) {
30✔
1052
    QString candidate = line;
1053
    if (candidate.endsWith('\r'))
18✔
1054
      candidate.chop(1);
×
1055
    const QString t = candidate.trimmed();
1056
    if (!t.isEmpty() && candidate.contains(rx))
18✔
1057
      matches << t;
1058
  }
1059
  return matches;
1060
}
9✔
1061

1062
/**
1063
 * @brief Walk the store, decrypt every .gpg file, collect matches.
1064
 */
1065
auto ImitatePass::grepScanStore(const QProcessEnvironment &env,
5✔
1066
                                const QString &gpgExe, const QString &storeDir,
1067
                                const QRegularExpression &rx)
1068
    -> QList<QPair<QString, QStringList>> {
1069
  QList<QPair<QString, QStringList>> results;
5✔
1070
  QDirIterator it(storeDir, QStringList() << "*.gpg", QDir::Files,
10✔
1071
                  QDirIterator::Subdirectories);
5✔
1072
  while (it.hasNext()) {
13✔
1073
    if (QThread::currentThread()->isInterruptionRequested())
8✔
1074
      return {};
×
1075
    const QString filePath = it.next();
8✔
1076
    const QStringList matches = grepMatchFile(env, gpgExe, filePath, rx);
8✔
1077
    if (!matches.isEmpty()) {
8✔
1078
      QString entry = QDir(storeDir).relativeFilePath(filePath);
6✔
1079
      if (entry.endsWith(QLatin1String(".gpg")))
6✔
1080
        entry.chop(4);
6✔
1081
      results.append({entry, matches});
6✔
1082
    }
1083
  }
1084
  return results;
1085
}
5✔
1086

1087
/**
1088
 * @brief Search all password content by GPG-decrypting each .gpg file.
1089
 *
1090
 * The pattern is evaluated with `QRegularExpression` (**PCRE**), which differs
1091
 * from the POSIX BRE dialect of the `pass` backend — see Pass::Grep for the
1092
 * cross-backend caveat.
1093
 *
1094
 * Runs a background thread to avoid blocking the UI. Results are emitted on
1095
 * the main thread via QMetaObject::invokeMethod. A sequence counter discards
1096
 * results from superseded searches.
1097
 */
1098
void ImitatePass::Grep(QString pattern, bool caseInsensitive) {
5✔
1099
  for (QThread *t : std::as_const(m_grepThreads))
5✔
1100
    if (t && t->isRunning())
×
1101
      t->requestInterruption();
×
1102
  // No wait() — blocking the UI thread while GPG decrypts would freeze the
1103
  // interface. Stale results are discarded via the sequence counter.
1104

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

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

1127
  const QRegularExpression rx(
1128
      pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
1129
                               : QRegularExpression::PatternOptions{});
10✔
1130
  if (!rx.isValid()) {
5✔
1131
    QMetaObject::invokeMethod(
1✔
1132
        this,
1133
        [this, seq]() {
1✔
1134
          if (m_grepSeq == seq)
1✔
1135
            emit finishedGrep({});
2✔
1136
        },
1✔
1137
        Qt::QueuedConnection);
1138
    return;
1139
  }
1140
  const QString gpgExe = m_settings.gpgExecutable;
1141
  const QString storeDir = m_settings.passStore;
1142
  const QProcessEnvironment env = exec.environment();
4✔
1143
  QPointer<ImitatePass> self(this);
1144

1145
  auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
8✔
1146
    if (!self)
4✔
1147
      return;
1148
    QMetaObject::invokeMethod(
4✔
1149
        self,
1150
        [self, seq, results = std::move(results)]() {
12✔
1151
          if (self && self->m_grepSeq == seq)
8✔
1152
            emit self->finishedGrep(results);
4✔
1153
        },
4✔
1154
        Qt::QueuedConnection);
1155
  };
4✔
1156

1157
  QThread *thread = QThread::create(
4✔
1158
      [gpgExe, storeDir, env, rx, emitResults = std::move(emitResults)]() {
8✔
1159
        std::move(emitResults)(grepScanStore(env, gpgExe, storeDir, rx));
4✔
1160
      });
4✔
1161

1162
  m_grepThreads.append(thread);
4✔
1163
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
4✔
1164
  connect(thread, &QThread::finished, this,
4✔
1165
          [this, thread]() { m_grepThreads.removeOne(thread); });
8✔
1166
  thread->start();
4✔
1167
}
9✔
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