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

IJHack / QtPass / 28526360412

01 Jul 2026 02:50PM UTC coverage: 55.4% (+0.2%) from 55.169%
28526360412

push

github

web-flow
fix: report gpg keygen start failures instead of hanging (#1598) (#1599)

* fix: report gpg keygen start failures instead of hanging (#1598)

When gpg failed to start (missing/unstartable binary, e.g. a Flatpak
sandbox denial — #637), the keygen command carried its batch on stdin, so
Executor::executeNext() took the waitForStarted() branch and, on failure,
dequeued the command without emitting finished() or error(). Pass::finished
was never invoked, cleanKeygenDialog() never ran, and the modal keygen
dialog spun forever with no success or failure feedback. An empty configured
gpg path hit the same dead-end via Executor::execute()'s empty-app return.

- Executor: emit error(id, -1, ...) on FailedToStart so the failure routes
  through Pass::finished's existing non-zero exit-code gate to the
  "GPG key pair generation failed" path. Deferred via a queued call so it
  does not re-enter KeygenDialog::done(), which drives keygen synchronously.
- Pass::GenerateGPGKeys: surface processErrorExit() when the gpg executable
  is unconfigured instead of handing an empty command to the Executor.
- Add regression tests: a failed-to-start stdin command now emits exactly
  one error() with a non-zero code (previously zero signals), and a
  signal-killed process reports CrashExit with a non-zero code.

The original unconditional-success defect was already fixed in 8df346373;
this closes the remaining silent-failure path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCin4yQwkNeYzTvGQhThqG

* test: cover GenerateGPGKeys unconfigured-gpg path (#1598)

Add an integration regression test asserting that Pass::GenerateGPGKeys emits
processErrorExit with a non-zero code (and not finishedGenerateGPGKeys) when no
gpg executable is configured, covering the empty-path guard added in this PR.
No real gpg is invoked, so it runs without a keyring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic... (continued)

11 of 16 new or added lines in 2 files covered. (68.75%)

8 existing lines in 1 file now uncovered.

3714 of 6704 relevant lines covered (55.4%)

29.29 hits per line

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

70.22
/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 <QFileInfo>
10
#include <QProcess>
11
#include <QRandomGenerator>
12
#include <QRegularExpression>
13
#include <utility>
14

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

19
using Enums::GIT_INIT;
20
using Enums::GIT_PULL;
21
using Enums::GIT_PUSH;
22
using Enums::GPG_GENKEYS;
23
using Enums::PASS_COPY;
24
using Enums::PASS_GREP;
25
using Enums::PASS_INIT;
26
using Enums::PASS_INSERT;
27
using Enums::PASS_MOVE;
28
using Enums::PASS_OTP_GENERATE;
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,239✔
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 {
35✔
54
  int sel = passConfig.selected;
35✔
55
  if (sel < 0 || sel >= PasswordConfiguration::CHARSETS_COUNT)
35✔
56
    sel = PasswordConfiguration::ALLCHARS;
57
  return fallbackCharset(
58
      passConfig.Characters[sel],
35✔
59
      passConfig.Characters[PasswordConfiguration::ALLCHARS]);
35✔
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()) {
39✔
67
  connect(&exec,
39✔
68
          static_cast<void (Executor::*)(int, int, const QString &,
69
                                         const QString &)>(&Executor::finished),
70
          this, &Pass::finished);
39✔
71
  connect(&exec, &Executor::error, this, &Pass::finished);
39✔
72

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

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

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

117
void Pass::beforeExecute(PROCESS /*id*/) {}
×
118

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

142
  if (!m_settings.gpgHome.isEmpty()) {
34✔
143
    QDir absHome(m_settings.gpgHome);
16✔
144
    absHome.makeAbsolute();
16✔
145
    env.insert(QStringLiteral("GNUPGHOME"), absHome.path());
32✔
146
  }
16✔
147
}
34✔
148

149
/**
150
 * @brief Pass::Generate use either pwgen or internal password
151
 * generator
152
 * @param length of the desired password
153
 * @param charset to use for generation
154
 * @return the password
155
 */
156
auto Pass::generatePassword(unsigned int length, const QString &charset)
1,205✔
157
    -> QString {
158
  if (length == 0) {
1,205✔
159
    emit critical(tr("Invalid password length"),
2✔
160
                  tr("Can't generate password with zero length."));
1✔
161
    return {};
162
  }
163
  QString passwd;
1,204✔
164
  if (m_settings.usePwgen) {
1,204✔
165
    // --secure goes first as it overrides --no-* otherwise
166
    QStringList args;
×
167
    args.append("-1");
×
168
    if (!m_settings.lessRandom) {
×
169
      args.append("--secure");
×
170
    }
171
    args.append(m_settings.avoidCapitals ? "--no-capitalize" : "--capitalize");
×
172
    args.append(m_settings.avoidNumbers ? "--no-numerals" : "--numerals");
×
173
    if (m_settings.useSymbols) {
×
174
      args.append("--symbols");
×
175
    }
176
    args.append(QString::number(length));
×
177
    // executeBlocking returns 0 on success, non-zero on failure
178
    if (Executor::executeBlocking(m_settings.pwgenExecutable, args, &passwd) ==
×
179
        0) {
180
      static const QRegularExpression literalNewLines{"[\\n\\r]"};
×
181
      passwd.remove(literalNewLines);
×
182
    } else {
183
      passwd.clear();
×
184
#ifdef QT_DEBUG
185
      qDebug() << __FILE__ << ":" << __LINE__ << "\t"
186
               << "pwgen fail";
187
#endif
188
      // Error is already handled by clearing passwd; no need for critical
189
      // signal here
190
    }
191
  } else {
192
    // Validate charset - if CUSTOM is selected but chars are empty,
193
    // fall back to ALLCHARS to prevent weak passwords (issue #780)
194
    const QString cs = fallbackCharset(
195
        charset, m_settings.passwordConfiguration
196
                     .Characters[PasswordConfiguration::ALLCHARS]);
1,204✔
197
    if (cs.length() > 0) {
198
      passwd = generateRandomPassword(cs, length);
2,408✔
199
    } else {
200
      emit critical(
×
201
          tr("No characters chosen"),
×
202
          tr("Can't generate password, there are no characters to choose from "
×
203
             "set in the configuration!"));
204
    }
205
  }
206
  return passwd;
207
}
208

209
/**
210
 * @brief Pass::gpgSupportsEd25519 check if GPG supports ed25519 (ECC)
211
 * GPG 2.1+ supports ed25519 which is much faster for key generation
212
 * @return true if ed25519 is supported
213
 */
214
bool Pass::gpgSupportsEd25519(const QString &gpgExecutable) {
12✔
215
  const QString exe =
216
      gpgExecutable.isEmpty() ? QStringLiteral("gpg") : gpgExecutable;
12✔
217
  QString out, err;
12✔
218
  if (Executor::executeBlocking(exe, {"--version"}, &out, &err) != 0) {
36✔
219
    return false;
220
  }
221
  QRegularExpression versionRegex(R"(gpg \(GnuPG\) (\d+)\.(\d+))");
24✔
222
  QRegularExpressionMatch match = versionRegex.match(out);
12✔
223
  if (!match.hasMatch()) {
12✔
224
    return false;
225
  }
226
  int major = match.captured(1).toInt();
12✔
227
  int minor = match.captured(2).toInt();
12✔
228
  return major > 2 || (major == 2 && minor >= 1);
12✔
229
}
24✔
230

231
/**
232
 * @brief Pass::getDefaultKeyTemplate return default key generation template
233
 * Uses ed25519 if supported, otherwise falls back to RSA
234
 * @return GPG batch template string
235
 */
236
QString Pass::getDefaultKeyTemplate(const QString &gpgExecutable) {
11✔
237
  if (gpgSupportsEd25519(gpgExecutable)) {
11✔
238
    return QStringLiteral("%echo Generating a default key\n"
11✔
239
                          "Key-Type: EdDSA\n"
240
                          "Key-Curve: Ed25519\n"
241
                          "Subkey-Type: ECDH\n"
242
                          "Subkey-Curve: Curve25519\n"
243
                          "Name-Real: \n"
244
                          "Name-Comment: QtPass\n"
245
                          "Name-Email: \n"
246
                          "Expire-Date: 0\n"
247
                          "%no-protection\n"
248
                          "%commit\n"
249
                          "%echo done");
250
  }
251
  return QStringLiteral("%echo Generating a default key\n"
×
252
                        "Key-Type: RSA\n"
253
                        "Subkey-Type: RSA\n"
254
                        "Name-Real: \n"
255
                        "Name-Comment: QtPass\n"
256
                        "Name-Email: \n"
257
                        "Expire-Date: 0\n"
258
                        "%no-protection\n"
259
                        "%commit\n"
260
                        "%echo done");
261
}
262

263
namespace {
264
/**
265
 * @brief Resolve a candidate gpgconf path from the trailing WSL path segment.
266
 *
267
 * Takes the directory portion of @p lastPart (separated by '/' or '\\') and
268
 * appends "gpgconf"; if no separator is present, returns the bare executable
269
 * name "gpgconf".
270
 *
271
 * @param lastPart Path fragment that may contain a directory and executable.
272
 * @return Full path ending in "gpgconf", or "gpgconf" as a fallback.
273
 */
274
auto resolveWslGpgconfPath(const QString &lastPart) -> QString {
3✔
275
  qsizetype lastSep = lastPart.lastIndexOf('/');
3✔
276
  if (lastSep < 0) {
3✔
277
    lastSep = lastPart.lastIndexOf('\\');
2✔
278
  }
279
  if (lastSep >= 0) {
2✔
280
    return lastPart.left(lastSep + 1) + "gpgconf";
2✔
281
  }
282
  return QStringLiteral("gpgconf");
2✔
283
}
284

285
/**
286
 * @brief Finds the path to the gpgconf executable in the same directory as the
287
 * given GPG path.
288
 * @example
289
 * QString result = findGpgconfInGpgDir(gpgPath);
290
 * std::cout << result.toStdString() << std::endl; // Expected output: path to
291
 * gpgconf or empty string
292
 *
293
 * @param gpgPath - Absolute path to a GPG executable or related file used to
294
 * locate gpgconf.
295
 * @return QString - The full path to gpgconf if found and executable; otherwise
296
 * an empty QString.
297
 */
298
QString findGpgconfInGpgDir(const QString &gpgPath) {
1✔
299
  QFileInfo gpgInfo(gpgPath);
1✔
300
  if (!gpgInfo.isAbsolute()) {
1✔
301
    return {};
302
  }
303

304
  QDir dir(gpgInfo.absolutePath());
1✔
305

306
#ifdef Q_OS_WIN
307
  QFileInfo candidateExe(dir.filePath("gpgconf.exe"));
308
  if (candidateExe.isExecutable()) {
309
    return candidateExe.filePath();
310
  }
311
#endif
312

313
  QFileInfo candidate(dir.filePath("gpgconf"));
1✔
314
  if (candidate.isExecutable()) {
1✔
315
    return candidate.filePath();
×
316
  }
317
  return {};
318
}
1✔
319

320
// Compatibility shim for Qt < 5.15 where QProcess::splitCommand is not
321
// available. Keep this fallback while supporting pre-5.15 builds; remove once
322
// the project's minimum supported Qt version is raised to 5.15 or newer.
323
#if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
324
/**
325
 * @brief Splits a command string into arguments while respecting quotes and
326
 * escape characters.
327
 * @example
328
 * QStringList result = splitCommandCompat("cmd \"arg one\" 'arg two'
329
 * escaped\\ space");
330
 * // Expected output: ["cmd", "arg one", "arg two", "escaped space"]
331
 *
332
 * @param command - The input command string to split into individual arguments.
333
 * @return QStringList - A list of parsed command arguments.
334
 */
335
QStringList splitCommandCompat(const QString &command) {
336
  QStringList result;
337
  QString current;
338
  bool inSingleQuote = false;
339
  bool inDoubleQuote = false;
340
  bool escaping = false;
341
  for (QChar ch : command) {
342
    if (escaping) {
343
      current.append(ch);
344
      escaping = false;
345
      continue;
346
    }
347
    if (ch == '\\') {
348
      escaping = true;
349
      continue;
350
    }
351
    if (ch == '\'' && !inDoubleQuote) {
352
      inSingleQuote = !inSingleQuote;
353
      continue;
354
    }
355
    if (ch == '"' && !inSingleQuote) {
356
      inDoubleQuote = !inDoubleQuote;
357
      continue;
358
    }
359
    if (ch.isSpace() && !inSingleQuote && !inDoubleQuote) {
360
      if (!current.isEmpty()) {
361
        result.append(current);
362
        current.clear();
363
      }
364
      continue;
365
    }
366
    current.append(ch);
367
  }
368
  if (escaping) {
369
    current.append('\\');
370
  }
371
  if (!current.isEmpty()) {
372
    result.append(current);
373
  }
374
  return result;
375
}
376
#endif
377

378
} // namespace
379

380
/**
381
 * @brief Resolves the appropriate gpgconf command from a given GPG executable
382
 * path or command string.
383
 * @example
384
 * ResolvedGpgconfCommand result = Pass::resolveGpgconfCommand("wsl.exe
385
 * /usr/bin/gpg"); std::cout << result.first.toStdString() << std::endl; //
386
 * Expected output sample
387
 *
388
 * @param const QString &gpgPath - Path or command string pointing to the GPG
389
 * executable.
390
 * @return ResolvedGpgconfCommand - A pair containing the resolved gpgconf
391
 * command and its arguments.
392
 */
393
auto Pass::resolveGpgconfCommand(const QString &gpgPath)
8✔
394
    -> ResolvedGpgconfCommand {
395
  if (gpgPath.trimmed().isEmpty()) {
8✔
396
    return {"gpgconf", {}};
397
  }
398

399
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
400
  QStringList parts = QProcess::splitCommand(gpgPath);
7✔
401
#else
402
  QStringList parts = splitCommandCompat(gpgPath);
403
#endif
404

405
  if (parts.isEmpty()) {
7✔
406
    return {"gpgconf", {}};
407
  }
408

409
  const QString first = parts.first();
410
  if (first.compare("wsl", Qt::CaseInsensitive) == 0 ||
16✔
411
      first.compare("wsl.exe", Qt::CaseInsensitive) == 0) {
9✔
412
    if (parts.size() >= 2 && parts.at(1).startsWith("sh")) {
9✔
413
      return {"gpgconf", {}};
414
    }
415
    if (parts.size() >= 2 &&
4✔
416
        QFileInfo(parts.last()).fileName().startsWith("gpg")) {
10✔
417
      QString wslGpgconf = resolveWslGpgconfPath(parts.last());
3✔
418
      parts.removeLast();
3✔
419
      parts.append(wslGpgconf);
420
      return {parts.first(), parts.mid(1)};
421
    }
422
    return {"gpgconf", {}};
423
  }
424

425
  if (!first.contains('/') && !first.contains('\\')) {
2✔
426
    return {"gpgconf", {}};
427
  }
428

429
  QString gpgconfPath = findGpgconfInGpgDir(first);
1✔
430
  if (!gpgconfPath.isEmpty()) {
1✔
431
    return {gpgconfPath, {}};
×
432
  }
433

434
  return {"gpgconf", {}};
435
}
8✔
436

437
/**
438
 * @brief Pass::GenerateGPGKeys internal gpg keypair generator . .
439
 * @param batch GnuPG style configuration string
440
 */
441
void Pass::GenerateGPGKeys(QString batch) {
1✔
442
  const QString gpgPath = m_settings.gpgExecutable;
443
  if (gpgPath.isEmpty()) {
1✔
444
    // No gpg configured: executeWrapper would hand an empty executable to the
445
    // Executor, which silently drops it (see Executor::execute), leaving the
446
    // keygen dialog spinning with no feedback. Surface the misconfiguration
447
    // instead. Deferred via a queued call so we do not re-enter
448
    // KeygenDialog::done(), which drives key generation synchronously.
449
    QMetaObject::invokeMethod(
1✔
450
        this,
451
        [this]() {
1✔
452
          emit processErrorExit(1, tr("No GPG executable configured"));
1✔
453
        },
1✔
454
        Qt::QueuedConnection);
455
    return;
456
  }
457

458
  // Kill any stale GPG agents that might be holding locks on the key database.
459
  // This helps avoid "database locked" timeouts during key generation.
NEW
460
  ResolvedGpgconfCommand resolvedGpgconf = resolveGpgconfCommand(gpgPath);
×
461
  QStringList killArgs = resolvedGpgconf.arguments;
NEW
462
  killArgs << "--kill";
×
NEW
463
  killArgs << "gpg-agent";
×
464
  // Use same environment as key generation to target correct gpg-agent
NEW
465
  if (Executor::executeBlocking(env, resolvedGpgconf.program, killArgs) != 0) {
×
NEW
466
    qWarning() << "Failed to kill gpg-agent";
×
467
  }
468

469
  executeWrapper(GPG_GENKEYS, gpgPath, {"--gen-key", "--no-tty", "--batch"},
×
470
                 std::move(batch), true, true);
471
}
×
472

473
/**
474
 * @brief Pass::listKeys list users
475
 * @param keystrings
476
 * @param secret list private keys
477
 * @return QList<UserInfo> users
478
 */
479
auto Pass::listKeys(QStringList keystrings, bool secret) -> QList<UserInfo> {
×
480
  QStringList args = {"--no-tty", "--with-colons", "--with-fingerprint"};
×
481
  args.append(secret ? "--list-secret-keys" : "--list-keys");
×
482

483
  for (const QString &keystring : std::as_const(keystrings)) {
×
484
    if (!keystring.isEmpty()) {
×
485
      args.append(keystring);
486
    }
487
  }
488
  QString p_out;
×
489
  if (Executor::executeBlocking(m_settings.gpgExecutable, args, &p_out) != 0) {
×
490
    return {};
×
491
  }
492
  return parseGpgColonOutput(p_out, secret);
×
493
}
×
494

495
/**
496
 * @brief Pass::listKeys list users
497
 * @param keystring
498
 * @param secret list private keys
499
 * @return QList<UserInfo> users
500
 */
501
auto Pass::listKeys(const QString &keystring, bool secret) -> QList<UserInfo> {
×
502
  return listKeys(QStringList(keystring), secret);
×
503
}
504

505
/**
506
 * @brief Maps GPG stderr (which may include --status-fd 2 tokens) to a
507
 * user-friendly encryption error string.
508
 *
509
 * Checked in order: machine-readable [GNUPG:] status tokens first (locale-
510
 * independent), then case-insensitive substring fallbacks for GPG builds that
511
 * don't emit status tokens.
512
 *
513
 * @param err Raw stderr from GPG
514
 * @return Translated human-readable error, or empty string if not recognised
515
 */
516
namespace {
517

518
/**
519
 * @brief Checks if @p str contains any of the @p patterns (case-sensitive).
520
 * @param str String to search in.
521
 * @param patterns Patterns to search for.
522
 * @return true if any pattern is found, false otherwise.
523
 */
524
auto containsAny(const QString &str, const QStringList &patterns) -> bool {
31✔
525
  for (const QString &p : patterns) {
82✔
526
    if (str.contains(p)) {
58✔
527
      return true;
528
    }
529
  }
530
  return false;
531
}
532

533
/**
534
 * @brief Checks if str contains any of the patterns (case-insensitive).
535
 * @param str String to search in (will be lowercased once).
536
 * @param patterns List of patterns to search for (must be lowercase; caller
537
 * should convert patterns to lowercase before calling).
538
 * @return true if any pattern is found.
539
 */
540
auto containsAnyCaseInsensitive(const QString &str, const QStringList &patterns)
13✔
541
    -> bool {
542
  const QString lower = str.toLower();
543
  for (const QString &p : patterns) {
32✔
544
    if (lower.contains(p)) {
23✔
545
      return true;
546
    }
547
  }
548
  return false;
549
}
550

551
} // namespace
552

553
auto gpgErrorMessage(const QString &err) -> QString {
13✔
554
  // Machine-readable status tokens added by --status-fd 2
555
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYEXPIRED"),
65✔
556
                        QStringLiteral("[GNUPG:] INV_RECP 5 ")}))
13✔
557
    return QCoreApplication::translate(
558
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
559
                "replace it.");
3✔
560
  if (containsAny(err, {QStringLiteral("[GNUPG:] KEYREVOKED"),
50✔
561
                        QStringLiteral("[GNUPG:] INV_RECP 4 ")}))
10✔
562
    return QCoreApplication::translate(
563
        "Pass", "Encryption failed: GPG key has been revoked.");
2✔
564
  if (containsAny(err, {QStringLiteral("[GNUPG:] NO_PUBKEY"),
40✔
565
                        QStringLiteral("[GNUPG:] INV_RECP")}))
8✔
566
    return QCoreApplication::translate(
567
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
568
                "Check that the key ID in .gpg-id is correct and imported.");
2✔
569
  if (err.contains(QStringLiteral("[GNUPG:] FAILURE")))
6✔
570
    return QCoreApplication::translate(
571
        "Pass", "Encryption failed. Check that your GPG key is valid.");
1✔
572

573
  // Locale-dependent fallbacks
574
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has expired"),
20✔
575
                                       QLatin1String("key expired")}))
