QtPass Developer documentation
QtPass test coverage
Current view: top level - src/profileinit.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 97.9 % 190 186
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 15 15

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
       2              : // SPDX-License-Identifier: GPL-3.0-or-later
       3              : /**
       4              :  * @file
       5              :  * @brief Implements ProfileInit; the environments its gpg and git processes
       6              :  * run in are built by withGpgHome() and gitEnvironment() here.
       7              :  */
       8              : #include "profileinit.h"
       9              : #include "appsettings.h"
      10              : #include "executor.h"
      11              : #include "gpgidgeneration.h"
      12              : #include "gpgidsigner.h"
      13              : #include "qtpasslogging.h"
      14              : #include "userinfo.h"
      15              : #include "util.h"
      16              : #include <QDir>
      17              : #include <QFile>
      18              : #include <QFileInfo>
      19              : #include <QProcess>
      20              : #include <algorithm>
      21              : 
      22              : namespace {
      23              : /// @p env with the configured GPG home applied as Pass::init() applies it:
      24              : /// GNUPGHOME is that folder when it exists; otherwise the inherited one
      25              : /// stays. The new store's .gpg-id is signed, and a signed commit made, with
      26              : /// the keyring QtPass uses everywhere else, where the signing key may be
      27              : /// the only place it is.
      28          236 : auto withGpgHome(QProcessEnvironment env, const AppSettings &s)
      29              :     -> QProcessEnvironment {
      30          236 :   if (!s.gpgHome.isEmpty()) {
      31           11 :     QDir home(s.gpgHome);
      32           11 :     home.makeAbsolute();
      33           11 :     if (home.exists()) {
      34           16 :       env.insert(QStringLiteral("GNUPGHOME"), home.path());
      35              :     }
      36           11 :   }
      37          236 :   return env;
      38              : }
      39              : 
      40              : /// As Pass::updateEnv() sets it for the active store: git in @p dir does not
      41              : /// look above it, so neither the identity check nor a commit reads or uses a
      42              : /// repository the new store merely lies in (a dotfiles one in the home
      43              : /// folder), unless the user opted into that; and paths are names to it, not
      44              : /// patterns.
      45           26 : auto gitEnvironment(const QString &dir, const AppSettings &s)
      46              :     -> QProcessEnvironment {
      47              :   QProcessEnvironment env =
      48           26 :       withGpgHome(QProcessEnvironment::systemEnvironment(), s);
      49              :   // As for the active store: the paths handed to git are names, not
      50              :   // patterns (see Pass::updateEnv()).
      51           52 :   env.insert(QStringLiteral("GIT_LITERAL_PATHSPECS"), QStringLiteral("1"));
      52           26 :   if (!s.gitUseParentRepository) {
      53              :     for (const auto &[name, value] :
      54           75 :          Util::gitStoreVariables(dir, s.gitExecutable)) {
      55           25 :       env.insert(name, value);
      56              :     }
      57              :   }
      58           26 :   return env;
      59            0 : }
      60              : /// "commit", and "-S" when git config pass.signcommits is true for the
      61              : /// repository @p git runs in (a new one: so the user's own git config), as
      62              : /// pass signs its commits.
      63            7 : auto commitArgs(QProcess &git, const AppSettings &s) -> QStringList {
      64            7 :   QString out;
      65              :   const bool sign =
      66           42 :       Executor::executeBlocking(
      67            7 :           git, s.gitExecutable,
      68            7 :           {QStringLiteral("config"), QStringLiteral("--bool"),
      69            7 :            QStringLiteral("--get"), QStringLiteral("pass.signcommits")},
      70           14 :           QString(), &out, nullptr) == 0 &&
      71           10 :       out.trimmed() == QLatin1String("true");
      72           14 :   return sign ? QStringList{QStringLiteral("commit"), QStringLiteral("-S")}
      73           21 :               : QStringList{QStringLiteral("commit")};
      74              : }
      75              : } // namespace
      76              : 
      77          201 : auto ProfileInit::gpgEnvironment(const AppSettings &s) -> QProcessEnvironment {
      78          201 :   return withGpgHome(QProcessEnvironment::systemEnvironment(), s);
      79              : }
      80              : 
      81           11 : auto ProfileInit::needsInit(const QString &path) -> bool {
      82           11 :   if (path.isEmpty()) {
      83              :     return false;
      84              :   }
      85           10 :   QDir dir(path);
      86           10 :   if (!dir.exists()) {
      87              :     return false;
      88              :   }
      89            9 :   return !dir.exists(".gpg-id");
      90           10 : }
      91              : 
      92           26 : auto ProfileInit::initialise(const QString &dir, const QList<UserInfo> &users,
      93              :                              const AppSettings &s, bool useGit, QString *note)
      94              :     -> bool {
      95           26 :   QString scratch;
      96           26 :   QString &out = note != nullptr ? *note : scratch;
      97           26 :   out.clear();
      98              : 
      99           26 :   const QDir folder(dir);
     100           32 :   if (!folder.exists() && !Util::mkpathForStore(folder.absolutePath())) {
     101            2 :     out = tr("Could not create %1.").arg(folder.absolutePath());
     102            1 :     return false;
     103              :   }
     104           50 :   const QString gpgIdFile = folder.filePath(QStringLiteral(".gpg-id"));
     105           25 :   QByteArray written;
     106           50 :   if (!writeGpgId(gpgIdFile, users, !s.passSigningKey.trimmed().isEmpty(), &out,
     107              :                   &written)) {
     108              :     return false;
     109              :   }
     110           22 :   QString sigFile;
     111           22 :   if (!s.passSigningKey.trimmed().isEmpty()) {
     112            6 :     if (!signGpgId(gpgIdFile, written, s, &out)) {
     113              :       return false;
     114              :     }
     115            4 :     sigFile = gpgIdFile + QStringLiteral(".sig");
     116              :   }
     117           23 :   if (useGit &&
     118           23 :       !commitGpgId(folder.absolutePath(), gpgIdFile, sigFile, s, &out)) {
     119              :     return false;
     120              :   }
     121              : 
     122              :   // Entries in sub-folders too: a store taken over whole has most of them
     123              :   // there. Visible regular files only, not through links, .git not walked.
     124              :   // Not those under a sub-folder with a .gpg-id of its own: its recipients
     125              :   // did not change here, and Users on the profile's root leaves it alone,
     126              :   // as pass does.
     127           16 :   const QString root = QDir::cleanPath(folder.absolutePath());
     128           16 :   const bool viaPass = s.usePass;
     129              :   const QString rootList =
     130           48 :       QFileInfo(QDir(root).filePath(QStringLiteral(".gpg-id")))
     131           16 :           .canonicalFilePath();
     132           17 :   const auto underSubStore = [&root, &rootList, viaPass](const QString &entry) {
     133           17 :     QString dir = QFileInfo(entry).absolutePath();
     134           36 :     while (dir != root && dir.startsWith(root + QLatin1Char('/'))) {
     135              :       // A list of its own: a regular file. A linked .gpg-id is no list to
     136              :       // the native backend, as everywhere in QtPass, so its folder's
     137              :       // entries still count; pass follows the link (`-f`) and keeps that
     138              :       // folder on the linked list, so with it they do not, unless the link
     139              :       // leads to the root's own list: that folder follows the root, and
     140              :       // Users on it re-encrypts them.
     141           24 :       const QFileInfo list(QDir(dir).filePath(QStringLiteral(".gpg-id")));
     142           12 :       if (viaPass && list.isFile() && !rootList.isEmpty() &&
     143           15 :           list.canonicalFilePath() == rootList) {
     144              :         return false;
     145              :       }
     146           11 :       if (list.isFile() &&
     147            5 :           (viaPass || (!list.isSymLink() && !list.isJunction()))) {
     148            4 :         return true;
     149              :       }
     150           14 :       dir = QFileInfo(dir).absolutePath();
     151           12 :     }
     152              :     return false;
     153           16 :   };
     154              :   QStringList existing =
     155           48 :       Util::regularFilesUnder(root, {QStringLiteral("*.gpg")});
     156           16 :   existing.erase(
     157           32 :       std::remove_if(existing.begin(), existing.end(), underSubStore),
     158           16 :       existing.end());
     159           16 :   if (!existing.isEmpty()) {
     160            9 :     out = tr("%1 already contains %n encrypted file(s); they were not "
     161              :              "re-encrypted. Switch to the profile and open Users to do that.",
     162              :              nullptr, static_cast<int>(existing.size()))
     163           18 :               .arg(folder.absolutePath());
     164              :   }
     165              :   return true;
     166           26 : }
     167              : 
     168           25 : auto ProfileInit::writeGpgId(const QString &gpgIdFile,
     169              :                              const QList<UserInfo> &users, bool signed_,
     170              :                              QString *note, QByteArray *written) -> bool {
     171           25 :   QStringList ids;
     172           71 :   for (const UserInfo &user : users) {
     173           46 :     if (user.enabled) {
     174           24 :       ids << user.key_id;
     175              :     }
     176              :   }
     177           25 :   if (ids.isEmpty()) {
     178            2 :     *note = tr("No recipient selected; %1 was not written.").arg(gpgIdFile);
     179            1 :     return false;
     180              :   }
     181              :   // As ImitatePass::writeGpgIdFile: renamed into place whole, so an
     182              :   // interrupted write leaves no half .gpg-id to sign or use. Owner-only: the
     183              :   // list leaks which keys the store is encrypted to.
     184              :   QByteArray contents =
     185           24 :       (ids.join(QLatin1Char('\n')) + QLatin1Char('\n')).toUtf8();
     186           24 :   if (signed_) {
     187              :     // Same rule as ImitatePass::writeGpgIdFile: reserved and recorded
     188              :     // before the write, bound to the store root (GpgIdGeneration). A new
     189              :     // profile has no list on disk to take a generation from.
     190            7 :     QString why;
     191              :     const std::optional<QString> store = GpgIdGeneration::storeIdFor(
     192           14 :         QFileInfo(gpgIdFile).absolutePath(), std::nullopt, &why);
     193            7 :     if (!store) {
     194            0 :       *note = why;
     195            0 :       return false;
     196              :     }
     197              :     const std::optional<qint64> generation =
     198            7 :         GpgIdGeneration::reserveNext(gpgIdFile, std::nullopt, &why);
     199            7 :     if (!generation) {
     200            1 :       *note = why;
     201            1 :       return false;
     202              :     }
     203           12 :     contents = GpgIdGeneration::withHeader(*generation, QStringLiteral("."),
     204              :                                            contents, *store);
     205              :   }
     206           23 :   QString why;
     207           23 :   if (!Util::writeFileReplacing(gpgIdFile, contents, true, &why)) {
     208            1 :     *note = why;
     209            1 :     return false;
     210              :   }
     211           22 :   if (written != nullptr) {
     212           22 :     *written = contents;
     213              :   }
     214           22 :   if (signed_) {
     215              :     // Same as ImitatePass::writeGpgIdFile: the bytes written are the ones
     216              :     // recognised; not fatal, the first read digests them otherwise.
     217            6 :     QString why;
     218            6 :     if (!GpgIdGeneration::recordWritten(gpgIdFile, contents, &why)) {
     219            0 :       qCWarning(lcQtPass) << "Could not record the written .gpg-id:" << why;
     220              :     }
     221              :   }
     222              :   return true;
     223              : }
     224              : 
     225            6 : auto ProfileInit::signGpgId(const QString &gpgIdFile,
     226              :                             const QByteArray &contents, const AppSettings &s,
     227              :                             QString *note) -> bool {
     228              :   // In the configured GPG home, as ImitatePass's signer runs in the store's
     229              :   // environment.
     230              :   const GpgIdSigner signer(
     231           12 :       s.gpgExecutable, GpgIdSigner::keysFromSetting(s.passSigningKey),
     232           15 :       [&s](const QString &app, const QStringList &args, const QString &input,
     233              :            QString *out, QString *err) {
     234            9 :         QProcess gpg;
     235            9 :         gpg.setProcessEnvironment(
     236           18 :             withGpgHome(QProcessEnvironment::systemEnvironment(), s));
     237           18 :         return Executor::executeBlocking(gpg, app, args, input, out, err);
     238           21 :       });
     239            6 :   QString err;
     240            6 :   if (!signer.sign(gpgIdFile, contents, &err)) {
     241            4 :     *note = err.trimmed().isEmpty()
     242           12 :                 ? tr("Could not sign %1 with %2.")
     243              :                       .arg(gpgIdFile, signer.keys().first())
     244            4 :                 : tr("Could not sign %1 with %2: %3")
     245            7 :                       .arg(gpgIdFile, signer.keys().first(), err.trimmed());
     246              :     // withGpgHome() fell back to the inherited home: gpg's "no secret key"
     247              :     // would not say that the configured one is missing.
     248            4 :     if (!s.gpgHome.isEmpty()) {
     249            1 :       QDir home(s.gpgHome);
     250            1 :       home.makeAbsolute();
     251            1 :       if (!home.exists()) {
     252              :         *note +=
     253            1 :             QLatin1Char(' ') +
     254            3 :             tr("The configured GPG home %1 does not exist.").arg(home.path());
     255              :       }
     256            1 :     }
     257            4 :     return false;
     258              :   }
     259              :   return true;
     260            6 : }
     261              : 
     262            5 : auto ProfileInit::commitGpgId(const QString &dir, const QString &gpgIdFile,
     263              :                               const QString &sigFile, const AppSettings &s,
     264              :                               QString *note) -> bool {
     265              :   // A process of our own in the profile directory: nothing here may touch
     266              :   // the shared Executor or PASSWORD_STORE_DIR of the active store.
     267            5 :   QProcess git;
     268            5 :   git.setWorkingDirectory(dir);
     269            5 :   git.setProcessEnvironment(gitEnvironment(dir, s));
     270           11 :   auto run = [&](const QStringList &args) -> bool {
     271           11 :     QString err;
     272           11 :     const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
     273           11 :                                              QString(), nullptr, &err);
     274           11 :     if (rc != 0) {
     275              :       *note =
     276            4 :           tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
     277            2 :       return false;
     278              :     }
     279              :     return true;
     280            5 :   };
     281           15 :   QStringList files{QFileInfo(gpgIdFile).fileName()};
     282            5 :   if (!sigFile.isEmpty()) {
     283            4 :     files << QFileInfo(sigFile).fileName();
     284              :   }
     285              :   const QStringList add =
     286           20 :       QStringList{QStringLiteral("add"), QStringLiteral("--")} + files;
     287           15 :   if (!run({QStringLiteral("init")}) || !run(add)) {
     288              :     return false;
     289              :   }
     290            9 :   return run(commitArgs(git, s) +
     291            6 :              QStringList{QStringLiteral("-m"),
     292            3 :                          QStringLiteral("Added .gpg-id using QtPass."),
     293           21 :                          QStringLiteral("--")} +
     294            3 :              files);
     295           10 : }
     296              : 
     297            6 : auto ProfileInit::initGit(const QString &dir, const AppSettings &s,
     298              :                           QString *note) -> bool {
     299            6 :   QProcess git;
     300            6 :   git.setWorkingDirectory(dir);
     301            6 :   git.setProcessEnvironment(gitEnvironment(dir, s));
     302           14 :   auto run = [&](const QStringList &args) -> bool {
     303           14 :     QString err;
     304           14 :     const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
     305           14 :                                              QString(), nullptr, &err);
     306           14 :     if (rc != 0) {
     307              :       *note =
     308            6 :           tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
     309            3 :       return false;
     310              :     }
     311              :     return true;
     312            6 :   };
     313              :   // Stage only store files (as the re-encryption backup commit does): an
     314              :   // existing folder may hold plaintext exports or swap files. Regular files
     315              :   // only: a link or junction must not have its target staged.
     316            6 :   QStringList files;
     317            6 :   const QDir base(dir);
     318              :   // Hidden directories (.git among them) are not walked, hidden files are:
     319              :   // .gpg-id is one.
     320           30 :   const QStringList found = Util::regularFilesUnder(
     321              :       dir,
     322            6 :       {QStringLiteral("*.gpg"), QStringLiteral(".gpg-id"),
     323            6 :        QStringLiteral(".gpg-id.sig")},
     324            6 :       nullptr, true);
     325           13 :   for (const QString &path : found) {
     326           14 :     files << base.relativeFilePath(path);
     327              :   }
     328           18 :   if (!run({QStringLiteral("init")})) {
     329              :     return false;
     330              :   }
     331            9 :   if (!files.isEmpty() &&
     332           21 :       !run(QStringList{QStringLiteral("add"), QStringLiteral("--")} + files)) {
     333              :     return false;
     334              :   }
     335           12 :   return run(commitArgs(git, s) +
     336            4 :              QStringList{QStringLiteral("--allow-empty"), QStringLiteral("-m"),
     337           28 :                          QStringLiteral("Added password store using QtPass.")});
     338           12 : }
     339              : 
     340           15 : auto ProfileInit::gitIdentityConfigured(const QString &dir,
     341              :                                         const AppSettings &s) -> bool {
     342           15 :   QProcess git;
     343           15 :   git.setWorkingDirectory(dir);
     344           15 :   git.setProcessEnvironment(gitEnvironment(dir, s));
     345           34 :   for (const char *key : {"user.name", "user.email"}) {
     346           25 :     QString out;
     347          125 :     if (Executor::executeBlocking(git, s.gitExecutable,
     348           25 :                                   {QStringLiteral("config"),
     349           25 :                                    QStringLiteral("--get"),
     350              :                                    QString::fromLatin1(key)},
     351           94 :                                   QString(), &out, nullptr) != 0 ||
     352           25 :         out.trimmed().isEmpty()) {
     353              :       return false;
     354              :     }
     355              :   }
     356              :   return true;
     357           65 : }
        

Generated by: LCOV version 2.0-1