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

IJHack / QtPass / 35983147367

24 Sep 2026 09:45AM UTC coverage: 92.955%. First build
35983147367

Pull #1916

github

web-flow
Merge c975578f9 into 3c79ac631
Pull Request #1916: Fewer branches where CodeFactor counts them

214 of 216 new or added lines in 7 files covered. (99.07%)

8788 of 9454 relevant lines covered (92.96%)

151.42 hits per line

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

95.77
/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 "gpgidgeneration.h"
6
#include "util.h"
7
#include <QDateTime>
8
#include <QDir>
9
#include <QFile>
10
#include <QFileInfo>
11
#include <QPointer>
12
#include <QProcess>
13
#include <QRegularExpression>
14
#include <QThread>
15
#include <QTimer>
16
#include <utility>
17

18
#include "qtpasslogging.h"
19

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

42
/**
43
 * @brief ImitatePass::ImitatePass for situations when pass is not available
44
 * we imitate the behavior of pass https://www.passwordstore.org/
45
 */
46
ImitatePass::ImitatePass() : m_grep(this) {
160 ✔
47
  connect(&m_grep, &NativeGrep::finished, this, &ImitatePass::finishedGrep);
160 ✔
48
}
160 ✔
49

50
ImitatePass::~ImitatePass() {
167 ✔
51
  // Let the search workers wind down while the re-encryption worker is
52
  // joined; m_grep's own destructor waits for them afterwards.
53
  m_grep.cancel();
160 ✔
54
  // Unlike the grep workers, the re-encryption worker calls member functions
55
  // and so must not outlive this object. Cancel it and join. A timeout on the
56
  // join would not be safe, since the worker would go on touching this
57
  // object's members, and none is needed: the worker polls the flag while it
58
  // waits on a process (see execBlocking()), ends that process itself (gpg
59
  // waiting on pinentry while the user quits, say) within the poll interval
60
  // plus the kill grace, and its remaining work is not process-bound.
61
  if (m_reencryptThread && m_reencryptThread->isRunning()) {
160 ✔
62
    m_reencryptCancel.store(true);
63
    m_reencryptThread->wait();
3 ✔
64
  }
65
}
327 ✔
66

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

92
auto ImitatePass::execBlocking(const QString &app, const QStringList &args,
200 ✔
93
                               QString *process_out, QString *process_err)
94
    -> int {
95
  return execBlocking(app, args, QString(), process_out, process_err);
400 ✔
96
}
97

98
auto ImitatePass::translatePathForWsl(const QString &path,
402 ✔
99
                                      const QString &exe) const -> QString {
100
  return Executor::translatePathForWsl(path, exe);
402 ✔
101
}
102

103
auto ImitatePass::pgit(const QString &path) const -> QString {
81 ✔
104
  return translatePathForWsl(path, m_settings.gitExecutable);
81 ✔
105
}
106

107
auto ImitatePass::pgpg(const QString &path) const -> QString {
321 ✔
108
  return translatePathForWsl(path, m_settings.gpgExecutable);
321 ✔
109
}
110

111
/**
112
 * @brief ImitatePass::GitInit git init wrapper
113
 */
114
void ImitatePass::GitInit() {
1 ✔
115
  executeGit(GIT_INIT, {"init", pgit(m_settings.passStore)});
4 ✔
116
}
2 ✔
117

118
/**
119
 * @brief ImitatePass::GitPull git pull wrapper
120
 */
121
void ImitatePass::GitPull() {
2 ✔
122
  if (gitReady()) {
2 ✔
123
    executeGit(GIT_PULL, {"pull"});
6 ✔
124
  }
125
}
4 ✔
126

127
/**
128
 * @brief ImitatePass::GitPull_b git pull wrapper which blocks until the
129
 *        process finishes
130
 */
131
void ImitatePass::GitPull_b() {
4 ✔
132
  if (!gitReady())
4 ✔
133
    return;
1 ✔
134
  // -C the store: executeBlocking sets no working directory, so without it
135
  // git would run in QtPass's launch directory and pull an unrelated
136
  // repository, or fail with "not a git repository".
137
  QString err;
3 ✔
138
  const int rc = Executor::executeBlocking(
15 ✔
139
      m_settings.gitExecutable, {"-C", pgit(m_settings.passStore), "pull"},
3 ✔
140
      QString(), nullptr, &err);
3 ✔
141
  if (rc != 0) {
3 ✔
142
    emit statusMsg(tr("Git pull failed: %1").arg(err.trimmed()), 5000);
6 ✔
143
  }
144
}
3 ✔
145

146
/**
147
 * @brief ImitatePass::GitPush git push wrapper
148
 */
149
void ImitatePass::GitPush() {
4 ✔
150
  if (gitReady()) {
4 ✔
151
    executeGit(GIT_PUSH, {"push"});
12 ✔
152
  }
153
}
8 ✔
154

155
/**
156
 * @brief ImitatePass::Show shows content of file
157
 */
158
void ImitatePass::Show(QString file) {
44 ✔
159
  if (refuseLinkedPath(file + ".gpg")) {
88 ✔
160
    return;
4 ✔
161
  }
162
  queueShow(file);
163
  file = m_settings.passStore + file + ".gpg";
40 ✔
164
  QStringList args = {"-d",      "--quiet",     "--yes", "--no-encrypt-to",
165
                      "--batch", "--use-agent", "--",    pgpg(file)};
360 ✔
166
  executeGpg(PASS_SHOW, args);
80 ✔
167
}
40 ✔
168

169
/**
170
 * @brief ImitatePass::Insert create new file with encrypted content
171
 *
172
 * @param file      file to be created
173
 * @param newValue  value to be stored in file
174
 * @param overwrite whether to overwrite existing file
175
 */
176
void ImitatePass::Insert(QString file, QString newValue, bool overwrite) {
63 ✔
177
  // The dialog names a new entry relative to the store; gpg used to resolve
178
  // that in its working directory. Everything below works on the one path.
179
  file =
180
      QDir::cleanPath(QDir::isAbsolutePath(file)
63 ✔
181
                          ? file + ".gpg"
111 ✔
182
                          : QDir(m_settings.passStore).filePath(file) + ".gpg");
159 ✔
183
  if (refuseLinkedPath(file)) {
63 ✔
184
    return;
9 ✔
185
  }
186
  // gpg used to refuse an existing output without --yes; it writes
187
  // elsewhere now, so the check is ours here, and again at the rename.
188
  const QFileInfo target(file);
61 ✔
189
  if (!overwrite && (target.exists() || target.isSymLink())) {
61 ✔
190
    emit critical(tr("Cannot add"), tr("%1 already exists.").arg(file));
3 ✔
191
    return;
1 ✔
192
  }
193
  QString gpgIdPath = Pass::getGpgIdPath(file, m_settings.passStore);
60 ✔
194
  QStringList recipients;
60 ✔
195
  QString why;
60 ✔
196
  if (!loadVerifiedRecipients(gpgIdPath, &recipients, &why)) {
60 ✔
197
    emit critical(tr("Check .gpg-id file signature!"), why);
4 ✔
198
    return;
4 ✔
199
  }
200
  if (recipients.isEmpty()) {
56 ✔
201
    // Already emit critical signal to notify user of error - no need to throw
202
    emit critical(tr("Can not edit"),
2 ✔
203
                  tr("Could not read encryption key to use, .gpg-id "
×
204
                     "file missing or invalid."));
205
    return;
1 ✔
206
  }
207
  // gpg never opens a path in the store for output: refuseLinkedPath()
208
  // judged the name a moment ago, and whoever can write to the store could
209
  // make it, or any name in the store, a link to somewhere else before gpg
210
  // opens it. gpg writes into a directory of QtPass's own (0700, in the
211
  // temporary location), which no co-writer of the store can reach;
212
  // placeEncryptedFile() then brings the bytes into the store through an
213
  // open handle and the operating system's rename.
214
  auto scratch = std::make_shared<QTemporaryDir>();
215
  if (!scratch->isValid()) {
55 ✔
216
    emit critical(tr("Cannot write"),
2 ✔
217
                  tr("Cannot create a temporary directory: %1")
1 ✔
218
                      .arg(scratch->errorString()));
2 ✔
219
    return;
220
  }
221
  const QString output = scratch->filePath(QStringLiteral("entry.gpg"));
108 ✔
222
  TransactionHelper trans(&m_transaction, PASS_INSERT);
54 ✔
223
  // --no-encrypt-to keeps an `encrypt-to` line in the user's gpg.conf from
224
  // adding a recipient that is not listed in the (possibly signed) .gpg-id;
225
  // --compress-algo=none mirrors pass(1). Both belong on every encrypt call.
226
  QStringList args = {"--batch",
227
                      "--status-fd",
228
                      "2",
229
                      "-eq",
230
                      "--compress-algo=none",
231
                      "--no-encrypt-to",
232
                      "--output",
233
                      pgpg(output)};
486 ✔
234
  for (auto &r : recipients) {
110 ✔
235
    args.append("-r");
112 ✔
236
    args.append(r);
237
  }
238
  args.append("-");
108 ✔
239
  m_pendingInserts.enqueue({std::move(scratch), output, file, overwrite});
54 ✔
240
  executeGpg(PASS_INSERT, args, newValue);
54 ✔
241
  if (gitReady()) {
54 ✔
242
    // Git is used when enabled - this is the standard pass workflow
243
    if (!overwrite) {
3 ✔
244
      executeGit(GIT_ADD, {"add", "--", pgit(file)});
15 ✔
245
    }
246
    QString path = QDir(m_settings.passStore).relativeFilePath(file);
3 ✔
247
    path.replace(Util::endsWithGpg(), "");
3 ✔
248
    QString msg =
249
        QString(overwrite ? "Edit" : "Add") + " for " + path + " using QtPass.";
6 ✔
250
    gitCommit(file, msg);
3 ✔
251
  }
252
}
172 ✔
253

254
/**
255
 * @brief ImitatePass::gitCommit commit a file to git with an appropriate commit
256
 * message
257
 * @param file
258
 * @param msg
259
 */
260
void ImitatePass::gitCommit(const QString &file, const QString &msg) {
8 ✔
261
  if (file.isEmpty()) {
8 ✔
262
    executeGit(GIT_COMMIT, {"commit", "-m", msg});
15 ✔
263
  } else {
264
    executeGit(GIT_COMMIT, {"commit", "-m", msg, "--", pgit(file)});
35 ✔
265
  }
266
}
21 ✔
267

268
/**
269
 * @brief ImitatePass::Remove custom implementation of "pass remove"
270
 */
271
void ImitatePass::Remove(QString file, bool isDir) {
15 ✔
272
  // No trailing separator: "link/" makes git look for what is behind the
273
  // link ("pathspec did not match") and rm follow it.
274
  file = QDir::cleanPath(m_settings.passStore + file);
15 ✔
275
  if (!isDir) {
15 ✔
276
    file += ".gpg";
6 ✔
277
  }
278
  // A link itself may go; anything behind one is not the store's to delete.
279
  if (refuseLinkedPath(file, false)) {
15 ✔
280
    return;
9 ✔
281
  }
282
  TransactionHelper trans(&m_transaction, PASS_REMOVE);
13 ✔
283
  QString path = QDir(m_settings.passStore).relativeFilePath(file);
13 ✔
284
  path.replace(Util::endsWithGpg(), "");
13 ✔
285
  if (Util::isLinkedFolder(file)) {
13 ✔
286
    // Unlink it here, never through a recursive rm: the link goes, what it
287
    // points to stays. Git then only has to forget it, if it knew it.
288
    if (!Util::removeTree(file)) {
7 ✔
289
      emit critical(tr("Delete failed"),
2 ✔
290
                    tr("Could not remove the link %1.").arg(file));
2 ✔
291
      return;
1 ✔
292
    }
293
    // Only when git knew it: a commit whose pathspec matches nothing (a link
294
    // synced or planted, never committed) exits 1, and that would be
295
    // reported as the removal failing. ls-files reads the index, so it still
296
    // answers now that the link is gone.
297
    if (gitReady() && gitTracks(file)) {
6 ✔
298
      executeGit(GIT_RM, {"rm", "-q", "--cached", "--", pgit(file)});
7 ✔
299
      gitCommit(file, "Remove for " + path + " using QtPass.");
2 ✔
300
    }
301
    return;
6 ✔
302
  }
303
  if (gitReady()) {
6 ✔
304
    executeGit(GIT_RM, {"rm", (isDir ? "-rf" : "-f"), "--", pgit(file)});
6 ✔
305
    gitCommit(file, "Remove for " + path + " using QtPass.");
2 ✔
306
  } else {
307
    if (isDir) {
5 ✔
308
      // Never QDir::removeRecursively(): it follows a junction inside the
309
      // folder and empties whatever that points to.
310
      Util::removeTree(file);
2 ✔
311
    } else {
312
      QFile(file).remove();
3 ✔
313
    }
314
  }
315
}
3 ✔
316

317
/**
318
 * @brief The `.gpg-id` signer for the configured gpg and signing keys.
319
 */
320
auto ImitatePass::gpgIdSigner() -> GpgIdSigner {
175 ✔
321
  return {m_settings.gpgExecutable,
322
          GpgIdSigner::keysFromSetting(m_settings.passSigningKey),
350 ✔
323
          [this](const QString &app, const QStringList &args,
324
                 const QString &input, QString *out, QString *err) {
325
            return execBlocking(app, args, input, out, err);
80 ✔
326
          }};
525 ✔
327
}
328

329
/**
330
 * @brief Writes the selected users' GPG key IDs to a .gpg-id file.
331
 * @details Composes one key ID per enabled user (with the generation and
332
 * folder header for a signed store), writes the list through a staged
333
 * sibling and a rename (Util::writeFileReplacing), hands the bytes back for
334
 * the signature, and warns if none of the selected users has a secret key
335
 * available.
336
 * @param gpgIdFile Path to the .gpg-id file to be written.
337
 * @param users List of users to evaluate and write to the file.
338
 * @param written Receives the exact bytes written, if not null.
339
 * @return true when the file was written; false after reporting through
340
 * critical().
341
 */
342
auto ImitatePass::writeGpgIdFile(const QString &gpgIdFile,
28 ✔
343
                                 const QList<UserInfo> &users,
344
                                 QByteArray *written) -> bool {
345
  QByteArray contents;
28 ✔
346
  bool secret_selected = false;
347
  for (const UserInfo &user : users) {
59 ✔
348
    if (user.enabled) {
31 ✔
349
      contents += (user.key_id + "\n").toUtf8();
31 ✔
350
      secret_selected |= user.have_secret;
31 ✔
351
    }
352
  }
353
  // With a signing key: reserve one generation above whatever this device
354
  // has accepted and whatever the list on disk says, if that list verifies
355
  // (an unverified number is not taken), recorded before the file is
356
  // written, and bind the list to its folder. The signature will cover both
357
  // lines; a later, older or relocated signed list cannot come back, and a
358
  // list that could not be recorded is not written at all (it would let the
359
  // previous one back in). Without a signing key nothing checks freshness,
360
  // and the plain list stays readable for every client (GpgIdGeneration).
361
  const GpgIdSigner signer = gpgIdSigner();
28 ✔
362
  if (signer.enabled()) {
28 ✔
363
    const std::optional<QString> folder =
364
        GpgIdGeneration::folderOf(gpgIdFile, m_settings.passStore);
18 ✔
365
    if (!folder) {
18 ✔
366
      emit critical(tr("Cannot update"),
3 ✔
367
                    tr("%1 is not inside the password store.").arg(gpgIdFile));
2 ✔
368
      return false;
1 ✔
369
    }
370
    // The list on disk counts only if it is this folder's own, verified
371
    // list: a signature vouches for the bytes, the folder line for the place.
372
    std::optional<qint64> verifiedOnDisk;
17 ✔
373
    QByteArray current;
17 ✔
374
    if (signer.verifyFile(gpgIdFile, &current)) {
17 ✔
375
      const auto header = GpgIdGeneration::parse(current);
14 ✔
376
      if (header && (!header->folder || *header->folder == *folder)) {
14 ✔
377
        verifiedOnDisk = header->generation;
13 ✔
378
      }
379
    }
380
    QString why;
17 ✔
381
    const std::optional<qint64> generation =
382
        GpgIdGeneration::reserveNext(gpgIdFile, verifiedOnDisk, &why);
17 ✔
383
    if (!generation) {
17 ✔
384
      emit critical(tr("Cannot update"), why);
×
385
      return false;
386
    }
387
    contents = GpgIdGeneration::withHeader(*generation, *folder, contents);
34 ✔
388
  }
389
  // Whole or not at all, owner-only (the list names the keys the store is
390
  // encrypted to), and never by opening the name: a link planted under it
391
  // since Init's check is replaced as an entry, not written through
392
  // (Util::writeFileReplacing).
393
  QString writeError;
27 ✔
394
  if (!Util::writeFileReplacing(gpgIdFile, contents, true, &writeError)) {
27 ✔
395
    emit critical(tr("Cannot update"), writeError);
1 ✔
396
    return false;
1 ✔
397
  }
398
  if (written != nullptr) {
26 ✔
399
    *written = contents;
22 ✔
400
  }
401
  if (signer.enabled()) {
26 ✔
402
    // The bytes on disk are the ones this device recognises from now on;
403
    // another list of the same generation is a conflict. The list is
404
    // written and goes on to be signed either way (an unsigned list would
405
    // be refused everywhere); when the record could not take the bytes,
406
    // because another writer reserved the next number in between or the
407
    // record was busy, the user hears so: the list on disk will read as
408
    // older than the record, and saving once more is the way through.
409
    QString why;
17 ✔
410
    if (!GpgIdGeneration::recordWritten(gpgIdFile, contents, &why)) {
17 ✔
411
      qCWarning(lcQtPass) << "Could not record the written .gpg-id:" << why;
×
412
      emit critical(
×
413
          tr("Recipient list written, but not recorded"),
×
414
          tr("%1 Save the recipients once more to get through.").arg(why));
×
415
    }
416
  }
417
  if (!secret_selected) {
26 ✔
418
    emit critical(
6 ✔
419
        tr("Check selected users!"),
3 ✔
420
        tr("None of the selected keys have a secret key available.\n"
3 ✔
421
           "You will not be able to decrypt any newly added passwords!"));
422
  }
423
  return true;
424
}
28 ✔
425

426
/**
427
 * @brief Signs a `.gpg-id` with the configured key and verifies the result.
428
 * @param gpgIdFile Path to the .gpg-id file to be signed.
429
 * @param contents The bytes just written as that file; the signature is made
430
 * over these.
431
 * @return true if the file was signed and its signature verified; otherwise
432
 * false, after reporting the failure through critical().
433
 */
434
auto ImitatePass::signGpgIdFile(const QString &gpgIdFile,
17 ✔
435
                                const QByteArray &contents) -> bool {
436
  const GpgIdSigner signer = gpgIdSigner();
17 ✔
437
  QString why;
17 ✔
438
  if (!signer.sign(gpgIdFile, contents, &why)) {
17 ✔
439
    emit critical(
4 ✔
440
        tr("GPG signing failed!"),
2 ✔
441
        why.trimmed().isEmpty()
2 ✔
442
            ? tr("Failed to sign %1.").arg(gpgIdFile)
6 ✔
443
            : tr("Failed to sign %1: %2").arg(gpgIdFile, why.trimmed()));
4 ✔
444
    return false;
2 ✔
445
  }
446
  QByteArray signedBytes;
15 ✔
447
  if (!signer.verifyFile(gpgIdFile, &signedBytes)) {
15 ✔
448
    emit critical(tr("Check .gpg-id file signature!"),
3 ✔
449
                  tr("Signature for %1 is invalid.").arg(gpgIdFile));
2 ✔
450
    return false;
1 ✔
451
  }
452
  return true;
453
}
17 ✔
454

455
/**
456
 * @brief Commit a `.gpg-id` together with its signature.
457
 *
458
 * Git runs synchronously here on purpose: Init follows up with reencryptPath,
459
 * whose backup and re-encryption commits are blocking as well. Queuing the
460
 * add/commit on the asynchronous executor instead let the two race for the
461
 * index lock, so either the queued `git add` died on `index.lock` (and the
462
 * cancelled commit left the .gpg-id untracked) or the backup commit absorbed
463
 * the file first and `git commit -- .gpg-id` failed with nothing to commit.
464
 *
465
 * Both files go into one commit: two commits left a moment (and, when the
466
 * second failed, a history) in which the repository held a new recipient
467
 * list with the old signature, which every clone then refused.
468
 */
469
auto ImitatePass::gitAddGpgId(const QString &gpgIdFile,
4 ✔
470
                              const QString &gpgIdSigFile, QString *out,
471
                              QString *err) -> int {
472
  const QString git = m_settings.gitExecutable;
473
  const QString store = pgit(m_settings.passStore);
4 ✔
474
  auto run = [&](const QStringList &args) -> int {
12 ✔
475
    QString runOut;
12 ✔
476
    QString runErr;
12 ✔
477
    const int rc = Executor::executeBlocking(
12 ✔
478
        git, QStringList{"-C", store} + args, &runOut, &runErr);
48 ✔
479
    if (out != nullptr) {
12 ✔
480
      out->append(runOut);
12 ✔
481
    }
482
    if (err != nullptr) {
12 ✔
483
      err->append(runErr);
12 ✔
484
    }
485
    return rc;
12 ✔
486
  };
12 ✔
487
  QStringList paths{pgit(gpgIdFile)};
8 ✔
488
  if (!gpgIdSigFile.isEmpty()) {
4 ✔
489
    paths << pgit(gpgIdSigFile);
6 ✔
490
  }
491
  // add stages new and modified files alike; already-clean ones are a no-op.
492
  int rc = run(QStringList{"add", "--"} + paths);
20 ✔
493
  if (rc != 0) {
4 ✔
494
    return rc;
495
  }
496
  // Re-initialising with the same recipients changes nothing; that is not a
497
  // failure, and `git commit` would make it one. diff --quiet: 0 nothing
498
  // staged, 1 something staged, anything else is git failing.
499
  rc = run(QStringList{"diff", "--cached", "--quiet", "--"} + paths);
28 ✔
500
  if (rc == 0) {
4 ✔
501
    return 0;
502
  }
503
  if (rc != 1) {
4 ✔
504
    return rc;
505
  }
506
  QString commitPath = gpgIdFile;
507
  commitPath.replace(Util::endsWithGpg(), "");
4 ✔
508
  return run(QStringList{"commit", "-m",
28 ✔
509
                         "Added " + commitPath + " using QtPass.", "--"} +
24 ✔
510
             paths);
511
}
20 ✔
512

513
/**
514
 * @brief Checks whether git already tracks a file in the password store.
515
 *
516
 * @param const QString &file - Absolute path of the file inside the store.
517
 * @return bool - true when the file is in the index, false when it is
518
 * untracked or the lookup failed.
519
 */
520
auto ImitatePass::gitTracks(const QString &file) -> bool {
3 ✔
521
  return Executor::executeBlocking(m_settings.gitExecutable,
24 ✔
522
                                   {"-C", pgit(m_settings.passStore),
3 ✔
523
                                    "ls-files", "--error-unmatch", "--",
524
                                    pgit(file)}) == 0;
6 ✔
525
}
3 ✔
526

527
/**
528
 * @brief Initializes the pass entry by writing and optionally signing the GPG
529
 * ID files.
530
 *
531
 * @example
532
 * void result = ImitatePass::Init(path, users);
533
 *
534
 * @param QString path - Base path for the pass entry where ".gpg-id" and
535
 * optional signature files are created.
536
 * @param const QList<UserInfo> &users - List of users whose keys are written
537
 * into the GPG ID file.
538
 * @return void - No return value.
539
 */
540
void ImitatePass::Init(QString path, const QList<UserInfo> &users) {
26 ✔
541
  // The .gpg-id is written as path + ".gpg-id": without the trailing
542
  // separator (the context menu hands over a cleaned path) that would be a
543
  // file beside the folder, not the folder's own list.
544
  path = Util::normalizeFolderPath(path);
26 ✔
545
  // A linked folder, or a link planted under the .gpg-id or .gpg-id.sig
546
  // name, is not this store's: refused up front, and the writes below go
547
  // through staged files and renames so that one planted afterwards is
548
  // replaced as an entry, not written through.
549
  const QString folder = QDir::cleanPath(path);
26 ✔
550
  if (refuseLinkedPath(path) ||
51 ✔
551
      refuseLinkedPath(folder + QStringLiteral("/.gpg-id")) ||
77 ✔
552
      refuseLinkedPath(folder + QStringLiteral("/.gpg-id.sig"))) {
74 ✔
553
    return;
554
  }
555
  const GpgIdSigner signer = gpgIdSigner();
24 ✔
556
  const QString gpgIdSigFile = path + ".gpg-id.sig";
24 ✔
557
  if (signer.enabled() && !signer.haveSecretKey()) {
24 ✔
558
    emit critical(tr("No signing key!"),
2 ✔
559
                  tr("None of the secret signing keys is available.\n"
×
560
                     "You will not be able to change the user list!"));
561
    return;
1 ✔
562
  }
563

564
  const bool useGit = gitReady();
23 ✔
565
  const QString gpgIdFile = path + ".gpg-id";
23 ✔
566
  QByteArray written;
23 ✔
567
  if (!writeGpgIdFile(gpgIdFile, users, &written)) {
23 ✔
568
    return;
569
  }
570

571
  QString sigToCommit;
22 ✔
572
  if (signer.enabled()) {
22 ✔
573
    if (!signGpgIdFile(gpgIdFile, written)) {
17 ✔
574
      return;
575
    }
576
    sigToCommit = gpgIdSigFile;
14 ✔
577
  } else if (QFile::exists(gpgIdSigFile)) {
5 ✔
578
    // Signing was switched off: a signature of the previous list must not
579
    // stay behind, where pass and other clients would reject the new list
580
    // under it. Its removal goes into the same commit.
581
    const bool tracked = useGit && gitTracks(gpgIdSigFile);
3 ✔
582
    if (!QFile::remove(gpgIdSigFile)) {
3 ✔
583
      emit critical(
1 ✔
584
          tr("Cannot update"),
1 ✔
585
          tr("Failed to remove the old signature %1.").arg(gpgIdSigFile));
2 ✔
586
      return;
1 ✔
587
    }
588
    if (tracked) {
2 ✔
589
      sigToCommit = gpgIdSigFile;
1 ✔
590
    }
591
  }
592

593
  if (useGit && m_settings.addGPGId) {
18 ✔
594
    // The .gpg-id (and its signature) must be in the repository before any
595
    // entry is re-encrypted to it: the backup commit before re-encryption
596
    // only picks up tracked files (#1685), and MainWindow::addFolder writes
597
    // a folder's .gpg-id without staging it, so without this the
598
    // re-encrypted entries were pushed without the recipients file they
599
    // were encrypted to (#1682). When the commit fails the re-encryption
600
    // does not start, or the working tree would follow a recipient list the
601
    // repository does not have.
602
    QString gitOut;
4 ✔
603
    QString gitErr;
4 ✔
604
    const int gitExit = gitAddGpgId(gpgIdFile, sigToCommit, &gitOut, &gitErr);
4 ✔
605
    if (gitExit != 0) {
4 ✔
606
      Pass::finished(PASS_INIT, gitExit, gitOut, gitErr);
1 ✔
607
      return;
608
    }
609
    reencryptPath(path);
3 ✔
610
    // Same contract the asynchronous add/commit transaction used to provide:
611
    // finishedInit once the .gpg-id landed in git.
612
    Pass::finished(PASS_INIT, 0, gitOut, gitErr);
3 ✔
613
    return;
614
  }
615
  reencryptPath(path);
14 ✔
616
  if (useGit) {
14 ✔
617
    Pass::finished(PASS_INIT, 0, QString(), QString());
2 ✔
618
  }
619
}
24 ✔
620

621
auto ImitatePass::loadVerifiedRecipients(const QString &gpgIdFile,
106 ✔
622
                                         QStringList *recipients, QString *why)
623
    -> bool {
624
  recipients->clear();
106 ✔
625
  if (why != nullptr) {
106 ✔
626
    *why = tr("Signature for %1 is invalid.").arg(gpgIdFile);
210 ✔
627
  }
628
  // A link under the .gpg-id name is not the store's list: with signing
629
  // off it reads as missing, with signing on verifyFile() refuses it.
630
  if (Util::isLinkedFolder(gpgIdFile)) {
106 ✔
631
    return !gpgIdSigner().enabled();
1 ✔
632
  }
633
  QByteArray contents;
105 ✔
634
  const GpgIdSigner signer = gpgIdSigner();
105 ✔
635
  if (signer.enabled()) {
105 ✔
636
    if (!signer.verifyFile(gpgIdFile, &contents)) {
14 ✔
637
      return false;
5 ✔
638
    }
639
    // Authentic and unmodified is not the same as current: an older signed
640
    // list put back into the store is refused once this device has seen a
641
    // newer one.
642
    QString reason;
13 ✔
643
    if (GpgIdGeneration::accept(gpgIdFile, contents, m_settings.passStore,
13 ✔
644
                                &reason) !=
645
        GpgIdGeneration::Verdict::Accepted) {
646
      if (why != nullptr) {
4 ✔
647
        *why = reason;
4 ✔
648
      }
649
      return false;
650
    }
651
  } else {
652
    QFile file(gpgIdFile);
91 ✔
653
    if (!file.open(QIODevice::ReadOnly)) {
91 ✔
654
      // No file is not a bad signature; the empty list says it all.
655
      return true;
656
    }
657
    contents = file.readAll();
89 ✔
658
  }
91 ✔
659
  *recipients = Pass::parseRecipients(contents, gpgIdFile);
98 ✔
660
  return true;
98 ✔
661
}
105 ✔
662

663
auto ImitatePass::recoverReencryptLeftovers(const QString &dir) -> bool {
65 ✔
664
  // What a crashed run can have left in the store, and what each means
665
  // (a temporary modified within the hour is left alone: see below).
666
  // Today's writers stage every file as .qtpass-XXXXXX.tmp next to its
667
  // destination and rename it into place in one step; QtPass 1.8.x wrote
668
  // X.gpg.reencrypt.tmp and replaced the entry through X.gpg.reencrypt.bak
669
  // in two renames, and builds between 1.8.x and 2.0 wrote X.gpg.XXXXXX.tmp.
670
  //   .qtpass-XXXXXX.tmp, X.gpg.reencrypt.tmp, X.gpg.XXXXXX.tmp
671
  //                      a file that was being written or never verified;
672
  //                      it is never a source of truth: delete.
673
  //   X.gpg.reencrypt.bak with no X.gpg
674
  //                      a 1.8.x crash between its two renames; the backup
675
  //                      is the only copy of the entry: put it back.
676
  //   X.gpg.reencrypt.bak next to an X.gpg
677
  //                      that run finished but the backup could not be
678
  //                      removed, or X.gpg was recreated since; both are
679
  //                      valid ciphertexts and it is not for QtPass to pick
680
  //                      one: report and leave both.
681
  bool clean = true;
682
  // Only regular files are walked and listed. A symlink, junction or special
683
  // file under a leftover's name is handed back separately: QtPass never
684
  // made one, and renaming a link into an entry's place would make the run
685
  // decrypt and re-encrypt whatever it points to, inside the store or not.
686
  // Linked directories are handed back too; they are not leftovers and are
687
  // left to reencryptFiles() to mention.
688
  const QStringList leftoverNames{QStringLiteral(".qtpass-??????.tmp"),
130 ✔
689
                                  QStringLiteral("*.gpg.reencrypt.tmp"),
65 ✔
690
                                  QStringLiteral("*.gpg.??????.tmp"),
65 ✔
691
                                  QStringLiteral("*.gpg.reencrypt.bak")};
325 ✔
692
  QStringList skipped;
65 ✔
693
  // Hidden files included: the staged name starts with a dot.
694
  const QStringList leftovers = Util::regularFilesUnder(
695
      QDir::cleanPath(dir), leftoverNames, &skipped, true);
130 ✔
696
  for (const QString &path : skipped) {
70 ✔
697
    if (!QDir::match(leftoverNames, QFileInfo(path).fileName())) {
10 ✔
698
      continue;
1 ✔
699
    }
700
    if (path.endsWith(QStringLiteral(".tmp"))) {
8 ✔
701
      // Removing a link removes the link, never what it points to; a
702
      // junction or a directory symlink on Windows is a directory entry and
703
      // goes with rmdir.
704
      if (!QFile::remove(path) && !QDir().rmdir(path)) {
2 ✔
705
        qCWarning(lcQtPass) << "Could not remove stale temporary" << path;
×
706
      }
707
      continue;
2 ✔
708
    }
2 ✔
709
    emit critical(tr("Leftover from an earlier re-encryption"),
6 ✔
710
                  tr("%1 is not a regular file and was not restored. Look "
2 ✔
711
                     "at it and remove it, then re-encrypt again.")
712
                      .arg(path));
4 ✔
713
    clean = false;
714
  }
715
  // A temporary younger than this is taken for a write in progress: another
716
  // QtPass on a shared store stages its files under the same names, and
717
  // removing one from under it would fail that write for nothing. A crash
718
  // leaves its temporary behind for good; the next run after an hour picks
719
  // it up.
720
  const QDateTime inFlightSince = QDateTime::currentDateTime().addSecs(-3600);
65 ✔
721
  for (const QString &path : leftovers) {
71 ✔
722
    if (path.endsWith(QStringLiteral(".tmp"))) {
12 ✔
723
      if (QFileInfo(path).lastModified() > inFlightSince) {
8 ✔
724
        qCDebug(lcQtPass) << "Leaving a recent temporary alone:" << path;
1 ✔
725
        continue;
5 ✔
726
      }
1 ✔
727
      if (!QFile::remove(path)) {
3 ✔
728
        qCWarning(lcQtPass) << "Could not remove stale temporary" << path;
×
729
      }
730
      continue;
3 ✔
731
    }
3 ✔
732
    // Belt and braces: the walker only lists regular files.
733
    const QFileInfo backup(path);
2 ✔
734
    if (backup.isSymLink() || !backup.isFile()) {
2 ✔
735
      emit critical(tr("Leftover from an earlier re-encryption"),
×
736
                    tr("%1 is not a regular file and was not restored. Look "
×
737
                       "at it and remove it, then re-encrypt again.")
738
                        .arg(path));
×
739
      clean = false;
740
      continue;
×
741
    }
742
    const QString original =
743
        path.chopped(QStringLiteral(".reencrypt.bak").size());
2 ✔
744
    if (QFileInfo::exists(original)) {
2 ✔
745
      emit critical(tr("Leftover from an earlier re-encryption"),
3 ✔
746
                    tr("%1 exists next to %2. Both are encrypted copies of the "
1 ✔
747
                       "entry; check which one you want and delete the other, "
748
                       "then re-encrypt again.")
749
                        .arg(path, original));
2 ✔
750
      clean = false;
751
      continue;
752
    }
753
    if (QFile::rename(path, original)) {
1 ✔
754
      qCWarning(lcQtPass) << "Restored" << original << "from" << path;
2 ✔
755
      emit statusMsg(tr("Restored %1 from the backup an interrupted "
2 ✔
756
                        "re-encryption left behind.")
757
                         .arg(original),
2 ✔
758
                     5000);
759
    } else {
760
      emit critical(tr("Leftover from an earlier re-encryption"),
×
761
                    tr("%1 is missing and its backup %2 could not be renamed "
×
762
                       "back. Rename it by hand, then re-encrypt again.")
763
                        .arg(original, path));
×
764
      clean = false;
765
    }
766
  }
2 ✔
767
  return clean;
65 ✔
768
}
65 ✔
769

770
/**
771
 * @brief ImitatePass::reencryptPath reencrypt all files under the chosen
772
 * directory
773
 *
774
 * This is still quite experimental..
775
 * @param dir
776
 */
777
auto ImitatePass::verifyGpgIdForDir(const QString &file,
46 ✔
778
                                    QHash<QString, QStringList> &verified,
779
                                    QStringList &gpgId) -> bool {
780
  const QString gpgIdPath = Pass::getGpgIdPath(file, m_settings.passStore);
46 ✔
781
  // The cache maps each .gpg-id to the recipients its verified bytes held,
782
  // so every file under it is encrypted to exactly the list the signature
783
  // covered; a second directory sharing the .gpg-id gets the same list, a
784
  // different .gpg-id is read and verified on its own.
785
  const auto cached = verified.constFind(gpgIdPath);
46 ✔
786
  if (cached != verified.constEnd()) {
787
    gpgId = cached.value();
788
    return true;
1 ✔
789
  }
790
  QStringList recipients;
45 ✔
791
  QString why;
45 ✔
792
  if (!loadVerifiedRecipients(gpgIdPath, &recipients, &why)) {
45 ✔
793
    // An interrupted gpg is a cancel, not a bad signature.
794
    if (!m_reencryptCancel.load())
1 ✔
795
      emit critical(tr("Check .gpg-id file signature!"), why);
2 ✔
796
    return false;
1 ✔
797
  }
798
  recipients.sort();
799
  verified.insert(gpgIdPath, recipients);
800
  gpgId = recipients;
801
  return true;
44 ✔
802
}
803

804
/**
805
 * @brief Extracts and returns a sorted list of valid key IDs from a GPG key
806
 * listing file.
807
 * @example
808
 * QStringList result = ImitatePass::getKeysFromFile(fileName);
809
 * std::cout << result.join(", ").toStdString() << std::endl;
810
 *
811
 * @param fileName - Path to the file used to query and parse GPG key
812
 * information.
813
 * @return QStringList - A sorted list of 16-character key IDs found in the
814
 * file.
815
 */
816
auto ImitatePass::getKeysFromFile(const QString &fileName) -> QStringList {
72 ✔
817
  QStringList args = {
818
      "-v",          "--no-secmem-warning", "--no-permission-warning",
819
      "--list-only", "--keyid-format=long", "--",
820
      pgpg(fileName)};
576 ✔
821
  QString keys;
72 ✔
822
  QString err;
72 ✔
823
  const int result = execBlocking(m_settings.gpgExecutable, args, &keys, &err);
72 ✔
824
  if (result != 0) {
72 ✔
825
    return {};
29 ✔
826
  }
827
  QStringList actualKeys;
43 ✔
828
  keys += err;
829
  QStringList key = keys.split(Util::newLinesRegex(), Qt::SkipEmptyParts);
43 ✔
830
  QListIterator<QString> itr(key);
831
  while (itr.hasNext()) {
75 ✔
832
    QString current = itr.next();
833
    QStringList cur = current.split(" ");
64 ✔
834
    if (cur.length() > 4) {
32 ✔
835
      QString actualKey = cur.takeAt(4);
22 ✔
836
      if (actualKey.length() == 16) {
22 ✔
837
        actualKeys << actualKey;
838
      }
839
    }
840
  }
841
  actualKeys.sort();
842
  return actualKeys;
843
}
72 ✔
844

845
/**
846
 * @brief Re-encrypts a single encrypted file for a new set of recipients.
847
 * @example
848
 * QString why;
849
 * bool result = ImitatePass::reencryptSingleFile(fileName, recipients, &why);
850
 * std::cout << result << std::endl; // Expected output: true on success, false
851
 * on failure
852
 *
853
 * @param const QString &fileName - Path to the encrypted file to re-encrypt.
854
 * @param const QStringList &recipients - List of recipient keys to encrypt the
855
 * file to.
856
 * @param QString *why - Receives the reason the new ciphertext could not be
857
 * put under the entry's name, for the run's summary; left empty for a gpg
858
 * failure, which is logged.
859
 * @return bool - True if the file was successfully decrypted, re-encrypted,
860
 * verified, and replaced; otherwise false.
861
 */
862
auto ImitatePass::decryptEntry(const QString &fileName, QString *plaintext)
72 ✔
863
    -> bool {
864
  const QStringList args = {
865
      "-d",      "--quiet",     "--yes", "--no-encrypt-to",
866
      "--batch", "--use-agent", "--",    pgpg(fileName)};
648 ✔
867
  if (execBlocking(m_settings.gpgExecutable, args, plaintext) != 0 ||
72 ✔
868
      plaintext->isEmpty()) {
869
    qCDebug(lcQtPass) << "Decrypt error on re-encrypt for:" << fileName;
29 ✔
870
    return false;
29 ✔
871
  }
872
  if (!plaintext->endsWith(u'\n')) {
43 ✔
873
    plaintext->append(u'\n');
1 ✔
874
  }
875
  return true;
876
}
72 ✔
877

