• 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

76.03
/src/imitatepass.cpp
1
// SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "imitatepass.h"
4
#include "executor.h"
5
#include "util.h"
6
#include <QDirIterator>
7
#include <QElapsedTimer>
8
#include <QFile>
9
#include <QPointer>
10
#include <QProcess>
11
#include <QRegularExpression>
12
#include <QSaveFile>
13
#include <QThread>
14
#include <QTimer>
15
#include <utility>
16

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

21
using Enums::CLIPBOARD_ALWAYS;
22
using Enums::CLIPBOARD_NEVER;
23
using Enums::CLIPBOARD_ON_DEMAND;
24
using Enums::GIT_ADD;
25
using Enums::GIT_COMMIT;
26
using Enums::GIT_COPY;
27
using Enums::GIT_INIT;
28
using Enums::GIT_MOVE;
29
using Enums::GIT_PULL;
30
using Enums::GIT_PUSH;
31
using Enums::GIT_RM;
32
using Enums::GPG_GENKEYS;
33
using Enums::INVALID;
34
using Enums::PASS_COPY;
35
using Enums::PASS_GREP;
36
using Enums::PASS_INIT;
37
using Enums::PASS_INSERT;
38
using Enums::PASS_MOVE;
39
using Enums::PASS_REMOVE;
40
using Enums::PASS_SHOW;
41
using Enums::PROCESS_COUNT;
42

43
/**
44
 * @brief ImitatePass::ImitatePass for situations when pass is not available
45
 * we imitate the behavior of pass https://www.passwordstore.org/
46
 */
47
ImitatePass::ImitatePass() = default;
148✔
48

49
ImitatePass::~ImitatePass() {
79✔
50
  static constexpr int kGrepThreadTimeoutMs = 5000;
51
  for (QThread *t : std::as_const(m_grepThreads))
74✔
52
    if (t && t->isRunning())
×
53
      t->requestInterruption();
×
54
  QElapsedTimer elapsed;
74✔
55
  elapsed.start();
74✔
56
  for (QThread *t : std::as_const(m_grepThreads)) {
74✔
57
    if (t && t->isRunning()) {
×
58
      const int remaining =
59
          kGrepThreadTimeoutMs - static_cast<int>(elapsed.elapsed());
×
60
      if (remaining > 0)
×
61
        t->wait(remaining);
×
62
    }
63
  }
64
  // Unlike the grep workers, the re-encryption worker calls member functions
65
  // and so must not outlive this object. Cancel it and join. A timeout on the
66
  // join would not be safe, since the worker would go on touching this
67
  // object's members, and none is needed: the worker polls the flag while it
68
  // waits on a process (see execBlocking()), ends that process itself (gpg
69
  // waiting on pinentry while the user quits, say) within the poll interval
70
  // plus the kill grace, and its remaining work is not process-bound.
71
  if (m_reencryptThread && m_reencryptThread->isRunning()) {
74✔
72
    m_reencryptCancel.store(true);
73
    m_reencryptThread->wait();
3✔
74
  }
75
}
79✔
76

77
/**
78
 * @brief Blocking process run for the re-encryption helpers.
79
 *
80
 * The helpers (verifyGpgIdFile(), getKeysFromFile(), reencryptSingleFile(),
81
 * createBackupCommit()) are shared between the owning thread and the
82
 * re-encryption worker; the ImitatePass thread affinity tells the two apart.
83
 * On the worker the run is handed m_reencryptCancel: nothing is started once
84
 * the flag is set, and while a process runs the wait polls the flag and, when
85
 * it gets set, terminates and if need be kills the process. All of that
86
 * happens on the worker thread, which owns the QProcess. The other threads
87
 * (cancelReencryptPath(), the destructor) only ever set the flag; they hold
88
 * neither the QProcess nor its pid, so they cannot act on a process that has
89
 * exited in the meantime, nor on a pid the OS has since reused.
90
 */
91
auto ImitatePass::execBlocking(const QString &app, const QStringList &args,
95✔
92
                               const QString &input, QString *process_out,
93
                               QString *process_err) -> int {
94
  if (QThread::currentThread() == thread())
95✔
95
    return Executor::executeBlocking(app, args, input, process_out,
8✔
96
                                     process_err);
97
  QProcess process;
87✔
98
  return Executor::executeBlocking(process, app, args, input, process_out,
87✔
99
                                   process_err, &m_reencryptCancel);
87✔
100
}
87✔
101

102
auto ImitatePass::execBlocking(const QString &app, const QStringList &args,
75✔
103
                               QString *process_out, QString *process_err)
104
    -> int {
105
  return execBlocking(app, args, QString(), process_out, process_err);
150✔
106
}
107

108
auto ImitatePass::translatePathForWsl(const QString &path,
174✔
109
                                      const QString &exe) const -> QString {
110
  QString normalizedPath = QDir::cleanPath(path);
174✔
111
  if (!exe.startsWith(QStringLiteral("wsl ")))
348✔
112
    return normalizedPath;
113
  QString wslPath;
×
114
  const int rc = Executor::executeBlocking(
×
115
      QStringLiteral("wsl"),
×
116
      Executor::wslExecArgs(QStringLiteral("wslpath"), {normalizedPath}),
×
117
      &wslPath);
118
  const QString translated = wslPath.trimmed();
119
  return (rc == 0 && !translated.isEmpty()) ? translated : normalizedPath;
×
120
}
121

122
auto ImitatePass::pgit(const QString &path) const -> QString {
21✔
123
  return translatePathForWsl(path, m_settings.gitExecutable);
21✔
124
}
125

126
auto ImitatePass::pgpg(const QString &path) const -> QString {
153✔
127
  return translatePathForWsl(path, m_settings.gpgExecutable);
153✔
128
}
129

130
/**
131
 * @brief ImitatePass::GitInit git init wrapper
132
 */
133
void ImitatePass::GitInit() {
×
134
  executeGit(GIT_INIT, {"init", pgit(m_settings.passStore)});
×
135
}
×
136

137
/**
138
 * @brief ImitatePass::GitPull git pull wrapper
139
 */
140
void ImitatePass::GitPull() {
×
141
  if (gitReady()) {
×
142
    executeGit(GIT_PULL, {"pull"});
×
143
  }
144
}
×
145

146
/**
147
 * @brief ImitatePass::GitPull_b git pull wrapper which blocks until the
148
 *        process finishes
149
 */
150
void ImitatePass::GitPull_b() {
×
151
  if (!gitReady())
×
152
    return;
×
153
  // -C the store: executeBlocking sets no working directory, so without it
154
  // git would run in QtPass's launch directory and pull an unrelated
155
  // repository, or fail with "not a git repository".
156
  QString err;
×
157
  const int rc = Executor::executeBlocking(
×
158
      m_settings.gitExecutable, {"-C", pgit(m_settings.passStore), "pull"},
×
159
      QString(), nullptr, &err);
×
160
  if (rc != 0) {
×
161
    emit statusMsg(tr("Git pull failed: %1").arg(err.trimmed()), 5000);
×
162
  }
163
}
×
164

165
/**
166
 * @brief ImitatePass::GitPush git push wrapper
167
 */
168
void ImitatePass::GitPush() {
1✔
169
  if (gitReady()) {
1✔
170
    executeGit(GIT_PUSH, {"push"});
3✔
171
  }
172
}
2✔
173

174
/**
175
 * @brief ImitatePass::Show shows content of file
176
 */
177
void ImitatePass::Show(QString file) {
20✔
178
  file = m_settings.passStore + file + ".gpg";
20✔
179
  QStringList args = {"-d",      "--quiet",     "--yes",   "--no-encrypt-to",
180
                      "--batch", "--use-agent", pgpg(file)};
160✔
181
  executeGpg(PASS_SHOW, args);
40✔
182
}
40✔
183

184
/**
185
 * @brief ImitatePass::Insert create new file with encrypted content
186
 *
187
 * @param file      file to be created
188
 * @param newValue  value to be stored in file
189
 * @param overwrite whether to overwrite existing file
190
 */
191
void ImitatePass::Insert(QString file, QString newValue, bool overwrite) {
43✔
192
  file = file + ".gpg";
43✔
193
  QString gpgIdPath = Pass::getGpgIdPath(file, m_settings.passStore);
43✔
194
  if (!verifyGpgIdFile(gpgIdPath)) {
43✔
195
    emit critical(tr("Check .gpg-id file signature!"),
×
196
                  tr("Signature for %1 is invalid.").arg(gpgIdPath));
×
197
    return;
×
198
  }
199
  transactionHelper trans(this, PASS_INSERT);
43✔
200
  QStringList recipients = Pass::getRecipientList(file, m_settings.passStore);
43✔
201
  if (recipients.isEmpty()) {
43✔
202
    // Already emit critical signal to notify user of error - no need to throw
203
    emit critical(tr("Can not edit"),
×
204
                  tr("Could not read encryption key to use, .gpg-id "
×
205
                     "file missing or invalid."));
206
    return;
207
  }
208
  // --no-encrypt-to keeps an `encrypt-to` line in the user's gpg.conf from
209
  // adding a recipient that is not listed in the (possibly signed) .gpg-id;
210
  // --compress-algo=none mirrors pass(1). Both belong on every encrypt call.
211
  QStringList args = {"--batch",
212
                      "--status-fd",
213
                      "2",
214
                      "-eq",
215
                      "--compress-algo=none",
216
                      "--no-encrypt-to",
217
                      "--output",
218
                      pgpg(file)};
387✔
219
  for (auto &r : recipients) {
88✔
220
    args.append("-r");
90✔
221
    args.append(r);
222
  }
223
  if (overwrite) {
43✔
224
    args.append("--yes");
4✔
225
  }
226
  args.append("-");
86✔
227
  executeGpg(PASS_INSERT, args, newValue);
43✔
228
  if (gitReady()) {
43✔
229
    // Git is used when enabled - this is the standard pass workflow
230
    if (!overwrite) {
2✔
231
      executeGit(GIT_ADD, {"add", pgit(file)});
8✔
232
    }
233
    QString path = QDir(m_settings.passStore).relativeFilePath(file);
2✔
234
    path.replace(Util::endsWithGpg(), "");
2✔
235
    QString msg =
236
        QString(overwrite ? "Edit" : "Add") + " for " + path + " using QtPass.";
4✔
237
    gitCommit(file, msg);
2✔
238
  }
239
}
45✔
240

241
/**
242
 * @brief ImitatePass::gitCommit commit a file to git with an appropriate commit
243
 * message
244
 * @param file
245
 * @param msg
246
 */
247
void ImitatePass::gitCommit(const QString &file, const QString &msg) {
3✔
248
  if (file.isEmpty()) {
3✔
249
    executeGit(GIT_COMMIT, {"commit", "-m", msg});
5✔
250
  } else {
251
    executeGit(GIT_COMMIT, {"commit", "-m", msg, "--", pgit(file)});
14✔
252
  }
253
}
8✔
254

255
/**
256
 * @brief ImitatePass::Remove custom implementation of "pass remove"
257
 */
258
void ImitatePass::Remove(QString file, bool isDir) {
2✔
259
  file = m_settings.passStore + file;
2✔
260
  transactionHelper trans(this, PASS_REMOVE);
2✔
261
  if (!isDir) {
2✔
262
    file += ".gpg";
2✔
263
  }
264
  if (gitReady()) {
2✔
265
    executeGit(GIT_RM, {"rm", (isDir ? "-rf" : "-f"), pgit(file)});
×
266
    // Normalize path the same way as add/edit operations
267
    QString path = QDir(m_settings.passStore).relativeFilePath(file);
×
268
    path.replace(Util::endsWithGpg(), "");
×
269
    gitCommit(file, "Remove for " + path + " using QtPass.");
×
270
  } else {
271
    if (isDir) {
2✔
272
      QDir dir(file);
×
273
      dir.removeRecursively();
×
274
    } else {
×
275
      QFile(file).remove();
2✔
276
    }
277
  }
278
}
2✔
279

280
/**
281
 * @brief ImitatePass::Init initialize pass repository
282
 *
283
 * @param path      path in which new password-store will be created
284
 * @param users     list of users who shall be able to decrypt passwords in
285
 * path
286
 */
287
auto ImitatePass::checkSigningKeys(const QStringList &signingKeys) -> bool {
2✔
288
  QString out;
2✔
289
  QStringList args =
290
      QStringList{"--status-fd=1", "--list-secret-keys"} + signingKeys;
8✔
291
  int result = Executor::executeBlocking(m_settings.gpgExecutable, args, &out);
2✔
292
  if (result != 0) {
2✔
293
#ifdef QT_DEBUG
294
    dbg() << "GPG list-secret-keys failed with code:" << result;
295
#endif
296
    return false;
297
  }
298
  for (auto &key : signingKeys) {
2✔
299
    if (out.contains("[GNUPG:] KEY_CONSIDERED " + key)) {
4✔
300
      return true;
301
    }
302
  }
303
  return false;
304
}
2✔
305

306
/**
307
 * @brief Writes the selected users' GPG key IDs to a .gpg-id file.
308
 * @details Opens the specified file for writing, stores the key ID of each
309
 * enabled user on a separate line, and warns if none of the selected users has
310
 * a secret key available.
311
 *
312
 * @param QString &gpgIdFile - Path to the .gpg-id file to be written.
313
 * @param QList<UserInfo> &users - List of users to evaluate and write to the
314
 * file.
315
 * @return void - This function does not return a value.
316
 *
317
 */
318
void ImitatePass::writeGpgIdFile(const QString &gpgIdFile,
4✔
319
                                 const QList<UserInfo> &users) {
320
  QFile gpgId(gpgIdFile);
4✔
321
  if (!gpgId.open(QIODevice::WriteOnly | QIODevice::Text)) {
4✔
322
    emit critical(tr("Cannot update"),
×
323
                  tr("Failed to open .gpg-id for writing."));
×
324
    return;
325
  }
326
  bool secret_selected = false;
327
  for (const UserInfo &user : users) {
8✔
328
    if (user.enabled) {
4✔
329
      gpgId.write((user.key_id + "\n").toUtf8());
8✔
330
      secret_selected |= user.have_secret;
4✔
331
    }
332
  }
333
  gpgId.close();
4✔
334
  // Lock the file to owner-only access. The .gpg-id leaks which keys the
335
  // store is encrypted to; while the typical ~/.password-store is 0700,
336
  // users may relocate the store onto NFS/SMB/USB where the parent dir
337
  // perms are more lax. On platforms where setPermissions is a no-op
338
  // (Windows), this is silently best-effort.
339
  QFile::setPermissions(gpgIdFile, QFile::ReadOwner | QFile::WriteOwner);
4✔
340
  if (!secret_selected) {
4✔
341
    emit critical(
×
342
        tr("Check selected users!"),
×
343
        tr("None of the selected keys have a secret key available.\n"
×
344
           "You will not be able to decrypt any newly added passwords!"));
345
  }
346
}
4✔
347

348
/**
349
 * @brief Signs a GPG ID file and verifies its signature.
350
 * @example
351
 * bool result = ImitatePass::signGpgIdFile(gpgIdFile, signingKeys);
352
 * std::cout << result << std::endl; // Expected output: true if signing and
353
 * verification succeed
354
 *
355
 * @param QString &gpgIdFile - Path to the .gpg-id file to be signed.
356
 * @param QStringList &signingKeys - List of signing keys; only the first key is
357
 * used.
358
 * @return bool - True if the file was signed and its signature verified
359
 * successfully; otherwise false.
360
 */
361
auto ImitatePass::signGpgIdFile(const QString &gpgIdFile,
2✔
362
                                const QStringList &signingKeys) -> bool {
363
  QStringList args;
2✔
364
  // Use only the first signing key; multiple --default-key options would
365
  // override each other and only the last one would take effect.
366
  if (!signingKeys.isEmpty()) {
2✔
367
#ifdef QT_DEBUG
368
    if (signingKeys.size() > 1) {
369
      dbg() << "Multiple signing keys configured; using only the first key:"
370
            << signingKeys.first();
371
    }
372
#endif
373
    args.append(QStringList{"--default-key", signingKeys.first()});
8✔
374
  }
375
  args.append(QStringList{"--yes", "--detach-sign", gpgIdFile});
10✔
376
  int result = Executor::executeBlocking(m_settings.gpgExecutable, args);
2✔
377
  if (result != 0) {
2✔
378
#ifdef QT_DEBUG
379
    dbg() << "GPG signing failed with code:" << result;
380
#endif
381
    emit critical(tr("GPG signing failed!"),
×
382
                  tr("Failed to sign %1.").arg(gpgIdFile));
×
383
    return false;
×
384
  }
385
  if (!verifyGpgIdFile(gpgIdFile)) {
2✔
386
    emit critical(tr("Check .gpg-id file signature!"),
×
387
                  tr("Signature for %1 is invalid.").arg(gpgIdFile));
×
388
    return false;
×
389
  }
390
  return true;
391
}
4✔
392

393
/**
394
 * @brief Adds a GPG ID file and optionally its signature file to git, then
395
 * creates corresponding commit(s).
396
 *
397
 * Git runs synchronously here on purpose: Init follows up with reencryptPath,
398
 * whose backup and re-encryption commits are blocking as well. Queuing the
399
 * add/commit on the asynchronous executor instead let the two race for the
400
 * index lock, so either the queued `git add` died on `index.lock` (and the
401
 * cancelled commit left the .gpg-id untracked) or the backup commit absorbed
402
 * the file first and `git commit -- .gpg-id` failed with nothing to commit.
403
 *
404
 * @example
405
 * int rc = ImitatePass::gitAddGpgId(gpgIdFile, gpgIdSigFile, true, true,
406
 *                                   &out, &err);
407
 *
408
 * @param const QString &gpgIdFile - Path to the GPG ID file to add and commit.
409
 * @param const QString &gpgIdSigFile - Path to the signature file associated
410
 * with the GPG ID file.
411
 * @param bool addFile - Whether to stage the GPG ID file before committing.
412
 * @param bool addSigFile - Whether to stage and commit the signature file.
413
 * @param QString *out - Receives the concatenated stdout of the git commands.
414
 * @param QString *err - Receives the concatenated stderr of the git commands.
415
 * @return int - Exit code of the first failing git command, 0 on success.
416
 */
417
auto ImitatePass::gitAddGpgId(const QString &gpgIdFile,
1✔
418
                              const QString &gpgIdSigFile, bool addFile,
419
                              bool addSigFile, QString *out, QString *err)
420
    -> int {
421
  const QString git = m_settings.gitExecutable;
422
  const QString store = pgit(m_settings.passStore);
1✔
423
  auto run = [&](const QStringList &args) -> int {
2✔
424
    QString runOut;
2✔
425
    QString runErr;
2✔
426
    const int rc = Executor::executeBlocking(
2✔
427
        git, QStringList{"-C", store} + args, &runOut, &runErr);
8✔
428
    if (out != nullptr) {
2✔
429
      out->append(runOut);
2✔
430
    }
431
    if (err != nullptr) {
2✔
432
      err->append(runErr);
2✔
433
    }
434
    return rc;
2✔
435
  };
2✔
436
  int rc = 0;
437
  if (addFile) {
1✔
438
    rc = run({"add", pgit(gpgIdFile)});
4✔
439
    if (rc != 0) {
1✔
440
      return rc;
441
    }
442
  }
443
  QString commitPath = gpgIdFile;
444
  commitPath.replace(Util::endsWithGpg(), "");
1✔
445
  rc = run({"commit", "-m", "Added " + commitPath + " using QtPass.", "--",
8✔
446
            pgit(gpgIdFile)});
447
  if (rc != 0 || !addSigFile) {
1✔
448
    return rc;
449
  }
450
  rc = run({"add", pgit(gpgIdSigFile)});
×
451
  if (rc != 0) {
×
452
    return rc;
453
  }
454
  commitPath = gpgIdSigFile;
×
455
  commitPath.replace(QRegularExpression("\\.gpg$"), "");
×
456
  return run({"commit", "-m", "Added " + commitPath + " using QtPass.", "--",
×
457
              pgit(gpgIdSigFile)});
458
}
3✔
459

460
/**
461
 * @brief Checks whether git already tracks a file in the password store.
462
 *
463
 * @param const QString &file - Absolute path of the file inside the store.
464
 * @return bool - true when the file is in the index, false when it is
465
 * untracked or the lookup failed.
466
 */
467
auto ImitatePass::gitTracks(const QString &file) -> bool {
1✔
468
  return Executor::executeBlocking(m_settings.gitExecutable,
8✔
469
                                   {"-C", pgit(m_settings.passStore),
1✔
470
                                    "ls-files", "--error-unmatch", "--",
471
                                    pgit(file)}) == 0;
2✔
472
}
1✔
473

474
/**
475
 * @brief Initializes the pass entry by writing and optionally signing the GPG
476
 * ID files.
477
 *
478
 * @example
479
 * void result = ImitatePass::Init(path, users);
480
 *
481
 * @param QString path - Base path for the pass entry where ".gpg-id" and
482
 * optional signature files are created.
483
 * @param const QList<UserInfo> &users - List of users whose keys are written
484
 * into the GPG ID file.
485
 * @return void - No return value.
486
 */
487
void ImitatePass::Init(QString path, const QList<UserInfo> &users) {
3✔
488
  QStringList signingKeys =
489
      m_settings.passSigningKey.split(" ", Qt::SkipEmptyParts);
3✔
490
  QString gpgIdSigFile = path + ".gpg-id.sig";
3✔
491
  bool addSigFile = false;
492
  if (!signingKeys.isEmpty()) {
3✔
493
    if (!checkSigningKeys(signingKeys)) {
2✔
494
      emit critical(tr("No signing key!"),
×
495
                    tr("None of the secret signing keys is available.\n"
×
496
                       "You will not be able to change the user list!"));
497
      return;
×
498
    }
499
    QFileInfo checkFile(gpgIdSigFile);
2✔
500
    if (!checkFile.exists() || !checkFile.isFile()) {
2✔
501
      addSigFile = true;
502
    }
503
  }
2✔
504

505
  const bool useGit = gitReady();
3✔
506
  QString gpgIdFile = path + ".gpg-id";
3✔
507
  bool addFile = false;
508
  if (m_settings.addGPGId && useGit) {
3✔
509
    // Stage the .gpg-id unless git already tracks it. Checking the working
510
    // tree instead is not enough: MainWindow::addFolder writes a folder's
511
    // .gpg-id without staging it, and since the backup commit before
512
    // re-encryption only picks up tracked files (#1685) nothing else ever
513
    // would. Without the add, `git commit -- <file>` below fails for the
514
    // untracked pathspec and the re-encrypted entries get pushed without
515
    // the recipients file they were encrypted to (#1682).
516
    addFile = !gitTracks(gpgIdFile);
1✔
517
  }
518
  writeGpgIdFile(gpgIdFile, users);
3✔
519

520
  if (!signingKeys.isEmpty()) {
3✔
521
    if (!signGpgIdFile(gpgIdFile, signingKeys)) {
2✔
522
      return;
523
    }
524
  }
525

526
  int gitExit = 0;
527
  QString gitOut;
3✔
528
  QString gitErr;
3✔
529
  if (useGit) {
3✔
530
    gitExit = gitAddGpgId(gpgIdFile, gpgIdSigFile, addFile, addSigFile, &gitOut,
1✔
531
                          &gitErr);
532
  }
533
  reencryptPath(path);
3✔
534
  if (useGit) {
3✔
535
    // Same contract the asynchronous add/commit transaction used to provide:
536
    // finishedInit when the .gpg-id landed in git, processErrorExit otherwise.
537
    Pass::finished(PASS_INIT, gitExit, gitOut, gitErr);
1✔
538
  }
539
}
540

541
/**
542
 * @brief ImitatePass::verifyGpgIdFile verify detached gpgid file signature.
543
 * @param file which gpgid file.
544
 * @return was verification successful?
545
 */
546
auto ImitatePass::verifyGpgIdFile(const QString &file) -> bool {
64✔
547
  QStringList signingKeys =
548
      m_settings.passSigningKey.split(" ", Qt::SkipEmptyParts);
128✔
549
  if (signingKeys.isEmpty()) {
64✔
550
    return true;
551
  }
552
  QString out;
6✔
553
  QStringList args =
554
      QStringList{"--verify", "--status-fd=1", pgpg(file) + ".sig", pgpg(file)};
36✔
555
  int result = execBlocking(m_settings.gpgExecutable, args, &out);
6✔
556
  if (result != 0) {
6✔
557
#ifdef QT_DEBUG
558
    dbg() << "GPG verify failed with code:" << result;
559
#endif
560
    return false;
561
  }
562
  QRegularExpression re(
563
      R"(^\[GNUPG:\] VALIDSIG ([A-F0-9]{40}) .* ([A-F0-9]{40})\r?$)",
564
      QRegularExpression::MultilineOption);
10✔
565
  QRegularExpressionMatch m = re.match(out);
5✔
566
  if (!m.hasMatch()) {
5✔
567
    return false;
568
  }
569
  QStringList fingerprints = m.capturedTexts();
5✔
570
  fingerprints.removeFirst();
5✔
571
  for (auto &key : signingKeys) {
5✔
572
    if (fingerprints.contains(key)) {
5✔
573
      return true;
5✔
574
    }
575
  }
576
  return false;
×
577
}
17✔
578

579
/**
580
 * @brief ImitatePass::reencryptPath reencrypt all files under the chosen
581
 * directory
582
 *
583
 * This is still quite experimental..
584
 * @param dir
585
 */