576
    return QCoreApplication::translate(
577
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
578
                "replace it.");
1✔
579
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has been revoked"),
16✔
580
                                       QLatin1String("revoked")}))
581
    return QCoreApplication::translate(
582
        "Pass", "Encryption failed: GPG key has been revoked.");
1✔
583
  if (containsAnyCaseInsensitive(err, {QLatin1String("no public key"),
15✔
584
                                       QLatin1String("unusable public key"),
585
                                       QLatin1String("no secret key")}))
586
    return QCoreApplication::translate(
587
        "Pass", "Encryption failed: recipient GPG key not found or invalid. "
588
                "Check that the key ID in .gpg-id is correct and imported.");
2✔
589
  if (containsAnyCaseInsensitive(err, {QLatin1String("encryption failed")}))
3✔
590
    return QCoreApplication::translate(
591
        "Pass", "Encryption failed. Check that your GPG key is valid.");
×
592

593
  return {};
594
}
×
595

596
namespace {
597
/**
598
 * @brief Determine whether a line from `pass grep` output is an entry header.
599
 *
600
 * Detects the ANSI blue escape (\x1B[94m) emitted by `pass grep`; as a
601
 * plain-text fallback, treats a non-indented line ending in ':' as a header.
602
 *
603
 * @param rawLine Original unmodified output line (with any ANSI codes).
604
 * @param trimmedLine The line after surrounding whitespace has been stripped.
605
 * @return true if the line is an entry header; otherwise false.
606
 */
607
auto isGrepHeaderLine(const QString &rawLine, const QString &trimmedLine)
45✔
608
    -> bool {
609
  return rawLine.startsWith(QStringLiteral("\x1B[94m")) ||
123✔
610
         (!rawLine.startsWith(' ') && !rawLine.startsWith('\t') &&
91✔
611
          trimmedLine.endsWith(':'));
119✔
612
}
613
} // namespace
614

