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

IJHack / QtPass / 35616083387

21 Sep 2026 03:01PM UTC coverage: 79.031% (+0.006%) from 79.025%
35616083387

push

github

web-flow
GpgIdGeneration: detect conflicting equal generations (#1878)

* GpgIdGeneration: the same generation is the same list only if the bytes are

The record remembered the generation alone, so an incoming signed list of
the remembered generation was accepted whatever its bytes. Two devices both
writing 19 from 18 is Git's conflict; the device that wrote or accepted one
of the two then met the other as generation 19 and took it for its own, and
so it would a swap between two authentic lists of one generation.

The record keeps a SHA-256 of the exact bytes next to the generation.
accept() at the remembered generation compares: other bytes are
Verdict::Conflict, with a message that names both readings (racing saves,
a swap) and the way through, checking the recipients and saving; a record
without a digest (a reservation whose write was not recorded) takes the
bytes it sees. reserveNext() clears the digest with the new number, and the
writers record their bytes right after the QSaveFile commit
(recordWritten(), not fatal when it fails: the generation is recorded, and
the first read digests it). Generation 0 is not pinned: pass writes a
headerless list for every change, also through QtPass's pass backend, so
other bytes there are the normal course of a store kept with pass.
recipientsForEditing() treats Conflict as Rejected: nothing preselected.

The record itself is a JSON file of QtPass's own now
(QtPass-gpgid-generations.json, where QSettings would put an ini of the
organisation, so the tests' redirection applies), read afresh under a lock
file for every transaction and written whole with QSaveFile. QSettings
cached the file per process and re-read it on size or mtime alone, which a
digest swap or a same-width generation bump does not change, so a second
process's write could be read stale even under the lock; on Windows the
default format is the registry, whose "file name" the lock would have taken
for a folder. The format-error stickiness and the failed-s... (continued)

144 of 162 new or added lines in 3 files covered. (88.89%)

18 existing lines in 3 files now uncovered.

7376 of 9333 relevant lines covered (79.03%)

80.39 hits per line

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

89.71
/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 <windows.h>
27
#else
28
#include <sys/time.h>
29
#endif
30

31
#include "qtpasslogging.h"
32

33
QProcessEnvironment Util::_env;
34
bool Util::_envInitialised = false;
35

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

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

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

112
auto Util::normalizeFolderPath(const QString &path) -> QString {
145 ✔
113
  QString normalizedPath = path;
114
  if (!normalizedPath.endsWith('/')) {
145 ✔
115
    normalizedPath += '/';
121 ✔
116
  }
117
  return normalizedPath;
145 ✔
118
}
119

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

142
  initialiseEnvironment();
172 ✔
143

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

194
  return ret;
195
}
196

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

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

252
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
51 ✔
253

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

272
/**
273
 * @brief Returns a regex matching strings that end with the .gpg extension.
274
 *
275
 * @return QRegularExpression reference
276
 */
277
auto Util::endsWithGpg() -> const QRegularExpression & {
937 ✔
278
  static const QRegularExpression expr{R"(\.gpg$)"};
937 ✔
279
  return expr;
937 ✔
280
}
281

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

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

345
/**
346
 * @brief Escape text as HTML and link only launchable http(s) URLs.
347
 *
348
 * See util.h for the contract. Detection uses protocolRegex() so that the
349
 * URL text is delimited the same way everywhere; the decision whether a
350
 * match becomes an anchor is isLaunchableWebUrl(), the same predicate that
351
 * gates the "open in browser" button.
352
 *
353
 * @param text Plain text, not yet HTML-escaped.
354
 * @param linked Set to true when at least one anchor was emitted.
355
 * @return HTML string safe to hand to QTextBrowser::setHtml().
356
 */
357
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
112 ✔
358
  if (linked != nullptr) {
112 ✔
359
    *linked = false;
81 ✔
360
  }
361
  QString html;
112 ✔
362
  html.reserve(text.size());
112 ✔
363
  qsizetype lastIndex = 0;
364
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
112 ✔
365
  while (it.hasNext()) {
147 ✔
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();
112 ✔
383
  return html;
112 ✔
384
}
112 ✔
385

386
/**
387
 * @brief Returns a regex matching newline characters (CR or LF).
388
 *
389
 * Useful for detecting or sanitising line breaks in text content.
390
 *
391
 * @return QRegularExpression reference
392
 */
393
auto Util::newLinesRegex() -> const QRegularExpression & {
136 ✔
394
  static const QRegularExpression regex{"[\r\n]"};
136 ✔
395
  return regex;
136 ✔
396
}
397

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

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

454
/// A symlink or an NTFS junction: never entered, never listed.
455
auto isLink(const QFileInfo &entry) -> bool {
1,286 ✔
456
  return entry.isSymLink() || entry.isJunction();
1,286 ✔
457
}
458

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

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

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

503
auto Util::directoriesUnder(const QString &dir) -> QStringList {
2 ✔
504
  QStringList dirs;
2 ✔
505
  walkStore(dir, [&](const QFileInfo &entry) {
2 ✔
506
    if (!isStoreDirectory(entry)) {
10 ✔
507
      return false;
508
    }
509
    dirs << entry.filePath();
4 ✔
510
    return true;
4 ✔
511
  });
512
  return dirs;
2 ✔
513
}
514

515
auto Util::isLinkedFolder(const QString &path) -> bool {
491 ✔
516
  return isLink(QFileInfo(QDir::cleanPath(path)));
982 ✔
517
}
518

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

554
auto Util::removeTree(const QString &dir) -> bool {
12 ✔
555
  // A trailing separator makes lstat follow a link ("link/" is the target
556
  // directory); the link itself is what this is about.
557
  const QString path = QDir::cleanPath(dir);
12 ✔
558
  const QFileInfo top(path);
12 ✔
559
  if (isLink(top)) {
12 ✔
560
    // rm -rf on a link removes the link.
561
    return QFile::remove(path) || QDir().rmdir(path);
8 ✔
562
  }
563
  if (!top.isDir()) {
4 ✔
564
    return false;
565
  }
566
  bool ok = true;
567
  const QFileInfoList entries =
568
      QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
6 ✔
569
                                   QDir::System | QDir::NoDotAndDotDot,
570
                               QDir::Name);
3 ✔
571
  for (const QFileInfo &entry : entries) {
10 ✔
572
    const QString entryPath = entry.filePath();
7 ✔
573
    if (isLink(entry)) {
7 ✔
574
      // The entry itself, never the target. A junction or a directory
575
      // symlink on Windows is a directory entry and goes with rmdir.
576
      if (!QFile::remove(entryPath) && !QDir().rmdir(entryPath)) {
3 ✔
577
        qCWarning(lcQtPass) << "Could not remove link" << entryPath;
×
578
        ok = false;
579
      }
580
    } else if (entry.isDir()) {
4 ✔
581
      ok = removeTree(entryPath) && ok;
1 ✔
582
    } else if (!QFile::remove(entryPath)) {
3 ✔
583
      // A read-only file blocks deletion on Windows; give it write access
584
      // and try once more, as QDir::removeRecursively() does.
UNCOV
585
      const QFile::Permissions perms = QFile::permissions(entryPath);
×
UNCOV
586
      if (perms.testFlag(QFile::WriteUser) ||
×
UNCOV
587
          !QFile::setPermissions(entryPath, perms | QFile::WriteUser) ||
×
UNCOV
588
          !QFile::remove(entryPath)) {
×
UNCOV
589
        qCWarning(lcQtPass) << "Could not remove" << entryPath;
×
590
        ok = false;
591
      }
592
    }
593
  }
594
  return ok && QDir().rmdir(path);
6 ✔
595
}
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