QtPass 2.0.0
Developer documentation
Loading...
Searching...
No Matches
imitatepass.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2// SPDX-License-Identifier: GPL-3.0-or-later
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
30using Enums::GIT_ADD;
32using Enums::GIT_COPY;
33using Enums::GIT_INIT;
34using Enums::GIT_MOVE;
35using Enums::GIT_PULL;
36using Enums::GIT_PUSH;
37using Enums::GIT_RM;
39using Enums::INVALID;
48
52
54 // Grep workers wind down meanwhile; m_grep's destructor waits for them.
55 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 if (m_reencryptThread && m_reencryptThread->isRunning()) {
59 m_reencryptCancel.store(true);
60 m_reencryptThread->wait();
61 }
62}
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.
67auto 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 QProcess process;
73 if (QThread::currentThread() == thread()) {
74 process.setProcessEnvironment(storeEnvironment());
75 return Executor::executeBlocking(process, app, args, input, process_out,
76 process_err);
77 }
78 process.setProcessEnvironment(withCurrentGitPin(m_reencryptEnv));
79 return Executor::executeBlocking(process, app, args, input, process_out,
80 process_err, &m_reencryptCancel);
81}
82
83auto ImitatePass::execBlocking(const QString &app, const QStringList &args,
84 QString *process_out, QString *process_err)
85 -> int {
86 return execBlocking(app, args, QString(), process_out, process_err);
87}
88
89auto ImitatePass::translatePathForWsl(const QString &path,
90 const QString &exe) const -> QString {
91 return Executor::translatePathForWsl(path, exe);
92}
93
94auto ImitatePass::storeGit(const QStringList &args, QString *out) -> bool {
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 const QString store = pgit(m_settings.passStore);
101 return execBlocking(m_settings.gitExecutable,
102 QStringList{QStringLiteral("-C"), store} + args, out,
103 nullptr) == 0;
104}
105
106auto ImitatePass::pgit(const QString &path) const -> QString {
107 return translatePathForWsl(path, m_settings.gitExecutable);
108}
109
110auto ImitatePass::pgpg(const QString &path) const -> QString {
111 return translatePathForWsl(path, m_settings.gpgExecutable);
112}
113
117
119 if (gitReady()) {
120 executeGit(GIT_PULL, {"pull"});
121 }
122}
123
124// Blocks until git finishes.
126 if (!gitReady())
127 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 QString err;
132 const int rc =
134 {"-C", pgit(m_settings.passStore), "pull"}, nullptr, &err);
135 if (rc != 0) {
136 emit statusMsg(tr("Git pull failed: %1").arg(err.trimmed()), 5000);
137 }
138}
139
141 if (gitReady()) {
142 executeGit(GIT_PUSH, {"push"});
143 }
144}
145
146void ImitatePass::Show(QString file) {
147 if (refuseLinkedPath(file + ".gpg")) {
148 return;
149 }
150 queueShow(file);
151 file = m_settings.passStore + file + ".gpg";
152 QStringList args = {"-d", "--quiet", "--yes", "--no-encrypt-to",
153 "--batch", "--use-agent"};
154 args << maxOutputArgs() << QStringLiteral("--") << pgpg(file);
155 executeGpg(PASS_SHOW, args);
156}
157
158namespace {
159
167auto encryptArgs(QStringList leading, const QString &output,
168 const QStringList &recipients) -> QStringList {
169 leading << "-eq" << "--compress-algo=none" << "--no-encrypt-to" << "--output"
170 << output;
171 for (const QString &recipient : recipients) {
172 leading << "-r" << recipient;
173 }
174 leading << "-";
175 return leading;
176}
177
178} // namespace
179
180auto ImitatePass::recipientsForEntry(const QString &file,
181 QStringList *recipients) -> bool {
182 recipients->clear();
183 if (refuseSkippedGpgId(file, gpgIdSigner().enabled())) {
184 return false;
185 }
186 QString why;
188 recipients, &why)) {
189 emit critical(tr("Check .gpg-id file signature!"), why);
190 return false;
191 }
192 if (recipients->isEmpty()) {
193 emit critical(tr("Can not edit"),
194 tr("Could not read encryption key to use, .gpg-id "
195 "file missing or invalid."));
196 return false;
197 }
198 return true;
199}
200
201void 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 QDir::cleanPath(QDir::isAbsolutePath(file)
206 ? file + ".gpg"
207 : QDir(m_settings.passStore).filePath(file) + ".gpg");
208 if (refuseLinkedPath(file)) {
209 return;
210 }
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 const QFileInfo target(file);
217 if (!overwrite && (target.exists() || target.isSymLink())) {
218 emit critical(tr("Cannot add"), tr("%1 already exists.").arg(file));
219 return;
220 }
221 QStringList recipients;
222 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 if (!scratch->isValid()) {
230 emit critical(tr("Cannot write"),
231 tr("Cannot create a temporary directory: %1")
232 .arg(scratch->errorString()));
233 return;
234 }
235 const QString output = scratch->filePath(QStringLiteral("entry.gpg"));
237 const QStringList args =
238 encryptArgs({"--batch", "--status-fd", "2"}, pgpg(output), recipients);
239 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 executeGpg(PASS_INSERT, args, newValue);
243 if (!gitReady()) {
244 return;
245 }
246 if (!overwrite) {
247 executeGit(GIT_ADD, {"add", "--", pgit(file)});
248 }
249 QString path = QDir(m_settings.passStore).relativeFilePath(file);
250 path.replace(Util::endsWithGpg(), "");
251 gitCommit(file, QString(overwrite ? "Edit" : "Add") + " for " + path +
252 " using QtPass.");
253}
254
255auto 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 QString out;
260 const bool sign = execBlocking(m_settings.gitExecutable,
261 {"-C", pgit(m_settings.passStore), "config",
262 "--bool", "--get", "pass.signcommits"},
263 &out) == 0 &&
264 out.trimmed() == QLatin1String("true");
265 return sign ? QStringList{"commit", "-S"} : QStringList{"commit"};
266}
267
268void ImitatePass::gitCommit(const QString &file, const QString &msg) {
269 if (file.isEmpty()) {
270 executeGit(GIT_COMMIT, commitArgs() + QStringList{"-m", msg});
271 } else {
273 commitArgs() + QStringList{"-m", msg, "--", pgit(file)});
274 }
275}
276
277void 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 file = QDir::cleanPath(m_settings.passStore + file);
281 if (!isDir) {
282 file += ".gpg";
283 }
284 // A link itself may go; anything behind one is not the store's to delete.
286 return;
287 }
289 return;
290 }
292 QString path = QDir(m_settings.passStore).relativeFilePath(file);
293 path.replace(Util::endsWithGpg(), "");
294 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 if (!Util::removeTree(file)) {
298 emit critical(tr("Delete failed"),
299 tr("Could not remove the link %1.").arg(file));
300 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 if (gitReady() && gitTracks(file)) {
306 executeGit(GIT_RM, {"rm", "-q", "--cached", "--", pgit(file)});
307 gitCommit(file, "Remove for " + path + " using QtPass.");
308 }
309 return;
310 }
311 if (gitReady()) {
312 executeGit(GIT_RM, {"rm", (isDir ? "-rf" : "-f"), "--", pgit(file)});
313 gitCommit(file, "Remove for " + path + " using QtPass.");
314 } else {
315 if (isDir) {
316 // Never QDir::removeRecursively(): it follows a junction inside the
317 // folder and empties whatever that points to.
318 Util::removeTree(file);
319 } else {
320 QFile(file).remove();
321 }
322 }
323}
324
326 return {m_settings.gpgExecutable,
328 [this](const QString &app, const QStringList &args,
329 const QString &input, QString *out, QString *err) {
330 return execBlocking(app, args, input, out, err);
331 }};
332}
333
334auto ImitatePass::addGenerationHeader(const QString &gpgIdFile,
335 const GpgIdSigner &signer,
336 QByteArray *contents) -> bool {
337 const std::optional<QString> folder =
338 GpgIdGeneration::folderOf(gpgIdFile, m_settings.passStore);
339 if (!folder) {
340 emit critical(tr("Cannot update"),
341 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 .arg(gpgIdFile));
345 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 std::optional<qint64> verifiedOnDisk;
350 std::optional<QString> storeOnDisk;
351 QByteArray current;
352 if (signer.verifyFile(gpgIdFile, &current)) {
353 const auto header = GpgIdGeneration::parse(current);
354 if (header && (!header->folder || *header->folder == *folder)) {
355 verifiedOnDisk = header->generation;
356 storeOnDisk = header->store;
357 }
358 }
359 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 GpgIdGeneration::storeIdFor(m_settings.passStore, storeOnDisk, &why);
364 if (!store) {
365 emit critical(tr("Cannot update"), why);
366 return false;
367 }
368 const std::optional<qint64> generation =
369 GpgIdGeneration::reserveNext(gpgIdFile, verifiedOnDisk, &why);
370 if (!generation) {
371 emit critical(tr("Cannot update"), why);
372 return false;
373 }
374 *contents =
375 GpgIdGeneration::withHeader(*generation, *folder, *contents, *store);
376 return true;
377}
378
379auto ImitatePass::writeGpgIdFile(const QString &gpgIdFile,
380 const QList<UserInfo> &users,
381 QByteArray *written) -> bool {
382 QByteArray contents;
383 bool secret_selected = false;
384 for (const UserInfo &user : users) {
385 if (user.enabled) {
386 contents += (user.key_id + "\n").toUtf8();
387 secret_selected |= user.have_secret;
388 }
389 }
390 // Without a signing key the plain list stays (GpgIdGeneration).
391 const GpgIdSigner signer = gpgIdSigner();
392 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 QString writeError;
398 if (!Util::writeFileReplacing(gpgIdFile, contents, true, &writeError)) {
399 emit critical(tr("Cannot update"), writeError);
400 return false;
401 }
402 if (written != nullptr) {
403 *written = contents;
404 }
405 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 QString why;
410 if (!GpgIdGeneration::recordWritten(gpgIdFile, contents, &why)) {
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) {
418 emit critical(
419 tr("Check selected users!"),
420 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}
425
426auto ImitatePass::signGpgIdFile(const QString &gpgIdFile,
427 const QByteArray &contents) -> bool {
428 const GpgIdSigner signer = gpgIdSigner();
429 QString why;
430 if (!signer.sign(gpgIdFile, contents, &why)) {
431 emit critical(
432 tr("GPG signing failed!"),
433 why.trimmed().isEmpty()
434 ? tr("Failed to sign %1.").arg(gpgIdFile)
435 : tr("Failed to sign %1: %2").arg(gpgIdFile, why.trimmed()));
436 return false;
437 }
438 QByteArray signedBytes;
439 if (!signer.verifyFile(gpgIdFile, &signedBytes)) {
440 emit critical(tr("Check .gpg-id file signature!"),
441 tr("Signature for %1 is invalid.").arg(gpgIdFile));
442 return false;
443 }
444 return true;
445}
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.
450auto ImitatePass::gitAddGpgId(const QString &gpgIdFile,
451 const QString &gpgIdSigFile, QString *out,
452 QString *err) -> int {
453 const QString git = m_settings.gitExecutable;
454 const QString store = pgit(m_settings.passStore);
455 auto run = [&](const QStringList &args) -> int {
456 QString runOut;
457 QString runErr;
458 const int rc =
459 execBlocking(git, QStringList{"-C", store} + args, &runOut, &runErr);
460 if (out != nullptr) {
461 out->append(runOut);
462 }
463 if (err != nullptr) {
464 err->append(runErr);
465 }
466 return rc;
467 };
468 QStringList paths{pgit(gpgIdFile)};
469 if (!gpgIdSigFile.isEmpty()) {
470 paths << pgit(gpgIdSigFile);
471 }
472 // add stages new and modified files alike; already-clean ones are a no-op.
473 int rc = run(QStringList{"add", "--"} + paths);
474 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 rc = run(QStringList{"diff", "--cached", "--quiet", "--"} + paths);
481 if (rc == 0) {
482 return 0;
483 }
484 if (rc != 1) {
485 return rc;
486 }
487 QString commitPath = gpgIdFile;
488 commitPath.replace(Util::endsWithGpg(), "");
489 return run(commitArgs() +
490 QStringList{"-m", "Added " + commitPath + " using QtPass.", "--"} +
491 paths);
492}
493
494auto ImitatePass::gitTracks(const QString &file) -> bool {
495 return execBlocking(m_settings.gitExecutable,
496 {"-C", pgit(m_settings.passStore), "ls-files",
497 "--error-unmatch", "--", pgit(file)}) == 0;
498}
499
500auto ImitatePass::refuseLinkedGpgIdFolder(const QString &path) -> bool {
501 // A link planted later is replaced, not written through: the writes stage
502 // and rename.
503 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) {
508 };
509 return refusePlaced(path) ||
510 refusePlaced(folder + QStringLiteral("/.gpg-id")) ||
511 refusePlaced(folder + QStringLiteral("/.gpg-id.sig"));
512}
513
514auto ImitatePass::settleSignature(const QString &gpgIdFile,
515 const QByteArray &written,
516 const GpgIdSigner &signer, bool useGit,
517 QString *sigToCommit) -> bool {
518 const QString gpgIdSigFile = gpgIdFile + ".sig";
519 if (signer.enabled()) {
520 if (!signGpgIdFile(gpgIdFile, written)) {
521 return false;
522 }
523 *sigToCommit = gpgIdSigFile;
524 return true;
525 }
526 if (!QFile::exists(gpgIdSigFile)) {
527 return true;
528 }
529 // Signing was switched off; the removal goes into the same commit.
530 const bool tracked = useGit && gitTracks(gpgIdSigFile);
531 if (!QFile::remove(gpgIdSigFile)) {
532 emit critical(
533 tr("Cannot update"),
534 tr("Failed to remove the old signature %1.").arg(gpgIdSigFile));
535 return false;
536 }
537 if (tracked) {
538 *sigToCommit = gpgIdSigFile;
539 }
540 return true;
541}
542
543void 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 if (!QDir::isAbsolutePath(path)) {
550 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 path = Util::normalizeFolderPath(QDir::cleanPath(path));
556 QString inStore;
557 if (refuseLinkedGpgIdFolder(path) ||
558 refuseFolderOutsideStore(path, &inStore)) {
559 return;
560 }
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.
569 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 if (m_reencryptActive) {
574 emit critical(tr("Recipients not changed"),
575 tr("A re-encryption is still running. Wait until it has "
576 "finished, then save the recipients again."));
577 return;
578 }
579 const GpgIdSigner signer = gpgIdSigner();
580 if (signer.enabled() && !signer.haveSecretKey()) {
581 emit critical(tr("No signing key!"),
582 tr("None of the secret signing keys is available.\n"
583 "You will not be able to change the user list!"));
584 return;
585 }
586
587 const bool useGit = gitReady();
588 const QString gpgIdFile = path + ".gpg-id";
589 QByteArray written;
590 QString sigToCommit;
591 if (!writeGpgIdFile(gpgIdFile, users, &written) ||
592 !settleSignature(gpgIdFile, written, signer, useGit, &sigToCommit)) {
593 return;
594 }
595
596 QString gitOut;
597 QString gitErr;
598 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 const int gitExit = gitAddGpgId(gpgIdFile, sigToCommit, &gitOut, &gitErr);
603 if (gitExit != 0) {
604 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();
612 path,
613 why.isEmpty() ? tr("Git exited with code %1.").arg(gitExit) : why);
614 return;
615 }
616 }
617 reencryptPath(path);
618 // finishedInit once the .gpg-id landed in git, as the async path did.
619 if (useGit) {
620 Pass::finished(PASS_INIT, 0, gitOut, gitErr);
621 }
622}
623
624auto ImitatePass::loadVerifiedRecipients(const QString &gpgIdFile,
625 QStringList *recipients, QString *why)
626 -> bool {
627 recipients->clear();
628 if (why != nullptr) {
629 *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 if (Util::isLinkedFolder(gpgIdFile)) {
634 return !gpgIdSigner().enabled();
635 }
636 QByteArray contents;
637 const GpgIdSigner signer = gpgIdSigner();
638 if (signer.enabled()) {
639 if (!verifySignedList(signer, gpgIdFile, &contents, why)) {
640 return false;
641 }
642 } else {
643 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 }
647 // Bounded, and only a regular file: see Util::readRegularFile().
648 std::optional<QByteArray> bytes =
650 if (!bytes) {
651 if (why != nullptr) {
652 *why = tr("%1 cannot be read as a recipient list (a regular file "
653 "of at most %2).")
654 .arg(gpgIdFile,
655 QLocale().formattedDataSize(Util::kMaxGpgIdBytes));
656 }
657 return false;
658 }
659 contents = *std::move(bytes);
660 }
661 *recipients = Pass::parseRecipients(contents, gpgIdFile);
662 return true;
663}
664
665void ImitatePass::reportUnrestorable(const QString &path) {
666 emit critical(tr("Leftover from an earlier re-encryption"),
667 tr("%1 is not a regular file and was not restored. Look "
668 "at it and remove it, then re-encrypt again.")
669 .arg(path));
670}
671
672void ImitatePass::dropStaleTemporary(const QString &path) {
673 // A crash's temporary is picked up by a run an hour later.
674 if (QFileInfo(path).lastModified() >
675 QDateTime::currentDateTime().addSecs(-3600)) {
676 qCDebug(lcQtPass) << "Leaving a recent temporary alone:" << path;
677 return;
678 }
679 if (!QFile::remove(path)) {
680 qCWarning(lcQtPass) << "Could not remove stale temporary" << path;
681 }
682}
683
684auto ImitatePass::restoreBackup(const QString &path) -> bool {
685 // Belt and braces: the walker only lists regular files.
686 const QFileInfo backup(path);
687 if (backup.isSymLink() || !backup.isFile()) {
688 reportUnrestorable(path);
689 return false;
690 }
691 const QString original =
692 path.chopped(QStringLiteral(".reencrypt.bak").size());
693 if (QFileInfo::exists(original)) {
694 emit critical(tr("Leftover from an earlier re-encryption"),
695 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 .arg(path, original));
699 return false;
700 }
701 if (!QFile::rename(path, original)) {
702 emit critical(tr("Leftover from an earlier re-encryption"),
703 tr("%1 is missing and its backup %2 could not be renamed "
704 "back. Rename it by hand, then re-encrypt again.")
705 .arg(original, path));
706 return false;
707 }
708 qCWarning(lcQtPass) << "Restored" << original << "from" << path;
709 emit statusMsg(tr("Restored %1 from the backup an interrupted "
710 "re-encryption left behind.")
711 .arg(original),
712 5000);
713 return true;
714}
715
716auto 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 const QStringList leftoverNames{QStringLiteral(".qtpass-??????.tmp"),
726 QStringLiteral("*.gpg.reencrypt.tmp"),
727 QStringLiteral("*.gpg.??????.tmp"),
728 QStringLiteral("*.gpg.reencrypt.bak")};
729 const auto isTemporary = [](const QString &path) {
730 return path.endsWith(QStringLiteral(".tmp"));
731 };
732 QStringList skipped;
733 // Hidden files included: the staged name starts with a dot.
734 const QStringList leftovers = Util::regularFilesUnder(
735 QDir::cleanPath(dir), leftoverNames, &skipped, true);
736 bool clean = true;
737 for (const QString &path : skipped) {
738 if (!QDir::match(leftoverNames, QFileInfo(path).fileName())) {
739 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 if (isTemporary(path)) {
745 if (!QFile::remove(path) && !QDir().rmdir(path)) {
746 qCWarning(lcQtPass) << "Could not remove stale temporary" << path;
747 }
748 continue;
749 }
750 reportUnrestorable(path);
751 clean = false;
752 }
753 for (const QString &path : leftovers) {
754 if (isTemporary(path)) {
755 dropStaleTemporary(path);
756 } else {
757 clean = restoreBackup(path) && clean;
758 }
759 }
760 return clean;
761}
762
763auto 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 if (refuseSkippedGpgId(file, gpgIdSigner().enabled())) {
769 return false;
770 }
771 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 const auto cached = verified.constFind(gpgIdPath);
775 if (cached != verified.constEnd()) {
776 gpgId = cached.value();
777 return true;
778 }
779 QStringList recipients;
780 QString why;
781 if (!loadVerifiedRecipients(gpgIdPath, &recipients, &why)) {
782 // An interrupted gpg is a cancel, not a bad signature.
783 if (!m_reencryptCancel.load())
784 emit critical(tr("Check .gpg-id file signature!"), why);
785 return false;
786 }
787 recipients.sort();
788 verified.insert(gpgIdPath, recipients);
789 gpgId = recipients;
790 return true;
791}
792
793auto ImitatePass::getKeysFromFile(const QString &fileName) -> QStringList {
794 QStringList args = {
795 "-v", "--no-secmem-warning", "--no-permission-warning",
796 "--list-only", "--keyid-format=long", "--",
797 pgpg(fileName)};
798 QString keys;
799 QString err;
800 const int result = execBlocking(m_settings.gpgExecutable, args, &keys, &err);
801 if (result != 0) {
802 return {};
803 }
804 QStringList actualKeys;
805 keys += err;
806 QStringList key = keys.split(Util::newLinesRegex(), Qt::SkipEmptyParts);
807 QListIterator<QString> itr(key);
808 while (itr.hasNext()) {
809 QString current = itr.next();
810 QStringList cur = current.split(" ");
811 if (cur.length() > 4) {
812 QString actualKey = cur.takeAt(4);
813 if (actualKey.length() == 16) {
814 actualKeys << actualKey;
815 }
816 }
817 }
818 actualKeys.sort();
819 return actualKeys;
820}
821
822auto ImitatePass::decryptEntry(const QString &fileName, QString *plaintext)
823 -> bool {
824 const QStringList args =
825 QStringList{"-d", "--quiet", "--yes", "--no-encrypt-to",
826 "--batch", "--use-agent"} +
827 maxOutputArgs() + QStringList{QStringLiteral("--"), pgpg(fileName)};
828 if (execBlocking(m_settings.gpgExecutable, args, plaintext) != 0 ||
829 plaintext->isEmpty()) {
830 qCDebug(lcQtPass) << "Decrypt error on re-encrypt for:" << fileName;
831 return false;
832 }
833 if (!plaintext->endsWith(u'\n')) {
834 plaintext->append(u'\n');
835 }
836 return true;
837}
838
839auto ImitatePass::encryptFor(const QString &output,
840 const QStringList &recipients,
841 const QString &plaintext) -> bool {
842 const QStringList args =
843 encryptArgs({"--yes", "--batch"}, pgpg(output), recipients);
844 if (execBlocking(m_settings.gpgExecutable, args, plaintext) != 0) {
845 qCDebug(lcQtPass) << "Encrypt error on re-encrypt, output:" << output;
846 return false;
847 }
848 return true;
849}
850
851auto ImitatePass::ciphertextHolds(const QString &ciphertext,
852 const QString &plaintext) -> bool {
853 QString decrypted;
854 const QStringList args =
855 QStringList{"-d", "--quiet", "--batch", "--use-agent"} + maxOutputArgs() +
856 QStringList{QStringLiteral("--"), pgpg(ciphertext)};
857 if (execBlocking(m_settings.gpgExecutable, args, &decrypted) != 0 ||
858 decrypted.isEmpty()) {
859 qCDebug(lcQtPass) << "Verification failed for:" << ciphertext;
860 return false;
861 }
862 // Defence in depth: gpg said it encrypted, and this is what comes back.
863 if (decrypted.trimmed() != plaintext.trimmed()) {
864 qCDebug(lcQtPass) << "Verification content mismatch for:" << ciphertext;
865 return false;
866 }
867 return true;
868}
869
870auto ImitatePass::commitReencrypted(const QString &fileName) -> bool {
871 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 const QString store = pgit(m_settings.passStore);
877 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 qCDebug(lcQtPass) << "git add failed after re-encrypting:" << fileName;
883 return false;
884 }
885 QString path = QDir(m_settings.passStore).relativeFilePath(fileName);
886 path.replace(Util::endsWithGpg(), "");
887 if (execBlocking(m_settings.gitExecutable,
888 QStringList{"-C", store} + commitArgs() +
889 QStringList{"-m",
890 "Re-encrypt for " + path + " using QtPass.",
891 "--", pgit(fileName)}) != 0) {
892 qCDebug(lcQtPass) << "git commit failed after re-encrypting:" << fileName;
893 return false;
894 }
895 return true;
896}
897
898auto ImitatePass::reencryptSingleFile(const QString &fileName,
899 const QStringList &recipients,
900 QString *why) -> bool {
901 qCDebug(lcQtPass) << "reencrypt" << fileName << "for" << recipients.size()
902 << "recipients";
903 if (recipients.isEmpty()) {
904 emit critical(tr("Can not edit"),
905 tr("Could not read encryption key to use, .gpg-id "
906 "file missing or invalid."));
907 return false;
908 }
909 QString plaintext;
910 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 QTemporaryDir scratch;
918 if (!scratch.isValid()) {
919 qCDebug(lcQtPass) << "Cannot create a scratch directory for re-encrypting"
920 << fileName;
921 return false;
922 }
923 const QString tempPath = scratch.filePath(QStringLiteral("reencrypted.tmp"));
924 if (!encryptFor(tempPath, recipients, plaintext) ||
925 !ciphertextHolds(tempPath, plaintext)) {
926 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 const QFileInfo still(fileName);
933 if (!still.exists() && !still.isSymLink()) {
934 qCDebug(lcQtPass) << "Entry vanished before it could be replaced:"
935 << fileName;
936 return false;
937 }
938 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 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 *why = tr("%1 could not be re-encrypted: %2").arg(fileName, *why);
946 }
947 return false;
948 }
949 return commitReencrypted(fileName);
950}
951
953 if (!reencryptUsesGit()) {
954 return true;
955 }
956 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 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 QString statusOut;
964 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 if (!m_reencryptCancel.load())
969 emit critical(
970 tr("Backup commit failed"),
971 tr("Could not inspect git status. Re-encryption was aborted."));
972 return false;
973 }
974 if (!statusOut.trimmed().isEmpty()) {
975 if (execBlocking(git, {"-C", store, "add", "-u"}) != 0 ||
977 git, QStringList{"-C", store} + commitArgs() +
978 QStringList{"-m", "Backup before re-encryption"}) != 0) {
979 if (!m_reencryptCancel.load())
980 emit critical(tr("Backup commit failed"),
981 tr("Re-encryption was aborted because a git backup "
982 "could not be created."));
983 return false;
984 }
985 }
986 return true;
987}
988
994 int total = 0;
995 int checked = 0;
996 int reencrypted = 0;
997 QStringList failed;
1000 bool cancelled = false;
1002 bool aborted = false;
1003};
1004
1005namespace {
1007constexpr int kReencryptRetryMs = 100;
1009constexpr int kReencryptMaxListedFailures = 15;
1010} // namespace
1011
1012void ImitatePass::reencryptPath(const QString &dir,
1013 const AppSettings &settings) {
1014 if (!m_reencryptActive) {
1015 init(settings);
1016 updateEnv();
1017 }
1018 reencryptPath(dir);
1019}
1020
1021void ImitatePass::reencryptPath(const QString &dir) {
1022 if (m_reencryptActive) {
1023 emit statusMsg(tr("A re-encryption is already running"), 3000);
1024 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().
1030 emit critical(tr("Not a folder of the store"),
1031 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 .arg(QDir::toNativeSeparators(QDir::cleanPath(dir))));
1035 emit endReencryptPath();
1036 return;
1037 }
1039 // Re-encrypting now would run createBackupCommit()'s `git add -u` and
1040 // conclude the stopped merge; refuse before the worker starts.
1041 emit endReencryptPath();
1042 return;
1043 }
1044 m_reencryptActive = true;
1045 m_reencryptCancel.store(false);
1046 emit statusMsg(tr("Re-encrypting from folder %1").arg(dir), 3000);
1047 emit startReencryptPath();
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.
1054 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.
1062void ImitatePass::startReencryptWorker(const QString &dir) {
1063 if (m_reencryptCancel.load()) {
1064 ReencryptResult result;
1065 result.cancelled = true;
1066 finishReencrypt(result);
1067 return;
1068 }
1069 if (!exec.isIdle()) {
1070 QTimer::singleShot(kReencryptRetryMs, this,
1071 [this, dir]() { startReencryptWorker(dir); });
1072 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).
1081 ReencryptResult result;
1082 result.aborted = true;
1083 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 updateEnv();
1094 QThread *thread = QThread::create([this, self, dir]() {
1095 ReencryptResult result = reencryptFiles(dir);
1096 QMetaObject::invokeMethod(
1097 self,
1098 [self, result = std::move(result)]() {
1099 if (self)
1100 self->finishReencrypt(result);
1101 },
1102 Qt::QueuedConnection);
1103 });
1104 m_reencryptThread = thread;
1105 connect(thread, &QThread::finished, this, [this, thread]() {
1106 if (m_reencryptThread == thread)
1107 m_reencryptThread = nullptr;
1108 });
1109 connect(thread, &QThread::finished, thread, &QObject::deleteLater);
1110 thread->start();
1111}
1112
1119 if (!m_settings.autoPull || !reencryptUsesGit()) {
1120 return true;
1121 }
1122 emit statusMsg(tr("Updating password-store"), 2000);
1123 if (execBlocking(m_settings.gitExecutable,
1124 {"-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 QString unmerged;
1131 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 emit critical(tr("Git pull failed"),
1136 tr("Could not check the store for unmerged files after the "
1137 "pull. Check it with git before re-encrypting."));
1138 return false;
1139 }
1140 if (!unmerged.trimmed().isEmpty()) {
1141 emit critical(tr("Git pull failed"),
1142 tr("The pull left the store with unmerged files. Resolve "
1143 "the conflict before re-encrypting."));
1144 return false;
1145 }
1146 emit statusMsg(tr("Git pull failed, re-encrypting the store as it is"), 5000);
1147 return true;
1148}
1149
1150auto 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 QStringList skipped;
1154 const QStringList files =
1155 Util::regularFilesUnder(dir, QStringList() << "*.gpg", &skipped);
1156 if (!skipped.isEmpty()) {
1157 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 return files;
1163}
1164
1165auto ImitatePass::recipientsForDir(const QString &fileName,
1166 QHash<QString, QStringList> &verified,
1167 QStringList &gpgId, ReencryptResult &result)
1168 -> bool {
1169 if (!verifyGpgIdForDir(fileName, verified, gpgId)) {
1170 if (m_reencryptCancel.load()) {
1171 result.cancelled = true;
1172 } else {
1173 result.aborted = true;
1174 }
1175 return false;
1176 }
1177 if (gpgId.isEmpty() && !verified.isEmpty()) {
1178 emit critical(tr("GPG ID verification failed"),
1179 tr("Could not verify .gpg-id for directory."));
1180 result.aborted = true;
1181 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.
1190 ReencryptResult result;
1191 if (!pullBeforeReencrypt()) {
1192 result.aborted = true;
1193 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 if (const QString midOp = unfinishedGitOperationReason(); !midOp.isEmpty()) {
1202 if (!m_reencryptCancel.load()) {
1203 emit critical(tr("Unfinished Git operation"), midOp);
1204 }
1205 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 if (Util::isUnderLink(dir, m_settings.passStore)) {
1212 emit critical(tr("Not part of the store"),
1213 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 .arg(QDir::toNativeSeparators(QDir::cleanPath(dir))));
1217 result.aborted = true;
1218 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 if (!recoverReencryptLeftovers(dir)) {
1224 result.aborted = true;
1225 return result;
1226 }
1227
1228 if (!createBackupCommit()) {
1229 if (m_reencryptCancel.load()) {
1230 result.cancelled = true;
1231 } else {
1232 result.aborted = true;
1233 }
1234 return result;
1235 }
1236
1237 const QStringList files = entriesToReencrypt(dir);
1238 result.total = files.size();
1239 emit reencryptProgress(0, result.total);
1240
1241 QString currentDir;
1242 QHash<QString, QStringList> gpgIdFilesVerified;
1243 QStringList gpgId;
1244 for (const QString &fileName : std::as_const(files)) {
1245 if (m_reencryptCancel.load()) {
1246 result.cancelled = true;
1247 break;
1248 }
1249 const QString fileDir = QFileInfo(fileName).path();
1250 if (fileDir != currentDir) {
1251 if (!recipientsForDir(fileName, gpgIdFilesVerified, gpgId, result)) {
1252 return result;
1253 }
1254 currentDir = fileDir;
1255 }
1256 if (getKeysFromFile(fileName) != gpgId) {
1257 QString why;
1258 if (reencryptSingleFile(fileName, gpgId, &why)) {
1259 result.reencrypted++;
1260 } else if (m_reencryptCancel.load()) {
1261 // Interrupted by the cancel: the file is untouched, not failed.
1262 result.cancelled = true;
1263 break;
1264 } else {
1265 // The reason names the entry itself when there is one to give.
1266 result.failed << (why.isEmpty() ? fileName : why);
1267 }
1268 }
1269 result.checked++;
1270 emit reencryptProgress(result.checked, result.total);
1271 }
1272 return result;
1273}
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.
1278 if (!result.failed.isEmpty()) {
1279 QStringList listed = result.failed.mid(0, kReencryptMaxListedFailures);
1280 const int more = result.failed.size() - listed.size();
1281 if (more > 0) {
1282 listed << tr("... and %n more", nullptr, more);
1283 }
1284 emit critical(tr("Re-encryption failed"),
1285 tr("%n file(s) could not be re-encrypted:", nullptr,
1286 result.failed.size()) +
1287 "\n\n" + listed.join('\n'));
1288 }
1289
1290 if (result.cancelled) {
1291 emit statusMsg(tr("Re-encryption cancelled: %1 of %2 files checked, "
1292 "%3 re-encrypted, %4 failed")
1293 .arg(result.checked)
1294 .arg(result.total)
1295 .arg(result.reencrypted)
1296 .arg(result.failed.size()),
1297 5000);
1298 } else if (!result.aborted) {
1299 if (!result.failed.isEmpty()) {
1300 emit statusMsg(tr("Re-encryption completed: %1 succeeded, %2 failed")
1301 .arg(result.reencrypted)
1302 .arg(result.failed.size()),
1303 5000);
1304 } else {
1305 emit statusMsg(tr("Re-encryption completed: %1 files re-encrypted")
1306 .arg(result.reencrypted),
1307 3000);
1308 }
1310 if (result.failed.isEmpty()) {
1311 emit statusMsg(tr("Updating password-store"), 2000);
1312 GitPush();
1313 } else {
1314 emit statusMsg(tr("Not pushing: %n file(s) failed to re-encrypt",
1315 nullptr, result.failed.size()),
1316 5000);
1317 }
1318 }
1319 }
1320 if (m_uncommittedGpgId) {
1321 const UncommittedGpgId uncommitted = *m_uncommittedGpgId;
1322 m_uncommittedGpgId.reset();
1323 reportUncommittedGpgId(uncommitted.folder, uncommitted.gitError,
1324 !result.cancelled && !result.aborted &&
1325 result.failed.isEmpty());
1326 }
1327 m_reencryptActive = false;
1328 emit endReencryptPath();
1329}
1330
1331auto ImitatePass::reencryptUsesGit() const -> bool {
1332 return gitConfigured() && !m_uncommittedGpgId;
1333}
1334
1335void ImitatePass::reencryptWithoutGit(const QString &dir,
1336 const QString &gitError) {
1337 if (m_reencryptActive) {
1338 // The run in flight was started with its own Git mode; this folder is
1339 // not part of it.
1340 reportUncommittedGpgId(dir, gitError, false);
1341 return;
1342 }
1343 m_uncommittedGpgId = UncommittedGpgId{dir, gitError};
1344 reencryptPath(dir);
1346 // Refused before it started; reencryptPath() said why.
1347 m_uncommittedGpgId.reset();
1348 reportUncommittedGpgId(dir, gitError, false);
1349 }
1350}
1351
1353 const QString &gitError,
1354 bool reencrypted) {
1355 const QString folder = QDir::toNativeSeparators(QDir::cleanPath(dir));
1356 if (reencrypted) {
1357 emit critical(
1358 tr("Recipients not committed"),
1359 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 .arg(folder, gitError));
1364 return;
1365 }
1366 emit critical(
1367 tr("Recipients not committed"),
1368 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 .arg(folder, gitError));
1374}
1375
1377 const QString &dest, bool force)
1378 -> QString {
1379 QFileInfo srcFileInfo(src);
1380 QFileInfo destFileInfo(dest);
1381 QString destFile;
1382 QString srcFileBaseName = srcFileInfo.fileName();
1383
1384 if (srcFileInfo.isFile()) {
1385 if (destFileInfo.isFile()) {
1386 if (!force) {
1387 qCDebug(lcQtPass) << "Destination file already exists";
1388 return {};
1389 }
1390 destFile = dest;
1391 } else if (destFileInfo.isDir()) {
1392 destFile = QDir(dest).filePath(srcFileBaseName);
1393 } else {
1394 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 if (destFile.endsWith(QLatin1String(".gpg"))) {
1402 destFile.chop(4);
1403 }
1404 destFile.append(".gpg");
1405 } else if (srcFileInfo.isDir()) {
1406 if (destFileInfo.isDir()) {
1407 destFile = QDir(dest).filePath(srcFileBaseName);
1408 } else if (destFileInfo.isFile()) {
1409 qCDebug(lcQtPass) << "Destination is a file";
1410 return {};
1411 } else {
1412 destFile = dest;
1413 }
1414 } else {
1415 qCDebug(lcQtPass) << "Source file does not exist";
1416 return {};
1417 }
1418 return destFile;
1419}
1420
1421void ImitatePass::executeMoveGit(const QString &src, const QString &destFile,
1422 bool force) {
1423 QStringList args;
1424 args << "mv";
1425 if (force) {
1426 args << "-f";
1427 }
1428 args << "--" << pgit(src) << pgit(destFile);
1429 executeGit(GIT_MOVE, args);
1430
1431 QString relSrc = QDir(m_settings.passStore).relativeFilePath(src);
1432 relSrc.replace(Util::endsWithGpg(), "");
1433 QString relDest = QDir(m_settings.passStore).relativeFilePath(destFile);
1434 relDest.replace(Util::endsWithGpg(), "");
1435 QString message = QString("Moved for %1 to %2 using QtPass.");
1436 message = message.arg(relSrc, relDest);
1437 gitCommit("", message);
1438}
1439
1440void ImitatePass::Move(const QString src, const QString dest,
1441 const bool force) {
1442 if (refuseLinkedPath(src) || refuseLinkedPath(dest)) {
1443 return;
1444 }
1446 return;
1447 }
1449 QString destFile = resolveMoveDestination(src, dest, force);
1450 if (destFile.isEmpty()) {
1451 return;
1452 }
1453 if (refuseLinkedPath(destFile)) {
1454 return;
1455 }
1456
1457 qCDebug(lcQtPass) << "Move Source: " << src;
1458 qCDebug(lcQtPass) << "Move Destination: " << destFile;
1459
1460 if (gitReady()) {
1461 executeMoveGit(src, destFile, force);
1462 } else {
1463 QDir qDir;
1464 if (force) {
1465 qDir.remove(destFile);
1466 }
1467 qDir.rename(src, destFile);
1468 }
1469}
1470
1471auto ImitatePass::copyDestination(const QString &src, const QString &dest,
1472 bool force) -> QString {
1473 const auto refuse = [this, &src](const QString &to) {
1474 emit critical(tr("Copy failed"),
1475 tr("Could not copy %1 to %2.").arg(src, to));
1476 return QString();
1477 };
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 const QString destFile = resolveMoveDestination(src, dest, force);
1482 if (destFile.isEmpty()) {
1483 return refuse(dest);
1484 }
1485 // A link planted under that name (dangling ones pass exists()) is not an
1486 // entry to write.
1487 if (refuseLinkedPath(destFile)) {
1488 return {};
1489 }
1490 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 if (QFileInfo(src) == destFileInfo || (!force && destFileInfo.exists())) {
1495 return refuse(destFile);
1496 }
1497 return destFile;
1498}
1499
1500void 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 if (refuseLinkedPath(src) || refuseLinkedPath(dest)) {
1505 return;
1506 }
1508 return;
1509 }
1511 const QString destFile = copyDestination(src, dest, force);
1512 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 QString why;
1520 if (!Util::copyFileReplacing(src, destFile, force, &why)) {
1521 emit critical(tr("Copy failed"),
1522 tr("Could not copy %1 to %2.").arg(src, destFile) + "\n" +
1523 why);
1524 return;
1525 }
1526 if (gitReady()) {
1527 executeGit(GIT_COPY, {"add", "--", pgit(destFile)});
1528 gitCommit("",
1529 QString("Copied from %1 to %2 using QtPass.").arg(src, destFile));
1530 }
1531 const QFileInfo written(destFile);
1532 if (written.isDir()) {
1533 reencryptPath(written.absoluteFilePath());
1534 } else if (written.isFile()) {
1535 reencryptPath(written.dir().path());
1536 }
1537}
1538
1539void ImitatePass::executeGpg(PROCESS id, const QStringList &args, QString input,
1540 bool readStdout, bool readStderr) {
1541 executeWrapper(id, m_settings.gpgExecutable, args, std::move(input),
1542 readStdout, readStderr);
1543}
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).
1547auto ImitatePass::gitConfigured() const -> bool {
1548 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.
1554 if (m_settings.useGit && m_settings.gitExecutable.isEmpty()) {
1555 emit statusMsg(tr("Git executable not configured, skipping git"), 3000);
1556 return false;
1557 }
1558 return m_settings.useGit;
1559}
1560
1561void 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 executeWrapper(id, m_settings.gitExecutable, args, std::move(input),
1566 readStdout, readStderr);
1567}
1568
1569// Only PASS_* processes reach Pass::finished, so to the interface this looks
1570// the same as RealPass.
1571void ImitatePass::finished(int id, int exitCode, const QString &out,
1572 const QString &err) {
1573 qCDebug(lcQtPass) << "Imitate Pass";
1574 QString error = err;
1575 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 m_insertPlaced = false;
1586 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 const QString midOp = unfinishedGitOperationReason();
1594 if (!midOp.isEmpty()) {
1595 error = midOp;
1596 exitCode = 1;
1597 } else {
1598 m_insertPlaced = placeEncryptedFile(pending.output, pending.file,
1599 pending.overwrite, &error);
1600 if (!m_insertPlaced) {
1601 exitCode = 1;
1602 }
1603 }
1604 }
1605 }
1606 PROCESS pid = m_transaction.transactionIsOver(static_cast<PROCESS>(id));
1607 m_transactionOutput.append(out);
1608
1609 if (exitCode == 0) {
1610 if (pid == INVALID) {
1611 return;
1612 }
1613 } else {
1614 while (pid == INVALID) {
1615 id = exec.cancelNext();
1616 if (id == -1) {
1617 // this is probably irrecoverable and shall not happen
1618 qCDebug(lcQtPass) << "No such transaction!";
1619 return;
1620 }
1621 pid = m_transaction.transactionIsOver(static_cast<PROCESS>(id));
1622 }
1623 }
1624 Pass::finished(pid, exitCode, m_transactionOutput, error);
1625 m_transactionOutput.clear();
1626}
1627
1628auto ImitatePass::placeEncryptedFile(const QString &output, const QString &file,
1629 bool overwrite, QString *error) -> bool {
1630 if (QFileInfo(output).size() <= 0) {
1631 // gpg reported success and wrote nothing: not an entry.
1632 *error = tr("gpg wrote no ciphertext for %1.").arg(file);
1633 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 return Util::copyFileReplacing(output, file, overwrite, error);
1638}
1639
1643
1644// PCRE, as for RealPass (see Pass::Grep). Runs on a NativeGrep thread; the
1645// result arrives on this thread as finishedGrep.
1646void ImitatePass::Grep(QString pattern, bool caseInsensitive) {
1647 m_grep.search(pattern, caseInsensitive, m_settings.gpgExecutable,
1649}
auto isIdle() const -> bool
Whether no command is running and nothing is queued.
Definition executor.h:425
static auto executeBlocking(const QString &app, const QStringList &args, const QString &input=QString(), QString *process_out=nullptr, QString *process_err=nullptr) -> int
Run a command synchronously and return its exit code.
Definition executor.cpp:422
static auto translatePathForWsl(const QString &path, const QString &exe) -> QString
Translate a native path for a WSL-routed executable.
Definition executor.cpp:177
auto environment() const -> QProcessEnvironment
Return the environment passed to child processes.
Definition executor.cpp:457
auto cancelNext() -> int
Cancel the next queued command without executing it.
Definition executor.cpp:462
static auto folderOf(const QString &gpgIdFile, const QString &storeRoot) -> std::optional< QString >
The store-relative folder of gpgIdFile, as written in and compared against the folder line: ....
static auto reserveNext(const QString &gpgIdFile, std::optional< qint64 > verifiedOnDisk, QString *error=nullptr) -> std::optional< qint64 >
Reserve the generation to write next for gpgIdFile: one above both what this device remembers and ver...
static auto recordWritten(const QString &gpgIdFile, const QByteArray &contents, QString *error=nullptr) -> bool
Record the bytes just written for gpgIdFile, so that this device recognises its own list afterwards a...
static auto withHeader(qint64 generation, const QString &folder, const QByteArray &recipients, const QString &store={}) -> QByteArray
A complete list: the header in front of the recipients.
static auto parse(const QByteArray &contents, QString *error=nullptr) -> std::optional< Header >
The header a .gpg-id's bytes declare.
static auto storeIdFor(const QString &storeRoot, const std::optional< QString > &onDisk, QString *error=nullptr) -> std::optional< QString >
The store id to write into a list of storeRoot: the one this device knows the store by; else onDisk,...
The detached signature on a store's .gpg-id: whether the configured signing key is usable,...
Definition gpgidsigner.h:28
auto enabled() const -> bool
Whether any signing key is configured at all.
Definition gpgidsigner.h:72
auto sign(const QString &gpgIdFile, const QByteArray &contents, QString *error=nullptr) const -> bool
Write <file>.sig, a detached signature by the first key over contents, the bytes the caller just wrot...
auto verifyFile(const QString &gpgIdFile, QByteArray *contents) const -> bool
Read gpgIdFile and verify it against <file>.sig.
static auto keysFromSetting(const QString &passSigningKey) -> QStringList
Split the settings value into keys, upper-cased the way gpg prints fingerprints.
auto haveSecretKey() const -> bool
Whether gpg has the secret key that sign() will use, i.e. the first configured key.
RAII helper for wrapping operations in transactions.
auto commitReencrypted(const QString &fileName) -> bool
Stage and commit a re-encrypted entry, when the store is a Git repository QtPass manages.
auto encryptFor(const QString &output, const QStringList &recipients, const QString &plaintext) -> bool
Encrypt plaintext to recipients, into output.
auto createBackupCommit() -> bool
Create git backup commit before re-encryption.
void finishReencrypt(const ReencryptResult &result)
Report the outcome of a run and emit endReencryptPath().
void Remove(QString file, bool isDir) override
Remove password.
bool m_insertPlaced
QString m_transactionOutput
stdout of the steps of the transaction in flight, collected by finished() and handed on,...
auto execBlocking(const QString &app, const QStringList &args, const QString &input=QString(), QString *process_out=nullptr, QString *process_err=nullptr) -> int
Executor::executeBlocking() for the re-encryption helpers.
void GitInit() override
Initialize Git repository.
auto recipientsForEntry(const QString &file, QStringList *recipients) -> bool
The recipients to encrypt file to: its folder's .gpg-id, verified when a signing key is set.
void executeGpg(PROCESS id, const QStringList &args, QString input=QString(), bool readStdout=true, bool readStderr=true)
Execute GPG command.
auto ciphertextHolds(const QString &ciphertext, const QString &plaintext) -> bool
Decrypt ciphertext again and compare it with plaintext, so nothing replaces an entry until it is know...
auto reencryptSingleFile(const QString &fileName, const QStringList &recipients, QString *why) -> bool
Re-encrypt single file with new recipients.
void reportUncommittedGpgId(const QString &dir, const QString &gitError, bool reencrypted)
Tell the user that the recipients of dir changed on disk but the change is not committed,...
auto copyDestination(const QString &src, const QString &dest, bool force) -> QString
The file a copy of src to dest writes: like pass cp, into an existing folder with ....
bool m_reencryptActive
Set while a re-encryption run is pending or on the worker thread.
void startReencryptWorker(const QString &dir)
Start the worker thread once the Executor queue is idle.
auto entriesToReencrypt(const QString &dir) -> QStringList
The entries under dir a run considers, telling the user about anything skipped for not being a regula...
auto writeGpgIdFile(const QString &gpgIdFile, const QList< UserInfo > &users, QByteArray *written=nullptr) -> bool
Write the enabled recipients to a .gpg-id, atomically: a write that fails halfway (full disk,...
auto commitArgs() -> QStringList
The start of a git commit for the store: "commit", and "-S" when git config pass.signcommits is true ...
auto verifyGpgIdForDir(const QString &file, QHash< QString, QStringList > &verified, QStringList &gpgId) -> bool
Recipients for the directory of file, from its verified .gpg-id.
void Grep(QString pattern, bool caseInsensitive) override
Search all password content by GPG-decrypting each .gpg file.
void Insert(QString file, QString newValue, bool overwrite) override
Insert new password.
auto recipientsForDir(const QString &fileName, QHash< QString, QStringList > &verified, QStringList &gpgId, ReencryptResult &result) -> bool
The verified recipients for the folder fileName sits in.
auto gitTracks(const QString &file) -> bool
Check whether git already tracks a file in the store.
void GitPull_b() override
Pull with rebase.
~ImitatePass() override
Cancel a running content search, and cancel and join a running re-encryption worker,...
void cancelReencryptPath()
Ask a running reencryptPath() to stop.
ImitatePass()
Construct the backend and connect its content search.
auto gitReady() -> bool
gitConfigured() plus a status message when git is enabled but no executable is configured.
void executeGit(PROCESS id, const QStringList &args, QString input=QString(), bool readStdout=true, bool readStderr=true)
Execute git command.
void Init(QString path, const QList< UserInfo > &users) override
Initialize store.
auto loadVerifiedRecipients(const QString &gpgIdFile, QStringList *recipients, QString *why=nullptr) -> bool
Read a .gpg-id, verify its detached signature and parse the recipients from the bytes that were verif...
void Copy(const QString src, const QString dest, const bool force) override
Copy password file.
QThread * m_reencryptThread
The re-encryption worker while it runs, null otherwise. The destructor sets m_reencryptCancel and joi...
auto reencryptFiles(const QString &dir) -> ReencryptResult
Worker-thread body: scan dir and re-encrypt stale files.
auto placeEncryptedFile(const QString &output, const QString &file, bool overwrite, QString *error) -> bool
Bring the ciphertext gpg wrote to output into the store as file (Util::copyFileReplacing): through a ...
auto settleSignature(const QString &gpgIdFile, const QByteArray &written, const GpgIdSigner &signer, bool useGit, QString *sigToCommit) -> bool
Bring the signature in line with the list just written: a new one when signing is on,...
SimpleTransaction m_transaction
auto signGpgIdFile(const QString &gpgIdFile, const QByteArray &contents) -> bool
Sign a .gpg-id with the configured key and verify the result; failures are reported through critical(...
QProcessEnvironment m_reencryptEnv
void reencryptProgress(int current, int total)
Re-encryption progress: current of total files checked.
void executeMoveGit(const QString &src, const QString &destFile, bool force)
Execute git move operation.
void endReencryptPath()
A reencryptPath() run is over, whether it finished, failed, was cancelled or was refused; the main wi...
auto translatePathForWsl(const QString &path, const QString &exe) const -> QString
Translate path for the given exe when WSL-routed: wraps in wslpath substitution, otherwise returns pa...
void finished(int id, int exitCode, const QString &out, const QString &err) override
Handle process completion.
void reencryptWithoutGit(const QString &dir, const QString &gitError)
Re-encrypt dir to the .gpg-id Init() wrote although its commit failed, without any Git step (they wou...
auto addGenerationHeader(const QString &gpgIdFile, const GpgIdSigner &signer, QByteArray *contents) -> bool
Put the generation header on a list that is about to be signed: reserve a generation above what this ...
auto gitAddGpgId(const QString &gpgIdFile, const QString &gpgIdSigFile, QString *out, QString *err) -> int
Stage a .gpg-id and, when given, its .sig, and commit both in one commit, so no commit in the history...
std::optional< UncommittedGpgId > m_uncommittedGpgId
std::atomic< bool > m_reencryptCancel
void GitPull() override
Pull from remote.
auto refuseLinkedGpgIdFolder(const QString &path) -> bool
Refuse a folder, or a .gpg-id/.gpg-id.sig in it, that is a link: writing there would reach outside th...
void Show(QString file) override
Show decrypted password.
auto pgpg(const QString &path) const -> QString
Translate path for gpg when WSL-routed: wraps in wslpath substitution, otherwise returns path unchang...
auto storeGit(const QStringList &args, QString *out) -> bool override
Run git with -C <store> in the store, blocking.
QQueue< PendingInsert > m_pendingInserts
The inserts whose gpg step has not ended yet, oldest first.
NativeGrep m_grep
Background search over the decrypted store; relays to finishedGrep.
auto resolveMoveDestination(const QString &src, const QString &dest, bool force) -> QString
Resolve destination for move operation.
auto recoverReencryptLeftovers(const QString &dir) -> bool
Deal with what a crashed or failed earlier run left under dir before touching anything: stale tempora...
void GitPush() override
Push to remote.
void Move(const QString src, const QString dest, const bool force) override
Move password file.
auto restoreBackup(const QString &path) -> bool
Put a 1.8.x backup back in its entry's place when the entry is missing; report it when both exist (bo...
void dropStaleTemporary(const QString &path)
Remove a stale temporary a crashed run left, unless it was modified within the hour: on a shared stor...
void startReencryptPath()
A reencryptPath() run is about to start its worker thread.
auto decryptEntry(const QString &fileName, QString *plaintext) -> bool
Decrypt fileName for re-encryption.
auto getKeysFromFile(const QString &fileName) -> QStringList
Read recipients from file.
auto reencryptUsesGit() const -> bool
gitConfigured(), unless the current run is one reencryptWithoutGit() started.
auto gitConfigured() const -> bool
Git is enabled and an executable is configured.
void reencryptPath(const QString &dir)
Re-encrypt entire directory.
auto pgit(const QString &path) const -> QString
Translate path for git when WSL-routed: wraps in wslpath substitution, otherwise returns path unchang...
void reportUnrestorable(const QString &path)
Tell the user a leftover is not a regular file and was left.
auto pullBeforeReencrypt() -> bool
Pull before a run, when the store is a Git repository and automatic pulling is on.
void gitCommit(const QString &file, const QString &msg)
Commit changes to git.
void beforeExecute(PROCESS id) override
Open a transaction for every execution.
auto gpgIdSigner() -> GpgIdSigner
The signer for this store's .gpg-id, bound to the configured gpg and signing keys and to execBlocking...
void finished(const QList< QPair< QString, QStringList > > &results)
The newest search is over.
void search(const QString &pattern, bool caseInsensitive, const QString &gpgExe, const QString &storeDir, const QProcessEnvironment &env)
Start a search; finished() follows asynchronously, also for an empty or invalid pattern (with no resu...
void cancel()
Ask running searches to stop without waiting for them: the walk ends at the next file and a gpg that ...
auto storeEnvironment() const -> QProcessEnvironment
The environment QtPass runs the store's processes in.
Definition pass.h:96
AppSettings m_settings
Application settings snapshot injected at init() time.
Definition pass.h:117
static auto getGpgIdPath(const QString &for_file, const QString &passStore) -> QString
Get .gpg-id file path for a password file.
Definition pass.cpp:920
auto refusePendingPull() -> bool
Refuse a store change while a Git pull is still queued or running.
Definition pass.cpp:883
Enums::PROCESS PROCESS
Alias for Enums::PROCESS used throughout this class.
Definition pass.h:126
void queueShow(const QString &file)
Remember what a Show() asked for, so its completion can say so.
Definition pass.h:474
void statusMsg(const QString &msg, int timeout)
Progress or a non-fatal problem worth a line in the status bar ("Creating backup commit",...
auto refuseSkippedGpgId(const QString &file, bool signing, const QString &except=QString()) -> bool
With signing on, refuse to encrypt file when the lookup passed over its folder's own list (skippedGpg...
Definition pass.cpp:927
auto unfinishedGitOperationReason() -> QString
The unfinished git operation in progress in the store, if any.
Definition pass.cpp:835
@ PlacedByCaller
Through the target of a linked store root, by a caller that places such a folder itself (Init()): not...
Definition pass.h:507
static auto maxOutputArgs() -> QStringList
The gpg options that enforce kMaxPlaintextBytes.
Definition pass.cpp:701
void executeWrapper(PROCESS id, const QString &app, const QStringList &args, bool readStdout=true, bool readStderr=true)
Execute external wrapper command.
Definition pass.cpp:113
auto refuseLinkedPath(const QString &path, LinkScope scope=LinkScope::EntryAndParents, PathSpelling spelling=PathSpelling::Configured) -> bool
Refuse an operation on something that is, or lies behind, a symbolic link or NTFS junction inside the...
Definition pass.cpp:791
void finishedGrep(const QList< QPair< QString, QStringList > > &results)
The newest Grep() is over; an empty list means no entry matched.
Executor exec
Internal command executor for queuing and running subprocesses.
Definition pass.h:121
auto verifySignedList(const GpgIdSigner &signer, const QString &gpgIdFile, QByteArray *contents, QString *why) const -> bool
Whether gpgIdFile is a signed list QtPass accepts: its signature verifies (GpgIdSigner::verifyFile())...
Definition pass.cpp:963
void init(const AppSettings &settings)
Initialize the Pass instance with a settings snapshot.
Definition pass.cpp:158
auto withCurrentGitPin(QProcessEnvironment base) const -> QProcessEnvironment
base with GIT_DIR and GIT_WORK_TREE as the store is now (Util::gitStoreVariables()),...
Definition pass.cpp:687
void updateEnv()
Update environment for subprocesses.
Definition pass.cpp:631
virtual void finished(int id, int exitCode, const QString &out, const QString &err)
Handle process completion.
Definition pass.cpp:523
void critical(const QString &title, const QString &message)
Something went wrong that the user has to be told about in a dialog, such as a refused path or a fail...
@ ParentsOnly
Only the folders above it: a removal unlinks a link and touches nothing behind it.
Definition pass.h:493
@ EntryAndParents
The entry itself and every folder above it in the store.
Definition pass.h:488
auto refuseUnfinishedGitOperation() -> bool
Refuse to change the store while git is mid-rebase, -merge, -cherry-pick or -revert.
Definition pass.cpp:898
void queueInsert()
Count one insert that has started queueing but not yet reported.
Definition pass.h:484
static auto parseRecipients(const QByteArray &contents, const QString &sourceName) -> QStringList
Parse the contents of a .gpg-id: one recipient per line, # starts a comment, unusable lines are logge...
Definition pass.cpp:1161
auto refuseFolderOutsideStore(const QString &folder, QString *relative=nullptr) -> bool
Refuse recipients for a folder outside the store, as pass refuses a path with .. in it.
Definition pass.cpp:819
void transactionAdd(Enums::PROCESS process)
Note that process has been queued.
auto transactionIsOver(Enums::PROCESS current) -> Enums::PROCESS
Tell whether the process that just finished ends the oldest pending transaction, and take that transa...
static auto regularFilesUnder(const QString &dir, const QStringList &nameFilters, QStringList *skipped=nullptr, bool hiddenFiles=false) -> QStringList
Regular files under dir whose name matches one of nameFilters, found by walking only real directories...
Definition util.cpp:721
static auto removeTree(const QString &dir) -> bool
Remove dir and everything in it, the way rm -rf does: a symbolic link or NTFS junction,...
Definition util.cpp:1239
static auto endsWithGpg() -> const QRegularExpression &
Returns a regex to match .gpg file extensions.
Definition util.cpp:582
static auto isUnderLink(const QString &path, const QString &storeRoot, bool includeSelf=true) -> bool
Whether the way from storeRoot down to path passes through a symbolic link or NTFS junction: path its...
Definition util.cpp:765
static auto writeFileReplacing(const QString &path, const QByteArray &bytes, bool replace, QString *error=nullptr) -> bool
Write bytes as the file path through stageFileReplacing().
Definition util.cpp:1165
static constexpr qint64 kMaxGpgIdBytes
Most bytes of a .gpg-id QtPass reads.
Definition util.h:387
static auto readRegularFile(const QString &path, qint64 maxBytes) -> std::optional< QByteArray >
Read path whole, as the regular file it is (openRegularFile()), when it holds at most maxBytes.
Definition util.cpp:973
static auto isLinkedFolder(const QString &path) -> bool
Whether path names a symbolic link or NTFS junction, with or without a trailing separator (which woul...
Definition util.cpp:761
static auto copyFileReplacing(const QString &src, const QString &dst, bool replace, QString *error=nullptr) -> bool
Copy the regular file src to the name dst through stageFileReplacing(): src is read through a handle ...
Definition util.cpp:1179
static auto newLinesRegex() -> const QRegularExpression &
Returns a regex to match newline characters.
Definition util.cpp:656
static auto normalizeFolderPath(const QString &path) -> QString
Ensure a folder path always ends with '/'.
Definition util.cpp:98
Executor, the FIFO queue every gpg, git and pass child process of QtPass goes through,...
Rollback detection for signed .gpg-id files: the generation counter and the record of what was last s...
@ PASS_INIT
Definition enums.h:53
@ PASS_INSERT
Definition enums.h:51
@ GIT_INIT
Definition enums.h:44
@ PASS_COPY
Definition enums.h:56
@ PASS_MOVE
Definition enums.h:55
@ GIT_MOVE
Definition enums.h:57
@ PASS_REMOVE
Definition enums.h:52
@ GIT_COPY
Definition enums.h:58
@ INVALID
Definition enums.h:63
@ GIT_COMMIT
Definition enums.h:46
@ GIT_RM
Definition enums.h:47
@ PASS_SHOW
Definition enums.h:50
@ GIT_ADD
Definition enums.h:45
@ GIT_PULL
Definition enums.h:48
@ GIT_PUSH
Definition enums.h:49
ImitatePass, the backend that does what pass does by running gpg and git itself.
@ CLIPBOARD_ALWAYS
Definition enums.h:26
@ CLIPBOARD_NEVER
Definition enums.h:25
@ CLIPBOARD_ON_DEMAND
Definition enums.h:27
@ PASS_INIT
Definition enums.h:53
@ PASS_INSERT
Definition enums.h:51
@ GIT_INIT
Definition enums.h:44
@ PASS_COPY
Definition enums.h:56
@ PASS_MOVE
Definition enums.h:55
@ GIT_MOVE
Definition enums.h:57
@ PASS_GREP
Definition enums.h:59
@ GPG_GENKEYS
Definition enums.h:54
@ PASS_REMOVE
Definition enums.h:52
@ GIT_COPY
Definition enums.h:58
@ INVALID
Definition enums.h:63
@ GIT_COMMIT
Definition enums.h:46
@ GIT_RM
Definition enums.h:47
@ PASS_SHOW
Definition enums.h:50
@ GIT_ADD
Definition enums.h:45
@ PROCESS_COUNT
Definition enums.h:62
@ GIT_PULL
Definition enums.h:48
@ GIT_PUSH
Definition enums.h:49
The "qtpass" logging category.
Plain value-object holding all flat QtPass configuration.
Definition appsettings.h:36
QString gpgExecutable
Path to the gpg/gpg2 executable.
Definition appsettings.h:66
QString passStore
Password-store root path.
Definition appsettings.h:60
QString gitExecutable
Path to the git executable.
Definition appsettings.h:65
bool useGit
Enable git integration.
bool addGPGId
Auto-add .gpg-id files to git.
QString file
The entry's path in the store, where output goes.
bool overwrite
Whether an entry already at file may be replaced.
QString output
The ciphertext file gpg writes, inside scratch.
Outcome of one reencryptPath() run; filled on the worker thread and consumed by finishReencrypt() on ...
int reencrypted
Files rewritten for the current recipients.
int checked
Files whose recipients were inspected.
int total
.gpg files found under the directory.
bool aborted
Stopped early on an error already reported.
A .gpg-id Init() wrote but could not commit.
QString folder
The folder whose recipients changed.
QString gitError
What Git said when the commit failed.
Stores GPG key info including validity, creation date, and more.
Definition userinfo.h:19
Util: static helpers for the process environment, binary discovery, store paths, URL safety and the l...