615
/**
616
 * @brief Parses 'pass grep' raw output into (entry, matches) pairs.
617
 *
618
 * pass grep emits ANSI blue color (\x1B[94m) at the start of each entry
619
 * header line. This is checked before stripping ANSI so headers are detected
620
 * reliably regardless of locale.
621
 */
622
auto parseGrepOutput(const QString &rawOut)
12✔
623
    -> QList<QPair<QString, QStringList>> {
624
  static const QRegularExpression ansi(
625
      QStringLiteral(R"(\x1B\[[0-9;]*[a-zA-Z])"));
13✔
626
  QList<QPair<QString, QStringList>> results;
12✔
627
  QString currentEntry;
12✔
628
  QStringList currentMatches;
12✔
629
  for (const QString &rawLine : rawOut.split('\n')) {
69✔
630
    QString line = rawLine;
631
    line.remove('\r');
45✔
632
    line.remove(ansi);
45✔
633
    line = line.trimmed();
45✔
634
    const bool isHeader = isGrepHeaderLine(rawLine, line);
45✔
635
    if (isHeader) {
45✔
636
      if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
14✔
637
        results.append({currentEntry, currentMatches});
3✔
638
      currentEntry = line.endsWith(':') ? line.chopped(1) : line;
14✔
639
      currentMatches.clear();
14✔
640
    } else if (!currentEntry.isEmpty()) {
31✔
641
      if (!line.isEmpty())
29✔
642
        currentMatches << line;
643
    }
644
  }
645
  if (!currentEntry.isEmpty() && !currentMatches.isEmpty())
12✔
646
    results.append({currentEntry, currentMatches});
11✔
647
  return results;
12✔
648
}
649

650
/**
651
 * @brief Pass::processFinished reemits specific signal based on what process
652
 * has finished
653
 * @param id    id of Pass process that was scheduled and finished
654
 * @param exitCode  return code of a process
655
 * @param out   output generated by process(if capturing was requested, empty
656
 *              otherwise)
657
 * @param err   error output generated by process(if capturing was requested,
658
 *              or error occurred)
659
 */
660
void Pass::finished(int id, int exitCode, const QString &out,
32✔
661
                    const QString &err) {
662
  auto pid = static_cast<PROCESS>(id);
32✔
663

664
  if (exitCode != 0) {
32✔
665
    handleProcessError(pid, exitCode, out, err);
2✔
666
    return;
2✔
667
  }
668

669
  emitProcessFinishedSignal(pid, out, err);
30✔
670
}
671

672
void Pass::handleProcessError(PROCESS pid, int exitCode, const QString &out,
2✔
673
                              const QString &err) {
674
  Q_UNUSED(out);
675

676
  if (pid == PASS_GREP) {
2✔
677
    handleGrepError(exitCode, err);
2✔
678
    return;
2✔
679
  }
680

681
  if (pid == PASS_INSERT) {
×
682
    const QString friendly = gpgErrorMessage(err);
×
683
    if (!friendly.isEmpty()) {
×
684
      emit processErrorExit(exitCode, formatInsertError(friendly, err));
×
685
      return;
686
    }
687
  }
688

689
  emit processErrorExit(exitCode, err);
×
690
}
691

692
void Pass::handleGrepError(int exitCode, const QString &err) {
2✔
693
  if (exitCode == 1) {
2✔
694
    emit finishedGrep({});
2✔
695
  } else {
696
    emit processErrorExit(exitCode, err);
1✔
697
    emit finishedGrep({});
2✔
698
  }
699
}
2✔
700

701
auto Pass::formatInsertError(const QString &friendly, const QString &err)
×
702
    -> QString {
703
  QStringList humanLines;
×
704
  for (const QString &line : err.split('\n')) {
×
705
    QString cleanedLine = line;
706
    cleanedLine.remove('\r');
×
707
    if (!cleanedLine.startsWith(QLatin1String("[GNUPG:]")))
×
708
      humanLines.append(cleanedLine);
709
  }
710
  const QString humanErr = humanLines.join('\n').trimmed();
×
711
  return humanErr.isEmpty() ? friendly : friendly + "\n\n" + humanErr;
×
712
}
713

714
/**
715
 * @brief Emit the appropriate finished signal for a completed subprocess.
716
 *
717
 * Emits a specific Qt signal corresponding to the given process identifier; for
718
 * grep results the stdout is parsed into a list of matches before emitting.
719
 *
720
 * @param pid The process identifier indicating which finished signal to emit.
721
 * @param out Standard output produced by the process.
722
 * @param err Standard error produced by the process.
723
 */
724
void Pass::emitProcessFinishedSignal(PROCESS pid, const QString &out,
30✔
725
                                     const QString &err) {
726
  /**
727
   * @brief Filter sensitive commands to prevent password leakage.
728
   *
729
   * Sensitive commands (PASS_SHOW, etc.) output plaintext passwords or
730
   * searchable content that should not be exposed to any UI listener.
731
   *
732
   * Using a default branch: if new PASS_* values are added, they
733
   * default to NOT leaking (safe by default). Making this
734
   * exhaustive would require updating here for every new
735
   * command and risk silent password leakage if forgotten.
736
   */
737
  switch (pid) {
30✔
738
  case PASS_SHOW:
739
  case PASS_OTP_GENERATE:
740
  case PASS_GREP:
741
  case PASS_INSERT:
742
    break;
743
  default:
×
744
    emit finishedAny(out, err);
×
745
    emit finishedAnyWithPid(out, err, pid);
×
746
    break;
×
747
  }
748

749
  switch (pid) {
30✔
750
  case GIT_INIT:
×
751
    emit finishedGitInit(out, err);
×
752
    break;
×
753
  case GIT_PULL:
×
754
    emit finishedGitPull(out, err);
×
755
    break;
×
756
  case GIT_PUSH:
×
757
    emit finishedGitPush(out, err);
×
758
    break;
×
759
  case PASS_SHOW:
10✔
760
    emit finishedShow(out);
10✔
761
    break;
10✔
762
  case PASS_OTP_GENERATE:
×
763
    emit finishedOtpGenerate(out);
×
764
    break;
×
765
  case PASS_INSERT:
19✔
766
    emit finishedInsert(out, err);
19✔
767
    break;
19✔
768
  case PASS_REMOVE:
×
769
    emit finishedRemove(out, err);
×
770
    break;
×
771
  case PASS_INIT:
×
772
    emit finishedInit(out, err);
×
773
    break;
×
774
  case PASS_MOVE:
×
775
    emit finishedMove(out, err);
×
776
    break;
×
777
  case PASS_COPY:
×
778
    emit finishedCopy(out, err);
×
779
    break;
×
780
  case GPG_GENKEYS:
×
781
    emit finishedGenerateGPGKeys(out, err);
×
782
    break;
×
783
  case PASS_GREP:
1✔
784
    emit finishedGrep(parseGrepOutput(out));
1✔
785
    break;
1✔
786
  default:
787
#ifdef QT_DEBUG
788
    dbg() << "Unhandled process type" << pid;
789
#endif
790
    break;
791
  }
792
}
30✔
793

794
/**
795
 * @brief Set or remove a single environment variable in the local env list.
796
 *
797
 * The provided key must include a trailing '=' (e.g. "FOO="). Existing entries
798
 * whose text begins with the given key are removed before the new value is
799
 * applied. If value is non-empty the pair "key+value" is appended; if value is
800
 * empty the variable is removed.
801
 *
802
 * The function asserts if key does not end with '='; if the assertion is not
803
 * active it will emit a warning and return without modifying env.
804
 *
805
 * @param key Environment variable name with trailing '=' (anchors the lookup).
806
 * @param value Value to set for the variable; an empty string unsets the
807
 * variable.
808
 */
809
void Pass::setEnvVar(const QString &key, const QString &value) {
146✔
810
  const bool hasEq = key.endsWith('=');
146✔
811
  Q_ASSERT_X(hasEq, "Pass::setEnvVar",
812
             "called with malformed key (missing '=')");
813
  if (!hasEq) {
146✔
814
    qWarning() << "Pass::setEnvVar called with malformed key (missing '='):"
×
815
               << key;
×
816
    return;
×
817
  }
818
  const QString varName = key.chopped(1);
819
  if (value.isEmpty())
146✔
820
    env.remove(varName);
30✔
821
  else
822
    env.insert(varName, value);
116✔
823
}
824

825
/**
826
 * @brief Update the process environment used for executing external commands.
827
 *
828
 * Updates environment entries for PASSWORD_STORE_SIGNING_KEY,
829
 * PASSWORD_STORE_DIR, PASSWORD_STORE_GENERATED_LENGTH, and
830
 * PASSWORD_STORE_CHARACTER_SET based on current settings, then applies the
831
 * environment to the internal executor.
832
 */
833
void Pass::updateEnv() {
35✔
834
  setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY="),
70✔
835
            m_settings.passSigningKey);
35✔
836
  setEnvVar(QStringLiteral("PASSWORD_STORE_DIR="), m_settings.passStore);
70✔
837

838
  const PasswordConfiguration &passConfig = m_settings.passwordConfiguration;
35✔
839
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH="),
70✔
840
            QString::number(passConfig.length));
35✔
841

842
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET="),
70✔
843
            effectiveCharset(passConfig));
35✔
844

845
  exec.setEnvironment(env);
35✔
846
}
35✔
847

848
/**
849
 * @brief Pass::getGpgIdPath return gpgid file path for some file (folder).
850
 * @param for_file which file (folder) would you like the gpgid file path for.
851
 * @return path to the gpgid file.
852
 */
853
auto Pass::getGpgIdPath(const QString &for_file, const QString &passStore)
49✔
854
    -> QString {
855
  QString normalizedStore = QDir::fromNativeSeparators(passStore);
49✔
856
  QString normalizedFile = QDir::fromNativeSeparators(for_file);
49✔
857
  QString fullPath = normalizedFile.startsWith(normalizedStore)
49✔
858
                         ? normalizedFile
49✔
859
                         : normalizedStore + "/" + normalizedFile;
38✔
860
  QDir gpgIdDir(QFileInfo(fullPath).absoluteDir());
49✔
861
  // QDir::cleanPath() always normalises to forward slashes, so use '/'
862
  // here rather than QDir::separator() (which returns '\\' on Windows).
863
  QString cleanPassStore = QDir::cleanPath(normalizedStore);
49✔
864
  bool found = false;
865
  while (gpgIdDir.exists()) {
70✔
866
    QString currentPath = QDir::cleanPath(gpgIdDir.absolutePath());
140✔
867
    const QString prefix =
868
        cleanPassStore.endsWith('/') ? cleanPassStore : cleanPassStore + "/";
70✔
869
    if (currentPath != cleanPassStore && !currentPath.startsWith(prefix)) {
70✔
870
      break;
871
    }
872
    if (QFile(gpgIdDir.absoluteFilePath(".gpg-id")).exists()) {
124✔
873
      found = true;
874
      break;
875
    }
876
    if (!gpgIdDir.cdUp()) {
21✔
877
      break;
878
    }
879
  }
880
  return found ? gpgIdDir.absoluteFilePath(".gpg-id")
49✔
881
               : QDir(normalizedStore).filePath(".gpg-id");
147✔
882
}
49✔
883

884
/**
885
 * @brief Pass::getRecipientList return list of gpg-id's to encrypt for
886
 * @param for_file which file (folder) would you like recipients for
887
 * @return recipients gpg-id contents
888
 */
889
auto Pass::getRecipientList(const QString &for_file, const QString &passStore)
26✔
890
    -> QStringList {
891
  QFile gpgId(getGpgIdPath(for_file, passStore));
26✔
892
  if (!gpgId.open(QIODevice::ReadOnly | QIODevice::Text)) {
26✔
893
    return {};
×
894
  }
895
  QStringList recipients;
26✔
896
  while (!gpgId.atEnd()) {
60✔
897
    QString recipient(gpgId.readLine());
68✔
898
    recipient = recipient.split("#")[0].trimmed();
68✔
899
    if (!recipient.isEmpty() && Util::isValidKeyId(recipient)) {
34✔
900
      recipients += recipient;
901
    }
902
  }
903
  return recipients;
904
}
26✔
905

906
/**
907
 * @brief Pass::getRecipientString formatted string for use with GPG
908
 * @param for_file which file (folder) would you like recipients for
909
 * @param separator formatting separator eg: " -r "
910
 * @param count
911
 * @return recipient string
912
 */
913
auto Pass::getRecipientString(const QString &for_file, const QString &passStore,
2✔
914
                              const QString &separator, int *count)
915
    -> QStringList {
916
  Q_UNUSED(separator)
917
  QStringList recipients = Pass::getRecipientList(for_file, passStore);
2✔
918
  if (count) {
2✔
919
    *count = static_cast<int>(recipients.size());
1✔
920
  }
921
  return recipients;
2✔
922
}
923

924
/* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
925
 */
926

927
/**
928
 * @brief Generates a random number bounded by the given value.
929
 * @param bound Upper bound (exclusive)
930
 * @return Random number in range [0, bound)
931
 */
932
auto Pass::boundedRandom(quint32 bound) -> quint32 {
7,592✔
933
  if (bound < 2) {
7,592✔
934
    return 0;
935
  }
936

937
  quint32 randval;
938
  // Rejection-sampling threshold to avoid modulo bias.
939
  // This follows the well-known "arc4random_uniform"-style approach:
940
  // reject values in the low range [0, min), where
941
  //   min = 2^32 % bound
942
  // so that the remaining range size is an exact multiple of `bound`.
943
  //
944
  // In quint32 arithmetic, (1 + ~bound) wraps to (2^32 - bound), therefore
945
  //   (1 + ~bound) % bound == 2^32 % bound.
946
  const quint32 rejectionThreshold = (1 + ~bound) % bound;
7,592✔
947

948
  do {
949
    randval = QRandomGenerator::system()->generate();
950
  } while (randval < rejectionThreshold);
7,592✔
951

952
  return randval % bound;
7,592✔
953
}
954

955
/**
956
 * @brief Generates a random password from the given charset.
957
 * @param charset Characters to use in the password
958
 * @param length Desired password length
959
 * @return Generated password string
960
 */
961
auto Pass::generateRandomPassword(const QString &charset, unsigned int length)
1,204✔
962
    -> QString {
963
  if (charset.isEmpty() || length == 0U) {
1,204✔
964
    return {};
965
  }
966
  QString out;
1,204✔
967
  for (unsigned int i = 0; i < length; ++i) {
8,796✔
968
    out.append(charset.at(static_cast<int>(
7,592✔
969
        boundedRandom(static_cast<quint32>(charset.length())))));
7,592✔
970
  }
971
  return out;
972
}
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