QtPass Developer documentation
QtPass test coverage
Current view: top level - src/imitatepass.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 93.1 % 841 783
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 71 71

            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, &current)) {
     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 : }
        

Generated by: LCOV version 2.0-1