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

IJHack / QtPass / 41284697913

01 Nov 2026 07:25AM UTC coverage: 92.69% (+0.03%) from 92.659%
41284697913

push

github

web-flow
Git pin: honour safe.directory for a store another user owns (#2102)

* Git pin: honour safe.directory for a store another user owns

When the store's parent path holds git's list separator, QtPass pins
GIT_DIR to the store's own .git, which skips git's ownership check; on
Unix it therefore repeats that check and refuses another user's store.
It did not consult safe.directory, so such a store got no Git even when
the user trusted it that way.

Read safe.directory as git does, only for a store that is not the user's
own: from system, global and command-line configuration (never the
repository's own, which cannot vouch for itself; the query runs with a
GIT_DIR that cannot exist and keeps only those scopes), in order. "*"
trusts every repository, an empty value forgets the entries before it, a
path ending in "/*" trusts what lies below it, any other absolute path
trusts that one folder after resolving links; relative entries and paths
that do not exist trust nothing.

tst_util's git config is isolated now, so the user's own safe.directory
cannot trust the stores the tests hand to another user.

Tracked in #2094.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJygpMUf461yZgfAqwMJ6d
Signed-off-by: Anne Jan Brouwer <brouwer@annejan.com>

* SECURITY: keep the Git pin bullet under the line limit

Move the note on where safe.directory counts into the next bullet; the
first one ran to 451 characters (MD013 allows 400).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJygpMUf461yZgfAqwMJ6d
Signed-off-by: Anne Jan Brouwer <brouwer@annejan.com>

* Git pin: leave GIT_CONFIG out of the safe.directory query

git config alone reads GIT_CONFIG as if it were --file, in place of the
system and global configuration, and reports it as "command"; git's
ownership check never reads it. Inherited, it could trust a store git
refuses and hide the entries git does ... (continued)

50 of 51 new or added lines in 3 files covered. (98.04%)

10080 of 10875 relevant lines covered (92.69%)

397.57 hits per line

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

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

4
#include "util.h"
5
#include "appsettings.h"
6
#include "executor.h"
7
#include <QCoreApplication>
8
#include <QDebug>
9
#include <QDir>
10
#include <QFile>
11
#include <QFileDevice>
12
#include <QFileInfo>
13
#include <QHash>
14
#include <QRegularExpression>
15
#include <QRegularExpressionMatchIterator>
16
#include <QStandardPaths>
17
#include <QTemporaryFile>
18
#include <QUrl>
19

20
#include <optional>
21

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

35
#include "qtpasslogging.h"
36

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

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

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

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

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

101
void Util::applyPassProcessDefaults() {
1 ✔
102
  // pass unsets these before it runs git: a GIT_DIR inherited from the
103
  // environment (QtPass started from a git hook, or an IDE terminal) would
104
  // send every commit and push to that repository instead of the store.
105
  for (const char *variable :
10 ✔
106
       {"GIT_DIR", "GIT_WORK_TREE", "GIT_NAMESPACE", "GIT_INDEX_FILE",
107
        "GIT_INDEX_VERSION", "GIT_OBJECT_DIRECTORY", "GIT_COMMON_DIR",
108
        // QtPass sets GIT_LITERAL_PATHSPECS for its git; git refuses a
109
        // pathspec when one of these is set as well.
110
        "GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS"}) {
11 ✔
111
    qunsetenv(variable);
10 ✔
112
  }
113
  Executor::setChildUmask(
1 ✔
114
      passUmask(qEnvironmentVariable("PASSWORD_STORE_UMASK")));
1 ✔
115
}
1 ✔
116

117
auto Util::passUmask(const QString &value) -> int {
11 ✔
118
  // Taken as it is, as bash's umask builtin (which pass hands it to) takes
119
  // it: " 022" is no umask there either. pass then keeps the session's;
120
  // QtPass falls back to 077.
121
  // anchoredPattern(), not ^...$: $ also matches before a final newline.
122
  static const QRegularExpression octal(
123
      QRegularExpression::anchoredPattern(QStringLiteral("[0-7]{1,4}")));
13 ✔
124
  if (!octal.match(value).hasMatch()) {
11 ✔
125
    return 077;
126
  }
127
  return value.toInt(nullptr, 8);
4 ✔
128
}
129

130
auto Util::gitCeiling(const QString &store) -> QString {
1,060 ✔
131
  if (store.isEmpty()) {
1,060 ✔
132
    return {};
133
  }
134
  QString path = QFileInfo(store).canonicalFilePath();
1,060 ✔
135
  if (path.isEmpty()) {
1,060 ✔
136
    path = QDir::cleanPath(QDir(store).absolutePath());
×
137
  }
138
  return QFileInfo(path).absolutePath();
1,060 ✔
139
}
140

141
namespace {
142
/// setGitOwnerForTests(): who owns "the user's own" store, when set.
143
std::optional<uint> s_gitOwnerForTests;
144
} // namespace
145

146
void Util::setGitOwnerForTests(std::optional<uint> user) {
4 ✔
147
  s_gitOwnerForTests = user;
4 ✔
148
}
4 ✔
149

150
#ifdef Q_OS_UNIX
151
namespace {
152
/**
153
 * @brief Whether git's `safe.directory` trusts the repository at @p root,
154
 * as git's ownership check would judge it in place of ownership.
155
 *
156
 * Read as git reads it: only from system, global and command-line
157
 * configuration (a repository cannot vouch for itself), in order. `*`
158
 * trusts every repository, an empty value forgets the entries before it,
159
 * a path ending in a slash and `*` trusts what lies below it, and any other
160
 * absolute path trusts that one folder, compared after resolving links.
161
 * Relative entries (including git's ".") and paths that do not exist trust
162
 * nothing.
163
 * @param root The store's canonical path.
164
 * @param git The git executable; "git" from PATH when empty.
165
 * @return True when an entry names @p root; false when none does or git
166
 *         cannot be asked.
167
 */
168
auto isSafeDirectory(const QString &root, const QString &git) -> bool {
23 ✔
169
  // Outside any repository, as git reads its protected config: no
170
  // repository's own config, and no includeIf "gitdir:" or "onbranch:"
171
  // that matches (a GIT_DIR, even one that cannot exist, would be matched
172
  // against them, and one that cannot exist makes git config fail). From
173
  // "/", which a user cannot make a repository; a local scope is dropped
174
  // below in any case.
175
  QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
23 ✔
176
  env.remove(QStringLiteral("GIT_DIR"));
46 ✔
177
  env.remove(QStringLiteral("GIT_WORK_TREE"));
46 ✔
178
  env.remove(QStringLiteral("GIT_COMMON_DIR"));
46 ✔
179
  // git config alone reads GIT_CONFIG in place of the system and global
180
  // configuration (and reports it as "command"); the ownership check never
181
  // reads it.
182
  env.remove(QStringLiteral("GIT_CONFIG"));
46 ✔
183
  QProcess process;
23 ✔
184
  process.setProcessEnvironment(env);
23 ✔
185
  process.setWorkingDirectory(QStringLiteral("/"));
46 ✔
186
  // Bounded: Git calls wait for this, and a git that never answers trusts
187
  // nothing.
188
  constexpr int kQueryTimeoutMs = 5000;
189
  process.start(
184 ✔
190
      Executor::resolveExecutable(git.isEmpty() ? QStringLiteral("git") : git),
69 ✔
191
      {QStringLiteral("config"), QStringLiteral("--show-scope"),
23 ✔
192
       QStringLiteral("--type=path"), QStringLiteral("--get-all"),
23 ✔
193
       QStringLiteral("safe.directory")});
23 ✔
194
  if (!process.waitForStarted(kQueryTimeoutMs)) {
23 ✔
195
    return false;
196
  }
197
  process.closeWriteChannel();
23 ✔
198
  if (!process.waitForFinished(kQueryTimeoutMs)) {
23 ✔
199
    process.kill();
1 ✔
200
    process.waitForFinished(1000);
1 ✔
201
    return false;
202
  }
203
  if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) {
22 ✔
204
    return false;
8 ✔
205
  }
206
  const QString out = QString::fromUtf8(process.readAllStandardOutput());
28 ✔
207
  bool safe = false;
208
  const QStringList lines = out.split(QLatin1Char('\n'));
14 ✔
209
  for (const QString &line : lines) {
47 ✔
210
    const qsizetype tab = line.indexOf(QLatin1Char('\t'));
33 ✔
211
    if (tab < 0) {
33 ✔
212
      continue;
18 ✔
213
    }
214
    const QString scope = line.left(tab);
19 ✔
215
    if (scope != QLatin1String("system") && scope != QLatin1String("global") &&
39 ✔
216
        scope != QLatin1String("command")) {
19 ✔
NEW
217
      continue;
×
218
    }
219
    const QString value = line.mid(tab + 1);
19 ✔
220
    if (value.isEmpty()) {
19 ✔
221
      safe = false;
222
    } else if (value == QLatin1String("*")) {
17 ✔
223
      safe = true;
224
    } else if (!QDir::isAbsolutePath(value)) {
13 ✔
225
      continue;
3 ✔
226
    } else if (value.endsWith(QLatin1String("/*"))) {
10 ✔
227
      const QString base = value.chopped(2);
228
      QString prefix = QFileInfo(base.isEmpty() ? QStringLiteral("/") : base)
12 ✔
229
                           .canonicalFilePath();
3 ✔
230
      if (prefix.isEmpty()) {
3 ✔
231
        continue;
232
      }
233
      if (!prefix.endsWith(QLatin1Char('/'))) {
2 ✔
234
        prefix += QLatin1Char('/');
2 ✔
235
      }
236
      safe = safe || root.startsWith(prefix);
2 ✔
237
    } else {
238
      const QString named = QFileInfo(value).canonicalFilePath();
7 ✔
239
      safe = safe || (!named.isEmpty() && named == root);
7 ✔
240
    }
241
  }
242
  return safe;
243
}
23 ✔
244
} // namespace
245
#endif
246