878
auto ImitatePass::encryptFor(const QString &output,
43 ✔
879
                             const QStringList &recipients,
880
                             const QString &plaintext) -> bool {
881
  // Same encrypt-only flags as Insert(): gpg.conf must not add recipients.
882
  QStringList args{
883
      "--yes",           "--batch",  "-eq",       "--compress-algo=none",
884
      "--no-encrypt-to", "--output", pgpg(output)};
344 ✔
885
  for (const QString &recipient : recipients) {
86 ✔
886
    args << "-r" << recipient;
86 ✔
887
  }
888
  args << "-";
43 ✔
889
  if (execBlocking(m_settings.gpgExecutable, args, plaintext) != 0) {
43 ✔
890
    qCDebug(lcQtPass) << "Encrypt error on re-encrypt, output:" << output;
3 ✔
891
    return false;
3 ✔
892
  }
893
  return true;
894
}
43 ✔
895

896
auto ImitatePass::ciphertextHolds(const QString &ciphertext,
40 ✔
897
                                  const QString &plaintext) -> bool {
898
  QString decrypted;
40 ✔
899
  const QStringList args{"-d",          "--quiet", "--batch",
900
                         "--use-agent", "--",      pgpg(ciphertext)};
280 ✔
901
  if (execBlocking(m_settings.gpgExecutable, args, &decrypted) != 0 ||
40 ✔
902
      decrypted.isEmpty()) {
903
    qCDebug(lcQtPass) << "Verification failed for:" << ciphertext;
1 ✔
904
    return false;
1 ✔
905
  }
906
  // Defence in depth: gpg said it encrypted, and this is what comes back.
907
  if (decrypted.trimmed() != plaintext.trimmed()) {
39 ✔
908
    qCDebug(lcQtPass) << "Verification content mismatch for:" << ciphertext;
1 ✔
909
    return false;
1 ✔
910
  }
911
  return true;
912
}
40 ✔
913

914
auto ImitatePass::commitReencrypted(const QString &fileName) -> bool {
30 ✔
915
  if (!gitConfigured()) {
30 ✔
916
    return true;
917
  }
918
  // -C the store so git runs there rather than in QtPass's launch directory
919
  // (executeBlocking sets no working directory).
920
  const QString store = pgit(m_settings.passStore);
9 ✔
921
  if (execBlocking(m_settings.gitExecutable,
63 ✔
922
                   {"-C", store, "add", "--", pgit(fileName)}) != 0) {
923
    // The file on disk is re-encrypted correctly; only the repository is
924
    // now behind. Report it so the caller counts this file as failed and
925
    // the run is not pushed.
926
    qCDebug(lcQtPass) << "git add failed after re-encrypting:" << fileName;
1 ✔
927
    return false;
1 ✔
928
  }
929
  QString path = QDir(m_settings.passStore).relativeFilePath(fileName);
8 ✔
930
  path.replace(Util::endsWithGpg(), "");
16 ✔
931
  if (execBlocking(m_settings.gitExecutable,
80 ✔
932
                   {"-C", store, "commit", "-m",
933
                    "Re-encrypt for " + path + " using QtPass.", "--",
16 ✔
934
                    pgit(fileName)}) != 0) {
935
    qCDebug(lcQtPass) << "git commit failed after re-encrypting:" << fileName;
1 ✔
936
    return false;
1 ✔
937
  }
938
  return true;
939
}
42 ✔
940

941
auto ImitatePass::reencryptSingleFile(const QString &fileName,
72 ✔
942
                                      const QStringList &recipients,
943
                                      QString *why) -> bool {
944
  qCDebug(lcQtPass) << "reencrypt" << fileName << "for" << recipients.size()
72 ✔
NEW
945
                    << "recipients";
×
946
  if (recipients.isEmpty()) {
72 ✔
947
    emit critical(tr("Can not edit"),
×
948
                  tr("Could not read encryption key to use, .gpg-id "
×
949
                     "file missing or invalid."));
950
    return false;
×
951
  }
952
  QString plaintext;
72 ✔
953
  if (!decryptEntry(fileName, &plaintext)) {
72 ✔
954
    return false;
955
  }
956

957
  // gpg writes the new ciphertext outside the store, into a directory of
958
  // QtPass's own (0700, an unguessable name): nothing a co-writer of the
959
  // store can pre-create or swap for a link before gpg opens it by name.
960
  // placeEncryptedFile() then brings the bytes into the store the way
961
  // Insert() does, replacing the entry in one rename.
962
  QTemporaryDir scratch;
43 ✔
963
  if (!scratch.isValid()) {
43 ✔
964
    qCDebug(lcQtPass) << "Cannot create a scratch directory for re-encrypting"
×
965
                      << fileName;
×
966
    return false;
×
967
  }
968
  const QString tempPath = scratch.filePath(QStringLiteral("reencrypted.tmp"));
86 ✔
969
  if (!encryptFor(tempPath, recipients, plaintext) ||
83 ✔
970
      !ciphertextHolds(tempPath, plaintext)) {
40 ✔
971
    return false;
5 ✔
972
  }
973

974
  // Another client may have removed the entry while gpg ran; the rename
975
  // would bring it back under its old name. Best effort: the check and the
976
  // replace are two steps, as pass's own write is.
977
  const QFileInfo still(fileName);
38 ✔
978
  if (!still.exists() && !still.isSymLink()) {
38 ✔
979
    qCDebug(lcQtPass) << "Entry vanished before it could be replaced:"
1 ✔
980
                      << fileName;
×
981
    return false;
1 ✔
982
  }
983
  if (!placeEncryptedFile(tempPath, fileName, true, why)) {
37 ✔
984
    // Reported once, in the run's summary, with the others: a folder that
985
    // cannot be written fails every entry in it the same way. The entry
986
    // keeps its old ciphertext, unless what failed was the check after the
987
    // rename, where the swapped-in object is under the name and said so.
988
    if (why != nullptr && !why->contains(fileName)) {
14 ✔
989
      // Every reason but the copy's names the entry; that one names the
990
      // file gpg wrote, in a scratch directory the user never sees.
991
      *why = tr("%1 could not be re-encrypted: %2").arg(fileName, *why);
2 ✔
992
    }
993
    return false;
7 ✔
994
  }
995
  return commitReencrypted(fileName);
30 ✔
996
}
81 ✔
997

998
/**
999
 * @brief Create git backup commit before re-encryption.
1000
 * @return true if backup created or not needed, false if backup failed.
1001
 */
1002
auto ImitatePass::createBackupCommit() -> bool {
64 ✔
1003
  if (!gitConfigured()) {
64 ✔
1004
    return true;
1005
  }
1006
  emit statusMsg(tr("Creating backup commit"), 2000);
28 ✔
1007
  const QString git = m_settings.gitExecutable;
1008
  // Run git in the password store: executeBlocking does not set a working
1009
  // directory, so without -C these commands would run in QtPass's launch
1010
  // directory and either fail or operate on an unrelated repository.
1011
  const QString store = pgit(m_settings.passStore);
14 ✔
1012
  // Only tracked files belong in the backup. Untracked files in the store (a
1013
  // plaintext export, an editor swap file, ...) must not be swept into a
1014
  // commit that autoPush then sends to the shared remote, so both the status
1015
  // check and the add are restricted to what git already knows about.
1016
  QString statusOut;
14 ✔
1017
  if (execBlocking(
98 ✔
1018
          git, {"-C", store, "status", "--porcelain", "--untracked-files=no"},
1019
          &statusOut) != 0) {
1020
    // An interrupted git is a cancel, not a failure worth a dialog.
1021
    if (!m_reencryptCancel.load())
1 ✔
1022
      emit critical(
1 ✔
1023
          tr("Backup commit failed"),
1 ✔
1024
          tr("Could not inspect git status. Re-encryption was aborted."));
1 ✔
1025
    return false;
1 ✔
1026
  }
1027
  if (!statusOut.trimmed().isEmpty()) {
13 ✔
1028
    if (execBlocking(git, {"-C", store, "add", "-u"}) != 0 ||
14 ✔
1029
        execBlocking(git, {"-C", store, "commit", "-m",
18 ✔
1030
                           "Backup before re-encryption"}) != 0) {
1031
      if (!m_reencryptCancel.load())
1 ✔
1032
        emit critical(tr("Backup commit failed"),
2 ✔
1033
                      tr("Re-encryption was aborted because a git backup "
1 ✔
1034
                         "could not be created."));
1035
      return false;
1 ✔
1036
    }
1037
  }
1038
  return true;
1039
}
36 ✔
1040

1041
/**
1042
 * @brief Outcome of one reencryptPath() run.
1043
 */
1044
struct ImitatePass::ReencryptResult {
132 ✔
1045
  int total = 0;          ///< `.gpg` files found under the directory.
1046
  int checked = 0;        ///< Files whose recipients were inspected.
1047
  int reencrypted = 0;    ///< Files rewritten for the current recipients.
1048
  QStringList failed;     ///< One line per file that could not be
1049
                          ///< re-encrypted: its path, or the reason (which
1050
                          ///< names the path) when there is one to give.
1051
  bool cancelled = false; ///< Stopped early by cancelReencryptPath(); the
1052
                          ///< interrupted file, if any, is not in `failed`.
1053
  bool aborted = false;   ///< Stopped early on an error already reported.
1054
};
1055

1056
namespace {
1057
/// Poll interval while waiting for queued git commands to drain.
1058
constexpr int kReencryptRetryMs = 100;
1059
/// Cap on the file names listed in the aggregated failure dialog.
1060
constexpr int kReencryptMaxListedFailures = 15;
1061
} // namespace
1062

1063
/**
1064
 * @brief Re-encrypts all `.gpg` files under the given directory using the
1065
 *        verified GPG key configuration for each folder.
1066
 *
1067
 * Emits startReencryptPath() and hands the actual work to a worker thread
1068
 * (see reencryptFiles()), so the GUI stays responsive and the run can be
1069
 * cancelled. The worker optionally pulls first, creates a backup commit,
1070
 * verifies `.gpg-id` files per directory and re-encrypts files whose current
1071
 * recipients do not match the expected keys, reporting progress through
1072
 * reencryptProgress(). Per-file failures are aggregated into a single
1073
 * critical() by finishReencrypt(), which also pushes when configured and
1074
 * emits endReencryptPath().
1075
 *
1076
 * @param dir - Root directory to scan recursively for `.gpg` files.
1077
 * @return void
1078
 */
1079
void ImitatePass::reencryptPath(const QString &dir) {
71 ✔
1080
  if (m_reencryptActive) {
71 ✔
1081
    emit statusMsg(tr("A re-encryption is already running"), 3000);
1 ✔
1082
    return;
1 ✔
1083
  }
1084
  // The walk follows its starting point (the store root is allowed to be a
1085
  // link); a folder inside the store that is, or lies behind, a link is not
1086
  // part of it. MainWindow refuses such a pick already; this holds for every
1087
  // caller, Init() included.
1088
  if (Util::isUnderLink(dir, m_settings.passStore)) {
70 ✔
1089
    emit critical(tr("Not a folder of the store"),
6 ✔
1090
                  tr("%1 is, or lies behind, a symbolic link or junction. What "
2 ✔
1091
                     "that points to is not part of the password store and "
1092
                     "was not re-encrypted.")
1093
                      .arg(QDir::toNativeSeparators(QDir::cleanPath(dir))));
4 ✔
1094
    emit endReencryptPath();
2 ✔
1095
    return;
2 ✔
1096
  }
1097
  m_reencryptActive = true;
68 ✔
1098
  m_reencryptCancel.store(false);
1099
  emit statusMsg(tr("Re-encrypting from folder %1").arg(dir), 3000);
136 ✔
1100
  emit startReencryptPath();
68 ✔
1101
  startReencryptWorker(dir);
68 ✔
1102
}
1103

1104
/**
1105
 * @brief Stop a running re-encryption promptly.
1106
 *
1107
 * Only sets the cancel flag. The worker starts no further process once it is
1108
 * set, and the process it is blocked on is ended by the worker itself: the
1109
 * cancellable Executor::executeBlocking() polls the flag and, on seeing it,
1110
 * terminate()s the child and kill()s it if it is still running after the
1111
 * grace period (terminate() is only a request: SIGTERM, or WM_CLOSE on
1112
 * Windows, which a console gpg ignores). Nothing here touches the worker's
1113
 * QProcess or its pid. A new run clears the flag.
1114
 */
1115
void ImitatePass::cancelReencryptPath() {
5 ✔
1116
  if (!m_reencryptActive)
5 ✔
1117
    return;
1118
  m_reencryptCancel.store(true);
1119
}
1120

1121
/**
1122
 * @brief Start the re-encryption worker once the Executor queue is idle.
1123
 *
1124
 * Callers such as Init(), Move() and Copy() queue git commands on `exec`
1125
 * right before calling reencryptPath(). Those run asynchronously on this
1126
 * thread, so the worker's blocking git calls would otherwise compete with
1127
 * them for the repository's index lock. Poll until the queue has drained.
1128
 */
1129
void ImitatePass::startReencryptWorker(const QString &dir) {
69 ✔
1130
  if (m_reencryptCancel.load()) {
69 ✔
1131
    ReencryptResult result;
1 ✔
1132
    result.cancelled = true;
1 ✔
1133
    finishReencrypt(result);
1 ✔
1134
    return;
1135
  }
1136
  if (!exec.isIdle()) {
1137
    QTimer::singleShot(kReencryptRetryMs, this,
2 ✔
1138
                       [this, dir]() { startReencryptWorker(dir); });
5 ✔
1139
    return;
2 ✔
1140
  }
1141

1142
  // The worker calls member functions, so `this` must outlive it: the
1143
  // destructor cancels and joins m_reencryptThread. `self` only guards the
1144
  // queued completion, which may run after the thread object is gone.
1145
  QPointer<ImitatePass> self(this);
1146
  QThread *thread = QThread::create([this, self, dir]() {
132 ✔
1147
    ReencryptResult result = reencryptFiles(dir);
66 ✔
1148
    QMetaObject::invokeMethod(
66 ✔
1149
        self,
1150
        [self, result = std::move(result)]() {
260 ✔
1151
          if (self)
62 ✔
1152
            self->finishReencrypt(result);
62 ✔
1153
        },
62 ✔
1154
        Qt::QueuedConnection);
1155
  });
66 ✔
1156
  m_reencryptThread = thread;
66 ✔
1157
  connect(thread, &QThread::finished, this, [this, thread]() {
66 ✔
1158
    if (m_reencryptThread == thread)
62 ✔
1159
      m_reencryptThread = nullptr;
62 ✔
1160
  });
1161
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
66 ✔
1162
  thread->start();
66 ✔
1163
}
1164

1165
/**
1166
 * @brief Worker-thread body of reencryptPath().
1167
 *
1168
 * Only blocking helpers (execBlocking() directly and through
1169
 * createBackupCommit(), verifyGpgIdForDir(), getKeysFromFile() and
1170
 * reencryptSingleFile()) run here; anything that touches `exec` or the
1171
 * transaction state stays on the owning thread. Signals emitted from here
1172
 * (statusMsg, critical, reencryptProgress) are delivered queued to their
1173
 * GUI-thread receivers. The cancel flag is checked between files, and a
1174
 * cancel also ends the process in progress from this thread (see
1175
 * execBlocking()): a helper that fails while the flag is set was
1176
 * interrupted, so its file is neither counted as checked nor reported as
1177
 * failed.
1178
 */
1179
auto ImitatePass::pullBeforeReencrypt() -> bool {
66 ✔
1180
  if (!m_settings.autoPull || !gitConfigured()) {
66 ✔
1181
    return true;
63 ✔
1182
  }
1183
  emit statusMsg(tr("Updating password-store"), 2000);
6 ✔
1184
  if (execBlocking(m_settings.gitExecutable,
15 ✔
1185
                   {"-C", pgit(m_settings.passStore), "pull"}) == 0) {
3 ✔
1186
    return true;
1187
  }
1188
  // A pull that could not reach the remote leaves the store as it was; one
1189
  // that stopped in a merge leaves conflict markers and an unmerged index,
1190
  // and re-encrypting on top of that would commit the mess.
1191
  QString unmerged;
2 ✔
1192
  execBlocking(m_settings.gitExecutable,
12 ✔
1193
               {"-C", pgit(m_settings.passStore), "ls-files", "--unmerged"},
1194
               &unmerged);
1195
  if (!unmerged.trimmed().isEmpty()) {
2 ✔
1196
    emit critical(tr("Git pull failed"),
2 ✔
1197
                  tr("The pull left the store with unmerged files. Resolve "
1 ✔
1198
                     "the conflict before re-encrypting."));
1199
    return false;
1 ✔
1200
  }
1201
  emit statusMsg(tr("Git pull failed, re-encrypting the store as it is"), 5000);
1 ✔
1202
  return true;
1 ✔
1203
}
5 ✔
1204

1205
auto ImitatePass::entriesToReencrypt(const QString &dir) -> QStringList {
60 ✔
1206
  // Regular files only: a symlink or junction is not a password entry, and
1207
  // following one would decrypt and rewrite something outside the store.
1208
  QStringList skipped;
60 ✔
1209
  const QStringList files =
1210
      Util::regularFilesUnder(dir, QStringList() << "*.gpg", &skipped);
180 ✔
1211
  if (!skipped.isEmpty()) {
60 ✔
1212
    emit statusMsg(tr("%n entr(y/ies) skipped: a symlink, junction or special "
2 ✔
1213
                      "file is not part of the store.",
1214
                      "", static_cast<int>(skipped.size())),
1215
                   5000);
1216
  }
1217
  return files;
60 ✔
1218
}
1219

1220
auto ImitatePass::recipientsForDir(const QString &fileName,
46 ✔
1221
                                   QHash<QString, QStringList> &verified,
1222
                                   QStringList &gpgId, ReencryptResult &result)
1223
    -> bool {
1224
  if (!verifyGpgIdForDir(fileName, verified, gpgId)) {
46 ✔
1225
    if (m_reencryptCancel.load()) {
1 ✔
NEW
1226
      result.cancelled = true;
×
1227
    } else {
1228
      result.aborted = true;
1 ✔
1229
    }
1230
    return false;
1 ✔
1231
  }
1232
  if (gpgId.isEmpty() && !verified.isEmpty()) {
45 ✔
1233
    emit critical(tr("GPG ID verification failed"),
3 ✔
1234
                  tr("Could not verify .gpg-id for directory."));
1 ✔
1235
    result.aborted = true;
1 ✔
1236
    return false;
1 ✔
1237
  }
1238
  return true;
1239
}
1240

1241
auto ImitatePass::reencryptFiles(const QString &dir) -> ReencryptResult {
66 ✔
1242
  ReencryptResult result;
66 ✔
1243
  if (!pullBeforeReencrypt()) {
66 ✔
1244
    result.aborted = true;
1 ✔
1245
    return result;
1 ✔
1246
  }
1247

1248
  // Leftovers of an interrupted run first: a restored entry then goes into
1249
  // the backup commit like everything else, and a stale temporary does not.
1250
  if (!recoverReencryptLeftovers(dir)) {
65 ✔
1251
    result.aborted = true;
3 ✔
1252
    return result;
3 ✔
1253
  }
1254

1255
  // Create backup before re-encryption - abort if it fails
1256
  if (!createBackupCommit()) {
62 ✔
1257
    if (m_reencryptCancel.load()) {
2 ✔
1258
      result.cancelled = true;
×
1259
    } else {
1260
      result.aborted = true;
2 ✔
1261
    }
1262
    return result;
1263
  }
1264

1265
  const QStringList files = entriesToReencrypt(dir);
60 ✔
1266
  result.total = files.size();
60 ✔
1267
  emit reencryptProgress(0, result.total);
60 ✔
1268

1269
  QString currentDir;
60 ✔
1270
  QHash<QString, QStringList> gpgIdFilesVerified;
60 ✔
1271
  QStringList gpgId;
60 ✔
1272
  for (const QString &fileName : std::as_const(files)) {
126 ✔
1273
    if (m_reencryptCancel.load()) {
74 ✔
1274
      result.cancelled = true;
×
1275
      break;
6 ✔
1276
    }
1277
    const QString fileDir = QFileInfo(fileName).path();
74 ✔
1278
    if (fileDir != currentDir) {
74 ✔
1279
      if (!recipientsForDir(fileName, gpgIdFilesVerified, gpgId, result)) {
46 ✔
1280
        return result;
1281
      }
1282
      currentDir = fileDir;
44 ✔
1283
    }
1284
    if (getKeysFromFile(fileName) != gpgId) {
144 ✔
1285
      QString why;
72 ✔
1286
      if (reencryptSingleFile(fileName, gpgId, &why)) {
72 ✔
1287
        result.reencrypted++;
28 ✔
1288
      } else if (m_reencryptCancel.load()) {
44 ✔
1289
        // Interrupted by the cancel: the file is untouched, not failed.
1290
        result.cancelled = true;
6 ✔
1291
        break;
1292
      } else {
1293
        // The reason names the entry itself when there is one to give.
1294
        result.failed << (why.isEmpty() ? fileName : why);
38 ✔
1295
      }
1296
    }
1297
    result.checked++;
66 ✔
1298
    emit reencryptProgress(result.checked, result.total);
66 ✔
1299
  }
1300
  return result;
1301
}
60 ✔
1302

