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

IJHack / QtPass / 27629875352

16 Jun 2026 03:46PM UTC coverage: 57.471% (-0.06%) from 57.526%
27629875352

Pull #1550

github

web-flow
Merge d5a106b1a into 156c096e8
Pull Request #1550: refactor(executor): replace QStringList env with QProcessEnvironment (#1510)

18 of 21 new or added lines in 5 files covered. (85.71%)

74 existing lines in 2 files now uncovered.

3977 of 6920 relevant lines covered (57.47%)

31.87 hits per line

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

66.98
/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 <utility>
15

16
#ifdef QT_DEBUG
17
#include "debughelper.h"
18
#endif
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_OTP_GENERATE;
30
using Enums::PASS_REMOVE;
31
using Enums::PASS_SHOW;
32

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

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

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

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

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

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

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

122
void Pass::beforeExecute(PROCESS /*id*/) {}
×
123

124
/**
125
 * @brief Initializes the pass wrapper environment.
126
 */
127
void Pass::init() {
28✔
128
#ifdef __APPLE__
129
  // If it exists, prepend gpgtools to PATH
130
  if (QFile(QStringLiteral("/usr/local/MacGPG2/bin")).exists())
131
    env.insert(QStringLiteral("PATH"),
132
               QStringLiteral("/usr/local/MacGPG2/bin:") +
133
                   env.value(QStringLiteral("PATH")));
134
  // Add missing /usr/local/bin
135
  if (!env.value(QStringLiteral("PATH"))
136
           .contains(QStringLiteral("/usr/local/bin")))
137
    env.insert(QStringLiteral("PATH"),
138
               QStringLiteral("/usr/local/bin:") +
139
                   env.value(QStringLiteral("PATH")));
140
#endif
141

142
  if (!QtPassSettings::getGpgHome().isEmpty()) {
56✔
143
    QDir absHome(QtPassSettings::getGpgHome());
30✔
144
    absHome.makeAbsolute();
15✔
145
    env.insert(QStringLiteral("GNUPGHOME"), absHome.path());
30✔
146
  }
15✔
147
}
28✔
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 (QtPassSettings::isUsePwgen()) {
1,204✔
165
    // --secure goes first as it overrides --no-* otherwise
UNCOV
166
    QStringList args;
×
UNCOV
167
    args.append("-1");
×
UNCOV
168
    if (!QtPassSettings::isLessRandom()) {
×
169
      args.append("--secure");
×
170
    }
171
    args.append(QtPassSettings::isAvoidCapitals() ? "--no-capitalize"
×
172
                                                  : "--capitalize");
UNCOV
173
    args.append(QtPassSettings::isAvoidNumbers() ? "--no-numerals"
×
174
                                                 : "--numerals");
UNCOV
175
    if (QtPassSettings::isUseSymbols()) {
×
176
      args.append("--symbols");
×
177
    }
178
    args.append(QString::number(length));
×
179
    // executeBlocking returns 0 on success, non-zero on failure
UNCOV
180
    if (Executor::executeBlocking(QtPassSettings::getPwgenExecutable(), args,
×
181
                                  &passwd) == 0) {
UNCOV
182
      static const QRegularExpression literalNewLines{"[\\n\\r]"};
×
183
      passwd.remove(literalNewLines);
×
184
    } else {
185
      passwd.clear();
×
186
#ifdef QT_DEBUG
187
      qDebug() << __FILE__ << ":" << __LINE__ << "\t"
188
               << "pwgen fail";
189
#endif
190
      // Error is already handled by clearing passwd; no need for critical
191
      // signal here
192
    }
193
  } else {
194
    // Validate charset - if CUSTOM is selected but chars are empty,
195
    // fall back to ALLCHARS to prevent weak passwords (issue #780)
196
    const QString cs = fallbackCharset(
197
        charset, QtPassSettings::getPasswordConfiguration()
3,612✔
198
                     .Characters[PasswordConfiguration::ALLCHARS]);
199
    if (cs.length() > 0) {
1,204✔
200
      passwd = generateRandomPassword(cs, length);
2,408✔
201
    } else {
UNCOV
202
      emit critical(
×
UNCOV
203
          tr("No characters chosen"),
×
UNCOV
204
          tr("Can't generate password, there are no characters to choose from "
×
205
             "set in the configuration!"));
206
    }
207
  }
208
  return passwd;
209
}
210

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

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

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

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

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

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

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

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

379
} // namespace
380

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

541
} // namespace
542

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

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

583
  return {};
584
}
×
585

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

829
  PasswordConfiguration passConfig = QtPassSettings::getPasswordConfiguration();
34✔
830
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH="),
68✔
831
            QString::number(passConfig.length));
34✔
832

833
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET="),
68✔
834
            effectiveCharset(passConfig));
34✔
835

836
  exec.setEnvironment(env);
34✔
837
}
34✔
838

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

874
  return gpgIdPath;
50✔
875
}
50✔
876

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

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

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

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

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

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

943
  return randval % bound;
7,592✔
944
}
945

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