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

IJHack / QtPass / 27478339193

13 Jun 2026 08:34PM UTC coverage: 55.569% (-0.04%) from 55.613%
27478339193

push

github

web-flow
refactor: replace executeWrapper override with beforeExecute hook (#1513) (#1526)

The Pass execution path had a three-level dispatch: the 6-arg
executeWrapper was virtual purely so ImitatePass could prepend
transactionAdd(id) before delegating back to the base. That hid the
transaction wrapping inside an override of the dispatch function itself.

Make the 6-arg executeWrapper non-virtual and introduce an explicit
virtual beforeExecute(PROCESS) hook (no-op in the base) that it calls
right before dispatching to the Executor. ImitatePass now overrides only
that hook to register the transaction, so the wrapping is visible at the
call site and there is a single executeWrapper implementation.

No behavioural change: beforeExecute runs at exactly the point
transactionAdd previously did. All tests pass (incl. imitatepass +
integration).

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

2 of 3 new or added lines in 2 files covered. (66.67%)

2 existing lines in 1 file now uncovered.

3732 of 6716 relevant lines covered (55.57%)

37.06 hits per line

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

67.4
/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 "qtpasssettings.h"
6
#include "util.h"
7
#include <QCoreApplication>
8
#include <QDebug>
9
#include <QDir>
10
#include <QFileInfo>
11
#include <QProcess>
12
#include <QRandomGenerator>
13
#include <QRegularExpression>
14
#include <algorithm>
15
#include <utility>
16

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

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

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

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

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

74
  // This was previously using direct QProcess signals.
75
  // The code now uses Executor instead of raw QProcess for better control.
76
  // connect(&process, SIGNAL(error(QProcess::ProcessError)), this,
77
  //        SIGNAL(error(QProcess::ProcessError)));
78

79
  connect(&exec, &Executor::starting, this, &Pass::startingExecuteWrapper);
39✔
80
  // Merge our vars into WSLENV rather than blindly appending a duplicate entry
81
  const QStringList wslenvVars = {
82
      QStringLiteral("PASSWORD_STORE_DIR/p"),
78✔
83
      QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH/w"),
39✔
84
      QStringLiteral("PASSWORD_STORE_CHARACTER_SET/w")};
195✔
85
  const QString wslenvPrefix = QStringLiteral("WSLENV=");
39✔
86
  auto it =
87
      std::find_if(env.begin(), env.end(), [&wslenvPrefix](const QString &s) {
78✔
88
        return s.startsWith(wslenvPrefix);
5,046✔
89
      });
90
  if (it == env.end()) {
39✔
91
    env.append(wslenvPrefix + wslenvVars.join(':'));
78✔
92
  } else {
93
    QStringList parts =
94
        it->mid(wslenvPrefix.size()).split(':', Qt::SkipEmptyParts);
×
95
    for (const QString &v : wslenvVars) {
×
96
      if (!parts.contains(v))
×
97
        parts.append(v);
98
    }
99
    *it = wslenvPrefix + parts.join(':');
×
100
  }
101
}
39✔
102

103
/**
104
 * @brief Executes a wrapper command.
105
 * @param id Process ID
106
 * @param app Application to execute
107
 * @param args Arguments
108
 * @param readStdout Whether to read stdout
109
 * @param readStderr Whether to read stderr
110
 */
111
void Pass::executeWrapper(PROCESS id, const QString &app,
×
112
                          const QStringList &args, bool readStdout,
113
                          bool readStderr) {
114
  executeWrapper(id, app, args, QString(), readStdout, readStderr);
×
115
}
×
116

117
void Pass::executeWrapper(PROCESS id, const QString &app,
31✔
118
                          const QStringList &args, QString input,
119
                          bool readStdout, bool readStderr) {
120
  beforeExecute(id);
31✔
121
#ifdef QT_DEBUG
122
  dbg() << app << args;
123
#endif
124
  exec.execute(id, QtPassSettings::getPassStore(), app, args, std::move(input),
62✔
125
               readStdout, readStderr);
126
}
31✔
127

NEW
128
void Pass::beforeExecute(PROCESS /*id*/) {}
×
129

130
/**
131
 * @brief Initializes the pass wrapper environment.
132
 */
133
void Pass::init() {
28✔
134
#ifdef __APPLE__
135
  // If it exists, add the gpgtools to PATH
136
  if (QFile("/usr/local/MacGPG2/bin").exists())
137
    env.replaceInStrings("PATH=", "PATH=/usr/local/MacGPG2/bin:");
138
  // Add missing /usr/local/bin
139
  if (env.filter("/usr/local/bin").isEmpty())
140
    env.replaceInStrings("PATH=", "PATH=/usr/local/bin:");
141
#endif
142

143
  if (!QtPassSettings::getGpgHome().isEmpty()) {
56✔
144
    QDir absHome(QtPassSettings::getGpgHome());
30✔
145
    absHome.makeAbsolute();
15✔
146
    env << "GNUPGHOME=" + absHome.path();
15✔
147
  }
15✔
148
}
28✔
149

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

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

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

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

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

306
  QDir dir(gpgInfo.absolutePath());
1✔
307

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

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

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

380
} // namespace
381

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

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

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

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

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

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

436
  return {"gpgconf", {}};
437
}
8✔
438

439
/**
440
 * @brief Pass::GenerateGPGKeys internal gpg keypair generator . .
441
 * @param batch GnuPG style configuration string
442
 */
443
void Pass::GenerateGPGKeys(QString batch) {
×
444
  // Kill any stale GPG agents that might be holding locks on the key database
445
  // This helps avoid "database locked" timeouts during key generation
446
  QString gpgPath = QtPassSettings::getGpgExecutable();
×
447
  if (!gpgPath.isEmpty()) {
×
448
    ResolvedGpgconfCommand resolvedGpgconf = resolveGpgconfCommand(gpgPath);
×
449
    QStringList killArgs = resolvedGpgconf.arguments;
450
    killArgs << "--kill";
×
451
    killArgs << "gpg-agent";
×
452
    // Use same environment as key generation to target correct gpg-agent
453
    if (Executor::executeBlocking(env, resolvedGpgconf.program, killArgs) !=
×
454
        0) {
455
      qWarning() << "Failed to kill gpg-agent";
×
456
    }
457
  }
458

459
  executeWrapper(GPG_GENKEYS, gpgPath, {"--gen-key", "--no-tty", "--batch"},
×
460
                 std::move(batch), true, true);
461
}
×
462

463
/**
464
 * @brief Pass::listKeys list users
465
 * @param keystrings
466
 * @param secret list private keys
467
 * @return QList<UserInfo> users
468
 */
469
auto Pass::listKeys(QStringList keystrings, bool secret) -> QList<UserInfo> {
×
470
  QStringList args = {"--no-tty", "--with-colons", "--with-fingerprint"};
×
471
  args.append(secret ? "--list-secret-keys" : "--list-keys");
×
472

473
  for (const QString &keystring : std::as_const(keystrings)) {
×
474
    if (!keystring.isEmpty()) {
×
475
      args.append(keystring);
476
    }
477
  }
478
  QString p_out;
×
479
  if (Executor::executeBlocking(QtPassSettings::getGpgExecutable(), args,
×
480
                                &p_out) != 0) {
481
    return {};
×
482
  }
483
  return parseGpgColonOutput(p_out, secret);
×
484
}
×
485

486
/**
487
 * @brief Pass::listKeys list users
488
 * @param keystring
489
 * @param secret list private keys
490
 * @return QList<UserInfo> users
491
 */
492
auto Pass::listKeys(const QString &keystring, bool secret) -> QList<UserInfo> {
×
493
  return listKeys(QStringList(keystring), secret);
×
494
}
495

496
/**
497
 * @brief Maps GPG stderr (which may include --status-fd 2 tokens) to a
498
 * user-friendly encryption error string.
499
 *
500
 * Checked in order: machine-readable [GNUPG:] status tokens first (locale-
501
 * independent), then case-insensitive substring fallbacks for GPG builds that
502
 * don't emit status tokens.
503
 *
504
 * @param err Raw stderr from GPG
505
 * @return Translated human-readable error, or empty string if not recognised
506
 */
507
namespace {
508

509
/**
510
 * @brief Checks if @p str contains any of the @p patterns (case-sensitive).
511
 * @param str String to search in.
512
 * @param patterns Patterns to search for.
513
 * @return true if any pattern is found, false otherwise.
514
 */
515
auto containsAny(const QString &str, const QStringList &patterns) -> bool {
31✔
516
  for (const QString &p : patterns) {
82✔
517
    if (str.contains(p)) {
58✔
518
      return true;
519
    }
520
  }
521
  return false;
522
}
523

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

542
} // namespace
543

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

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

584
  return {};
585
}
×
586

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

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

641
/**
642
 * @brief Pass::processFinished reemits specific signal based on what process
643
 * has finished
644
 * @param id    id of Pass process that was scheduled and finished
645
 * @param exitCode  return code of a process
646
 * @param out   output generated by process(if capturing was requested, empty
647
 *              otherwise)
648
 * @param err   error output generated by process(if capturing was requested,
649
 *              or error occurred)
650
 */
651
void Pass::finished(int id, int exitCode, const QString &out,
32✔
652
                    const QString &err) {
653
  auto pid = static_cast<PROCESS>(id);
32✔
654

655
  if (exitCode != 0) {
32✔
656
    handleProcessError(pid, exitCode, out, err);
2✔
657
    return;
2✔
658
  }
659

660
  emitProcessFinishedSignal(pid, out, err);
30✔
661
}
662

663
void Pass::handleProcessError(PROCESS pid, int exitCode, const QString &out,
2✔
664
                              const QString &err) {
665
  Q_UNUSED(out);
666

667
  if (pid == PASS_GREP) {
2✔
668
    handleGrepError(exitCode, err);
2✔
669
    return;
2✔
670
  }
671

672
  if (pid == PASS_INSERT) {
×
673
    const QString friendly = gpgErrorMessage(err);
×
674
    if (!friendly.isEmpty()) {
×
675
      emit processErrorExit(exitCode, formatInsertError(friendly, err));
×
676
      return;
677
    }
678
  }
679

680
  emit processErrorExit(exitCode, err);
×
681
}
682

683
void Pass::handleGrepError(int exitCode, const QString &err) {
2✔
684
  if (exitCode == 1) {
2✔
685
    emit finishedGrep({});
2✔
686
  } else {
687
    emit processErrorExit(exitCode, err);
1✔
688
    emit finishedGrep({});
2✔
689
  }
690
}
2✔
691

692
auto Pass::formatInsertError(const QString &friendly, const QString &err)
×
693
    -> QString {
694
  QStringList humanLines;
×
695
  for (const QString &line : err.split('\n')) {
×
696
    QString cleanedLine = line;
697
    cleanedLine.remove('\r');
×
698
    if (!cleanedLine.startsWith(QLatin1String("[GNUPG:]")))
×
699
      humanLines.append(cleanedLine);
700
  }
701
  const QString humanErr = humanLines.join('\n').trimmed();
×
702
  return humanErr.isEmpty() ? friendly : friendly + "\n\n" + humanErr;
×
703
}
704

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

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

785
/**
786
 * @brief Set or remove a single environment variable in the local env list.
787
 *
788
 * The provided key must include a trailing '=' (e.g. "FOO="). Existing entries
789
 * whose text begins with the given key are removed before the new value is
790
 * applied. If value is non-empty the pair "key+value" is appended; if value is
791
 * empty the variable is removed.
792
 *
793
 * The function asserts if key does not end with '='; if the assertion is not
794
 * active it will emit a warning and return without modifying env.
795
 *
796
 * @param key Environment variable name with trailing '=' (anchors the lookup).
797
 * @param value Value to set for the variable; an empty string unsets the
798
 * variable.
799
 */
800
void Pass::setEnvVar(const QString &key, const QString &value) {
146✔
801
  const bool hasEq = key.endsWith('=');
146✔
802
  Q_ASSERT_X(hasEq, "Pass::setEnvVar",
803
             "called with malformed key (missing '=')");
804
  if (!hasEq) {
146✔
805
    qWarning() << "Pass::setEnvVar called with malformed key (missing '='):"
×
806
               << key;
×
807
    return;
×
808
  }
809
  env.erase(std::remove_if(
292✔
810
                env.begin(), env.end(),
811
                [&key](const QString &entry) { return entry.startsWith(key); }),
19,373✔
812
            env.end());
813
  if (!value.isEmpty())
146✔
814
    env.append(key + value);
242✔
815
}
816

817
/**
818
 * @brief Update the process environment used for executing external commands.
819
 *
820
 * Updates environment entries for PASSWORD_STORE_SIGNING_KEY,
821
 * PASSWORD_STORE_DIR, PASSWORD_STORE_GENERATED_LENGTH, and
822
 * PASSWORD_STORE_CHARACTER_SET based on current settings, then applies the
823
 * environment to the internal executor.
824
 */
825
void Pass::updateEnv() {
35✔
826
  setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY="),
70✔
827
            QtPassSettings::getPassSigningKey());
70✔
828
  setEnvVar(QStringLiteral("PASSWORD_STORE_DIR="),
70✔
829
            QtPassSettings::getPassStore());
70✔
830

831
  PasswordConfiguration passConfig = QtPassSettings::getPasswordConfiguration();
35✔
832
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH="),
70✔
833
            QString::number(passConfig.length));
35✔
834

835
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET="),
70✔
836
            effectiveCharset(passConfig));
35✔
837

838
  exec.setEnvironment(env);
35✔
839
}
35✔
840

841
/**
842
 * @brief Pass::getGpgIdPath return gpgid file path for some file (folder).
843
 * @param for_file which file (folder) would you like the gpgid file path for.
844
 * @return path to the gpgid file.
845
 */
846
auto Pass::getGpgIdPath(const QString &for_file) -> QString {
50✔
847
  QString passStore =
848
      QDir::fromNativeSeparators(QtPassSettings::getPassStore());
100✔
849
  QString normalizedFile = QDir::fromNativeSeparators(for_file);
50✔
850
  QString fullPath = normalizedFile.startsWith(passStore)
50✔
851
                         ? normalizedFile
50✔
852
                         : passStore + "/" + normalizedFile;
45✔
853
  QDir gpgIdDir(QFileInfo(fullPath).absoluteDir());
50✔
854
  // QDir::cleanPath() always normalises to forward slashes, so use '/'
855
  // here rather than QDir::separator() (which returns '\\' on Windows).
856
  QString cleanPassStore = QDir::cleanPath(passStore);
50✔
857
  bool found = false;
858
  while (gpgIdDir.exists()) {
71✔
859
    QString currentPath = QDir::cleanPath(gpgIdDir.absolutePath());
128✔
860
    if (currentPath != cleanPassStore &&
86✔
861
        !currentPath.startsWith(cleanPassStore + "/")) {
86✔
862
      break;
863
    }
864
    if (QFile(gpgIdDir.absoluteFilePath(".gpg-id")).exists()) {
126✔
865
      found = true;
866
      break;
867
    }
868
    if (!gpgIdDir.cdUp()) {
21✔
869
      break;
870
    }
871
  }
872
  QString gpgIdPath(
873
      found ? gpgIdDir.absoluteFilePath(".gpg-id")
50✔
874
            : QDir(QtPassSettings::getPassStore()).filePath(".gpg-id"));
116✔
875

876
  return gpgIdPath;
50✔
877
}
50✔
878

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

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

917
/* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
918
 */
919

920
/**
921
 * @brief Generates a random number bounded by the given value.
922
 * @param bound Upper bound (exclusive)
923
 * @return Random number in range [0, bound)
924
 */
925
auto Pass::boundedRandom(quint32 bound) -> quint32 {
7,592✔
926
  if (bound < 2) {
7,592✔
927
    return 0;
928
  }
929

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

941
  do {
942
    randval = QRandomGenerator::system()->generate();
943
  } while (randval < rejectionThreshold);
7,592✔
944

945
  return randval % bound;
7,592✔
946
}
947

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