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

IJHack / QtPass / 35638958349

21 Sep 2026 06:31PM UTC coverage: 78.994% (-0.02%) from 79.01%
35638958349

push

github

web-flow
Copy: the object read is the object judged (#1883)

* Copy: the object read is the object judged

ImitatePass::Copy() judged the source with refuseLinkedPath() and then
copyFileReplacing() opened it by name, through whatever link a co-writer
of the store had put under the name in between: the bytes of any file this
user can read, copied into the store as an entry and committed before the
re-encryption could notice they were no ciphertext.

Util::openRegularFile() opens without following (O_NOFOLLOW and fstat on
POSIX; FILE_FLAG_OPEN_REPARSE_POINT and the handle's attributes on
Windows) and accepts a regular file only: a link, a directory, a FIFO (the
open does not block on it) or a device under the name fails. The copy is
written to a temporary next to the destination through its open handle
and given the destination's name with Util::replaceFile(), as an added
entry is since the previous change; QSaveFile, which resolved a link
under the destination name at open, is not used for entries any more.
Without force nothing under the name is replaced, also nothing that
appeared since the check.

Tests: tst_util pins openRegularFile (regular file read, missing file,
directory, link although a plain QFile reads through it, FIFO; a junction
on Windows); tst_imitatepass pins the copy's bytes and staging, no
temporary left, an unforced copy keeping an existing entry and a forced
one replacing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

* tst_imitatepass: the copy test uses a shell fake gpg; skip it on Windows

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

* Util::openRegularFile: tell CodeQL the descriptor is what is opened

Its check-then-use matcher flags QFile::open(fd) after fstat(fd) as a
race on the name; the name is not opened again, the descriptor is.

Co-Authored-By: Claude Opus 5 <nore... (continued)

21 of 28 new or added lines in 2 files covered. (75.0%)

7431 of 9407 relevant lines covered (78.99%)

80.52 hits per line

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

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

4
/**
5
 * @class Util
6
 * @brief Static utility functions implementation.
7
 *
8
 * Implementation of utility functions for path handling, binary discovery,
9
 * and configuration validation.
10
 *
11
 * @see util.h
12
 */
13

14
#include "util.h"
15
#include "appsettings.h"
16
#include "executor.h"
17
#include <QDebug>
18
#include <QDir>
19
#include <QFile>
20
#include <QFileInfo>
21
#include <QHash>
22
#include <QRegularExpressionMatchIterator>
23
#include <QStandardPaths>
24
#include <QUrl>
25
#ifdef Q_OS_WIN
26
#include <fcntl.h>
27
#include <io.h>
28
#include <windows.h>
29
#else
30
#include <cstdio>
31
#include <fcntl.h>
32
#include <sys/stat.h>
33
#include <sys/time.h>
34
#include <unistd.h>
35
#endif
36

37
#include "qtpasslogging.h"
38

39
QProcessEnvironment Util::_env;
40
bool Util::_envInitialised = false;
41

42
/**
43
 * @brief Initializes the process environment and augments PATH with
44
 * platform-specific GPG locations.
45
 * @example
46
 * Util::initialiseEnvironment();
47
 *
48
 * @note On macOS, appends common MacGPG2 and /usr/local/bin paths if available.
49
 * @note On Windows, appends common WinGPG and GnuPG installation paths if
50
 * available.
51
 */
52
void Util::initialiseEnvironment() {
277 ✔
53
  if (!_envInitialised) {
277 ✔
54
    _env = QProcessEnvironment::systemEnvironment();
6 ✔
55
#ifdef __APPLE__
56
    QString path = _env.value("PATH");
57
    if (!path.contains("/usr/local/MacGPG2/bin") &&
58
        QDir("/usr/local/MacGPG2/bin").exists())
59
      path += ":/usr/local/MacGPG2/bin";
60
    if (!path.contains("/usr/local/bin"))
61
      path += ":/usr/local/bin";
62
    _env.insert("PATH", path);
63
#endif
64
#ifdef Q_OS_WIN
65
    QString path = _env.value("PATH");
66
    if (!path.contains("C:\\Program Files\\WinGPG\\x86") &&
67
        QDir("C:\\Program Files\\WinGPG\\x86").exists())
68
      path += ";C:\\Program Files\\WinGPG\\x86";
69
    if (!path.contains("C:\\Program Files\\GnuPG\\bin") &&
70
        QDir("C:\\Program Files\\GnuPG\\bin").exists())
71
      path += ";C:\\Program Files\\GnuPG\\bin";
72
    _env.insert("PATH", path);
73
#endif
74
    qCDebug(lcQtPass) << _env.value("PATH");
6 ✔
75
    _envInitialised = true;
6 ✔
76
  }
77
}
277 ✔
78

79
/**
80
 * @brief Resolves the path to the password store directory.
81
 * @details Initializes the environment, checks for the {@code
82
 * PASSWORD_STORE_DIR} variable, and falls back to a platform-specific default
83
 * location under the user's home directory.
84
 * @return QString - Normalized path to the password store folder.
85
 */
86
auto Util::findPasswordStore() -> QString {
105 ✔
87
  QString path;
105 ✔
88
  initialiseEnvironment();
105 ✔
89
  if (_env.contains("PASSWORD_STORE_DIR")) {
210 ✔
90
    path = Util::expandTilde(_env.value("PASSWORD_STORE_DIR"));
×
91
  } else {
92
#ifdef Q_OS_WIN
93
    path = QDir(QDir::homePath()).filePath("password-store");
94
#else
95
    path = QDir(QDir::homePath()).filePath(".password-store");
315 ✔
96
#endif
97
  }
98
  return Util::normalizeFolderPath(QDir::cleanPath(path));
210 ✔
99
}
100

101
/**
102
 * @brief Expand a leading current-user tilde in a path.
103
 *
104
 * Environment variables set in non-shell contexts (systemd units, .desktop
105
 * entries, quoted shell assignments) skip shell tilde expansion and keep a
106
 * literal "~". "~username" forms are intentionally not resolved.
107
 */
108
auto Util::expandTilde(const QString &path) -> QString {
9 ✔
109
  if (path == QLatin1String("~")) {
9 ✔
110
    return QDir::homePath();
1 ✔
111
  }
112
  if (path.startsWith(QLatin1String("~/"))) {
8 ✔
113
    return QDir::homePath() + path.mid(1);
4 ✔
114
  }
115
  return path;
116
}
117

118
auto Util::normalizeFolderPath(const QString &path) -> QString {
145 ✔
119
  QString normalizedPath = path;
120
  if (!normalizedPath.endsWith('/')) {
145 ✔
121
    normalizedPath += '/';
121 ✔
122
  }
123
  return normalizedPath;
145 ✔
124
}
125

126
/**
127
 * @brief Finds the absolute path of a binary by searching the PATH environment
128
 * variable.
129
 *
130
 * Splits the (platform-augmented) PATH into directories and delegates to the
131
 * two-argument overload. On Windows, if no local match is found, it may fall
132
 * back to a WSL invocation when the binary name is valid and WSL appears to
133
 * support it.
134
 *
135
 * @example
136
 * QString result = Util::findBinaryInPath("git");
137
 * // Expected output sample: "/usr/bin/git" or "wsl git"
138
 *
139
 * @param QString binary - The name of the binary to locate.
140
 * @return QString - The absolute path to the binary, or an empty string if not
141
 * found.
142
 */
143
auto Util::findBinaryInPath(const QString &binary) -> QString {
173 ✔
144
  if (binary.isEmpty()) {
173 ✔
145
    return {};
146
  }
147

148
  initialiseEnvironment();
172 ✔
149

150
  const QStringList dirs =
151
      _env.value(QStringLiteral("PATH"))
344 ✔
152
          .split(QDir::listSeparator(), Qt::SkipEmptyParts);
172 ✔
153
  QString ret;
172 ✔
154
  if (QDir::fromNativeSeparators(binary).contains(u'/')) {
172 ✔
155
    // An explicit path is not a PATH search: an absolute path is checked
156
    // as-is, a relative one is resolved against the PATH directories. The
157
    // directory-list overload refuses such names, so handle them here.
158
    if (QDir::isAbsolutePath(binary)) {
2 ✔
159
      ret = QStandardPaths::findExecutable(binary);
4 ✔
160
    } else if (!dirs.isEmpty()) {
×
161
      ret = QStandardPaths::findExecutable(binary, dirs);
×
162
    }
163
  } else {
164
    ret = findBinaryInPath(binary, dirs);
340 ✔
165
  }
166
#ifdef Q_OS_WIN
167
  if (ret.isEmpty()) {
168
    // Cache per-binary WSL lookup result — the wsl --version probe is a
169
    // blocking subprocess that can run several times per session for
170
    // missing binaries; once decided, the answer doesn't change at runtime.
171
    static QHash<QString, QString> wslBinaryCache;
172
    const bool hasWhitespace =
173
        std::any_of(binary.cbegin(), binary.cend(),
174
                    [](const QChar ch) { return ch.isSpace(); });
175
    if (!hasWhitespace) {
176
      auto cached = wslBinaryCache.constFind(binary);
177
      if (cached != wslBinaryCache.constEnd()) {
178
        ret = cached.value();
179
      } else {
180
        QString wslCommand = QStringLiteral("wsl ") + binary;
181
        qCDebug(lcQtPass)
182
            << "Util::findBinaryInPath(): falling back to WSL for binary"
183
            << binary;
184
        QString out, err;
185
        QString cachedResult;
186
        if (Executor::executeBlocking(wslCommand, {"--version"}, &out, &err) ==
187
                0 &&
188
            !out.isEmpty() && err.isEmpty()) {
189
          qCDebug(lcQtPass)
190
              << "Util::findBinaryInPath(): using WSL binary" << wslCommand;
191
          cachedResult = wslCommand;
192
        }
193
        wslBinaryCache.insert(binary, cachedResult);
194
        ret = cachedResult;
195
      }
196
    }
197
  }
198
#endif
199

200
  return ret;
201
}
202

203
/**
204
 * @brief Finds an executable in an explicit list of directories.
205
 *
206
 * Thin wrapper around QStandardPaths::findExecutable(): only regular files
207
 * that are executable match (a directory named like the binary is skipped),
208
 * and on Windows the PATHEXT extensions are tried. Empty entries are dropped
209
 * rather than being resolved against the current working directory, and an
210
 * empty list finds nothing instead of silently falling back to the process
211
 * PATH. Only bare names are accepted: QStandardPaths::findExecutable() would
212
 * return an absolute @p binary without consulting @p searchPaths at all, and
213
 * a relative one containing ".." could escape them, so both find nothing.
214
 *
215
 * @param binary The name of the binary to locate; must not contain a
216
 * directory separator.
217
 * @param searchPaths Directories to search, in order.
218
 * @return QString - The absolute path to the binary, or an empty string if not
219
 * found.
220
 */
221
auto Util::findBinaryInPath(const QString &binary,
184 ✔
222
                            const QStringList &searchPaths) -> QString {
223
  if (binary.isEmpty() || QDir::fromNativeSeparators(binary).contains(u'/')) {
367 ✔
224
    return {};
225
  }
226
  QStringList dirs;
180 ✔
227
  dirs.reserve(searchPaths.size());
180 ✔
228
  for (const QString &dir : searchPaths) {
3,226 ✔
229
    if (!dir.isEmpty()) {
3,046 ✔
230
      dirs.append(dir);
231
    }
232
  }
233
  if (dirs.isEmpty()) {
180 ✔
234
    // QStandardPaths::findExecutable() treats an empty list as "use PATH".
235
    return {};
236
  }
237
  return QStandardPaths::findExecutable(binary, dirs);
176 ✔
238
}
239

240
/**
241
 * @brief Checks whether the current QtPass configuration is valid.
242
 * @example
243
 * AppSettings s = QtPassSettings::load();
244
 * bool result = Util::configIsValid(s);
245
 * std::cout << std::boolalpha << result << std::endl; // Expected output: true
246
 * or false
247
 *
248
 * @param s Application settings snapshot to validate.
249
 * @return bool - True if the configuration file exists and the required
250
 * executable is available; otherwise false.
251
 */
252
auto Util::configIsValid(const AppSettings &s) -> bool {
85 ✔
253
  const QString configFilePath = QDir(s.passStore).filePath(".gpg-id");
170 ✔
254
  if (!QFile(configFilePath).exists()) {
85 ✔
255
    return false;
256
  }
257

258
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
51 ✔
259

260
  if (const auto wsl = Executor::parseWslCommand(executable)) {
51 ✔
261
    // Probe WSL once per session — availability doesn't change at runtime
262
    // and the executeBlocking call is a blocking subprocess.
263
    static const bool wslAvailable = [&wsl]() {
×
264
      QString out;
×
265
      QString err;
×
266
      return Executor::executeBlocking(wsl->launcher,
×
267
                                       {QStringLiteral("--version")}, &out,
×
268
                                       &err) == 0 &&
×
269
             !out.isEmpty() && err.isEmpty();
×
270
    }();
×
271
    if (wslAvailable) {
×
272
      return true;
273
    }
274
  }
275
  return QFile(executable).exists();
51 ✔
276
}
277

278
/**
279
 * @brief Returns a regex matching strings that end with the .gpg extension.
280
 *
281
 * @return QRegularExpression reference
282
 */
283
auto Util::endsWithGpg() -> const QRegularExpression & {
942 ✔
284
  static const QRegularExpression expr{R"(\.gpg$)"};
942 ✔
285
  return expr;
942 ✔
286
}
287

288
/**
289
 * @brief Returns a regex matching common remote/network protocol schemes.
290
 *
291
 * Matches http://, https://, ftp://, ftps://, ssh://, sftp://, webdav://,
292
 * webdavs://
293
 *
294
 * The URL text ends at the first whitespace character (space, tab, CR, LF),
295
 * quote or bracket, so a URL on its own line in multi-line text (pass file
296
 * bodies, gpg stderr) is captured without the line break that follows it.
297
 *
298
 * Note: Local file URLs (file:///) are intentionally excluded by design, as
299
 * they represent local paths rather than network protocols. If this behavior
300
 * needs to change, update both this function and the corresponding test.
301
 *
302
 * @return QRegularExpression reference
303
 */
304
auto Util::protocolRegex() -> const QRegularExpression & {
116 ✔
305
  static const QRegularExpression regex{
306
      R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
116 ✔
307
  return regex;
116 ✔
308
}
309

310
/**
311
 * @brief Validate a value as a launchable http(s) URL.
312
 *
313
 * Security gate for the "open in browser" action. See util.h for the full
314
 * contract. Deliberately stricter than protocolRegex(): only http/https,
315
 * valid host, no embedded credentials, no control characters.
316
 *
317
 * @param value Candidate URL string.
318
 * @return true if launchable in a browser, false otherwise.
319
 */
320
auto Util::isLaunchableWebUrl(const QString &value) -> bool {
81 ✔
321
  const QString trimmed = value.trimmed();
322
  if (trimmed.isEmpty()) {
81 ✔
323
    return false;
324
  }
325
  // Reject control characters first, before QUrl normalisation can hide a
326
  // CR/LF/NUL injection into the OS URL handler.
327
  for (const QChar &c : trimmed) {
1,945 ✔
328
    if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
329
        c == QChar(QChar::Null)) {
330
      return false;
331
    }
332
  }
333
  const QUrl url(trimmed, QUrl::StrictMode);
77 ✔
334
  if (!url.isValid()) {
77 ✔
335
    return false;
336
  }
337
  const QString scheme = url.scheme().toLower();
134 ✔
338
  if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
129 ✔
339
    return false;
22 ✔
340
  }
341
  if (url.host().isEmpty()) {
90 ✔
342
    return false;
343
  }
344
  // Embedded userinfo (user:pass@host) would leak into browser history.
345
  if (!url.userName().isEmpty() || !url.password().isEmpty()) {
83 ✔
346
    return false;
347
  }
348
  return true;
349
}
77 ✔
350

351
/**
352
 * @brief Escape text as HTML and link only launchable http(s) URLs.
353
 *
354
 * See util.h for the contract. Detection uses protocolRegex() so that the
355
 * URL text is delimited the same way everywhere; the decision whether a
356
 * match becomes an anchor is isLaunchableWebUrl(), the same predicate that
357
 * gates the "open in browser" button.
358
 *
359
 * @param text Plain text, not yet HTML-escaped.
360
 * @param linked Set to true when at least one anchor was emitted.
361
 * @return HTML string safe to hand to QTextBrowser::setHtml().
362
 */
363
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
112 ✔
364
  if (linked != nullptr) {
112 ✔
365
    *linked = false;
81 ✔
366
  }
367
  QString html;
112 ✔
368
  html.reserve(text.size());
112 ✔
369
  qsizetype lastIndex = 0;
370
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
112 ✔
371
  while (it.hasNext()) {
147 ✔
372
    const QRegularExpressionMatch match = it.next();
35 ✔
373
    const QString url = match.captured(0);
35 ✔
374
    if (!isLaunchableWebUrl(url)) {
35 ✔
375
      // Not a web URL (or it carries credentials): leave it in the escaped
376
      // plain-text run instead of making it clickable.
377
      continue;
378
    }
379
    const qsizetype start = match.capturedStart(0);
22 ✔
380
    html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
22 ✔
381
    const QString escapedUrl = url.toHtmlEscaped();
22 ✔
382
    html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
44 ✔
383
    lastIndex = match.capturedEnd(0);
22 ✔
384
    if (linked != nullptr) {
22 ✔
385
      *linked = true;
16 ✔
386
    }
387
  }
35 ✔
388
  html += text.mid(lastIndex).toHtmlEscaped();
112 ✔
389
  return html;
112 ✔
390
}
112 ✔
391

392
/**
393
 * @brief Returns a regex matching newline characters (CR or LF).
394
 *
395
 * Useful for detecting or sanitising line breaks in text content.
396
 *
397
 * @return QRegularExpression reference
398
 */
399
auto Util::newLinesRegex() -> const QRegularExpression & {
140 ✔
400
  static const QRegularExpression regex{"[\r\n]"};
140 ✔
401
  return regex;
140 ✔
402
}
403

404
/**
405
 * @brief Validate whether a string is an accepted GPG key identifier.
406
 *
407
 * Mirrors what `pass` itself accepts in `.gpg-id`: every non-empty token is
408
 * handed to gpg as a `-r` argument, and gpg resolves it — key ID or
409
 * fingerprint of any version (v4 hex, v6 hex, with or without `0x`),
410
 * `<email>`, `=Exact User ID`, a plain name substring, or a `@`/`/`/`#`/`&`
411
 * routing prefix. No content heuristics are applied here: they can only
412
 * reject recipients gpg would have accepted, and a rejected line is not just
413
 * skipped but erased the next time `.gpg-id` is rewritten.
414
 *
415
 * The one thing rejected is a token starting with `-`: the recipient list is
416
 * also passed positionally to `gpg --list-keys`, where such a token would be
417
 * parsed as an option instead of a key selector.
418
 *
419
 * Empty input is invalid.
420
 *
421
 * @param keyId Input key identifier string to validate.
422
 * @return true unless the input is empty or starts with `-`.
423
 */
424
auto Util::isValidKeyId(const QString &keyId) -> bool {
139 ✔
425
  return !keyId.isEmpty() && !keyId.startsWith('-');
139 ✔
426
}
427

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

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

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

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

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

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

521
auto Util::isLinkedFolder(const QString &path) -> bool {
512 ✔
522
  return isLink(QFileInfo(QDir::cleanPath(path)));
1,024 ✔
523
}
524

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

560
auto Util::replaceFile(const QString &from, const QString &to, bool replace)
64 ✔
561
    -> bool {
562
#ifdef Q_OS_WIN
563
  const std::wstring source =
564
      QDir::toNativeSeparators(QFileInfo(from).absoluteFilePath())
565
          .toStdWString();
566
  const std::wstring target =
567
      QDir::toNativeSeparators(QFileInfo(to).absoluteFilePath()).toStdWString();
568
  return MoveFileExW(source.c_str(), target.c_str(),
569
                     replace ? MOVEFILE_REPLACE_EXISTING : 0) != 0;
570
#else
571
  const QByteArray source = QFile::encodeName(from);
572
  const QByteArray target = QFile::encodeName(to);
573
  if (replace) {
64 ✔
574
    return ::rename(source.constData(), target.constData()) == 0;
9 ✔
575
  }
576
  // link() makes no second name where one exists and follows nothing.
577
  if (::link(source.constData(), target.constData()) != 0) {
55 ✔
578
    return false;
579
  }
580
  ::unlink(source.constData());
52 ✔
581
  return true;
52 ✔
582
#endif
583
}
584

585
auto Util::openRegularFile(const QString &path, QFile &file) -> bool {
12 ✔
586
#ifdef Q_OS_WIN
587
  // FILE_FLAG_OPEN_REPARSE_POINT opens a symbolic link or junction itself
588
  // rather than its target, so the handle's attributes say what the name
589
  // was at the moment of the open.
590
  const std::wstring native =
591
      QDir::toNativeSeparators(QFileInfo(path).absoluteFilePath())
592
          .toStdWString();
593
  HANDLE handle = CreateFileW(
594
      native.c_str(), GENERIC_READ,
595
      FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
596
      OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
597
  if (handle == INVALID_HANDLE_VALUE) {
598
    return false;
599
  }
600
  BY_HANDLE_FILE_INFORMATION info{};
601
  if (!GetFileInformationByHandle(handle, &info) ||
602
      (info.dwFileAttributes &
603
       (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY |
604
        FILE_ATTRIBUTE_DEVICE)) != 0 ||
605
      GetFileType(handle) != FILE_TYPE_DISK) {
606
    CloseHandle(handle);
607
    return false;
608
  }
609
  const int fd = _open_osfhandle(reinterpret_cast<intptr_t>(handle),
610
                                 _O_RDONLY | _O_BINARY);
611
  if (fd < 0) {
612
    CloseHandle(handle);
613
    return false;
614
  }
615
  if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
616
    _close(fd);
617
    return false;
618
  }
619
  return true;
620
#else
621
  // O_NOFOLLOW fails with ELOOP on a symbolic link; O_NONBLOCK keeps a FIFO
622
  // from blocking the open until fstat() can refuse it.
623
  const int fd = ::open(QFile::encodeName(path).constData(),
12 ✔
624
                        O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
625
  if (fd < 0) {
12 ✔
626
    return false;
627
  }
628
  struct stat st{};
10 ✔
629
  if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
10 ✔
630
    ::close(fd);
2 ✔
631
    return false;
2 ✔
632
  }
633
  // The descriptor, not the name, is what QFile opens here: the object
634
  // fstat() judged is the object read. CodeQL's check-then-use pattern
635
  // matcher sees an open after a check and cannot tell.
636
  if (!file.open(fd, QIODevice::ReadOnly, // codeql[cpp/toctou-race-condition]
8 ✔
637
                 QFileDevice::AutoCloseHandle)) {
NEW
638
    ::close(fd);
×
NEW
639
    return false;
×
640
  }
641
  return true;
642
#endif
643
}
644

645
auto Util::removeTree(const QString &dir) -> bool {
12 ✔
646
  // A trailing separator makes lstat follow a link ("link/" is the target
647
  // directory); the link itself is what this is about.
648
  const QString path = QDir::cleanPath(dir);
12 ✔
649
  const QFileInfo top(path);
12 ✔
650
  if (isLink(top)) {
12 ✔
651
    // rm -rf on a link removes the link.
652
    return QFile::remove(path) || QDir().rmdir(path);
8 ✔
653
  }
654
  if (!top.isDir()) {
4 ✔
655
    return false;
656
  }
657
  bool ok = true;
658
  const QFileInfoList entries =
659
      QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
6 ✔
660
                                   QDir::System | QDir::NoDotAndDotDot,
661
                               QDir::Name);
3 ✔
662
  for (const QFileInfo &entry : entries) {
10 ✔
663
    const QString entryPath = entry.filePath();
7 ✔
664
    if (isLink(entry)) {
7 ✔
665
      // The entry itself, never the target. A junction or a directory
666
      // symlink on Windows is a directory entry and goes with rmdir.
667
      if (!QFile::remove(entryPath) && !QDir().rmdir(entryPath)) {
3 ✔
668
        qCWarning(lcQtPass) << "Could not remove link" << entryPath;
×
669
        ok = false;
670
      }
671
    } else if (entry.isDir()) {
4 ✔
672
      ok = removeTree(entryPath) && ok;
1 ✔
673
    } else if (!QFile::remove(entryPath)) {
3 ✔
674
      // A read-only file blocks deletion on Windows; give it write access
675
      // and try once more, as QDir::removeRecursively() does.
676
      const QFile::Permissions perms = QFile::permissions(entryPath);
×
677
      if (perms.testFlag(QFile::WriteUser) ||
×
678
          !QFile::setPermissions(entryPath, perms | QFile::WriteUser) ||
×
679
          !QFile::remove(entryPath)) {
×
680
        qCWarning(lcQtPass) << "Could not remove" << entryPath;
×
681
        ok = false;
682
      }
683
    }
684
  }
685
  return ok && QDir().rmdir(path);
6 ✔
686
}
12 ✔
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