Line data Source code
1 : // SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements ImitatePass: the gpg and git command sequences behind each
6 : * operation, the transactions that group them, and the re-encryption worker
7 : * thread.
8 : */
9 : #include "imitatepass.h"
10 : #include "executor.h"
11 : #include "gpgidgeneration.h"
12 : #include "util.h"
13 : #include <QDateTime>
14 : #include <QDir>
15 : #include <QFile>
16 : #include <QFileInfo>
17 : #include <QLocale>
18 : #include <QPointer>
19 : #include <QProcess>
20 : #include <QRegularExpression>
21 : #include <QThread>
22 : #include <QTimer>
23 : #include <utility>
24 :
25 : #include "qtpasslogging.h"
26 :
27 : using Enums::CLIPBOARD_ALWAYS;
28 : using Enums::CLIPBOARD_NEVER;
29 : using Enums::CLIPBOARD_ON_DEMAND;
30 : using Enums::GIT_ADD;
31 : using Enums::GIT_COMMIT;
32 : using Enums::GIT_COPY;
33 : using Enums::GIT_INIT;
34 : using Enums::GIT_MOVE;
35 : using Enums::GIT_PULL;
36 : using Enums::GIT_PUSH;
37 : using Enums::GIT_RM;
38 : using Enums::GPG_GENKEYS;
39 : using Enums::INVALID;
40 : using Enums::PASS_COPY;
41 : using Enums::PASS_GREP;
42 : using Enums::PASS_INIT;
43 : using Enums::PASS_INSERT;
44 : using Enums::PASS_MOVE;
45 : using Enums::PASS_REMOVE;
46 : using Enums::PASS_SHOW;
47 : using Enums::PROCESS_COUNT;
48 :
49 190 : ImitatePass::ImitatePass() : m_grep(this) {
50 190 : connect(&m_grep, &NativeGrep::finished, this, &ImitatePass::finishedGrep);
51 190 : }
52 :
53 197 : ImitatePass::~ImitatePass() {
54 : // Grep workers wind down meanwhile; m_grep's destructor waits for them.
55 190 : m_grep.cancel();
56 : // The re-encryption worker calls members, so join it with no timeout; it
57 : // ends its own blocked process on the flag (see execBlocking()).
58 190 : if (m_reencryptThread && m_reencryptThread->isRunning()) {
59 : m_reencryptCancel.store(true);
60 3 : m_reencryptThread->wait();
61 : }
62 387 : }
63 :
64 : // The helpers are shared by the owning thread and the worker; thread affinity
65 : // tells them apart. Only the worker touches its QProcess, so a cancel can
66 : // never hit an exited process or a reused pid.
67 579 : auto ImitatePass::execBlocking(const QString &app, const QStringList &args,
68 : const QString &input, QString *process_out,
69 : QString *process_err) -> int {
70 : // In the store's environment: the git ceiling QtPass's own does not
71 : // carry.
72 579 : QProcess process;
73 579 : if (QThread::currentThread() == thread()) {
74 237 : process.setProcessEnvironment(storeEnvironment());
75 237 : return Executor::executeBlocking(process, app, args, input, process_out,
76 : process_err);
77 : }
78 342 : process.setProcessEnvironment(withCurrentGitPin(m_reencryptEnv));
79 342 : return Executor::executeBlocking(process, app, args, input, process_out,
80 342 : process_err, &m_reencryptCancel);
81 579 : }
82 :
83 367 : auto ImitatePass::execBlocking(const QString &app, const QStringList &args,
84 : QString *process_out, QString *process_err)
85 : -> int {
86 734 : return execBlocking(app, args, QString(), process_out, process_err);
87 : }
88 :
89 593 : auto ImitatePass::translatePathForWsl(const QString &path,
90 : const QString &exe) const -> QString {
91 593 : return Executor::translatePathForWsl(path, exe);
92 : }
93 :
94 95 : auto ImitatePass::storeGit(const QStringList &args, QString *out) -> bool {
95 95 : if (!m_settings.useGit || m_settings.gitExecutable.isEmpty()) {
96 : return false;
97 : }
98 : // -C the store: executeBlocking sets no working directory, so git would
99 : // otherwise run in QtPass's launch directory.
100 83 : const QString store = pgit(m_settings.passStore);
101 83 : return execBlocking(m_settings.gitExecutable,
102 332 : QStringList{QStringLiteral("-C"), store} + args, out,
103 83 : nullptr) == 0;
104 : }
105 :
106 221 : auto ImitatePass::pgit(const QString &path) const -> QString {
107 221 : return translatePathForWsl(path, m_settings.gitExecutable);
108 : }
109 :
110 372 : auto ImitatePass::pgpg(const QString &path) const -> QString {
111 372 : return translatePathForWsl(path, m_settings.gpgExecutable);
112 : }
113 :
114 1 : void ImitatePass::GitInit() {
115 4 : executeGit(GIT_INIT, {"init", pgit(m_settings.passStore)});
116 2 : }
117 :
118 5 : void ImitatePass::GitPull() {
119 5 : if (gitReady()) {
120 20 : executeGit(GIT_PULL, {"pull"});
121 : }
122 5 : }
123 :
124 : // Blocks until git finishes.
125 4 : void ImitatePass::GitPull_b() {
126 4 : if (!gitReady())
127 1 : return;
128 : // -C the store: executeBlocking sets no working directory, so without it
129 : // git would run in QtPass's launch directory and pull an unrelated
130 : // repository, or fail with "not a git repository".
131 3 : QString err;
132 : const int rc =
133 15 : execBlocking(m_settings.gitExecutable,
134 3 : {"-C", pgit(m_settings.passStore), "pull"}, nullptr, &err);
135 3 : if (rc != 0) {
136 4 : emit statusMsg(tr("Git pull failed: %1").arg(err.trimmed()), 5000);
137 : }
138 3 : }
139 :
140 4 : void ImitatePass::GitPush() {
141 4 : if (gitReady()) {
142 16 : executeGit(GIT_PUSH, {"push"});
143 : }
144 4 : }
145 :
146 50 : void ImitatePass::Show(QString file) {
147 100 : if (refuseLinkedPath(file + ".gpg")) {
148 4 : return;
149 : }
150 : queueShow(file);
151 46 : file = m_settings.passStore + file + ".gpg";
152 : QStringList args = {"-d", "--quiet", "--yes", "--no-encrypt-to",
153 598 : "--batch", "--use-agent"};
154 138 : args << maxOutputArgs() << QStringLiteral("--") << pgpg(file);
155 92 : executeGpg(PASS_SHOW, args);
156 : }
157 :
158 : namespace {
159 :
160 : /**
161 : * @brief The arguments of every gpg encrypt call: @p leading, then encrypt
162 : * to exactly @p recipients from stdin into @p output. --no-encrypt-to keeps
163 : * an `encrypt-to` line in the user's gpg.conf from adding a recipient the
164 : * (possibly signed) .gpg-id does not list; --compress-algo=none mirrors
165 : * pass(1).
166 : */
167 116 : auto encryptArgs(QStringList leading, const QString &output,
168 : const QStringList &recipients) -> QStringList {
169 580 : leading << "-eq" << "--compress-algo=none" << "--no-encrypt-to" << "--output"
170 : << output;
171 234 : for (const QString &recipient : recipients) {
172 236 : leading << "-r" << recipient;
173 : }
174 232 : leading << "-";
175 116 : return leading;
176 : }
177 :
178 : } // namespace
179 :
180 73 : auto ImitatePass::recipientsForEntry(const QString &file,
181 : QStringList *recipients) -> bool {
182 73 : recipients->clear();
183 73 : if (refuseSkippedGpgId(file, gpgIdSigner().enabled())) {
184 : return false;
185 : }
186 71 : QString why;
187 142 : if (!loadVerifiedRecipients(Pass::getGpgIdPath(file, m_settings.passStore),
188 : recipients, &why)) {
189 5 : emit critical(tr("Check .gpg-id file signature!"), why);
190 5 : return false;
191 : }
192 66 : if (recipients->isEmpty()) {
193 2 : emit critical(tr("Can not edit"),
194 0 : tr("Could not read encryption key to use, .gpg-id "
195 : "file missing or invalid."));
196 1 : return false;
197 : }
198 : return true;
199 : }
200 :
201 78 : void ImitatePass::Insert(QString file, QString newValue, bool overwrite) {
202 : // The dialog names a new entry relative to the store; gpg used to resolve
203 : // that in its working directory. Everything below works on the one path.
204 : file =
205 78 : QDir::cleanPath(QDir::isAbsolutePath(file)
206 135 : ? file + ".gpg"
207 192 : : QDir(m_settings.passStore).filePath(file) + ".gpg");
208 78 : if (refuseLinkedPath(file)) {
209 73 : return;
210 : }
211 76 : if (refuseUnfinishedGitOperation()) {
212 : return;
213 : }
214 : // gpg used to refuse an existing output without --yes; it writes
215 : // elsewhere now, so the check is ours here, and again at the rename.
216 74 : const QFileInfo target(file);
217 74 : if (!overwrite && (target.exists() || target.isSymLink())) {
218 3 : emit critical(tr("Cannot add"), tr("%1 already exists.").arg(file));
219 1 : return;
220 : }
221 73 : QStringList recipients;
222 73 : if (!recipientsForEntry(file, &recipients)) {
223 : return;
224 : }
225 : // gpg never opens a store path for output: a co-writer could make it a link
226 : // after refuseLinkedPath(). gpg writes into a 0700 directory of our own and
227 : // placeEncryptedFile() brings the bytes in via an open handle and a rename.
228 : auto scratch = std::make_shared<QTemporaryDir>();
229 65 : if (!scratch->isValid()) {
230 2 : emit critical(tr("Cannot write"),
231 1 : tr("Cannot create a temporary directory: %1")
232 2 : .arg(scratch->errorString()));
233 1 : return;
234 : }
235 128 : const QString output = scratch->filePath(QStringLiteral("entry.gpg"));
236 64 : TransactionHelper trans(&m_transaction, PASS_INSERT);
237 : const QStringList args =
238 512 : encryptArgs({"--batch", "--status-fd", "2"}, pgpg(output), recipients);
239 64 : m_pendingInserts.enqueue({std::move(scratch), output, file, overwrite});
240 : // Past every refusal: this insert is now queued and will report once.
241 : queueInsert();
242 64 : executeGpg(PASS_INSERT, args, newValue);
243 64 : if (!gitReady()) {
244 : return;
245 : }
246 5 : if (!overwrite) {
247 25 : executeGit(GIT_ADD, {"add", "--", pgit(file)});
248 : }
249 5 : QString path = QDir(m_settings.passStore).relativeFilePath(file);
250 5 : path.replace(Util::endsWithGpg(), "");
251 20 : gitCommit(file, QString(overwrite ? "Edit" : "Add") + " for " + path +
252 5 : " using QtPass.");
253 143 : }
254 :
255 32 : auto ImitatePass::commitArgs() -> QStringList {
256 : // As pass's git_commit(): -S when the store (or the user's git config)
257 : // sets pass.signcommits. A commit that cannot be signed then fails, as
258 : // with pass, rather than going in unsigned.
259 32 : QString out;
260 256 : const bool sign = execBlocking(m_settings.gitExecutable,
261 32 : {"-C", pgit(m_settings.passStore), "config",
262 : "--bool", "--get", "pass.signcommits"},
263 32 : &out) == 0 &&
264 53 : out.trimmed() == QLatin1String("true");
265 102 : return sign ? QStringList{"commit", "-S"} : QStringList{"commit"};
266 32 : }
267 :
268 11 : void ImitatePass::gitCommit(const QString &file, const QString &msg) {
269 11 : if (file.isEmpty()) {
270 15 : executeGit(GIT_COMMIT, commitArgs() + QStringList{"-m", msg});
271 : } else {
272 16 : executeGit(GIT_COMMIT,
273 56 : commitArgs() + QStringList{"-m", msg, "--", pgit(file)});
274 : }
275 30 : }
276 :
277 16 : void ImitatePass::Remove(QString file, bool isDir) {
278 : // No trailing separator: "link/" makes git look for what is behind the
279 : // link ("pathspec did not match") and rm follow it.
280 16 : file = QDir::cleanPath(m_settings.passStore + file);
281 16 : if (!isDir) {
282 7 : file += ".gpg";
283 : }
284 : // A link itself may go; anything behind one is not the store's to delete.
285 16 : if (refuseLinkedPath(file, LinkScope::ParentsOnly)) {
286 10 : return;
287 : }
288 14 : if (refuseUnfinishedGitOperation() || refusePendingPull()) {
289 1 : return;
290 : }
291 13 : TransactionHelper trans(&m_transaction, PASS_REMOVE);
292 13 : QString path = QDir(m_settings.passStore).relativeFilePath(file);
293 13 : path.replace(Util::endsWithGpg(), "");
294 13 : if (Util::isLinkedFolder(file)) {
295 : // Unlink it here, never through a recursive rm: the link goes, what it
296 : // points to stays. Git then only has to forget it, if it knew it.
297 7 : if (!Util::removeTree(file)) {
298 2 : emit critical(tr("Delete failed"),
299 2 : tr("Could not remove the link %1.").arg(file));
300 1 : return;
301 : }
302 : // Only when git knew it: a commit matching nothing (a link never
303 : // committed) exits 1 and reads as a failed removal. ls-files reads the
304 : // index, so it still answers now that the link is gone.
305 6 : if (gitReady() && gitTracks(file)) {
306 7 : executeGit(GIT_RM, {"rm", "-q", "--cached", "--", pgit(file)});
307 2 : gitCommit(file, "Remove for " + path + " using QtPass.");
308 : }
309 6 : return;
310 : }
311 6 : if (gitReady()) {
312 6 : executeGit(GIT_RM, {"rm", (isDir ? "-rf" : "-f"), "--", pgit(file)});
313 2 : gitCommit(file, "Remove for " + path + " using QtPass.");
314 : } else {
315 5 : if (isDir) {
316 : // Never QDir::removeRecursively(): it follows a junction inside the
317 : // folder and empties whatever that points to.
318 2 : Util::removeTree(file);
319 : } else {
320 3 : QFile(file).remove();
321 : }
322 : }
323 3 : }
324 :
325 346 : auto ImitatePass::gpgIdSigner() -> GpgIdSigner {
326 : return {m_settings.gpgExecutable,
327 692 : GpgIdSigner::keysFromSetting(m_settings.passSigningKey),
328 346 : [this](const QString &app, const QStringList &args,
329 : const QString &input, QString *out, QString *err) {
330 121 : return execBlocking(app, args, input, out, err);
331 692 : }};
332 : }
333 :
334 24 : auto ImitatePass::addGenerationHeader(const QString &gpgIdFile,
335 : const GpgIdSigner &signer,
336 : QByteArray *contents) -> bool {
337 : const std::optional<QString> folder =
338 24 : GpgIdGeneration::folderOf(gpgIdFile, m_settings.passStore);
339 24 : if (!folder) {
340 0 : emit critical(tr("Cannot update"),
341 0 : tr("%1 is not inside the password store, or a folder "
342 : "name on the way holds a line break or another control "
343 : "character, which a signed list cannot record.")
344 0 : .arg(gpgIdFile));
345 0 : return false;
346 : }
347 : // The list on disk counts only if it is this folder's own, verified list:
348 : // a signature vouches for the bytes, the folder line for the place.
349 24 : std::optional<qint64> verifiedOnDisk;
350 24 : std::optional<QString> storeOnDisk;
351 24 : QByteArray current;
352 24 : if (signer.verifyFile(gpgIdFile, ¤t)) {
353 7 : const auto header = GpgIdGeneration::parse(current);
354 7 : if (header && (!header->folder || *header->folder == *folder)) {
355 6 : verifiedOnDisk = header->generation;
356 : storeOnDisk = header->store;
357 : }
358 : }
359 24 : QString why;
360 : // The store's id: the one this device knows, else the one this store's
361 : // lists already carry (#2050).
362 : const std::optional<QString> store =
363 24 : GpgIdGeneration::storeIdFor(m_settings.passStore, storeOnDisk, &why);
364 24 : if (!store) {
365 0 : emit critical(tr("Cannot update"), why);
366 0 : return false;
367 : }
368 : const std::optional<qint64> generation =
369 24 : GpgIdGeneration::reserveNext(gpgIdFile, verifiedOnDisk, &why);
370 24 : if (!generation) {
371 0 : emit critical(tr("Cannot update"), why);
372 0 : return false;
373 : }
374 : *contents =
375 24 : GpgIdGeneration::withHeader(*generation, *folder, *contents, *store);
376 24 : return true;
377 : }
378 :
379 37 : auto ImitatePass::writeGpgIdFile(const QString &gpgIdFile,
380 : const QList<UserInfo> &users,
381 : QByteArray *written) -> bool {
382 37 : QByteArray contents;
383 : bool secret_selected = false;
384 78 : for (const UserInfo &user : users) {
385 41 : if (user.enabled) {
386 41 : contents += (user.key_id + "\n").toUtf8();
387 41 : secret_selected |= user.have_secret;
388 : }
389 : }
390 : // Without a signing key the plain list stays (GpgIdGeneration).
391 37 : const GpgIdSigner signer = gpgIdSigner();
392 37 : if (signer.enabled() && !addGenerationHeader(gpgIdFile, signer, &contents)) {
393 : return false;
394 : }
395 : // Whole or not at all, owner-only (it names the store's keys); a link
396 : // planted since Init's check is replaced, not written through.
397 37 : QString writeError;
398 37 : if (!Util::writeFileReplacing(gpgIdFile, contents, true, &writeError)) {
399 1 : emit critical(tr("Cannot update"), writeError);
400 1 : return false;
401 : }
402 36 : if (written != nullptr) {
403 32 : *written = contents;
404 : }
405 36 : if (signer.enabled()) {
406 : // From now on these bytes are this generation. The list is signed either
407 : // way (unsigned is refused everywhere); if the record could not take them
408 : // (another writer, record busy), the user is told to save once more.
409 24 : QString why;
410 24 : if (!GpgIdGeneration::recordWritten(gpgIdFile, contents, &why)) {
411 0 : qCWarning(lcQtPass) << "Could not record the written .gpg-id:" << why;
412 0 : emit critical(
413 0 : tr("Recipient list written, but not recorded"),
414 0 : tr("%1 Save the recipients once more to get through.").arg(why));
415 : }
416 : }
417 36 : if (!secret_selected) {
418 4 : emit critical(
419 2 : tr("Check selected users!"),
420 2 : tr("None of the selected keys have a secret key available.\n"
421 : "You will not be able to decrypt any newly added passwords!"));
422 : }
423 : return true;
424 37 : }
425 :
426 24 : auto ImitatePass::signGpgIdFile(const QString &gpgIdFile,
427 : const QByteArray &contents) -> bool {
428 24 : const GpgIdSigner signer = gpgIdSigner();
429 24 : QString why;
430 24 : if (!signer.sign(gpgIdFile, contents, &why)) {
431 6 : emit critical(
432 3 : tr("GPG signing failed!"),
433 3 : why.trimmed().isEmpty()
434 9 : ? tr("Failed to sign %1.").arg(gpgIdFile)
435 7 : : tr("Failed to sign %1: %2").arg(gpgIdFile, why.trimmed()));
436 3 : return false;
437 : }
438 21 : QByteArray signedBytes;
439 21 : if (!signer.verifyFile(gpgIdFile, &signedBytes)) {
440 0 : emit critical(tr("Check .gpg-id file signature!"),
441 0 : tr("Signature for %1 is invalid.").arg(gpgIdFile));
442 0 : return false;
443 : }
444 : return true;
445 24 : }
446 :
447 : // Synchronous on purpose: queued on the async executor, the add/commit raced
448 : // reencryptPath()'s blocking backup commit for index.lock and either left the
449 : // .gpg-id untracked or found nothing to commit.
450 7 : auto ImitatePass::gitAddGpgId(const QString &gpgIdFile,
451 : const QString &gpgIdSigFile, QString *out,
452 : QString *err) -> int {
453 : const QString git = m_settings.gitExecutable;
454 7 : const QString store = pgit(m_settings.passStore);
455 19 : auto run = [&](const QStringList &args) -> int {
456 19 : QString runOut;
457 19 : QString runErr;
458 : const int rc =
459 76 : execBlocking(git, QStringList{"-C", store} + args, &runOut, &runErr);
460 19 : if (out != nullptr) {
461 19 : out->append(runOut);
462 : }
463 19 : if (err != nullptr) {
464 19 : err->append(runErr);
465 : }
466 19 : return rc;
467 19 : };
468 14 : QStringList paths{pgit(gpgIdFile)};
469 7 : if (!gpgIdSigFile.isEmpty()) {
470 12 : paths << pgit(gpgIdSigFile);
471 : }
472 : // add stages new and modified files alike; already-clean ones are a no-op.
473 42 : int rc = run(QStringList{"add", "--"} + paths);
474 7 : if (rc != 0) {
475 : return rc;
476 : }
477 : // Re-initialising with the same recipients changes nothing; that is not a
478 : // failure, and `git commit` would make it one. diff --quiet: 0 nothing
479 : // staged, 1 something staged, anything else is git failing.
480 60 : rc = run(QStringList{"diff", "--cached", "--quiet", "--"} + paths);
481 6 : if (rc == 0) {
482 : return 0;
483 : }
484 6 : if (rc != 1) {
485 : return rc;
486 : }
487 : QString commitPath = gpgIdFile;
488 6 : commitPath.replace(Util::endsWithGpg(), "");
489 18 : return run(commitArgs() +
490 48 : QStringList{"-m", "Added " + commitPath + " using QtPass.", "--"} +
491 6 : paths);
492 19 : }
493 :
494 3 : auto ImitatePass::gitTracks(const QString &file) -> bool {
495 24 : return execBlocking(m_settings.gitExecutable,
496 3 : {"-C", pgit(m_settings.passStore), "ls-files",
497 6 : "--error-unmatch", "--", pgit(file)}) == 0;
498 3 : }
499 :
500 39 : auto ImitatePass::refuseLinkedGpgIdFolder(const QString &path) -> bool {
501 : // A link planted later is replaced, not written through: the writes stage
502 : // and rename.
503 39 : const QString folder = QDir::cleanPath(path);
504 : // Init() places a folder spelled through a linked root's target itself.
505 : const auto refusePlaced = [this](const QString &placed) {
506 114 : return refuseLinkedPath(placed, LinkScope::EntryAndParents,
507 : PathSpelling::PlacedByCaller);
508 : };
509 38 : return refusePlaced(path) ||
510 116 : refusePlaced(folder + QStringLiteral("/.gpg-id")) ||
511 152 : refusePlaced(folder + QStringLiteral("/.gpg-id.sig"));
512 : }
513 :
514 32 : auto ImitatePass::settleSignature(const QString &gpgIdFile,
515 : const QByteArray &written,
516 : const GpgIdSigner &signer, bool useGit,
517 : QString *sigToCommit) -> bool {
518 32 : const QString gpgIdSigFile = gpgIdFile + ".sig";
519 32 : if (signer.enabled()) {
520 24 : if (!signGpgIdFile(gpgIdFile, written)) {
521 : return false;
522 : }
523 21 : *sigToCommit = gpgIdSigFile;
524 21 : return true;
525 : }
526 8 : if (!QFile::exists(gpgIdSigFile)) {
527 : return true;
528 : }
529 : // Signing was switched off; the removal goes into the same commit.
530 3 : const bool tracked = useGit && gitTracks(gpgIdSigFile);
531 3 : if (!QFile::remove(gpgIdSigFile)) {
532 2 : emit critical(
533 1 : tr("Cannot update"),
534 2 : tr("Failed to remove the old signature %1.").arg(gpgIdSigFile));
535 1 : return false;
536 : }
537 2 : if (tracked) {
538 1 : *sigToCommit = gpgIdSigFile;
539 : }
540 : return true;
541 : }
542 :
543 39 : void ImitatePass::Init(QString path, const QList<UserInfo> &users) {
544 : // The .gpg-id is written as path + ".gpg-id": without the trailing
545 : // separator (the context menu hands over a cleaned path) that would be a
546 : // file beside the folder, not the folder's own list. A relative path names
547 : // a folder in the store, as it does for pass, not one in the working
548 : // directory.
549 39 : if (!QDir::isAbsolutePath(path)) {
550 4 : path = QDir(m_settings.passStore).filePath(path);
551 : }
552 : // Cleaned before any check: the checks clean what they look at, and the
553 : // write must go to the same place. Left as /store/link/../team/, the
554 : // system resolves link first and writes beside wherever it points.
555 39 : path = Util::normalizeFolderPath(QDir::cleanPath(path));
556 39 : QString inStore;
557 76 : if (refuseLinkedGpgIdFolder(path) ||
558 37 : refuseFolderOutsideStore(path, &inStore)) {
559 5 : return;
560 : }
561 34 : if (refuseUnfinishedGitOperation()) {
562 : return;
563 : }
564 : // From here on in the store's own spelling, the same folder: one spelled
565 : // through the target of a linked store root is not under the store as
566 : // named, so each entry's nearest list was looked up past the new one and
567 : // the entries were encrypted to the store root's.
568 34 : path = Util::normalizeFolderPath(
569 68 : QDir::cleanPath(QDir(m_settings.passStore).filePath(inStore)));
570 : // A running re-encryption cannot take a second folder: the new list would
571 : // be written and its entries left encrypted to the old recipients, which
572 : // for a removed person means they can still read them. Nothing is written.
573 34 : if (m_reencryptActive) {
574 2 : emit critical(tr("Recipients not changed"),
575 0 : tr("A re-encryption is still running. Wait until it has "
576 : "finished, then save the recipients again."));
577 1 : return;
578 : }
579 33 : const GpgIdSigner signer = gpgIdSigner();
580 33 : if (signer.enabled() && !signer.haveSecretKey()) {
581 2 : emit critical(tr("No signing key!"),
582 1 : tr("None of the secret signing keys is available.\n"
583 : "You will not be able to change the user list!"));
584 1 : return;
585 : }
586 :
587 32 : const bool useGit = gitReady();
588 32 : const QString gpgIdFile = path + ".gpg-id";
589 32 : QByteArray written;
590 32 : QString sigToCommit;
591 64 : if (!writeGpgIdFile(gpgIdFile, users, &written) ||
592 32 : !settleSignature(gpgIdFile, written, signer, useGit, &sigToCommit)) {
593 4 : return;
594 : }
595 :
596 28 : QString gitOut;
597 28 : QString gitErr;
598 28 : if (useGit && m_settings.addGPGId) {
599 : // Commit the .gpg-id (and .sig) before re-encrypting to it: the backup
600 : // commit only takes tracked files (#1685) and addFolder does not stage
601 : // it, so entries were pushed without their recipients file (#1682).
602 7 : const int gitExit = gitAddGpgId(gpgIdFile, sigToCommit, &gitOut, &gitErr);
603 7 : if (gitExit != 0) {
604 3 : Pass::finished(PASS_INIT, gitExit, gitOut, gitErr);
605 : // The new list is on disk either way. Stopping here left every entry
606 : // readable by whoever was just removed from it, so re-encrypt without
607 : // Git and say the change is not committed. Putting the old list back
608 : // is no way out: with signing the generation record has moved on and
609 : // would refuse it as a rollback.
610 : const QString why = gitErr.trimmed();
611 3 : reencryptWithoutGit(
612 : path,
613 10 : why.isEmpty() ? tr("Git exited with code %1.").arg(gitExit) : why);
614 : return;
615 : }
616 : }
617 25 : reencryptPath(path);
618 : // finishedInit once the .gpg-id landed in git, as the async path did.
619 25 : if (useGit) {
620 5 : Pass::finished(PASS_INIT, 0, gitOut, gitErr);
621 : }
622 33 : }
623 :
624 124 : auto ImitatePass::loadVerifiedRecipients(const QString &gpgIdFile,
625 : QStringList *recipients, QString *why)
626 : -> bool {
627 124 : recipients->clear();
628 124 : if (why != nullptr) {
629 246 : *why = tr("Signature for %1 is invalid.").arg(gpgIdFile);
630 : }
631 : // A link under the .gpg-id name is not the store's list: with signing
632 : // off it reads as missing, with signing on verifyFile() refuses it.
633 124 : if (Util::isLinkedFolder(gpgIdFile)) {
634 1 : return !gpgIdSigner().enabled();
635 : }
636 123 : QByteArray contents;
637 123 : const GpgIdSigner signer = gpgIdSigner();
638 123 : if (signer.enabled()) {
639 22 : if (!verifySignedList(signer, gpgIdFile, &contents, why)) {
640 : return false;
641 : }
642 : } else {
643 101 : if (const QFileInfo info(gpgIdFile); !info.exists() && !info.isSymLink()) {
644 : // No file is not a bad signature; the empty list says it all.
645 : return true;
646 101 : }
647 : // Bounded, and only a regular file: see Util::readRegularFile().
648 : std::optional<QByteArray> bytes =
649 99 : Util::readRegularFile(gpgIdFile, Util::kMaxGpgIdBytes);
650 99 : if (!bytes) {
651 0 : if (why != nullptr) {
652 0 : *why = tr("%1 cannot be read as a recipient list (a regular file "
653 : "of at most %2).")
654 0 : .arg(gpgIdFile,
655 0 : QLocale().formattedDataSize(Util::kMaxGpgIdBytes));
656 : }
657 : return false;
658 : }
659 : contents = *std::move(bytes);
660 : }
661 115 : *recipients = Pass::parseRecipients(contents, gpgIdFile);
662 115 : return true;
663 123 : }
664 :
665 2 : void ImitatePass::reportUnrestorable(const QString &path) {
666 6 : emit critical(tr("Leftover from an earlier re-encryption"),
667 2 : tr("%1 is not a regular file and was not restored. Look "
668 : "at it and remove it, then re-encrypt again.")
669 4 : .arg(path));
670 2 : }
671 :
672 4 : void ImitatePass::dropStaleTemporary(const QString &path) {
673 : // A crash's temporary is picked up by a run an hour later.
674 8 : if (QFileInfo(path).lastModified() >
675 8 : QDateTime::currentDateTime().addSecs(-3600)) {
676 1 : qCDebug(lcQtPass) << "Leaving a recent temporary alone:" << path;
677 1 : return;
678 : }
679 3 : if (!QFile::remove(path)) {
680 0 : qCWarning(lcQtPass) << "Could not remove stale temporary" << path;
681 : }
682 : }
683 :
684 2 : auto ImitatePass::restoreBackup(const QString &path) -> bool {
685 : // Belt and braces: the walker only lists regular files.
686 2 : const QFileInfo backup(path);
687 2 : if (backup.isSymLink() || !backup.isFile()) {
688 0 : reportUnrestorable(path);
689 : return false;
690 : }
691 : const QString original =
692 2 : path.chopped(QStringLiteral(".reencrypt.bak").size());
693 2 : if (QFileInfo::exists(original)) {
694 3 : emit critical(tr("Leftover from an earlier re-encryption"),
695 1 : tr("%1 exists next to %2. Both are encrypted copies of the "
696 : "entry; check which one you want and delete the other, "
697 : "then re-encrypt again.")
698 1 : .arg(path, original));
699 1 : return false;
700 : }
701 1 : if (!QFile::rename(path, original)) {
702 0 : emit critical(tr("Leftover from an earlier re-encryption"),
703 0 : tr("%1 is missing and its backup %2 could not be renamed "
704 : "back. Rename it by hand, then re-encrypt again.")
705 0 : .arg(original, path));
706 0 : return false;
707 : }
708 2 : qCWarning(lcQtPass) << "Restored" << original << "from" << path;
709 2 : emit statusMsg(tr("Restored %1 from the backup an interrupted "
710 : "re-encryption left behind.")
711 2 : .arg(original),
712 : 5000);
713 1 : return true;
714 2 : }
715 :
716 79 : auto ImitatePass::recoverReencryptLeftovers(const QString &dir) -> bool {
717 : // .qtpass-XXXXXX.tmp is today's staging name; 1.8.x replaced entries via
718 : // X.gpg.reencrypt.tmp and .bak in two renames, builds up to 2.0 wrote
719 : // X.gpg.XXXXXX.tmp. A .tmp is never a source of truth; a .bak is the only
720 : // copy when X.gpg is missing, and one of two valid ones when it is not.
721 : //
722 : // Regular files only: renaming a link under a leftover name into an entry's
723 : // place would re-encrypt whatever it points to. Links come back in
724 : // `skipped`; linked directories are left to reencryptFiles() to mention.
725 158 : const QStringList leftoverNames{QStringLiteral(".qtpass-??????.tmp"),
726 79 : QStringLiteral("*.gpg.reencrypt.tmp"),
727 79 : QStringLiteral("*.gpg.??????.tmp"),
728 395 : QStringLiteral("*.gpg.reencrypt.bak")};
729 10 : const auto isTemporary = [](const QString &path) {
730 20 : return path.endsWith(QStringLiteral(".tmp"));
731 : };
732 79 : QStringList skipped;
733 : // Hidden files included: the staged name starts with a dot.
734 : const QStringList leftovers = Util::regularFilesUnder(
735 158 : QDir::cleanPath(dir), leftoverNames, &skipped, true);
736 : bool clean = true;
737 84 : for (const QString &path : skipped) {
738 10 : if (!QDir::match(leftoverNames, QFileInfo(path).fileName())) {
739 1 : continue;
740 : }
741 : // Removing a link removes the link, never what it points to; a junction
742 : // or a directory symlink on Windows is a directory entry and goes with
743 : // rmdir.
744 4 : if (isTemporary(path)) {
745 2 : if (!QFile::remove(path) && !QDir().rmdir(path)) {
746 0 : qCWarning(lcQtPass) << "Could not remove stale temporary" << path;
747 : }
748 2 : continue;
749 2 : }
750 2 : reportUnrestorable(path);
751 : clean = false;
752 : }
753 85 : for (const QString &path : leftovers) {
754 6 : if (isTemporary(path)) {
755 4 : dropStaleTemporary(path);
756 : } else {
757 2 : clean = restoreBackup(path) && clean;
758 : }
759 : }
760 79 : return clean;
761 : }
762 :
763 55 : auto ImitatePass::verifyGpgIdForDir(const QString &file,
764 : QHash<QString, QStringList> &verified,
765 : QStringList &gpgId) -> bool {
766 : // Before the cache: two folders can resolve to the same list, and only
767 : // one of them may have had its own.
768 55 : if (refuseSkippedGpgId(file, gpgIdSigner().enabled())) {
769 : return false;
770 : }
771 53 : const QString gpgIdPath = Pass::getGpgIdPath(file, m_settings.passStore);
772 : // Keyed by .gpg-id, so every file under it gets exactly the list its
773 : // signature covered.
774 53 : const auto cached = verified.constFind(gpgIdPath);
775 : if (cached != verified.constEnd()) {
776 : gpgId = cached.value();
777 1 : return true;
778 : }
779 52 : QStringList recipients;
780 52 : QString why;
781 52 : if (!loadVerifiedRecipients(gpgIdPath, &recipients, &why)) {
782 : // An interrupted gpg is a cancel, not a bad signature.
783 1 : if (!m_reencryptCancel.load())
784 2 : emit critical(tr("Check .gpg-id file signature!"), why);
785 1 : return false;
786 : }
787 : recipients.sort();
788 : verified.insert(gpgIdPath, recipients);
789 : gpgId = recipients;
790 51 : return true;
791 : }
792 :
793 81 : auto ImitatePass::getKeysFromFile(const QString &fileName) -> QStringList {
794 : QStringList args = {
795 : "-v", "--no-secmem-warning", "--no-permission-warning",
796 : "--list-only", "--keyid-format=long", "--",
797 648 : pgpg(fileName)};
798 81 : QString keys;
799 81 : QString err;
800 81 : const int result = execBlocking(m_settings.gpgExecutable, args, &keys, &err);
801 81 : if (result != 0) {
802 29 : return {};
803 : }
804 52 : QStringList actualKeys;
805 : keys += err;
806 52 : QStringList key = keys.split(Util::newLinesRegex(), Qt::SkipEmptyParts);
807 : QListIterator<QString> itr(key);
808 84 : while (itr.hasNext()) {
809 : QString current = itr.next();
810 64 : QStringList cur = current.split(" ");
811 32 : if (cur.length() > 4) {
812 22 : QString actualKey = cur.takeAt(4);
813 22 : if (actualKey.length() == 16) {
814 : actualKeys << actualKey;
815 : }
816 : }
817 : }
818 : actualKeys.sort();
819 : return actualKeys;
820 81 : }
821 :
822 81 : auto ImitatePass::decryptEntry(const QString &fileName, QString *plaintext)
823 : -> bool {
824 : const QStringList args =
825 81 : QStringList{"-d", "--quiet", "--yes", "--no-encrypt-to",
826 1134 : "--batch", "--use-agent"} +
827 486 : maxOutputArgs() + QStringList{QStringLiteral("--"), pgpg(fileName)};
828 81 : if (execBlocking(m_settings.gpgExecutable, args, plaintext) != 0 ||
829 : plaintext->isEmpty()) {
830 29 : qCDebug(lcQtPass) << "Decrypt error on re-encrypt for:" << fileName;
831 29 : return false;
832 : }
833 52 : if (!plaintext->endsWith(u'\n')) {
834 1 : plaintext->append(u'\n');
835 : }
836 : return true;
837 81 : }
838 :
839 52 : auto ImitatePass::encryptFor(const QString &output,
840 : const QStringList &recipients,
841 : const QString &plaintext) -> bool {
842 : const QStringList args =
843 312 : encryptArgs({"--yes", "--batch"}, pgpg(output), recipients);
844 52 : if (execBlocking(m_settings.gpgExecutable, args, plaintext) != 0) {
845 4 : qCDebug(lcQtPass) << "Encrypt error on re-encrypt, output:" << output;
846 4 : return false;
847 : }
848 : return true;
849 : }
850 :
851 48 : auto ImitatePass::ciphertextHolds(const QString &ciphertext,
852 : const QString &plaintext) -> bool {
853 48 : QString decrypted;
854 : const QStringList args =
855 480 : QStringList{"-d", "--quiet", "--batch", "--use-agent"} + maxOutputArgs() +
856 240 : QStringList{QStringLiteral("--"), pgpg(ciphertext)};
857 48 : if (execBlocking(m_settings.gpgExecutable, args, &decrypted) != 0 ||
858 : decrypted.isEmpty()) {
859 1 : qCDebug(lcQtPass) << "Verification failed for:" << ciphertext;
860 1 : return false;
861 : }
862 : // Defence in depth: gpg said it encrypted, and this is what comes back.
863 47 : if (decrypted.trimmed() != plaintext.trimmed()) {
864 1 : qCDebug(lcQtPass) << "Verification content mismatch for:" << ciphertext;
865 1 : return false;
866 : }
867 : return true;
868 48 : }
869 :
870 38 : auto ImitatePass::commitReencrypted(const QString &fileName) -> bool {
871 38 : if (!reencryptUsesGit()) {
872 : return true;
873 : }
874 : // -C the store so git runs there rather than in QtPass's launch directory
875 : // (executeBlocking sets no working directory).
876 11 : const QString store = pgit(m_settings.passStore);
877 77 : if (execBlocking(m_settings.gitExecutable,
878 : {"-C", store, "add", "--", pgit(fileName)}) != 0) {
879 : // The file on disk is re-encrypted correctly; only the repository is
880 : // now behind. Report it so the caller counts this file as failed and
881 : // the run is not pushed.
882 1 : qCDebug(lcQtPass) << "git add failed after re-encrypting:" << fileName;
883 1 : return false;
884 : }
885 10 : QString path = QDir(m_settings.passStore).relativeFilePath(fileName);
886 10 : path.replace(Util::endsWithGpg(), "");
887 20 : if (execBlocking(m_settings.gitExecutable,
888 50 : QStringList{"-C", store} + commitArgs() +
889 10 : QStringList{"-m",
890 10 : "Re-encrypt for " + path + " using QtPass.",
891 80 : "--", pgit(fileName)}) != 0) {
892 1 : qCDebug(lcQtPass) << "git commit failed after re-encrypting:" << fileName;
893 1 : return false;
894 : }
895 : return true;
896 52 : }
897 :
898 81 : auto ImitatePass::reencryptSingleFile(const QString &fileName,
899 : const QStringList &recipients,
900 : QString *why) -> bool {
901 81 : qCDebug(lcQtPass) << "reencrypt" << fileName << "for" << recipients.size()
902 0 : << "recipients";
903 81 : if (recipients.isEmpty()) {
904 0 : emit critical(tr("Can not edit"),
905 0 : tr("Could not read encryption key to use, .gpg-id "
906 : "file missing or invalid."));
907 0 : return false;
908 : }
909 81 : QString plaintext;
910 81 : if (!decryptEntry(fileName, &plaintext)) {
911 : return false;
912 : }
913 :
914 : // gpg writes outside the store, into a 0700 directory with an unguessable
915 : // name no co-writer can pre-create or link; placeEncryptedFile() then
916 : // replaces the entry in one rename, as in Insert().
917 52 : QTemporaryDir scratch;
918 52 : if (!scratch.isValid()) {
919 0 : qCDebug(lcQtPass) << "Cannot create a scratch directory for re-encrypting"
920 0 : << fileName;
921 0 : return false;
922 : }
923 104 : const QString tempPath = scratch.filePath(QStringLiteral("reencrypted.tmp"));
924 100 : if (!encryptFor(tempPath, recipients, plaintext) ||
925 48 : !ciphertextHolds(tempPath, plaintext)) {
926 6 : return false;
927 : }
928 :
929 : // Another client may have removed the entry while gpg ran; the rename
930 : // would bring it back under its old name. Best effort: the check and the
931 : // replace are two steps, as pass's own write is.
932 46 : const QFileInfo still(fileName);
933 46 : if (!still.exists() && !still.isSymLink()) {
934 1 : qCDebug(lcQtPass) << "Entry vanished before it could be replaced:"
935 0 : << fileName;
936 1 : return false;
937 : }
938 45 : if (!placeEncryptedFile(tempPath, fileName, true, why)) {
939 : // Reported once, in the run's summary: an unwritable folder fails every
940 : // entry alike. The old ciphertext stays unless the post-rename check
941 : // failed, and then `why` says so.
942 14 : if (why != nullptr && !why->contains(fileName)) {
943 : // Every reason but the copy's names the entry; that one names the
944 : // file gpg wrote, in a scratch directory the user never sees.
945 2 : *why = tr("%1 could not be re-encrypted: %2").arg(fileName, *why);
946 : }
947 7 : return false;
948 : }
949 38 : return commitReencrypted(fileName);
950 98 : }
951 :
952 81 : auto ImitatePass::createBackupCommit() -> bool {
953 81 : if (!reencryptUsesGit()) {
954 : return true;
955 : }
956 36 : emit statusMsg(tr("Creating backup commit"), 2000);
957 : const QString git = m_settings.gitExecutable;
958 : // -C the store: executeBlocking sets no working directory, so git would
959 : // run in QtPass's launch directory, possibly an unrelated repository.
960 18 : const QString store = pgit(m_settings.passStore);
961 : // Tracked files only: an untracked plaintext export or swap file must not
962 : // be swept into a commit that autoPush sends to the shared remote.
963 18 : QString statusOut;
964 126 : if (execBlocking(
965 : git, {"-C", store, "status", "--porcelain", "--untracked-files=no"},
966 : &statusOut) != 0) {
967 : // An interrupted git is a cancel, not a failure worth a dialog.
968 1 : if (!m_reencryptCancel.load())
969 1 : emit critical(
970 1 : tr("Backup commit failed"),
971 1 : tr("Could not inspect git status. Re-encryption was aborted."));
972 1 : return false;
973 : }
974 17 : if (!statusOut.trimmed().isEmpty()) {
975 35 : if (execBlocking(git, {"-C", store, "add", "-u"}) != 0 ||
976 10 : execBlocking(
977 35 : git, QStringList{"-C", store} + commitArgs() +
978 40 : QStringList{"-m", "Backup before re-encryption"}) != 0) {
979 2 : if (!m_reencryptCancel.load())
980 4 : emit critical(tr("Backup commit failed"),
981 2 : tr("Re-encryption was aborted because a git backup "
982 : "could not be created."));
983 2 : return false;
984 : }
985 : }
986 : return true;
987 51 : }
988 :
989 : /**
990 : * @brief Outcome of one reencryptPath() run; filled on the worker thread
991 : * and consumed by finishReencrypt() on the owning thread.
992 : */
993 162 : struct ImitatePass::ReencryptResult {
994 : int total = 0; ///< `.gpg` files found under the directory.
995 : int checked = 0; ///< Files whose recipients were inspected.
996 : int reencrypted = 0; ///< Files rewritten for the current recipients.
997 : QStringList failed; ///< One line per file that could not be
998 : ///< re-encrypted: its path, or the reason (which
999 : ///< names the path) when there is one to give.
1000 : bool cancelled = false; ///< Stopped early by cancelReencryptPath(); the
1001 : ///< interrupted file, if any, is not in `failed`.
1002 : bool aborted = false; ///< Stopped early on an error already reported.
1003 : };
1004 :
1005 : namespace {
1006 : /// Poll interval while waiting for queued git commands to drain.
1007 : constexpr int kReencryptRetryMs = 100;
1008 : /// Cap on the file names listed in the aggregated failure dialog.
1009 : constexpr int kReencryptMaxListedFailures = 15;
1010 : } // namespace
1011 :
1012 1 : void ImitatePass::reencryptPath(const QString &dir,
1013 : const AppSettings &settings) {
1014 1 : if (!m_reencryptActive) {
1015 1 : init(settings);
1016 1 : updateEnv();
1017 : }
1018 1 : reencryptPath(dir);
1019 1 : }
1020 :
1021 87 : void ImitatePass::reencryptPath(const QString &dir) {
1022 87 : if (m_reencryptActive) {
1023 1 : emit statusMsg(tr("A re-encryption is already running"), 3000);
1024 1 : return;
1025 : }
1026 : // The store root may be a link; a folder inside that is, or lies behind,
1027 : // one is not part of the store. MainWindow refuses it too; this covers
1028 : // Init().
1029 86 : if (Util::isUnderLink(dir, m_settings.passStore)) {
1030 6 : emit critical(tr("Not a folder of the store"),
1031 2 : tr("%1 is, or lies behind, a symbolic link or junction. What "
1032 : "that points to is not part of the password store and "
1033 : "was not re-encrypted.")
1034 4 : .arg(QDir::toNativeSeparators(QDir::cleanPath(dir))));
1035 2 : emit endReencryptPath();
1036 2 : return;
1037 : }
1038 84 : if (refuseUnfinishedGitOperation()) {
1039 : // Re-encrypting now would run createBackupCommit()'s `git add -u` and
1040 : // conclude the stopped merge; refuse before the worker starts.
1041 1 : emit endReencryptPath();
1042 1 : return;
1043 : }
1044 83 : m_reencryptActive = true;
1045 : m_reencryptCancel.store(false);
1046 166 : emit statusMsg(tr("Re-encrypting from folder %1").arg(dir), 3000);
1047 83 : emit startReencryptPath();
1048 83 : startReencryptWorker(dir);
1049 : }
1050 :
1051 : // The worker's kill() after the grace period is needed: terminate() is only
1052 : // SIGTERM, or WM_CLOSE on Windows, which a console gpg ignores.
1053 5 : void ImitatePass::cancelReencryptPath() {
1054 5 : if (!m_reencryptActive)
1055 : return;
1056 : m_reencryptCancel.store(true);
1057 : }
1058 :
1059 : // Init(), Move() and Copy() queue async git commands right before
1060 : // reencryptPath(); the worker's blocking git would fight them for the index
1061 : // lock, so wait for the queue to drain.
1062 84 : void ImitatePass::startReencryptWorker(const QString &dir) {
1063 84 : if (m_reencryptCancel.load()) {
1064 1 : ReencryptResult result;
1065 1 : result.cancelled = true;
1066 1 : finishReencrypt(result);
1067 : return;
1068 : }
1069 : if (!exec.isIdle()) {
1070 2 : QTimer::singleShot(kReencryptRetryMs, this,
1071 5 : [this, dir]() { startReencryptWorker(dir); });
1072 2 : return;
1073 : }
1074 : // Re-check now the queue is idle: a pull queued earlier through the
1075 : // asynchronous GitPull() may have finished during the wait and left a
1076 : // conflict. reencryptPath() checked before scheduling, but that was before
1077 : // such a pull could run; starting the worker now would let its backup commit
1078 : // conclude the stopped operation. The refusal already reported it, so finish
1079 : // as an aborted run (clears m_reencryptActive, emits endReencryptPath).
1080 81 : if (refuseUnfinishedGitOperation()) {
1081 0 : ReencryptResult result;
1082 0 : result.aborted = true;
1083 0 : finishReencrypt(result);
1084 : return;
1085 : }
1086 :
1087 : // The worker calls member functions, so `this` must outlive it: the
1088 : // destructor cancels and joins m_reencryptThread. `self` only guards the
1089 : // queued completion, which may run after the thread object is gone.
1090 : QPointer<ImitatePass> self(this);
1091 : // The worker's own copy: env is the owning thread's to change meanwhile.
1092 81 : updateEnv();
1093 81 : m_reencryptEnv = storeEnvironment();
1094 162 : QThread *thread = QThread::create([this, self, dir]() {
1095 81 : ReencryptResult result = reencryptFiles(dir);
1096 81 : QMetaObject::invokeMethod(
1097 : self,
1098 320 : [self, result = std::move(result)]() {
1099 77 : if (self)
1100 77 : self->finishReencrypt(result);
1101 77 : },
1102 : Qt::QueuedConnection);
1103 81 : });
1104 81 : m_reencryptThread = thread;
1105 81 : connect(thread, &QThread::finished, this, [this, thread]() {
1106 77 : if (m_reencryptThread == thread)
1107 77 : m_reencryptThread = nullptr;
1108 : });
1109 81 : connect(thread, &QThread::finished, thread, &QObject::deleteLater);
1110 81 : thread->start();
1111 : }
1112 :
1113 : /**
1114 : * @brief Pull before re-encryption when automatic pulling is configured.
1115 : * @return true if no pull is needed, the pull succeeds, or a failed pull leaves
1116 : * no unmerged files; false if unmerged files remain or their check fails.
1117 : */
1118 81 : auto ImitatePass::pullBeforeReencrypt() -> bool {
1119 81 : if (!m_settings.autoPull || !reencryptUsesGit()) {
1120 77 : return true;
1121 : }
1122 4 : emit statusMsg(tr("Updating password-store"), 2000);
1123 20 : if (execBlocking(m_settings.gitExecutable,
1124 4 : {"-C", pgit(m_settings.passStore), "pull"}) == 0) {
1125 : return true;
1126 : }
1127 : // A pull that could not reach the remote leaves the store as it was; one
1128 : // that stopped in a merge leaves conflict markers and an unmerged index,
1129 : // and re-encrypting on top of that would commit the mess.
1130 2 : QString unmerged;
1131 12 : if (execBlocking(m_settings.gitExecutable,
1132 : {"-C", pgit(m_settings.passStore), "ls-files", "--unmerged"},
1133 : &unmerged) != 0) {
1134 : // Unknown is not clean: the pull may have stopped in a merge.
1135 0 : emit critical(tr("Git pull failed"),
1136 0 : tr("Could not check the store for unmerged files after the "
1137 : "pull. Check it with git before re-encrypting."));
1138 0 : return false;
1139 : }
1140 2 : if (!unmerged.trimmed().isEmpty()) {
1141 2 : emit critical(tr("Git pull failed"),
1142 1 : tr("The pull left the store with unmerged files. Resolve "
1143 : "the conflict before re-encrypting."));
1144 1 : return false;
1145 : }
1146 1 : emit statusMsg(tr("Git pull failed, re-encrypting the store as it is"), 5000);
1147 1 : return true;
1148 6 : }
1149 :
1150 74 : auto ImitatePass::entriesToReencrypt(const QString &dir) -> QStringList {
1151 : // Regular files only: a symlink or junction is not a password entry, and
1152 : // following one would decrypt and rewrite something outside the store.
1153 74 : QStringList skipped;
1154 : const QStringList files =
1155 222 : Util::regularFilesUnder(dir, QStringList() << "*.gpg", &skipped);
1156 74 : if (!skipped.isEmpty()) {
1157 2 : emit statusMsg(tr("%n entr(y/ies) skipped: a symlink, junction or special "
1158 : "file is not part of the store.",
1159 : "", static_cast<int>(skipped.size())),
1160 : 5000);
1161 : }
1162 74 : return files;
1163 : }
1164 :
1165 55 : auto ImitatePass::recipientsForDir(const QString &fileName,
1166 : QHash<QString, QStringList> &verified,
1167 : QStringList &gpgId, ReencryptResult &result)
1168 : -> bool {
1169 55 : if (!verifyGpgIdForDir(fileName, verified, gpgId)) {
1170 3 : if (m_reencryptCancel.load()) {
1171 0 : result.cancelled = true;
1172 : } else {
1173 3 : result.aborted = true;
1174 : }
1175 3 : return false;
1176 : }
1177 52 : if (gpgId.isEmpty() && !verified.isEmpty()) {
1178 3 : emit critical(tr("GPG ID verification failed"),
1179 1 : tr("Could not verify .gpg-id for directory."));
1180 1 : result.aborted = true;
1181 1 : return false;
1182 : }
1183 : return true;
1184 : }
1185 :
1186 : // Worker thread: only blocking helpers, nothing touching `exec` or the
1187 : // transaction state; signals reach the GUI queued. A helper failing while the
1188 : // cancel flag is set was interrupted: its file is neither checked nor failed.
1189 81 : auto ImitatePass::reencryptFiles(const QString &dir) -> ReencryptResult {
1190 81 : ReencryptResult result;
1191 81 : if (!pullBeforeReencrypt()) {
1192 1 : result.aborted = true;
1193 1 : return result;
1194 : }
1195 : // The GUI-thread checks ran before this worker's own pull.
1196 : // pullBeforeReencrypt refuses an unmerged index, but a pull that stopped in a
1197 : // rebase can leave a rebase-merge/rebase-apply state with no unmerged entry
1198 : // yet; the backup commit's `git add -u` would then conclude it. Re-check
1199 : // here, before any write, with the non-emitting detection (the refusal is
1200 : // reported once, as an aborted run).
1201 80 : if (const QString midOp = unfinishedGitOperationReason(); !midOp.isEmpty()) {
1202 1 : if (!m_reencryptCancel.load()) {
1203 2 : emit critical(tr("Unfinished Git operation"), midOp);
1204 : }
1205 1 : result.aborted = true;
1206 : return result;
1207 : }
1208 : // reencryptPath() checked the folder before the pull; a pulled commit can
1209 : // have made it, or a folder above it, a link since. Recovery and the walk
1210 : // below would then rename and remove files beyond the store.
1211 79 : if (Util::isUnderLink(dir, m_settings.passStore)) {
1212 0 : emit critical(tr("Not part of the store"),
1213 0 : tr("%1 is, or lies behind, a symbolic link or junction "
1214 : "after pulling. What that points to is not part of the "
1215 : "password store and is left alone.")
1216 0 : .arg(QDir::toNativeSeparators(QDir::cleanPath(dir))));
1217 0 : result.aborted = true;
1218 0 : return result;
1219 : }
1220 :
1221 : // Leftovers of an interrupted run first: a restored entry then goes into
1222 : // the backup commit like everything else, and a stale temporary does not.
1223 79 : if (!recoverReencryptLeftovers(dir)) {
1224 3 : result.aborted = true;
1225 3 : return result;
1226 : }
1227 :
1228 76 : if (!createBackupCommit()) {
1229 2 : if (m_reencryptCancel.load()) {
1230 0 : result.cancelled = true;
1231 : } else {
1232 2 : result.aborted = true;
1233 : }
1234 : return result;
1235 : }
1236 :
1237 74 : const QStringList files = entriesToReencrypt(dir);
1238 74 : result.total = files.size();
1239 74 : emit reencryptProgress(0, result.total);
1240 :
1241 74 : QString currentDir;
1242 74 : QHash<QString, QStringList> gpgIdFilesVerified;
1243 74 : QStringList gpgId;
1244 149 : for (const QString &fileName : std::as_const(files)) {
1245 85 : if (m_reencryptCancel.load()) {
1246 0 : result.cancelled = true;
1247 6 : break;
1248 : }
1249 85 : const QString fileDir = QFileInfo(fileName).path();
1250 85 : if (fileDir != currentDir) {
1251 55 : if (!recipientsForDir(fileName, gpgIdFilesVerified, gpgId, result)) {
1252 : return result;
1253 : }
1254 51 : currentDir = fileDir;
1255 : }
1256 162 : if (getKeysFromFile(fileName) != gpgId) {
1257 81 : QString why;
1258 81 : if (reencryptSingleFile(fileName, gpgId, &why)) {
1259 36 : result.reencrypted++;
1260 45 : } else if (m_reencryptCancel.load()) {
1261 : // Interrupted by the cancel: the file is untouched, not failed.
1262 6 : result.cancelled = true;
1263 : break;
1264 : } else {
1265 : // The reason names the entry itself when there is one to give.
1266 39 : result.failed << (why.isEmpty() ? fileName : why);
1267 : }
1268 : }
1269 75 : result.checked++;
1270 75 : emit reencryptProgress(result.checked, result.total);
1271 : }
1272 : return result;
1273 74 : }
1274 :
1275 : // No push after a cancel, abort or per-file failure: a partially re-encrypted
1276 : // store must not reach the remote before the user inspects it.
1277 78 : void ImitatePass::finishReencrypt(const ReencryptResult &result) {
1278 78 : if (!result.failed.isEmpty()) {
1279 17 : QStringList listed = result.failed.mid(0, kReencryptMaxListedFailures);
1280 17 : const int more = result.failed.size() - listed.size();
1281 17 : if (more > 0) {
1282 1 : listed << tr("... and %n more", nullptr, more);
1283 : }
1284 51 : emit critical(tr("Re-encryption failed"),
1285 17 : tr("%n file(s) could not be re-encrypted:", nullptr,
1286 17 : result.failed.size()) +
1287 34 : "\n\n" + listed.join('\n'));
1288 : }
1289 :
1290 78 : if (result.cancelled) {
1291 8 : emit statusMsg(tr("Re-encryption cancelled: %1 of %2 files checked, "
1292 : "%3 re-encrypted, %4 failed")
1293 4 : .arg(result.checked)
1294 4 : .arg(result.total)
1295 4 : .arg(result.reencrypted)
1296 12 : .arg(result.failed.size()),
1297 : 5000);
1298 74 : } else if (!result.aborted) {
1299 63 : if (!result.failed.isEmpty()) {
1300 32 : emit statusMsg(tr("Re-encryption completed: %1 succeeded, %2 failed")
1301 16 : .arg(result.reencrypted)
1302 48 : .arg(result.failed.size()),
1303 : 5000);
1304 : } else {
1305 47 : emit statusMsg(tr("Re-encryption completed: %1 files re-encrypted")
1306 94 : .arg(result.reencrypted),
1307 : 3000);
1308 : }
1309 63 : if (m_settings.autoPush && reencryptUsesGit()) {
1310 6 : if (result.failed.isEmpty()) {
1311 3 : emit statusMsg(tr("Updating password-store"), 2000);
1312 3 : GitPush();
1313 : } else {
1314 6 : emit statusMsg(tr("Not pushing: %n file(s) failed to re-encrypt",
1315 : nullptr, result.failed.size()),
1316 : 5000);
1317 : }
1318 : }
1319 : }
1320 78 : if (m_uncommittedGpgId) {
1321 3 : const UncommittedGpgId uncommitted = *m_uncommittedGpgId;
1322 : m_uncommittedGpgId.reset();
1323 3 : reportUncommittedGpgId(uncommitted.folder, uncommitted.gitError,
1324 3 : !result.cancelled && !result.aborted &&
1325 : result.failed.isEmpty());
1326 : }
1327 78 : m_reencryptActive = false;
1328 78 : emit endReencryptPath();
1329 78 : }
1330 :
1331 133 : auto ImitatePass::reencryptUsesGit() const -> bool {
1332 133 : return gitConfigured() && !m_uncommittedGpgId;
1333 : }
1334 :
1335 3 : void ImitatePass::reencryptWithoutGit(const QString &dir,
1336 : const QString &gitError) {
1337 3 : if (m_reencryptActive) {
1338 : // The run in flight was started with its own Git mode; this folder is
1339 : // not part of it.
1340 0 : reportUncommittedGpgId(dir, gitError, false);
1341 0 : return;
1342 : }
1343 3 : m_uncommittedGpgId = UncommittedGpgId{dir, gitError};
1344 3 : reencryptPath(dir);
1345 3 : if (m_uncommittedGpgId && !m_reencryptActive) {
1346 : // Refused before it started; reencryptPath() said why.
1347 : m_uncommittedGpgId.reset();
1348 0 : reportUncommittedGpgId(dir, gitError, false);
1349 : }
1350 : }
1351 :
1352 3 : void ImitatePass::reportUncommittedGpgId(const QString &dir,
1353 : const QString &gitError,
1354 : bool reencrypted) {
1355 3 : const QString folder = QDir::toNativeSeparators(QDir::cleanPath(dir));
1356 3 : if (reencrypted) {
1357 4 : emit critical(
1358 2 : tr("Recipients not committed"),
1359 2 : tr("The recipients of %1 were changed and its entries were "
1360 : "re-encrypted to them, but the Git commit failed:\n\n%2\n\n"
1361 : "The change is not committed or pushed yet. Fix the Git problem, "
1362 : "then commit the changes, or save the recipients again.")
1363 4 : .arg(folder, gitError));
1364 : return;
1365 : }
1366 2 : emit critical(
1367 1 : tr("Recipients not committed"),
1368 1 : tr("The recipients of %1 were changed, but the Git commit "
1369 : "failed:\n\n%2\n\nRe-encryption did not complete, so some or all "
1370 : "entries are still encrypted to the previous recipients, who can "
1371 : "still read them. Nothing was committed or pushed. Fix the Git "
1372 : "problem, then save the recipients again.")
1373 2 : .arg(folder, gitError));
1374 : }
1375 :
1376 30 : auto ImitatePass::resolveMoveDestination(const QString &src,
1377 : const QString &dest, bool force)
1378 : -> QString {
1379 30 : QFileInfo srcFileInfo(src);
1380 30 : QFileInfo destFileInfo(dest);
1381 30 : QString destFile;
1382 30 : QString srcFileBaseName = srcFileInfo.fileName();
1383 :
1384 30 : if (srcFileInfo.isFile()) {
1385 25 : if (destFileInfo.isFile()) {
1386 9 : if (!force) {
1387 4 : qCDebug(lcQtPass) << "Destination file already exists";
1388 4 : return {};
1389 : }
1390 5 : destFile = dest;
1391 16 : } else if (destFileInfo.isDir()) {
1392 12 : destFile = QDir(dest).filePath(srcFileBaseName);
1393 : } else {
1394 10 : destFile = dest;
1395 : }
1396 :
1397 : // Case-sensitive: only an actual ".gpg" entry already carries the suffix.
1398 : // A neighbouring file like "login.GPG" is a different name, so it must not
1399 : // be chopped to "login" and re-suffixed to the real "login.gpg" entry,
1400 : // which the forced move would then overwrite.
1401 21 : if (destFile.endsWith(QLatin1String(".gpg"))) {
1402 19 : destFile.chop(4);
1403 : }
1404 21 : destFile.append(".gpg");
1405 5 : } else if (srcFileInfo.isDir()) {
1406 4 : if (destFileInfo.isDir()) {
1407 2 : destFile = QDir(dest).filePath(srcFileBaseName);
1408 3 : } else if (destFileInfo.isFile()) {
1409 1 : qCDebug(lcQtPass) << "Destination is a file";
1410 1 : return {};
1411 : } else {
1412 2 : destFile = dest;
1413 : }
1414 : } else {
1415 1 : qCDebug(lcQtPass) << "Source file does not exist";
1416 1 : return {};
1417 : }
1418 : return destFile;
1419 30 : }
1420 :
1421 2 : void ImitatePass::executeMoveGit(const QString &src, const QString &destFile,
1422 : bool force) {
1423 2 : QStringList args;
1424 2 : args << "mv";
1425 2 : if (force) {
1426 2 : args << "-f";
1427 : }
1428 8 : args << "--" << pgit(src) << pgit(destFile);
1429 2 : executeGit(GIT_MOVE, args);
1430 :
1431 2 : QString relSrc = QDir(m_settings.passStore).relativeFilePath(src);
1432 2 : relSrc.replace(Util::endsWithGpg(), "");
1433 2 : QString relDest = QDir(m_settings.passStore).relativeFilePath(destFile);
1434 2 : relDest.replace(Util::endsWithGpg(), "");
1435 2 : QString message = QString("Moved for %1 to %2 using QtPass.");
1436 2 : message = message.arg(relSrc, relDest);
1437 4 : gitCommit("", message);
1438 2 : }
1439 :
1440 13 : void ImitatePass::Move(const QString src, const QString dest,
1441 : const bool force) {
1442 13 : if (refuseLinkedPath(src) || refuseLinkedPath(dest)) {
1443 4 : return;
1444 : }
1445 11 : if (refuseUnfinishedGitOperation() || refusePendingPull()) {
1446 0 : return;
1447 : }
1448 11 : TransactionHelper trans(&m_transaction, PASS_MOVE);
1449 11 : QString destFile = resolveMoveDestination(src, dest, force);
1450 11 : if (destFile.isEmpty()) {
1451 : return;
1452 : }
1453 9 : if (refuseLinkedPath(destFile)) {
1454 : return;
1455 : }
1456 :
1457 9 : qCDebug(lcQtPass) << "Move Source: " << src;
1458 9 : qCDebug(lcQtPass) << "Move Destination: " << destFile;
1459 :
1460 9 : if (gitReady()) {
1461 2 : executeMoveGit(src, destFile, force);
1462 : } else {
1463 7 : QDir qDir;
1464 7 : if (force) {
1465 1 : qDir.remove(destFile);
1466 : }
1467 7 : qDir.rename(src, destFile);
1468 7 : }
1469 : }
1470 :
1471 13 : auto ImitatePass::copyDestination(const QString &src, const QString &dest,
1472 : bool force) -> QString {
1473 4 : const auto refuse = [this, &src](const QString &to) {
1474 12 : emit critical(tr("Copy failed"),
1475 4 : tr("Could not copy %1 to %2.").arg(src, to));
1476 4 : return QString();
1477 13 : };
1478 : // Like `pass cp`, dest may be an existing folder (a drag-and-drop copy hands
1479 : // over the folder, not the new file name). Resolve the real target the same
1480 : // way Move does: into the folder, .gpg appended, no clobbering without force.
1481 13 : const QString destFile = resolveMoveDestination(src, dest, force);
1482 13 : if (destFile.isEmpty()) {
1483 2 : return refuse(dest);
1484 : }
1485 : // A link planted under that name (dangling ones pass exists()) is not an
1486 : // entry to write.
1487 11 : if (refuseLinkedPath(destFile)) {
1488 1 : return {};
1489 : }
1490 10 : const QFileInfo destFileInfo(destFile);
1491 : // A folder destination that is the source's own folder resolves to the
1492 : // source itself; with force that would replace the only copy with itself.
1493 : // And resolveMoveDestination only sees a clash when dest names the file.
1494 30 : if (QFileInfo(src) == destFileInfo || (!force && destFileInfo.exists())) {
1495 2 : return refuse(destFile);
1496 : }
1497 : return destFile;
1498 10 : }
1499 :
1500 15 : void ImitatePass::Copy(const QString src, const QString dest,
1501 : const bool force) {
1502 : // QFile::copy reads through a link: the target's bytes would become an
1503 : // entry of the store.
1504 15 : if (refuseLinkedPath(src) || refuseLinkedPath(dest)) {
1505 8 : return;
1506 : }
1507 13 : if (refuseUnfinishedGitOperation() || refusePendingPull()) {
1508 0 : return;
1509 : }
1510 13 : TransactionHelper trans(&m_transaction, PASS_COPY);
1511 13 : const QString destFile = copyDestination(src, dest, force);
1512 13 : if (destFile.isEmpty()) {
1513 : return;
1514 : }
1515 : // git has no "cp": copy on disk in both modes, then stage. Synchronous and
1516 : // atomic (Util::copyFileReplacing follows no link), so it exists before the
1517 : // re-encryption below and a forced overwrite survives a half-way failure;
1518 : // without force nothing is replaced, not even what appeared since the check.
1519 8 : QString why;
1520 8 : if (!Util::copyFileReplacing(src, destFile, force, &why)) {
1521 2 : emit critical(tr("Copy failed"),
1522 2 : tr("Could not copy %1 to %2.").arg(src, destFile) + "\n" +
1523 1 : why);
1524 : return;
1525 : }
1526 7 : if (gitReady()) {
1527 5 : executeGit(GIT_COPY, {"add", "--", pgit(destFile)});
1528 2 : gitCommit("",
1529 2 : QString("Copied from %1 to %2 using QtPass.").arg(src, destFile));
1530 : }
1531 7 : const QFileInfo written(destFile);
1532 7 : if (written.isDir()) {
1533 0 : reencryptPath(written.absoluteFilePath());
1534 7 : } else if (written.isFile()) {
1535 14 : reencryptPath(written.dir().path());
1536 : }
1537 8 : }
1538 :
1539 110 : void ImitatePass::executeGpg(PROCESS id, const QStringList &args, QString input,
1540 : bool readStdout, bool readStderr) {
1541 110 : executeWrapper(id, m_settings.gpgExecutable, args, std::move(input),
1542 : readStdout, readStderr);
1543 110 : }
1544 :
1545 : // useGit can be on with no gitExecutable (fresh setup, git removed later);
1546 : // handing that to the Executor used to wedge the queue (#1682).
1547 133 : auto ImitatePass::gitConfigured() const -> bool {
1548 133 : return m_settings.useGit && !m_settings.gitExecutable.isEmpty();
1549 : }
1550 :
1551 : // Tells the user once per operation why git was skipped, so the store
1552 : // silently drifting from git does not go unnoticed.
1553 137 : auto ImitatePass::gitReady() -> bool {
1554 137 : if (m_settings.useGit && m_settings.gitExecutable.isEmpty()) {
1555 8 : emit statusMsg(tr("Git executable not configured, skipping git"), 3000);
1556 8 : return false;
1557 : }
1558 : return m_settings.useGit;
1559 : }
1560 :
1561 31 : void ImitatePass::executeGit(PROCESS id, const QStringList &args, QString input,
1562 : bool readStdout, bool readStderr) {
1563 : // Callers check gitReady() first; an empty executable that still gets here
1564 : // is reported by the Executor instead of wedging the queue (#1682).
1565 31 : executeWrapper(id, m_settings.gitExecutable, args, std::move(input),
1566 : readStdout, readStderr);
1567 31 : }
1568 :
1569 : // Only PASS_* processes reach Pass::finished, so to the interface this looks
1570 : // the same as RealPass.
1571 138 : void ImitatePass::finished(int id, int exitCode, const QString &out,
1572 : const QString &err) {
1573 138 : qCDebug(lcQtPass) << "Imitate Pass";
1574 : QString error = err;
1575 138 : if (id == PASS_INSERT && !m_pendingInserts.isEmpty()) {
1576 : // Insert()'s gpg step: place the ciphertext before the queued git steps
1577 : // run (the executor starts the next item only after this returns). On
1578 : // failure it fails like a gpg error: git steps cancelled below.
1579 : // No dialog here: it would spin the event loop and run git steps first.
1580 : const PendingInsert pending = m_pendingInserts.dequeue();
1581 : // The flag answers for this insert, the one whose gpg step just ended, not
1582 : // for whichever Insert() was called last: with two inserts queued, a gpg
1583 : // failure of the second must report written=false even though the first
1584 : // was placed. Cleared here, before placement, and set only on success.
1585 64 : m_insertPlaced = false;
1586 64 : if (exitCode == 0) {
1587 : // Insert() checked the repository when the save was requested, but a
1588 : // pull queued before it (the asynchronous Update button) may have run
1589 : // and stopped in a conflict while this gpg step encrypted. Placing the
1590 : // entry and committing it now would land it in the unfinished operation,
1591 : // where `git rebase --abort` would drop it. Re-check and, if so, fail the
1592 : // save instead of placing it; the queued git steps are cancelled below.
1593 62 : const QString midOp = unfinishedGitOperationReason();
1594 62 : if (!midOp.isEmpty()) {
1595 1 : error = midOp;
1596 : exitCode = 1;
1597 : } else {
1598 122 : m_insertPlaced = placeEncryptedFile(pending.output, pending.file,
1599 61 : pending.overwrite, &error);
1600 61 : if (!m_insertPlaced) {
1601 : exitCode = 1;
1602 : }
1603 : }
1604 : }
1605 64 : }
1606 138 : PROCESS pid = m_transaction.transactionIsOver(static_cast<PROCESS>(id));
1607 138 : m_transactionOutput.append(out);
1608 :
1609 138 : if (exitCode == 0) {
1610 116 : if (pid == INVALID) {
1611 : return;
1612 : }
1613 : } else {
1614 27 : while (pid == INVALID) {
1615 5 : id = exec.cancelNext();
1616 5 : if (id == -1) {
1617 : // this is probably irrecoverable and shall not happen
1618 0 : qCDebug(lcQtPass) << "No such transaction!";
1619 0 : return;
1620 : }
1621 5 : pid = m_transaction.transactionIsOver(static_cast<PROCESS>(id));
1622 : }
1623 : }
1624 128 : Pass::finished(pid, exitCode, m_transactionOutput, error);
1625 128 : m_transactionOutput.clear();
1626 : }
1627 :
1628 106 : auto ImitatePass::placeEncryptedFile(const QString &output, const QString &file,
1629 : bool overwrite, QString *error) -> bool {
1630 106 : if (QFileInfo(output).size() <= 0) {
1631 : // gpg reported success and wrote nothing: not an entry.
1632 4 : *error = tr("gpg wrote no ciphertext for %1.").arg(file);
1633 2 : return false;
1634 : }
1635 : // A link planted since the check is replaced as an entry; without
1636 : // overwrite, anything new under the name fails the add.
1637 104 : return Util::copyFileReplacing(output, file, overwrite, error);
1638 : }
1639 :
1640 145 : void ImitatePass::beforeExecute(PROCESS id) {
1641 145 : m_transaction.transactionAdd(id);
1642 145 : }
1643 :
1644 : // PCRE, as for RealPass (see Pass::Grep). Runs on a NativeGrep thread; the
1645 : // result arrives on this thread as finishedGrep.
1646 8 : void ImitatePass::Grep(QString pattern, bool caseInsensitive) {
1647 16 : m_grep.search(pattern, caseInsensitive, m_settings.gpgExecutable,
1648 8 : m_settings.passStore, exec.environment());
1649 8 : }
|