1303
/**
1304
 * @brief Owning-thread epilogue of reencryptPath().
1305
 *
1306
 * Reports the aggregated failures in one dialog, summarises the run in the
1307
 * status bar, pushes when configured (not after a cancel, an abort or a
1308
 * per-file failure: a partially re-encrypted store must not reach the remote,
1309
 * and the user should inspect the result first) and releases the UI.
1310
 */
1311
void ImitatePass::finishReencrypt(const ReencryptResult &result) {
63 ✔
1312
  if (!result.failed.isEmpty()) {
63 ✔
1313
    QStringList listed = result.failed.mid(0, kReencryptMaxListedFailures);
16 ✔
1314
    const int more = result.failed.size() - listed.size();
16 ✔
1315
    if (more > 0) {
16 ✔
1316
      listed << tr("... and %n more", nullptr, more);
1 ✔
1317
    }
1318
    emit critical(tr("Re-encryption failed"),
48 ✔
1319
                  tr("%n file(s) could not be re-encrypted:", nullptr,
16 ✔
1320
                     result.failed.size()) +
32 ✔
1321
                      "\n\n" + listed.join('\n'));
48 ✔
1322
  }
1323

1324
  if (result.cancelled) {
63 ✔
1325
    emit statusMsg(tr("Re-encryption cancelled: %1 of %2 files checked, "
8 ✔
1326
                      "%3 re-encrypted, %4 failed")
1327
                       .arg(result.checked)
4 ✔
1328
                       .arg(result.total)
4 ✔
1329
                       .arg(result.reencrypted)
4 ✔
1330
                       .arg(result.failed.size()),
8 ✔
1331
                   5000);
1332
  } else if (!result.aborted) {
59 ✔
1333
    if (!result.failed.isEmpty()) {
51 ✔
1334
      emit statusMsg(tr("Re-encryption completed: %1 succeeded, %2 failed")
30 ✔
1335
                         .arg(result.reencrypted)
15 ✔
1336
                         .arg(result.failed.size()),
45 ✔
1337
                     5000);
1338
    } else {
1339
      emit statusMsg(tr("Re-encryption completed: %1 files re-encrypted")
36 ✔
1340
                         .arg(result.reencrypted),
72 ✔
1341
                     3000);
1342
    }
1343
    if (m_settings.autoPush && gitConfigured()) {
51 ✔
1344
      if (result.failed.isEmpty()) {
6 ✔
1345
        emit statusMsg(tr("Updating password-store"), 2000);
3 ✔
1346
        GitPush();
3 ✔
1347
      } else {
1348
        emit statusMsg(tr("Not pushing: %n file(s) failed to re-encrypt",
6 ✔
1349
                          nullptr, result.failed.size()),
1350
                       5000);
1351
      }
1352
    }
1353
  }
1354
  m_reencryptActive = false;
63 ✔
1355
  emit endReencryptPath();
63 ✔
1356
}
63 ✔
1357

1358
/**
1359
 * @brief Resolves the final destination path for moving a file or directory,
1360
 * applying .gpg handling for files.
1361
 * @example
1362
 * QString result = ImitatePass::resolveMoveDestination("/tmp/source.txt",
1363
 * "/backup", false); std::cout << result.toStdString() << std::endl; //
1364
 * Expected output sample: "/backup/source.txt.gpg"
1365
 *
1366
 * @param src - Source path to the file or directory.
1367
 * @param dest - Requested destination path, which may be a file or directory.
1368
 * @param force - When true, allows overwriting an existing destination file.
1369
 * @return QString - Resolved destination path, or an empty QString if the
1370
 * source/destination is invalid or conflicts occur.
1371
 */
1372
auto ImitatePass::resolveMoveDestination(const QString &src,
29 ✔
1373
                                         const QString &dest, bool force)
1374
    -> QString {
1375
  QFileInfo srcFileInfo(src);
29 ✔
1376
  QFileInfo destFileInfo(dest);
29 ✔
1377
  QString destFile;
29 ✔
1378
  QString srcFileBaseName = srcFileInfo.fileName();
29 ✔
1379

1380
  if (srcFileInfo.isFile()) {
29 ✔
1381
    if (destFileInfo.isFile()) {
24 ✔
1382
      if (!force) {
8 ✔
1383
        qCDebug(lcQtPass) << "Destination file already exists";
4 ✔
1384
        return {};
1385
      }
1386
      destFile = dest;
4 ✔
1387
    } else if (destFileInfo.isDir()) {
16 ✔
1388
      destFile = QDir(dest).filePath(srcFileBaseName);
12 ✔
1389
    } else {
1390
      destFile = dest;
10 ✔
1391
    }
1392

1393
    if (destFile.endsWith(".gpg", Qt::CaseInsensitive)) {
40 ✔
1394
      destFile.chop(4);
19 ✔
1395
    }
1396
    destFile.append(".gpg");
20 ✔
1397
  } else if (srcFileInfo.isDir()) {
5 ✔
1398
    if (destFileInfo.isDir()) {
4 ✔
1399
      destFile = QDir(dest).filePath(srcFileBaseName);
2 ✔
1400
    } else if (destFileInfo.isFile()) {
3 ✔
1401
      qCDebug(lcQtPass) << "Destination is a file";
1 ✔
1402
      return {};
1403
    } else {
1404
      destFile = dest;
2 ✔
1405
    }
1406
  } else {
1407
    qCDebug(lcQtPass) << "Source file does not exist";
1 ✔
1408
    return {};
1409
  }
1410
  return destFile;
1411
}
29 ✔
1412

1413
/**
1414
 * @brief Moves a password store item in the Git repository and commits the
1415
 * change.
1416
 * @example
1417
 * void result = className.executeMoveGit(src, destFile, force);
1418
 *
1419
 * @param const QString &src - Source path of the item to move.
1420
 * @param const QString &destFile - Destination path of the item after the move.
1421
 * @param bool force - Whether to force the move using Git's -f option.
1422
 * @return void - This method does not return a value.
1423
 */
1424
void ImitatePass::executeMoveGit(const QString &src, const QString &destFile,
2 ✔
1425
                                 bool force) {
1426
  QStringList args;
2 ✔
1427
  args << "mv";
2 ✔
1428
  if (force) {
2 ✔
1429
    args << "-f";
2 ✔
1430
  }
1431
  args << "--" << pgit(src) << pgit(destFile);
8 ✔
1432
  executeGit(GIT_MOVE, args);
2 ✔
1433

1434
  QString relSrc = QDir(m_settings.passStore).relativeFilePath(src);
2 ✔
1435
  relSrc.replace(Util::endsWithGpg(), "");
2 ✔
1436
  QString relDest = QDir(m_settings.passStore).relativeFilePath(destFile);
2 ✔
1437
  relDest.replace(Util::endsWithGpg(), "");
2 ✔
1438
  QString message = QString("Moved for %1 to %2 using QtPass.");
2 ✔
1439
  message = message.arg(relSrc, relDest);
2 ✔
1440
  gitCommit("", message);
4 ✔
1441
}
2 ✔
1442

1443
/**
1444
 * @brief Moves a password entry from the source path to the destination path.
1445
 * @example
1446
 * ImitatePass::Move(src, dest, true);
1447
 *
1448
 * @param const QString src - The source path or entry name to move.
1449
 * @param const QString dest - The destination path or entry name.
1450
 * @param const bool force - If true, overwrites an existing destination entry
1451
 * when necessary.
1452
 * @return void - This function does not return a value.
1453
 */
1454
void ImitatePass::Move(const QString src, const QString dest,
13 ✔
1455
                       const bool force) {
1456
  if (refuseLinkedPath(src) || refuseLinkedPath(dest)) {
13 ✔
1457
    return;
4 ✔
1458
  }
1459
  TransactionHelper trans(&m_transaction, PASS_MOVE);
11 ✔
1460
  QString destFile = resolveMoveDestination(src, dest, force);
11 ✔
1461
  if (destFile.isEmpty()) {
11 ✔
1462
    return;
1463
  }
1464
  if (refuseLinkedPath(destFile)) {
9 ✔
1465
    return;
1466
  }
1467

1468
  qCDebug(lcQtPass) << "Move Source: " << src;
9 ✔
1469
  qCDebug(lcQtPass) << "Move Destination: " << destFile;
9 ✔
1470

1471
  if (gitReady()) {
9 ✔
1472
    executeMoveGit(src, destFile, force);
2 ✔
1473
  } else {
1474
    QDir qDir;
7 ✔
1475
    if (force) {
7 ✔
1476
      qDir.remove(destFile);
1 ✔
1477
    }
1478
    qDir.rename(src, destFile);
7 ✔
1479
  }
7 ✔
1480
}
1481

1482
/**
1483
 * @brief Copies a file or directory from source to destination, optionally
1484
 * forcing overwrite.
1485
 * @example
1486
 * void result = ImitatePass::Copy(src, dest, force);
1487
 *
1488
 * @param QString src - Source path to copy from.
1489
 * @param QString dest - Destination path to copy to: a new file name, or an
1490
 * existing folder to copy into (like `pass cp`).
1491
 * @param bool force - If true, overwrites the destination when it already
1492
 * exists.
1493
 * @return void - This function does not return a value.
1494
 */
1495
void ImitatePass::Copy(const QString src, const QString dest,
15 ✔
1496
                       const bool force) {
1497
  // QFile::copy reads through a link: the target's bytes would become an
1498
  // entry of the store.
1499
  if (refuseLinkedPath(src) || refuseLinkedPath(dest)) {
15 ✔
1500
    return;
8 ✔
1501
  }
1502
  TransactionHelper trans(&m_transaction, PASS_COPY);
13 ✔
1503
  // Like `pass cp`, dest may be an existing folder (a drag-and-drop copy hands
1504
  // over the folder, not the new file name). Resolve the real target the same
1505
  // way Move does: into the folder, .gpg appended, no clobbering without force.
1506
  QString destFile = resolveMoveDestination(src, dest, force);
13 ✔
1507
  if (destFile.isEmpty()) {
13 ✔
1508
    emit critical(tr("Copy failed"),
4 ✔
1509
                  tr("Could not copy %1 to %2.").arg(src, dest));
2 ✔
1510
    return;
2 ✔
1511
  }
1512
  // dest may have been a folder; the file that ends up written is destFile,
1513
  // and a link planted under that name (dangling ones pass exists()) is not
1514
  // an entry to write.
1515
  if (refuseLinkedPath(destFile)) {
11 ✔
1516
    return;
1517
  }
1518
  QFileInfo destFileInfo(destFile);
10 ✔
1519
  // A folder destination that is the source's own folder resolves to the
1520
  // source itself; with force that would replace the only copy with itself.
1521
  if (QFileInfo(src) == destFileInfo) {
20 ✔
1522
    emit critical(tr("Copy failed"),
2 ✔
1523
                  tr("Could not copy %1 to %2.").arg(src, destFile));
1 ✔
1524
    return;
1 ✔
1525
  }
1526
  // resolveMoveDestination only sees a clash when dest names the file; for a
1527
  // folder destination the resolved <folder>/<entry>.gpg may exist as well.
1528
  if (!force && destFileInfo.exists()) {
9 ✔
1529
    emit critical(tr("Copy failed"),
2 ✔
1530
                  tr("Could not copy %1 to %2.").arg(src, destFile));
1 ✔
1531
    return;
1 ✔
1532
  }
1533
  // git has no "cp" subcommand, so copy on the filesystem in both modes and,
1534
  // when using git, stage the new path afterwards. The copy is synchronous
1535
  // and replaces the destination atomically (Util::copyFileReplacing: the
1536
  // source read as the regular file it is, the bytes staged next to the
1537
  // destination, the rename following nothing), so it exists before the
1538
  // re-encryption below runs and an entry being overwritten with force
1539
  // survives a copy that fails half-way. Without force nothing under the
1540
  // name is replaced, also nothing that appeared since the check above.
1541
  QString why;
8 ✔
1542
  if (!Util::copyFileReplacing(src, destFile, force, &why)) {
8 ✔
1543
    emit critical(tr("Copy failed"),
2 ✔
1544
                  tr("Could not copy %1 to %2.").arg(src, destFile) + "\n" +
2 ✔
1545
                      why);
1546
    return;
1547
  }
1548
  // QFileInfo caches; the comparison above may have looked at a path that did
1549
  // not exist yet, so re-read it before deciding what to re-encrypt.
1550
  destFileInfo.refresh();
7 ✔
1551
  if (gitReady()) {
7 ✔
1552
    executeGit(GIT_COPY, {"add", "--", pgit(destFile)});
5 ✔
1553
    QString message = QString("Copied from %1 to %2 using QtPass.");
1 ✔
1554
    message = message.arg(src, destFile);
1 ✔
1555
    gitCommit("", message);
2 ✔
1556
  }
1557
  // reecrypt all files under the new folder
1558
  if (destFileInfo.isDir()) {
7 ✔
1559
    reencryptPath(destFileInfo.absoluteFilePath());
×
1560
  } else if (destFileInfo.isFile()) {
7 ✔
1561
    reencryptPath(destFileInfo.dir().path());
14 ✔
1562
  }
1563
}
11 ✔
1564

1565
/**
1566
 * @brief ImitatePass::executeGpg easy wrapper for running gpg commands
1567
 * @param args
1568
 */
1569
void ImitatePass::executeGpg(PROCESS id, const QStringList &args, QString input,
94 ✔
1570
                             bool readStdout, bool readStderr) {
1571
  executeWrapper(id, m_settings.gpgExecutable, args, std::move(input),
94 ✔
1572
                 readStdout, readStderr);
1573
}
94 ✔
1574

1575
/**
1576
 * @brief ImitatePass::gitConfigured git is enabled and an executable is set.
1577
 *
1578
 * useGit can be on while gitExecutable is empty (fresh setup, git removed
1579
 * later). Handing that empty executable to the Executor used to wedge the
1580
 * command queue (#1682); the git-only paths must not be taken in that case.
1581
 * @return true when git commands can actually run.
1582
 */
1583
auto ImitatePass::gitConfigured() const -> bool {
103 ✔
1584
  return m_settings.useGit && !m_settings.gitExecutable.isEmpty();
103 ✔
1585
}
1586

1587
/**
1588
 * @brief ImitatePass::gitReady gitConfigured() plus a status message.
1589
 *
1590
 * For the user-facing operations: tells the user once per operation why git
1591
 * was skipped, so the store silently drifting from git does not go unnoticed.
1592
 * @return true when git commands can actually run.
1593
 */
1594
auto ImitatePass::gitReady() -> bool {
115 ✔
1595
  if (m_settings.useGit && m_settings.gitExecutable.isEmpty()) {
115 ✔
1596
    emit statusMsg(tr("Git executable not configured, skipping git"), 3000);
8 ✔
1597
    return false;
8 ✔
1598
  }
1599
  return m_settings.useGit;
1600
}
1601

1602
/**
1603
 * @brief ImitatePass::executeGit easy wrapper for running git commands
1604
 * @param args
1605
 */
1606
void ImitatePass::executeGit(PROCESS id, const QStringList &args, QString input,
23 ✔
1607
                             bool readStdout, bool readStderr) {
1608
  // Callers check gitReady() first and fall back to plain filesystem
1609
  // operations when no git executable is configured. Should an empty
1610
  // executable still get here, the Executor now reports it as an error
1611
  // instead of wedging the queue (#1682).
1612
  executeWrapper(id, m_settings.gitExecutable, args, std::move(input),
23 ✔
1613
                 readStdout, readStderr);
1614
}
23 ✔
1615

1616
/**
1617
 * @brief ImitatePass::finished this function is overloaded to ensure
1618
 *                              identical behaviour to RealPass ie. only PASS_*
1619
 *                              processes are visible inside Pass::finish, so
1620
 *                              that interface-wise it all looks the same
1621
 * @param id
1622
 * @param exitCode
1623
 * @param out
1624
 * @param err
1625
 */
1626
void ImitatePass::finished(int id, int exitCode, const QString &out,
118 ✔
1627
                           const QString &err) {
1628
  qCDebug(lcQtPass) << "Imitate Pass";
118 ✔
1629
  QString error = err;
1630
  if (id == PASS_INSERT && !m_pendingInserts.isEmpty()) {
118 ✔
1631
    // The gpg step of an Insert(): its ciphertext goes into the store now,
1632
    // before the git steps queued behind it run (the executor starts the
1633
    // next item only after this returns). When it cannot, the insert failed
1634
    // like a gpg error would have: the git steps are cancelled below and
1635
    // the reason reaches the interface through the failed-operation path.
1636
    // Nothing is shown from here: a dialog would spin the event loop and
1637
    // let the queued git steps run first. The scratch directory goes with
1638
    // the pending entry.
1639
    const PendingInsert pending = m_pendingInserts.dequeue();
1640
    if (exitCode == 0 && !placeEncryptedFile(pending.output, pending.file,
107 ✔
1641
                                             pending.overwrite, &error)) {
53 ✔
1642
      exitCode = 1;
1643
    }
1644
  }
54 ✔
1645
  PROCESS pid = m_transaction.transactionIsOver(static_cast<PROCESS>(id));
118 ✔
1646
  m_transactionOutput.append(out);
118 ✔
1647

1648
  if (exitCode == 0) {
118 ✔
1649
    if (pid == INVALID) {
100 ✔
1650
      return;
1651
    }
1652
  } else {
1653
    while (pid == INVALID) {
20 ✔
1654
      id = exec.cancelNext();
2 ✔
1655
      if (id == -1) {
2 ✔
1656
        //  this is probably irrecoverable and shall not happen
1657
        qCDebug(lcQtPass) << "No such transaction!";
×
1658
        return;
×
1659
      }
1660
      pid = m_transaction.transactionIsOver(static_cast<PROCESS>(id));
2 ✔
1661
    }
1662
  }
1663
  Pass::finished(pid, exitCode, m_transactionOutput, error);
109 ✔
1664
  m_transactionOutput.clear();
109 ✔
1665
}
1666

1667
auto ImitatePass::placeEncryptedFile(const QString &output, const QString &file,
90 ✔
1668
                                     bool overwrite, QString *error) -> bool {
1669
  if (QFileInfo(output).size() <= 0) {
90 ✔
1670
    // gpg reported success and wrote nothing: not an entry.
1671
    *error = tr("gpg wrote no ciphertext for %1.").arg(file);
4 ✔
1672
    return false;
2 ✔
1673
  }
1674
  // Staged next to the entry, written by the temporary's own handle, then
1675
  // the operating system's rename: the entry under the name is replaced as
1676
  // an entry, a link planted since the check included; without overwrite,
1677
  // anything that appeared under the name since the check fails the add.
1678
  return Util::copyFileReplacing(output, file, overwrite, error);
88 ✔
1679
}
1680

1681
/**
1682
 * @brief Register a transaction before each wrapped execution.
1683
 *
1684
 * Native mode treats every git/gpg invocation as a transaction; the base
1685
 * Pass::executeWrapper calls this hook just before dispatching.
1686
 * @param id Process identifier of the command about to run.
1687
 */
1688
void ImitatePass::beforeExecute(PROCESS id) {
120 ✔
1689
  m_transaction.transactionAdd(id);
120 ✔
1690
}
120 ✔
1691

1692
/**
1693
 * @brief Search all password content by GPG-decrypting each .gpg file.
1694
 *
1695
 * The pattern is evaluated with `QRegularExpression` (**PCRE**), which differs
1696
 * from the POSIX BRE dialect of the `pass` backend — see Pass::Grep for the
1697
 * cross-backend caveat. The work happens on a NativeGrep thread; its result
1698
 * arrives on this object's thread as finishedGrep.
1699
 */
1700
void ImitatePass::Grep(QString pattern, bool caseInsensitive) {
8 ✔
1701
  m_grep.search(pattern, caseInsensitive, m_settings.gpgExecutable,
16 ✔
1702
                m_settings.passStore, exec.environment());
8 ✔
1703
}
8 ✔
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