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

IJHack / QtPass / 35721681840

22 Sep 2026 11:28AM UTC coverage: 92.98% (+0.04%) from 92.937%
35721681840

push

github

web-flow
The staged write checks it placed the file it wrote; re-encryption reports once (#1906)

* The staged write checks it placed the file it wrote; re-encryption reports once

After the rename, Util::stageFileReplacing() (the primitive behind
writeFileReplacing() and copyFileReplacing(), now public so the window can be
tested) opens the object under the name without following and compares it,
by device and inode, with the temporary it filled. A file swapped under the
temporary's name before the rename, a hard link to something of the user's
say, is a regular file to every check by name and used to become the entry;
it is reported now, and a name the write itself made is removed again.
Without replacing, the name is made with linkat(2) and no flags: link(2)
follows a symlink at the source on macOS and the BSDs (XNU and FreeBSD pass
AT_SYMLINK_FOLLOW), which would have made the entry a second name for the
link's target.

reencryptSingleFile() hands the reason a ciphertext could not be placed back
to the run, which lists it in its one summary: a folder that cannot be
written failed every entry in it with its own modal dialog before the summary.

recoverReencryptLeftovers() leaves a temporary modified within the hour
alone: on a shared store it may be another QtPass's write in flight, and
unlinking it would fail that write for nothing.

Tests: linkat does not follow a planted symlink; a filler that plays the
co-writer swaps the temporary for a hard link or a symlink and neither lands
(red with the identity comparison removed); systematic failures produce one
dialog; a recent staged temporary survives the recovery pass; the leftover
assertions look at hidden names too.

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

* lupdate + best-effort translations for the swapped-file message

"%1 was swapped for another file while it was written." replaces "%1 was
replaced by a link while it ... (continued)

28 of 29 new or added lines in 2 files covered. (96.55%)

7 existing lines in 1 file now uncovered.

8795 of 9459 relevant lines covered (92.98%)

148.8 hits per line

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

94.16
/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 <QCoreApplication>
18
#include <QDebug>
19
#include <QDir>
20
#include <QFile>
21
#include <QFileDevice>
22
#include <QFileInfo>
23
#include <QHash>
24
#include <QRegularExpressionMatchIterator>
25
#include <QStandardPaths>
26
#include <QTemporaryFile>
27
#include <QUrl>
28

29
#ifdef Q_OS_WIN
30
#include <fcntl.h>
31
#include <io.h>
32
#include <windows.h>
33
#else
34
#include <cerrno>
35
#include <cstdio>
36
#include <fcntl.h>
37
#include <sys/stat.h>
38
#include <sys/time.h>
39
#include <unistd.h>
40
#endif
41

42
#include "qtpasslogging.h"
43

44
QProcessEnvironment Util::_env;
45
bool Util::_envInitialised = false;
46

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

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

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

123
auto Util::normalizeFolderPath(const QString &path) -> QString {
291 ✔
124
  QString normalizedPath = path;
125
  if (!normalizedPath.endsWith('/')) {
291 ✔
126
    normalizedPath += '/';
255 ✔
127
  }
128
  return normalizedPath;
291 ✔
129
}
130

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

153
  initialiseEnvironment();
343 ✔
154

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

205
  return ret;
206
}
207

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

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

263
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
115 ✔
264

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

283
/**
284
 * @brief Returns a regex matching strings that end with the .gpg extension.
285
 *
286
 * @return QRegularExpression reference
287
 */
288
auto Util::endsWithGpg() -> const QRegularExpression & {
6,160 ✔
289
  static const QRegularExpression expr{R"(\.gpg$)"};
6,160 ✔
290
  return expr;
6,160 ✔
291
}
292

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

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

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

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

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

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

465
/// A symlink or an NTFS junction: never entered, never listed.
466
auto isLink(const QFileInfo &entry) -> bool {
2,018 ✔
467
  return entry.isSymLink() || entry.isJunction();
2,018 ✔
468
}
469

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

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

486
auto Util::regularFilesUnder(const QString &dir, const QStringList &nameFilters,
154 ✔
487
                             QStringList *skipped, bool hiddenFiles)
488
    -> QStringList {
489
  QStringList files;
154 ✔
490
  walkStore(dir, [&](const QFileInfo &entry) {
154 ✔
491
    if (isStoreDirectory(entry)) {
659 ✔
492
      return true;
493
    }
494
    const bool named = QDir::match(nameFilters, entry.fileName());
624 ✔
495
    if (isLink(entry) || (!entry.isDir() && !entry.isFile())) {
624 ✔
496
      // A linked directory hides everything behind it; a linked file, or a
497
      // FIFO, socket or device, is only of interest under a name the caller
498
      // asked for.
499
      if (skipped != nullptr && (entry.isDir() || named)) {
17 ✔
500
        qCWarning(lcQtPass) << "Skipping" << entry.filePath()
24 ✔
501
                            << ": not a regular file or directory";
12 ✔
502
        *skipped << entry.filePath();
24 ✔
503
      }
504
      return false;
17 ✔
505
    }
506
    if (entry.isFile() && named && (hiddenFiles || !isHiddenEntry(entry))) {
607 ✔
507
      files << entry.filePath();
360 ✔
508
    }
509
    return false;
510
  });
511
  return files;
154 ✔
512
}
513

514
auto Util::directoriesUnder(const QString &dir) -> QStringList {
3 ✔
515
  QStringList dirs;
3 ✔
516
  walkStore(dir, [&](const QFileInfo &entry) {
3 ✔
517
    if (!isStoreDirectory(entry)) {
54 ✔
518
      return false;
519
    }
520
    dirs << entry.filePath();
18 ✔
521
    return true;
18 ✔
522
  });
523
  return dirs;
3 ✔
524
}
525

526
auto Util::isLinkedFolder(const QString &path) -> bool {
650 ✔
527
  return isLink(QFileInfo(QDir::cleanPath(path)));
1,300 ✔
528
}
529

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

565
auto Util::replaceFile(const QString &from, const QString &to, bool replace)
179 ✔
566
    -> bool {
567
#ifdef Q_OS_WIN
568
  const std::wstring source =
569
      QDir::toNativeSeparators(QFileInfo(from).absoluteFilePath())
570
          .toStdWString();
571
  const std::wstring target =
572
      QDir::toNativeSeparators(QFileInfo(to).absoluteFilePath()).toStdWString();
573
  // WRITE_THROUGH: the new entry is on the device when this returns.
574
  return MoveFileExW(source.c_str(), target.c_str(),
575
                     (replace ? MOVEFILE_REPLACE_EXISTING : 0) |
576
                         MOVEFILE_WRITE_THROUGH) != 0;
577
#else
578
  const QByteArray source = QFile::encodeName(from);
579
  const QByteArray target = QFile::encodeName(to);
580
  if (replace) {
179 ✔
581
    if (::rename(source.constData(), target.constData()) != 0) {
105 ✔
582
      return false;
583
    }
584
  } else {
585
    // linkat() makes no second name where one exists, and without
586
    // AT_SYMLINK_FOLLOW it follows nothing: link() would, on macOS and the
587
    // BSDs, make the new name a hard link to whatever a symlink planted
588
    // under the source's name points at.
589
    if (::linkat(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(),
74 ✔
590
                 0) != 0) {
591
      return false;
592
    }
593
    ::unlink(source.constData());
67 ✔
594
  }
595
  // The directory entry too, so a crash right after does not lose the new
596
  // name. Best effort: the rename has happened and the file's own bytes
597
  // were synced before it, so a directory that cannot be synced (some
598
  // network filesystems) is no reason to report the write as failed.
599
  const int dir = ::open(QFile::encodeName(QFileInfo(to).path()).constData(),
340 ✔
600
                         O_RDONLY | O_DIRECTORY | O_CLOEXEC);
601
  if (dir >= 0) {
170 ✔
602
    int rc;
603
    do {
604
      rc = ::fsync(dir);
170 ✔
605
    } while (rc != 0 && errno == EINTR);
170 ✔
606
    ::close(dir);
170 ✔
607
  }
608
  return true;
609
#endif
610
}
611

612
auto Util::openRegularFile(const QString &path, QFile &file) -> bool {
273 ✔
613
#ifdef Q_OS_WIN
614
  // FILE_FLAG_OPEN_REPARSE_POINT opens a symbolic link or junction itself
615
  // rather than its target, so the handle's attributes say what the name
616
  // was at the moment of the open.
617
  const std::wstring native =
618
      QDir::toNativeSeparators(QFileInfo(path).absoluteFilePath())
619
          .toStdWString();
620
  HANDLE handle = CreateFileW(
621
      native.c_str(), GENERIC_READ,
622
      FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
623
      OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
624
  if (handle == INVALID_HANDLE_VALUE) {
625
    return false;
626
  }
627
  BY_HANDLE_FILE_INFORMATION info{};
628
  if (!GetFileInformationByHandle(handle, &info) ||
629
      (info.dwFileAttributes &
630
       (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY |
631
        FILE_ATTRIBUTE_DEVICE)) != 0 ||
632
      GetFileType(handle) != FILE_TYPE_DISK) {
633
    CloseHandle(handle);
634
    return false;
635
  }
636
  const int fd = _open_osfhandle(reinterpret_cast<intptr_t>(handle),
637
                                 _O_RDONLY | _O_BINARY);
638
  if (fd < 0) {
639
    CloseHandle(handle);
640
    return false;
641
  }
642
  if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
643
    _close(fd);
644
    return false;
645
  }
646
  return true;
647
#else
648
  // O_NOFOLLOW fails with ELOOP on a symbolic link; O_NONBLOCK keeps a FIFO
649
  // from blocking the open until fstat() can refuse it.
650
  const int fd = ::open(QFile::encodeName(path).constData(),
273 ✔
651
                        O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
652
  if (fd < 0) {
273 ✔
653
    return false;
654
  }
655
  struct stat st{};
267 ✔
656
  if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
267 ✔
657
    ::close(fd);
3 ✔
658
    return false;
3 ✔
659
  }
660
  // The descriptor, not the name, is what QFile opens here: the object
661
  // fstat() judged is the object read. CodeQL's check-then-use pattern
662
  // matcher sees an open after a check and cannot tell.
663
  if (!file.open(fd, QIODevice::ReadOnly, // codeql[cpp/toctou-race-condition]
264 ✔
664
                 QFileDevice::AutoCloseHandle)) {
665
    ::close(fd);
×
666
    return false;
×
667
  }
668
  return true;
669
#endif
670
}
671

672
auto Util::syncToDisk(QFileDevice &file) -> bool {
172 ✔
673
  if (!file.flush()) {
172 ✔
674
    return false;
675
  }
676
#ifdef Q_OS_WIN
677
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
678
  return handle != INVALID_HANDLE_VALUE && FlushFileBuffers(handle) != 0;
679
#else
680
  return ::fsync(file.handle()) == 0;
172 ✔
681
#endif
682
}
683

684
namespace {
685

686
/**
687
 * @brief What tells one file object from another on its filesystem: device
688
 * and inode on POSIX, volume serial and file index on Windows.
689
 */
690
struct FileIdentity {
691
  quint64 volume = 0;
692
  quint64 index = 0;
693
  bool known = false;
694
};
695

696
auto operator==(const FileIdentity &a, const FileIdentity &b) -> bool {
163 ✔
697
  return a.known && b.known && a.volume == b.volume && a.index == b.index;
163 ✔
698
}
699

700
/// The identity of the object @p file is open on, or an unknown one.
701
auto identityOf(const QFileDevice &file) -> FileIdentity {
335 ✔
702
#ifdef Q_OS_WIN
703
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
704
  BY_HANDLE_FILE_INFORMATION info;
705
  if (handle == INVALID_HANDLE_VALUE ||
706
      GetFileInformationByHandle(handle, &info) == 0) {
707
    return {};
708
  }
709
  return {info.dwVolumeSerialNumber,
710
          (static_cast<quint64>(info.nFileIndexHigh) << 32) |
711
              info.nFileIndexLow,
712
          true};
713
#else
714
  struct stat st{};
335 ✔
715
  if (::fstat(file.handle(), &st) != 0) {
335 ✔
NEW
716
    return {};
×
717
  }
718
  return {static_cast<quint64>(st.st_dev), static_cast<quint64>(st.st_ino),
335 ✔
719
          true};
335 ✔
720
#endif
721
}
722

723
} // namespace
724

725
auto Util::stageFileReplacing(const QString &path, bool replace,
180 ✔
726
                              const Filler &fill, QString *error) -> bool {
727
  QString stagedPath;
180 ✔
728
  QString why;
180 ✔
729
  FileIdentity written;
180 ✔
730
  {
731
    // The QTemporaryFile goes out of scope before the rename: it keeps its
732
    // handle open for as long as it lives, also after close(), and Windows
733
    // does not rename an open file.
734
    QTemporaryFile staged(QFileInfo(path).path() +
360 ✔
735
                          QStringLiteral("/.qtpass-XXXXXX.tmp"));
360 ✔
736
    staged.setAutoRemove(false);
180 ✔
737
    if (!staged.open()) {
180 ✔
738
      if (error)
8 ✔
739
        *error = QCoreApplication::translate(
8 ✔
740
                     "Util", "Cannot create a temporary file next to %1: %2")
741
                     .arg(path, staged.errorString());
16 ✔
742
      return false;
743
    }
744
    stagedPath = staged.fileName();
172 ✔
745
    // Owner-only: a .gpg-id names the keys a store is encrypted to, an
746
    // entry is an entry. QTemporaryFile creates 0600 already; say so for
747
    // platforms where it may not.
748
    staged.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
172 ✔
749
    why = fill(staged);
344 ✔
750
    if (why.isEmpty() && !syncToDisk(staged)) {
172 ✔
751
      why = QCoreApplication::translate("Util", "Cannot write %1: %2")
×
752
                .arg(path, staged.errorString());
×
753
    }
754
    written = identityOf(staged);
172 ✔
755
  }
180 ✔
756
  if (!why.isEmpty()) {
172 ✔
757
    QFile::remove(stagedPath);
×
758
    if (error)
×
759
      *error = why;
×
760
    return false;
×
761
  }
762
  if (!replaceFile(stagedPath, path, replace)) {
172 ✔
763
    QFile::remove(stagedPath);
7 ✔
764
    if (error) {
7 ✔
765
      const QFileInfo taken(path);
7 ✔
766
      if (replace) {
7 ✔
767
        *error = QCoreApplication::translate("Util", "Failed to replace %1.")
2 ✔
768
                     .arg(path);
4 ✔
769
      } else if (taken.exists() || taken.isSymLink()) {
5 ✔
770
        *error =
771
            QCoreApplication::translate("Util", "%1 already exists.").arg(path);
8 ✔
772
      } else {
773
        *error = QCoreApplication::translate("Util", "Failed to write %1.")
1 ✔
774
                     .arg(path);
2 ✔
775
      }
776
    }
7 ✔
777
    return false;
7 ✔
778
  }
779
  // The object under the name must be the file that was filled: not a link
780
  // (the temporary's name swapped for one before the rename: the bytes went
781
  // into an unnamed inode, nothing through the link), and not another file
782
  // put under the temporary's name in that window (a hard link to something
783
  // of the user's would sit under the entry's name, a regular file to every
784
  // check by name). Opened without following, compared by identity. What is
785
  // under the name then is reported and left: removing it by name could
786
  // take another writer's file that landed there since the check.
787
  QFile placed;
165 ✔
788
  if (!openRegularFile(path, placed) || !(identityOf(placed) == written)) {
165 ✔
789
    if (error)
4 ✔
790
      *error =
791
          QCoreApplication::translate(
4 ✔
792
              "Util", "%1 was swapped for another file while it was written.")
793
              .arg(path);
8 ✔
794
    return false;
4 ✔
795
  }
796
  return true;
797
}
165 ✔
798

799
auto Util::writeFileReplacing(const QString &path, const QByteArray &bytes,
75 ✔
800
                              bool replace, QString *error) -> bool {
801
  return stageFileReplacing(
75 ✔
802
      path, replace,
803
      [&path, &bytes](QFileDevice &staged) -> QString {
73 ✔
804
        if (staged.write(bytes) != bytes.size()) {
73 ✔
805
          return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
806
              .arg(path, staged.errorString());
×
807
        }
808
        return {};
809
      },
810
      error);
75 ✔
811
}
812

813
auto Util::copyFileReplacing(const QString &src, const QString &dst,
103 ✔
814
                             bool replace, QString *error) -> bool {
815
  QFile in;
103 ✔
816
  if (!openRegularFile(src, in)) {
103 ✔
817
    if (error)
3 ✔
818
      *error = QCoreApplication::translate("Util", "Cannot read %1.").arg(src);
6 ✔
819
    return false;
3 ✔
820
  }
821
  return stageFileReplacing(
100 ✔
822
      dst, replace,
823
      [&src, &dst, &in](QFileDevice &staged) -> QString {
294 ✔
824
        char buf[64 * 1024];
825
        for (;;) {
826
          const qint64 n = in.read(buf, sizeof buf);
200 ✔
827
          if (n < 0) {
200 ✔
828
            return QCoreApplication::translate("Util", "Cannot read %1: %2")
×
829
                .arg(src, in.errorString());
×
830
          }
831
          if (n == 0) {
200 ✔
832
            return {};
833
          }
834
          if (staged.write(buf, n) != n) {
106 ✔
835
            return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
836
                .arg(dst, staged.errorString());
×
837
          }
838
        }
839
      },
840
      error);
841
}
103 ✔
842

843
auto Util::removeTree(const QString &dir) -> bool {
19 ✔
844
  // A trailing separator makes lstat follow a link ("link/" is the target
845
  // directory); the link itself is what this is about.
846
  const QString path = QDir::cleanPath(dir);
19 ✔
847
  const QFileInfo top(path);
19 ✔
848
  if (isLink(top)) {
19 ✔
849
    // rm -rf on a link removes the link.
850
    return QFile::remove(path) || QDir().rmdir(path);
14 ✔
851
  }
852
  if (!top.isDir()) {
7 ✔
853
    return false;
854
  }
855
  bool ok = true;
856
  const QFileInfoList entries =
857
      QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
12 ✔
858
                                   QDir::System | QDir::NoDotAndDotDot,
859
                               QDir::Name);
6 ✔
860
  for (const QFileInfo &entry : entries) {
18 ✔
861
    const QString entryPath = entry.filePath();
12 ✔
862
    if (isLink(entry)) {
12 ✔
863
      // The entry itself, never the target. A junction or a directory
864
      // symlink on Windows is a directory entry and goes with rmdir.
865
      if (!QFile::remove(entryPath) && !QDir().rmdir(entryPath)) {
6 ✔
866
        qCWarning(lcQtPass) << "Could not remove link" << entryPath;
2 ✔
867
        ok = false;
868
      }
869
    } else if (entry.isDir()) {
7 ✔
870
      ok = removeTree(entryPath) && ok;
1 ✔
871
    } else if (!QFile::remove(entryPath)) {
6 ✔
872
      // A read-only file blocks deletion on Windows; give it write access
873
      // and try once more, as QDir::removeRecursively() does.
874
      const QFile::Permissions perms = QFile::permissions(entryPath);
1 ✔
875
      if (perms.testFlag(QFile::WriteUser) ||
1 ✔
876
          !QFile::setPermissions(entryPath, perms | QFile::WriteUser) ||
2 ✔
877
          !QFile::remove(entryPath)) {
1 ✔
878
        qCWarning(lcQtPass) << "Could not remove" << entryPath;
2 ✔
879
        ok = false;
880
      }
881
    }
882
  }
883
  return ok && QDir().rmdir(path);
11 ✔
884
}
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