586
auto ImitatePass::verifyGpgIdForDir(const QString &file,
20✔
587
                                    QStringList &gpgIdFilesVerified,
588
                                    QStringList &gpgId) -> bool {
589
  QString gpgIdPath = Pass::getGpgIdPath(file, m_settings.passStore);
20✔
590
  // Verify each .gpg-id signature only once, but always refresh the recipient
591
  // list for the current file: a cache hit means the signature was already
592
  // checked, not that gpgId still holds this directory's recipients — it may
593
  // carry a different directory's list, which would re-encrypt to wrong keys.
594
  if (!gpgIdFilesVerified.contains(gpgIdPath)) {
20✔
595
    if (!verifyGpgIdFile(gpgIdPath)) {
19✔
596
      // An interrupted gpg is a cancel, not a bad signature.
597
      if (!m_reencryptCancel.load())
1✔
598
        emit critical(tr("Check .gpg-id file signature!"),
3✔
599
                      tr("Signature for %1 is invalid.").arg(gpgIdPath));
2✔
600
      return false;
1✔
601
    }
602
    gpgIdFilesVerified.append(gpgIdPath);
603
  }
604
  gpgId = getRecipientList(file, m_settings.passStore);
38✔
605
  gpgId.sort();
606
  return true;
607
}
608

609
/**
610
 * @brief Extracts and returns a sorted list of valid key IDs from a GPG key
611
 * listing file.
612
 * @example
613
 * QStringList result = ImitatePass::getKeysFromFile(fileName);
614
 * std::cout << result.join(", ").toStdString() << std::endl;
615
 *
616
 * @param fileName - Path to the file used to query and parse GPG key
617
 * information.
618
 * @return QStringList - A sorted list of 16-character key IDs found in the
619
 * file.
620
 */
621
auto ImitatePass::getKeysFromFile(const QString &fileName) -> QStringList {
26✔
622
  QStringList args = {
623
      "-v",          "--no-secmem-warning", "--no-permission-warning",
624
      "--list-only", "--keyid-format=long", pgpg(fileName)};
182✔
625
  QString keys;
26✔
626
  QString err;
26✔
627
  const int result = execBlocking(m_settings.gpgExecutable, args, &keys, &err);
26✔
628
  if (result != 0) {
26✔
629
    return {};
12✔
630
  }
631
  QStringList actualKeys;
14✔
632
  keys += err;
633
  QStringList key = keys.split(Util::newLinesRegex(), Qt::SkipEmptyParts);
14✔
634
  QListIterator<QString> itr(key);
635
  while (itr.hasNext()) {
46✔
636
    QString current = itr.next();
637
    QStringList cur = current.split(" ");
64✔
638
    if (cur.length() > 4) {
32✔
639
      QString actualKey = cur.takeAt(4);
22✔
640
      if (actualKey.length() == 16) {
22✔
641
        actualKeys << actualKey;
642
      }
643
    }
644
  }
645
  actualKeys.sort();
646
  return actualKeys;
647
}
26✔
648

649
/**
650
 * @brief Re-encrypts a single encrypted file for a new set of recipients.
651
 * @example
652
 * bool result = ImitatePass::reencryptSingleFile(fileName, recipients);
653
 * std::cout << result << std::endl; // Expected output: true on success, false
654
 * on failure
655
 *
656
 * @param const QString &fileName - Path to the encrypted file to re-encrypt.
657
 * @param const QStringList &recipients - List of recipient keys to encrypt the
658
 * file to.
659
 * @return bool - True if the file was successfully decrypted, re-encrypted,
660
 * verified, and replaced; otherwise false.
661
 */
662
auto ImitatePass::reencryptSingleFile(const QString &fileName,
26✔
663
                                      const QStringList &recipients) -> bool {
664
#ifdef QT_DEBUG
665
  dbg() << "reencrypt " << fileName << " for " << recipients;
666
#endif
667
  QString local_lastDecrypt;
26✔
668
  QStringList args = {
669
      "-d",      "--quiet",     "--yes",       "--no-encrypt-to",
670
      "--batch", "--use-agent", pgpg(fileName)};
208✔
671
  int result = execBlocking(m_settings.gpgExecutable, args, &local_lastDecrypt);
26✔
672

673
  if (result != 0 || local_lastDecrypt.isEmpty()) {
26✔
674
#ifdef QT_DEBUG
675
    dbg() << "Decrypt error on re-encrypt for:" << fileName;
676
#endif
677
    return false;
678
  }
679

680
  if (local_lastDecrypt.right(1) != "\n") {
28✔
681
    local_lastDecrypt += "\n";
×
682
  }
683

684
  // Use passed recipients instead of re-reading from file
685
  if (recipients.isEmpty()) {
14✔
686
    emit critical(tr("Can not edit"),
×
687
                  tr("Could not read encryption key to use, .gpg-id "
×
688
                     "file missing or invalid."));
689
    return false;
×
690
  }
691

692
  // Encrypt to temporary file for atomic replacement
693
  QString tempPath = fileName + ".reencrypt.tmp";
14✔
694
  // Same encrypt-only flags as Insert(): gpg.conf must not add recipients.
695
  args = QStringList{
126✔
696
      "--yes",           "--batch",  "-eq",         "--compress-algo=none",
697
      "--no-encrypt-to", "--output", pgpg(tempPath)};
98✔
698
  for (const auto &i : recipients) {
28✔
699
    args.append("-r");
28✔
700
    args.append(i);
701
  }
702
  args.append("-");
14✔
703
  result = execBlocking(m_settings.gpgExecutable, args, local_lastDecrypt);
14✔
704

705
  if (result != 0) {
14✔
706
#ifdef QT_DEBUG
707
    dbg() << "Encrypt error on re-encrypt for:" << fileName;
708
#endif
709
    QFile::remove(tempPath);
2✔
710
    return false;
711
  }
712

713
  // Verify encryption worked by attempting to decrypt the temp file
714
  QString verifyOutput;
12✔
715
  args = QStringList{"-d", "--quiet", "--batch", "--use-agent", pgpg(tempPath)};
84✔
716
  result = execBlocking(m_settings.gpgExecutable, args, &verifyOutput);
12✔
717
  if (result != 0 || verifyOutput.isEmpty()) {
12✔
718
#ifdef QT_DEBUG
719
    dbg() << "Verification failed for:" << tempPath;
720
#endif
721
    QFile::remove(tempPath);
×
722
    return false;
723
  }
724
  // Verify content matches original decrypted content (defense in depth)
725
  if (verifyOutput.trimmed() != local_lastDecrypt.trimmed()) {
12✔
726
#ifdef QT_DEBUG
727
    dbg() << "Verification content mismatch for:" << tempPath;
728
#endif
729
    QFile::remove(tempPath);
×
730
    return false;
731
  }
732

733
  // Atomic replace with backup: rename original to .bak, rename temp to
734
  // original, then remove backup
735
  QString backupPath = fileName + ".reencrypt.bak";
12✔
736
  if (!QFile::rename(fileName, backupPath)) {
12✔
737
#ifdef QT_DEBUG
738
    dbg() << "Failed to backup original file:" << fileName;
739
#endif
740
    QFile::remove(tempPath);
×
741
    return false;
742
  }
743
  if (!QFile::rename(tempPath, fileName)) {
12✔
744
#ifdef QT_DEBUG
745
    dbg() << "Failed to rename temp file to:" << fileName;
746
#endif
747
    // Restore backup and clean up temp file
748
    QFile::rename(backupPath, fileName);
×
749
    QFile::remove(tempPath);
×
750
    emit critical(
×
751
        tr("Re-encryption failed"),
×
752
        tr("Failed to replace %1. Original has been restored.").arg(fileName));
×
753
    return false;
×
754
  }
755
  // Success - remove backup
756
  QFile::remove(backupPath);
12✔
757

758
  if (gitConfigured()) {
12✔
759
    // -C the store so git runs there rather than in QtPass's launch directory
760
    // (executeBlocking sets no working directory).
761
    const QString store = pgit(m_settings.passStore);
2✔
762
    if (execBlocking(m_settings.gitExecutable,
12✔
763
                     {"-C", store, "add", pgit(fileName)}) != 0) {
764
#ifdef QT_DEBUG
765
      dbg() << "git add failed after re-encrypting:" << fileName;
766
#endif
767
      // The file on disk is re-encrypted correctly; only the repository is
768
      // now behind. Report it so the caller counts this file as failed and
769
      // the run is not pushed.
770
      return false;
771
    }
772
    QString path = QDir(m_settings.passStore).relativeFilePath(fileName);
2✔
773
    path.replace(Util::endsWithGpg(), "");
4✔
774
    if (execBlocking(m_settings.gitExecutable,
18✔
775
                     {"-C", store, "commit", pgit(fileName), "-m",
776
                      "Re-encrypt for " + path + " using QtPass."}) != 0) {
4✔
777
#ifdef QT_DEBUG
778
      dbg() << "git commit failed after re-encrypting:" << fileName;
779
#endif
780
      return false;
781
    }
782
  }
783

784
  return true;
785
}
62✔
786

787
/**
788
 * @brief Create git backup commit before re-encryption.
789
 * @return true if backup created or not needed, false if backup failed.
790
 */
791
auto ImitatePass::createBackupCommit() -> bool {
24✔
792
  if (!gitConfigured()) {
24✔
793
    return true;
794
  }
795
  emit statusMsg(tr("Creating backup commit"), 2000);
10✔
796
  const QString git = m_settings.gitExecutable;
797
  // Run git in the password store: executeBlocking does not set a working
798
  // directory, so without -C these commands would run in QtPass's launch
799
  // directory and either fail or operate on an unrelated repository.
800
  const QString store = pgit(m_settings.passStore);
5✔
801
  // Only tracked files belong in the backup. Untracked files in the store (a
802
  // plaintext export, an editor swap file, ...) must not be swept into a
803
  // commit that autoPush then sends to the shared remote, so both the status
804
  // check and the add are restricted to what git already knows about.
805
  QString statusOut;
5✔
806
  if (execBlocking(
35✔
807
          git, {"-C", store, "status", "--porcelain", "--untracked-files=no"},
808
          &statusOut) != 0) {
809
    // An interrupted git is a cancel, not a failure worth a dialog.
810
    if (!m_reencryptCancel.load())
×
811
      emit critical(
×
812
          tr("Backup commit failed"),
×
813
          tr("Could not inspect git status. Re-encryption was aborted."));
×
814
    return false;
×
815
  }
816
  if (!statusOut.trimmed().isEmpty()) {
5✔
817
    if (execBlocking(git, {"-C", store, "add", "-u"}) != 0 ||
7✔
818
        execBlocking(git, {"-C", store, "commit", "-m",
9✔
819
                           "Backup before re-encryption"}) != 0) {
820
      if (!m_reencryptCancel.load())
×
821
        emit critical(tr("Backup commit failed"),
×
822
                      tr("Re-encryption was aborted because a git backup "
×
823
                         "could not be created."));
824
      return false;
×
825
    }
826
  }
827
  return true;
828
}
14✔
829

830
/**
831
 * @brief Outcome of one reencryptPath() run.
832
 */
833
struct ImitatePass::ReencryptResult {
44✔
834
  int total = 0;          ///< `.gpg` files found under the directory.
835
  int checked = 0;        ///< Files whose recipients were inspected.
836
  int reencrypted = 0;    ///< Files rewritten for the current recipients.
837
  QStringList failed;     ///< Files that could not be re-encrypted.
838
  bool cancelled = false; ///< Stopped early by cancelReencryptPath(); the
839
                          ///< interrupted file, if any, is not in `failed`.
840
  bool aborted = false;   ///< Stopped early on an error already reported.
841
};
842

843
namespace {
844
/// Poll interval while waiting for queued git commands to drain.
845
constexpr int kReencryptRetryMs = 100;
846
/// Cap on the file names listed in the aggregated failure dialog.
847
constexpr int kReencryptMaxListedFailures = 15;
848
} // namespace
849

850
/**
851
 * @brief Re-encrypts all `.gpg` files under the given directory using the
852
 *        verified GPG key configuration for each folder.
853
 *
854
 * Emits startReencryptPath() and hands the actual work to a worker thread
855
 * (see reencryptFiles()), so the GUI stays responsive and the run can be
856
 * cancelled. The worker optionally pulls first, creates a backup commit,
857
 * verifies `.gpg-id` files per directory and re-encrypts files whose current
858
 * recipients do not match the expected keys, reporting progress through
859
 * reencryptProgress(). Per-file failures are aggregated into a single
860
 * critical() by finishReencrypt(), which also pushes when configured and
861
 * emits endReencryptPath().
862
 *
863
 * @param dir - Root directory to scan recursively for `.gpg` files.
864
 * @return void
865
 */
866
void ImitatePass::reencryptPath(const QString &dir) {
24✔
867
  if (m_reencryptActive) {
24✔
868
    emit statusMsg(tr("A re-encryption is already running"), 3000);
1✔
869
    return;
1✔
870
  }
871
  m_reencryptActive = true;
23✔
872
  m_reencryptCancel.store(false);
873
  emit statusMsg(tr("Re-encrypting from folder %1").arg(dir), 3000);
46✔
874
  emit startReencryptPath();
23✔
875
  startReencryptWorker(dir);
23✔
876
}
877

878
/**
879
 * @brief Stop a running re-encryption promptly.
880
 *
881
 * Only sets the cancel flag. The worker starts no further process once it is
882
 * set, and the process it is blocked on is ended by the worker itself: the
883
 * cancellable Executor::executeBlocking() polls the flag and, on seeing it,
884
 * terminate()s the child and kill()s it if it is still running after the
885
 * grace period (terminate() is only a request: SIGTERM, or WM_CLOSE on
886
 * Windows, which a console gpg ignores). Nothing here touches the worker's
887
 * QProcess or its pid. A new run clears the flag.
888
 */
889
void ImitatePass::cancelReencryptPath() {
3✔
890
  if (!m_reencryptActive)
3✔
891
    return;
892
  m_reencryptCancel.store(true);
893
}
894

895
/**
896
 * @brief Start the re-encryption worker once the Executor queue is idle.
897
 *
898
 * Callers such as Init(), Move() and Copy() queue git commands on `exec`
899
 * right before calling reencryptPath(). Those run asynchronously on this
900
 * thread, so the worker's blocking git calls would otherwise compete with
901
 * them for the repository's index lock. Poll until the queue has drained.
902
 */
903
void ImitatePass::startReencryptWorker(const QString &dir) {
23✔
904
  if (m_reencryptCancel.load()) {
23✔
905
    ReencryptResult result;
×
906
    result.cancelled = true;
×
907
    finishReencrypt(result);
×
908
    return;
909
  }
910
  if (!exec.isIdle()) {
911
    QTimer::singleShot(kReencryptRetryMs, this,
1✔
912
                       [this, dir]() { startReencryptWorker(dir); });
2✔
913
    return;
1✔
914
  }
915

916
  // The worker calls member functions, so `this` must outlive it: the
917
  // destructor cancels and joins m_reencryptThread. `self` only guards the
918
  // queued completion, which may run after the thread object is gone.
919
  QPointer<ImitatePass> self(this);
920
  QThread *thread = QThread::create([this, self, dir]() {
44✔
921
    ReencryptResult result = reencryptFiles(dir);
22✔
922
    QMetaObject::invokeMethod(
22✔
923
        self,
924
        [self, result = std::move(result)]() {
84✔
925
          if (self)
18✔
926
            self->finishReencrypt(result);
18✔
927
        },
18✔
928
        Qt::QueuedConnection);
929
  });
22✔
930
  m_reencryptThread = thread;
22✔
931
  connect(thread, &QThread::finished, this, [this, thread]() {
22✔
932
    if (m_reencryptThread == thread)
18✔
933
      m_reencryptThread = nullptr;
18✔
934
  });
935
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
22✔
936
  thread->start();
22✔
937
}
938

939
/**
940
 * @brief Worker-thread body of reencryptPath().
941
 *
942
 * Only blocking helpers (execBlocking() directly and through
943
 * createBackupCommit(), verifyGpgIdForDir(), getKeysFromFile() and
944
 * reencryptSingleFile()) run here; anything that touches `exec` or the
945
 * transaction state stays on the owning thread. Signals emitted from here
946
 * (statusMsg, critical, reencryptProgress) are delivered queued to their
947
 * GUI-thread receivers. The cancel flag is checked between files, and a
948
 * cancel also ends the process in progress from this thread (see
949
 * execBlocking()): a helper that fails while the flag is set was
950
 * interrupted, so its file is neither counted as checked nor reported as
951
 * failed.
952
 */
953
auto ImitatePass::reencryptFiles(const QString &dir) -> ReencryptResult {
22✔
954
  ReencryptResult result;
22✔
955
  if (m_settings.autoPull && gitConfigured()) {
22✔
956
    emit statusMsg(tr("Updating password-store"), 2000);
×
957
    if (execBlocking(m_settings.gitExecutable,
×
958
                     {"-C", pgit(m_settings.passStore), "pull"}) != 0) {
×
959
      emit statusMsg(tr("Git pull failed, re-encrypting the store as it is"),
×
960
                     5000);
961
    }
962
  }
963

964
  // Create backup before re-encryption - abort if it fails
965
  if (!createBackupCommit()) {
22✔
966
    if (m_reencryptCancel.load())
×
967
      result.cancelled = true;
×
968
    else
969
      result.aborted = true;
×
970
    return result;
971
  }
972

973
  QStringList files;
22✔
974
  QDirIterator gpgFiles(dir, QStringList() << "*.gpg", QDir::Files,
44✔
975
                        QDirIterator::Subdirectories);
22✔
976
  while (gpgFiles.hasNext()) {
60✔
977
    files << gpgFiles.next();
76✔
978
  }
979
  result.total = files.size();
22✔
980
  emit reencryptProgress(0, result.total);
22✔
981

982
  QString currentDir;
22✔
983
  QStringList gpgIdFilesVerified;
22✔
984
  QStringList gpgId;
22✔
985
  for (const QString &fileName : std::as_const(files)) {
42✔
986
    if (m_reencryptCancel.load()) {
27✔
987
      result.cancelled = true;
×
988
      break;
6✔
989
    }
990
    const QString fileDir = QFileInfo(fileName).path();
27✔
991
    if (fileDir != currentDir) {
27✔
992
      if (!verifyGpgIdForDir(fileName, gpgIdFilesVerified, gpgId)) {
20✔
993
        if (m_reencryptCancel.load())
1✔
994
          result.cancelled = true;
×
995
        else
996
          result.aborted = true;
1✔
997
        return result;
998
      }
999
      if (gpgId.isEmpty() && !gpgIdFilesVerified.isEmpty()) {
19✔
1000
        emit critical(tr("GPG ID verification failed"),
×
1001
                      tr("Could not verify .gpg-id for directory."));
×
1002
        result.aborted = true;
×
1003
        return result;
×
1004
      }
1005
      currentDir = fileDir;
19✔
1006
    }
1007
    QStringList actualKeys = getKeysFromFile(fileName);
26✔
1008
    if (actualKeys != gpgId) {
26✔
1009
      if (reencryptSingleFile(fileName, gpgId)) {
26✔
1010
        result.reencrypted++;
12✔
1011
      } else if (m_reencryptCancel.load()) {
14✔
1012
        // Interrupted by the cancel: the file is untouched, not failed.
1013
        result.cancelled = true;
6✔
1014
        break;
1015
      } else {
1016
        result.failed << fileName;
8✔
1017
      }
1018
    }
1019
    result.checked++;
20✔
1020
    emit reencryptProgress(result.checked, result.total);
20✔
1021
  }
1022
  return result;
1023
}
22✔
1024

1025
/**
1026
 * @brief Owning-thread epilogue of reencryptPath().
1027
 *
1028
 * Reports the aggregated failures in one dialog, summarises the run in the
1029
 * status bar, pushes when configured (not after a cancel, an abort or a
1030
 * per-file failure: a partially re-encrypted store must not reach the remote,
1031
 * and the user should inspect the result first) and releases the UI.
1032
 */
1033
void ImitatePass::finishReencrypt(const ReencryptResult &result) {
18✔
1034
  if (!result.failed.isEmpty()) {
18✔
1035
    QStringList listed = result.failed.mid(0, kReencryptMaxListedFailures);
4✔
1036
    const int more = result.failed.size() - listed.size();
4✔
1037
    if (more > 0) {
4✔
1038
      listed << tr("... and %n more", nullptr, more);
×
1039
    }
1040
    emit critical(tr("Re-encryption failed"),
12✔
1041
                  tr("%n file(s) could not be re-encrypted:", nullptr,
4✔
1042
                     result.failed.size()) +
8✔
1043
                      "\n\n" + listed.join('\n'));
12✔
1044
  }
1045

1046
  if (result.cancelled) {
18✔
1047
    emit statusMsg(tr("Re-encryption cancelled: %1 of %2 files checked, "
6✔
1048
                      "%3 re-encrypted, %4 failed")
1049
                       .arg(result.checked)
3✔
1050
                       .arg(result.total)
3✔
1051
                       .arg(result.reencrypted)
3✔
1052
                       .arg(result.failed.size()),
9✔
1053
                   5000);
1054
  } else if (!result.aborted) {
15✔
1055
    if (!result.failed.isEmpty()) {
14✔
1056
      emit statusMsg(tr("Re-encryption completed: %1 succeeded, %2 failed")
6✔
1057
                         .arg(result.reencrypted)
3✔
1058
                         .arg(result.failed.size()),
9✔
1059
                     5000);
1060
    } else {
1061
      emit statusMsg(tr("Re-encryption completed: %1 files re-encrypted")
11✔
1062
                         .arg(result.reencrypted),
22✔
1063
                     3000);
1064
    }
1065
    if (m_settings.autoPush && gitConfigured()) {
14✔
1066
      if (result.failed.isEmpty()) {
2✔
1067
        emit statusMsg(tr("Updating password-store"), 2000);
1✔
1068
        GitPush();
1✔
1069
      } else {
1070
        emit statusMsg(tr("Not pushing: %n file(s) failed to re-encrypt",
2✔
1071
                          nullptr, result.failed.size()),
1072
                       5000);
1073
      }
1074
    }
1075
  }
1076
  m_reencryptActive = false;
18✔
1077
  emit endReencryptPath();
18✔
1078
}
18✔
1079

1080
/**
1081
 * @brief Resolves the final destination path for moving a file or directory,
1082
 * applying .gpg handling for files.
1083
 * @example
1084
 * QString result = ImitatePass::resolveMoveDestination("/tmp/source.txt",
1085
 * "/backup", false); std::cout << result.toStdString() << std::endl; //
1086
 * Expected output sample: "/backup/source.txt.gpg"
1087
 *
1088
 * @param src - Source path to the file or directory.
1089
 * @param dest - Requested destination path, which may be a file or directory.
1090
 * @param force - When true, allows overwriting an existing destination file.
1091
 * @return QString - Resolved destination path, or an empty QString if the
1092
 * source/destination is invalid or conflicts occur.
1093
 */
1094
auto ImitatePass::resolveMoveDestination(const QString &src,
15✔
1095
                                         const QString &dest, bool force)
1096
    -> QString {
1097
  QFileInfo srcFileInfo(src);
15✔
1098
  QFileInfo destFileInfo(dest);
15✔
1099
  QString destFile;
15✔
1100
  QString srcFileBaseName = srcFileInfo.fileName();
15✔
1101

1102
  if (srcFileInfo.isFile()) {
15✔
1103
    if (destFileInfo.isFile()) {
14✔
1104
      if (!force) {
4✔
1105
#ifdef QT_DEBUG
1106
        dbg() << "Destination file already exists";
1107
#endif
1108
        return {};
1109
      }
1110
      destFile = dest;
2✔
1111
    } else if (destFileInfo.isDir()) {
10✔
1112
      destFile = QDir(dest).filePath(srcFileBaseName);
10✔
1113
    } else {
1114
      destFile = dest;
5✔
1115
    }
1116

1117
    if (destFile.endsWith(".gpg", Qt::CaseInsensitive)) {
24✔
1118
      destFile.chop(4);
12✔
1119
    }
1120
    destFile.append(".gpg");
12✔
1121
  } else if (srcFileInfo.isDir()) {
1✔
1122
    if (destFileInfo.isDir()) {
×
1123
      destFile = QDir(dest).filePath(srcFileBaseName);
×
1124
    } else if (destFileInfo.isFile()) {
×
1125
#ifdef QT_DEBUG
1126
      dbg() << "Destination is a file";
1127
#endif
1128
      return {};
1129
    } else {
1130
      destFile = dest;
×
1131
    }
1132
  } else {
1133
#ifdef QT_DEBUG
1134
    dbg() << "Source file does not exist";
1135
#endif
1136
    return {};
1137
  }
1138
  return destFile;
1139
}
15✔
1140

1141
/**
1142
 * @brief Moves a password store item in the Git repository and commits the
1143
 * change.
1144
 * @example
1145
 * void result = className.executeMoveGit(src, destFile, force);
1146
 *
1147
 * @param const QString &src - Source path of the item to move.
1148
 * @param const QString &destFile - Destination path of the item after the move.
1149
 * @param bool force - Whether to force the move using Git's -f option.
1150
 * @return void - This method does not return a value.
1151
 */
1152
void ImitatePass::executeMoveGit(const QString &src, const QString &destFile,
×
1153
                                 bool force) {
1154
  QStringList args;
×
1155
  args << "mv";
×
1156
  if (force) {
×
1157
    args << "-f";
×
1158
  }
1159
  args << pgit(src);
×
1160
  args << pgit(destFile);
×
1161
  executeGit(GIT_MOVE, args);
×
1162

1163
  QString relSrc = QDir(m_settings.passStore).relativeFilePath(src);
×
1164
  relSrc.replace(Util::endsWithGpg(), "");
×
1165
  QString relDest = QDir(m_settings.passStore).relativeFilePath(destFile);
×
1166
  relDest.replace(Util::endsWithGpg(), "");
×
1167
  QString message = QString("Moved for %1 to %2 using QtPass.");
×
1168
  message = message.arg(relSrc, relDest);
×
1169
  gitCommit("", message);
×
1170
}
×
1171

1172
/**
1173
 * @brief Moves a password entry from the source path to the destination path.
1174
 * @example
1175
 * ImitatePass::Move(src, dest, true);
1176
 *
1177
 * @param const QString src - The source path or entry name to move.
1178
 * @param const QString dest - The destination path or entry name.
1179
 * @param const bool force - If true, overwrites an existing destination entry
1180
 * when necessary.
1181
 * @return void - This function does not return a value.
1182
 */
1183
void ImitatePass::Move(const QString src, const QString dest,
2✔
1184
                       const bool force) {
1185
  transactionHelper trans(this, PASS_MOVE);
2✔
1186
  QString destFile = resolveMoveDestination(src, dest, force);
2✔
1187
  if (destFile.isEmpty()) {
2✔
1188
    return;
1189
  }
1190

1191
#ifdef QT_DEBUG
1192
  dbg() << "Move Source: " << src;
1193
  dbg() << "Move Destination: " << destFile;
1194
#endif
1195

1196
  if (gitReady()) {
2✔
1197
    executeMoveGit(src, destFile, force);
×
1198
  } else {
1199
    QDir qDir;
2✔
1200
    if (force) {
2✔
1201
      qDir.remove(destFile);
×
1202
    }
1203
    qDir.rename(src, destFile);
2✔
1204
  }
2✔
1205
}
1206

1207
/**
1208
 * @brief Copies a regular file onto dst, replacing dst atomically.
1209
 *
1210
 * The bytes are written to a temporary sibling that QSaveFile renames over
1211
 * dst only once all of them are in, so a failure part-way (disk full,
1212
 * permissions, a vanished source) leaves an existing dst untouched instead of
1213
 * removed first and never rewritten. The source's permissions are carried
1214
 * over, as QFile::copy would.
1215
 * @return true on success; on failure nothing at dst has changed.
1216
 */
1217
static auto copyFileReplacing(const QString &src, const QString &dst) -> bool {
5✔
1218
  QFile in(src);
5✔
1219
  if (!QFileInfo(in).isFile() || !in.open(QIODevice::ReadOnly))
5✔
1220
    return false;
1221
  QSaveFile out(dst);
5✔
1222
  if (!out.open(QIODevice::WriteOnly))
5✔
1223
    return false;
1224
  out.setPermissions(in.permissions());
5✔
1225
  char buf[64 * 1024];
1226
  for (;;) {
1227
    const qint64 n = in.read(buf, sizeof buf);
10✔
1228
    if (n < 0) {
10✔
1229
      out.cancelWriting();
×
1230
      return false;
1231
    }
1232
    if (n == 0)
10✔
1233
      break;
1234
    if (out.write(buf, n) != n) {
5✔
1235
      out.cancelWriting();
×
1236
      return false;
1237
    }
1238
  }
1239
  return out.commit();
5✔
1240
}
5✔
1241

1242
/**
1243
 * @brief Copies a file or directory from source to destination, optionally
1244
 * forcing overwrite.
1245
 * @example
1246
 * void result = ImitatePass::Copy(src, dest, force);
1247
 *
1248
 * @param QString src - Source path to copy from.
1249
 * @param QString dest - Destination path to copy to: a new file name, or an
1250
 * existing folder to copy into (like `pass cp`).
1251
 * @param bool force - If true, overwrites the destination when it already
1252
 * exists.
1253
 * @return void - This function does not return a value.
1254
 */
1255
void ImitatePass::Copy(const QString src, const QString dest,
8✔
1256
                       const bool force) {
1257
  transactionHelper trans(this, PASS_COPY);
8✔
1258
  // Like `pass cp`, dest may be an existing folder (a drag-and-drop copy hands
1259
  // over the folder, not the new file name). Resolve the real target the same
1260
  // way Move does: into the folder, .gpg appended, no clobbering without force.
1261
  QString destFile = resolveMoveDestination(src, dest, force);
8✔
1262
  if (destFile.isEmpty()) {
8✔
1263
    emit critical(tr("Copy failed"),
3✔
1264
                  tr("Could not copy %1 to %2.").arg(src, dest));
1✔
1265
    return;
1✔
1266
  }
1267
  QFileInfo destFileInfo(destFile);
7✔
1268
  // A folder destination that is the source's own folder resolves to the
1269
  // source itself; with force that would replace the only copy with itself.
1270
  if (QFileInfo(src) == destFileInfo) {
14✔
1271
    emit critical(tr("Copy failed"),
3✔
1272
                  tr("Could not copy %1 to %2.").arg(src, destFile));
1✔
1273
    return;
1✔
1274
  }
1275
  // resolveMoveDestination only sees a clash when dest names the file; for a
1276
  // folder destination the resolved <folder>/<entry>.gpg may exist as well.
1277
  if (!force && destFileInfo.exists()) {
6✔
1278
    emit critical(tr("Copy failed"),
3✔
1279
                  tr("Could not copy %1 to %2.").arg(src, destFile));
1✔
1280
    return;
1✔
1281
  }
1282
  // git has no "cp" subcommand, so copy on the filesystem in both modes and,
1283
  // when using git, stage the new path afterwards. The copy is synchronous and
1284
  // replaces the destination atomically (see copyFileReplacing), so it exists
1285
  // before the re-encryption below runs and an entry being overwritten with
1286
  // force survives a copy that fails half-way.
1287
  if (!copyFileReplacing(src, destFile)) {
5✔
1288
    emit critical(tr("Copy failed"),
×
1289
                  tr("Could not copy %1 to %2.").arg(src, destFile));
×
1290
    return;
×
1291
  }
1292
  // QFileInfo caches; the comparison above may have looked at a path that did
1293
  // not exist yet, so re-read it before deciding what to re-encrypt.
1294
  destFileInfo.refresh();
5✔
1295
  if (gitReady()) {
5✔
1296
    executeGit(GIT_COPY, {"add", pgit(destFile)});
4✔
1297
    QString message = QString("Copied from %1 to %2 using QtPass.");
1✔
1298
    message = message.arg(src, destFile);
1✔
1299
    gitCommit("", message);
2✔
1300
  }
1301
  // reecrypt all files under the new folder
1302
  if (destFileInfo.isDir()) {
5✔
1303
    reencryptPath(destFileInfo.absoluteFilePath());
×
1304
  } else if (destFileInfo.isFile()) {
5✔
1305
    reencryptPath(destFileInfo.dir().path());
10✔
1306
  }
1307
}
8✔
1308

1309
/**
1310
 * @brief ImitatePass::executeGpg easy wrapper for running gpg commands
1311
 * @param args
1312
 */
1313
void ImitatePass::executeGpg(PROCESS id, const QStringList &args, QString input,
63✔
1314
                             bool readStdout, bool readStderr) {
1315
  executeWrapper(id, m_settings.gpgExecutable, args, std::move(input),
63✔
1316
                 readStdout, readStderr);
1317
}
63✔
1318

1319
/**
1320
 * @brief ImitatePass::gitConfigured git is enabled and an executable is set.
1321
 *
1322
 * useGit can be on while gitExecutable is empty (fresh setup, git removed
1323
 * later). Handing that empty executable to the Executor used to wedge the
1324
 * command queue (#1682); the git-only paths must not be taken in that case.
1325
 * @return true when git commands can actually run.
1326
 */
1327
auto ImitatePass::gitConfigured() const -> bool {
38✔
1328
  return m_settings.useGit && !m_settings.gitExecutable.isEmpty();
38✔
1329
}
1330

1331
/**
1332
 * @brief ImitatePass::gitReady gitConfigured() plus a status message.
1333
 *
1334
 * For the user-facing operations: tells the user once per operation why git
1335
 * was skipped, so the store silently drifting from git does not go unnoticed.
1336
 * @return true when git commands can actually run.
1337
 */
1338
auto ImitatePass::gitReady() -> bool {
56✔
1339
  if (m_settings.useGit && m_settings.gitExecutable.isEmpty()) {
56✔
1340
    emit statusMsg(tr("Git executable not configured, skipping git"), 3000);
7✔
1341
    return false;
7✔
1342
  }
1343
  return m_settings.useGit;
1344
}
1345

1346
/**
1347
 * @brief ImitatePass::executeGit easy wrapper for running git commands
1348
 * @param args
1349
 */
1350
void ImitatePass::executeGit(PROCESS id, const QStringList &args, QString input,
7✔
1351
                             bool readStdout, bool readStderr) {
1352
  // Callers check gitReady() first and fall back to plain filesystem
1353
  // operations when no git executable is configured. Should an empty
1354
  // executable still get here, the Executor now reports it as an error
1355
  // instead of wedging the queue (#1682).
1356
  executeWrapper(id, m_settings.gitExecutable, args, std::move(input),
7✔
1357
                 readStdout, readStderr);
1358
}
7✔
1359

1360
/**
1361
 * @brief ImitatePass::finished this function is overloaded to ensure
1362
 *                              identical behaviour to RealPass ie. only PASS_*
1363
 *                              processes are visible inside Pass::finish, so
1364
 *                              that interface-wise it all looks the same
1365
 * @param id
1366
 * @param exitCode
1367
 * @param out
1368
 * @param err
1369
 */
1370
void ImitatePass::finished(int id, int exitCode, const QString &out,
70✔
1371
                           const QString &err) {
1372
#ifdef QT_DEBUG
1373
  dbg() << "Imitate Pass";
1374
#endif
1375
  PROCESS pid = transactionIsOver(static_cast<PROCESS>(id));
70✔
1376
  m_transactionOutput.append(out);
70✔
1377

1378
  if (exitCode == 0) {
70✔
1379
    if (pid == INVALID) {
70✔
1380
      return;
1381
    }
1382
  } else {
1383
    while (pid == INVALID) {
×
1384
      id = exec.cancelNext();
×
1385
      if (id == -1) {
×
1386
        //  this is probably irrecoverable and shall not happen
1387
#ifdef QT_DEBUG
1388
        dbg() << "No such transaction!";
1389
#endif
1390
        return;
1391
      }
1392
      pid = transactionIsOver(static_cast<PROCESS>(id));
×
1393
    }
1394
  }
1395
  Pass::finished(pid, exitCode, m_transactionOutput, err);
65✔
1396
  m_transactionOutput.clear();
65✔
1397
}
1398

1399
/**
1400
 * @brief Register a transaction before each wrapped execution.
1401
 *
1402
 * Native mode treats every git/gpg invocation as a transaction; the base
1403
 * Pass::executeWrapper calls this hook just before dispatching.
1404
 * @param id Process identifier of the command about to run.
1405
 */
1406
void ImitatePass::beforeExecute(PROCESS id) { transactionAdd(id); }
70✔
1407

1408
/**
1409
 * @brief Decrypt one .gpg file and return lines matching rx.
1410
 */
1411
auto ImitatePass::grepMatchFile(const QProcessEnvironment &env,
9✔
1412
                                const QString &gpgExe, const QString &filePath,
1413
                                const QRegularExpression &rx) -> QStringList {
1414
  QString translatedPath = filePath;
1415
  if (gpgExe.startsWith(QStringLiteral("wsl "))) {
18✔
1416
    QString wslPath;
×
1417
    const int wrc = Executor::executeBlocking(
×
1418
        QStringLiteral("wsl"),
×
1419
        Executor::wslExecArgs(QStringLiteral("wslpath"), {filePath}), &wslPath);
×
1420
    const QString translated = wslPath.trimmed();
1421
    if (wrc == 0 && !translated.isEmpty())
×
1422
      translatedPath = translated;
×
1423
  }
1424
  QString plaintext;
9✔
1425
  const int rc =
1426
      Executor::executeBlocking(env, gpgExe,
81✔
1427
                                {"-d", "--quiet", "--yes", "--no-encrypt-to",
1428
                                 "--batch", "--use-agent", translatedPath},
1429
                                &plaintext);
1430
  if (rc != 0 || plaintext.isEmpty())
9✔
1431
    return {};
3✔
1432
  QStringList matches;
6✔
1433
  for (const QString &line : plaintext.split('\n')) {
30✔
1434
    QString candidate = line;
1435
    if (candidate.endsWith('\r'))
18✔
1436
      candidate.chop(1);
×
1437
    const QString t = candidate.trimmed();
1438
    if (!t.isEmpty() && candidate.contains(rx))
18✔
1439
      matches << t;
1440
  }
1441
  return matches;
1442
}
9✔
1443

1444
/**
1445
 * @brief Walk the store, decrypt every .gpg file, collect matches.
1446
 */
1447
auto ImitatePass::grepScanStore(const QProcessEnvironment &env,
5✔
1448
                                const QString &gpgExe, const QString &storeDir,
1449
                                const QRegularExpression &rx)
1450
    -> QList<QPair<QString, QStringList>> {
1451
  QList<QPair<QString, QStringList>> results;
5✔
1452
  QDirIterator it(storeDir, QStringList() << "*.gpg", QDir::Files,
10✔
1453
                  QDirIterator::Subdirectories);
5✔
1454
  while (it.hasNext()) {
13✔
1455
    if (QThread::currentThread()->isInterruptionRequested())
8✔
1456
      return {};
×
1457
    const QString filePath = it.next();
8✔
1458
    const QStringList matches = grepMatchFile(env, gpgExe, filePath, rx);
8✔
1459
    if (!matches.isEmpty()) {
8✔
1460
      QString entry = QDir(storeDir).relativeFilePath(filePath);
6✔
1461
      if (entry.endsWith(QLatin1String(".gpg")))
6✔
1462
        entry.chop(4);
6✔
1463
      results.append({entry, matches});
6✔
1464
    }
1465
  }
1466
  return results;
1467
}
5✔
1468

1469
/**
1470
 * @brief Search all password content by GPG-decrypting each .gpg file.
1471
 *
1472
 * The pattern is evaluated with `QRegularExpression` (**PCRE**), which differs
1473
 * from the POSIX BRE dialect of the `pass` backend — see Pass::Grep for the
1474
 * cross-backend caveat.
1475
 *
1476
 * Runs a background thread to avoid blocking the UI. Results are emitted on
1477
 * the main thread via QMetaObject::invokeMethod. A sequence counter discards
1478
 * results from superseded searches.
1479
 */
1480
void ImitatePass::Grep(QString pattern, bool caseInsensitive) {
5✔
1481
  for (QThread *t : std::as_const(m_grepThreads))
5✔
1482
    if (t && t->isRunning())
×
1483
      t->requestInterruption();
×
1484
  // No wait() — blocking the UI thread while GPG decrypts would freeze the
1485
  // interface. Stale results are discarded via the sequence counter.
1486

1487
  // Advance the sequence before any early return so in-flight workers from the
1488
  // previous query fail the seq check and cannot publish stale results.
1489
  const int seq = ++m_grepSeq;
5✔
1490

1491
  // Use trimmed() rather than isEmpty(): a whitespace-only string is a valid
1492
  // regex that matches every non-empty line, which is almost never intentional
1493
  // and would decrypt the entire store.
1494
  //
1495
  // Both early returns post finishedGrep via Qt::QueuedConnection so that the
1496
  // signal is always delivered asynchronously after Grep() returns, matching
1497
  // the contract of the threaded path.
1498
  if (pattern.trimmed().isEmpty()) {
5✔
1499
    QMetaObject::invokeMethod(
×
1500
        this,
1501
        [this, seq]() {
×
1502
          if (m_grepSeq == seq)
×
1503
            emit finishedGrep({});
×
1504
        },
×
1505
        Qt::QueuedConnection);
1506
    return;
1✔
1507
  }
1508

1509
  const QRegularExpression rx(
1510
      pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
1511
                               : QRegularExpression::PatternOptions{});
10✔
1512
  if (!rx.isValid()) {
5✔
1513
    QMetaObject::invokeMethod(
1✔
1514
        this,
1515
        [this, seq]() {
1✔
1516
          if (m_grepSeq == seq)
1✔
1517
            emit finishedGrep({});
2✔
1518
        },
1✔
1519
        Qt::QueuedConnection);
1520
    return;
1521
  }
1522
  const QString gpgExe = m_settings.gpgExecutable;
1523
  const QString storeDir = m_settings.passStore;
1524
  const QProcessEnvironment env = exec.environment();
4✔
1525
  QPointer<ImitatePass> self(this);
1526

1527
  auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
8✔
1528
    if (!self)
4✔
1529
      return;
1530
    QMetaObject::invokeMethod(
4✔
1531
        self,
1532
        [self, seq, results = std::move(results)]() {
12✔
1533
          if (self && self->m_grepSeq == seq)
8✔
1534
            emit self->finishedGrep(results);
4✔
1535
        },
4✔
1536
        Qt::QueuedConnection);
1537
  };
4✔
1538

1539
  QThread *thread = QThread::create(
4✔
1540
      [gpgExe, storeDir, env, rx, emitResults = std::move(emitResults)]() {
8✔
1541
        std::move(emitResults)(grepScanStore(env, gpgExe, storeDir, rx));
4✔
1542
      });
4✔
1543

1544
  m_grepThreads.append(thread);
4✔
1545
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
4✔
1546
  connect(thread, &QThread::finished, this,
4✔
1547
          [this, thread]() { m_grepThreads.removeOne(thread); });
8✔
1548
  thread->start();
4✔
1549
}
9✔
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