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

IJHack / QtPass / 40686495231

28 Oct 2026 12:16PM UTC coverage: 92.563% (+0.003%) from 92.56%
40686495231

Pull #2086

github

web-flow
Merge c5ab76cc0 into 27211dd18
Pull Request #2086: Git: keep to the store when its parent's path holds the list separator

20 of 22 new or added lines in 3 files covered. (90.91%)

18 existing lines in 1 file now uncovered.

9633 of 10407 relevant lines covered (92.56%)

336.39 hits per line

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

94.15
/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() {
638 ✔
42
  if (!_envInitialised) {
638 ✔
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
}
638 ✔
67

68
auto Util::findPasswordStore() -> QString {
255 ✔
69
  QString path;
255 ✔
70
  initialiseEnvironment();
255 ✔
71
  if (_env.contains("PASSWORD_STORE_DIR")) {
510 ✔
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");
756 ✔
78
#endif
79
  }
80
  return Util::normalizeFolderPath(QDir::cleanPath(path));
510 ✔
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 {
334 ✔
94
  QString normalizedPath = path;
95
  if (!normalizedPath.endsWith('/')) {
334 ✔
96
    normalizedPath += '/';
288 ✔
97
  }
98
  return normalizedPath;
334 ✔
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 {
298 ✔
131
  if (store.isEmpty()) {
298 ✔
132
    return {};
133
  }
134
  QString path = QFileInfo(store).canonicalFilePath();
298 ✔
135
  if (path.isEmpty()) {
298 ✔
136
    path = QDir::cleanPath(QDir(store).absolutePath());
×
137
  }
138
  return QFileInfo(path).absolutePath();
298 ✔
139
}
140

141
auto Util::gitStoreVariables(const QString &store)
298 ✔
142
    -> QList<std::pair<QString, QString>> {
143
  const QString ceiling = gitCeiling(store);
298 ✔
144
  if (ceiling.isEmpty()) {
298 ✔
NEW
145
    return {};
×
146
  }
147
  QList<std::pair<QString, QString>> vars{
148
      {QStringLiteral("GIT_CEILING_DIRECTORIES"), ceiling}};
894 ✔
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)) {
298 ✔
157
    QString root = QFileInfo(store).canonicalFilePath();
2 ✔
158
    if (root.isEmpty()) {
2 ✔
NEW
159
      root = QDir::cleanPath(QDir(store).absolutePath());
×
160
    }
161
    vars.append({QStringLiteral("GIT_DIR"),
2 ✔
162
                 QDir(root).filePath(QStringLiteral(".git"))});
4 ✔
163
    vars.append({QStringLiteral("GIT_WORK_TREE"), root});
4 ✔
164
  }
165
  return vars;
166
}
298 ✔
167

168
auto Util::mkpathForStore(const QString &path) -> bool {
54 ✔
169
  const QString clean = QDir::cleanPath(QDir(path).absolutePath());
108 ✔
170
  if (QFileInfo(clean).isDir()) {
54 ✔
171
    return true;
172
  }
173
  const QString parent = QFileInfo(clean).absolutePath();
31 ✔
174
  if (parent != clean && !mkpathForStore(parent)) {
31 ✔
175
    return false;
176
  }
177
#ifdef Q_OS_UNIX
178
  // The store's umask, as git and gpg get it: a store shared with a group
179
  // (PASSWORD_STORE_UMASK=007) gets group folders, the default owner-only.
180
  const int umask = Executor::childUmask() >= 0 ? Executor::childUmask() : 077;
28 ✔
181
  QFile::Permissions permissions;
182
  const QFile::Permission bits[] = {
28 ✔
183
      QFile::ReadOwner, QFile::WriteOwner, QFile::ExeOwner,
184
      QFile::ReadGroup, QFile::WriteGroup, QFile::ExeGroup,
185
      QFile::ReadOther, QFile::WriteOther, QFile::ExeOther};
186
  for (int i = 0; i < 9; ++i) {
280 ✔
187
    if ((umask & (0400 >> i)) == 0) {
252 ✔
188
      permissions |= bits[i];
90 ✔
189
    }
190
  }
191
  // mkdir() still takes QtPass's own umask off (a session's 022 would drop
192
  // the group's write bit); set the mode exactly afterwards. That only ever
193
  // widens it to what was asked, never past.
194
  const bool made = QDir().mkdir(clean, permissions) &&
56 ✔
195
                    QFile::setPermissions(clean, permissions);
23 ✔
196
#else
197
  // Windows: permissions here would become an ACL of their own, replacing
198
  // the inherited one; the folder takes its parent's, as before.
199
  const bool made = QDir().mkdir(clean);
200
#endif
201
  return made || QFileInfo(clean).isDir();
28 ✔
202
}
203

204
auto Util::findBinaryInPath(const QString &binary) -> QString {
384 ✔
205
  if (binary.isEmpty()) {
384 ✔
206
    return {};
207
  }
208

209
  initialiseEnvironment();
383 ✔
210

211
  const QStringList dirs =
212
      _env.value(QStringLiteral("PATH"))
766 ✔
213
          .split(QDir::listSeparator(), Qt::SkipEmptyParts);
383 ✔
214
  QString ret;
383 ✔
215
  if (QDir::fromNativeSeparators(binary).contains(u'/')) {
383 ✔
216
    // An explicit path is not a PATH search: an absolute path is checked
217
    // as-is, a relative one is resolved against the PATH directories. The
218
    // directory-list overload refuses such names, so handle them here.
219
    if (QDir::isAbsolutePath(binary)) {
3 ✔
220
      ret = QStandardPaths::findExecutable(binary);
4 ✔
221
    } else if (!dirs.isEmpty()) {
1 ✔
222
      ret = QStandardPaths::findExecutable(binary, dirs);
2 ✔
223
    }
224
  } else {
225
    ret = findBinaryInPath(binary, dirs);
760 ✔
226
  }
227
#ifdef Q_OS_WIN
228
  if (ret.isEmpty()) {
229
    // Cache per-binary WSL lookup result — the wsl --version probe is a
230
    // blocking subprocess that can run several times per session for
231
    // missing binaries; once decided, the answer doesn't change at runtime.
232
    static QHash<QString, QString> wslBinaryCache;
233
    const bool hasWhitespace =
234
        std::any_of(binary.cbegin(), binary.cend(),
235
                    [](const QChar ch) { return ch.isSpace(); });
236
    if (!hasWhitespace) {
237
      auto cached = wslBinaryCache.constFind(binary);
238
      if (cached != wslBinaryCache.constEnd()) {
239
        ret = cached.value();
240
      } else {
241
        QString wslCommand = QStringLiteral("wsl ") + binary;
242
        qCDebug(lcQtPass)
243
            << "Util::findBinaryInPath(): falling back to WSL for binary"
244
            << binary;
245
        QString out, err;
246
        QString cachedResult;
247
        if (Executor::executeBlocking(wslCommand, {"--version"}, &out, &err) ==
248
                0 &&
249
            !out.isEmpty() && err.isEmpty()) {
250
          qCDebug(lcQtPass)
251
              << "Util::findBinaryInPath(): using WSL binary" << wslCommand;
252
          cachedResult = wslCommand;
253
        }
254
        wslBinaryCache.insert(binary, cachedResult);
255
        ret = cachedResult;
256
      }
257
    }
258
  }
259
#endif
260

261
  return ret;
262
}
263

264
// Bare names only: QStandardPaths::findExecutable() returns an absolute binary
265
// without consulting searchPaths, and a relative one with ".." could escape
266
// them.
267
auto Util::findBinaryInPath(const QString &binary,
394 ✔
268
                            const QStringList &searchPaths) -> QString {
269
  if (binary.isEmpty() || QDir::fromNativeSeparators(binary).contains(u'/')) {
787 ✔
270
    return {};
271
  }
272
  QStringList dirs;
390 ✔
273
  dirs.reserve(searchPaths.size());
390 ✔
274
  for (const QString &dir : searchPaths) {
5,788 ✔
275
    if (!dir.isEmpty()) {
5,398 ✔
276
      dirs.append(dir);
277
    }
278
  }
279
  if (dirs.isEmpty()) {
390 ✔
280
    // QStandardPaths::findExecutable() treats an empty list as "use PATH".
281
    return {};
282
  }
283
  return QStandardPaths::findExecutable(binary, dirs);
386 ✔
284
}
285

286
auto Util::configIsValid(const AppSettings &s) -> bool {
197 ✔
287
  const QString configFilePath = QDir(s.passStore).filePath(".gpg-id");
394 ✔
288
  if (!QFile(configFilePath).exists()) {
197 ✔
289
    return false;
290
  }
291

292
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
129 ✔
293

294
  if (const auto wsl = Executor::parseWslCommand(executable)) {
129 ✔
295
    // Probe WSL once per session — availability doesn't change at runtime
296
    // and the executeBlocking call is a blocking subprocess.
297
    static const bool wslAvailable = [&wsl]() {
1 ✔
298
      QString out;
1 ✔
299
      QString err;
1 ✔
300
      return Executor::executeBlocking(wsl->launcher,
3 ✔
301
                                       {QStringLiteral("--version")}, &out,
1 ✔
UNCOV
302
                                       &err) == 0 &&
×
303
             !out.isEmpty() && err.isEmpty();
2 ✔
304
    }();
1 ✔
305
    if (wslAvailable) {
1 ✔
306
      return true;
307
    }
308
  }
309
  return QFile(executable).exists();
129 ✔
310
}
311

312
auto Util::endsWithGpg() -> const QRegularExpression & {
6,666 ✔
313
  static const QRegularExpression expr{R"(\.gpg$)"};
6,666 ✔
314
  return expr;
6,666 ✔
315
}
316

317
// Stops at whitespace, quotes and brackets so a URL on its own line keeps no
318
// line break. file:/// is excluded on purpose (local, not network); the test
319
// relies on that.
320
auto Util::protocolRegex() -> const QRegularExpression & {
136 ✔
321
  static const QRegularExpression regex{
322
      R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
136 ✔
323
  return regex;
136 ✔
324
}
325

326
auto Util::isLaunchableWebUrl(const QString &value) -> bool {
82 ✔
327
  const QString trimmed = value.trimmed();
328
  if (trimmed.isEmpty()) {
82 ✔
329
    return false;
330
  }
331
  // Reject control characters first, before QUrl normalisation can hide a
332
  // CR/LF/NUL injection into the OS URL handler.
333
  for (const QChar &c : trimmed) {
1,947 ✔
334
    if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
335
        c == QChar(QChar::Null)) {
336
      return false;
337
    }
338
  }
339
  const QUrl url(trimmed, QUrl::StrictMode);
78 ✔
340
  if (!url.isValid()) {
78 ✔
341
    return false;
342
  }
343
  const QString scheme = url.scheme().toLower();
136 ✔
344
  if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
131 ✔
345
    return false;
23 ✔
346
  }
347
  if (url.host().isEmpty()) {
90 ✔
348
    return false;
349
  }
350
  // Embedded userinfo (user:pass@host) would leak into browser history.
351
  if (!url.userName().isEmpty() || !url.password().isEmpty()) {
83 ✔
352
    return false;
353
  }
354
  return true;
355
}
78 ✔
356

357
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
132 ✔
358
  if (linked != nullptr) {
132 ✔
359
    *linked = false;
87 ✔
360
  }
361
  QString html;
132 ✔
362
  html.reserve(text.size());
132 ✔
363
  qsizetype lastIndex = 0;
364
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
132 ✔
365
  while (it.hasNext()) {
167 ✔
366
    const QRegularExpressionMatch match = it.next();
35 ✔
367
    const QString url = match.captured(0);
35 ✔
368
    if (!isLaunchableWebUrl(url)) {
35 ✔
369
      // Not a web URL (or it carries credentials): leave it in the escaped
370
      // plain-text run instead of making it clickable.
371
      continue;
372
    }
373
    const qsizetype start = match.capturedStart(0);
22 ✔
374
    html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
22 ✔
375
    const QString escapedUrl = url.toHtmlEscaped();
22 ✔
376
    html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
44 ✔
377
    lastIndex = match.capturedEnd(0);
22 ✔
378
    if (linked != nullptr) {
22 ✔
379
      *linked = true;
16 ✔
380
    }
381
  }
35 ✔
382
  html += text.mid(lastIndex).toHtmlEscaped();
132 ✔
383
  return html;
132 ✔
384
}
132 ✔
385

386
auto Util::newLinesRegex() -> const QRegularExpression & {
233 ✔
387
  static const QRegularExpression regex{"[\r\n]"};
233 ✔
388
  return regex;
233 ✔
389
}
390

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

398
namespace {
399
/**
400
 * @brief Walk @p dir's real, visible directories in sorted pre-order and hand
401
 * every entry to @p visit before any recursion; a directory is entered only
402
 * when @p visit returns true for it.
403
 */
404
template <typename Visit> void walkStore(const QString &dir, Visit visit) {
198 ✔
405
  // Absolute, so the results are whatever the caller's cwd; not canonical,
406
  // so a linked root keeps the name it was configured under.
407
  QStringList pending{QDir(QDir::cleanPath(dir)).absolutePath()};
594 ✔
408
  while (!pending.isEmpty()) {
724 ✔
409
    const QDir current(pending.takeLast());
526 ✔
410
    // Every entry once, links and hidden entries included, so the decision
411
    // what to do with each is taken here, before any recursion. QDir::System
412
    // keeps dangling links in the listing.
413
    const QFileInfoList entries =
263 ✔
414
        current.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
415
                                  QDir::System | QDir::NoDotAndDotDot,
416
                              QDir::Name);
417
    QStringList subdirs;
263 ✔
418
    for (const QFileInfo &entry : entries) {
1,474 ✔
419
      if (visit(entry)) {
1,211 ✔
420
        subdirs << entry.filePath();
130 ✔
421
      }
422
    }
423
    // Pushed last-to-first so the next one taken is the first by name.
424
    for (auto it = subdirs.crbegin(); it != subdirs.crend(); ++it) {
328 ✔
425
      pending << *it;
426
    }
427
  }
428
}
396 ✔
429

430
/// A symlink or an NTFS junction: never entered, never listed.
431
auto isLink(const QFileInfo &entry) -> bool {
3,125 ✔
432
  return entry.isSymLink() || entry.isJunction();
3,125 ✔
433
}
434

435
/// Hidden by attribute, or by the dot convention on every platform: Qt 6.11
436
/// on macOS answers isHidden() from the UF_HIDDEN flag alone once an entry
437
/// was lstat()ed (qfilesystemengine_unix.cpp marks the attribute known there
438
/// without the dot check), and Windows does not consider .stversions hidden
439
/// at all.
440
auto isHiddenEntry(const QFileInfo &entry) -> bool {
619 ✔
441
  return entry.isHidden() || entry.fileName().startsWith(QLatin1Char('.'));
1,223 ✔
442
}
443

444
/// A real directory that is part of the store: .git, .stversions,
445
/// .Trash-1000 are not, as with QDirIterator without QDir::Hidden.
446
auto isStoreDirectory(const QFileInfo &entry) -> bool {
1,211 ✔
447
  return !isLink(entry) && entry.isDir() && !isHiddenEntry(entry);
1,211 ✔
448
}
449
} // namespace
450

451
auto Util::regularFilesUnder(const QString &dir, const QStringList &nameFilters,
195 ✔
452
                             QStringList *skipped, bool hiddenFiles)
453
    -> QStringList {
454
  QStringList files;
195 ✔
455
  walkStore(dir, [&](const QFileInfo &entry) {
195 ✔
456
    if (isStoreDirectory(entry)) {
1,157 ✔
457
      return true;
458
    }
459
    const bool named = QDir::match(nameFilters, entry.fileName());
1,110 ✔
460
    if (isLink(entry) || (!entry.isDir() && !entry.isFile())) {
1,110 ✔
461
      // A linked directory hides everything behind it; a linked file, or a
462
      // FIFO, socket or device, is only of interest under a name the caller
463
      // asked for.
464
      if (skipped != nullptr && (entry.isDir() || named)) {
25 ✔
465
        qCWarning(lcQtPass) << "Skipping" << entry.filePath()
24 ✔
466
                            << ": not a regular file or directory";
12 ✔
467
        *skipped << entry.filePath();
24 ✔
468
      }
469
      return false;
25 ✔
470
    }
471
    if (entry.isFile() && named && (hiddenFiles || !isHiddenEntry(entry))) {
1,085 ✔
472
      files << entry.filePath();
1,118 ✔
473
    }
474
    return false;
475
  });
476
  return files;
195 ✔
477
}
478

479
auto Util::directoriesUnder(const QString &dir) -> QStringList {
3 ✔
480
  QStringList dirs;
3 ✔
481
  walkStore(dir, [&](const QFileInfo &entry) {
3 ✔
482
    if (!isStoreDirectory(entry)) {
54 ✔
483
      return false;
484
    }
485
    dirs << entry.filePath();
18 ✔
486
    return true;
18 ✔
487
  });
488
  return dirs;
3 ✔
489
}
490

491
auto Util::isLinkedFolder(const QString &path) -> bool {
767 ✔
492
  return isLink(QFileInfo(QDir::cleanPath(path)));
1,534 ✔
493
}
494

495
auto Util::isUnderLink(const QString &path, const QString &storeRoot,
531 ✔
496
                       bool includeSelf) -> bool {
497
  // "C:/Store" and "c:/store" are one directory on Windows; a path spelled
498
  // the other way must not skip the walk (as in Pass::getGpgIdPath).
499
#ifdef Q_OS_WIN
500
  constexpr auto cs = Qt::CaseInsensitive;
501
#else
502
  constexpr auto cs = Qt::CaseSensitive;
503
#endif
504
  const QString root = QDir::cleanPath(storeRoot);
531 ✔
505
  // A root of "/" or "C:/" already ends in the separator.
506
  const QString prefix =
507
      root.endsWith(QLatin1Char('/')) ? root : root + QLatin1Char('/');
531 ✔
508
  const auto isRoot = [&](const QString &p) {
509
    return p.compare(root, cs) == 0;
901 ✔
510
  };
511
  QString current = QDir::cleanPath(path);
531 ✔
512
  if (!current.startsWith(prefix, cs)) {
531 ✔
513
    // Not under the store as named: only the entry itself can be judged.
514
    return includeSelf && !isRoot(current) && isLinkedFolder(current);
354 ✔
515
  }
516
  if (!includeSelf) {
354 ✔
517
    current = QFileInfo(current).path();
54 ✔
518
  }
519
  for (; !isRoot(current) && current.startsWith(prefix, cs);
724 ✔
520
       current = QFileInfo(current).path()) {
740 ✔
521
    if (isLinkedFolder(current)) {
407 ✔
522
      return true;
523
    }
524
  }
525
  return false;
526
}
527

528
#ifndef Q_OS_WIN
529
namespace {
530

531
/// The filesystem cannot make hard links at all (FAT/exFAT refuse with
532
/// EPERM, some FUSE filesystems with ENOTSUP or ENOSYS).
533
auto hasNoHardLinks(int error) -> bool {
534
  return error == EPERM || error == ENOTSUP || error == EOPNOTSUPP ||
2 ✔
535
         error == ENOSYS;
7 ✔
536
}
537

538
/// rename() that fails with EEXIST instead of replacing, where the platform
539
/// has one; nothing when neither the platform nor the filesystem does.
540
auto renameExclusive(const QByteArray &source, const QByteArray &target)
8 ✔
541
    -> std::optional<bool> {
542
#if defined(__GLIBC__) &&                                                      \
543
    (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 28))
544
  if (::renameat2(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(),
8 ✔
545
                  RENAME_NOREPLACE) == 0) {
546
    return true;
2 ✔
547
  }
548
  if (errno == EINVAL || errno == ENOSYS || hasNoHardLinks(errno)) {
6 ✔
549
    return std::nullopt;
4 ✔
550
  }
551
  return false;
2 ✔
552
#elif defined(Q_OS_MACOS)
553
  if (::renamex_np(source.constData(), target.constData(), RENAME_EXCL) == 0) {
554
    return true;
555
  }
556
  if (errno == EINVAL || errno == ENOTSUP) {
557
    return std::nullopt;
558
  }
559
  return false;
560
#else
561
  Q_UNUSED(source);
562
  Q_UNUSED(target);
563
  return std::nullopt;
564
#endif
565
}
566

567
/**
568
 * @brief Gives @p source the name @p target unless something already has it.
569
 *
570
 * A hard link is the one way every POSIX system can do that in one step:
571
 * linkat() fails with EEXIST rather than replacing. Where the filesystem has
572
 * no hard links, an exclusive rename does it; where that is missing too, the
573
 * name is checked and then renamed, which leaves a short window for another
574
 * writer to create it in between.
575
 */
576
auto placeWithoutReplacing(const QByteArray &source, const QByteArray &target)
87 ✔
577
    -> bool {
578
  // linkat() without AT_SYMLINK_FOLLOW follows nothing; link() would, on
579
  // macOS and the BSDs, hard-link whatever a symlink planted under the
580
  // source's name points at.
581
  if (::linkat(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(), 0) ==
87 ✔
582
      0) {
583
    ::unlink(source.constData());
72 ✔
584
    return true;
72 ✔
585
  }
586
  if (!hasNoHardLinks(errno)) {
15 ✔
587
    return false;
588
  }
589
  if (const std::optional<bool> renamed = renameExclusive(source, target)) {
8 ✔
590
    return *renamed;
4 ✔
591
  }
592
  struct stat st{};
4 ✔
593
  if (::lstat(target.constData(), &st) == 0 || errno != ENOENT) {
4 ✔
594
    errno = EEXIST;
2 ✔
595
    return false;
2 ✔
596
  }
597
  return ::rename(source.constData(), target.constData()) == 0;
2 ✔
598
}
599

600
} // namespace
601
#endif
602

603
auto Util::replaceFile(const QString &from, const QString &to, bool replace)
220 ✔
604
    -> bool {
605
#ifdef Q_OS_WIN
606
  const std::wstring source =
607
      QDir::toNativeSeparators(QFileInfo(from).absoluteFilePath())
608
          .toStdWString();
609
  const std::wstring target =
610
      QDir::toNativeSeparators(QFileInfo(to).absoluteFilePath()).toStdWString();
611
  // WRITE_THROUGH: the new entry is on the device when this returns.
612
  return MoveFileExW(source.c_str(), target.c_str(),
613
                     (replace ? MOVEFILE_REPLACE_EXISTING : 0) |
614
                         MOVEFILE_WRITE_THROUGH) != 0;
615
#else
616
  const QByteArray source = QFile::encodeName(from);
617
  const QByteArray target = QFile::encodeName(to);
618
  if (replace) {
220 ✔
619
    if (::rename(source.constData(), target.constData()) != 0) {
133 ✔
620
      return false;
621
    }
622
  } else {
623
    if (!placeWithoutReplacing(source, target)) {
87 ✔
624
      return false;
625
    }
626
  }
627
  // Sync the directory entry so a crash does not lose the new name. Best
628
  // effort: the bytes are already synced, and some network filesystems
629
  // cannot sync a directory.
630
  const int dir = ::open(QFile::encodeName(QFileInfo(to).path()).constData(),
414 ✔
631
                         O_RDONLY | O_DIRECTORY | O_CLOEXEC);
632
  if (dir >= 0) {
207 ✔
633
    int rc;
634
    do {
635
      rc = ::fsync(dir);
207 ✔
636
    } while (rc != 0 && errno == EINTR);
207 ✔
637
    ::close(dir);
207 ✔
638
  }
639
  return true;
640
#endif
641
}
642

643
auto Util::openRegularFile(const QString &path, QFile &file) -> bool {
682 ✔
644
#ifdef Q_OS_WIN
645
  // FILE_FLAG_OPEN_REPARSE_POINT opens a symbolic link or junction itself
646
  // rather than its target, so the handle's attributes say what the name
647
  // was at the moment of the open.
648
  const std::wstring native =
649
      QDir::toNativeSeparators(QFileInfo(path).absoluteFilePath())
650
          .toStdWString();
651
  HANDLE handle = CreateFileW(
652
      native.c_str(), GENERIC_READ,
653
      FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
654
      OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
655
  if (handle == INVALID_HANDLE_VALUE) {
656
    return false;
657
  }
658
  BY_HANDLE_FILE_INFORMATION info{};
659
  if (!GetFileInformationByHandle(handle, &info) ||
660
      (info.dwFileAttributes &
661
       (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY |
662
        FILE_ATTRIBUTE_DEVICE)) != 0 ||
663
      GetFileType(handle) != FILE_TYPE_DISK) {
664
    CloseHandle(handle);
665
    return false;
666
  }
667
  const int fd = _open_osfhandle(reinterpret_cast<intptr_t>(handle),
668
                                 _O_RDONLY | _O_BINARY);
669
  if (fd < 0) {
670
    CloseHandle(handle);
671
    return false;
672
  }
673
  if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
674
    _close(fd);
675
    return false;
676
  }
677
  return true;
678
#else
679
  // O_NOFOLLOW fails with ELOOP on a symbolic link; O_NONBLOCK keeps a FIFO
680
  // from blocking the open until fstat() can refuse it.
681
  const int fd = ::open(QFile::encodeName(path).constData(),
682 ✔
682
                        O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
683
  if (fd < 0) {
682 ✔
684
    return false;
685
  }
686
  struct stat st{};
633 ✔
687
  if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
633 ✔
688
    ::close(fd);
7 ✔
689
    return false;
7 ✔
690
  }
691
  // The descriptor, not the name, is what QFile opens here: the object
692
  // fstat() judged is the object read. CodeQL's check-then-use pattern
693
  // matcher sees an open after a check and cannot tell.
694
  if (!file.open(fd, QIODevice::ReadOnly, // codeql[cpp/toctou-race-condition]
626 ✔
695
                 QFileDevice::AutoCloseHandle)) {
UNCOV
696
    ::close(fd);
×
UNCOV
697
    return false;
×
698
  }
699
  return true;
700
#endif
701
}
702

703
auto Util::readRegularFile(const QString &path, qint64 maxBytes)
209 ✔
704
    -> std::optional<QByteArray> {
705
  QFile file;
209 ✔
706
  if (!openRegularFile(path, file)) {
209 ✔
707
    return std::nullopt;
11 ✔
708
  }
709
  // A regular file's size is reliable, also when sparse: refuse a large one
710
  // before reading anything. One byte past the limit still guards a file
711
  // that grows meanwhile.
712
  if (file.size() > maxBytes) {
198 ✔
713
    return std::nullopt;
3 ✔
714
  }
715
  QByteArray bytes = file.read(maxBytes + 1);
195 ✔
716
  if (bytes.size() > maxBytes || file.error() != QFileDevice::NoError) {
195 ✔
UNCOV
717
    return std::nullopt;
×
718
  }
719
  return bytes;
720
}
209 ✔
721

722
auto Util::syncToDisk(QFileDevice &file) -> bool {
209 ✔
723
  if (!file.flush()) {
209 ✔
724
    return false;
725
  }
726
#ifdef Q_OS_WIN
727
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
728
  return handle != INVALID_HANDLE_VALUE && FlushFileBuffers(handle) != 0;
729
#else
730
  return ::fsync(file.handle()) == 0;
209 ✔
731
#endif
732
}
733

734
namespace {
735

736
/**
737
 * @brief What tells one file object from another on its filesystem: device
738
 * and inode on POSIX, volume serial and file index on Windows.
739
 */
740
struct FileIdentity {
741
  quint64 volume = 0;
742
  quint64 index = 0;
743
  bool known = false;
744
};
745

746
auto operator==(const FileIdentity &a, const FileIdentity &b) -> bool {
198 ✔
747
  return a.known && b.known && a.volume == b.volume && a.index == b.index;
198 ✔
748
}
749

750
/// The identity of the object @p file is open on, or an unknown one.
751
auto identityOf(const QFileDevice &file) -> FileIdentity {
407 ✔
752
#ifdef Q_OS_WIN
753
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
754
  BY_HANDLE_FILE_INFORMATION info;
755
  if (handle == INVALID_HANDLE_VALUE ||
756
      GetFileInformationByHandle(handle, &info) == 0) {
757
    return {};
758
  }
759
  // On FAT/exFAT the file ID is the directory entry's offset, which a rename
760
  // can move. They have no hard links either (the swap this catches needs
761
  // one), so the identity is left unknown there.
762
  DWORD flags = 0;
763
  if (GetVolumeInformationByHandleW(handle, nullptr, 0, nullptr, nullptr,
764
                                    &flags, nullptr, 0) == 0 ||
765
      (flags & FILE_SUPPORTS_HARD_LINKS) == 0) {
766
    return {};
767
  }
768
  return {info.dwVolumeSerialNumber,
769
          (static_cast<quint64>(info.nFileIndexHigh) << 32) |
770
              info.nFileIndexLow,
771
          true};
772
#else
773
  struct stat st{};
407 ✔
774
  if (::fstat(file.handle(), &st) != 0) {
407 ✔
UNCOV
775
    return {};
×
776
  }
777
  return {static_cast<quint64>(st.st_dev), static_cast<quint64>(st.st_ino),
407 ✔
778
          true};
407 ✔
779
#endif
780
}
781

782
/// Put @p text in @p error when there is one; false, for `return fail(...)`.
783
auto fail(QString *error, const QString &text) -> bool {
784
  if (error != nullptr) {
21 ✔
785
    *error = text;
21 ✔
786
  }
787
  return false;
788
}
789

790
/// A filled, synced temporary next to its destination.
791
struct Staged {
635 ✔
792
  QString path;
793
  FileIdentity identity;
794
};
795

796
/**
797
 * @brief Create the temporary next to @p path (opaque name, exclusive,
798
 * owner-only), fill it through its handle and sync it. The QTemporaryFile
799
 * goes out of scope before the caller renames: it keeps its handle open for
800
 * as long as it lives, and Windows does not rename an open file.
801
 */
802
auto stageNextTo(const QString &path, const Util::Filler &fill, QString *why)
217 ✔
803
    -> std::optional<Staged> {
804
  Staged staged;
217 ✔
805
  {
806
    QTemporaryFile file(QFileInfo(path).path() +
434 ✔
807
                        QStringLiteral("/.qtpass-XXXXXX.tmp"));
434 ✔
808
    file.setAutoRemove(false);
217 ✔
809
    if (!file.open()) {
217 ✔
810
      *why = QCoreApplication::translate(
8 ✔
811
                 "Util", "Cannot create a temporary file next to %1: %2")
812
                 .arg(path, file.errorString());
16 ✔
813
      return std::nullopt;
814
    }
815
    staged.path = file.fileName();
209 ✔
816
    // Owner-only: a .gpg-id names the keys a store is encrypted to, an
817
    // entry is an entry. QTemporaryFile creates 0600 already; say so for
818
    // platforms where it may not.
819
    file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
209 ✔
820
    *why = fill(file);
418 ✔
821
    if (why->isEmpty() && !Util::syncToDisk(file)) {
209 ✔
UNCOV
822
      *why = QCoreApplication::translate("Util", "Cannot write %1: %2")
×
UNCOV
823
                 .arg(path, file.errorString());
×
824
    }
825
    staged.identity = identityOf(file);
209 ✔
826
  }
217 ✔
827
  if (!why->isEmpty()) {
209 ✔
UNCOV
828
    QFile::remove(staged.path);
×
UNCOV
829
    return std::nullopt;
×
830
  }
831
  return staged;
832
}
833

834
/// Why replaceFile() could not put the temporary under @p path.
835
auto placementError(const QString &path, bool replace) -> QString {
9 ✔
836
  if (replace) {
9 ✔
837
    return QCoreApplication::translate("Util", "Failed to replace %1.")
4 ✔
838
        .arg(path);
2 ✔
839
  }
840
  const QFileInfo taken(path);
7 ✔
841
  return taken.exists() || taken.isSymLink()
8 ✔
842
             ? QCoreApplication::translate("Util", "%1 already exists.")
7 ✔
843
                   .arg(path)
844
             : QCoreApplication::translate("Util", "Failed to write %1.")
1 ✔
845
                   .arg(path);
8 ✔
846
}
7 ✔
847

848
/**
849
 * @brief Whether what is under @p path is the file that was filled: not a
850
 * link, and not another file (a hard link to something of the user's, say)
851
 * swapped in under the temporary's name. Opened without following, compared
852
 * by identity; only two known identities can prove a swap, otherwise every
853
 * write on such a store would be reported as tampering.
854
 */
855
auto isTheFileWritten(const QString &path, const FileIdentity &written)
200 ✔
856
    -> bool {
857
  QFile placed;
200 ✔
858
  if (!Util::openRegularFile(path, placed)) {
200 ✔
859
    return false;
860
  }
861
  const FileIdentity there = identityOf(placed);
198 ✔
862
  if (!written.known || !there.known) {
198 ✔
UNCOV
863
    qCWarning(lcQtPass) << "Cannot tell whether" << path
×
UNCOV
864
                        << "is the file that was written here";
×
UNCOV
865
    return true;
×
866
  }
867
  return there == written;
198 ✔
868
}
200 ✔
869

870
} // namespace
871

872
auto Util::stageFileReplacing(const QString &path, bool replace,
217 ✔
873
                              const Filler &fill, QString *error) -> bool {
874
  QString why;
217 ✔
875
  const std::optional<Staged> staged = stageNextTo(path, fill, &why);
217 ✔
876
  if (!staged) {
217 ✔
877
    return fail(error, why);
878
  }
879
  if (!replaceFile(staged->path, path, replace)) {
209 ✔
880
    QFile::remove(staged->path);
9 ✔
881
    return fail(error, placementError(path, replace));
18 ✔
882
  }
883
  // A mismatch is reported and left: removing by name could take another
884
  // writer's file.
885
  if (!isTheFileWritten(path, staged->identity)) {
200 ✔
886
    return fail(
887
        error,
888
        QCoreApplication::translate(
4 ✔
889
            "Util", "%1 was swapped for another file while it was written.")
890
            .arg(path));
8 ✔
891
  }
892
  return true;
893
}
894

895
auto Util::writeFileReplacing(const QString &path, const QByteArray &bytes,
101 ✔
896
                              bool replace, QString *error) -> bool {
897
  return stageFileReplacing(
101 ✔
898
      path, replace,
899
      [&path, &bytes](QFileDevice &staged) -> QString {
99 ✔
900
        if (staged.write(bytes) != bytes.size()) {
99 ✔
UNCOV
901
          return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
UNCOV
902
              .arg(path, staged.errorString());
×
903
        }
904
        return {};
905
      },
906
      error);
101 ✔
907
}
908

909
auto Util::copyFileReplacing(const QString &src, const QString &dst,
115 ✔
910
                             bool replace, QString *error) -> bool {
911
  QFile in;
115 ✔
912
  if (!openRegularFile(src, in)) {
115 ✔
913
    if (error)
4 ✔
914
      *error = QCoreApplication::translate("Util", "Cannot read %1.").arg(src);
8 ✔
915
    return false;
4 ✔
916
  }
917
  return stageFileReplacing(
111 ✔
918
      dst, replace,
919
      [&src, &dst, &in](QFileDevice &staged) -> QString {
327 ✔
920
        char buf[64 * 1024];
921
        for (;;) {
922
          const qint64 n = in.read(buf, sizeof buf);
222 ✔
923
          if (n < 0) {
222 ✔
UNCOV
924
            return QCoreApplication::translate("Util", "Cannot read %1: %2")
×
UNCOV
925
                .arg(src, in.errorString());
×
926
          }
927
          if (n == 0) {
222 ✔
928
            return {};
929
          }
930
          if (staged.write(buf, n) != n) {
117 ✔
UNCOV
931
            return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
UNCOV
932
                .arg(dst, staged.errorString());
×
933
          }
934
        }
935
      },
936
      error);
937
}
115 ✔
938

939
namespace {
940

941
/// Remove one directory entry that is not a real directory: a link goes as
942
/// the entry itself, never its target (a junction or a directory symlink on
943
/// Windows is a directory entry and goes with rmdir); a read-only file gets
944
/// write access and one more try, as QDir::removeRecursively() does.
945
auto removeEntry(const QFileInfo &entry) -> bool {
11 ✔
946
  const QString path = entry.filePath();
11 ✔
947
  if (isLink(entry)) {
11 ✔
948
    if (QFile::remove(path) || QDir().rmdir(path)) {
6 ✔
949
      return true;
950
    }
951
    qCWarning(lcQtPass) << "Could not remove link" << path;
2 ✔
952
    return false;
1 ✔
953
  }
954
  if (QFile::remove(path)) {
6 ✔
955
    return true;
956
  }
957
  const QFile::Permissions perms = QFile::permissions(path);
1 ✔
958
  if (!perms.testFlag(QFile::WriteUser) &&
1 ✔
959
      QFile::setPermissions(path, perms | QFile::WriteUser) &&
2 ✔
960
      QFile::remove(path)) {
1 ✔
961
    return true;
962
  }
963
  qCWarning(lcQtPass) << "Could not remove" << path;
2 ✔
964
  return false;
1 ✔
965
}
966

967
} // namespace
968

969
auto Util::removeTree(const QString &dir) -> bool {
21 ✔
970
  // A trailing separator makes lstat follow a link ("link/" is the target
971
  // directory); the link itself is what this is about.
972
  const QString path = QDir::cleanPath(dir);
21 ✔
973
  const QFileInfo top(path);
21 ✔
974
  if (isLink(top)) {
21 ✔
975
    // rm -rf on a link removes the link.
976
    return QFile::remove(path) || QDir().rmdir(path);
16 ✔
977
  }
978
  if (!top.isDir()) {
7 ✔
979
    return false;
980
  }
981
  bool ok = true;
982
  const QFileInfoList entries =
983
      QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
12 ✔
984
                                   QDir::System | QDir::NoDotAndDotDot,
985
                               QDir::Name);
6 ✔
986
  for (const QFileInfo &entry : entries) {
18 ✔
987
    const bool removed = entry.isDir() && !isLink(entry)
17 ✔
988
                             ? removeTree(entry.filePath())
13 ✔
989
                             : removeEntry(entry);
11 ✔
990
    ok = removed && ok;
12 ✔
991
  }
992
  return ok && QDir().rmdir(path);
11 ✔
993
}
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