247
auto Util::gitStoreVariables(const QString &store, const QString &git,
1,060 ✔
248
                             std::optional<uint> asUser)
249
    -> QList<std::pair<QString, QString>> {
250
  const QString ceiling = gitCeiling(store);
1,060 ✔
251
  if (ceiling.isEmpty()) {
1,060 ✔
252
    return {};
×
253
  }
254
  QList<std::pair<QString, QString>> vars{
255
      {QStringLiteral("GIT_CEILING_DIRECTORIES"), ceiling}};
3,180 ✔
256
#ifdef Q_OS_WIN
257
  const QChar separator = QLatin1Char(';');
258
#else
259
  const QChar separator = QLatin1Char(':');
260
#endif
261
  // git splits the ceiling list on the separator: "/home/a:b" would be
262
  // "/home/a" and "b", neither of them the parent, and git would climb.
263
  if (ceiling.contains(separator)) {
1,060 ✔
264
    QString root = QFileInfo(store).canonicalFilePath();
28 ✔
265
    if (root.isEmpty()) {
28 ✔
266
      root = QDir::cleanPath(QDir(store).absolutePath());
×
267
    }
268
    QString gitDir = QDir(root).filePath(QStringLiteral(".git"));
56 ✔
269
#ifdef Q_OS_UNIX
270
    // What discovery's ownership check would have looked at: the work tree,
271
    // the .git, and the directory a gitfile names. Not the user's: no
272
    // repository at all, under a path that cannot be created (its parent
273
    // is a device), rather than another user's hooks run as this one.
274
    const uint me = asUser.value_or(
275
        s_gitOwnerForTests.value_or(static_cast<uint>(::geteuid())));
28 ✔
276
    const auto ownedOrMissing = [me](const QString &path) {
33 ✔
277
      const QFileInfo info(path);
33 ✔
278
      return !info.exists() || info.ownerId() == me;
66 ✔
279
    };
33 ✔
280
    bool owned = ownedOrMissing(root) && ownedOrMissing(gitDir);
28 ✔
281
    if (owned && QFileInfo(gitDir).isFile()) {
28 ✔
282
      QFile gitFile(gitDir);
×
283
      if (gitFile.open(QIODevice::ReadOnly)) {
×
284
        const QString line =
285
            QString::fromUtf8(gitFile.readLine(4096)).trimmed();
×
286
        if (line.startsWith(QLatin1String("gitdir:"))) {
×
287
          const QString target = line.mid(7).trimmed();
×
288
          owned = ownedOrMissing(QDir(root).absoluteFilePath(target));
×
289
        }
290
      }
291
    }
×
292
    // Or trusted as git would trust it in place of ownership.
293
    if (!owned && !isSafeDirectory(root, git)) {
28 ✔
294
      gitDir = QStringLiteral("/dev/null/.git");
14 ✔
295
    }
296
#else
297
    // Git for Windows runs the same ownership check during discovery, on
298
    // SIDs, with its own rules for the Administrators group; this pin skips
299
    // it there and does not repeat it (a known gap, #2089).
300
    Q_UNUSED(git);
301
    Q_UNUSED(asUser);
302
#endif
303
    vars.append({QStringLiteral("GIT_DIR"), gitDir});
56 ✔
304
    vars.append({QStringLiteral("GIT_WORK_TREE"), root});
56 ✔
305
  }
306
  return vars;
307
}
1,060 ✔
308

309
auto Util::mkpathForStore(const QString &path) -> bool {
54 ✔
310
  const QString clean = QDir::cleanPath(QDir(path).absolutePath());
108 ✔
311
  if (QFileInfo(clean).isDir()) {
54 ✔
312
    return true;
313
  }
314
  const QString parent = QFileInfo(clean).absolutePath();
31 ✔
315
  if (parent != clean && !mkpathForStore(parent)) {
31 ✔
316
    return false;
317
  }
318
#ifdef Q_OS_UNIX
319
  // The store's umask, as git and gpg get it: a store shared with a group
320
  // (PASSWORD_STORE_UMASK=007) gets group folders, the default owner-only.
321
  const int umask = Executor::childUmask() >= 0 ? Executor::childUmask() : 077;
28 ✔
322
  QFile::Permissions permissions;
323
  const QFile::Permission bits[] = {
28 ✔
324
      QFile::ReadOwner, QFile::WriteOwner, QFile::ExeOwner,
325
      QFile::ReadGroup, QFile::WriteGroup, QFile::ExeGroup,
326
      QFile::ReadOther, QFile::WriteOther, QFile::ExeOther};
327
  for (int i = 0; i < 9; ++i) {
280 ✔
328
    if ((umask & (0400 >> i)) == 0) {
252 ✔
329
      permissions |= bits[i];
90 ✔
330
    }
331
  }
332
  // mkdir() still takes QtPass's own umask off (a session's 022 would drop
333
  // the group's write bit); set the mode exactly afterwards. That only ever
334
  // widens it to what was asked, never past.
335
  const bool made = QDir().mkdir(clean, permissions) &&
56 ✔
336
                    QFile::setPermissions(clean, permissions);
23 ✔
337
#else
338
  // Windows: permissions here would become an ACL of their own, replacing
339
  // the inherited one; the folder takes its parent's, as before.
340
  const bool made = QDir().mkdir(clean);
341
#endif
342
  return made || QFileInfo(clean).isDir();
28 ✔
343
}
344

345
auto Util::findBinaryInPath(const QString &binary) -> QString {
394 ✔
346
  if (binary.isEmpty()) {
394 ✔
347
    return {};
348
  }
349

350
  initialiseEnvironment();
393 ✔
351

352
  const QStringList dirs =
353
      _env.value(QStringLiteral("PATH"))
786 ✔
354
          .split(QDir::listSeparator(), Qt::SkipEmptyParts);
393 ✔
355
  QString ret;
393 ✔
356
  if (QDir::fromNativeSeparators(binary).contains(u'/')) {
393 ✔
357
    // An explicit path is not a PATH search: an absolute path is checked
358
    // as-is, a relative one is resolved against the PATH directories. The
359
    // directory-list overload refuses such names, so handle them here.
360
    if (QDir::isAbsolutePath(binary)) {
3 ✔
361
      ret = QStandardPaths::findExecutable(binary);
4 ✔
362
    } else if (!dirs.isEmpty()) {
1 ✔
363
      ret = QStandardPaths::findExecutable(binary, dirs);
2 ✔
364
    }
365
  } else {
366
    ret = findBinaryInPath(binary, dirs);
780 ✔
367
  }
368
#ifdef Q_OS_WIN
369
  if (ret.isEmpty()) {
370
    // Cache per-binary WSL lookup result — the wsl --version probe is a
371
    // blocking subprocess that can run several times per session for
372
    // missing binaries; once decided, the answer doesn't change at runtime.
373
    static QHash<QString, QString> wslBinaryCache;
374
    const bool hasWhitespace =
375
        std::any_of(binary.cbegin(), binary.cend(),
376
                    [](const QChar ch) { return ch.isSpace(); });
377
    if (!hasWhitespace) {
378
      auto cached = wslBinaryCache.constFind(binary);
379
      if (cached != wslBinaryCache.constEnd()) {
380
        ret = cached.value();
381
      } else {
382
        QString wslCommand = QStringLiteral("wsl ") + binary;
383
        qCDebug(lcQtPass)
384
            << "Util::findBinaryInPath(): falling back to WSL for binary"
385
            << binary;
386
        QString out, err;
387
        QString cachedResult;
388
        if (Executor::executeBlocking(wslCommand, {"--version"}, &out, &err) ==
389
                0 &&
390
            !out.isEmpty() && err.isEmpty()) {
391
          qCDebug(lcQtPass)
392
              << "Util::findBinaryInPath(): using WSL binary" << wslCommand;
393
          cachedResult = wslCommand;
394
        }
395
        wslBinaryCache.insert(binary, cachedResult);
396
        ret = cachedResult;
397
      }
398
    }
399
  }
400
#endif
401

402
  return ret;
403
}
404

405
// Bare names only: QStandardPaths::findExecutable() returns an absolute binary
406
// without consulting searchPaths, and a relative one with ".." could escape
407
// them.
408
auto Util::findBinaryInPath(const QString &binary,
404 ✔
409
                            const QStringList &searchPaths) -> QString {
410
  if (binary.isEmpty() || QDir::fromNativeSeparators(binary).contains(u'/')) {
807 ✔
411
    return {};
412
  }
413
  QStringList dirs;
400 ✔
414
  dirs.reserve(searchPaths.size());
400 ✔
415
  for (const QString &dir : searchPaths) {
5,916 ✔
416
    if (!dir.isEmpty()) {
5,516 ✔
417
      dirs.append(dir);
418
    }
419
  }
420
  if (dirs.isEmpty()) {
400 ✔
421
    // QStandardPaths::findExecutable() treats an empty list as "use PATH".
422
    return {};
423
  }
424
  return QStandardPaths::findExecutable(binary, dirs);
396 ✔
425
}
426

427
auto Util::configIsValid(const AppSettings &s) -> bool {
204 ✔
428
  const QString configFilePath = QDir(s.passStore).filePath(".gpg-id");
408 ✔
429
  if (!QFile(configFilePath).exists()) {
204 ✔
430
    return false;
431
  }
432

433
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
132 ✔
434

435
  if (const auto wsl = Executor::parseWslCommand(executable)) {
132 ✔
436
    // Probe WSL once per session — availability doesn't change at runtime
437
    // and the executeBlocking call is a blocking subprocess.
438
    static const bool wslAvailable = [&wsl]() {
1 ✔
439
      QString out;
1 ✔
440
      QString err;
1 ✔
441
      return Executor::executeBlocking(wsl->launcher,
3 ✔
442
                                       {QStringLiteral("--version")}, &out,
1 ✔
443
                                       &err) == 0 &&
×
444
             !out.isEmpty() && err.isEmpty();
2 ✔
445
    }();
1 ✔
446
    if (wslAvailable) {
1 ✔
447
      return true;
448
    }
449
  }
450
  return QFile(executable).exists();
132 ✔
451
}
452

453
auto Util::endsWithGpg() -> const QRegularExpression & {
6,906 ✔
454
  static const QRegularExpression expr{R"(\.gpg$)"};
6,906 ✔
455
  return expr;
6,906 ✔
456
}
457

458
// Stops at whitespace, quotes and brackets so a URL on its own line keeps no
459
// line break. file:/// is excluded on purpose (local, not network); the test
460
// relies on that.
461
auto Util::protocolRegex() -> const QRegularExpression & {
147 ✔
462
  static const QRegularExpression regex{
463
      R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
147 ✔
464
  return regex;
147 ✔
465
}
466

467
auto Util::isLaunchableWebUrl(const QString &value) -> bool {
97 ✔
468
  const QString trimmed = value.trimmed();
469
  if (trimmed.isEmpty()) {
97 ✔
470
    return false;
471
  }
472
  // Reject control characters first, before QUrl normalisation can hide a
473
  // CR/LF/NUL injection into the OS URL handler.
474
  for (const QChar &c : trimmed) {
2,186 ✔
475
    if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
476
        c == QChar(QChar::Null)) {
477
      return false;
478
    }
479
  }
480
  const QUrl url(trimmed, QUrl::StrictMode);
93 ✔
481
  if (!url.isValid()) {
93 ✔
482
    return false;
483
  }
484
  const QString scheme = url.scheme().toLower();
166 ✔
485
  if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
161 ✔
486
    return false;
28 ✔
487
  }
488
  if (url.host().isEmpty()) {
110 ✔
489
    return false;
490
  }
491
  // Embedded userinfo (user:pass@host) would leak into browser history.
492
  if (!url.userName().isEmpty() || !url.password().isEmpty()) {
103 ✔
493
    return false;
494
  }
495
  return true;
496
}
93 ✔
497

498
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
143 ✔
499
  if (linked != nullptr) {
143 ✔
500
    *linked = false;
98 ✔
501
  }
502
  QString html;
143 ✔
503
  html.reserve(text.size());
143 ✔
504
  qsizetype lastIndex = 0;
505
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
143 ✔
506
  while (it.hasNext()) {
183 ✔
507
    const QRegularExpressionMatch match = it.next();
40 ✔
508
    const QString url = match.captured(0);
40 ✔
509
    if (!isLaunchableWebUrl(url)) {
40 ✔
510
      // Not a web URL (or it carries credentials): leave it in the escaped
511
      // plain-text run instead of making it clickable.
512
      continue;
513
    }
514
    const qsizetype start = match.capturedStart(0);
27 ✔
515
    html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
27 ✔
516
    const QString escapedUrl = url.toHtmlEscaped();
27 ✔
517
    html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
54 ✔
518
    lastIndex = match.capturedEnd(0);
27 ✔
519
    if (linked != nullptr) {
27 ✔
520
      *linked = true;
21 ✔
521
    }
522
  }
40 ✔
523
  html += text.mid(lastIndex).toHtmlEscaped();
143 ✔
524
  return html;
143 ✔
525
}
143 ✔
526

527
auto Util::newLinesRegex() -> const QRegularExpression & {
252 ✔
528
  static const QRegularExpression regex{"[\r\n]"};
252 ✔
529
  return regex;
252 ✔
530
}
531

532
// No content heuristics: a line rejected here is erased the next time .gpg-id
533
// is rewritten. A leading `-` is refused because the recipients also go
534
// positionally to `gpg --list-keys`, where it would parse as an option.
535
auto Util::isValidKeyId(const QString &keyId) -> bool {
188 ✔
536
  return !keyId.isEmpty() && !keyId.startsWith('-');
188 ✔
537
}
538

539
namespace {
540
/**
541
 * @brief Walk @p dir's real, visible directories in sorted pre-order and hand
542
 * every entry to @p visit before any recursion; a directory is entered only
543
 * when @p visit returns true for it.
544
 */
545
template <typename Visit> void walkStore(const QString &dir, Visit visit) {
204 ✔
546
  // Absolute, so the results are whatever the caller's cwd; not canonical,
547
  // so a linked root keeps the name it was configured under.
548
  QStringList pending{QDir(QDir::cleanPath(dir)).absolutePath()};
612 ✔
549
  while (!pending.isEmpty()) {
742 ✔
550
    const QDir current(pending.takeLast());
538 ✔
551
    // Every entry once, links and hidden entries included, so the decision
552
    // what to do with each is taken here, before any recursion. QDir::System
553
    // keeps dangling links in the listing.
554
    const QFileInfoList entries =
269 ✔
555
        current.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
556
                                  QDir::System | QDir::NoDotAndDotDot,
557
                              QDir::Name);
558
    QStringList subdirs;
269 ✔
559
    for (const QFileInfo &entry : entries) {
1,488 ✔
560
      if (visit(entry)) {
1,219 ✔
561
        subdirs << entry.filePath();
130 ✔
562
      }
563
    }
564
    // Pushed last-to-first so the next one taken is the first by name.
565
    for (auto it = subdirs.crbegin(); it != subdirs.crend(); ++it) {
334 ✔
566
      pending << *it;
567
    }
568
  }
569
}
408 ✔
570

571
/// A symlink or an NTFS junction: never entered, never listed.
572
auto isLink(const QFileInfo &entry) -> bool {
3,271 ✔
573
  return entry.isSymLink() || entry.isJunction();
3,271 ✔
574
}
575

576
/// Hidden by attribute, or by the dot convention on every platform: Qt 6.11
577
/// on macOS answers isHidden() from the UF_HIDDEN flag alone once an entry
578
/// was lstat()ed (qfilesystemengine_unix.cpp marks the attribute known there
579
/// without the dot check), and Windows does not consider .stversions hidden
580
/// at all.
581
auto isHiddenEntry(const QFileInfo &entry) -> bool {
620 ✔
582
  return entry.isHidden() || entry.fileName().startsWith(QLatin1Char('.'));
1,225 ✔
583
}
584

585
/// A real directory that is part of the store: .git, .stversions,
586
/// .Trash-1000 are not, as with QDirIterator without QDir::Hidden.
587
auto isStoreDirectory(const QFileInfo &entry) -> bool {
1,219 ✔
588
  return !isLink(entry) && entry.isDir() && !isHiddenEntry(entry);
1,219 ✔
589
}
590
} // namespace
591

592
auto Util::regularFilesUnder(const QString &dir, const QStringList &nameFilters,
201 ✔
593
                             QStringList *skipped, bool hiddenFiles)
594
    -> QStringList {
595
  QStringList files;
201 ✔
596
  walkStore(dir, [&](const QFileInfo &entry) {
201 ✔
597
    if (isStoreDirectory(entry)) {
1,165 ✔
598
      return true;
599
    }
600
    const bool named = QDir::match(nameFilters, entry.fileName());
1,118 ✔
601
    if (isLink(entry) || (!entry.isDir() && !entry.isFile())) {
1,118 ✔
602
      // A linked directory hides everything behind it; a linked file, or a
603
      // FIFO, socket or device, is only of interest under a name the caller
604
      // asked for.
605
      if (skipped != nullptr && (entry.isDir() || named)) {
25 ✔
606
        qCWarning(lcQtPass) << "Skipping" << entry.filePath()
24 ✔
607
                            << ": not a regular file or directory";
12 ✔
608
        *skipped << entry.filePath();
24 ✔
609
      }
610
      return false;
25 ✔
611
    }
612
    if (entry.isFile() && named && (hiddenFiles || !isHiddenEntry(entry))) {
1,093 ✔
613
      files << entry.filePath();
1,120 ✔
614
    }
615
    return false;
616
  });
617
  return files;
201 ✔
618
}
619

620
auto Util::directoriesUnder(const QString &dir) -> QStringList {
3 ✔
621
  QStringList dirs;
3 ✔
622
  walkStore(dir, [&](const QFileInfo &entry) {
3 ✔
623
    if (!isStoreDirectory(entry)) {
54 ✔
624
      return false;
625
    }
626
    dirs << entry.filePath();
18 ✔
627
    return true;
18 ✔
628
  });
629
  return dirs;
3 ✔
630
}
631

632
auto Util::isLinkedFolder(const QString &path) -> bool {
897 ✔
633
  return isLink(QFileInfo(QDir::cleanPath(path)));
1,794 ✔
634
}
635

636
auto Util::isUnderLink(const QString &path, const QString &storeRoot,
597 ✔
637
                       bool includeSelf) -> bool {
638
  // "C:/Store" and "c:/store" are one directory on Windows; a path spelled
639
  // the other way must not skip the walk (as in Pass::getGpgIdPath).
640
#ifdef Q_OS_WIN
641
  constexpr auto cs = Qt::CaseInsensitive;
642
#else
643
  constexpr auto cs = Qt::CaseSensitive;
644
#endif
645
  const QString root = QDir::cleanPath(storeRoot);
597 ✔
646
  // A root of "/" or "C:/" already ends in the separator.
647
  const QString prefix =
648
      root.endsWith(QLatin1Char('/')) ? root : root + QLatin1Char('/');
597 ✔
649
  const auto isRoot = [&](const QString &p) {
650
    return p.compare(root, cs) == 0;
1,033 ✔
651
  };
652
  QString current = QDir::cleanPath(path);
597 ✔
653
  if (!current.startsWith(prefix, cs)) {
597 ✔
654
    // Not under the store as named: only the entry itself can be judged.
655
    return includeSelf && !isRoot(current) && isLinkedFolder(current);
392 ✔
656
  }
657
  if (!includeSelf) {
401 ✔
658
    current = QFileInfo(current).path();
54 ✔
659
  }
660
  for (; !isRoot(current) && current.startsWith(prefix, cs);
837 ✔
661
       current = QFileInfo(current).path()) {
872 ✔
662
    if (isLinkedFolder(current)) {
473 ✔
663
      return true;
664
    }
665
  }
666
  return false;
667
}
668

669
#ifndef Q_OS_WIN
670
namespace {
671

672
/// The filesystem cannot make hard links at all (FAT/exFAT refuse with
673
/// EPERM, some FUSE filesystems with ENOTSUP or ENOSYS).
674
auto hasNoHardLinks(int error) -> bool {
675
  return error == EPERM || error == ENOTSUP || error == EOPNOTSUPP ||
2 ✔
676
         error == ENOSYS;
7 ✔
677
}
678

679
/// rename() that fails with EEXIST instead of replacing, where the platform
680
/// has one; nothing when neither the platform nor the filesystem does.
681
auto renameExclusive(const QByteArray &source, const QByteArray &target)
8 ✔
682
    -> std::optional<bool> {
683
#if defined(__GLIBC__) &&                                                      \
684
    (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 28))
685
  if (::renameat2(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(),
8 ✔
686
                  RENAME_NOREPLACE) == 0) {
687
    return true;
2 ✔
688
  }
689
  if (errno == EINVAL || errno == ENOSYS || hasNoHardLinks(errno)) {
6 ✔
690
    return std::nullopt;
4 ✔
691
  }
692
  return false;
2 ✔
693
#elif defined(Q_OS_MACOS)
694
  if (::renamex_np(source.constData(), target.constData(), RENAME_EXCL) == 0) {
695
    return true;
696
  }
697
  if (errno == EINVAL || errno == ENOTSUP) {
698
    return std::nullopt;
699
  }
700
  return false;
701
#else
702
  Q_UNUSED(source);
703
  Q_UNUSED(target);
704
  return std::nullopt;
705
#endif
706
}
707

708
/**
709
 * @brief Gives @p source the name @p target unless something already has it.
710
 *
711
 * A hard link is the one way every POSIX system can do that in one step:
712
 * linkat() fails with EEXIST rather than replacing. Where the filesystem has
713
 * no hard links, an exclusive rename does it; where that is missing too, the
714
 * name is checked and then renamed, which leaves a short window for another
715
 * writer to create it in between.
716
 */
717
auto placeWithoutReplacing(const QByteArray &source, const QByteArray &target)
87 ✔
718
    -> bool {
719
  // linkat() without AT_SYMLINK_FOLLOW follows nothing; link() would, on
720
  // macOS and the BSDs, hard-link whatever a symlink planted under the
721
  // source's name points at.
722
  if (::linkat(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(), 0) ==
87 ✔
723
      0) {
724
    ::unlink(source.constData());
72 ✔
725
    return true;
72 ✔
726
  }
727
  if (!hasNoHardLinks(errno)) {
15 ✔
728
    return false;
729
  }
730
  if (const std::optional<bool> renamed = renameExclusive(source, target)) {
8 ✔
731
    return *renamed;
4 ✔
732
  }
733
  struct stat st{};
4 ✔
734
  if (::lstat(target.constData(), &st) == 0 || errno != ENOENT) {
4 ✔
735
    errno = EEXIST;
2 ✔
736
    return false;
2 ✔
737
  }
738
  return ::rename(source.constData(), target.constData()) == 0;
2 ✔
739
}
740

741
} // namespace
742
#endif
743

744
auto Util::replaceFile(const QString &from, const QString &to, bool replace)
224 ✔
745
    -> bool {
746
#ifdef Q_OS_WIN
747
  const std::wstring source =
748
      QDir::toNativeSeparators(QFileInfo(from).absoluteFilePath())
749
          .toStdWString();
750
  const std::wstring target =
751
      QDir::toNativeSeparators(QFileInfo(to).absoluteFilePath()).toStdWString();
752
  // WRITE_THROUGH: the new entry is on the device when this returns.
753
  return MoveFileExW(source.c_str(), target.c_str(),
754
                     (replace ? MOVEFILE_REPLACE_EXISTING : 0) |
755
                         MOVEFILE_WRITE_THROUGH) != 0;
756
#else
757
  const QByteArray source = QFile::encodeName(from);
758
  const QByteArray target = QFile::encodeName(to);
759
  if (replace) {
224 ✔
760
    if (::rename(source.constData(), target.constData()) != 0) {
137 ✔
761
      return false;
762
    }
763
  } else {
764
    if (!placeWithoutReplacing(source, target)) {
87 ✔
765
      return false;
766
    }
767
  }
768
  // Sync the directory entry so a crash does not lose the new name. Best
769
  // effort: the bytes are already synced, and some network filesystems
770
  // cannot sync a directory.
771
  const int dir = ::open(QFile::encodeName(QFileInfo(to).path()).constData(),
422 ✔
772
                         O_RDONLY | O_DIRECTORY | O_CLOEXEC);
773
  if (dir >= 0) {
211 ✔
774
    int rc;
775
    do {
776
      rc = ::fsync(dir);
211 ✔
777
    } while (rc != 0 && errno == EINTR);
211 ✔
778
    ::close(dir);
211 ✔
779
  }
780
  return true;
781
#endif
782
}
783

784
auto Util::openRegularFile(const QString &path, QFile &file) -> bool {
733 ✔
785
#ifdef Q_OS_WIN
786
  // FILE_FLAG_OPEN_REPARSE_POINT opens a symbolic link or junction itself
787
  // rather than its target, so the handle's attributes say what the name
788
  // was at the moment of the open.
789
  const std::wstring native =
790
      QDir::toNativeSeparators(QFileInfo(path).absoluteFilePath())
791
          .toStdWString();
792
  HANDLE handle = CreateFileW(
793
      native.c_str(), GENERIC_READ,
794
      FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
795
      OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
796
  if (handle == INVALID_HANDLE_VALUE) {
797
    return false;
798
  }
799
  BY_HANDLE_FILE_INFORMATION info{};
800
  if (!GetFileInformationByHandle(handle, &info) ||
801
      (info.dwFileAttributes &
802
       (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY |
803
        FILE_ATTRIBUTE_DEVICE)) != 0 ||
804
      GetFileType(handle) != FILE_TYPE_DISK) {
805
    CloseHandle(handle);
806
    return false;
807
  }
808
  const int fd = _open_osfhandle(reinterpret_cast<intptr_t>(handle),
809
                                 _O_RDONLY | _O_BINARY);
810
  if (fd < 0) {
811
    CloseHandle(handle);
812
    return false;
813
  }
814
  if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
815
    _close(fd);
816
    return false;
817
  }
818
  return true;
819
#else
820
  // O_NOFOLLOW fails with ELOOP on a symbolic link; O_NONBLOCK keeps a FIFO
821
  // from blocking the open until fstat() can refuse it.
822
  const int fd = ::open(QFile::encodeName(path).constData(),
733 ✔
823
                        O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
824
  if (fd < 0) {
733 ✔
825
    return false;
826
  }
827
  struct stat st{};
683 ✔
828
  if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
683 ✔
829
    ::close(fd);
7 ✔
830
    return false;
7 ✔
831
  }
832
  // The descriptor, not the name, is what QFile opens here: the object
833
  // fstat() judged is the object read. CodeQL's check-then-use pattern
834
  // matcher sees an open after a check and cannot tell.
835
  if (!file.open(fd, QIODevice::ReadOnly, // codeql[cpp/toctou-race-condition]
676 ✔
836
                 QFileDevice::AutoCloseHandle)) {
837
    ::close(fd);
×
838
    return false;
×
839
  }
840
  return true;
841
#endif
842
}
843

844
auto Util::readRegularFile(const QString &path, qint64 maxBytes)
232 ✔
845
    -> std::optional<QByteArray> {
846
  QFile file;
232 ✔
847
  if (!openRegularFile(path, file)) {
232 ✔
848
    return std::nullopt;
11 ✔
849
  }
850
  // A regular file's size is reliable, also when sparse: refuse a large one
851
  // before reading anything. One byte past the limit still guards a file
852
  // that grows meanwhile.
853
  if (file.size() > maxBytes) {
221 ✔
854
    return std::nullopt;
3 ✔
855
  }
856
  QByteArray bytes = file.read(maxBytes + 1);
218 ✔
857
  if (bytes.size() > maxBytes || file.error() != QFileDevice::NoError) {
218 ✔
858
    return std::nullopt;
×
859
  }
860
  return bytes;
861
}
232 ✔
862

863
auto Util::syncToDisk(QFileDevice &file) -> bool {
213 ✔
864
  if (!file.flush()) {
213 ✔
865
    return false;
866
  }
867
#ifdef Q_OS_WIN
868
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
869
  return handle != INVALID_HANDLE_VALUE && FlushFileBuffers(handle) != 0;
870
#else
871
  return ::fsync(file.handle()) == 0;
213 ✔
872
#endif
873
}
874

875
namespace {
876

877
/**
878
 * @brief What tells one file object from another on its filesystem: device
879
 * and inode on POSIX, volume serial and file index on Windows.
880
 */
881
struct FileIdentity {
882
  quint64 volume = 0;
883
  quint64 index = 0;
884
  bool known = false;
885
};
886

887
auto operator==(const FileIdentity &a, const FileIdentity &b) -> bool {
202 ✔
888
  return a.known && b.known && a.volume == b.volume && a.index == b.index;
202 ✔
889
}
890

891
/// The identity of the object @p file is open on, or an unknown one.
892
auto identityOf(const QFileDevice &file) -> FileIdentity {
415 ✔
893
#ifdef Q_OS_WIN
894
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
895
  BY_HANDLE_FILE_INFORMATION info;
896
  if (handle == INVALID_HANDLE_VALUE ||
897
      GetFileInformationByHandle(handle, &info) == 0) {
898
    return {};
899
  }
900
  // On FAT/exFAT the file ID is the directory entry's offset, which a rename
901
  // can move. They have no hard links either (the swap this catches needs
902
  // one), so the identity is left unknown there.
903
  DWORD flags = 0;
904
  if (GetVolumeInformationByHandleW(handle, nullptr, 0, nullptr, nullptr,
905
                                    &flags, nullptr, 0) == 0 ||
906
      (flags & FILE_SUPPORTS_HARD_LINKS) == 0) {
907
    return {};
908
  }
909
  return {info.dwVolumeSerialNumber,
910
          (static_cast<quint64>(info.nFileIndexHigh) << 32) |
911
              info.nFileIndexLow,
912
          true};
913
#else
914
  struct stat st{};
415 ✔
915
  if (::fstat(file.handle(), &st) != 0) {
415 ✔
916
    return {};
×
917
  }
918
  return {static_cast<quint64>(st.st_dev), static_cast<quint64>(st.st_ino),
415 ✔
919
          true};
415 ✔
920
#endif
921
}
922

923
/// Put @p text in @p error when there is one; false, for `return fail(...)`.
924
auto fail(QString *error, const QString &text) -> bool {
925
  if (error != nullptr) {
21 ✔
926
    *error = text;
21 ✔
927
  }
928
  return false;
929
}
930

931
/// A filled, synced temporary next to its destination.
932
struct Staged {
647 ✔
933
  QString path;
934
  FileIdentity identity;
935
};
936

937
/**
938
 * @brief Create the temporary next to @p path (opaque name, exclusive,
939
 * owner-only), fill it through its handle and sync it. The QTemporaryFile
940
 * goes out of scope before the caller renames: it keeps its handle open for
941
 * as long as it lives, and Windows does not rename an open file.
942
 */
943
auto stageNextTo(const QString &path, const Util::Filler &fill, QString *why)
221 ✔
944
    -> std::optional<Staged> {
945
  Staged staged;
221 ✔
946
  {
947
    QTemporaryFile file(QFileInfo(path).path() +
442 ✔
948
                        QStringLiteral("/.qtpass-XXXXXX.tmp"));
442 ✔
949
    file.setAutoRemove(false);
221 ✔
950
    if (!file.open()) {
221 ✔
951
      *why = QCoreApplication::translate(
8 ✔
952
                 "Util", "Cannot create a temporary file next to %1: %2")
953
                 .arg(path, file.errorString());
16 ✔
954
      return std::nullopt;
955
    }
956
    staged.path = file.fileName();
213 ✔
957
    // Owner-only: a .gpg-id names the keys a store is encrypted to, an
958
    // entry is an entry. QTemporaryFile creates 0600 already; say so for
959
    // platforms where it may not.
960
    file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
213 ✔
961
    *why = fill(file);
426 ✔
962
    if (why->isEmpty() && !Util::syncToDisk(file)) {
213 ✔
963
      *why = QCoreApplication::translate("Util", "Cannot write %1: %2")
×
964
                 .arg(path, file.errorString());
×
965
    }
966
    staged.identity = identityOf(file);
213 ✔
967
  }
221 ✔
968
  if (!why->isEmpty()) {
213 ✔
969
    QFile::remove(staged.path);
×
970
    return std::nullopt;
×
971
  }
972
  return staged;
973
}
974

975
/// Why replaceFile() could not put the temporary under @p path.
976
auto placementError(const QString &path, bool replace) -> QString {
9 ✔
977
  if (replace) {
9 ✔
978
    return QCoreApplication::translate("Util", "Failed to replace %1.")
4 ✔
979
        .arg(path);
2 ✔
980
  }
981
  const QFileInfo taken(path);
7 ✔
982
  return taken.exists() || taken.isSymLink()
8 ✔
983
             ? QCoreApplication::translate("Util", "%1 already exists.")
7 ✔
984
                   .arg(path)
985
             : QCoreApplication::translate("Util", "Failed to write %1.")
1 ✔
986
                   .arg(path);
8 ✔
987
}
7 ✔
988

989
/**
990
 * @brief Whether what is under @p path is the file that was filled: not a
991
 * link, and not another file (a hard link to something of the user's, say)
992
 * swapped in under the temporary's name. Opened without following, compared
993
 * by identity; only two known identities can prove a swap, otherwise every
994
 * write on such a store would be reported as tampering.
995
 */
996
auto isTheFileWritten(const QString &path, const FileIdentity &written)
204 ✔
997
    -> bool {
998
  QFile placed;
204 ✔
999
  if (!Util::openRegularFile(path, placed)) {
204 ✔
1000
    return false;
1001
  }
1002
  const FileIdentity there = identityOf(placed);
202 ✔
1003
  if (!written.known || !there.known) {
202 ✔
1004
    qCWarning(lcQtPass) << "Cannot tell whether" << path
×
1005
                        << "is the file that was written here";
×
1006
    return true;
×
1007
  }
1008
  return there == written;
202 ✔
1009
}
204 ✔
1010

1011
} // namespace
1012

1013
auto Util::stageFileReplacing(const QString &path, bool replace,
221 ✔
1014
                              const Filler &fill, QString *error) -> bool {
1015
  QString why;
221 ✔
1016
  const std::optional<Staged> staged = stageNextTo(path, fill, &why);
221 ✔
1017
  if (!staged) {
221 ✔
1018
    return fail(error, why);
1019
  }
1020
  if (!replaceFile(staged->path, path, replace)) {
213 ✔
1021
    QFile::remove(staged->path);
9 ✔
1022
    return fail(error, placementError(path, replace));
18 ✔
1023
  }
1024
  // A mismatch is reported and left: removing by name could take another
1025
  // writer's file.
1026
  if (!isTheFileWritten(path, staged->identity)) {
204 ✔
1027
    return fail(
1028
        error,
1029
        QCoreApplication::translate(
4 ✔
1030
            "Util", "%1 was swapped for another file while it was written.")
1031
            .arg(path));
8 ✔
1032
  }
1033
  return true;
1034
}
1035

1036
auto Util::writeFileReplacing(const QString &path, const QByteArray &bytes,
104 ✔
1037
                              bool replace, QString *error) -> bool {
1038
  return stageFileReplacing(
104 ✔
1039
      path, replace,
1040
      [&path, &bytes](QFileDevice &staged) -> QString {
102 ✔
1041
        if (staged.write(bytes) != bytes.size()) {
102 ✔
1042
          return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
1043
              .arg(path, staged.errorString());
×
1044
        }
1045
        return {};
1046
      },
1047
      error);
104 ✔
1048
}
1049

1050
auto Util::copyFileReplacing(const QString &src, const QString &dst,
116 ✔
1051
                             bool replace, QString *error) -> bool {
1052
  QFile in;
116 ✔
1053
  if (!openRegularFile(src, in)) {
116 ✔
1054
    if (error)
4 ✔
1055
      *error = QCoreApplication::translate("Util", "Cannot read %1.").arg(src);
8 ✔
1056
    return false;
4 ✔
1057
  }
1058
  return stageFileReplacing(
112 ✔
1059
      dst, replace,
1060
      [&src, &dst, &in](QFileDevice &staged) -> QString {
330 ✔
1061
        char buf[64 * 1024];
1062
        for (;;) {
1063
          const qint64 n = in.read(buf, sizeof buf);
224 ✔
1064
          if (n < 0) {
224 ✔
1065
            return QCoreApplication::translate("Util", "Cannot read %1: %2")
×
1066
                .arg(src, in.errorString());
×
1067
          }
1068
          if (n == 0) {
224 ✔
1069
            return {};
1070
          }
1071
          if (staged.write(buf, n) != n) {
118 ✔
1072
            return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
1073
                .arg(dst, staged.errorString());
×
1074
          }
1075
        }
1076
      },
1077
      error);
1078
}
116 ✔
1079

1080
namespace {
1081

1082
/// Remove one directory entry that is not a real directory: a link goes as
1083
/// the entry itself, never its target (a junction or a directory symlink on
1084
/// Windows is a directory entry and goes with rmdir); a read-only file gets
1085
/// write access and one more try, as QDir::removeRecursively() does.
1086
auto removeEntry(const QFileInfo &entry) -> bool {
11 ✔
1087
  const QString path = entry.filePath();
11 ✔
1088
  if (isLink(entry)) {
11 ✔
1089
    if (QFile::remove(path) || QDir().rmdir(path)) {
6 ✔
1090
      return true;
1091
    }
1092
    qCWarning(lcQtPass) << "Could not remove link" << path;
2 ✔
1093
    return false;
1 ✔
1094
  }
1095
  if (QFile::remove(path)) {
6 ✔
1096
    return true;
1097
  }
1098
  const QFile::Permissions perms = QFile::permissions(path);
1 ✔
1099
  if (!perms.testFlag(QFile::WriteUser) &&
1 ✔
1100
      QFile::setPermissions(path, perms | QFile::WriteUser) &&
2 ✔
1101
      QFile::remove(path)) {
1 ✔
1102
    return true;
1103
  }
1104
  qCWarning(lcQtPass) << "Could not remove" << path;
2 ✔
1105
  return false;
1 ✔
1106
}
1107

1108
} // namespace
1109

1110
auto Util::removeTree(const QString &dir) -> bool {
21 ✔
1111
  // A trailing separator makes lstat follow a link ("link/" is the target
1112
  // directory); the link itself is what this is about.
1113
  const QString path = QDir::cleanPath(dir);
21 ✔
1114
  const QFileInfo top(path);
21 ✔
1115
  if (isLink(top)) {
21 ✔
1116
    // rm -rf on a link removes the link.
1117
    return QFile::remove(path) || QDir().rmdir(path);
16 ✔
1118
  }
1119
  if (!top.isDir()) {
7 ✔
1120
    return false;
1121
  }
1122
  bool ok = true;
1123
  const QFileInfoList entries =
1124
      QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
12 ✔
1125
                                   QDir::System | QDir::NoDotAndDotDot,
1126
                               QDir::Name);
6 ✔
1127
  for (const QFileInfo &entry : entries) {
18 ✔
1128
    const bool removed = entry.isDir() && !isLink(entry)
17 ✔
1129
                             ? removeTree(entry.filePath())
13 ✔
1130
                             : removeEntry(entry);
11 ✔
1131
    ok = removed && ok;
12 ✔
1132
  }
1133
  return ok && QDir().rmdir(path);
11 ✔
1134
}
21 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc