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

IJHack / QtPass / 35096664673

16 Sep 2026 12:34PM UTC coverage: 71.098% (+0.7%) from 70.358%
35096664673

push

github

web-flow
Merge pull request #1764 from IJHack/build/plist-and-test-pro-consolidation

Anchor the macOS plist and icon to the repo root; one .pro boilerplate for the suites (#1682)

5284 of 7432 relevant lines covered (71.1%)

54.7 hits per line

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

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

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

22
using Enums::GIT_INIT;
23
using Enums::GIT_PULL;
24
using Enums::GIT_PUSH;
25
using Enums::GPG_GENKEYS;
26
using Enums::PASS_COPY;
27
using Enums::PASS_GREP;
28
using Enums::PASS_INIT;
29
using Enums::PASS_INSERT;
30
using Enums::PASS_MOVE;
31
using Enums::PASS_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,280✔
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 {
76✔
56
  int sel = passConfig.selected;
76✔
57
  if (sel < 0 || sel >= PasswordConfiguration::CHARSETS_COUNT)
76✔
58
    sel = PasswordConfiguration::ALLCHARS;
59
  return fallbackCharset(
60
      passConfig.Characters[sel],
76✔
61
      passConfig.Characters[PasswordConfiguration::ALLCHARS]);
76✔
62
}
63
} // namespace
64

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

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

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

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

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

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

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

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

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

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

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

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

340
  QDir dir(gpgInfo.absolutePath());
1✔
341

342
#ifdef Q_OS_WIN
343
  QFileInfo candidateExe(dir.filePath("gpgconf.exe"));
344
  if (candidateExe.isExecutable()) {
345
    return candidateExe.filePath();
346
  }
347
#endif
348

349
  QFileInfo candidate(dir.filePath("gpgconf"));
1✔
350
  if (candidate.isExecutable()) {
1✔
351
    return candidate.filePath();
×
352
  }
353
  return {};
354
}
1✔
355

356
} // namespace
357

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

377
  QStringList parts = QProcess::splitCommand(gpgPath);
9✔
378

379
  if (parts.isEmpty()) {
9✔
380
    return {"gpgconf", {}};
381
  }
382

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

405
  if (!first.contains('/') && !first.contains('\\')) {
2✔
406
    return {"gpgconf", {}};
407
  }
408

409
  QString gpgconfPath = findGpgconfInGpgDir(first);
1✔
410
  if (!gpgconfPath.isEmpty()) {
1✔
411
    return {gpgconfPath, {}};
×
412
  }
413

414
  return {"gpgconf", {}};
415
}
10✔
416

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

438
  // Kill any stale GPG agents that might be holding locks on the key database.
439
  // This helps avoid "database locked" timeouts during key generation.
440
  ResolvedGpgconfCommand resolvedGpgconf = resolveGpgconfCommand(gpgPath);
×
441
  QStringList killArgs = resolvedGpgconf.arguments;
442
  killArgs << "--kill";
×
443
  killArgs << "gpg-agent";
×
444
  // Use same environment as key generation to target correct gpg-agent
445
  if (Executor::executeBlocking(env, resolvedGpgconf.program, killArgs) != 0) {
×
446
    qWarning() << "Failed to kill gpg-agent";
×
447
  }
448

449
  executeWrapper(GPG_GENKEYS, gpgPath, {"--gen-key", "--no-tty", "--batch"},
×
450
                 std::move(batch), true, true);
451
}
×
452

453
/**
454
 * @brief Pass::listKeys list users
455
 * @param keystrings
456
 * @param secret list private keys
457
 * @return QList<UserInfo> users
458
 */
459
auto Pass::listKeys(QStringList keystrings, bool secret) -> QList<UserInfo> {
7✔
460
  QStringList args = {"--no-tty", "--with-colons", "--with-fingerprint"};
28✔
461
  args.append(secret ? "--list-secret-keys" : "--list-keys");
16✔
462

463
  for (const QString &keystring : std::as_const(keystrings)) {
14✔
464
    if (!keystring.isEmpty()) {
7✔
465
      args.append(keystring);
466
    }
467
  }
468
  QString p_out;
7✔
469
  if (Executor::executeBlocking(m_settings.gpgExecutable, args, &p_out) != 0) {
7✔
470
    return {};
×
471
  }
472
  return parseGpgColonOutput(p_out, secret);
7✔
473
}
7✔
474

475
/**
476
 * @brief Pass::listKeys list users
477
 * @param keystring
478
 * @param secret list private keys
479
 * @return QList<UserInfo> users
480
 */
481
auto Pass::listKeys(const QString &keystring, bool secret) -> QList<UserInfo> {
6✔
482
  return listKeys(QStringList(keystring), secret);
12✔
483
}
484

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

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

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

531
} // namespace
532

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

553
  // Locale-dependent fallbacks
554
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has expired"),
20✔
555
                                       QLatin1String("key expired")}))
556
    return QCoreApplication::translate(
557
        "Pass", "Encryption failed: GPG key has expired. Please renew or "
558
                "replace it.");
1✔
559
  if (containsAnyCaseInsensitive(err, {QLatin1String("key has been revoked"),
16✔
560
                                       QLatin1String("revoked")}))
561
    return QCoreApplication::translate(
562
        "Pass", "Encryption failed: GPG key has been revoked.");
1✔
563
  if (containsAnyCaseInsensitive(err, {QLatin1String("no public key"),
15✔
564
                                       QLatin1String("unusable public key"),
565
                                       QLatin1String("no secret key")}))
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 (containsAnyCaseInsensitive(err, {QLatin1String("encryption failed")}))
3✔
570
    return QCoreApplication::translate(
571
        "Pass", "Encryption failed. Check that your GPG key is valid.");
×
572

573
  return {};
574
}
×
575

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

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

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

644
  if (exitCode != 0) {
69✔
645
    handleProcessError(pid, exitCode, out, err);
2✔
646
    return;
2✔
647
  }
648

649
  emitProcessFinishedSignal(pid, out, err);
67✔
650
}
651

652
void Pass::handleProcessError(PROCESS pid, int exitCode, const QString &out,
2✔
653
                              const QString &err) {
654
  Q_UNUSED(out);
655

656
  if (pid == PASS_GREP) {
2✔
657
    handleGrepError(exitCode, err);
2✔
658
    return;
2✔
659
  }
660

661
  if (pid == PASS_INSERT) {
×
662
    const QString friendly = gpgErrorMessage(err);
×
663
    if (!friendly.isEmpty()) {
×
664
      emit processErrorExit(exitCode, formatInsertError(friendly, err));
×
665
      return;
666
    }
667
  }
668

669
  emit processErrorExit(exitCode, err);
×
670
}
671

672
void Pass::handleGrepError(int exitCode, const QString &err) {
2✔
673
  if (exitCode == 1) {
2✔
674
    emit finishedGrep({});
2✔
675
  } else {
676
    emit processErrorExit(exitCode, err);
1✔
677
    emit finishedGrep({});
2✔
678
  }
679
}
2✔
680

681
auto Pass::formatInsertError(const QString &friendly, const QString &err)
×
682
    -> QString {
683
  QStringList humanLines;
×
684
  for (const QString &line : err.split('\n')) {
×
685
    QString cleanedLine = line;
686
    cleanedLine.remove('\r');
×
687
    if (!cleanedLine.startsWith(QLatin1String("[GNUPG:]")))
×
688
      humanLines.append(cleanedLine);
689
  }
690
  const QString humanErr = humanLines.join('\n').trimmed();
×
691
  return humanErr.isEmpty() ? friendly : friendly + "\n\n" + humanErr;
×
692
}
693

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

727
  switch (pid) {
67✔
728
  case GIT_INIT:
×
729
    emit finishedGitInit(out, err);
×
730
    break;
×
731
  case GIT_PULL:
×
732
    emit finishedGitPull(out, err);
×
733
    break;
×
734
  case GIT_PUSH:
1✔
735
    emit finishedGitPush(out, err);
1✔
736
    break;
1✔
737
  case PASS_SHOW:
20✔
738
    emit finishedShow(out);
20✔
739
    break;
20✔
740
  case PASS_INSERT:
43✔
741
    emit finishedInsert(out, err);
43✔
742
    break;
43✔
743
  case PASS_REMOVE:
×
744
    emit finishedRemove(out, err);
×
745
    break;
×
746
  case PASS_INIT:
1✔
747
    emit finishedInit(out, err);
1✔
748
    break;
1✔
749
  case PASS_MOVE:
×
750
    emit finishedMove(out, err);
×
751
    break;
×
752
  case PASS_COPY:
1✔
753
    emit finishedCopy(out, err);
1✔
754
    break;
1✔
755
  case GPG_GENKEYS:
×
756
    emit finishedGenerateGPGKeys(out, err);
×
757
    break;
×
758
  case PASS_GREP:
1✔
759
    emit finishedGrep(parseGrepOutput(out));
1✔
760
    break;
1✔
761
  default:
762
#ifdef QT_DEBUG
763
    dbg() << "Unhandled process type" << pid;
764
#endif
765
    break;
766
  }
767
}
67✔
768

769
/**
770
 * @brief Set or remove a single environment variable.
771
 *
772
 * @param name Variable name, without a trailing '=' (e.g.
773
 * "PASSWORD_STORE_DIR").
774
 * @param value New value; an empty string removes the variable entirely.
775
 */
776
void Pass::setEnvVar(const QString &name, const QString &value) {
310✔
777
  if (value.isEmpty())
310✔
778
    env.remove(name);
85✔
779
  else
780
    env.insert(name, value);
225✔
781
}
310✔
782

783
/**
784
 * @brief Update the process environment used for executing external commands.
785
 *
786
 * Updates environment entries for PASSWORD_STORE_SIGNING_KEY,
787
 * PASSWORD_STORE_DIR, PASSWORD_STORE_GENERATED_LENGTH, and
788
 * PASSWORD_STORE_CHARACTER_SET based on current settings, then applies the
789
 * environment to the internal executor.
790
 */
791
void Pass::updateEnv() {
76✔
792
  setEnvVar(QStringLiteral("PASSWORD_STORE_SIGNING_KEY"),
152✔
793
            m_settings.passSigningKey);
76✔
794
  setEnvVar(QStringLiteral("PASSWORD_STORE_DIR"), m_settings.passStore);
152✔
795

796
  const PasswordConfiguration &passConfig = m_settings.passwordConfiguration;
76✔
797
  setEnvVar(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH"),
152✔
798
            QString::number(passConfig.length));
76✔
799

800
  setEnvVar(QStringLiteral("PASSWORD_STORE_CHARACTER_SET"),
152✔
801
            effectiveCharset(passConfig));
76✔
802

803
  exec.setEnvironment(env);
76✔
804
}
76✔
805

806
/**
807
 * @brief Pass::getGpgIdPath return gpgid file path for some file (folder).
808
 * @param for_file which file (folder) would you like the gpgid file path for.
809
 * @return path to the gpgid file.
810
 */
811
auto Pass::getGpgIdPath(const QString &for_file, const QString &passStore)
143✔
812
    -> QString {
813
  QString normalizedStore = QDir::fromNativeSeparators(passStore);
143✔
814
  QString normalizedFile = QDir::fromNativeSeparators(for_file);
143✔
815
  QString fullPath = normalizedFile.startsWith(normalizedStore)
143✔
816
                         ? normalizedFile
143✔
817
                         : normalizedStore + "/" + normalizedFile;
81✔
818
  QDir gpgIdDir(QFileInfo(fullPath).absoluteDir());
143✔
819
  // QDir::cleanPath() always normalises to forward slashes, so use '/'
820
  // here rather than QDir::separator() (which returns '\\' on Windows).
821
  QString cleanPassStore = QDir::cleanPath(normalizedStore);
143✔
822
  bool found = false;
823
  while (gpgIdDir.exists()) {
173✔
824
    QString currentPath = QDir::cleanPath(gpgIdDir.absolutePath());
344✔
825
    const QString prefix =
826
        cleanPassStore.endsWith('/') ? cleanPassStore : cleanPassStore + "/";
172✔
827
    if (currentPath != cleanPassStore && !currentPath.startsWith(prefix)) {
172✔
828
      break;
829
    }
830
    if (QFile(gpgIdDir.absoluteFilePath(".gpg-id")).exists()) {
326✔
831
      found = true;
832
      break;
833
    }
834
    if (!gpgIdDir.cdUp()) {
30✔
835
      break;
836
    }
837
  }
838
  return found ? gpgIdDir.absoluteFilePath(".gpg-id")
143✔
839
               : QDir(normalizedStore).filePath(".gpg-id");
429✔
840
}
143✔
841

842
/**
843
 * @brief Pass::getRecipientList return list of gpg-id's to encrypt for
844
 * @param for_file which file (folder) would you like recipients for
845
 * @return recipients gpg-id contents
846
 */
847
auto Pass::getRecipientList(const QString &for_file, const QString &passStore)
76✔
848
    -> QStringList {
849
  QFile gpgId(getGpgIdPath(for_file, passStore));
76✔
850
  if (!gpgId.open(QIODevice::ReadOnly | QIODevice::Text)) {
76✔
851
    return {};
1✔
852
  }
853
  QStringList recipients;
75✔
854
  while (!gpgId.atEnd()) {
171✔
855
    QString recipient(gpgId.readLine());
192✔
856
    recipient = recipient.split("#")[0].trimmed();
192✔
857
    if (recipient.isEmpty()) {
96✔
858
      continue;
7✔
859
    }
860
    if (!Util::isValidKeyId(recipient)) {
89✔
861
      // Never drop a recipient silently: the list is written back verbatim
862
      // by UsersDialog, so a skipped line disappears from .gpg-id.
863
      qWarning() << "Skipping unusable recipient in" << gpgId.fileName() << ":"
4✔
864
                 << recipient;
2✔
865
      continue;
2✔
866
    }
867
    recipients += recipient;
868
  }
869
  return recipients;
870
}
76✔
871

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

911
/* Copyright (C) 2017 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
912
 */
913

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

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

935
  do {
936
    randval = QRandomGenerator::system()->generate();
937
  } while (randval < rejectionThreshold);
7,592✔
938

939
  return randval % bound;
7,592✔
940
}
941

942
/**
943
 * @brief Generates a random password from the given charset.
944
 * @param charset Characters to use in the password
945
 * @param length Desired password length
946
 * @return Generated password string
947
 */
948
auto Pass::generateRandomPassword(const QString &charset, unsigned int length)
1,204✔
949
    -> QString {
950
  if (charset.isEmpty() || length == 0U) {
1,204✔
951
    return {};
952
  }
953
  QString out;
1,204✔
954
  for (unsigned int i = 0; i < length; ++i) {
8,796✔
955
    out.append(charset.at(static_cast<int>(
7,592✔
956
        boundedRandom(static_cast<quint32>(charset.length())))));
7,592✔
957
  }
958
  return out;
959
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc