• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IJHack / QtPass / 40795055024

29 Oct 2026 12:35AM UTC coverage: 92.489% (-0.07%) from 92.561%
40795055024

Pull #2086

github

web-flow
Merge 3fb57b3ec into 8c7c950d2
Pull Request #2086: Git: keep to the store when its parent's path holds the list separator

29 of 38 new or added lines in 3 files covered. (76.32%)

1 existing line in 1 file now uncovered.

9691 of 10478 relevant lines covered (92.49%)

337.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.53
/src/util.cpp
1
// SPDX-FileCopyrightText: 2014 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3

4
#include "util.h"
5
#include "appsettings.h"
6
#include "executor.h"
7
#include <QCoreApplication>
8
#include <QDebug>
9
#include <QDir>
10
#include <QFile>
11
#include <QFileDevice>
12
#include <QFileInfo>
13
#include <QHash>
14
#include <QRegularExpression>
15
#include <QRegularExpressionMatchIterator>
16
#include <QStandardPaths>
17
#include <QTemporaryFile>
18
#include <QUrl>
19

20
#include <optional>
21

22
#ifdef Q_OS_WIN
23
#include <fcntl.h>
24
#include <io.h>
25
#include <windows.h>
26
#else
27
#include <cerrno>
28
#include <cstdio>
29
#include <fcntl.h>
30
#include <sys/stat.h>
31
#include <sys/time.h>
32
#include <unistd.h>
33
#endif
34

35
#include "qtpasslogging.h"
36

37
QProcessEnvironment Util::_env;
38
bool Util::_envInitialised = false;
39

40
// macOS and Windows: append the usual GPG install directories to PATH.
41
void Util::initialiseEnvironment() {
650 ✔
42
  if (!_envInitialised) {
650 ✔
43
    _env = QProcessEnvironment::systemEnvironment();
6 ✔
44
#ifdef __APPLE__
45
    QString path = _env.value("PATH");
46
    if (!path.contains("/usr/local/MacGPG2/bin") &&
47
        QDir("/usr/local/MacGPG2/bin").exists())
48
      path += ":/usr/local/MacGPG2/bin";
49
    if (!path.contains("/usr/local/bin"))
50
      path += ":/usr/local/bin";
51
    _env.insert("PATH", path);
52
#endif
53
#ifdef Q_OS_WIN
54
    QString path = _env.value("PATH");
55
    if (!path.contains("C:\\Program Files\\WinGPG\\x86") &&
56
        QDir("C:\\Program Files\\WinGPG\\x86").exists())
57
      path += ";C:\\Program Files\\WinGPG\\x86";
58
    if (!path.contains("C:\\Program Files\\GnuPG\\bin") &&
59
        QDir("C:\\Program Files\\GnuPG\\bin").exists())
60
      path += ";C:\\Program Files\\GnuPG\\bin";
61
    _env.insert("PATH", path);
62
#endif
63
    qCDebug(lcQtPass) << _env.value("PATH");
6 ✔
64
    _envInitialised = true;
6 ✔
65
  }
66
}
650 ✔
67

68
auto Util::findPasswordStore() -> QString {
261 ✔
69
  QString path;
261 ✔
70
  initialiseEnvironment();
261 ✔
71
  if (_env.contains("PASSWORD_STORE_DIR")) {
522 ✔
72
    path = Util::expandTilde(_env.value("PASSWORD_STORE_DIR"));
6 ✔
73
  } else {
74
#ifdef Q_OS_WIN
75
    path = QDir(QDir::homePath()).filePath("password-store");
76
#else
77
    path = QDir(QDir::homePath()).filePath(".password-store");
774 ✔
78
#endif
79
  }
80
  return Util::normalizeFolderPath(QDir::cleanPath(path));
522 ✔
81
}
82

83
auto Util::expandTilde(const QString &path) -> QString {
12 ✔
84
  if (path == QLatin1String("~")) {
12 ✔
85
    return QDir::homePath();
1 ✔
86
  }
87
  if (path.startsWith(QLatin1String("~/"))) {
11 ✔
88
    return QDir::homePath() + path.mid(1);
10 ✔
89
  }
90
  return path;
91
}
92

93
auto Util::normalizeFolderPath(const QString &path) -> QString {
340 ✔
94
  QString normalizedPath = path;
95
  if (!normalizedPath.endsWith('/')) {
340 ✔
96
    normalizedPath += '/';
294 ✔
97
  }
98
  return normalizedPath;
340 ✔
99
}
100

101
void Util::applyPassProcessDefaults() {
1 ✔
102
  // pass unsets these before it runs git: a GIT_DIR inherited from the
103
  // environment (QtPass started from a git hook, or an IDE terminal) would
104
  // send every commit and push to that repository instead of the store.
105
  for (const char *variable :
10 ✔
106
       {"GIT_DIR", "GIT_WORK_TREE", "GIT_NAMESPACE", "GIT_INDEX_FILE",
107
        "GIT_INDEX_VERSION", "GIT_OBJECT_DIRECTORY", "GIT_COMMON_DIR",
108
        // QtPass sets GIT_LITERAL_PATHSPECS for its git; git refuses a
109
        // pathspec when one of these is set as well.
110
        "GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS"}) {
11 ✔
111
    qunsetenv(variable);
10 ✔
112
  }
113
  Executor::setChildUmask(
1 ✔
114
      passUmask(qEnvironmentVariable("PASSWORD_STORE_UMASK")));
1 ✔
115
}
1 ✔
116

117
auto Util::passUmask(const QString &value) -> int {
11 ✔
118
  // Taken as it is, as bash's umask builtin (which pass hands it to) takes
119
  // it: " 022" is no umask there either. pass then keeps the session's;
120
  // QtPass falls back to 077.
121
  // anchoredPattern(), not ^...$: $ also matches before a final newline.
122
  static const QRegularExpression octal(
123
      QRegularExpression::anchoredPattern(QStringLiteral("[0-7]{1,4}")));
13 ✔
124
  if (!octal.match(value).hasMatch()) {
11 ✔
125
    return 077;
126
  }
127
  return value.toInt(nullptr, 8);
4 ✔
128
}
129

130
auto Util::gitCeiling(const QString &store) -> QString {
306 ✔
131
  if (store.isEmpty()) {
306 ✔
132
    return {};
133
  }
134
  QString path = QFileInfo(store).canonicalFilePath();
306 ✔
135
  if (path.isEmpty()) {
306 ✔
136
    path = QDir::cleanPath(QDir(store).absolutePath());
×
137
  }
138
  return QFileInfo(path).absolutePath();
306 ✔
139
}
140

141
auto Util::gitStoreVariables(const QString &store, std::optional<uint> asUser)
306 ✔
142
    -> QList<std::pair<QString, QString>> {
143
  const QString ceiling = gitCeiling(store);
306 ✔
144
  if (ceiling.isEmpty()) {
306 ✔
NEW
145
    return {};
×
146
  }
147
  QList<std::pair<QString, QString>> vars{
148
      {QStringLiteral("GIT_CEILING_DIRECTORIES"), ceiling}};
918 ✔
149
#ifdef Q_OS_WIN
150
  const QChar separator = QLatin1Char(';');
151
#else
152
  const QChar separator = QLatin1Char(':');
153
#endif
154
  // git splits the ceiling list on the separator: "/home/a:b" would be
155
  // "/home/a" and "b", neither of them the parent, and git would climb.
156
  if (ceiling.contains(separator)) {
306 ✔
157
    QString root = QFileInfo(store).canonicalFilePath();
3 ✔
158
    if (root.isEmpty()) {
3 ✔
NEW
159
      root = QDir::cleanPath(QDir(store).absolutePath());
×
160
    }
161
    QString gitDir = QDir(root).filePath(QStringLiteral(".git"));
6 ✔
162
#ifdef Q_OS_UNIX
163
    // What discovery's ownership check would have looked at: the work tree,
164
    // the .git, and the directory a gitfile names. Not the user's: no
165
    // repository at all, under a path that cannot be created (its parent
166
    // is a device), rather than another user's hooks run as this one.
167
    const uint me = asUser.value_or(static_cast<uint>(::geteuid()));
3 ✔
168
    const auto ownedOrMissing = [me](const QString &path) {
5 ✔
169
      const QFileInfo info(path);
5 ✔
170
      return !info.exists() || info.ownerId() == me;
10 ✔
171
    };
5 ✔
172
    bool owned = ownedOrMissing(root) && ownedOrMissing(gitDir);
3 ✔
173
    if (owned && QFileInfo(gitDir).isFile()) {
3 ✔
NEW
174
      QFile gitFile(gitDir);
×
NEW
175
      if (gitFile.open(QIODevice::ReadOnly)) {
×
176
        const QString line =
NEW
177
            QString::fromUtf8(gitFile.readLine(4096)).trimmed();
×
NEW
178
        if (line.startsWith(QLatin1String("gitdir:"))) {
×
NEW
179
          const QString target = line.mid(7).trimmed();
×
NEW
180
          owned = ownedOrMissing(QDir(root).absoluteFilePath(target));
×
181
        }
182
      }
NEW
183
    }
×
184
    if (!owned) {
3 ✔
185
      gitDir = QStringLiteral("/dev/null/.git");
1 ✔
186
    }
187
#else
188
    Q_UNUSED(asUser);
189
#endif
190
    vars.append({QStringLiteral("GIT_DIR"), gitDir});
6 ✔
191
    vars.append({QStringLiteral("GIT_WORK_TREE"), root});
6 ✔
192
  }
193
  return vars;
194
}
306 ✔
195

196
auto Util::mkpathForStore(const QString &path) -> bool {
54 ✔
197
  const QString clean = QDir::cleanPath(QDir(path).absolutePath());
108 ✔
198
  if (QFileInfo(clean).isDir()) {
54 ✔
199
    return true;
200
  }
201
  const QString parent = QFileInfo(clean).absolutePath();
31 ✔
202
  if (parent != clean && !mkpathForStore(parent)) {
31 ✔
203
    return false;
204
  }
205
#ifdef Q_OS_UNIX
206
  // The store's umask, as git and gpg get it: a store shared with a group
207
  // (PASSWORD_STORE_UMASK=007) gets group folders, the default owner-only.
208
  const int umask = Executor::childUmask() >= 0 ? Executor::childUmask() : 077;
28 ✔
209
  QFile::Permissions permissions;
210
  const QFile::Permission bits[] = {
28 ✔
211
      QFile::ReadOwner, QFile::WriteOwner, QFile::ExeOwner,
212
      QFile::ReadGroup, QFile::WriteGroup, QFile::ExeGroup,
213
      QFile::ReadOther, QFile::WriteOther, QFile::ExeOther};
214
  for (int i = 0; i < 9; ++i) {
280 ✔
215
    if ((umask & (0400 >> i)) == 0) {
252 ✔
216
      permissions |= bits[i];
90 ✔
217
    }
218
  }
219
  // mkdir() still takes QtPass's own umask off (a session's 022 would drop
220
  // the group's write bit); set the mode exactly afterwards. That only ever
221
  // widens it to what was asked, never past.
222
  const bool made = QDir().mkdir(clean, permissions) &&
56 ✔
223
                    QFile::setPermissions(clean, permissions);
23 ✔
224
#else
225
  // Windows: permissions here would become an ACL of their own, replacing
226
  // the inherited one; the folder takes its parent's, as before.
227
  const bool made = QDir().mkdir(clean);
228
#endif
229
  return made || QFileInfo(clean).isDir();
28 ✔
230
}
231

232
auto Util::findBinaryInPath(const QString &binary) -> QString {
390 ✔
233
  if (binary.isEmpty()) {
390 ✔
234
    return {};
235
  }
236

237
  initialiseEnvironment();
389 ✔
238

239
  const QStringList dirs =
240
      _env.value(QStringLiteral("PATH"))
778 ✔
241
          .split(QDir::listSeparator(), Qt::SkipEmptyParts);
389 ✔
242
  QString ret;
389 ✔
243
  if (QDir::fromNativeSeparators(binary).contains(u'/')) {
389 ✔
244
    // An explicit path is not a PATH search: an absolute path is checked
245
    // as-is, a relative one is resolved against the PATH directories. The
246
    // directory-list overload refuses such names, so handle them here.
247
    if (QDir::isAbsolutePath(binary)) {
3 ✔
248
      ret = QStandardPaths::findExecutable(binary);
4 ✔
249
    } else if (!dirs.isEmpty()) {
1 ✔
250
      ret = QStandardPaths::findExecutable(binary, dirs);
2 ✔
251
    }
252
  } else {
253
    ret = findBinaryInPath(binary, dirs);
772 ✔
254
  }
255
#ifdef Q_OS_WIN
256
  if (ret.isEmpty()) {
257
    // Cache per-binary WSL lookup result — the wsl --version probe is a
258
    // blocking subprocess that can run several times per session for
259
    // missing binaries; once decided, the answer doesn't change at runtime.
260
    static QHash<QString, QString> wslBinaryCache;
261
    const bool hasWhitespace =
262
        std::any_of(binary.cbegin(), binary.cend(),
263
                    [](const QChar ch) { return ch.isSpace(); });
264
    if (!hasWhitespace) {
265
      auto cached = wslBinaryCache.constFind(binary);
266
      if (cached != wslBinaryCache.constEnd()) {
267
        ret = cached.value();
268
      } else {
269
        QString wslCommand = QStringLiteral("wsl ") + binary;
270
        qCDebug(lcQtPass)
271
            << "Util::findBinaryInPath(): falling back to WSL for binary"
272
            << binary;
273
        QString out, err;
274
        QString cachedResult;
275
        if (Executor::executeBlocking(wslCommand, {"--version"}, &out, &err) ==
276
                0 &&
277
            !out.isEmpty() && err.isEmpty()) {
278
          qCDebug(lcQtPass)
279
              << "Util::findBinaryInPath(): using WSL binary" << wslCommand;
280
          cachedResult = wslCommand;
281
        }
282
        wslBinaryCache.insert(binary, cachedResult);
283
        ret = cachedResult;
284
      }
285
    }
286
  }
287
#endif
288

289
  return ret;
290
}
291

292
// Bare names only: QStandardPaths::findExecutable() returns an absolute binary
293
// without consulting searchPaths, and a relative one with ".." could escape
294
// them.
295
auto Util::findBinaryInPath(const QString &binary,
400 ✔
296
                            const QStringList &searchPaths) -> QString {
297
  if (binary.isEmpty() || QDir::fromNativeSeparators(binary).contains(u'/')) {
799 ✔
298
    return {};
299
  }
300
  QStringList dirs;
396 ✔
301
  dirs.reserve(searchPaths.size());
396 ✔
302
  for (const QString &dir : searchPaths) {
5,908 ✔
303
    if (!dir.isEmpty()) {
5,512 ✔
304
      dirs.append(dir);
305
    }
306
  }
307
  if (dirs.isEmpty()) {
396 ✔
308
    // QStandardPaths::findExecutable() treats an empty list as "use PATH".
309
    return {};
310
  }
311
  return QStandardPaths::findExecutable(binary, dirs);
392 ✔
312
}
313

314
auto Util::configIsValid(const AppSettings &s) -> bool {
200 ✔
315
  const QString configFilePath = QDir(s.passStore).filePath(".gpg-id");
400 ✔
316
  if (!QFile(configFilePath).exists()) {
200 ✔
317
    return false;
318
  }
319

320
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
132 ✔
321

322
  if (const auto wsl = Executor::parseWslCommand(executable)) {
132 ✔
323
    // Probe WSL once per session — availability doesn't change at runtime
324
    // and the executeBlocking call is a blocking subprocess.
325
    static const bool wslAvailable = [&wsl]() {
1 ✔
326
      QString out;
1 ✔
327
      QString err;
1 ✔
328
      return Executor::executeBlocking(wsl->launcher,
3 ✔
329
                                       {QStringLiteral("--version")}, &out,
1 ✔
330
                                       &err) == 0 &&
×
331
             !out.isEmpty() && err.isEmpty();
2 ✔
332
    }();
1 ✔
333
    if (wslAvailable) {
1 ✔
334
      return true;
335
    }
336
  }
337
  return QFile(executable).exists();
132 ✔
338
}
339

340
auto Util::endsWithGpg() -> const QRegularExpression & {
6,911 ✔
341
  static const QRegularExpression expr{R"(\.gpg$)"};
6,911 ✔
342
  return expr;
6,911 ✔
343
}
344

345
// Stops at whitespace, quotes and brackets so a URL on its own line keeps no
346
// line break. file:/// is excluded on purpose (local, not network); the test
347
// relies on that.
348
auto Util::protocolRegex() -> const QRegularExpression & {
136 ✔
349
  static const QRegularExpression regex{
350
      R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
136 ✔
351
  return regex;
136 ✔
352
}
353

354
auto Util::isLaunchableWebUrl(const QString &value) -> bool {
82 ✔
355
  const QString trimmed = value.trimmed();
356
  if (trimmed.isEmpty()) {
82 ✔
357
    return false;
358
  }
359
  // Reject control characters first, before QUrl normalisation can hide a
360
  // CR/LF/NUL injection into the OS URL handler.
361
  for (const QChar &c : trimmed) {
1,947 ✔
362
    if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
363
        c == QChar(QChar::Null)) {
364
      return false;
365
    }
366
  }
367
  const QUrl url(trimmed, QUrl::StrictMode);
78 ✔
368
  if (!url.isValid()) {
78 ✔
369
    return false;
370
  }
371
  const QString scheme = url.scheme().toLower();
136 ✔
372
  if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
131 ✔
373
    return false;
23 ✔
374
  }
375
  if (url.host().isEmpty()) {
90 ✔
376
    return false;
377
  }
378
  // Embedded userinfo (user:pass@host) would leak into browser history.
379
  if (!url.userName().isEmpty() || !url.password().isEmpty()) {
83 ✔
380
    return false;
381
  }
382
  return true;
383
}
78 ✔
384

385
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
132 ✔
386
  if (linked != nullptr) {
132 ✔
387
    *linked = false;
87 ✔
388
  }
389
  QString html;
132 ✔
390
  html.reserve(text.size());
132 ✔
391
  qsizetype lastIndex = 0;
392
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
132 ✔
393
  while (it.hasNext()) {
167 ✔
394
    const QRegularExpressionMatch match = it.next();
35 ✔
395
    const QString url = match.captured(0);
35 ✔
396
    if (!isLaunchableWebUrl(url)) {
35 ✔
397
      // Not a web URL (or it carries credentials): leave it in the escaped
398
      // plain-text run instead of making it clickable.
399
      continue;
400
    }
401
    const qsizetype start = match.capturedStart(0);
22 ✔
402
    html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
22 ✔
403
    const QString escapedUrl = url.toHtmlEscaped();
22 ✔
404
    html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
44 ✔
405
    lastIndex = match.capturedEnd(0);
22 ✔
406
    if (linked != nullptr) {
22 ✔
407
      *linked = true;
16 ✔
408
    }
409
  }
35 ✔
410
  html += text.mid(lastIndex).toHtmlEscaped();
132 ✔
411
  return html;
132 ✔
412
}
132 ✔
413

414
auto Util::newLinesRegex() -> const QRegularExpression & {
233 ✔
415
  static const QRegularExpression regex{"[\r\n]"};
233 ✔
416
  return regex;
233 ✔
417
}
418

419
// No content heuristics: a line rejected here is erased the next time .gpg-id
420
// is rewritten. A leading `-` is refused because the recipients also go
421
// positionally to `gpg --list-keys`, where it would parse as an option.
422
auto Util::isValidKeyId(const QString &keyId) -> bool {
187 ✔
423
  return !keyId.isEmpty() && !keyId.startsWith('-');
187 ✔
424
}
425

426
namespace {
427
/**
428
 * @brief Walk @p dir's real, visible directories in sorted pre-order and hand
429
 * every entry to @p visit before any recursion; a directory is entered only
430
 * when @p visit returns true for it.
431
 */
432
template <typename Visit> void walkStore(const QString &dir, Visit visit) {
198 ✔
433
  // Absolute, so the results are whatever the caller's cwd; not canonical,
434
  // so a linked root keeps the name it was configured under.
435
  QStringList pending{QDir(QDir::cleanPath(dir)).absolutePath()};
594 ✔
436
  while (!pending.isEmpty()) {
724 ✔
437
    const QDir current(pending.takeLast());
526 ✔
438
    // Every entry once, links and hidden entries included, so the decision
439
    // what to do with each is taken here, before any recursion. QDir::System
440
    // keeps dangling links in the listing.
441
    const QFileInfoList entries =
263 ✔
442
        current.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
443
                                  QDir::System | QDir::NoDotAndDotDot,
444
                              QDir::Name);
445
    QStringList subdirs;
263 ✔
446
    for (const QFileInfo &entry : entries) {
1,474 ✔
447
      if (visit(entry)) {
1,211 ✔
448
        subdirs << entry.filePath();
130 ✔
449
      }
450
    }
451
    // Pushed last-to-first so the next one taken is the first by name.
452
    for (auto it = subdirs.crbegin(); it != subdirs.crend(); ++it) {
328 ✔
453
      pending << *it;
454
    }
455
  }
456
}
396 ✔
457

458
/// A symlink or an NTFS junction: never entered, never listed.
459
auto isLink(const QFileInfo &entry) -> bool {
3,125 ✔
460
  return entry.isSymLink() || entry.isJunction();
3,125 ✔
461
}
462

463
/// Hidden by attribute, or by the dot convention on every platform: Qt 6.11
464
/// on macOS answers isHidden() from the UF_HIDDEN flag alone once an entry
465
/// was lstat()ed (qfilesystemengine_unix.cpp marks the attribute known there
466
/// without the dot check), and Windows does not consider .stversions hidden
467
/// at all.
468
auto isHiddenEntry(const QFileInfo &entry) -> bool {
619 ✔
469
  return entry.isHidden() || entry.fileName().startsWith(QLatin1Char('.'));
1,223 ✔
470
}
471

472
/// A real directory that is part of the store: .git, .stversions,
473
/// .Trash-1000 are not, as with QDirIterator without QDir::Hidden.
474
auto isStoreDirectory(const QFileInfo &entry) -> bool {
1,211 ✔
475
  return !isLink(entry) && entry.isDir() && !isHiddenEntry(entry);
1,211 ✔
476
}
477
} // namespace
478

479
auto Util::regularFilesUnder(const QString &dir, const QStringList &nameFilters,
195 ✔
480
                             QStringList *skipped, bool hiddenFiles)
481
    -> QStringList {
482
  QStringList files;
195 ✔
483
  walkStore(dir, [&](const QFileInfo &entry) {
195 ✔
484
    if (isStoreDirectory(entry)) {
1,157 ✔
485
      return true;
486
    }
487
    const bool named = QDir::match(nameFilters, entry.fileName());
1,110 ✔
488
    if (isLink(entry) || (!entry.isDir() && !entry.isFile())) {
1,110 ✔
489
      // A linked directory hides everything behind it; a linked file, or a
490
      // FIFO, socket or device, is only of interest under a name the caller
491
      // asked for.
492
      if (skipped != nullptr && (entry.isDir() || named)) {
25 ✔
493
        qCWarning(lcQtPass) << "Skipping" << entry.filePath()
24 ✔
494
                            << ": not a regular file or directory";
12 ✔
495
        *skipped << entry.filePath();
24 ✔
496
      }
497
      return false;
25 ✔
498
    }
499
    if (entry.isFile() && named && (hiddenFiles || !isHiddenEntry(entry))) {
1,085 ✔
500
      files << entry.filePath();
1,118 ✔
501
    }
502
    return false;
503
  });
504
  return files;
195 ✔
505
}
506

507
auto Util::directoriesUnder(const QString &dir) -> QStringList {
3 ✔
508
  QStringList dirs;
3 ✔
509
  walkStore(dir, [&](const QFileInfo &entry) {
3 ✔
510
    if (!isStoreDirectory(entry)) {
54 ✔
511
      return false;
512
    }
513
    dirs << entry.filePath();
18 ✔
514
    return true;
18 ✔
515
  });
516
  return dirs;
3 ✔
517
}
518

519
auto Util::isLinkedFolder(const QString &path) -> bool {
767 ✔
520
  return isLink(QFileInfo(QDir::cleanPath(path)));
1,534 ✔
521
}
522

523
auto Util::isUnderLink(const QString &path, const QString &storeRoot,
531 ✔
524
                       bool includeSelf) -> bool {
525
  // "C:/Store" and "c:/store" are one directory on Windows; a path spelled
526
  // the other way must not skip the walk (as in Pass::getGpgIdPath).
527
#ifdef Q_OS_WIN
528
  constexpr auto cs = Qt::CaseInsensitive;
529
#else
530
  constexpr auto cs = Qt::CaseSensitive;
531
#endif
532
  const QString root = QDir::cleanPath(storeRoot);
531 ✔
533
  // A root of "/" or "C:/" already ends in the separator.
534
  const QString prefix =
535
      root.endsWith(QLatin1Char('/')) ? root : root + QLatin1Char('/');
531 ✔
536
  const auto isRoot = [&](const QString &p) {
537
    return p.compare(root, cs) == 0;
901 ✔
538
  };
539
  QString current = QDir::cleanPath(path);
531 ✔
540
  if (!current.startsWith(prefix, cs)) {
531 ✔
541
    // Not under the store as named: only the entry itself can be judged.
542
    return includeSelf && !isRoot(current) && isLinkedFolder(current);
354 ✔
543
  }
544
  if (!includeSelf) {
354 ✔
545
    current = QFileInfo(current).path();
54 ✔
546
  }
547
  for (; !isRoot(current) && current.startsWith(prefix, cs);
724 ✔
548
       current = QFileInfo(current).path()) {
740 ✔
549
    if (isLinkedFolder(current)) {
407 ✔
550
      return true;
551
    }
552
  }
553
  return false;
554
}
555

556
#ifndef Q_OS_WIN
557
namespace {
558

559
/// The filesystem cannot make hard links at all (FAT/exFAT refuse with
560
/// EPERM, some FUSE filesystems with ENOTSUP or ENOSYS).
561
auto hasNoHardLinks(int error) -> bool {
562
  return error == EPERM || error == ENOTSUP || error == EOPNOTSUPP ||
2 ✔
563
         error == ENOSYS;
7 ✔
564
}
565

566
/// rename() that fails with EEXIST instead of replacing, where the platform
567
/// has one; nothing when neither the platform nor the filesystem does.
568
auto renameExclusive(const QByteArray &source, const QByteArray &target)
8 ✔
569
    -> std::optional<bool> {
570
#if defined(__GLIBC__) &&                                                      \
571
    (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 28))
572
  if (::renameat2(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(),
8 ✔
573
                  RENAME_NOREPLACE) == 0) {
574
    return true;
2 ✔
575
  }
576
  if (errno == EINVAL || errno == ENOSYS || hasNoHardLinks(errno)) {
6 ✔
577
    return std::nullopt;
4 ✔
578
  }
579
  return false;
2 ✔
580
#elif defined(Q_OS_MACOS)
581
  if (::renamex_np(source.constData(), target.constData(), RENAME_EXCL) == 0) {
582
    return true;
583
  }
584
  if (errno == EINVAL || errno == ENOTSUP) {
585
    return std::nullopt;
586
  }
587
  return false;
588
#else
589
  Q_UNUSED(source);
590
  Q_UNUSED(target);
591
  return std::nullopt;
592
#endif
593
}
594

595
/**
596
 * @brief Gives @p source the name @p target unless something already has it.
597
 *
598
 * A hard link is the one way every POSIX system can do that in one step:
599
 * linkat() fails with EEXIST rather than replacing. Where the filesystem has
600
 * no hard links, an exclusive rename does it; where that is missing too, the
601
 * name is checked and then renamed, which leaves a short window for another
602
 * writer to create it in between.
603
 */
604
auto placeWithoutReplacing(const QByteArray &source, const QByteArray &target)
87 ✔
605
    -> bool {
606
  // linkat() without AT_SYMLINK_FOLLOW follows nothing; link() would, on
607
  // macOS and the BSDs, hard-link whatever a symlink planted under the
608
  // source's name points at.
609
  if (::linkat(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(), 0) ==
87 ✔
610
      0) {
611
    ::unlink(source.constData());
72 ✔
612
    return true;
72 ✔
613
  }
614
  if (!hasNoHardLinks(errno)) {
15 ✔
615
    return false;
616
  }
617
  if (const std::optional<bool> renamed = renameExclusive(source, target)) {
8 ✔
618
    return *renamed;
4 ✔
619
  }
620
  struct stat st{};
4 ✔
621
  if (::lstat(target.constData(), &st) == 0 || errno != ENOENT) {
4 ✔
622
    errno = EEXIST;
2 ✔
623
    return false;
2 ✔
624
  }
625
  return ::rename(source.constData(), target.constData()) == 0;
2 ✔
626
}
627

628
} // namespace
629
#endif
630

631
auto Util::replaceFile(const QString &from, const QString &to, bool replace)
220 ✔
632
    -> bool {
633
#ifdef Q_OS_WIN
634
  const std::wstring source =
635
      QDir::toNativeSeparators(QFileInfo(from).absoluteFilePath())
636
          .toStdWString();
637
  const std::wstring target =
638
      QDir::toNativeSeparators(QFileInfo(to).absoluteFilePath()).toStdWString();
639
  // WRITE_THROUGH: the new entry is on the device when this returns.
640
  return MoveFileExW(source.c_str(), target.c_str(),
641
                     (replace ? MOVEFILE_REPLACE_EXISTING : 0) |
642
                         MOVEFILE_WRITE_THROUGH) != 0;
643
#else
644
  const QByteArray source = QFile::encodeName(from);
645
  const QByteArray target = QFile::encodeName(to);
646
  if (replace) {
220 ✔
647
    if (::rename(source.constData(), target.constData()) != 0) {
133 ✔
648
      return false;
649
    }
650
  } else {
651
    if (!placeWithoutReplacing(source, target)) {
87 ✔
652
      return false;
653
    }
654
  }
655
  // Sync the directory entry so a crash does not lose the new name. Best
656
  // effort: the bytes are already synced, and some network filesystems
657
  // cannot sync a directory.
658
  const int dir = ::open(QFile::encodeName(QFileInfo(to).path()).constData(),
414 ✔
659
                         O_RDONLY | O_DIRECTORY | O_CLOEXEC);
660
  if (dir >= 0) {
207 ✔
661
    int rc;
662
    do {
663
      rc = ::fsync(dir);
207 ✔
664
    } while (rc != 0 && errno == EINTR);
207 ✔
665
    ::close(dir);
207 ✔
666
  }
667
  return true;
668
#endif
669
}
670

671
auto Util::openRegularFile(const QString &path, QFile &file) -> bool {
682 ✔
672
#ifdef Q_OS_WIN
673
  // FILE_FLAG_OPEN_REPARSE_POINT opens a symbolic link or junction itself
674
  // rather than its target, so the handle's attributes say what the name
675
  // was at the moment of the open.
676
  const std::wstring native =
677
      QDir::toNativeSeparators(QFileInfo(path).absoluteFilePath())
678
          .toStdWString();
679
  HANDLE handle = CreateFileW(
680
      native.c_str(), GENERIC_READ,
681
      FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
682
      OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
683
  if (handle == INVALID_HANDLE_VALUE) {
684
    return false;
685
  }
686
  BY_HANDLE_FILE_INFORMATION info{};
687
  if (!GetFileInformationByHandle(handle, &info) ||
688
      (info.dwFileAttributes &
689
       (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY |
690
        FILE_ATTRIBUTE_DEVICE)) != 0 ||
691
      GetFileType(handle) != FILE_TYPE_DISK) {
692
    CloseHandle(handle);
693
    return false;
694
  }
695
  const int fd = _open_osfhandle(reinterpret_cast<intptr_t>(handle),
696
                                 _O_RDONLY | _O_BINARY);
697
  if (fd < 0) {
698
    CloseHandle(handle);
699
    return false;
700
  }
701
  if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
702
    _close(fd);
703
    return false;
704
  }
705
  return true;
706
#else
707
  // O_NOFOLLOW fails with ELOOP on a symbolic link; O_NONBLOCK keeps a FIFO
708
  // from blocking the open until fstat() can refuse it.
709
  const int fd = ::open(QFile::encodeName(path).constData(),
682 ✔
710
                        O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
711
  if (fd < 0) {
682 ✔
712
    return false;
713
  }
714
  struct stat st{};
633 ✔
715
  if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
633 ✔
716
    ::close(fd);
7 ✔
717
    return false;
7 ✔
718
  }
719
  // The descriptor, not the name, is what QFile opens here: the object
720
  // fstat() judged is the object read. CodeQL's check-then-use pattern
721
  // matcher sees an open after a check and cannot tell.
722
  if (!file.open(fd, QIODevice::ReadOnly, // codeql[cpp/toctou-race-condition]
626 ✔
723
                 QFileDevice::AutoCloseHandle)) {
724
    ::close(fd);
×
725
    return false;
×
726
  }
727
  return true;
728
#endif
729
}
730

731
auto Util::readRegularFile(const QString &path, qint64 maxBytes)
209 ✔
732
    -> std::optional<QByteArray> {
733
  QFile file;
209 ✔
734
  if (!openRegularFile(path, file)) {
209 ✔
735
    return std::nullopt;
11 ✔
736
  }
737
  // A regular file's size is reliable, also when sparse: refuse a large one
738
  // before reading anything. One byte past the limit still guards a file
739
  // that grows meanwhile.
740
  if (file.size() > maxBytes) {
198 ✔
741
    return std::nullopt;
3 ✔
742
  }
743
  QByteArray bytes = file.read(maxBytes + 1);
195 ✔
744
  if (bytes.size() > maxBytes || file.error() != QFileDevice::NoError) {
195 ✔
745
    return std::nullopt;
×
746
  }
747
  return bytes;
748
}
209 ✔
749

750
auto Util::syncToDisk(QFileDevice &file) -> bool {
209 ✔
751
  if (!file.flush()) {
209 ✔
752
    return false;
753
  }
754
#ifdef Q_OS_WIN
755
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
756
  return handle != INVALID_HANDLE_VALUE && FlushFileBuffers(handle) != 0;
757
#else
758
  return ::fsync(file.handle()) == 0;
209 ✔
759
#endif
760
}
761

762
namespace {
763

764
/**
765
 * @brief What tells one file object from another on its filesystem: device
766
 * and inode on POSIX, volume serial and file index on Windows.
767
 */
768
struct FileIdentity {
769
  quint64 volume = 0;
770
  quint64 index = 0;
771
  bool known = false;
772
};
773

774
auto operator==(const FileIdentity &a, const FileIdentity &b) -> bool {
198 ✔
775
  return a.known && b.known && a.volume == b.volume && a.index == b.index;
198 ✔
776
}
777

778
/// The identity of the object @p file is open on, or an unknown one.
779
auto identityOf(const QFileDevice &file) -> FileIdentity {
407 ✔
780
#ifdef Q_OS_WIN
781
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
782
  BY_HANDLE_FILE_INFORMATION info;
783
  if (handle == INVALID_HANDLE_VALUE ||
784
      GetFileInformationByHandle(handle, &info) == 0) {
785
    return {};
786
  }
787
  // On FAT/exFAT the file ID is the directory entry's offset, which a rename
788
  // can move. They have no hard links either (the swap this catches needs
789
  // one), so the identity is left unknown there.
790
  DWORD flags = 0;
791
  if (GetVolumeInformationByHandleW(handle, nullptr, 0, nullptr, nullptr,
792
                                    &flags, nullptr, 0) == 0 ||
793
      (flags & FILE_SUPPORTS_HARD_LINKS) == 0) {
794
    return {};
795
  }
796
  return {info.dwVolumeSerialNumber,
797
          (static_cast<quint64>(info.nFileIndexHigh) << 32) |
798
              info.nFileIndexLow,
799
          true};
800
#else
801
  struct stat st{};
407 ✔
802
  if (::fstat(file.handle(), &st) != 0) {
407 ✔
803
    return {};
×
804
  }
805
  return {static_cast<quint64>(st.st_dev), static_cast<quint64>(st.st_ino),
407 ✔
806
          true};
407 ✔
807
#endif
808
}
809

810
/// Put @p text in @p error when there is one; false, for `return fail(...)`.
811
auto fail(QString *error, const QString &text) -> bool {
812
  if (error != nullptr) {
21 ✔
813
    *error = text;
21 ✔
814
  }
815
  return false;
816
}
817

818
/// A filled, synced temporary next to its destination.
819
struct Staged {
635 ✔
820
  QString path;
821
  FileIdentity identity;
822
};
823

824
/**
825
 * @brief Create the temporary next to @p path (opaque name, exclusive,
826
 * owner-only), fill it through its handle and sync it. The QTemporaryFile
827
 * goes out of scope before the caller renames: it keeps its handle open for
828
 * as long as it lives, and Windows does not rename an open file.
829
 */
830
auto stageNextTo(const QString &path, const Util::Filler &fill, QString *why)
217 ✔
831
    -> std::optional<Staged> {
832
  Staged staged;
217 ✔
833
  {
834
    QTemporaryFile file(QFileInfo(path).path() +
434 ✔
835
                        QStringLiteral("/.qtpass-XXXXXX.tmp"));
434 ✔
836
    file.setAutoRemove(false);
217 ✔
837
    if (!file.open()) {
217 ✔
838
      *why = QCoreApplication::translate(
8 ✔
839
                 "Util", "Cannot create a temporary file next to %1: %2")
840
                 .arg(path, file.errorString());
16 ✔
841
      return std::nullopt;
842
    }
843
    staged.path = file.fileName();
209 ✔
844
    // Owner-only: a .gpg-id names the keys a store is encrypted to, an
845
    // entry is an entry. QTemporaryFile creates 0600 already; say so for
846
    // platforms where it may not.
847
    file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
209 ✔
848
    *why = fill(file);
418 ✔
849
    if (why->isEmpty() && !Util::syncToDisk(file)) {
209 ✔
850
      *why = QCoreApplication::translate("Util", "Cannot write %1: %2")
×
851
                 .arg(path, file.errorString());
×
852
    }
853
    staged.identity = identityOf(file);
209 ✔
854
  }
217 ✔
855
  if (!why->isEmpty()) {
209 ✔
856
    QFile::remove(staged.path);
×
857
    return std::nullopt;
×
858
  }
859
  return staged;
860
}
861

862
/// Why replaceFile() could not put the temporary under @p path.
863
auto placementError(const QString &path, bool replace) -> QString {
9 ✔
864
  if (replace) {
9 ✔
865
    return QCoreApplication::translate("Util", "Failed to replace %1.")
4 ✔
866
        .arg(path);
2 ✔
867
  }
868
  const QFileInfo taken(path);
7 ✔
869
  return taken.exists() || taken.isSymLink()
8 ✔
870
             ? QCoreApplication::translate("Util", "%1 already exists.")
7 ✔
871
                   .arg(path)
872
             : QCoreApplication::translate("Util", "Failed to write %1.")
1 ✔
873
                   .arg(path);
8 ✔
874
}
7 ✔
875

876
/**
877
 * @brief Whether what is under @p path is the file that was filled: not a
878
 * link, and not another file (a hard link to something of the user's, say)
879
 * swapped in under the temporary's name. Opened without following, compared
880
 * by identity; only two known identities can prove a swap, otherwise every
881
 * write on such a store would be reported as tampering.
882
 */
883
auto isTheFileWritten(const QString &path, const FileIdentity &written)
200 ✔
884
    -> bool {
885
  QFile placed;
200 ✔
886
  if (!Util::openRegularFile(path, placed)) {
200 ✔
887
    return false;
888
  }
889
  const FileIdentity there = identityOf(placed);
198 ✔
890
  if (!written.known || !there.known) {
198 ✔
891
    qCWarning(lcQtPass) << "Cannot tell whether" << path
×
892
                        << "is the file that was written here";
×
893
    return true;
×
894
  }
895
  return there == written;
198 ✔
896
}
200 ✔
897

898
} // namespace
899

900
auto Util::stageFileReplacing(const QString &path, bool replace,
217 ✔
901
                              const Filler &fill, QString *error) -> bool {
902
  QString why;
217 ✔
903
  const std::optional<Staged> staged = stageNextTo(path, fill, &why);
217 ✔
904
  if (!staged) {
217 ✔
905
    return fail(error, why);
906
  }
907
  if (!replaceFile(staged->path, path, replace)) {
209 ✔
908
    QFile::remove(staged->path);
9 ✔
909
    return fail(error, placementError(path, replace));
18 ✔
910
  }
911
  // A mismatch is reported and left: removing by name could take another
912
  // writer's file.
913
  if (!isTheFileWritten(path, staged->identity)) {
200 ✔
914
    return fail(
915
        error,
916
        QCoreApplication::translate(
4 ✔
917
            "Util", "%1 was swapped for another file while it was written.")
918
            .arg(path));
8 ✔
919
  }
920
  return true;
921
}
922

923
auto Util::writeFileReplacing(const QString &path, const QByteArray &bytes,
101 ✔
924
                              bool replace, QString *error) -> bool {
925
  return stageFileReplacing(
101 ✔
926
      path, replace,
927
      [&path, &bytes](QFileDevice &staged) -> QString {
99 ✔
928
        if (staged.write(bytes) != bytes.size()) {
99 ✔
929
          return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
930
              .arg(path, staged.errorString());
×
931
        }
932
        return {};
933
      },
934
      error);
101 ✔
935
}
936

937
auto Util::copyFileReplacing(const QString &src, const QString &dst,
115 ✔
938
                             bool replace, QString *error) -> bool {
939
  QFile in;
115 ✔
940
  if (!openRegularFile(src, in)) {
115 ✔
941
    if (error)
4 ✔
942
      *error = QCoreApplication::translate("Util", "Cannot read %1.").arg(src);
8 ✔
943
    return false;
4 ✔
944
  }
945
  return stageFileReplacing(
111 ✔
946
      dst, replace,
947
      [&src, &dst, &in](QFileDevice &staged) -> QString {
327 ✔
948
        char buf[64 * 1024];
949
        for (;;) {
950
          const qint64 n = in.read(buf, sizeof buf);
222 ✔
951
          if (n < 0) {
222 ✔
952
            return QCoreApplication::translate("Util", "Cannot read %1: %2")
×
953
                .arg(src, in.errorString());
×
954
          }
955
          if (n == 0) {
222 ✔
956
            return {};
957
          }
958
          if (staged.write(buf, n) != n) {
117 ✔
959
            return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
960
                .arg(dst, staged.errorString());
×
961
          }
962
        }
963
      },
964
      error);
965
}
115 ✔
966

967
namespace {
968

969
/// Remove one directory entry that is not a real directory: a link goes as
970
/// the entry itself, never its target (a junction or a directory symlink on
971
/// Windows is a directory entry and goes with rmdir); a read-only file gets
972
/// write access and one more try, as QDir::removeRecursively() does.
973
auto removeEntry(const QFileInfo &entry) -> bool {
11 ✔
974
  const QString path = entry.filePath();
11 ✔
975
  if (isLink(entry)) {
11 ✔
976
    if (QFile::remove(path) || QDir().rmdir(path)) {
6 ✔
977
      return true;
978
    }
979
    qCWarning(lcQtPass) << "Could not remove link" << path;
2 ✔
980
    return false;
1 ✔
981
  }
982
  if (QFile::remove(path)) {
6 ✔
983
    return true;
984
  }
985
  const QFile::Permissions perms = QFile::permissions(path);
1 ✔
986
  if (!perms.testFlag(QFile::WriteUser) &&
1 ✔
987
      QFile::setPermissions(path, perms | QFile::WriteUser) &&
2 ✔
988
      QFile::remove(path)) {
1 ✔
989
    return true;
990
  }
991
  qCWarning(lcQtPass) << "Could not remove" << path;
2 ✔
992
  return false;
1 ✔
993
}
994

995
} // namespace
996

997
auto Util::removeTree(const QString &dir) -> bool {
21 ✔
998
  // A trailing separator makes lstat follow a link ("link/" is the target
999
  // directory); the link itself is what this is about.
1000
  const QString path = QDir::cleanPath(dir);
21 ✔
1001
  const QFileInfo top(path);
21 ✔
1002
  if (isLink(top)) {
21 ✔
1003
    // rm -rf on a link removes the link.
1004
    return QFile::remove(path) || QDir().rmdir(path);
16 ✔
1005
  }
1006
  if (!top.isDir()) {
7 ✔
1007
    return false;
1008
  }
1009
  bool ok = true;
1010
  const QFileInfoList entries =
1011
      QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
12 ✔
1012
                                   QDir::System | QDir::NoDotAndDotDot,
1013
                               QDir::Name);
6 ✔
1014
  for (const QFileInfo &entry : entries) {
18 ✔
1015
    const bool removed = entry.isDir() && !isLink(entry)
17 ✔
1016
                             ? removeTree(entry.filePath())
13 ✔
1017
                             : removeEntry(entry);
11 ✔
1018
    ok = removed && ok;
12 ✔
1019
  }
1020
  return ok && QDir().rmdir(path);
11 ✔
1021
}
21 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc