QtPass Developer documentation
QtPass test coverage
Current view: top level - src/util.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 93.6 % 486 455
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 54 54

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2014 Anne Jan Brouwer
       2              : // SPDX-License-Identifier: GPL-3.0-or-later
       3              : /**
       4              :  * @file
       5              :  * @brief Implements Util; the staged, link-safe store writes
       6              :  * (stageFileReplacing() and its helpers) and the store walk (walkStore())
       7              :  * live here.
       8              :  */
       9              : #include "util.h"
      10              : #include "appsettings.h"
      11              : #include "executor.h"
      12              : #include <QCoreApplication>
      13              : #include <QDebug>
      14              : #include <QDir>
      15              : #include <QFile>
      16              : #include <QFileDevice>
      17              : #include <QFileInfo>
      18              : #include <QHash>
      19              : #include <QRegularExpression>
      20              : #include <QRegularExpressionMatchIterator>
      21              : #include <QStandardPaths>
      22              : #include <QTemporaryFile>
      23              : #include <QUrl>
      24              : 
      25              : #include <optional>
      26              : 
      27              : #ifdef Q_OS_WIN
      28              : #include <fcntl.h>
      29              : #include <io.h>
      30              : #include <windows.h>
      31              : #else
      32              : #include <cerrno>
      33              : #include <cstdio>
      34              : #include <fcntl.h>
      35              : #include <sys/stat.h>
      36              : #include <sys/time.h>
      37              : #include <unistd.h>
      38              : #endif
      39              : 
      40              : #include "qtpasslogging.h"
      41              : 
      42              : QProcessEnvironment Util::_env;
      43              : bool Util::_envInitialised = false;
      44              : 
      45              : // macOS and Windows: append the usual GPG install directories to PATH.
      46          681 : void Util::initialiseEnvironment() {
      47          681 :   if (!_envInitialised) {
      48            6 :     _env = QProcessEnvironment::systemEnvironment();
      49              : #ifdef __APPLE__
      50              :     QString path = _env.value("PATH");
      51              :     if (!path.contains("/usr/local/MacGPG2/bin") &&
      52              :         QDir("/usr/local/MacGPG2/bin").exists())
      53              :       path += ":/usr/local/MacGPG2/bin";
      54              :     if (!path.contains("/usr/local/bin"))
      55              :       path += ":/usr/local/bin";
      56              :     _env.insert("PATH", path);
      57              : #endif
      58              : #ifdef Q_OS_WIN
      59              :     QString path = _env.value("PATH");
      60              :     if (!path.contains("C:\\Program Files\\WinGPG\\x86") &&
      61              :         QDir("C:\\Program Files\\WinGPG\\x86").exists())
      62              :       path += ";C:\\Program Files\\WinGPG\\x86";
      63              :     if (!path.contains("C:\\Program Files\\GnuPG\\bin") &&
      64              :         QDir("C:\\Program Files\\GnuPG\\bin").exists())
      65              :       path += ";C:\\Program Files\\GnuPG\\bin";
      66              :     _env.insert("PATH", path);
      67              : #endif
      68            6 :     qCDebug(lcQtPass) << _env.value("PATH");
      69            6 :     _envInitialised = true;
      70              :   }
      71          681 : }
      72              : 
      73          273 : auto Util::findPasswordStore() -> QString {
      74          273 :   QString path;
      75          273 :   initialiseEnvironment();
      76          546 :   if (_env.contains("PASSWORD_STORE_DIR")) {
      77            6 :     path = Util::expandTilde(_env.value("PASSWORD_STORE_DIR"));
      78              :   } else {
      79              : #ifdef Q_OS_WIN
      80              :     path = QDir(QDir::homePath()).filePath("password-store");
      81              : #else
      82          810 :     path = QDir(QDir::homePath()).filePath(".password-store");
      83              : #endif
      84              :   }
      85          546 :   return Util::normalizeFolderPath(QDir::cleanPath(path));
      86              : }
      87              : 
      88           12 : auto Util::expandTilde(const QString &path) -> QString {
      89           12 :   if (path == QLatin1String("~")) {
      90            1 :     return QDir::homePath();
      91              :   }
      92           11 :   if (path.startsWith(QLatin1String("~/"))) {
      93           10 :     return QDir::homePath() + path.mid(1);
      94              :   }
      95              :   return path;
      96              : }
      97              : 
      98          393 : auto Util::normalizeFolderPath(const QString &path) -> QString {
      99              :   QString normalizedPath = path;
     100          393 :   if (!normalizedPath.endsWith('/')) {
     101          378 :     normalizedPath += '/';
     102              :   }
     103          393 :   return normalizedPath;
     104              : }
     105              : 
     106            1 : void Util::applyPassProcessDefaults() {
     107              :   // pass unsets these before it runs git: a GIT_DIR inherited from the
     108              :   // environment (QtPass started from a git hook, or an IDE terminal) would
     109              :   // send every commit and push to that repository instead of the store.
     110           11 :   for (const char *variable :
     111              :        {"GIT_DIR", "GIT_WORK_TREE", "GIT_NAMESPACE", "GIT_INDEX_FILE",
     112              :         "GIT_INDEX_VERSION", "GIT_OBJECT_DIRECTORY", "GIT_COMMON_DIR",
     113              :         // QtPass sets GIT_LITERAL_PATHSPECS for its git; git refuses a
     114              :         // pathspec when one of these is set as well.
     115           11 :         "GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS"}) {
     116           10 :     qunsetenv(variable);
     117              :   }
     118            1 :   Executor::setChildUmask(
     119            1 :       passUmask(qEnvironmentVariable("PASSWORD_STORE_UMASK")));
     120            1 : }
     121              : 
     122           11 : auto Util::passUmask(const QString &value) -> int {
     123              :   // Taken as it is, as bash's umask builtin (which pass hands it to) takes
     124              :   // it: " 022" is no umask there either. pass then keeps the session's;
     125              :   // QtPass falls back to 077.
     126              :   // anchoredPattern(), not ^...$: $ also matches before a final newline.
     127              :   static const QRegularExpression octal(
     128           35 :       QRegularExpression::anchoredPattern(QStringLiteral("[0-7]{1,4}")));
     129           11 :   if (!octal.match(value).hasMatch()) {
     130              :     return 077;
     131              :   }
     132            4 :   return value.toInt(nullptr, 8);
     133              : }
     134              : 
     135         1178 : auto Util::gitCeiling(const QString &store) -> QString {
     136         1178 :   if (store.isEmpty()) {
     137            0 :     return {};
     138              :   }
     139         1178 :   QString path = QFileInfo(store).canonicalFilePath();
     140         1178 :   if (path.isEmpty()) {
     141            0 :     path = QDir::cleanPath(QDir(store).absolutePath());
     142              :   }
     143         1178 :   return QFileInfo(path).absolutePath();
     144              : }
     145              : 
     146           40 : auto Util::repositoryPassClimbsTo(const QString &store) -> QString {
     147              : #ifdef Q_OS_WIN
     148              :   Q_UNUSED(store);
     149              :   return {};
     150              : #else
     151           40 :   if (!store.contains(QLatin1Char(':'))) {
     152           10 :     return {};
     153              :   }
     154              :   // git walks up from the real folder, so the real path is the one to walk,
     155              :   // and for a store that does not exist yet the one it will have: its
     156              :   // nearest existing folder resolved, the rest appended. By the raw string:
     157              :   // QDir and QFileInfo clean a path by name, and "link/.." is the parent
     158              :   // of the link's target, not the folder the link is in. On macOS the
     159              :   // temporary folder is under /var, a link to /private/var.
     160              :   // Absolute as git means it, a leading "/" (Qt would take ":name" for a
     161              :   // resource path).
     162              :   QString path =
     163           60 :       store.startsWith(u'/') ? store : QDir::currentPath() + u'/' + store;
     164           33 :   while (path.size() > 1 && path.endsWith(u'/')) {
     165            3 :     path.chop(1);
     166              :   }
     167           30 :   QString rest;
     168           40 :   while (!QFileInfo::exists(path)) {
     169           10 :     const qsizetype slash = path.lastIndexOf(u'/');
     170           10 :     if (slash < 0) {
     171            0 :       return {};
     172              :     }
     173           10 :     const QString name = path.mid(slash + 1);
     174           24 :     rest = rest.isEmpty() ? name : name + u'/' + rest;
     175           20 :     path = slash == 0 ? QStringLiteral("/") : path.left(slash);
     176              :   }
     177           30 :   const QString real = QFileInfo(path).canonicalFilePath();
     178           30 :   QString start = real.isEmpty() ? QDir::cleanPath(path) : real;
     179           30 :   if (!rest.isEmpty()) {
     180            8 :     start = QDir::cleanPath(start + u'/' + rest);
     181              :   }
     182              :   // The pieces of pass's ceiling as git takes them (setup.c,
     183              :   // canonicalize_ceiling_entry): relative ones are dropped, the rest
     184              :   // resolved, one that cannot be resolved is dropped; after an empty piece
     185              :   // ("::") they are kept exactly as written. A piece can still be a real
     186              :   // ceiling: "/repo/:vault/.." holds "/repo/".
     187           30 :   QString ceiling;
     188              :   bool afterEmpty = false;
     189              :   for (const QString &piece :
     190          123 :        (store + QStringLiteral("/..")).split(QLatin1Char(':'))) {
     191           63 :     if (piece.isEmpty()) {
     192              :       afterEmpty = true;
     193           31 :       continue;
     194              :     }
     195           61 :     if (!piece.startsWith(u'/')) {
     196           29 :       continue;
     197              :     }
     198              :     const QString entry =
     199           33 :         afterEmpty ? piece : QFileInfo(piece).canonicalFilePath();
     200              :     // git stops below the longest entry the store lies under
     201              :     // (longest_ancestor_length: "/" is above everything).
     202           32 :     const QString under = entry == u'/' ? entry : entry + u'/';
     203           32 :     if (!entry.isEmpty() && start.startsWith(under) &&
     204              :         entry.size() > ceiling.size()) {
     205            4 :       ceiling = entry;
     206              :     }
     207              :   }
     208              :   // What git takes for a repository (setup.c, is_git_directory()): HEAD
     209              :   // naming a ref or an object, objects/ and refs/. An empty or broken .git
     210              :   // is passed by, as git passes it by.
     211           81 :   const auto isGitDirectory = [](const QString &candidate) {
     212           81 :     const QDir dir(candidate);
     213          185 :     if (!QFileInfo(dir.filePath(QStringLiteral("objects"))).isDir() ||
     214          150 :         !QFileInfo(dir.filePath(QStringLiteral("refs"))).isDir()) {
     215              :       return false;
     216              :     }
     217           46 :     QFile head(dir.filePath(QStringLiteral("HEAD")));
     218           23 :     if (!head.open(QIODevice::ReadOnly)) {
     219              :       return false;
     220              :     }
     221              :     static const QRegularExpression valid(
     222           27 :         QStringLiteral("^(?:ref: refs/|[0-9a-f]{40}\\s*$|[0-9a-f]{64}\\s*$)"));
     223           69 :     return valid.match(QString::fromUtf8(head.read(256))).hasMatch();
     224           81 :   };
     225              :   // At each folder, as git: a .git folder that is a repository, or a .git
     226              :   // file naming one ("gitdir: ..."), is the repository. Any other .git file
     227              :   // stops git with an error, and pass then commits nothing; a .git folder
     228              :   // that is no repository is passed by.
     229              :   enum class Found { Nothing, Repository, Refused };
     230           80 :   const auto repositoryAt = [&isGitDirectory](const QString &dir) {
     231          160 :     const QString dotGit = QDir(dir).filePath(QStringLiteral(".git"));
     232           80 :     const QFileInfo info(dotGit);
     233           80 :     if (info.isDir()) {
     234           21 :       return isGitDirectory(dotGit) ? Found::Repository : Found::Nothing;
     235              :     }
     236           59 :     if (!info.exists()) {
     237              :       return Found::Nothing;
     238              :     }
     239            6 :     QFile file(dotGit);
     240            6 :     if (!file.open(QIODevice::ReadOnly)) {
     241              :       return Found::Refused;
     242              :     }
     243            6 :     const QString line = QString::fromUtf8(file.readLine(4096)).trimmed();
     244            6 :     if (!line.startsWith(QLatin1String("gitdir: "))) {
     245              :       return Found::Refused;
     246              :     }
     247            4 :     const QString target = line.mid(8);
     248            8 :     return isGitDirectory(target.startsWith(u'/') ? target
     249            7 :                                                   : QDir(dir).filePath(target))
     250            4 :                ? Found::Repository
     251              :                : Found::Refused;
     252          110 :   };
     253              :   // The store's own repository comes first; only without one does git look
     254              :   // further up, and never into the ceiling. By name from here: the real
     255              :   // path holds no link or "..".
     256              :   for (QString dir = start;;) {
     257           80 :     switch (repositoryAt(dir)) {
     258              :     case Found::Repository:
     259           48 :       return dir == start ? QString() : dir;
     260            3 :     case Found::Refused:
     261            3 :       return {};
     262              :     case Found::Nothing:
     263              :       break;
     264              :     }
     265              :     // A bare repository: git stops there, and pass, finding no work tree,
     266              :     // commits nothing.
     267           56 :     if (isGitDirectory(dir)) {
     268            1 :       return {};
     269              :     }
     270           55 :     const QString up = QFileInfo(dir).absolutePath();
     271           55 :     if (up == dir || up == ceiling) {
     272            5 :       return {};
     273              :     }
     274           50 :     dir = up;
     275              :   }
     276              : #endif
     277              : }
     278              : 
     279              : namespace {
     280              : /// setGitOwnerForTests(): who owns "the user's own" store, when set.
     281              : std::optional<uint> s_gitOwnerForTests;
     282              : } // namespace
     283              : 
     284            4 : void Util::setGitOwnerForTests(std::optional<uint> user) {
     285            4 :   s_gitOwnerForTests = user;
     286            4 : }
     287              : 
     288              : #ifdef Q_OS_UNIX
     289              : namespace {
     290              : /**
     291              :  * @brief Whether git's `safe.directory` trusts the repository at @p root,
     292              :  * as git's ownership check would judge it in place of ownership.
     293              :  *
     294              :  * Read as git reads it: only from system, global and command-line
     295              :  * configuration (a repository cannot vouch for itself), in order. `*`
     296              :  * trusts every repository, an empty value forgets the entries before it,
     297              :  * a path ending in a slash and `*` trusts what lies below it, and any other
     298              :  * absolute path trusts that one folder, compared after resolving links.
     299              :  * Relative entries (including git's ".") and paths that do not exist trust
     300              :  * nothing.
     301              :  * @param root The store's canonical path.
     302              :  * @param git The git executable; "git" from PATH when empty.
     303              :  * @return True when an entry names @p root; false when none does or git
     304              :  *         cannot be asked.
     305              :  */
     306           23 : auto isSafeDirectory(const QString &root, const QString &git) -> bool {
     307              :   // Outside any repository, as git reads its protected config: no
     308              :   // repository's own config, and no includeIf "gitdir:" or "onbranch:"
     309              :   // that matches (a GIT_DIR, even one that cannot exist, would be matched
     310              :   // against them, and one that cannot exist makes git config fail). From
     311              :   // "/", which a user cannot make a repository; a local scope is dropped
     312              :   // below in any case.
     313           23 :   QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
     314           46 :   env.remove(QStringLiteral("GIT_DIR"));
     315           46 :   env.remove(QStringLiteral("GIT_WORK_TREE"));
     316           46 :   env.remove(QStringLiteral("GIT_COMMON_DIR"));
     317              :   // git config alone reads GIT_CONFIG in place of the system and global
     318              :   // configuration (and reports it as "command"); the ownership check never
     319              :   // reads it.
     320           46 :   env.remove(QStringLiteral("GIT_CONFIG"));
     321           23 :   QProcess process;
     322           23 :   process.setProcessEnvironment(env);
     323           46 :   process.setWorkingDirectory(QStringLiteral("/"));
     324              :   // Bounded: Git calls wait for this, and a git that never answers trusts
     325              :   // nothing.
     326              :   constexpr int kQueryTimeoutMs = 5000;
     327           23 :   QString out;
     328          184 :   if (Executor::executeBlocking(
     329           46 :           process, git.isEmpty() ? QStringLiteral("git") : git,
     330           23 :           {QStringLiteral("config"), QStringLiteral("--show-scope"),
     331           23 :            QStringLiteral("--type=path"), QStringLiteral("--get-all"),
     332           23 :            QStringLiteral("safe.directory")},
     333           46 :           QString(), &out, nullptr, nullptr, kQueryTimeoutMs) != 0) {
     334              :     return false;
     335              :   }
     336              :   bool safe = false;
     337           14 :   const QStringList lines = out.split(QLatin1Char('\n'));
     338           47 :   for (const QString &line : lines) {
     339           33 :     const qsizetype tab = line.indexOf(QLatin1Char('\t'));
     340           33 :     if (tab < 0) {
     341           18 :       continue;
     342              :     }
     343           19 :     const QString scope = line.left(tab);
     344           19 :     if (scope != QLatin1String("system") && scope != QLatin1String("global") &&
     345            1 :         scope != QLatin1String("command")) {
     346            0 :       continue;
     347              :     }
     348           19 :     const QString value = line.mid(tab + 1);
     349           19 :     if (value.isEmpty()) {
     350              :       safe = false;
     351           17 :     } else if (value == QLatin1String("*")) {
     352              :       safe = true;
     353           13 :     } else if (!QDir::isAbsolutePath(value)) {
     354            3 :       continue;
     355           10 :     } else if (value.endsWith(QLatin1String("/*"))) {
     356              :       const QString base = value.chopped(2);
     357           12 :       QString prefix = QFileInfo(base.isEmpty() ? QStringLiteral("/") : base)
     358            3 :                            .canonicalFilePath();
     359            3 :       if (prefix.isEmpty()) {
     360              :         continue;
     361              :       }
     362            2 :       if (!prefix.endsWith(QLatin1Char('/'))) {
     363            2 :         prefix += QLatin1Char('/');
     364              :       }
     365            2 :       safe = safe || root.startsWith(prefix);
     366              :     } else {
     367            7 :       const QString named = QFileInfo(value).canonicalFilePath();
     368            7 :       safe = safe || (!named.isEmpty() && named == root);
     369              :     }
     370              :   }
     371              :   return safe;
     372           23 : }
     373              : } // namespace
     374              : #endif
     375              : 
     376         1178 : auto Util::gitStoreVariables(const QString &store, const QString &git,
     377              :                              std::optional<uint> asUser)
     378              :     -> QList<std::pair<QString, QString>> {
     379         1178 :   const QString ceiling = gitCeiling(store);
     380         1178 :   if (ceiling.isEmpty()) {
     381            0 :     return {};
     382              :   }
     383              :   QList<std::pair<QString, QString>> vars{
     384         3534 :       {QStringLiteral("GIT_CEILING_DIRECTORIES"), ceiling}};
     385              : #ifdef Q_OS_WIN
     386              :   const QChar separator = QLatin1Char(';');
     387              : #else
     388              :   const QChar separator = QLatin1Char(':');
     389              : #endif
     390              :   // git splits the ceiling list on the separator: "/home/a:b" would be
     391              :   // "/home/a" and "b", neither of them the parent, and git would climb.
     392         1178 :   if (ceiling.contains(separator)) {
     393           28 :     QString root = QFileInfo(store).canonicalFilePath();
     394           28 :     if (root.isEmpty()) {
     395            0 :       root = QDir::cleanPath(QDir(store).absolutePath());
     396              :     }
     397           56 :     QString gitDir = QDir(root).filePath(QStringLiteral(".git"));
     398              : #ifdef Q_OS_UNIX
     399              :     // What discovery's ownership check would have looked at: the work tree,
     400              :     // the .git, and the directory a gitfile names. Not the user's: no
     401              :     // repository at all, under a path that cannot be created (its parent
     402              :     // is a device), rather than another user's hooks run as this one.
     403              :     const uint me = asUser.value_or(
     404           28 :         s_gitOwnerForTests.value_or(static_cast<uint>(::geteuid())));
     405           33 :     const auto ownedOrMissing = [me](const QString &path) {
     406           33 :       const QFileInfo info(path);
     407           66 :       return !info.exists() || info.ownerId() == me;
     408           33 :     };
     409           28 :     bool owned = ownedOrMissing(root) && ownedOrMissing(gitDir);
     410           28 :     if (owned && QFileInfo(gitDir).isFile()) {
     411            0 :       QFile gitFile(gitDir);
     412            0 :       if (gitFile.open(QIODevice::ReadOnly)) {
     413              :         const QString line =
     414            0 :             QString::fromUtf8(gitFile.readLine(4096)).trimmed();
     415            0 :         if (line.startsWith(QLatin1String("gitdir:"))) {
     416            0 :           const QString target = line.mid(7).trimmed();
     417            0 :           owned = ownedOrMissing(QDir(root).absoluteFilePath(target));
     418              :         }
     419              :       }
     420            0 :     }
     421              :     // Or trusted as git would trust it in place of ownership.
     422           28 :     if (!owned && !isSafeDirectory(root, git)) {
     423           14 :       gitDir = QStringLiteral("/dev/null/.git");
     424              :     }
     425              : #else
     426              :     // Git for Windows runs the same ownership check during discovery, on
     427              :     // SIDs, with its own rules for the Administrators group; this pin skips
     428              :     // it there and does not repeat it (a known gap, #2089).
     429              :     Q_UNUSED(git);
     430              :     Q_UNUSED(asUser);
     431              : #endif
     432           56 :     vars.append({QStringLiteral("GIT_DIR"), gitDir});
     433           56 :     vars.append({QStringLiteral("GIT_WORK_TREE"), root});
     434              :   }
     435              :   return vars;
     436         1178 : }
     437              : 
     438           54 : auto Util::mkpathForStore(const QString &path) -> bool {
     439          108 :   const QString clean = QDir::cleanPath(QDir(path).absolutePath());
     440           54 :   if (QFileInfo(clean).isDir()) {
     441              :     return true;
     442              :   }
     443           31 :   const QString parent = QFileInfo(clean).absolutePath();
     444           31 :   if (parent != clean && !mkpathForStore(parent)) {
     445              :     return false;
     446              :   }
     447              : #ifdef Q_OS_UNIX
     448              :   // The store's umask, as git and gpg get it: a store shared with a group
     449              :   // (PASSWORD_STORE_UMASK=007) gets group folders, the default owner-only.
     450           28 :   const int umask = Executor::childUmask() >= 0 ? Executor::childUmask() : 077;
     451              :   QFile::Permissions permissions;
     452           28 :   const QFile::Permission bits[] = {
     453              :       QFile::ReadOwner, QFile::WriteOwner, QFile::ExeOwner,
     454              :       QFile::ReadGroup, QFile::WriteGroup, QFile::ExeGroup,
     455              :       QFile::ReadOther, QFile::WriteOther, QFile::ExeOther};
     456          280 :   for (int i = 0; i < 9; ++i) {
     457          252 :     if ((umask & (0400 >> i)) == 0) {
     458           90 :       permissions |= bits[i];
     459              :     }
     460              :   }
     461              :   // mkdir() still takes QtPass's own umask off (a session's 022 would drop
     462              :   // the group's write bit); set the mode exactly afterwards. That only ever
     463              :   // widens it to what was asked, never past.
     464           56 :   const bool made = QDir().mkdir(clean, permissions) &&
     465           23 :                     QFile::setPermissions(clean, permissions);
     466              : #else
     467              :   // Windows: permissions here would become an ACL of their own, replacing
     468              :   // the inherited one; the folder takes its parent's, as before.
     469              :   const bool made = QDir().mkdir(clean);
     470              : #endif
     471           28 :   return made || QFileInfo(clean).isDir();
     472              : }
     473              : 
     474          409 : auto Util::findBinaryInPath(const QString &binary) -> QString {
     475          409 :   if (binary.isEmpty()) {
     476            1 :     return {};
     477              :   }
     478              : 
     479          408 :   initialiseEnvironment();
     480              : 
     481              :   const QStringList dirs =
     482          816 :       _env.value(QStringLiteral("PATH"))
     483          408 :           .split(QDir::listSeparator(), Qt::SkipEmptyParts);
     484          408 :   QString ret;
     485          408 :   if (QDir::fromNativeSeparators(binary).contains(u'/')) {
     486              :     // An explicit path is not a PATH search: an absolute path is checked
     487              :     // as-is, a relative one is resolved against the PATH directories. The
     488              :     // directory-list overload refuses such names, so handle them here.
     489            3 :     if (QDir::isAbsolutePath(binary)) {
     490            4 :       ret = QStandardPaths::findExecutable(binary);
     491            1 :     } else if (!dirs.isEmpty()) {
     492            2 :       ret = QStandardPaths::findExecutable(binary, dirs);
     493              :     }
     494              :   } else {
     495          810 :     ret = findBinaryInPath(binary, dirs);
     496              :   }
     497              : #ifdef Q_OS_WIN
     498              :   if (ret.isEmpty()) {
     499              :     // Cache per-binary WSL lookup result — the wsl --version probe is a
     500              :     // blocking subprocess that can run several times per session for
     501              :     // missing binaries; once decided, the answer doesn't change at runtime.
     502              :     static QHash<QString, QString> wslBinaryCache;
     503              :     const bool hasWhitespace =
     504              :         std::any_of(binary.cbegin(), binary.cend(),
     505              :                     [](const QChar ch) { return ch.isSpace(); });
     506              :     if (!hasWhitespace) {
     507              :       auto cached = wslBinaryCache.constFind(binary);
     508              :       if (cached != wslBinaryCache.constEnd()) {
     509              :         ret = cached.value();
     510              :       } else {
     511              :         QString wslCommand = QStringLiteral("wsl ") + binary;
     512              :         qCDebug(lcQtPass)
     513              :             << "Util::findBinaryInPath(): falling back to WSL for binary"
     514              :             << binary;
     515              :         QString out, err;
     516              :         QString cachedResult;
     517              :         if (Executor::executeBlocking(wslCommand, {"--version"}, &out, &err) ==
     518              :                 0 &&
     519              :             !out.isEmpty() && err.isEmpty()) {
     520              :           qCDebug(lcQtPass)
     521              :               << "Util::findBinaryInPath(): using WSL binary" << wslCommand;
     522              :           cachedResult = wslCommand;
     523              :         }
     524              :         wslBinaryCache.insert(binary, cachedResult);
     525              :         ret = cachedResult;
     526              :       }
     527              :     }
     528              :   }
     529              : #endif
     530              : 
     531              :   return ret;
     532              : }
     533              : 
     534              : // Bare names only: QStandardPaths::findExecutable() returns an absolute binary
     535              : // without consulting searchPaths, and a relative one with ".." could escape
     536              : // them.
     537          419 : auto Util::findBinaryInPath(const QString &binary,
     538              :                             const QStringList &searchPaths) -> QString {
     539          837 :   if (binary.isEmpty() || QDir::fromNativeSeparators(binary).contains(u'/')) {
     540            4 :     return {};
     541              :   }
     542          415 :   QStringList dirs;
     543          415 :   dirs.reserve(searchPaths.size());
     544         6162 :   for (const QString &dir : searchPaths) {
     545         5747 :     if (!dir.isEmpty()) {
     546              :       dirs.append(dir);
     547              :     }
     548              :   }
     549          415 :   if (dirs.isEmpty()) {
     550              :     // QStandardPaths::findExecutable() treats an empty list as "use PATH".
     551            4 :     return {};
     552              :   }
     553          411 :   return QStandardPaths::findExecutable(binary, dirs);
     554              : }
     555              : 
     556          213 : auto Util::configIsValid(const AppSettings &s) -> bool {
     557          426 :   const QString configFilePath = QDir(s.passStore).filePath(".gpg-id");
     558          213 :   if (!QFile(configFilePath).exists()) {
     559              :     return false;
     560              :   }
     561              : 
     562          138 :   const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
     563              : 
     564          138 :   if (const auto wsl = Executor::parseWslCommand(executable)) {
     565              :     // Probe WSL once per session — availability doesn't change at runtime
     566              :     // and the executeBlocking call is a blocking subprocess.
     567            1 :     static const bool wslAvailable = [&wsl]() {
     568            1 :       QString out;
     569            1 :       QString err;
     570            3 :       return Executor::executeBlocking(wsl->launcher,
     571            1 :                                        {QStringLiteral("--version")}, &out,
     572            0 :                                        &err) == 0 &&
     573            2 :              !out.isEmpty() && err.isEmpty();
     574            1 :     }();
     575            1 :     if (wslAvailable) {
     576              :       return true;
     577              :     }
     578              :   }
     579          138 :   return QFile(executable).exists();
     580              : }
     581              : 
     582         7535 : auto Util::endsWithGpg() -> const QRegularExpression & {
     583         7542 :   static const QRegularExpression expr{R"(\.gpg$)"};
     584         7535 :   return expr;
     585              : }
     586              : 
     587              : // Stops at whitespace, quotes and brackets so a URL on its own line keeps no
     588              : // line break. file:/// is excluded on purpose (local, not network); the test
     589              : // relies on that.
     590          168 : auto Util::protocolRegex() -> const QRegularExpression & {
     591              :   static const QRegularExpression regex{
     592          173 :       R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
     593          168 :   return regex;
     594              : }
     595              : 
     596          109 : auto Util::isLaunchableWebUrl(const QString &value) -> bool {
     597              :   const QString trimmed = value.trimmed();
     598          109 :   if (trimmed.isEmpty()) {
     599              :     return false;
     600              :   }
     601              :   // Reject control characters first, before QUrl normalisation can hide a
     602              :   // CR/LF/NUL injection into the OS URL handler.
     603         2348 :   for (const QChar &c : trimmed) {
     604              :     if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
     605              :         c == QChar(QChar::Null)) {
     606              :       return false;
     607              :     }
     608              :   }
     609          105 :   const QUrl url(trimmed, QUrl::StrictMode);
     610          105 :   if (!url.isValid()) {
     611              :     return false;
     612              :   }
     613           92 :   const QString scheme = url.scheme().toLower();
     614           92 :   if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
     615           35 :     return false;
     616              :   }
     617          114 :   if (url.host().isEmpty()) {
     618              :     return false;
     619              :   }
     620              :   // Embedded userinfo (user:pass@host) would leak into browser history.
     621          107 :   if (!url.userName().isEmpty() || !url.password().isEmpty()) {
     622              :     return false;
     623              :   }
     624              :   return true;
     625          105 : }
     626              : 
     627          164 : auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
     628          164 :   if (linked != nullptr) {
     629          115 :     *linked = false;
     630              :   }
     631          164 :   QString html;
     632          164 :   html.reserve(text.size());
     633              :   qsizetype lastIndex = 0;
     634          164 :   QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
     635          205 :   while (it.hasNext()) {
     636           41 :     const QRegularExpressionMatch match = it.next();
     637           41 :     const QString url = match.captured(0);
     638           41 :     if (!isLaunchableWebUrl(url)) {
     639              :       // Not a web URL (or it carries credentials): leave it in the escaped
     640              :       // plain-text run instead of making it clickable.
     641              :       continue;
     642              :     }
     643           28 :     const qsizetype start = match.capturedStart(0);
     644           28 :     html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
     645           28 :     const QString escapedUrl = url.toHtmlEscaped();
     646           56 :     html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
     647           28 :     lastIndex = match.capturedEnd(0);
     648           28 :     if (linked != nullptr) {
     649           22 :       *linked = true;
     650              :     }
     651           41 :   }
     652          164 :   html += text.mid(lastIndex).toHtmlEscaped();
     653          164 :   return html;
     654          164 : }
     655              : 
     656          280 : auto Util::newLinesRegex() -> const QRegularExpression & {
     657          289 :   static const QRegularExpression regex{"[\r\n]"};
     658          280 :   return regex;
     659              : }
     660              : 
     661              : // No content heuristics: a line rejected here is erased the next time .gpg-id
     662              : // is rewritten. A leading `-` is refused because the recipients also go
     663              : // positionally to `gpg --list-keys`, where it would parse as an option.
     664          222 : auto Util::isValidKeyId(const QString &keyId) -> bool {
     665          222 :   return !keyId.isEmpty() && !keyId.startsWith('-');
     666              : }
     667              : 
     668              : namespace {
     669              : /**
     670              :  * @brief Walk @p dir's real, visible directories in sorted pre-order and hand
     671              :  * every entry to @p visit before any recursion; a directory is entered only
     672              :  * when @p visit returns true for it.
     673              :  */
     674          206 : template <typename Visit> void walkStore(const QString &dir, Visit visit) {
     675              :   // Absolute, so the results are whatever the caller's cwd; not canonical,
     676              :   // so a linked root keeps the name it was configured under.
     677          618 :   QStringList pending{QDir(QDir::cleanPath(dir)).absolutePath()};
     678          748 :   while (!pending.isEmpty()) {
     679          271 :     const QDir current(pending.takeLast());
     680              :     // Every entry once, links and hidden entries included, so the decision
     681              :     // what to do with each is taken here, before any recursion. QDir::System
     682              :     // keeps dangling links in the listing.
     683          271 :     const QFileInfoList entries =
     684              :         current.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
     685              :                                   QDir::System | QDir::NoDotAndDotDot,
     686              :                               QDir::Name);
     687          271 :     QStringList subdirs;
     688         1509 :     for (const QFileInfo &entry : entries) {
     689         1238 :       if (visit(entry)) {
     690          130 :         subdirs << entry.filePath();
     691              :       }
     692              :     }
     693              :     // Pushed last-to-first so the next one taken is the first by name.
     694          336 :     for (auto it = subdirs.crbegin(); it != subdirs.crend(); ++it) {
     695              :       pending << *it;
     696              :     }
     697              :   }
     698          412 : }
     699              : 
     700              : /// A symlink or an NTFS junction: never entered, never listed.
     701         3346 : auto isLink(const QFileInfo &entry) -> bool {
     702         3346 :   return entry.isSymLink() || entry.isJunction();
     703              : }
     704              : 
     705              : /// Hidden by attribute, or by the dot convention on every platform: Qt 6.11
     706              : /// on macOS answers isHidden() from the UF_HIDDEN flag alone once an entry
     707              : /// was lstat()ed (qfilesystemengine_unix.cpp marks the attribute known there
     708              : /// without the dot check), and Windows does not consider .stversions hidden
     709              : /// at all.
     710          623 : auto isHiddenEntry(const QFileInfo &entry) -> bool {
     711         1231 :   return entry.isHidden() || entry.fileName().startsWith(QLatin1Char('.'));
     712              : }
     713              : 
     714              : /// A real directory that is part of the store: .git, .stversions,
     715              : /// .Trash-1000 are not, as with QDirIterator without QDir::Hidden.
     716         1238 : auto isStoreDirectory(const QFileInfo &entry) -> bool {
     717         1238 :   return !isLink(entry) && entry.isDir() && !isHiddenEntry(entry);
     718              : }
     719              : } // namespace
     720              : 
     721          203 : auto Util::regularFilesUnder(const QString &dir, const QStringList &nameFilters,
     722              :                              QStringList *skipped, bool hiddenFiles)
     723              :     -> QStringList {
     724          203 :   QStringList files;
     725          203 :   walkStore(dir, [&](const QFileInfo &entry) {
     726         1183 :     if (isStoreDirectory(entry)) {
     727              :       return true;
     728              :     }
     729         1136 :     const bool named = QDir::match(nameFilters, entry.fileName());
     730         1136 :     if (isLink(entry) || (!entry.isDir() && !entry.isFile())) {
     731              :       // A linked directory hides everything behind it; a linked file, or a
     732              :       // FIFO, socket or device, is only of interest under a name the caller
     733              :       // asked for.
     734           25 :       if (skipped != nullptr && (entry.isDir() || named)) {
     735           24 :         qCWarning(lcQtPass) << "Skipping" << entry.filePath()
     736           12 :                             << ": not a regular file or directory";
     737           24 :         *skipped << entry.filePath();
     738              :       }
     739           25 :       return false;
     740              :     }
     741         1111 :     if (entry.isFile() && named && (hiddenFiles || !isHiddenEntry(entry))) {
     742         1126 :       files << entry.filePath();
     743              :     }
     744              :     return false;
     745              :   });
     746          203 :   return files;
     747              : }
     748              : 
     749            3 : auto Util::directoriesUnder(const QString &dir) -> QStringList {
     750            3 :   QStringList dirs;
     751            3 :   walkStore(dir, [&](const QFileInfo &entry) {
     752           55 :     if (!isStoreDirectory(entry)) {
     753              :       return false;
     754              :     }
     755           18 :     dirs << entry.filePath();
     756           18 :     return true;
     757              :   });
     758            3 :   return dirs;
     759              : }
     760              : 
     761          935 : auto Util::isLinkedFolder(const QString &path) -> bool {
     762         1870 :   return isLink(QFileInfo(QDir::cleanPath(path)));
     763              : }
     764              : 
     765          625 : auto Util::isUnderLink(const QString &path, const QString &storeRoot,
     766              :                        bool includeSelf) -> bool {
     767              :   // "C:/Store" and "c:/store" are one directory on Windows; a path spelled
     768              :   // the other way must not skip the walk (as in Pass::getGpgIdPath).
     769              : #ifdef Q_OS_WIN
     770              :   constexpr auto cs = Qt::CaseInsensitive;
     771              : #else
     772              :   constexpr auto cs = Qt::CaseSensitive;
     773              : #endif
     774          625 :   const QString root = QDir::cleanPath(storeRoot);
     775              :   // A root of "/" or "C:/" already ends in the separator.
     776              :   const QString prefix =
     777          625 :       root.endsWith(QLatin1Char('/')) ? root : root + QLatin1Char('/');
     778              :   const auto isRoot = [&](const QString &p) {
     779         1083 :     return p.compare(root, cs) == 0;
     780              :   };
     781          625 :   QString current = QDir::cleanPath(path);
     782          625 :   if (!current.startsWith(prefix, cs)) {
     783              :     // Not under the store as named: only the entry itself can be judged.
     784          406 :     return includeSelf && !isRoot(current) && isLinkedFolder(current);
     785              :   }
     786          422 :   if (!includeSelf) {
     787           58 :     current = QFileInfo(current).path();
     788              :   }
     789          880 :   for (; !isRoot(current) && current.startsWith(prefix, cs);
     790          916 :        current = QFileInfo(current).path()) {
     791          495 :     if (isLinkedFolder(current)) {
     792              :       return true;
     793              :     }
     794              :   }
     795              :   return false;
     796              : }
     797              : 
     798              : #ifndef Q_OS_WIN
     799              : namespace {
     800              : 
     801              : /// The filesystem cannot make hard links at all (FAT/exFAT refuse with
     802              : /// EPERM, some FUSE filesystems with ENOTSUP or ENOSYS).
     803              : auto hasNoHardLinks(int error) -> bool {
     804            2 :   return error == EPERM || error == ENOTSUP || error == EOPNOTSUPP ||
     805              :          error == ENOSYS;
     806              : }
     807              : 
     808              : /// rename() that fails with EEXIST instead of replacing, where the platform
     809              : /// has one; nothing when neither the platform nor the filesystem does.
     810            8 : auto renameExclusive(const QByteArray &source, const QByteArray &target)
     811              :     -> std::optional<bool> {
     812              : #if defined(__GLIBC__) &&                                                      \
     813              :     (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 28))
     814            8 :   if (::renameat2(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(),
     815              :                   RENAME_NOREPLACE) == 0) {
     816            2 :     return true;
     817              :   }
     818            6 :   if (errno == EINVAL || errno == ENOSYS || hasNoHardLinks(errno)) {
     819            4 :     return std::nullopt;
     820              :   }
     821            2 :   return false;
     822              : #elif defined(Q_OS_MACOS)
     823              :   if (::renamex_np(source.constData(), target.constData(), RENAME_EXCL) == 0) {
     824              :     return true;
     825              :   }
     826              :   if (errno == EINVAL || errno == ENOTSUP) {
     827              :     return std::nullopt;
     828              :   }
     829              :   return false;
     830              : #else
     831              :   Q_UNUSED(source);
     832              :   Q_UNUSED(target);
     833              :   return std::nullopt;
     834              : #endif
     835              : }
     836              : 
     837              : /**
     838              :  * @brief Gives @p source the name @p target unless something already has it.
     839              :  *
     840              :  * A hard link is the one way every POSIX system can do that in one step:
     841              :  * linkat() fails with EEXIST rather than replacing. Where the filesystem has
     842              :  * no hard links, an exclusive rename does it; where that is missing too, the
     843              :  * name is checked and then renamed, which leaves a short window for another
     844              :  * writer to create it in between.
     845              :  */
     846           91 : auto placeWithoutReplacing(const QByteArray &source, const QByteArray &target)
     847              :     -> bool {
     848              :   // linkat() without AT_SYMLINK_FOLLOW follows nothing; link() would, on
     849              :   // macOS and the BSDs, hard-link whatever a symlink planted under the
     850              :   // source's name points at.
     851           91 :   if (::linkat(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(), 0) ==
     852              :       0) {
     853           76 :     ::unlink(source.constData());
     854           76 :     return true;
     855              :   }
     856           15 :   if (!hasNoHardLinks(errno)) {
     857              :     return false;
     858              :   }
     859            8 :   if (const std::optional<bool> renamed = renameExclusive(source, target)) {
     860            4 :     return *renamed;
     861              :   }
     862            4 :   struct stat st{};
     863            4 :   if (::lstat(target.constData(), &st) == 0 || errno != ENOENT) {
     864            2 :     errno = EEXIST;
     865            2 :     return false;
     866              :   }
     867            2 :   return ::rename(source.constData(), target.constData()) == 0;
     868              : }
     869              : 
     870              : } // namespace
     871              : #endif
     872              : 
     873          230 : auto Util::replaceFile(const QString &from, const QString &to, bool replace)
     874              :     -> bool {
     875              : #ifdef Q_OS_WIN
     876              :   const std::wstring source =
     877              :       QDir::toNativeSeparators(QFileInfo(from).absoluteFilePath())
     878              :           .toStdWString();
     879              :   const std::wstring target =
     880              :       QDir::toNativeSeparators(QFileInfo(to).absoluteFilePath()).toStdWString();
     881              :   // WRITE_THROUGH: the new entry is on the device when this returns.
     882              :   return MoveFileExW(source.c_str(), target.c_str(),
     883              :                      (replace ? MOVEFILE_REPLACE_EXISTING : 0) |
     884              :                          MOVEFILE_WRITE_THROUGH) != 0;
     885              : #else
     886              :   const QByteArray source = QFile::encodeName(from);
     887              :   const QByteArray target = QFile::encodeName(to);
     888          230 :   if (replace) {
     889          139 :     if (::rename(source.constData(), target.constData()) != 0) {
     890              :       return false;
     891              :     }
     892              :   } else {
     893           91 :     if (!placeWithoutReplacing(source, target)) {
     894              :       return false;
     895              :     }
     896              :   }
     897              :   // Sync the directory entry so a crash does not lose the new name. Best
     898              :   // effort: the bytes are already synced, and some network filesystems
     899              :   // cannot sync a directory.
     900          434 :   const int dir = ::open(QFile::encodeName(QFileInfo(to).path()).constData(),
     901              :                          O_RDONLY | O_DIRECTORY | O_CLOEXEC);
     902          217 :   if (dir >= 0) {
     903              :     int rc;
     904              :     do {
     905          217 :       rc = ::fsync(dir);
     906          217 :     } while (rc != 0 && errno == EINTR);
     907          217 :     ::close(dir);
     908              :   }
     909              :   return true;
     910              : #endif
     911              : }
     912              : 
     913          767 : auto Util::openRegularFile(const QString &path, QFile &file) -> bool {
     914              : #ifdef Q_OS_WIN
     915              :   // FILE_FLAG_OPEN_REPARSE_POINT opens a symbolic link or junction itself
     916              :   // rather than its target, so the handle's attributes say what the name
     917              :   // was at the moment of the open.
     918              :   const std::wstring native =
     919              :       QDir::toNativeSeparators(QFileInfo(path).absoluteFilePath())
     920              :           .toStdWString();
     921              :   HANDLE handle = CreateFileW(
     922              :       native.c_str(), GENERIC_READ,
     923              :       FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
     924              :       OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
     925              :   if (handle == INVALID_HANDLE_VALUE) {
     926              :     return false;
     927              :   }
     928              :   BY_HANDLE_FILE_INFORMATION info{};
     929              :   if (!GetFileInformationByHandle(handle, &info) ||
     930              :       (info.dwFileAttributes &
     931              :        (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY |
     932              :         FILE_ATTRIBUTE_DEVICE)) != 0 ||
     933              :       GetFileType(handle) != FILE_TYPE_DISK) {
     934              :     CloseHandle(handle);
     935              :     return false;
     936              :   }
     937              :   const int fd = _open_osfhandle(reinterpret_cast<intptr_t>(handle),
     938              :                                  _O_RDONLY | _O_BINARY);
     939              :   if (fd < 0) {
     940              :     CloseHandle(handle);
     941              :     return false;
     942              :   }
     943              :   if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
     944              :     _close(fd);
     945              :     return false;
     946              :   }
     947              :   return true;
     948              : #else
     949              :   // O_NOFOLLOW fails with ELOOP on a symbolic link; O_NONBLOCK keeps a FIFO
     950              :   // from blocking the open until fstat() can refuse it.
     951          767 :   const int fd = ::open(QFile::encodeName(path).constData(),
     952              :                         O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
     953          767 :   if (fd < 0) {
     954              :     return false;
     955              :   }
     956          714 :   struct stat st{};
     957          714 :   if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
     958            7 :     ::close(fd);
     959            7 :     return false;
     960              :   }
     961              :   // The descriptor, not the name, is what QFile opens here: the object
     962              :   // fstat() judged is the object read. CodeQL's check-then-use pattern
     963              :   // matcher sees an open after a check and cannot tell.
     964          707 :   if (!file.open(fd, QIODevice::ReadOnly, // codeql[cpp/toctou-race-condition]
     965              :                  QFileDevice::AutoCloseHandle)) {
     966            0 :     ::close(fd);
     967            0 :     return false;
     968              :   }
     969              :   return true;
     970              : #endif
     971              : }
     972              : 
     973          245 : auto Util::readRegularFile(const QString &path, qint64 maxBytes)
     974              :     -> std::optional<QByteArray> {
     975          245 :   QFile file;
     976          245 :   if (!openRegularFile(path, file)) {
     977           11 :     return std::nullopt;
     978              :   }
     979              :   // A regular file's size is reliable, also when sparse: refuse a large one
     980              :   // before reading anything. One byte past the limit still guards a file
     981              :   // that grows meanwhile.
     982          234 :   if (file.size() > maxBytes) {
     983            3 :     return std::nullopt;
     984              :   }
     985          231 :   QByteArray bytes = file.read(maxBytes + 1);
     986          231 :   if (bytes.size() > maxBytes || file.error() != QFileDevice::NoError) {
     987            0 :     return std::nullopt;
     988              :   }
     989              :   return bytes;
     990          245 : }
     991              : 
     992          219 : auto Util::syncToDisk(QFileDevice &file) -> bool {
     993          219 :   if (!file.flush()) {
     994              :     return false;
     995              :   }
     996              : #ifdef Q_OS_WIN
     997              :   const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
     998              :   return handle != INVALID_HANDLE_VALUE && FlushFileBuffers(handle) != 0;
     999              : #else
    1000          219 :   return ::fsync(file.handle()) == 0;
    1001              : #endif
    1002              : }
    1003              : 
    1004              : namespace {
    1005              : 
    1006              : /**
    1007              :  * @brief What tells one file object from another on its filesystem: device
    1008              :  * and inode on POSIX, volume serial and file index on Windows.
    1009              :  */
    1010              : struct FileIdentity {
    1011              :   quint64 volume = 0;
    1012              :   quint64 index = 0;
    1013              :   bool known = false;
    1014              : };
    1015              : 
    1016          208 : auto operator==(const FileIdentity &a, const FileIdentity &b) -> bool {
    1017          208 :   return a.known && b.known && a.volume == b.volume && a.index == b.index;
    1018              : }
    1019              : 
    1020              : /// The identity of the object @p file is open on, or an unknown one.
    1021          427 : auto identityOf(const QFileDevice &file) -> FileIdentity {
    1022              : #ifdef Q_OS_WIN
    1023              :   const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
    1024              :   BY_HANDLE_FILE_INFORMATION info;
    1025              :   if (handle == INVALID_HANDLE_VALUE ||
    1026              :       GetFileInformationByHandle(handle, &info) == 0) {
    1027              :     return {};
    1028              :   }
    1029              :   // On FAT/exFAT the file ID is the directory entry's offset, which a rename
    1030              :   // can move. They have no hard links either (the swap this catches needs
    1031              :   // one), so the identity is left unknown there.
    1032              :   DWORD flags = 0;
    1033              :   if (GetVolumeInformationByHandleW(handle, nullptr, 0, nullptr, nullptr,
    1034              :                                     &flags, nullptr, 0) == 0 ||
    1035              :       (flags & FILE_SUPPORTS_HARD_LINKS) == 0) {
    1036              :     return {};
    1037              :   }
    1038              :   return {info.dwVolumeSerialNumber,
    1039              :           (static_cast<quint64>(info.nFileIndexHigh) << 32) |
    1040              :               info.nFileIndexLow,
    1041              :           true};
    1042              : #else
    1043          427 :   struct stat st{};
    1044          427 :   if (::fstat(file.handle(), &st) != 0) {
    1045            0 :     return {};
    1046              :   }
    1047          427 :   return {static_cast<quint64>(st.st_dev), static_cast<quint64>(st.st_ino),
    1048          427 :           true};
    1049              : #endif
    1050              : }
    1051              : 
    1052              : /// Put @p text in @p error when there is one; false, for `return fail(...)`.
    1053              : auto fail(QString *error, const QString &text) -> bool {
    1054           21 :   if (error != nullptr) {
    1055           21 :     *error = text;
    1056              :   }
    1057              :   return false;
    1058              : }
    1059              : 
    1060              : /// A filled, synced temporary next to its destination.
    1061          665 : struct Staged {
    1062              :   QString path;
    1063              :   FileIdentity identity;
    1064              : };
    1065              : 
    1066              : /**
    1067              :  * @brief Create the temporary next to @p path (opaque name, exclusive,
    1068              :  * owner-only), fill it through its handle and sync it. The QTemporaryFile
    1069              :  * goes out of scope before the caller renames: it keeps its handle open for
    1070              :  * as long as it lives, and Windows does not rename an open file.
    1071              :  */
    1072          227 : auto stageNextTo(const QString &path, const Util::Filler &fill, QString *why)
    1073              :     -> std::optional<Staged> {
    1074          227 :   Staged staged;
    1075              :   {
    1076          454 :     QTemporaryFile file(QFileInfo(path).path() +
    1077          454 :                         QStringLiteral("/.qtpass-XXXXXX.tmp"));
    1078          227 :     file.setAutoRemove(false);
    1079          227 :     if (!file.open()) {
    1080            8 :       *why = QCoreApplication::translate(
    1081              :                  "Util", "Cannot create a temporary file next to %1: %2")
    1082           16 :                  .arg(path, file.errorString());
    1083            8 :       return std::nullopt;
    1084              :     }
    1085          219 :     staged.path = file.fileName();
    1086              :     // Owner-only: a .gpg-id names the keys a store is encrypted to, an
    1087              :     // entry is an entry. QTemporaryFile creates 0600 already; say so for
    1088              :     // platforms where it may not.
    1089          219 :     file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
    1090          438 :     *why = fill(file);
    1091          219 :     if (why->isEmpty() && !Util::syncToDisk(file)) {
    1092            0 :       *why = QCoreApplication::translate("Util", "Cannot write %1: %2")
    1093            0 :                  .arg(path, file.errorString());
    1094              :     }
    1095          219 :     staged.identity = identityOf(file);
    1096          227 :   }
    1097          219 :   if (!why->isEmpty()) {
    1098            0 :     QFile::remove(staged.path);
    1099            0 :     return std::nullopt;
    1100              :   }
    1101              :   return staged;
    1102              : }
    1103              : 
    1104              : /// Why replaceFile() could not put the temporary under @p path.
    1105            9 : auto placementError(const QString &path, bool replace) -> QString {
    1106            9 :   if (replace) {
    1107            4 :     return QCoreApplication::translate("Util", "Failed to replace %1.")
    1108            2 :         .arg(path);
    1109              :   }
    1110            7 :   const QFileInfo taken(path);
    1111            8 :   return taken.exists() || taken.isSymLink()
    1112            7 :              ? QCoreApplication::translate("Util", "%1 already exists.")
    1113              :                    .arg(path)
    1114            1 :              : QCoreApplication::translate("Util", "Failed to write %1.")
    1115            8 :                    .arg(path);
    1116            7 : }
    1117              : 
    1118              : /**
    1119              :  * @brief Whether what is under @p path is the file that was filled: not a
    1120              :  * link, and not another file (a hard link to something of the user's, say)
    1121              :  * swapped in under the temporary's name. Opened without following, compared
    1122              :  * by identity; only two known identities can prove a swap, otherwise every
    1123              :  * write on such a store would be reported as tampering.
    1124              :  */
    1125          210 : auto isTheFileWritten(const QString &path, const FileIdentity &written)
    1126              :     -> bool {
    1127          210 :   QFile placed;
    1128          210 :   if (!Util::openRegularFile(path, placed)) {
    1129              :     return false;
    1130              :   }
    1131          208 :   const FileIdentity there = identityOf(placed);
    1132          208 :   if (!written.known || !there.known) {
    1133            0 :     qCWarning(lcQtPass) << "Cannot tell whether" << path
    1134            0 :                         << "is the file that was written here";
    1135            0 :     return true;
    1136              :   }
    1137          208 :   return there == written;
    1138          210 : }
    1139              : 
    1140              : } // namespace
    1141              : 
    1142          227 : auto Util::stageFileReplacing(const QString &path, bool replace,
    1143              :                               const Filler &fill, QString *error) -> bool {
    1144          227 :   QString why;
    1145          227 :   const std::optional<Staged> staged = stageNextTo(path, fill, &why);
    1146          227 :   if (!staged) {
    1147              :     return fail(error, why);
    1148              :   }
    1149          219 :   if (!replaceFile(staged->path, path, replace)) {
    1150            9 :     QFile::remove(staged->path);
    1151           18 :     return fail(error, placementError(path, replace));
    1152              :   }
    1153              :   // A mismatch is reported and left: removing by name could take another
    1154              :   // writer's file.
    1155          210 :   if (!isTheFileWritten(path, staged->identity)) {
    1156              :     return fail(
    1157              :         error,
    1158            4 :         QCoreApplication::translate(
    1159              :             "Util", "%1 was swapped for another file while it was written.")
    1160            8 :             .arg(path));
    1161              :   }
    1162              :   return true;
    1163              : }
    1164              : 
    1165          106 : auto Util::writeFileReplacing(const QString &path, const QByteArray &bytes,
    1166              :                               bool replace, QString *error) -> bool {
    1167          106 :   return stageFileReplacing(
    1168              :       path, replace,
    1169          210 :       [&path, &bytes](QFileDevice &staged) -> QString {
    1170          104 :         if (staged.write(bytes) != bytes.size()) {
    1171            0 :           return QCoreApplication::translate("Util", "Cannot write %1: %2")
    1172            0 :               .arg(path, staged.errorString());
    1173              :         }
    1174          104 :         return {};
    1175              :       },
    1176          106 :       error);
    1177              : }
    1178              : 
    1179          120 : auto Util::copyFileReplacing(const QString &src, const QString &dst,
    1180              :                              bool replace, QString *error) -> bool {
    1181          120 :   QFile in;
    1182          120 :   if (!openRegularFile(src, in)) {
    1183            4 :     if (error)
    1184            8 :       *error = QCoreApplication::translate("Util", "Cannot read %1.").arg(src);
    1185            4 :     return false;
    1186              :   }
    1187          116 :   return stageFileReplacing(
    1188              :       dst, replace,
    1189          342 :       [&src, &dst, &in](QFileDevice &staged) -> QString {
    1190              :         char buf[64 * 1024];
    1191              :         for (;;) {
    1192          232 :           const qint64 n = in.read(buf, sizeof buf);
    1193          232 :           if (n < 0) {
    1194            0 :             return QCoreApplication::translate("Util", "Cannot read %1: %2")
    1195            0 :                 .arg(src, in.errorString());
    1196              :           }
    1197          232 :           if (n == 0) {
    1198          110 :             return {};
    1199              :           }
    1200          122 :           if (staged.write(buf, n) != n) {
    1201            0 :             return QCoreApplication::translate("Util", "Cannot write %1: %2")
    1202            0 :                 .arg(dst, staged.errorString());
    1203              :           }
    1204              :         }
    1205              :       },
    1206              :       error);
    1207          120 : }
    1208              : 
    1209              : namespace {
    1210              : 
    1211              : /// Remove one directory entry that is not a real directory: a link goes as
    1212              : /// the entry itself, never its target (a junction or a directory symlink on
    1213              : /// Windows is a directory entry and goes with rmdir); a read-only file gets
    1214              : /// write access and one more try, as QDir::removeRecursively() does.
    1215           11 : auto removeEntry(const QFileInfo &entry) -> bool {
    1216           11 :   const QString path = entry.filePath();
    1217           11 :   if (isLink(entry)) {
    1218            6 :     if (QFile::remove(path) || QDir().rmdir(path)) {
    1219              :       return true;
    1220              :     }
    1221            2 :     qCWarning(lcQtPass) << "Could not remove link" << path;
    1222            1 :     return false;
    1223              :   }
    1224            6 :   if (QFile::remove(path)) {
    1225              :     return true;
    1226              :   }
    1227            1 :   const QFile::Permissions perms = QFile::permissions(path);
    1228            1 :   if (!perms.testFlag(QFile::WriteUser) &&
    1229            2 :       QFile::setPermissions(path, perms | QFile::WriteUser) &&
    1230            1 :       QFile::remove(path)) {
    1231              :     return true;
    1232              :   }
    1233            2 :   qCWarning(lcQtPass) << "Could not remove" << path;
    1234            1 :   return false;
    1235              : }
    1236              : 
    1237              : } // namespace
    1238              : 
    1239           21 : auto Util::removeTree(const QString &dir) -> bool {
    1240              :   // A trailing separator makes lstat follow a link ("link/" is the target
    1241              :   // directory); the link itself is what this is about.
    1242           21 :   const QString path = QDir::cleanPath(dir);
    1243           21 :   const QFileInfo top(path);
    1244           21 :   if (isLink(top)) {
    1245              :     // rm -rf on a link removes the link.
    1246           16 :     return QFile::remove(path) || QDir().rmdir(path);
    1247              :   }
    1248            7 :   if (!top.isDir()) {
    1249              :     return false;
    1250              :   }
    1251              :   bool ok = true;
    1252              :   const QFileInfoList entries =
    1253           12 :       QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
    1254              :                                    QDir::System | QDir::NoDotAndDotDot,
    1255            6 :                                QDir::Name);
    1256           18 :   for (const QFileInfo &entry : entries) {
    1257           17 :     const bool removed = entry.isDir() && !isLink(entry)
    1258           13 :                              ? removeTree(entry.filePath())
    1259           11 :                              : removeEntry(entry);
    1260           12 :     ok = removed && ok;
    1261              :   }
    1262           11 :   return ok && QDir().rmdir(path);
    1263           21 : }
        

Generated by: LCOV version 2.0-1