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

IJHack / QtPass / 35159963146

16 Sep 2026 10:55PM UTC coverage: 72.009% (-0.3%) from 72.336%
35159963146

push

github

web-flow
One "qtpass" logging category instead of dbg() and 69 QT_DEBUG guards (#1785)

* One "qtpass" logging category instead of dbg() and 69 QT_DEBUG guards

debughelper.h promised file:line output "only in debug builds" while
src.pro already compiled qDebug out of release builds, and the #ifdef
blocks spliced control flow (an else in trayicon.cpp, an else-if in
sshauthsock.cpp) and have broken the build before (55149eca1, 0b6a80bdf).

Q_LOGGING_CATEGORY(lcQtPass, "qtpass", QtInfoMsg): every dbg() is a
qCDebug(lcQtPass), the debug-only qWarning()s are qCWarning(lcQtPass), the
guards are gone, and QT_NO_DEBUG_OUTPUT is no longer defined for release,
so a shipped build can produce a trace with
QT_LOGGING_RULES="qtpass.debug=true". FAQ entry and fixing skill updated;
tst_util checks the category is quiet until asked.

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

* Convert the last bare qDebug(); FAQ: key IDs

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>

36 of 78 new or added lines in 15 files covered. (46.15%)

23 existing lines in 8 files now uncovered.

5513 of 7656 relevant lines covered (72.01%)

60.03 hits per line

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

78.81
/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
#include "qtpasslogging.h"
19

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

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

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

63
/**
64
 * @brief Pass::Pass wrapper for using either pass or the pass imitation
65
 */
66
Pass::Pass() : env(QProcessEnvironment::systemEnvironment()) {
92 ✔
67
  connect(&exec, &Executor::finished, this, &Pass::finished);
92 ✔
68
  connect(&exec, &Executor::error, this, &Pass::finished);
92 ✔
69

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

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

103
void Pass::executeWrapper(PROCESS id, const QString &app,
70 ✔
104
                          const QStringList &args, QString input,
105
                          bool readStdout, bool readStderr) {
106
  beforeExecute(id);
70 ✔
107
  qCDebug(lcQtPass) << app << args;
70 ✔
108
  exec.execute(id, m_settings.passStore, app, args, std::move(input),
70 ✔
109
               readStdout, readStderr);
110
}
70 ✔
111

112
void Pass::beforeExecute(PROCESS /*id*/) {}
×
113

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

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

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

238
/**
239
 * @brief Pass::gpgSupportsEd25519 check if GPG supports ed25519 (ECC)
240
 * GPG 2.1+ supports ed25519 which is much faster for key generation
241
 * @return true if ed25519 is supported
242
 */
243
bool Pass::gpgSupportsEd25519(const QString &gpgExecutable) {
19 ✔
244
  const QString exe =
245
      gpgExecutable.isEmpty() ? QStringLiteral("gpg") : gpgExecutable;
19 ✔
246
  QString out, err;
19 ✔
247
  if (Executor::executeBlocking(exe, {"--version"}, &out, &err) != 0) {
57 ✔
248
    return false;
249
  }
250
  QRegularExpression versionRegex(R"(gpg \(GnuPG\) (\d+)\.(\d+))");
38 ✔
251
  QRegularExpressionMatch match = versionRegex.match(out);
19 ✔
252
  if (!match.hasMatch()) {
19 ✔
253
    return false;
254
  }
255
  int major = match.captured(1).toInt();
19 ✔
256
  int minor = match.captured(2).toInt();
19 ✔
257
  return major > 2 || (major == 2 && minor >= 1);
19 ✔
258
}
38 ✔
259

260
/**
261
 * @brief Pass::getDefaultKeyTemplate return default key generation template
262
 * Uses ed25519 if supported, otherwise falls back to RSA
263
 * @return GPG batch template string
264
 */
265
QString Pass::getDefaultKeyTemplate(const QString &gpgExecutable) {
18 ✔
266
  if (gpgSupportsEd25519(gpgExecutable)) {
18 ✔
267
    return QStringLiteral("%echo Generating a default key\n"
18 ✔
268
                          "Key-Type: EdDSA\n"
269
                          "Key-Curve: Ed25519\n"
270
                          "Subkey-Type: ECDH\n"
271
                          "Subkey-Curve: Curve25519\n"
272
                          "Name-Real: \n"
273
                          "Name-Comment: QtPass\n"
274
                          "Name-Email: \n"
275
                          "Expire-Date: 0\n"
276
                          "%no-protection\n"
277
                          "%commit\n"
278
                          "%echo done");
279
  }
280
  return QStringLiteral("%echo Generating a default key\n"
×
281
                        "Key-Type: RSA\n"
282
                        "Subkey-Type: RSA\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

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

314
/**
315
 * @brief Finds the path to the gpgconf executable in the same directory as the
316
 * given GPG path.
317
 * @example
318
 * QString result = findGpgconfInGpgDir(gpgPath);
319
 * std::cout << result.toStdString() << std::endl; // Expected output: path to
320
 * gpgconf or empty string
321
 *
322
 * @param gpgPath - Absolute path to a GPG executable or related file used to
323
 * locate gpgconf.
324
 * @return QString - The full path to gpgconf if found and executable; otherwise
325
 * an empty QString.
326
 */
327
QString findGpgconfInGpgDir(const QString &gpgPath) {
1 ✔
328
  QFileInfo gpgInfo(gpgPath);
1 ✔
329
  if (!gpgInfo.isAbsolute()) {
1 ✔
330
    return {};
331
  }
332

333
  QDir dir(gpgInfo.absolutePath());
1 ✔
334

335
#ifdef Q_OS_WIN
336
  QFileInfo candidateExe(dir.filePath("gpgconf.exe"));
337
  if (candidateExe.isExecutable()) {
338
    return candidateExe.filePath();
339
  }
340
#endif
341

342
  QFileInfo candidate(dir.filePath("gpgconf"));
1 ✔
343
  if (candidate.isExecutable()) {
1 ✔
344
    return candidate.filePath();
×
345
  }
346
  return {};
347
}
1 ✔
348

349
} // namespace
350

351
/**
352
 * @brief Resolves the appropriate gpgconf command from a given GPG executable
353
 * path or command string.
354
 * @example
355
 * ResolvedGpgconfCommand result = Pass::resolveGpgconfCommand("wsl.exe
356
 * /usr/bin/gpg"); std::cout << result.first.toStdString() << std::endl; //
357
 * Expected output sample
358
 *
359
 * @param const QString &gpgPath - Path or command string pointing to the GPG
360
 * executable.
361
 * @return ResolvedGpgconfCommand - A pair containing the resolved gpgconf
362
 * command and its arguments.
363
 */
364
auto Pass::resolveGpgconfCommand(const QString &gpgPath)
10 ✔
365
    -> ResolvedGpgconfCommand {
366
  if (gpgPath.trimmed().isEmpty()) {
10 ✔
367
    return {"gpgconf", {}};
368
  }
369

370
  QStringList parts = QProcess::splitCommand(gpgPath);
9 ✔
371

372
  if (parts.isEmpty()) {
9 ✔
373
    return {"gpgconf", {}};
374
  }
375

376
  const QString first = parts.first();
377
  if (first.compare("wsl", Qt::CaseInsensitive) == 0 ||
20 ✔
378
      first.compare("wsl.exe", Qt::CaseInsensitive) == 0) {
11 ✔
379
    if (parts.size() >= 2 && parts.at(1).startsWith("sh")) {
13 ✔
380
      return {"gpgconf", {}};
381
    }
382
    if (parts.size() >= 2 &&
6 ✔
383
        QFileInfo(parts.last()).fileName().startsWith("gpg")) {
16 ✔
384
      QString wslGpgconf = resolveWslGpgconfPath(parts.last());
5 ✔
385
      parts.removeLast();
5 ✔
386
      // Run gpgconf directly rather than through the distribution's default
387
      // shell, which would word-split and expand the arguments. Keep any
388
      // --exec/-e the user already put in the command.
389
      if (!parts.contains("--exec") && !parts.contains("-e")) {
9 ✔
390
        parts.append("--exec");
6 ✔
391
      }
392
      parts.append(wslGpgconf);
393
      return {parts.first(), parts.mid(1)};
394
    }
395
    return {"gpgconf", {}};
396
  }
397

398
  if (!first.contains('/') && !first.contains('\\')) {
2 ✔
399
    return {"gpgconf", {}};
400
  }
401

402
  QString gpgconfPath = findGpgconfInGpgDir(first);
1 ✔
403
  if (!gpgconfPath.isEmpty()) {
1 ✔
404
    return {gpgconfPath, {}};
×
405
  }
406

407
  return {"gpgconf", {}};
408
}
10 ✔
409

410
/**
411
 * @brief Pass::GenerateGPGKeys internal gpg keypair generator . .
412
 * @param batch GnuPG style configuration string
413
 */
414
void Pass::GenerateGPGKeys(QString batch) {
1 ✔
415
  const QString gpgPath = m_settings.gpgExecutable;
416
  if (gpgPath.isEmpty()) {
1 ✔
417
    // No gpg configured: executeWrapper would hand an empty executable to the
418
    // Executor, which silently drops it (see Executor::execute), leaving the
419
    // keygen dialog spinning with no feedback. Surface the misconfiguration
420
    // instead. Deferred via a queued call so we do not re-enter
421
    // KeygenDialog::done(), which drives key generation synchronously.
422
    QMetaObject::invokeMethod(
1 ✔
423
        this,
424
        [this]() {
1 ✔
425
          emit processErrorExit(1, tr("No GPG executable configured"));
1 ✔
426
        },
1 ✔
427
        Qt::QueuedConnection);
428
    return;
429
  }
430

431
  // Kill any stale GPG agents that might be holding locks on the key database.
432
  // This helps avoid "database locked" timeouts during key generation.
433
  ResolvedGpgconfCommand resolvedGpgconf = resolveGpgconfCommand(gpgPath);
×
434
  QStringList killArgs = resolvedGpgconf.arguments;
435
  killArgs << "--kill";
×
436
  killArgs << "gpg-agent";
×
437
  // Use same environment as key generation to target correct gpg-agent
438
  if (Executor::executeBlocking(env, resolvedGpgconf.program, killArgs) != 0) {
×
NEW
439
    qCWarning(lcQtPass) << "Failed to kill gpg-agent";
×
440
  }
441

442
  executeWrapper(GPG_GENKEYS, gpgPath, {"--gen-key", "--no-tty", "--batch"},
×
443
                 std::move(batch), true, true);
444
}
×
445

446
/**
447
 * @brief Pass::listKeys list users
448
 * @param keystrings
449
 * @param secret list private keys
450
 * @return QList<UserInfo> users
451
 */
452
auto Pass::listKeys(QStringList keystrings, bool secret) -> QList<UserInfo> {
23 ✔
453
  QStringList args = {"--no-tty", "--with-colons", "--with-fingerprint"};
92 ✔
454
  args.append(secret ? "--list-secret-keys" : "--list-keys");
57 ✔
455

456
  for (const QString &keystring : std::as_const(keystrings)) {
46 ✔
457
    if (!keystring.isEmpty()) {
23 ✔
458
      args.append(keystring);
459
    }
460
  }
461
  QString p_out;
23 ✔
462
  if (Executor::executeBlocking(m_settings.gpgExecutable, args, &p_out) != 0) {
23 ✔
463
    return {};
×
464
  }
465
  return parseGpgColonOutput(p_out, secret);
23 ✔
466
}
23 ✔
467

468
/**
469
 * @brief Pass::listKeys list users
470
 * @param keystring
471
 * @param secret list private keys
472
 * @return QList<UserInfo> users
473
 */
474
auto Pass::listKeys(const QString &keystring, bool secret) -> QList<UserInfo> {
20 ✔
475
  return listKeys(QStringList(keystring), secret);
40 ✔
476
}
477

478
/**
479
 * @brief Maps GPG stderr (which may include --status-fd 2 tokens) to a
480
 * user-friendly encryption error string.
481
 *
482
 * Checked in order: machine-readable [GNUPG:] status tokens first (locale-
483
 * independent), then case-insensitive substring fallbacks for GPG builds that
484
 * don't emit status tokens.
485
 *
486
 * @param err Raw stderr from GPG
487
 * @return Translated human-readable error, or empty string if not recognised
488
 */
489
namespace {
490

491
/**
492
 * @brief Checks if @p str contains any of the @p patterns (case-sensitive).
493
 * @param str String to search in.
494
 * @param patterns Patterns to search for.
495
 * @return true if any pattern is found, false otherwise.
496
 */
497
auto containsAny(const QString &str, const QStringList &patterns) -> bool {
31 ✔
498
  for (const QString &p : patterns) {
82 ✔
499
    if (str.contains(p)) {
58 ✔
500
      return true;
501
    }
502
  }
503
  return false;
504
}
505

506
/**
507
 * @brief Checks if str contains any of the patterns (case-insensitive).
508
 * @param str String to search in (will be lowercased once).
509
 * @param patterns List of patterns to search for (must be lowercase; caller
510
 * should convert patterns to lowercase before calling).
511
 * @return true if any pattern is found.
512
 */
513
auto containsAnyCaseInsensitive(const QString &str, const QStringList &patterns)
13 ✔
514
    -> bool {
515
  const QString lower = str.toLower();
516
  for (const QString &p : patterns) {
32 ✔
517
    if (lower.contains(p)) {
23 ✔
518
      return true;
519
    }
520
  }
521
  return false;
522
}
523

524
} // namespace
525

526
auto gpgErrorMessage(const QString &err) -> QString {
13 ✔
527
  // Machine-readable status tokens added by --status-fd 2
528
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYEXPIRED"),
65 ✔
529
                        QStringLiteral("[GNUPG:] INV_RECP 5 ")}))
13 ✔
530
    return QCoreApplication::translate(
531
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
532
                "replace it.");
3 ✔
533
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYREVOKED"),
50 ✔
534
                        QStringLiteral("[GNUPG:] INV_RECP 4 ")}))
10 ✔
535
    return QCoreApplication::translate(
536
        "Pass", "Encryption failed: GPG key has been revoked.");
2 ✔
537
  if (containsAny(err, {QStringLiteral("[GNUPG:] NO_PUBKEY"),
40 ✔
538
                        QStringLiteral("[GNUPG:] INV_RECP")}))
8 ✔
539
    return QCoreApplication::translate(
540
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
541
                "Check that the key ID in .gpg-id is correct and imported.");
2 ✔
542
  if (err.contains(QStringLiteral("[GNUPG:] FAILURE")))
6 ✔
543
    return QCoreApplication::translate(
544
        "Pass", "Encryption failed. Check that your GPG key is valid.");
1 ✔
545

546
  // Locale-dependent fallbacks
547
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has expired"),
20 ✔
548
                                       QLatin1String("key expired")}))
549
    return QCoreApplication::translate(
550
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
551
                "replace it.");
1 ✔
552
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has been revoked"),
16 ✔
553
                                       QLatin1String("revoked")}))
554
    return QCoreApplication::translate(
555
        "Pass", "Encryption failed: GPG key has been revoked.");
1 ✔
556
  if (containsAnyCaseInsensitive(err, {QLatin1String("no public key"),
15 ✔
557
                                       QLatin1String("unusable public key"),
558
                                       QLatin1String("no secret key")}))
559
    return QCoreApplication::translate(
560
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
561
                "Check that the key ID in .gpg-id is correct and imported.");
2 ✔
562
  if (containsAnyCaseInsensitive(err, {QLatin1String("encryption failed")}))
3 ✔
563
    return QCoreApplication::translate(
564
        "Pass", "Encryption failed. Check that your GPG key is valid.");
×
565

566
  return {};
567
}
×
568

569
namespace {
570
/**
571
 * @brief Determine whether a line from `pass grep` output is an entry header.
572
 *
573
 * Detects the ANSI blue escape (\x1B[94m) emitted by `pass grep`; as a
574
 * plain-text fallback, treats a non-indented line ending in ':' as a header.
575
 *
576
 * @param rawLine Original unmodified output line (with any ANSI codes).
577
 * @param trimmedLine The line after surrounding whitespace has been stripped.
578
 * @return true if the line is an entry header; otherwise false.
579
 */
580
auto isGrepHeaderLine(const QString &rawLine, const QString &trimmedLine)
45 ✔
581
    -> bool {
582
  return rawLine.startsWith(QStringLiteral("\x1B[94m")) ||
123 ✔
583
         (!rawLine.startsWith(' ') && !rawLine.startsWith('\t') &&
91 ✔
584
          trimmedLine.endsWith(':'));
119 ✔
585
}
586
} // namespace
587

588
/**
589
 * @brief Parses 'pass grep' raw output into (entry, matches) pairs.
590
 *
591
 * pass grep emits ANSI blue color (\x1B[94m) at the start of each entry
592
 * header line. This is checked before stripping ANSI so headers are detected
593
 * reliably regardless of locale.
594
 */
595
auto parseGrepOutput(const QString &rawOut)
12 ✔
596
    -> QList<QPair<QString, QStringList>> {
597
  static const QRegularExpression ansi(
598
      QStringLiteral(R"(\x1B\[[0-9;]*[a-zA-Z])"));
13 ✔
599
  QList<QPair<QString, QStringList>> results;
12 ✔
600
  QString currentEntry;
12 ✔
601
  QStringList currentMatches;
12 ✔
602
  for (const QString &rawLine : rawOut.split('\n')) {
69 ✔
603
    QString line = rawLine;
604
    line.remove('\r');
45 ✔
605
    line.remove(ansi);
45 ✔
606
    line = line.trimmed();
45 ✔
607
    const bool isHeader = isGrepHeaderLine(rawLine, line);
45 ✔
608
    if (isHeader) {
45 ✔
609
      if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
14 ✔
610
        results.append({currentEntry, currentMatches});
3 ✔
611
      currentEntry = line.endsWith(':') ? line.chopped(1) : line;
14 ✔
612
      currentMatches.clear();
14 ✔
613
    } else if (!currentEntry.isEmpty()) {
31 ✔
614
      if (!line.isEmpty())
29 ✔
615
        currentMatches << line;
616
    }
617
  }
618
  if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
12 ✔
619
    results.append({currentEntry, currentMatches});
11 ✔
620
  return results;
12 ✔
621
}
622

623
/**
624
 * @brief Pass::processFinished reemits specific signal based on what process
625
 * has finished
626
 * @param id    id of Pass process that was scheduled and finished
627
 * @param exitCode  return code of a process
628
 * @param out   output generated by process(if capturing was requested, empty
629
 *              otherwise)
630
 * @param err   error output generated by process(if capturing was requested,
631
 *              or error occurred)
632
 */
633
void Pass::finished(int id, int exitCode, const QString &out,
69 ✔
634
                    const QString &err) {
635
  auto pid = static_cast<PROCESS>(id);
69 ✔
636

637
  if (exitCode != 0) {
69 ✔
638
    handleProcessError(pid, exitCode, out, err);
2 ✔
639
    return;
2 ✔
640
  }
641

642
  emitProcessFinishedSignal(pid, out, err);
67 ✔
643
}
644

645
void Pass::handleProcessError(PROCESS pid, int exitCode, const QString &out,
2 ✔
646
                              const QString &err) {
647
  Q_UNUSED(out);
648

649
  if (pid == PASS_GREP) {
2 ✔
650
    handleGrepError(exitCode, err);
2 ✔
651
    return;
2 ✔
652
  }
653

UNCOV
654
  if (pid == PASS_INSERT) {
×
655
    const QString friendly = gpgErrorMessage(err);
×
656
    if (!friendly.isEmpty()) {
×
657
      emit processErrorExit(exitCode, formatInsertError(friendly, err));
×
658
      return;
659
    }
660
  }
661

UNCOV
662
  emit processErrorExit(exitCode, err);
×
663
}
664

665
void Pass::handleGrepError(int exitCode, const QString &err) {
2 ✔
666
  if (exitCode == 1) {
2 ✔
667
    emit finishedGrep({});
2 ✔
668
  } else {
669
    emit processErrorExit(exitCode, err);
1 ✔
670
    emit finishedGrep({});
2 ✔
671
  }
672
}
2 ✔
673

674
auto Pass::formatInsertError(const QString &friendly, const QString &err)
×
675
    -> QString {
676
  QStringList humanLines;
×
677
  for (const QString &line : err.split('\n')) {
×
678
    QString cleanedLine = line;
679
    cleanedLine.remove('\r');
×
680
    if (!cleanedLine.startsWith(QLatin1String("[GNUPG:]")))
×
681
      humanLines.append(cleanedLine);
682
  }
683
  const QString humanErr = humanLines.join('\n').trimmed();
×
684
  return humanErr.isEmpty() ? friendly : friendly + "\n\n" + humanErr;
×
685
}
686

687
/**
688
 * @brief Emit the appropriate finished signal for a completed subprocess.
689
 *
690
 * Emits a specific Qt signal corresponding to the given process identifier; for
691
 * grep results the stdout is parsed into a list of matches before emitting.
692
 *
693
 * @param pid The process identifier indicating which finished signal to emit.
694
 * @param out Standard output produced by the process.
695
 * @param err Standard error produced by the process.
696
 */
697
void Pass::emitProcessFinishedSignal(PROCESS pid, const QString &out,
67 ✔
698
                                     const QString &err) {
699
  /**
700
   * @brief Filter sensitive commands to prevent password leakage.
701
   *
702
   * Sensitive commands (PASS_SHOW, etc.) output plaintext passwords or
703
   * searchable content that should not be exposed to any UI listener.
704
   *
705
   * Using a default branch: if new PASS_* values are added, they
706
   * default to NOT leaking (safe by default). Making this
707
   * exhaustive would require updating here for every new
708
   * command and risk silent password leakage if forgotten.
709
   */
710
  switch (pid) {
67 ✔
711
  case PASS_SHOW:
712
  case PASS_GREP:
713
  case PASS_INSERT:
714
    break;
715
  default:
3 ✔
716
    emit finishedAnyWithPid(out, err, pid);
3 ✔
717
    break;
3 ✔
718
  }
719

720
  switch (pid) {
67 ✔
721
  case GIT_INIT:
×
722
    emit finishedGitInit(out, err);
×
723
    break;
×
724
  case GIT_PULL:
×
725
    emit finishedGitPull(out, err);
×
726
    break;
×
727
  case GIT_PUSH:
1 ✔
728
    emit finishedGitPush(out, err);
1 ✔
729
    break;
1 ✔
730
  case PASS_SHOW:
20 ✔
731
    emit finishedShow(out);
20 ✔
732
    break;
20 ✔
733
  case PASS_INSERT:
43 ✔
734
    emit finishedInsert(out, err);
43 ✔
735
    break;
43 ✔
736
  case PASS_REMOVE:
×
737
    emit finishedRemove(out, err);
×
738
    break;
×
739
  case PASS_INIT:
1 ✔
740
    emit finishedInit(out, err);
1 ✔
741
    break;
1 ✔
742
  case PASS_MOVE:
×
743
    emit finishedMove(out, err);
×
744
    break;
×
745
  case PASS_COPY:
1 ✔
746
    emit finishedCopy(out, err);
1 ✔
747
    break;
1 ✔
748
  case GPG_GENKEYS:
×
749
    emit finishedGenerateGPGKeys(out, err);
×
750
    break;
×
751
  case PASS_GREP:
1 ✔
752
    emit finishedGrep(parseGrepOutput(out));
1 ✔
753
    break;
1 ✔
UNCOV
754
  default:
×
NEW
755
    qCDebug(lcQtPass) << "Unhandled process type" << pid;
×
UNCOV
756
    break;
×
757
  }
758
}
67 ✔
759

760
/**
761
 * @brief Set or remove a single environment variable.
762
 *
763
 * @param name Variable name, without a trailing '=' (e.g.
764
 * "PASSWORD_STORE_DIR").
765
 * @param value New value; an empty string removes the variable entirely.
766
 */
767
void Pass::setEnvVar(const QString &name, const QString &value) {
326 ✔
768
  if (value.isEmpty())
326 ✔
769
    env.remove(name);
89 ✔
770
  else
771
    env.insert(name, value);
237 ✔
772
}
326 ✔
773

774
/**
775
 * @brief Update the process environment used for executing external commands.
776
 *
777
 * Updates environment entries for PASSWORD_STORE_SIGNING_KEY,
778
 * PASSWORD_STORE_DIR, PASSWORD_STORE_GENERATED_LENGTH, and
779
 * PASSWORD_STORE_CHARACTER_SET based on current settings, then applies the
780
 * environment to the internal executor.
781
 */
782
void Pass::updateEnv() {
80 ✔
783
  setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY"),
160 ✔
784
            m_settings.passSigningKey);
80 ✔
785
  setEnvVar(QStringLiteral("PASSWORD_STORE_DIR"), m_settings.passStore);
160 ✔
786

787
  const PasswordConfiguration &passConfig = m_settings.passwordConfiguration;
80 ✔
788
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH"),
160 ✔
789
            QString::number(passConfig.length));
80 ✔
790

791
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET"),
160 ✔
792
            effectiveCharset(passConfig));
80 ✔
793

794
  exec.setEnvironment(env);
80 ✔
795
}
80 ✔
796

797
/**
798
 * @brief Pass::getGpgIdPath return gpgid file path for some file (folder).
799
 * @param for_file which file (folder) would you like the gpgid file path for.
800
 * @return path to the gpgid file.
801
 */
802
auto Pass::getGpgIdPath(const QString &for_file, const QString &passStore)
150 ✔
803
    -> QString {
804
  QString normalizedStore = QDir::fromNativeSeparators(passStore);
150 ✔
805
  QString normalizedFile = QDir::fromNativeSeparators(for_file);
150 ✔
806
  QString fullPath = normalizedFile.startsWith(normalizedStore)
150 ✔
807
                         ? normalizedFile
150 ✔
808
                         : normalizedStore + "/" + normalizedFile;
82 ✔
809
  QDir gpgIdDir(QFileInfo(fullPath).absoluteDir());
150 ✔
810
  // QDir::cleanPath() always normalises to forward slashes, so use '/'
811
  // here rather than QDir::separator() (which returns '\\' on Windows).
812
  QString cleanPassStore = QDir::cleanPath(normalizedStore);
150 ✔
813
  bool found = false;
814
  while (gpgIdDir.exists()) {
185 ✔
815
    QString currentPath = QDir::cleanPath(gpgIdDir.absolutePath());
366 ✔
816
    const QString prefix =
817
        cleanPassStore.endsWith('/') ? cleanPassStore : cleanPassStore + "/";
183 ✔
818
    if (currentPath != cleanPassStore && !currentPath.startsWith(prefix)) {
183 ✔
819
      break;
820
    }
821
    if (QFile(gpgIdDir.absoluteFilePath(".gpg-id")).exists()) {
338 ✔
822
      found = true;
823
      break;
824
    }
825
    if (!gpgIdDir.cdUp()) {
35 ✔
826
      break;
827
    }
828
  }
829
  return found ? gpgIdDir.absoluteFilePath(".gpg-id")
150 ✔
830
               : QDir(normalizedStore).filePath(".gpg-id");
450 ✔
831
}
150 ✔
832

833
/**
834
 * @brief Pass::getRecipientList return list of gpg-id's to encrypt for
835
 * @param for_file which file (folder) would you like recipients for
836
 * @return recipients gpg-id contents
837
 */
838
auto Pass::getRecipientList(const QString &for_file, const QString &passStore)
83 ✔
839
    -> QStringList {
840
  QFile gpgId(getGpgIdPath(for_file, passStore));
83 ✔
841
  if (!gpgId.open(QIODevice::ReadOnly | QIODevice::Text)) {
83 ✔
842
    return {};
6 ✔
843
  }
844
  QStringList recipients;
77 ✔
845
  while (!gpgId.atEnd()) {
175 ✔
846
    QString recipient(gpgId.readLine());
196 ✔
847
    recipient = recipient.split("#")[0].trimmed();
196 ✔
848
    if (recipient.isEmpty()) {
98 ✔
849
      continue;
7 ✔
850
    }
851
    if (!Util::isValidKeyId(recipient)) {
91 ✔
852
      // Never drop a recipient silently: the list is written back verbatim
853
      // by UsersDialog, so a skipped line disappears from .gpg-id.
854
      qCWarning(lcQtPass) << "Skipping unusable recipient in"
4 ✔
855
                          << gpgId.fileName() << ":" << recipient;
4 ✔
856
      continue;
2 ✔
857
    }
2 ✔
858
    recipients += recipient;
859
  }
860
  return recipients;
861
}
83 ✔
862

863
/**
864
 * @brief Pass::seedGpgIdFile write the inherited recipients into a new
865
 * folder's .gpg-id
866
 * @param newDir absolute path of the freshly created folder
867
 * @param passStore root directory of the password store
868
 * @return true when newDir/.gpg-id was written
869
 */
870
auto Pass::seedGpgIdFile(const QString &newDir, const QString &passStore)
5 ✔
871
    -> bool {
872
  const QString gpgIdFile = QDir(newDir).absoluteFilePath(".gpg-id");
10 ✔
873
  if (QFileInfo::exists(gpgIdFile)) {
5 ✔
874
    return false;
875
  }
876
  // Resolve from the file we are about to create: getGpgIdPath walks up from
877
  // its directory, so this yields the parent's .gpg-id whether or not newDir
878
  // carries a trailing separator.
879
  const QStringList recipients = getRecipientList(gpgIdFile, passStore);
4 ✔
880
  if (recipients.isEmpty()) {
4 ✔
881
    return false;
882
  }
883
  QSaveFile gpgId(gpgIdFile);
2 ✔
884
  if (!gpgId.open(QIODevice::WriteOnly)) {
2 ✔
885
    return false;
886
  }
887
  QTextStream out(&gpgId);
2 ✔
888
  for (const QString &recipient : recipients) {
5 ✔
889
    out << recipient << '\n';
3 ✔
890
  }
891
  out.flush();
2 ✔
892
  if (out.status() != QTextStream::Ok || !gpgId.commit()) {
2 ✔
893
    return false;
×
894
  }
895
  // Lock to owner-only access; see ImitatePass::writeGpgIdFile for the
896
  // rationale (NFS / USB / unusual umask). Best-effort where setPermissions
897
  // is a no-op.
898
  QFile::setPermissions(gpgIdFile, QFile::ReadOwner | QFile::WriteOwner);
2 ✔
899
  return true;
900
}
2 ✔
901

902
/* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
903
 */
904

905
/**
906
 * @brief Generates a random number bounded by the given value.
907
 * @param bound Upper bound (exclusive)
908
 * @return Random number in range [0, bound)
909
 */
910
auto Pass::boundedRandom(quint32 bound) -> quint32 {
7,592 ✔
911
  if (bound < 2) {
7,592 ✔
912
    return 0;
913
  }
914

915
  quint32 randval;
916
  // Rejection-sampling threshold to avoid modulo bias.
917
  // This follows the well-known "arc4random_uniform"-style approach:
918
  // reject values in the low range [0, min), where
919
  //   min = 2^32 % bound
920
  // so that the remaining range size is an exact multiple of `bound`.
921
  //
922
  // In quint32 arithmetic, (1 + ~bound) wraps to (2^32 - bound), therefore
923
  //   (1 + ~bound) % bound == 2^32 % bound.
924
  const quint32 rejectionThreshold = (1 + ~bound) % bound;
7,592 ✔
925

926
  do {
927
    randval = QRandomGenerator::system()->generate();
928
  } while (randval < rejectionThreshold);
7,592 ✔
929

930
  return randval % bound;
7,592 ✔
931
}
932

933
/**
934
 * @brief Generates a random password from the given charset.
935
 * @param charset Characters to use in the password
936
 * @param length Desired password length
937
 * @return Generated password string
938
 */
939
auto Pass::generateRandomPassword(const QString &charset, unsigned int length)
1,204 ✔
940
    -> QString {
941
  if (charset.isEmpty() || length == 0U) {
1,204 ✔
942
    return {};
943
  }
944
  QString out;
1,204 ✔
945
  for (unsigned int i = 0; i < length; ++i) {
8,796 ✔
946
    out.append(charset.at(static_cast<int>(
7,592 ✔
947
        boundedRandom(static_cast<quint32>(charset.length())))));
7,592 ✔
948
  }
949
  return out;
950
}
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