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

IJHack / QtPass / 34888458418

14 Sep 2026 07:41PM UTC coverage: 68.231% (+0.005%) from 68.226%
34888458418

push

github

web-flow
Backport: ignore a configured GPG home that does not exist (#1711) (#1741)

* Ignore a configured GPG home that does not exist (#1711)

The 1.7.0 test suite wrote its temporary GNUPGHOME into the live
QtPass.conf as gpgHome and never restored it, so anyone who ran `make check`
as themselves (a distribution package build, #1711) had every gpg call fail
with "keyblock resource ... No such file" and "No secret key" from then on,
in every QtPass version, with nothing in the settings dialog to explain it.

Pass::init() now exports GNUPGHOME only when the configured directory
exists; otherwise it logs a warning, shows a status message naming the
path, and leaves whatever the process environment already had. Clearing
gpgHome at runtime restores the inherited value too instead of keeping the
previous path for the rest of the session.

Tests: gpgHomeExportedWhenItExists, gpgHomeMissingFallsBackAndWarns,
gpgHomeClearedRestoresInheritedValue (the last two fail on the previous
code). FAQ entry for the symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc
(cherry picked from commit 8d79f748f)

* Address review: QVERIFY2 with invariants on the gpgHome test setup and cleanup checks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

* Address review: say which GNUPGHOME the missing-gpgHome fallback uses

With GNUPGHOME already in the process environment the fallback uses that,
not the default keyring; the status message and warning now say so, and the
test checks the message for both cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

16 of 22 new or added lines in 1 file covered. (72.73%)

5032 of 7375 relevant lines covered (68.23%)

67.24 hits per line

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

77.62
/src/pass.cpp
1
// SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "pass.h"
4
#include "gpgkeystate.h"
5
#include "util.h"
6
#include <QCoreApplication>
7
#include <QDebug>
8
#include <QDir>
9
#include <QFile>
10
#include <QFileInfo>
11
#include <QProcess>
12
#include <QRandomGenerator>
13
#include <QRegularExpression>
14
#include <QSaveFile>
15
#include <QTextStream>
16
#include <utility>
17

18
#ifdef QT_DEBUG
19
#include "debughelper.h"
20
#endif
21

22
using Enums::GIT_INIT;
23
using Enums::GIT_PULL;
24
using Enums::GIT_PUSH;
25
using Enums::GPG_GENKEYS;
26
using Enums::PASS_COPY;
27
using Enums::PASS_GREP;
28
using Enums::PASS_INIT;
29
using Enums::PASS_INSERT;
30
using Enums::PASS_MOVE;
31
using Enums::PASS_OTP_GENERATE;
32
using Enums::PASS_REMOVE;
33
using Enums::PASS_SHOW;
34

35
namespace {
36
/**
37
 * @brief Returns a non-empty charset value, using a fallback when needed.
38
 * @param input Preferred charset value.
39
 * @param fallback Charset to use when @p input is empty.
40
 * @return @p input if it is not empty; otherwise @p fallback.
41
 */
42
auto fallbackCharset(const QString &input, const QString &fallback) -> QString {
43
  return input.isEmpty() ? fallback : input;
1,270 ✔
44
}
45

46
/**
47
 * @brief Resolve the effective password character set from configuration.
48
 *
49
 * Uses the selected charset index from @p passConfig when it is within range;
50
 * otherwise falls back to the ALLCHARS entry. If the resolved charset string
51
 * is empty, falls back again to the ALLCHARS value.
52
 *
53
 * @param passConfig Password generation configuration.
54
 * @return Non-empty charset string to use for password generation.
55
 */
56
auto effectiveCharset(const PasswordConfiguration &passConfig) -> QString {
66 ✔
57
  int sel = passConfig.selected;
66 ✔
58
  if (sel < 0 || sel >= PasswordConfiguration::CHARSETS_COUNT)
66 ✔
59
    sel = PasswordConfiguration::ALLCHARS;
60
  return fallbackCharset(
61
      passConfig.Characters[sel],
66 ✔
62
      passConfig.Characters[PasswordConfiguration::ALLCHARS]);
66 ✔
63
}
64
} // namespace
65

66
/**
67
 * @brief Pass::Pass wrapper for using either pass or the pass imitation
68
 */
69
Pass::Pass() : env(QProcessEnvironment::systemEnvironment()) {
67 ✔
70
  connect(&exec,
67 ✔
71
          static_cast<void (Executor::*)(int, int, const QString &,
72
                                         const QString &)>(&Executor::finished),
73
          this, &Pass::finished);
67 ✔
74
  connect(&exec, &Executor::error, this, &Pass::finished);
67 ✔
75

76
  connect(&exec, &Executor::starting, this, &Pass::startingExecuteWrapper);
67 ✔
77
  // Merge our vars into WSLENV rather than blindly appending a duplicate entry
78
  const QStringList wslenvVars = {
79
      QStringLiteral("PASSWORD_STORE_DIR/p"),
134 ✔
80
      QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH/w"),
67 ✔
81
      QStringLiteral("PASSWORD_STORE_CHARACTER_SET/w")};
268 ✔
82
  const QString existing = env.value(QStringLiteral("WSLENV"));
134 ✔
83
  if (existing.isEmpty()) {
67 ✔
84
    env.insert(QStringLiteral("WSLENV"), wslenvVars.join(':'));
134 ✔
85
  } else {
86
    QStringList parts = existing.split(':', Qt::SkipEmptyParts);
×
87
    for (const QString &v : wslenvVars) {
×
88
      if (!parts.contains(v))
×
89
        parts.append(v);
90
    }
91
    env.insert(QStringLiteral("WSLENV"), parts.join(':'));
×
92
  }
93
}
67 ✔
94

95
/**
96
 * @brief Executes a wrapper command.
97
 * @param id Process ID
98
 * @param app Application to execute
99
 * @param args Arguments
100
 * @param readStdout Whether to read stdout
101
 * @param readStderr Whether to read stderr
102
 */
103
void Pass::executeWrapper(PROCESS id, const QString &app,
×
104
                          const QStringList &args, bool readStdout,
105
                          bool readStderr) {
106
  executeWrapper(id, app, args, QString(), readStdout, readStderr);
×
107
}
×
108

109
void Pass::executeWrapper(PROCESS id, const QString &app,
57 ✔
110
                          const QStringList &args, QString input,
111
                          bool readStdout, bool readStderr) {
112
  beforeExecute(id);
57 ✔
113
#ifdef QT_DEBUG
114
  dbg() << app << args;
115
#endif
116
  exec.execute(id, m_settings.passStore, app, args, std::move(input),
57 ✔
117
               readStdout, readStderr);
118
}
57 ✔
119

120
void Pass::beforeExecute(PROCESS /*id*/) {}
×
121

122
/**
123
 * @brief Initializes the pass wrapper with a settings snapshot.
124
 * @param settings Application settings to use for this backend lifetime.
125
 */
126
void Pass::init(const AppSettings &settings) {
91 ✔
127
  m_settings = settings;
91 ✔
128
#ifdef __APPLE__
129
  // If it exists, prepend gpgtools to PATH
130
  if (QFile(QStringLiteral("/usr/local/MacGPG2/bin")).exists())
131
    env.insert(QStringLiteral("PATH"),
132
               QStringLiteral("/usr/local/MacGPG2/bin:") +
133
                   env.value(QStringLiteral("PATH")));
134
  // Add missing /usr/local/bin (exact component match, no leading colon)
135
  const QString currentPath = env.value(QStringLiteral("PATH"));
136
  if (!currentPath.split(':', Qt::SkipEmptyParts)
137
           .contains(QStringLiteral("/usr/local/bin"))) {
138
    env.insert(QStringLiteral("PATH"),
139
               currentPath.isEmpty()
140
                   ? QStringLiteral("/usr/local/bin")
141
                   : QStringLiteral("/usr/local/bin:") + currentPath);
142
  }
143
#endif
144

145
  // GNUPGHOME: the configured gpgHome wins over the inherited environment,
146
  // but only when it exists. A gpgHome that is gone (the 1.7.0 test suite
147
  // left its temporary keyring path in the live QtPass.conf, #1711) would
148
  // make every gpg call fail with "No secret key"; fall back to whatever the
149
  // environment says and tell the user. Clearing the setting at runtime
150
  // restores the inherited value as well instead of keeping the old path.
151
  const QString inheritedHome = QProcessEnvironment::systemEnvironment().value(
91 ✔
152
      QStringLiteral("GNUPGHOME"));
182 ✔
153
  const auto useInheritedHome = [this, &inheritedHome]() {
57 ✔
154
    if (inheritedHome.isEmpty()) {
57 ✔
155
      env.remove(QStringLiteral("GNUPGHOME"));
114 ✔
156
    } else {
NEW
157
      env.insert(QStringLiteral("GNUPGHOME"), inheritedHome);
×
158
    }
159
  };
148 ✔
160
  if (m_settings.gpgHome.isEmpty()) {
91 ✔
161
    useInheritedHome();
56 ✔
162
  } else {
163
    QDir absHome(m_settings.gpgHome);
35 ✔
164
    absHome.makeAbsolute();
35 ✔
165
    if (absHome.exists()) {
35 ✔
166
      env.insert(QStringLiteral("GNUPGHOME"), absHome.path());
68 ✔
167
    } else {
168
      if (inheritedHome.isEmpty()) {
1 ✔
169
        qWarning() << "gpgHome" << absHome.path()
2 ✔
170
                   << "does not exist; using the default GnuPG home";
1 ✔
171
        emit statusMsg(tr("Configured GPG home %1 does not exist, using the "
1 ✔
172
                          "default keyring")
173
                           .arg(absHome.path()),
2 ✔
174
                       5000);
175
      } else {
NEW
176
        qWarning() << "gpgHome" << absHome.path()
×
NEW
177
                   << "does not exist; using GNUPGHOME" << inheritedHome
×
NEW
178
                   << "from the environment";
×
NEW
179
        emit statusMsg(tr("Configured GPG home %1 does not exist, using "
×
180
                          "GNUPGHOME %2 from the environment")
NEW
181
                           .arg(absHome.path(), inheritedHome),
×
182
                       5000);
183
      }
184
      useInheritedHome();
1 ✔
185
    }
186
  }
35 ✔
187
}
91 ✔
188

189
/**
190
 * @brief Pass::Generate use either pwgen or internal password
191
 * generator
192
 * @param length of the desired password
193
 * @param charset to use for generation
194
 * @return the password
195
 */
196
auto Pass::generatePassword(unsigned int length, const QString &charset)
1,205 ✔
197
    -> QString {
198
  if (length == 0) {
1,205 ✔
199
    emit critical(tr("Invalid password length"),
2 ✔
200
                  tr("Can't generate password with zero length."));
1 ✔
201
    return {};
202
  }
203
  QString passwd;
1,204 ✔
204
  if (m_settings.usePwgen) {
1,204 ✔
205
    // --secure goes first as it overrides --no-* otherwise
206
    QStringList args;
×
207
    args.append("-1");
×
208
    if (!m_settings.lessRandom) {
×
209
      args.append("--secure");
×
210
    }
211
    args.append(m_settings.avoidCapitals ? "--no-capitalize" : "--capitalize");
×
212
    args.append(m_settings.avoidNumbers ? "--no-numerals" : "--numerals");
×
213
    if (m_settings.useSymbols) {
×
214
      args.append("--symbols");
×
215
    }
216
    args.append(QString::number(length));
×
217
    // executeBlocking returns 0 on success, non-zero on failure
218
    if (Executor::executeBlocking(m_settings.pwgenExecutable, args, &passwd) ==
×
219
        0) {
220
      static const QRegularExpression literalNewLines{"[\\n\\r]"};
×
221
      passwd.remove(literalNewLines);
×
222
    } else {
223
      passwd.clear();
×
224
#ifdef QT_DEBUG
225
      qDebug() << __FILE__ << ":" << __LINE__ << "\t"
226
               << "pwgen fail";
227
#endif
228
      // Error is already handled by clearing passwd; no need for critical
229
      // signal here
230
    }
231
  } else {
232
    // Validate charset - if CUSTOM is selected but chars are empty,
233
    // fall back to ALLCHARS to prevent weak passwords (issue #780)
234
    const QString cs = fallbackCharset(
235
        charset, m_settings.passwordConfiguration
236
                     .Characters[PasswordConfiguration::ALLCHARS]);
1,204 ✔
237
    if (cs.length() > 0) {
1,204 ✔
238
      passwd = generateRandomPassword(cs, length);
2,408 ✔
239
    } else {
240
      emit critical(
×
241
          tr("No characters chosen"),
×
242
          tr("Can't generate password, there are no characters to choose from "
×
243
             "set in the configuration!"));
244
    }
245
  }
246
  return passwd;
247
}
248

249
/**
250
 * @brief Pass::gpgSupportsEd25519 check if GPG supports ed25519 (ECC)
251
 * GPG 2.1+ supports ed25519 which is much faster for key generation
252
 * @return true if ed25519 is supported
253
 */
254
bool Pass::gpgSupportsEd25519(const QString &gpgExecutable) {
18 ✔
255
  const QString exe =
256
      gpgExecutable.isEmpty() ? QStringLiteral("gpg") : gpgExecutable;
18 ✔
257
  QString out, err;
18 ✔
258
  if (Executor::executeBlocking(exe, {"--version"}, &out, &err) != 0) {
54 ✔
259
    return false;
260
  }
261
  QRegularExpression versionRegex(R"(gpg \(GnuPG\) (\d+)\.(\d+))");
36 ✔
262
  QRegularExpressionMatch match = versionRegex.match(out);
18 ✔
263
  if (!match.hasMatch()) {
18 ✔
264
    return false;
265
  }
266
  int major = match.captured(1).toInt();
18 ✔
267
  int minor = match.captured(2).toInt();
18 ✔
268
  return major > 2 || (major == 2 && minor >= 1);
18 ✔
269
}
36 ✔
270

271
/**
272
 * @brief Pass::getDefaultKeyTemplate return default key generation template
273
 * Uses ed25519 if supported, otherwise falls back to RSA
274
 * @return GPG batch template string
275
 */
276
QString Pass::getDefaultKeyTemplate(const QString &gpgExecutable) {
17 ✔
277
  if (gpgSupportsEd25519(gpgExecutable)) {
17 ✔
278
    return QStringLiteral("%echo Generating a default key\n"
17 ✔
279
                          "Key-Type: EdDSA\n"
280
                          "Key-Curve: Ed25519\n"
281
                          "Subkey-Type: ECDH\n"
282
                          "Subkey-Curve: Curve25519\n"
283
                          "Name-Real: \n"
284
                          "Name-Comment: QtPass\n"
285
                          "Name-Email: \n"
286
                          "Expire-Date: 0\n"
287
                          "%no-protection\n"
288
                          "%commit\n"
289
                          "%echo done");
290
  }
291
  return QStringLiteral("%echo Generating a default key\n"
×
292
                        "Key-Type: RSA\n"
293
                        "Subkey-Type: RSA\n"
294
                        "Name-Real: \n"
295
                        "Name-Comment: QtPass\n"
296
                        "Name-Email: \n"
297
                        "Expire-Date: 0\n"
298
                        "%no-protection\n"
299
                        "%commit\n"
300
                        "%echo done");
301
}
302

303
namespace {
304
/**
305
 * @brief Resolve a candidate gpgconf path from the trailing WSL path segment.
306
 *
307
 * Takes the directory portion of @p lastPart (separated by '/' or '\\') and
308
 * appends "gpgconf"; if no separator is present, returns the bare executable
309
 * name "gpgconf".
310
 *
311
 * @param lastPart Path fragment that may contain a directory and executable.
312
 * @return Full path ending in "gpgconf", or "gpgconf" as a fallback.
313
 */
314
auto resolveWslGpgconfPath(const QString &lastPart) -> QString {
5 ✔
315
  qsizetype lastSep = lastPart.lastIndexOf('/');
5 ✔
316
  if (lastSep < 0) {
5 ✔
317
    lastSep = lastPart.lastIndexOf('\\');
4 ✔
318
  }
319
  if (lastSep >= 0) {
4 ✔
320
    return lastPart.left(lastSep + 1) + "gpgconf";
2 ✔
321
  }
322
  return QStringLiteral("gpgconf");
4 ✔
323
}
324

325
/**
326
 * @brief Finds the path to the gpgconf executable in the same directory as the
327
 * given GPG path.
328
 * @example
329
 * QString result = findGpgconfInGpgDir(gpgPath);
330
 * std::cout << result.toStdString() << std::endl; // Expected output: path to
331
 * gpgconf or empty string
332
 *
333
 * @param gpgPath - Absolute path to a GPG executable or related file used to
334
 * locate gpgconf.
335
 * @return QString - The full path to gpgconf if found and executable; otherwise
336
 * an empty QString.
337
 */
338
QString findGpgconfInGpgDir(const QString &gpgPath) {
1 ✔
339
  QFileInfo gpgInfo(gpgPath);
1 ✔
340
  if (!gpgInfo.isAbsolute()) {
1 ✔
341
    return {};
342
  }
343

344
  QDir dir(gpgInfo.absolutePath());
1 ✔
345

346
#ifdef Q_OS_WIN
347
  QFileInfo candidateExe(dir.filePath("gpgconf.exe"));
348
  if (candidateExe.isExecutable()) {
349
    return candidateExe.filePath();
350
  }
351
#endif
352

353
  QFileInfo candidate(dir.filePath("gpgconf"));
1 ✔
354
  if (candidate.isExecutable()) {
1 ✔
355
    return candidate.filePath();
×
356
  }
357
  return {};
358
}
1 ✔
359

360
// Compatibility shim for Qt < 5.15 where QProcess::splitCommand is not
361
// available. Keep this fallback while supporting pre-5.15 builds; remove once
362
// the project's minimum supported Qt version is raised to 5.15 or newer.
363
#if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
364
/**
365
 * @brief Splits a command string into arguments while respecting quotes and
366
 * escape characters.
367
 * @example
368
 * QStringList result = splitCommandCompat("cmd \"arg one\" 'arg two'
369
 * escaped\\ space");
370
 * // Expected output: ["cmd", "arg one", "arg two", "escaped space"]
371
 *
372
 * @param command - The input command string to split into individual arguments.
373
 * @return QStringList - A list of parsed command arguments.
374
 */
375
QStringList splitCommandCompat(const QString &command) {
376
  QStringList result;
377
  QString current;
378
  bool inSingleQuote = false;
379
  bool inDoubleQuote = false;
380
  bool escaping = false;
381
  for (QChar ch : command) {
382
    if (escaping) {
383
      current.append(ch);
384
      escaping = false;
385
      continue;
386
    }
387
    if (ch == '\\') {
388
      escaping = true;
389
      continue;
390
    }
391
    if (ch == '\'' && !inDoubleQuote) {
392
      inSingleQuote = !inSingleQuote;
393
      continue;
394
    }
395
    if (ch == '"' && !inSingleQuote) {
396
      inDoubleQuote = !inDoubleQuote;
397
      continue;
398
    }
399
    if (ch.isSpace() && !inSingleQuote && !inDoubleQuote) {
400
      if (!current.isEmpty()) {
401
        result.append(current);
402
        current.clear();
403
      }
404
      continue;
405
    }
406
    current.append(ch);
407
  }
408
  if (escaping) {
409
    current.append('\\');
410
  }
411
  if (!current.isEmpty()) {
412
    result.append(current);
413
  }
414
  return result;
415
}
416
#endif
417

418
} // namespace
419

420
/**
421
 * @brief Resolves the appropriate gpgconf command from a given GPG executable
422
 * path or command string.
423
 * @example
424
 * ResolvedGpgconfCommand result = Pass::resolveGpgconfCommand("wsl.exe
425
 * /usr/bin/gpg"); std::cout << result.first.toStdString() << std::endl; //
426
 * Expected output sample
427
 *
428
 * @param const QString &gpgPath - Path or command string pointing to the GPG
429
 * executable.
430
 * @return ResolvedGpgconfCommand - A pair containing the resolved gpgconf
431
 * command and its arguments.
432
 */
433
auto Pass::resolveGpgconfCommand(const QString &gpgPath)
10 ✔
434
    -> ResolvedGpgconfCommand {
435
  if (gpgPath.trimmed().isEmpty()) {
10 ✔
436
    return {"gpgconf", {}};
437
  }
438

439
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
440
  QStringList parts = QProcess::splitCommand(gpgPath);
9 ✔
441
#else
442
  QStringList parts = splitCommandCompat(gpgPath);
443
#endif
444

445
  if (parts.isEmpty()) {
9 ✔
446
    return {"gpgconf", {}};
447
  }
448

449
  const QString first = parts.first();
450
  if (first.compare("wsl", Qt::CaseInsensitive) == 0 ||
20 ✔
451
      first.compare("wsl.exe", Qt::CaseInsensitive) == 0) {
11 ✔
452
    if (parts.size() >= 2 && parts.at(1).startsWith("sh")) {
13 ✔
453
      return {"gpgconf", {}};
454
    }
455
    if (parts.size() >= 2 &&
6 ✔
456
        QFileInfo(parts.last()).fileName().startsWith("gpg")) {
16 ✔
457
      QString wslGpgconf = resolveWslGpgconfPath(parts.last());
5 ✔
458
      parts.removeLast();
5 ✔
459
      // Run gpgconf directly rather than through the distribution's default
460
      // shell, which would word-split and expand the arguments. Keep any
461
      // --exec/-e the user already put in the command.
462
      if (!parts.contains("--exec") && !parts.contains("-e")) {
9 ✔
463
        parts.append("--exec");
6 ✔
464
      }
465
      parts.append(wslGpgconf);
466
      return {parts.first(), parts.mid(1)};
467
    }
468
    return {"gpgconf", {}};
469
  }
470

471
  if (!first.contains('/') && !first.contains('\\')) {
2 ✔
472
    return {"gpgconf", {}};
473
  }
474

475
  QString gpgconfPath = findGpgconfInGpgDir(first);
1 ✔
476
  if (!gpgconfPath.isEmpty()) {
1 ✔
477
    return {gpgconfPath, {}};
×
478
  }
479

480
  return {"gpgconf", {}};
481
}
10 ✔
482

483
/**
484
 * @brief Pass::GenerateGPGKeys internal gpg keypair generator . .
485
 * @param batch GnuPG style configuration string
486
 */
487
void Pass::GenerateGPGKeys(QString batch) {
1 ✔
488
  const QString gpgPath = m_settings.gpgExecutable;
489
  if (gpgPath.isEmpty()) {
1 ✔
490
    // No gpg configured: executeWrapper would hand an empty executable to the
491
    // Executor, which silently drops it (see Executor::execute), leaving the
492
    // keygen dialog spinning with no feedback. Surface the misconfiguration
493
    // instead. Deferred via a queued call so we do not re-enter
494
    // KeygenDialog::done(), which drives key generation synchronously.
495
    QMetaObject::invokeMethod(
1 ✔
496
        this,
497
        [this]() {
1 ✔
498
          emit processErrorExit(1, tr("No GPG executable configured"));
1 ✔
499
        },
1 ✔
500
        Qt::QueuedConnection);
501
    return;
502
  }
503

504
  // Kill any stale GPG agents that might be holding locks on the key database.
505
  // This helps avoid "database locked" timeouts during key generation.
506
  ResolvedGpgconfCommand resolvedGpgconf = resolveGpgconfCommand(gpgPath);
×
507
  QStringList killArgs = resolvedGpgconf.arguments;
508
  killArgs << "--kill";
×
509
  killArgs << "gpg-agent";
×
510
  // Use same environment as key generation to target correct gpg-agent
511
  if (Executor::executeBlocking(env, resolvedGpgconf.program, killArgs) != 0) {
×
512
    qWarning() << "Failed to kill gpg-agent";
×
513
  }
514

515
  executeWrapper(GPG_GENKEYS, gpgPath, {"--gen-key", "--no-tty", "--batch"},
×
516
                 std::move(batch), true, true);
517
}
×
518

519
/**
520
 * @brief Pass::listKeys list users
521
 * @param keystrings
522
 * @param secret list private keys
523
 * @return QList<UserInfo> users
524
 */
525
auto Pass::listKeys(QStringList keystrings, bool secret) -> QList<UserInfo> {
7 ✔
526
  QStringList args = {"--no-tty", "--with-colons", "--with-fingerprint"};
28 ✔
527
  args.append(secret ? "--list-secret-keys" : "--list-keys");
16 ✔
528

529
  for (const QString &keystring : std::as_const(keystrings)) {
14 ✔
530
    if (!keystring.isEmpty()) {
7 ✔
531
      args.append(keystring);
532
    }
533
  }
534
  QString p_out;
7 ✔
535
  if (Executor::executeBlocking(m_settings.gpgExecutable, args, &p_out) != 0) {
7 ✔
536
    return {};
×
537
  }
538
  return parseGpgColonOutput(p_out, secret);
7 ✔
539
}
7 ✔
540

541
/**
542
 * @brief Pass::listKeys list users
543
 * @param keystring
544
 * @param secret list private keys
545
 * @return QList<UserInfo> users
546
 */
547
auto Pass::listKeys(const QString &keystring, bool secret) -> QList<UserInfo> {
6 ✔
548
  return listKeys(QStringList(keystring), secret);
12 ✔
549
}
550

551
/**
552
 * @brief Maps GPG stderr (which may include --status-fd 2 tokens) to a
553
 * user-friendly encryption error string.
554
 *
555
 * Checked in order: machine-readable [GNUPG:] status tokens first (locale-
556
 * independent), then case-insensitive substring fallbacks for GPG builds that
557
 * don't emit status tokens.
558
 *
559
 * @param err Raw stderr from GPG
560
 * @return Translated human-readable error, or empty string if not recognised
561
 */
562
namespace {
563

564
/**
565
 * @brief Checks if @p str contains any of the @p patterns (case-sensitive).
566
 * @param str String to search in.
567
 * @param patterns Patterns to search for.
568
 * @return true if any pattern is found, false otherwise.
569
 */
570
auto containsAny(const QString &str, const QStringList &patterns) -> bool {
31 ✔
571
  for (const QString &p : patterns) {
82 ✔
572
    if (str.contains(p)) {
58 ✔
573
      return true;
574
    }
575
  }
576
  return false;
577
}
578

579
/**
580
 * @brief Checks if str contains any of the patterns (case-insensitive).
581
 * @param str String to search in (will be lowercased once).
582
 * @param patterns List of patterns to search for (must be lowercase; caller
583
 * should convert patterns to lowercase before calling).
584
 * @return true if any pattern is found.
585
 */
586
auto containsAnyCaseInsensitive(const QString &str, const QStringList &patterns)
13 ✔
587
    -> bool {
588
  const QString lower = str.toLower();
589
  for (const QString &p : patterns) {
32 ✔
590
    if (lower.contains(p)) {
23 ✔
591
      return true;
592
    }
593
  }
594
  return false;
595
}
596

597
} // namespace
598

599
auto gpgErrorMessage(const QString &err) -> QString {
13 ✔
600
  // Machine-readable status tokens added by --status-fd 2
601
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYEXPIRED"),
65 ✔
602
                        QStringLiteral("[GNUPG:] INV_RECP 5 ")}))
13 ✔
603
    return QCoreApplication::translate(
604
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
605
                "replace it.");
3 ✔
606
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYREVOKED"),
50 ✔
607
                        QStringLiteral("[GNUPG:] INV_RECP 4 ")}))
10 ✔
608
    return QCoreApplication::translate(
609
        "Pass", "Encryption failed: GPG key has been revoked.");
2 ✔
610
  if (containsAny(err, {QStringLiteral("[GNUPG:] NO_PUBKEY"),
40 ✔
611
                        QStringLiteral("[GNUPG:] INV_RECP")}))
8 ✔
612
    return QCoreApplication::translate(
613
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
614
                "Check that the key ID in .gpg-id is correct and imported.");
2 ✔
615
  if (err.contains(QStringLiteral("[GNUPG:] FAILURE")))
6 ✔
616
    return QCoreApplication::translate(
617
        "Pass", "Encryption failed. Check that your GPG key is valid.");
1 ✔
618

619
  // Locale-dependent fallbacks
620
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has expired"),
20 ✔
621
                                       QLatin1String("key expired")}))
622
    return QCoreApplication::translate(
623
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
624
                "replace it.");
1 ✔
625
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has been revoked"),
16 ✔
626
                                       QLatin1String("revoked")}))
627
    return QCoreApplication::translate(
628
        "Pass", "Encryption failed: GPG key has been revoked.");
1 ✔
629
  if (containsAnyCaseInsensitive(err, {QLatin1String("no public key"),
15 ✔
630
                                       QLatin1String("unusable public key"),
631
                                       QLatin1String("no secret key")}))
632
    return QCoreApplication::translate(
633
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
634
                "Check that the key ID in .gpg-id is correct and imported.");
2 ✔
635
  if (containsAnyCaseInsensitive(err, {QLatin1String("encryption failed")}))
3 ✔
636
    return QCoreApplication::translate(
637
        "Pass", "Encryption failed. Check that your GPG key is valid.");
×
638

639
  return {};
640
}
×
641

642
namespace {
643
/**
644
 * @brief Determine whether a line from `pass grep` output is an entry header.
645
 *
646
 * Detects the ANSI blue escape (\x1B[94m) emitted by `pass grep`; as a
647
 * plain-text fallback, treats a non-indented line ending in ':' as a header.
648
 *
649
 * @param rawLine Original unmodified output line (with any ANSI codes).
650
 * @param trimmedLine The line after surrounding whitespace has been stripped.
651
 * @return true if the line is an entry header; otherwise false.
652
 */
653
auto isGrepHeaderLine(const QString &rawLine, const QString &trimmedLine)
45 ✔
654
    -> bool {
655
  return rawLine.startsWith(QStringLiteral("\x1B[94m")) ||
123 ✔
656
         (!rawLine.startsWith(' ') && !rawLine.startsWith('\t') &&
91 ✔
657
          trimmedLine.endsWith(':'));
119 ✔
658
}
659
} // namespace
660

661
/**
662
 * @brief Parses 'pass grep' raw output into (entry, matches) pairs.
663
 *
664
 * pass grep emits ANSI blue color (\x1B[94m) at the start of each entry
665
 * header line. This is checked before stripping ANSI so headers are detected
666
 * reliably regardless of locale.
667
 */
668
auto parseGrepOutput(const QString &rawOut)
12 ✔
669
    -> QList<QPair<QString, QStringList>> {
670
  static const QRegularExpression ansi(
671
      QStringLiteral(R"(\x1B\[[0-9;]*[a-zA-Z])"));
13 ✔
672
  QList<QPair<QString, QStringList>> results;
12 ✔
673
  QString currentEntry;
12 ✔
674
  QStringList currentMatches;
12 ✔
675
  for (const QString &rawLine : rawOut.split('\n')) {
69 ✔
676
    QString line = rawLine;
677
    line.remove('\r');
45 ✔
678
    line.remove(ansi);
45 ✔
679
    line = line.trimmed();
45 ✔
680
    const bool isHeader = isGrepHeaderLine(rawLine, line);
45 ✔
681
    if (isHeader) {
45 ✔
682
      if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
14 ✔
683
        results.append({currentEntry, currentMatches});
3 ✔
684
      currentEntry = line.endsWith(':') ? line.chopped(1) : line;
14 ✔
685
      currentMatches.clear();
14 ✔
686
    } else if (!currentEntry.isEmpty()) {
31 ✔
687
      if (!line.isEmpty())
29 ✔
688
        currentMatches << line;
689
    }
690
  }
691
  if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
12 ✔
692
    results.append({currentEntry, currentMatches});
11 ✔
693
  return results;
12 ✔
694
}
695

696
/**
697
 * @brief Pass::processFinished reemits specific signal based on what process
698
 * has finished
699
 * @param id    id of Pass process that was scheduled and finished
700
 * @param exitCode  return code of a process
701
 * @param out   output generated by process(if capturing was requested, empty
702
 *              otherwise)
703
 * @param err   error output generated by process(if capturing was requested,
704
 *              or error occurred)
705
 */
706
void Pass::finished(int id, int exitCode, const QString &out,
56 ✔
707
                    const QString &err) {
708
  auto pid = static_cast<PROCESS>(id);
56 ✔
709

710
  if (exitCode != 0) {
56 ✔
711
    handleProcessError(pid, exitCode, out, err);
4 ✔
712
    return;
4 ✔
713
  }
714

715
  emitProcessFinishedSignal(pid, out, err);
52 ✔
716
}
717

718
void Pass::handleProcessError(PROCESS pid, int exitCode, const QString &out,
4 ✔
719
                              const QString &err) {
720
  Q_UNUSED(out);
721

722
  if (pid == PASS_GREP) {
4 ✔
723
    handleGrepError(exitCode, err);
2 ✔
724
    return;
2 ✔
725
  }
726

727
  if (pid == PASS_INSERT) {
2 ✔
728
    const QString friendly = gpgErrorMessage(err);
×
729
    if (!friendly.isEmpty()) {
×
730
      emit processErrorExit(exitCode, formatInsertError(friendly, err));
×
731
      return;
732
    }
733
  }
734

735
  emit processErrorExit(exitCode, err);
2 ✔
736
}
737

738
void Pass::handleGrepError(int exitCode, const QString &err) {
2 ✔
739
  if (exitCode == 1) {
2 ✔
740
    emit finishedGrep({});
2 ✔
741
  } else {
742
    emit processErrorExit(exitCode, err);
1 ✔
743
    emit finishedGrep({});
2 ✔
744
  }
745
}
2 ✔
746

747
auto Pass::formatInsertError(const QString &friendly, const QString &err)
×
748
    -> QString {
749
  QStringList humanLines;
×
750
  for (const QString &line : err.split('\n')) {
×
751
    QString cleanedLine = line;
752
    cleanedLine.remove('\r');
×
753
    if (!cleanedLine.startsWith(QLatin1String("[GNUPG:]")))
×
754
      humanLines.append(cleanedLine);
755
  }
756
  const QString humanErr = humanLines.join('\n').trimmed();
×
757
  return humanErr.isEmpty() ? friendly : friendly + "\n\n" + humanErr;
×
758
}
759

760
/**
761
 * @brief Emit the appropriate finished signal for a completed subprocess.
762
 *
763
 * Emits a specific Qt signal corresponding to the given process identifier; for
764
 * grep results the stdout is parsed into a list of matches before emitting.
765
 *
766
 * @param pid The process identifier indicating which finished signal to emit.
767
 * @param out Standard output produced by the process.
768
 * @param err Standard error produced by the process.
769
 */
770
void Pass::emitProcessFinishedSignal(PROCESS pid, const QString &out,
52 ✔
771
                                     const QString &err) {
772
  /**
773
   * @brief Filter sensitive commands to prevent password leakage.
774
   *
775
   * Sensitive commands (PASS_SHOW, etc.) output plaintext passwords or
776
   * searchable content that should not be exposed to any UI listener.
777
   *
778
   * Using a default branch: if new PASS_* values are added, they
779
   * default to NOT leaking (safe by default). Making this
780
   * exhaustive would require updating here for every new
781
   * command and risk silent password leakage if forgotten.
782
   */
783
  switch (pid) {
52 ✔
784
  case PASS_SHOW:
785
  case PASS_OTP_GENERATE:
786
  case PASS_GREP:
787
  case PASS_INSERT:
788
    break;
789
  default:
1 ✔
790
    emit finishedAny(out, err);
1 ✔
791
    emit finishedAnyWithPid(out, err, pid);
1 ✔
792
    break;
1 ✔
793
  }
794

795
  switch (pid) {
52 ✔
796
  case GIT_INIT:
×
797
    emit finishedGitInit(out, err);
×
798
    break;
×
799
  case GIT_PULL:
×
800
    emit finishedGitPull(out, err);
×
801
    break;
×
802
  case GIT_PUSH:
×
803
    emit finishedGitPush(out, err);
×
804
    break;
×
805
  case PASS_SHOW:
16 ✔
806
    emit finishedShow(out);
16 ✔
807
    break;
16 ✔
808
  case PASS_OTP_GENERATE:
×
809
    emit finishedOtpGenerate(out);
×
810
    break;
×
811
  case PASS_INSERT:
34 ✔
812
    emit finishedInsert(out, err);
34 ✔
813
    break;
34 ✔
814
  case PASS_REMOVE:
×
815
    emit finishedRemove(out, err);
×
816
    break;
×
817
  case PASS_INIT:
1 ✔
818
    emit finishedInit(out, err);
1 ✔
819
    break;
1 ✔
820
  case PASS_MOVE:
×
821
    emit finishedMove(out, err);
×
822
    break;
×
823
  case PASS_COPY:
×
824
    emit finishedCopy(out, err);
×
825
    break;
×
826
  case GPG_GENKEYS:
×
827
    emit finishedGenerateGPGKeys(out, err);
×
828
    break;
×
829
  case PASS_GREP:
1 ✔
830
    emit finishedGrep(parseGrepOutput(out));
1 ✔
831
    break;
1 ✔
832
  default:
833
#ifdef QT_DEBUG
834
    dbg() << "Unhandled process type" << pid;
835
#endif
836
    break;
837
  }
838
}
52 ✔
839

840
/**
841
 * @brief Set or remove a single environment variable in the local env list.
842
 *
843
 * The provided key must include a trailing '=' (e.g. "FOO="). Existing entries
844
 * whose text begins with the given key are removed before the new value is
845
 * applied. If value is non-empty the pair "key+value" is appended; if value is
846
 * empty the variable is removed.
847
 *
848
 * The function asserts if key does not end with '='; if the assertion is not
849
 * active it will emit a warning and return without modifying env.
850
 *
851
 * @param key Environment variable name with trailing '=' (anchors the lookup).
852
 * @param value Value to set for the variable; an empty string unsets the
853
 * variable.
854
 */
855
void Pass::setEnvVar(const QString &key, const QString &value) {
270 ✔
856
  const bool hasEq = key.endsWith('=');
270 ✔
857
  Q_ASSERT_X(hasEq, "Pass::setEnvVar",
858
             "called with malformed key (missing '=')");
859
  if (!hasEq) {
270 ✔
860
    qWarning() << "Pass::setEnvVar called with malformed key (missing '='):"
×
861
               << key;
×
862
    return;
×
863
  }
864
  const QString varName = key.chopped(1);
865
  if (value.isEmpty())
270 ✔
866
    env.remove(varName);
77 ✔
867
  else
868
    env.insert(varName, value);
193 ✔
869
}
870

871
/**
872
 * @brief Update the process environment used for executing external commands.
873
 *
874
 * Updates environment entries for PASSWORD_STORE_SIGNING_KEY,
875
 * PASSWORD_STORE_DIR, PASSWORD_STORE_GENERATED_LENGTH, and
876
 * PASSWORD_STORE_CHARACTER_SET based on current settings, then applies the
877
 * environment to the internal executor.
878
 */
879
void Pass::updateEnv() {
66 ✔
880
  setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY="),
132 ✔
881
            m_settings.passSigningKey);
66 ✔
882
  setEnvVar(QStringLiteral("PASSWORD_STORE_DIR="), m_settings.passStore);
132 ✔
883

884
  const PasswordConfiguration &passConfig = m_settings.passwordConfiguration;
66 ✔
885
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH="),
132 ✔
886
            QString::number(passConfig.length));
66 ✔
887

888
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET="),
132 ✔
889
            effectiveCharset(passConfig));
66 ✔
890

891
  exec.setEnvironment(env);
66 ✔
892
}
66 ✔
893

894
/**
895
 * @brief Pass::getGpgIdPath return gpgid file path for some file (folder).
896
 * @param for_file which file (folder) would you like the gpgid file path for.
897
 * @return path to the gpgid file.
898
 */
899
auto Pass::getGpgIdPath(const QString &for_file, const QString &passStore)
102 ✔
900
    -> QString {
901
  QString normalizedStore = QDir::fromNativeSeparators(passStore);
102 ✔
902
  QString normalizedFile = QDir::fromNativeSeparators(for_file);
102 ✔
903
  QString fullPath = normalizedFile.startsWith(normalizedStore)
102 ✔
904
                         ? normalizedFile
102 ✔
905
                         : normalizedStore + "/" + normalizedFile;
67 ✔
906
  QDir gpgIdDir(QFileInfo(fullPath).absoluteDir());
102 ✔
907
  // QDir::cleanPath() always normalises to forward slashes, so use '/'
908
  // here rather than QDir::separator() (which returns '\\' on Windows).
909
  QString cleanPassStore = QDir::cleanPath(normalizedStore);
102 ✔
910
  bool found = false;
911
  while (gpgIdDir.exists()) {
128 ✔
912
    QString currentPath = QDir::cleanPath(gpgIdDir.absolutePath());
254 ✔
913
    const QString prefix =
914
        cleanPassStore.endsWith('/') ? cleanPassStore : cleanPassStore + "/";
127 ✔
915
    if (currentPath != cleanPassStore && !currentPath.startsWith(prefix)) {
127 ✔
916
      break;
917
    }
918
    if (QFile(gpgIdDir.absoluteFilePath(".gpg-id")).exists()) {
232 ✔
919
      found = true;
920
      break;
921
    }
922
    if (!gpgIdDir.cdUp()) {
26 ✔
923
      break;
924
    }
925
  }
926
  return found ? gpgIdDir.absoluteFilePath(".gpg-id")
102 ✔
927
               : QDir(normalizedStore).filePath(".gpg-id");
306 ✔
928
}
102 ✔
929

930
/**
931
 * @brief Pass::getRecipientList return list of gpg-id's to encrypt for
932
 * @param for_file which file (folder) would you like recipients for
933
 * @return recipients gpg-id contents
934
 */
935
auto Pass::getRecipientList(const QString &for_file, const QString &passStore)
57 ✔
936
    -> QStringList {
937
  QFile gpgId(getGpgIdPath(for_file, passStore));
57 ✔
938
  if (!gpgId.open(QIODevice::ReadOnly | QIODevice::Text)) {
57 ✔
939
    return {};
1 ✔
940
  }
941
  QStringList recipients;
56 ✔
942
  while (!gpgId.atEnd()) {
135 ✔
943
    QString recipient(gpgId.readLine());
158 ✔
944
    recipient = recipient.split("#")[0].trimmed();
158 ✔
945
    if (recipient.isEmpty()) {
79 ✔
946
      continue;
7 ✔
947
    }
948
    if (!Util::isValidKeyId(recipient)) {
72 ✔
949
      // Never drop a recipient silently: the list is written back verbatim
950
      // by UsersDialog, so a skipped line disappears from .gpg-id.
951
      qWarning() << "Skipping unusable recipient in" << gpgId.fileName() << ":"
4 ✔
952
                 << recipient;
2 ✔
953
      continue;
2 ✔
954
    }
955
    recipients += recipient;
956
  }
957
  return recipients;
958
}
57 ✔
959

960
/**
961
 * @brief Pass::getRecipientString formatted string for use with GPG
962
 * @param for_file which file (folder) would you like recipients for
963
 * @param separator formatting separator eg: " -r "
964
 * @param count
965
 * @return recipient string
966
 */
967
auto Pass::getRecipientString(const QString &for_file, const QString &passStore,
3 ✔
968
                              const QString &separator, int *count)
969
    -> QStringList {
970
  Q_UNUSED(separator)
971
  QStringList recipients = Pass::getRecipientList(for_file, passStore);
3 ✔
972
  if (count) {
3 ✔
973
    *count = static_cast<int>(recipients.size());
2 ✔
974
  }
975
  return recipients;
3 ✔
976
}
977

978
/**
979
 * @brief Pass::seedGpgIdFile write the inherited recipients into a new
980
 * folder's .gpg-id
981
 * @param newDir absolute path of the freshly created folder
982
 * @param passStore root directory of the password store
983
 * @return true when newDir/.gpg-id was written
984
 */
985
auto Pass::seedGpgIdFile(const QString &newDir, const QString &passStore)
5 ✔
986
    -> bool {
987
  const QString gpgIdFile = QDir(newDir).absoluteFilePath(".gpg-id");
10 ✔
988
  if (QFileInfo::exists(gpgIdFile)) {
5 ✔
989
    return false;
990
  }
991
  // Resolve from the file we are about to create: getGpgIdPath walks up from
992
  // its directory, so this yields the parent's .gpg-id whether or not newDir
993
  // carries a trailing separator.
994
  const QStringList recipients = getRecipientList(gpgIdFile, passStore);
4 ✔
995
  if (recipients.isEmpty()) {
4 ✔
996
    return false;
997
  }
998
  QSaveFile gpgId(gpgIdFile);
2 ✔
999
  if (!gpgId.open(QIODevice::WriteOnly)) {
2 ✔
1000
    return false;
1001
  }
1002
  QTextStream out(&gpgId);
2 ✔
1003
  for (const QString &recipient : recipients) {
5 ✔
1004
    out << recipient << '\n';
3 ✔
1005
  }
1006
  out.flush();
2 ✔
1007
  if (out.status() != QTextStream::Ok || !gpgId.commit()) {
2 ✔
1008
    return false;
×
1009
  }
1010
  // Lock to owner-only access; see ImitatePass::writeGpgIdFile for the
1011
  // rationale (NFS / USB / unusual umask). Best-effort where setPermissions
1012
  // is a no-op.
1013
  QFile::setPermissions(gpgIdFile, QFile::ReadOwner | QFile::WriteOwner);
2 ✔
1014
  return true;
1015
}
2 ✔
1016

1017
/* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
1018
 */
1019

1020
/**
1021
 * @brief Generates a random number bounded by the given value.
1022
 * @param bound Upper bound (exclusive)
1023
 * @return Random number in range [0, bound)
1024
 */
1025
auto Pass::boundedRandom(quint32 bound) -> quint32 {
7,592 ✔
1026
  if (bound < 2) {
7,592 ✔
1027
    return 0;
1028
  }
1029

1030
  quint32 randval;
1031
  // Rejection-sampling threshold to avoid modulo bias.
1032
  // This follows the well-known "arc4random_uniform"-style approach:
1033
  // reject values in the low range [0, min), where
1034
  //   min = 2^32 % bound
1035
  // so that the remaining range size is an exact multiple of `bound`.
1036
  //
1037
  // In quint32 arithmetic, (1 + ~bound) wraps to (2^32 - bound), therefore
1038
  //   (1 + ~bound) % bound == 2^32 % bound.
1039
  const quint32 rejectionThreshold = (1 + ~bound) % bound;
7,592 ✔
1040

1041
  do {
1042
    randval = QRandomGenerator::system()->generate();
1043
  } while (randval < rejectionThreshold);
7,592 ✔
1044

1045
  return randval % bound;
7,592 ✔
1046
}
1047

1048
/**
1049
 * @brief Generates a random password from the given charset.
1050
 * @param charset Characters to use in the password
1051
 * @param length Desired password length
1052
 * @return Generated password string
1053
 */
1054
auto Pass::generateRandomPassword(const QString &charset, unsigned int length)
1,204 ✔
1055
    -> QString {
1056
  if (charset.isEmpty() || length == 0U) {
1,204 ✔
1057
    return {};
1058
  }
1059
  QString out;
1,204 ✔
1060
  for (unsigned int i = 0; i < length; ++i) {
8,796 ✔
1061
    out.append(charset.at(static_cast<int>(
7,592 ✔
1062
        boundedRandom(static_cast<quint32>(charset.length())))));
7,592 ✔
1063
  }
1064
  return out;
1065
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc