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

IJHack / QtPass / 36004058790

24 Sep 2026 01:12PM UTC coverage: 92.935% (+0.006%) from 92.929%
36004058790

Pull #1922

github

web-flow
Merge b1d73ab39 into bd7f054b4
Pull Request #1922: No function at cyclomatic complexity 15 or above

241 of 252 new or added lines in 7 files covered. (95.63%)

2 existing lines in 2 files now uncovered.

8827 of 9498 relevant lines covered (92.94%)

154.86 hits per line

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

94.41
/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 <QRegularExpressionMatchIterator>
15
#include <QStandardPaths>
16
#include <QTemporaryFile>
17
#include <QUrl>
18

19
#include <optional>
20

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

34
#include "qtpasslogging.h"
35

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

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

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

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

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

100
auto Util::findBinaryInPath(const QString &binary) -> QString {
356✔
101
  if (binary.isEmpty()) {
356✔
102
    return {};
103
  }
104

105
  initialiseEnvironment();
355✔
106

107
  const QStringList dirs =
108
      _env.value(QStringLiteral("PATH"))
710✔
109
          .split(QDir::listSeparator(), Qt::SkipEmptyParts);
355✔
110
  QString ret;
355✔
111
  if (QDir::fromNativeSeparators(binary).contains(u'/')) {
355✔
112
    // An explicit path is not a PATH search: an absolute path is checked
113
    // as-is, a relative one is resolved against the PATH directories. The
114
    // directory-list overload refuses such names, so handle them here.
115
    if (QDir::isAbsolutePath(binary)) {
3✔
116
      ret = QStandardPaths::findExecutable(binary);
4✔
117
    } else if (!dirs.isEmpty()) {
1✔
118
      ret = QStandardPaths::findExecutable(binary, dirs);
2✔
119
    }
120
  } else {
121
    ret = findBinaryInPath(binary, dirs);
704✔
122
  }
123
#ifdef Q_OS_WIN
124
  if (ret.isEmpty()) {
125
    // Cache per-binary WSL lookup result — the wsl --version probe is a
126
    // blocking subprocess that can run several times per session for
127
    // missing binaries; once decided, the answer doesn't change at runtime.
128
    static QHash<QString, QString> wslBinaryCache;
129
    const bool hasWhitespace =
130
        std::any_of(binary.cbegin(), binary.cend(),
131
                    [](const QChar ch) { return ch.isSpace(); });
132
    if (!hasWhitespace) {
133
      auto cached = wslBinaryCache.constFind(binary);
134
      if (cached != wslBinaryCache.constEnd()) {
135
        ret = cached.value();
136
      } else {
137
        QString wslCommand = QStringLiteral("wsl ") + binary;
138
        qCDebug(lcQtPass)
139
            << "Util::findBinaryInPath(): falling back to WSL for binary"
140
            << binary;
141
        QString out, err;
142
        QString cachedResult;
143
        if (Executor::executeBlocking(wslCommand, {"--version"}, &out, &err) ==
144
                0 &&
145
            !out.isEmpty() && err.isEmpty()) {
146
          qCDebug(lcQtPass)
147
              << "Util::findBinaryInPath(): using WSL binary" << wslCommand;
148
          cachedResult = wslCommand;
149
        }
150
        wslBinaryCache.insert(binary, cachedResult);
151
        ret = cachedResult;
152
      }
153
    }
154
  }
155
#endif
156

157
  return ret;
158
}
159

160
// Bare names only: QStandardPaths::findExecutable() returns an absolute binary
161
// without consulting searchPaths, and a relative one with ".." could escape
162
// them.
163
auto Util::findBinaryInPath(const QString &binary,
366✔
164
                            const QStringList &searchPaths) -> QString {
165
  if (binary.isEmpty() || QDir::fromNativeSeparators(binary).contains(u'/')) {
731✔
166
    return {};
167
  }
168
  QStringList dirs;
362✔
169
  dirs.reserve(searchPaths.size());
362✔
170
  for (const QString &dir : searchPaths) {
5,264✔
171
    if (!dir.isEmpty()) {
4,902✔
172
      dirs.append(dir);
173
    }
174
  }
175
  if (dirs.isEmpty()) {
362✔
176
    // QStandardPaths::findExecutable() treats an empty list as "use PATH".
177
    return {};
178
  }
179
  return QStandardPaths::findExecutable(binary, dirs);
358✔
180
}
181

182
auto Util::configIsValid(const AppSettings &s) -> bool {
182✔
183
  const QString configFilePath = QDir(s.passStore).filePath(".gpg-id");
364✔
184
  if (!QFile(configFilePath).exists()) {
182✔
185
    return false;
186
  }
187

188
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
116✔
189

190
  if (const auto wsl = Executor::parseWslCommand(executable)) {
116✔
191
    // Probe WSL once per session — availability doesn't change at runtime
192
    // and the executeBlocking call is a blocking subprocess.
193
    static const bool wslAvailable = [&wsl]() {
1✔
194
      QString out;
1✔
195
      QString err;
1✔
196
      return Executor::executeBlocking(wsl->launcher,
3✔
197
                                       {QStringLiteral("--version")}, &out,
1✔
198
                                       &err) == 0 &&
199
             !out.isEmpty() && err.isEmpty();
2✔
200
    }();
1✔
201
    if (wslAvailable) {
1✔
202
      return true;
203
    }
204
  }
205
  return QFile(executable).exists();
116✔
206
}
207

208
auto Util::endsWithGpg() -> const QRegularExpression & {
6,166✔
209
  static const QRegularExpression expr{R"(\.gpg$)"};
6,166✔
210
  return expr;
6,166✔
211
}
212

213
// Stops at whitespace, quotes and brackets so a URL on its own line keeps no
214
// line break. file:/// is excluded on purpose (local, not network); the test
215
// relies on that.
216
auto Util::protocolRegex() -> const QRegularExpression & {
139✔
217
  static const QRegularExpression regex{
218
      R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
139✔
219
  return regex;
139✔
220
}
221

222
auto Util::isLaunchableWebUrl(const QString &value) -> bool {
81✔
223
  const QString trimmed = value.trimmed();
224
  if (trimmed.isEmpty()) {
81✔
225
    return false;
226
  }
227
  // Reject control characters first, before QUrl normalisation can hide a
228
  // CR/LF/NUL injection into the OS URL handler.
229
  for (const QChar &c : trimmed) {
1,945✔
230
    if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
231
        c == QChar(QChar::Null)) {
232
      return false;
233
    }
234
  }
235
  const QUrl url(trimmed, QUrl::StrictMode);
77✔
236
  if (!url.isValid()) {
77✔
237
    return false;
238
  }
239
  const QString scheme = url.scheme().toLower();
134✔
240
  if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
129✔
241
    return false;
22✔
242
  }
243
  if (url.host().isEmpty()) {
90✔
244
    return false;
245
  }
246
  // Embedded userinfo (user:pass@host) would leak into browser history.
247
  if (!url.userName().isEmpty() || !url.password().isEmpty()) {
83✔
248
    return false;
249
  }
250
  return true;
251
}
77✔
252

253
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
135✔
254
  if (linked != nullptr) {
135✔
255
    *linked = false;
88✔
256
  }
257
  QString html;
135✔
258
  html.reserve(text.size());
135✔
259
  qsizetype lastIndex = 0;
260
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
135✔
261
  while (it.hasNext()) {
170✔
262
    const QRegularExpressionMatch match = it.next();
35✔
263
    const QString url = match.captured(0);
35✔
264
    if (!isLaunchableWebUrl(url)) {
35✔
265
      // Not a web URL (or it carries credentials): leave it in the escaped
266
      // plain-text run instead of making it clickable.
267
      continue;
268
    }
269
    const qsizetype start = match.capturedStart(0);
22✔
270
    html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
22✔
271
    const QString escapedUrl = url.toHtmlEscaped();
22✔
272
    html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
44✔
273
    lastIndex = match.capturedEnd(0);
22✔
274
    if (linked != nullptr) {
22✔
275
      *linked = true;
16✔
276
    }
277
  }
35✔
278
  html += text.mid(lastIndex).toHtmlEscaped();
135✔
279
  return html;
135✔
280
}
135✔
281

282
auto Util::newLinesRegex() -> const QRegularExpression & {
219✔
283
  static const QRegularExpression regex{"[\r\n]"};
219✔
284
  return regex;
219✔
285
}
286

287
// No content heuristics: a line rejected here is erased the next time .gpg-id
288
// is rewritten. A leading `-` is refused because the recipients also go
289
// positionally to `gpg --list-keys`, where it would parse as an option.
290
auto Util::isValidKeyId(const QString &keyId) -> bool {
170✔
291
  return !keyId.isEmpty() && !keyId.startsWith('-');
170✔
292
}
293

294
namespace {
295
/**
296
 * @brief Walk @p dir's real, visible directories in sorted pre-order and hand
297
 * every entry to @p visit before any recursion; a directory is entered only
298
 * when @p visit returns true for it.
299
 */
300
template <typename Visit> void walkStore(const QString &dir, Visit visit) {
159✔
301
  // Absolute, so the results are whatever the caller's cwd; not canonical,
302
  // so a linked root keeps the name it was configured under.
303
  QStringList pending{QDir(QDir::cleanPath(dir)).absolutePath()};
477✔
304
  while (!pending.isEmpty()) {
583✔
305
    const QDir current(pending.takeLast());
424✔
306
    // Every entry once, links and hidden entries included, so the decision
307
    // what to do with each is taken here, before any recursion. QDir::System
308
    // keeps dangling links in the listing.
309
    const QFileInfoList entries =
212✔
310
        current.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
311
                                  QDir::System | QDir::NoDotAndDotDot,
312
                              QDir::Name);
313
    QStringList subdirs;
212✔
314
    for (const QFileInfo &entry : entries) {
931✔
315
      if (visit(entry)) {
719✔
316
        subdirs << entry.filePath();
106✔
317
      }
318
    }
319
    // Pushed last-to-first so the next one taken is the first by name.
320
    for (auto it = subdirs.crbegin(); it != subdirs.crend(); ++it) {
265✔
321
      pending << *it;
322
    }
323
  }
324
}
318✔
325

326
/// A symlink or an NTFS junction: never entered, never listed.
327
auto isLink(const QFileInfo &entry) -> bool {
2,038✔
328
  return entry.isSymLink() || entry.isJunction();
2,038✔
329
}
330

331
/// Hidden by attribute, or by the dot convention on every platform: Qt 6.11
332
/// on macOS answers isHidden() from the UF_HIDDEN flag alone once an entry
333
/// was lstat()ed (qfilesystemengine_unix.cpp marks the attribute known there
334
/// without the dot check), and Windows does not consider .stversions hidden
335
/// at all.
336
auto isHiddenEntry(const QFileInfo &entry) -> bool {
223✔
337
  return entry.isHidden() || entry.fileName().startsWith(QLatin1Char('.'));
437✔
338
}
339

340
/// A real directory that is part of the store: .git, .stversions,
341
/// .Trash-1000 are not, as with QDirIterator without QDir::Hidden.
342
auto isStoreDirectory(const QFileInfo &entry) -> bool {
719✔
343
  return !isLink(entry) && entry.isDir() && !isHiddenEntry(entry);
719✔
344
}
345
} // namespace
346

347
auto Util::regularFilesUnder(const QString &dir, const QStringList &nameFilters,
156✔
348
                             QStringList *skipped, bool hiddenFiles)
349
    -> QStringList {
350
  QStringList files;
156✔
351
  walkStore(dir, [&](const QFileInfo &entry) {
156✔
352
    if (isStoreDirectory(entry)) {
665✔
353
      return true;
354
    }
355
    const bool named = QDir::match(nameFilters, entry.fileName());
630✔
356
    if (isLink(entry) || (!entry.isDir() && !entry.isFile())) {
630✔
357
      // A linked directory hides everything behind it; a linked file, or a
358
      // FIFO, socket or device, is only of interest under a name the caller
359
      // asked for.
360
      if (skipped != nullptr && (entry.isDir() || named)) {
17✔
361
        qCWarning(lcQtPass) << "Skipping" << entry.filePath()
24✔
362
                            << ": not a regular file or directory";
12✔
363
        *skipped << entry.filePath();
24✔
364
      }
365
      return false;
17✔
366
    }
367
    if (entry.isFile() && named && (hiddenFiles || !isHiddenEntry(entry))) {
613✔
368
      files << entry.filePath();
362✔
369
    }
370
    return false;
371
  });
372
  return files;
156✔
373
}
374

375
auto Util::directoriesUnder(const QString &dir) -> QStringList {
3✔
376
  QStringList dirs;
3✔
377
  walkStore(dir, [&](const QFileInfo &entry) {
3✔
378
    if (!isStoreDirectory(entry)) {
54✔
379
      return false;
380
    }
381
    dirs << entry.filePath();
18✔
382
    return true;
18✔
383
  });
384
  return dirs;
3✔
385
}
386

387
auto Util::isLinkedFolder(const QString &path) -> bool {
654✔
388
  return isLink(QFileInfo(QDir::cleanPath(path)));
1,308✔
389
}
390

391
auto Util::isUnderLink(const QString &path, const QString &storeRoot,
407✔
392
                       bool includeSelf) -> bool {
393
  // "C:/Store" and "c:/store" are one directory on Windows; a path spelled
394
  // the other way must not skip the walk (as in Pass::getGpgIdPath).
395
#ifdef Q_OS_WIN
396
  constexpr auto cs = Qt::CaseInsensitive;
397
#else
398
  constexpr auto cs = Qt::CaseSensitive;
399
#endif
400
  const QString root = QDir::cleanPath(storeRoot);
407✔
401
  // A root of "/" or "C:/" already ends in the separator.
402
  const QString prefix =
403
      root.endsWith(QLatin1Char('/')) ? root : root + QLatin1Char('/');
407✔
404
  const auto isRoot = [&](const QString &p) {
405
    return p.compare(root, cs) == 0;
717✔
406
  };
407
  QString current = QDir::cleanPath(path);
407✔
408
  if (!current.startsWith(prefix, cs)) {
407✔
409
    // Not under the store as named: only the entry itself can be judged.
410
    return includeSelf && !isRoot(current) && isLinkedFolder(current);
196✔
411
  }
412
  if (!includeSelf) {
309✔
413
    current = QFileInfo(current).path();
50✔
414
  }
415
  for (; !isRoot(current) && current.startsWith(prefix, cs);
619✔
416
       current = QFileInfo(current).path()) {
620✔
417
    if (isLinkedFolder(current)) {
347✔
418
      return true;
419
    }
420
  }
421
  return false;
422
}
423

424
auto Util::replaceFile(const QString &from, const QString &to, bool replace)
179✔
425
    -> bool {
426
#ifdef Q_OS_WIN
427
  const std::wstring source =
428
      QDir::toNativeSeparators(QFileInfo(from).absoluteFilePath())
429
          .toStdWString();
430
  const std::wstring target =
431
      QDir::toNativeSeparators(QFileInfo(to).absoluteFilePath()).toStdWString();
432
  // WRITE_THROUGH: the new entry is on the device when this returns.
433
  return MoveFileExW(source.c_str(), target.c_str(),
434
                     (replace ? MOVEFILE_REPLACE_EXISTING : 0) |
435
                         MOVEFILE_WRITE_THROUGH) != 0;
436
#else
437
  const QByteArray source = QFile::encodeName(from);
438
  const QByteArray target = QFile::encodeName(to);
439
  if (replace) {
179✔
440
    if (::rename(source.constData(), target.constData()) != 0) {
105✔
441
      return false;
442
    }
443
  } else {
444
    // linkat() without AT_SYMLINK_FOLLOW follows nothing; link() would, on
445
    // macOS and the BSDs, hard-link whatever a symlink planted under the
446
    // source's name points at.
447
    if (::linkat(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(),
74✔
448
                 0) != 0) {
449
      return false;
450
    }
451
    ::unlink(source.constData());
67✔
452
  }
453
  // Sync the directory entry so a crash does not lose the new name. Best
454
  // effort: the bytes are already synced, and some network filesystems
455
  // cannot sync a directory.
456
  const int dir = ::open(QFile::encodeName(QFileInfo(to).path()).constData(),
340✔
457
                         O_RDONLY | O_DIRECTORY | O_CLOEXEC);
458
  if (dir >= 0) {
170✔
459
    int rc;
460
    do {
461
      rc = ::fsync(dir);
170✔
462
    } while (rc != 0 && errno == EINTR);
170✔
463
    ::close(dir);
170✔
464
  }
465
  return true;
466
#endif
467
}
468

469
auto Util::openRegularFile(const QString &path, QFile &file) -> bool {
274✔
470
#ifdef Q_OS_WIN
471
  // FILE_FLAG_OPEN_REPARSE_POINT opens a symbolic link or junction itself
472
  // rather than its target, so the handle's attributes say what the name
473
  // was at the moment of the open.
474
  const std::wstring native =
475
      QDir::toNativeSeparators(QFileInfo(path).absoluteFilePath())
476
          .toStdWString();
477
  HANDLE handle = CreateFileW(
478
      native.c_str(), GENERIC_READ,
479
      FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
480
      OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
481
  if (handle == INVALID_HANDLE_VALUE) {
482
    return false;
483
  }
484
  BY_HANDLE_FILE_INFORMATION info{};
485
  if (!GetFileInformationByHandle(handle, &info) ||
486
      (info.dwFileAttributes &
487
       (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY |
488
        FILE_ATTRIBUTE_DEVICE)) != 0 ||
489
      GetFileType(handle) != FILE_TYPE_DISK) {
490
    CloseHandle(handle);
491
    return false;
492
  }
493
  const int fd = _open_osfhandle(reinterpret_cast<intptr_t>(handle),
494
                                 _O_RDONLY | _O_BINARY);
495
  if (fd < 0) {
496
    CloseHandle(handle);
497
    return false;
498
  }
499
  if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
500
    _close(fd);
501
    return false;
502
  }
503
  return true;
504
#else
505
  // O_NOFOLLOW fails with ELOOP on a symbolic link; O_NONBLOCK keeps a FIFO
506
  // from blocking the open until fstat() can refuse it.
507
  const int fd = ::open(QFile::encodeName(path).constData(),
274✔
508
                        O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
509
  if (fd < 0) {
274✔
510
    return false;
511
  }
512
  struct stat st{};
267✔
513
  if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
267✔
514
    ::close(fd);
3✔
515
    return false;
3✔
516
  }
517
  // The descriptor, not the name, is what QFile opens here: the object
518
  // fstat() judged is the object read. CodeQL's check-then-use pattern
519
  // matcher sees an open after a check and cannot tell.
520
  if (!file.open(fd, QIODevice::ReadOnly, // codeql[cpp/toctou-race-condition]
264✔
521
                 QFileDevice::AutoCloseHandle)) {
522
    ::close(fd);
×
523
    return false;
×
524
  }
525
  return true;
526
#endif
527
}
528

529
auto Util::syncToDisk(QFileDevice &file) -> bool {
172✔
530
  if (!file.flush()) {
172✔
531
    return false;
532
  }
533
#ifdef Q_OS_WIN
534
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
535
  return handle != INVALID_HANDLE_VALUE && FlushFileBuffers(handle) != 0;
536
#else
537
  return ::fsync(file.handle()) == 0;
172✔
538
#endif
539
}
540

541
namespace {
542

543
/**
544
 * @brief What tells one file object from another on its filesystem: device
545
 * and inode on POSIX, volume serial and file index on Windows.
546
 */
547
struct FileIdentity {
548
  quint64 volume = 0;
549
  quint64 index = 0;
550
  bool known = false;
551
};
552

553
auto operator==(const FileIdentity &a, const FileIdentity &b) -> bool {
163✔
554
  return a.known && b.known && a.volume == b.volume && a.index == b.index;
163✔
555
}
556

557
/// The identity of the object @p file is open on, or an unknown one.
558
auto identityOf(const QFileDevice &file) -> FileIdentity {
335✔
559
#ifdef Q_OS_WIN
560
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
561
  BY_HANDLE_FILE_INFORMATION info;
562
  if (handle == INVALID_HANDLE_VALUE ||
563
      GetFileInformationByHandle(handle, &info) == 0) {
564
    return {};
565
  }
566
  // On FAT/exFAT the file ID is the directory entry's offset, which a rename
567
  // can move. They have no hard links either (the swap this catches needs
568
  // one), so the identity is left unknown there.
569
  DWORD flags = 0;
570
  if (GetVolumeInformationByHandleW(handle, nullptr, 0, nullptr, nullptr,
571
                                    &flags, nullptr, 0) == 0 ||
572
      (flags & FILE_SUPPORTS_HARD_LINKS) == 0) {
573
    return {};
574
  }
575
  return {info.dwVolumeSerialNumber,
576
          (static_cast<quint64>(info.nFileIndexHigh) << 32) |
577
              info.nFileIndexLow,
578
          true};
579
#else
580
  struct stat st{};
335✔
581
  if (::fstat(file.handle(), &st) != 0) {
335✔
582
    return {};
×
583
  }
584
  return {static_cast<quint64>(st.st_dev), static_cast<quint64>(st.st_ino),
335✔
585
          true};
335✔
586
#endif
587
}
588

589
/// Put @p text in @p error when there is one; false, for `return fail(...)`.
590
auto fail(QString *error, const QString &text) -> bool {
591
  if (error != nullptr) {
19✔
592
    *error = text;
19✔
593
  }
594
  return false;
595
}
596

597
/// A filled, synced temporary next to its destination.
598
struct Staged {
524✔
599
  QString path;
600
  FileIdentity identity;
601
};
602

603
/**
604
 * @brief Create the temporary next to @p path (opaque name, exclusive,
605
 * owner-only), fill it through its handle and sync it. The QTemporaryFile
606
 * goes out of scope before the caller renames: it keeps its handle open for
607
 * as long as it lives, and Windows does not rename an open file.
608
 */
609
auto stageNextTo(const QString &path, const Util::Filler &fill, QString *why)
180✔
610
    -> std::optional<Staged> {
611
  Staged staged;
180✔
612
  {
613
    QTemporaryFile file(QFileInfo(path).path() +
360✔
614
                        QStringLiteral("/.qtpass-XXXXXX.tmp"));
360✔
615
    file.setAutoRemove(false);
180✔
616
    if (!file.open()) {
180✔
617
      *why = QCoreApplication::translate(
8✔
618
                 "Util", "Cannot create a temporary file next to %1: %2")
619
                 .arg(path, file.errorString());
16✔
620
      return std::nullopt;
621
    }
622
    staged.path = file.fileName();
172✔
623
    // Owner-only: a .gpg-id names the keys a store is encrypted to, an
624
    // entry is an entry. QTemporaryFile creates 0600 already; say so for
625
    // platforms where it may not.
626
    file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
172✔
627
    *why = fill(file);
344✔
628
    if (why->isEmpty() && !Util::syncToDisk(file)) {
172✔
NEW
629
      *why = QCoreApplication::translate("Util", "Cannot write %1: %2")
×
NEW
630
                 .arg(path, file.errorString());
×
631
    }
632
    staged.identity = identityOf(file);
172✔
633
  }
180✔
634
  if (!why->isEmpty()) {
172✔
NEW
635
    QFile::remove(staged.path);
×
NEW
636
    return std::nullopt;
×
637
  }
638
  return staged;
639
}
640

641
/// Why replaceFile() could not put the temporary under @p path.
642
auto placementError(const QString &path, bool replace) -> QString {
7✔
643
  if (replace) {
7✔
644
    return QCoreApplication::translate("Util", "Failed to replace %1.")
4✔
645
        .arg(path);
2✔
646
  }
647
  const QFileInfo taken(path);
5✔
648
  return taken.exists() || taken.isSymLink()
6✔
649
             ? QCoreApplication::translate("Util", "%1 already exists.")
5✔
650
                   .arg(path)
651
             : QCoreApplication::translate("Util", "Failed to write %1.")
1✔
652
                   .arg(path);
6✔
653
}
5✔
654

655
/**
656
 * @brief Whether what is under @p path is the file that was filled: not a
657
 * link, and not another file (a hard link to something of the user's, say)
658
 * swapped in under the temporary's name. Opened without following, compared
659
 * by identity; only two known identities can prove a swap, otherwise every
660
 * write on such a store would be reported as tampering.
661
 */
662
auto isTheFileWritten(const QString &path, const FileIdentity &written)
165✔
663
    -> bool {
664
  QFile placed;
165✔
665
  if (!Util::openRegularFile(path, placed)) {
165✔
666
    return false;
667
  }
668
  const FileIdentity there = identityOf(placed);
163✔
669
  if (!written.known || !there.known) {
163✔
UNCOV
670
    qCWarning(lcQtPass) << "Cannot tell whether" << path
×
671
                        << "is the file that was written here";
×
NEW
672
    return true;
×
673
  }
674
  return there == written;
163✔
675
}
165✔
676

677
} // namespace
678

679
auto Util::stageFileReplacing(const QString &path, bool replace,
180✔
680
                              const Filler &fill, QString *error) -> bool {
681
  QString why;
180✔
682
  const std::optional<Staged> staged = stageNextTo(path, fill, &why);
180✔
683
  if (!staged) {
180✔
684
    return fail(error, why);
685
  }
686
  if (!replaceFile(staged->path, path, replace)) {
172✔
687
    QFile::remove(staged->path);
7✔
688
    return fail(error, placementError(path, replace));
14✔
689
  }
690
  // A mismatch is reported and left: removing by name could take another
691
  // writer's file.
692
  if (!isTheFileWritten(path, staged->identity)) {
165✔
693
    return fail(
694
        error,
695
        QCoreApplication::translate(
4✔
696
            "Util", "%1 was swapped for another file while it was written.")
697
            .arg(path));
8✔
698
  }
699
  return true;
700
}
701

702
auto Util::writeFileReplacing(const QString &path, const QByteArray &bytes,
75✔
703
                              bool replace, QString *error) -> bool {
704
  return stageFileReplacing(
75✔
705
      path, replace,
706
      [&path, &bytes](QFileDevice &staged) -> QString {
73✔
707
        if (staged.write(bytes) != bytes.size()) {
73✔
708
          return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
709
              .arg(path, staged.errorString());
×
710
        }
711
        return {};
712
      },
713
      error);
75✔
714
}
715

716
auto Util::copyFileReplacing(const QString &src, const QString &dst,
104✔
717
                             bool replace, QString *error) -> bool {
718
  QFile in;
104✔
719
  if (!openRegularFile(src, in)) {
104✔
720
    if (error)
4✔
721
      *error = QCoreApplication::translate("Util", "Cannot read %1.").arg(src);
8✔
722
    return false;
4✔
723
  }
724
  return stageFileReplacing(
100✔
725
      dst, replace,
726
      [&src, &dst, &in](QFileDevice &staged) -> QString {
294✔
727
        char buf[64 * 1024];
728
        for (;;) {
729
          const qint64 n = in.read(buf, sizeof buf);
200✔
730
          if (n < 0) {
200✔
731
            return QCoreApplication::translate("Util", "Cannot read %1: %2")
×
732
                .arg(src, in.errorString());
×
733
          }
734
          if (n == 0) {
200✔
735
            return {};
736
          }
737
          if (staged.write(buf, n) != n) {
106✔
738
            return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
739
                .arg(dst, staged.errorString());
×
740
          }
741
        }
742
      },
743
      error);
744
}
104✔
745

746
namespace {
747

748
/// Remove one directory entry that is not a real directory: a link goes as
749
/// the entry itself, never its target (a junction or a directory symlink on
750
/// Windows is a directory entry and goes with rmdir); a read-only file gets
751
/// write access and one more try, as QDir::removeRecursively() does.
752
auto removeEntry(const QFileInfo &entry) -> bool {
11✔
753
  const QString path = entry.filePath();
11✔
754
  if (isLink(entry)) {
11✔
755
    if (QFile::remove(path) || QDir().rmdir(path)) {
6✔
756
      return true;
757
    }
758
    qCWarning(lcQtPass) << "Could not remove link" << path;
2✔
759
    return false;
1✔
760
  }
761
  if (QFile::remove(path)) {
6✔
762
    return true;
763
  }
764
  const QFile::Permissions perms = QFile::permissions(path);
1✔
765
  if (!perms.testFlag(QFile::WriteUser) &&
1✔
766
      QFile::setPermissions(path, perms | QFile::WriteUser) &&
2✔
767
      QFile::remove(path)) {
1✔
768
    return true;
769
  }
770
  qCWarning(lcQtPass) << "Could not remove" << path;
2✔
771
  return false;
1✔
772
}
773

774
} // namespace
775

776
auto Util::removeTree(const QString &dir) -> bool {
19✔
777
  // A trailing separator makes lstat follow a link ("link/" is the target
778
  // directory); the link itself is what this is about.
779
  const QString path = QDir::cleanPath(dir);
19✔
780
  const QFileInfo top(path);
19✔
781
  if (isLink(top)) {
19✔
782
    // rm -rf on a link removes the link.
783
    return QFile::remove(path) || QDir().rmdir(path);
14✔
784
  }
785
  if (!top.isDir()) {
7✔
786
    return false;
787
  }
788
  bool ok = true;
789
  const QFileInfoList entries =
790
      QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
12✔
791
                                   QDir::System | QDir::NoDotAndDotDot,
792
                               QDir::Name);
6✔
793
  for (const QFileInfo &entry : entries) {
18✔
794
    const bool removed = entry.isDir() && !isLink(entry)
17✔
795
                             ? removeTree(entry.filePath())
13✔
796
                             : removeEntry(entry);
11✔
797
    ok = removed && ok;
12✔
798
  }
799
  return ok && QDir().rmdir(path);
11✔
800
}
19✔
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