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

IJHack / QtPass / 40501301411

27 Oct 2026 12:07PM UTC coverage: 92.583% (+0.02%) from 92.562%
40501301411

push

github

web-flow
Keep git inside the store and new files private, as pass does (#2057)

* Keep git inside the store and new files private, as pass does

Two things password-store does at its start that QtPass did not:

- GIT_CEILING_DIRECTORIES stops git at the store's parent, so a store
  that is not a repository itself but lies inside one (a dotfiles
  repository in the home folder) is not committed and pushed there. Set
  in Pass's environment and in QtPass's own, which the blocking git
  calls inherit; the real parent when the store root is a link, since
  git climbs from the real path. Passed to WSL with /p.
- The GIT_DIR family is cleared at startup, so an inherited GIT_DIR or
  GIT_WORK_TREE cannot send QtPass's commits elsewhere.
- umask 077, or PASSWORD_STORE_UMASK, at startup. Entries were owner-only
  already; folders in the store and git's files under .git were not.

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>

* Settings: opt in to a Git repository above the store

A store kept inside a larger repository on purpose stopped syncing with
the ceiling. Settings > Git > "Use a Git repository in a folder above
the store" (gitUseParentRepository, off by default as in pass) drops
QtPass's ceiling and puts back whatever GIT_CEILING_DIRECTORIES QtPass
was started with. Upgrade note in the CHANGELOG.

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>

* passUmask: take the value as it is, no trailing newline either

A blank around PASSWORD_STORE_UMASK made it valid after trimming; bash's
umask, which pass hands the value to, rejects it, so QtPass now falls
back to 077 for it. The test then showed ^...$ also accepting "022\n"
($ matches before a final newline): anchoredPattern() instead.

Co-Authored-B... (continued)

78 of 81 new or added lines in 13 files covered. (96.3%)

3 existing lines in 2 files now uncovered.

9374 of 10125 relevant lines covered (92.58%)

201.08 hits per line

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

94.64
/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() {
634 ✔
42
  if (!_envInitialised) {
634 ✔
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
}
634 ✔
67

68
auto Util::findPasswordStore() -> QString {
253 ✔
69
  QString path;
253 ✔
70
  initialiseEnvironment();
253 ✔
71
  if (_env.contains("PASSWORD_STORE_DIR")) {
506 ✔
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");
750 ✔
78
#endif
79
  }
80
  return Util::normalizeFolderPath(QDir::cleanPath(path));
506 ✔
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 {
332 ✔
94
  QString normalizedPath = path;
95
  if (!normalizedPath.endsWith('/')) {
332 ✔
96
    normalizedPath += '/';
286 ✔
97
  }
98
  return normalizedPath;
332 ✔
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 :
7 ✔
106
       {"GIT_DIR", "GIT_WORK_TREE", "GIT_NAMESPACE", "GIT_INDEX_FILE",
107
        "GIT_INDEX_VERSION", "GIT_OBJECT_DIRECTORY", "GIT_COMMON_DIR"}) {
8 ✔
108
    qunsetenv(variable);
7 ✔
109
  }
110
  Executor::setChildUmask(
1 ✔
111
      passUmask(qEnvironmentVariable("PASSWORD_STORE_UMASK")));
1 ✔
112
}
1 ✔
113

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

127
auto Util::gitCeiling(const QString &store) -> QString {
281 ✔
128
  if (store.isEmpty()) {
281 ✔
129
    return {};
130
  }
131
  QString path = QFileInfo(store).canonicalFilePath();
281 ✔
132
  if (path.isEmpty()) {
281 ✔
NEW
133
    path = QDir::cleanPath(QDir(store).absolutePath());
×
134
  }
135
  return QFileInfo(path).absolutePath();
281 ✔
136
}
137

138
auto Util::mkpathForStore(const QString &path) -> bool {
48 ✔
139
  const QString clean = QDir::cleanPath(QDir(path).absolutePath());
96 ✔
140
  if (QFileInfo(clean).isDir()) {
48 ✔
141
    return true;
142
  }
143
  const QString parent = QFileInfo(clean).absolutePath();
28 ✔
144
  if (parent != clean && !mkpathForStore(parent)) {
28 ✔
145
    return false;
146
  }
147
#ifdef Q_OS_UNIX
148
  // The store's umask, as git and gpg get it: a store shared with a group
149
  // (PASSWORD_STORE_UMASK=007) gets group folders, the default owner-only.
150
  const int umask = Executor::childUmask() >= 0 ? Executor::childUmask() : 077;
25 ✔
151
  QFile::Permissions permissions;
152
  const QFile::Permission bits[] = {
25 ✔
153
      QFile::ReadOwner, QFile::WriteOwner, QFile::ExeOwner,
154
      QFile::ReadGroup, QFile::WriteGroup, QFile::ExeGroup,
155
      QFile::ReadOther, QFile::WriteOther, QFile::ExeOther};
156
  for (int i = 0; i < 9; ++i) {
250 ✔
157
    if ((umask & (0400 >> i)) == 0) {
225 ✔
158
      permissions |= bits[i];
81 ✔
159
    }
160
  }
161
  // mkdir() still takes QtPass's own umask off (a session's 022 would drop
162
  // the group's write bit); set the mode exactly afterwards. That only ever
163
  // widens it to what was asked, never past.
164
  const bool made = QDir().mkdir(clean, permissions) &&
50 ✔
165
                    QFile::setPermissions(clean, permissions);
20 ✔
166
#else
167
  // Windows: permissions here would become an ACL of their own, replacing
168
  // the inherited one; the folder takes its parent's, as before.
169
  const bool made = QDir().mkdir(clean);
170
#endif
171
  return made || QFileInfo(clean).isDir();
25 ✔
172
}
173

174
auto Util::findBinaryInPath(const QString &binary) -> QString {
382 ✔
175
  if (binary.isEmpty()) {
382 ✔
176
    return {};
177
  }
178

179
  initialiseEnvironment();
381 ✔
180

181
  const QStringList dirs =
182
      _env.value(QStringLiteral("PATH"))
762 ✔
183
          .split(QDir::listSeparator(), Qt::SkipEmptyParts);
381 ✔
184
  QString ret;
381 ✔
185
  if (QDir::fromNativeSeparators(binary).contains(u'/')) {
381 ✔
186
    // An explicit path is not a PATH search: an absolute path is checked
187
    // as-is, a relative one is resolved against the PATH directories. The
188
    // directory-list overload refuses such names, so handle them here.
189
    if (QDir::isAbsolutePath(binary)) {
3 ✔
190
      ret = QStandardPaths::findExecutable(binary);
4 ✔
191
    } else if (!dirs.isEmpty()) {
1 ✔
192
      ret = QStandardPaths::findExecutable(binary, dirs);
2 ✔
193
    }
194
  } else {
195
    ret = findBinaryInPath(binary, dirs);
756 ✔
196
  }
197
#ifdef Q_OS_WIN
198
  if (ret.isEmpty()) {
199
    // Cache per-binary WSL lookup result — the wsl --version probe is a
200
    // blocking subprocess that can run several times per session for
201
    // missing binaries; once decided, the answer doesn't change at runtime.
202
    static QHash<QString, QString> wslBinaryCache;
203
    const bool hasWhitespace =
204
        std::any_of(binary.cbegin(), binary.cend(),
205
                    [](const QChar ch) { return ch.isSpace(); });
206
    if (!hasWhitespace) {
207
      auto cached = wslBinaryCache.constFind(binary);
208
      if (cached != wslBinaryCache.constEnd()) {
209
        ret = cached.value();
210
      } else {
211
        QString wslCommand = QStringLiteral("wsl ") + binary;
212
        qCDebug(lcQtPass)
213
            << "Util::findBinaryInPath(): falling back to WSL for binary"
214
            << binary;
215
        QString out, err;
216
        QString cachedResult;
217
        if (Executor::executeBlocking(wslCommand, {"--version"}, &out, &err) ==
218
                0 &&
219
            !out.isEmpty() && err.isEmpty()) {
220
          qCDebug(lcQtPass)
221
              << "Util::findBinaryInPath(): using WSL binary" << wslCommand;
222
          cachedResult = wslCommand;
223
        }
224
        wslBinaryCache.insert(binary, cachedResult);
225
        ret = cachedResult;
226
      }
227
    }
228
  }
229
#endif
230

231
  return ret;
232
}
233

234
// Bare names only: QStandardPaths::findExecutable() returns an absolute binary
235
// without consulting searchPaths, and a relative one with ".." could escape
236
// them.
237
auto Util::findBinaryInPath(const QString &binary,
392 ✔
238
                            const QStringList &searchPaths) -> QString {
239
  if (binary.isEmpty() || QDir::fromNativeSeparators(binary).contains(u'/')) {
783 ✔
240
    return {};
241
  }
242
  QStringList dirs;
388 ✔
243
  dirs.reserve(searchPaths.size());
388 ✔
244
  for (const QString &dir : searchPaths) {
5,748 ✔
245
    if (!dir.isEmpty()) {
5,360 ✔
246
      dirs.append(dir);
247
    }
248
  }
249
  if (dirs.isEmpty()) {
388 ✔
250
    // QStandardPaths::findExecutable() treats an empty list as "use PATH".
251
    return {};
252
  }
253
  return QStandardPaths::findExecutable(binary, dirs);
384 ✔
254
}
255

256
auto Util::configIsValid(const AppSettings &s) -> bool {
196 ✔
257
  const QString configFilePath = QDir(s.passStore).filePath(".gpg-id");
392 ✔
258
  if (!QFile(configFilePath).exists()) {
196 ✔
259
    return false;
260
  }
261

262
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
128 ✔
263

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

282
auto Util::endsWithGpg() -> const QRegularExpression & {
6,537 ✔
283
  static const QRegularExpression expr{R"(\.gpg$)"};
6,537 ✔
284
  return expr;
6,537 ✔
285
}
286

287
// Stops at whitespace, quotes and brackets so a URL on its own line keeps no
288
// line break. file:/// is excluded on purpose (local, not network); the test
289
// relies on that.
290
auto Util::protocolRegex() -> const QRegularExpression & {
139 ✔
291
  static const QRegularExpression regex{
292
      R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
139 ✔
293
  return regex;
139 ✔
294
}
295

296
auto Util::isLaunchableWebUrl(const QString &value) -> bool {
81 ✔
297
  const QString trimmed = value.trimmed();
298
  if (trimmed.isEmpty()) {
81 ✔
299
    return false;
300
  }
301
  // Reject control characters first, before QUrl normalisation can hide a
302
  // CR/LF/NUL injection into the OS URL handler.
303
  for (const QChar &c : trimmed) {
1,945 ✔
304
    if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
305
        c == QChar(QChar::Null)) {
306
      return false;
307
    }
308
  }
309
  const QUrl url(trimmed, QUrl::StrictMode);
77 ✔
310
  if (!url.isValid()) {
77 ✔
311
    return false;
312
  }
313
  const QString scheme = url.scheme().toLower();
134 ✔
314
  if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
129 ✔
315
    return false;
22 ✔
316
  }
317
  if (url.host().isEmpty()) {
90 ✔
318
    return false;
319
  }
320
  // Embedded userinfo (user:pass@host) would leak into browser history.
321
  if (!url.userName().isEmpty() || !url.password().isEmpty()) {
83 ✔
322
    return false;
323
  }
324
  return true;
325
}
77 ✔
326

327
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
135 ✔
328
  if (linked != nullptr) {
135 ✔
329
    *linked = false;
88 ✔
330
  }
331
  QString html;
135 ✔
332
  html.reserve(text.size());
135 ✔
333
  qsizetype lastIndex = 0;
334
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
135 ✔
335
  while (it.hasNext()) {
170 ✔
336
    const QRegularExpressionMatch match = it.next();
35 ✔
337
    const QString url = match.captured(0);
35 ✔
338
    if (!isLaunchableWebUrl(url)) {
35 ✔
339
      // Not a web URL (or it carries credentials): leave it in the escaped
340
      // plain-text run instead of making it clickable.
341
      continue;
342
    }
343
    const qsizetype start = match.capturedStart(0);
22 ✔
344
    html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
22 ✔
345
    const QString escapedUrl = url.toHtmlEscaped();
22 ✔
346
    html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
44 ✔
347
    lastIndex = match.capturedEnd(0);
22 ✔
348
    if (linked != nullptr) {
22 ✔
349
      *linked = true;
16 ✔
350
    }
351
  }
35 ✔
352
  html += text.mid(lastIndex).toHtmlEscaped();
135 ✔
353
  return html;
135 ✔
354
}
135 ✔
355

356
auto Util::newLinesRegex() -> const QRegularExpression & {
230 ✔
357
  static const QRegularExpression regex{"[\r\n]"};
230 ✔
358
  return regex;
230 ✔
359
}
360

361
// No content heuristics: a line rejected here is erased the next time .gpg-id
362
// is rewritten. A leading `-` is refused because the recipients also go
363
// positionally to `gpg --list-keys`, where it would parse as an option.
364
auto Util::isValidKeyId(const QString &keyId) -> bool {
185 ✔
365
  return !keyId.isEmpty() && !keyId.startsWith('-');
185 ✔
366
}
367

368
namespace {
369
/**
370
 * @brief Walk @p dir's real, visible directories in sorted pre-order and hand
371
 * every entry to @p visit before any recursion; a directory is entered only
372
 * when @p visit returns true for it.
373
 */
374
template <typename Visit> void walkStore(const QString &dir, Visit visit) {
179 ✔
375
  // Absolute, so the results are whatever the caller's cwd; not canonical,
376
  // so a linked root keeps the name it was configured under.
377
  QStringList pending{QDir(QDir::cleanPath(dir)).absolutePath()};
537 ✔
378
  while (!pending.isEmpty()) {
647 ✔
379
    const QDir current(pending.takeLast());
468 ✔
380
    // Every entry once, links and hidden entries included, so the decision
381
    // what to do with each is taken here, before any recursion. QDir::System
382
    // keeps dangling links in the listing.
383
    const QFileInfoList entries =
234 ✔
384
        current.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
385
                                  QDir::System | QDir::NoDotAndDotDot,
386
                              QDir::Name);
387
    QStringList subdirs;
234 ✔
388
    for (const QFileInfo &entry : entries) {
1,037 ✔
389
      if (visit(entry)) {
803 ✔
390
        subdirs << entry.filePath();
110 ✔
391
      }
392
    }
393
    // Pushed last-to-first so the next one taken is the first by name.
394
    for (auto it = subdirs.crbegin(); it != subdirs.crend(); ++it) {
289 ✔
395
      pending << *it;
396
    }
397
  }
398
}
358 ✔
399

400
/// A symlink or an NTFS junction: never entered, never listed.
401
auto isLink(const QFileInfo &entry) -> bool {
2,315 ✔
402
  return entry.isSymLink() || entry.isJunction();
2,315 ✔
403
}
404

405
/// Hidden by attribute, or by the dot convention on every platform: Qt 6.11
406
/// on macOS answers isHidden() from the UF_HIDDEN flag alone once an entry
407
/// was lstat()ed (qfilesystemengine_unix.cpp marks the attribute known there
408
/// without the dot check), and Windows does not consider .stversions hidden
409
/// at all.
410
auto isHiddenEntry(const QFileInfo &entry) -> bool {
239 ✔
411
  return entry.isHidden() || entry.fileName().startsWith(QLatin1Char('.'));
465 ✔
412
}
413

414
/// A real directory that is part of the store: .git, .stversions,
415
/// .Trash-1000 are not, as with QDirIterator without QDir::Hidden.
416
auto isStoreDirectory(const QFileInfo &entry) -> bool {
803 ✔
417
  return !isLink(entry) && entry.isDir() && !isHiddenEntry(entry);
803 ✔
418
}
419
} // namespace
420

421
auto Util::regularFilesUnder(const QString &dir, const QStringList &nameFilters,
176 ✔
422
                             QStringList *skipped, bool hiddenFiles)
423
    -> QStringList {
424
  QStringList files;
176 ✔
425
  walkStore(dir, [&](const QFileInfo &entry) {
176 ✔
426
    if (isStoreDirectory(entry)) {
749 ✔
427
      return true;
428
    }
429
    const bool named = QDir::match(nameFilters, entry.fileName());
712 ✔
430
    if (isLink(entry) || (!entry.isDir() && !entry.isFile())) {
712 ✔
431
      // A linked directory hides everything behind it; a linked file, or a
432
      // FIFO, socket or device, is only of interest under a name the caller
433
      // asked for.
434
      if (skipped != nullptr && (entry.isDir() || named)) {
21 ✔
435
        qCWarning(lcQtPass) << "Skipping" << entry.filePath()
24 ✔
436
                            << ": not a regular file or directory";
12 ✔
437
        *skipped << entry.filePath();
24 ✔
438
      }
439
      return false;
21 ✔
440
    }
441
    if (entry.isFile() && named && (hiddenFiles || !isHiddenEntry(entry))) {
691 ✔
442
      files << entry.filePath();
382 ✔
443
    }
444
    return false;
445
  });
446
  return files;
176 ✔
447
}
448

449
auto Util::directoriesUnder(const QString &dir) -> QStringList {
3 ✔
450
  QStringList dirs;
3 ✔
451
  walkStore(dir, [&](const QFileInfo &entry) {
3 ✔
452
    if (!isStoreDirectory(entry)) {
54 ✔
453
      return false;
454
    }
455
    dirs << entry.filePath();
18 ✔
456
    return true;
18 ✔
457
  });
458
  return dirs;
3 ✔
459
}
460

461
auto Util::isLinkedFolder(const QString &path) -> bool {
764 ✔
462
  return isLink(QFileInfo(QDir::cleanPath(path)));
1,528 ✔
463
}
464

465
auto Util::isUnderLink(const QString &path, const QString &storeRoot,
446 ✔
466
                       bool includeSelf) -> bool {
467
  // "C:/Store" and "c:/store" are one directory on Windows; a path spelled
468
  // the other way must not skip the walk (as in Pass::getGpgIdPath).
469
#ifdef Q_OS_WIN
470
  constexpr auto cs = Qt::CaseInsensitive;
471
#else
472
  constexpr auto cs = Qt::CaseSensitive;
473
#endif
474
  const QString root = QDir::cleanPath(storeRoot);
446 ✔
475
  // A root of "/" or "C:/" already ends in the separator.
476
  const QString prefix =
477
      root.endsWith(QLatin1Char('/')) ? root : root + QLatin1Char('/');
446 ✔
478
  const auto isRoot = [&](const QString &p) {
479
    return p.compare(root, cs) == 0;
791 ✔
480
  };
481
  QString current = QDir::cleanPath(path);
446 ✔
482
  if (!current.startsWith(prefix, cs)) {
446 ✔
483
    // Not under the store as named: only the entry itself can be judged.
484
    return includeSelf && !isRoot(current) && isLinkedFolder(current);
220 ✔
485
  }
486
  if (!includeSelf) {
336 ✔
487
    current = QFileInfo(current).path();
52 ✔
488
  }
489
  for (; !isRoot(current) && current.startsWith(prefix, cs);
681 ✔
490
       current = QFileInfo(current).path()) {
690 ✔
491
    if (isLinkedFolder(current)) {
382 ✔
492
      return true;
493
    }
494
  }
495
  return false;
496
}
497

498
#ifndef Q_OS_WIN
499
namespace {
500

501
/// The filesystem cannot make hard links at all (FAT/exFAT refuse with
502
/// EPERM, some FUSE filesystems with ENOTSUP or ENOSYS).
503
auto hasNoHardLinks(int error) -> bool {
504
  return error == EPERM || error == ENOTSUP || error == EOPNOTSUPP ||
2 ✔
505
         error == ENOSYS;
7 ✔
506
}
507

508
/// rename() that fails with EEXIST instead of replacing, where the platform
509
/// has one; nothing when neither the platform nor the filesystem does.
510
auto renameExclusive(const QByteArray &source, const QByteArray &target)
8 ✔
511
    -> std::optional<bool> {
512
#if defined(__GLIBC__) &&                                                      \
513
    (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 28))
514
  if (::renameat2(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(),
8 ✔
515
                  RENAME_NOREPLACE) == 0) {
516
    return true;
2 ✔
517
  }
518
  if (errno == EINVAL || errno == ENOSYS || hasNoHardLinks(errno)) {
6 ✔
519
    return std::nullopt;
4 ✔
520
  }
521
  return false;
2 ✔
522
#elif defined(Q_OS_MACOS)
523
  if (::renamex_np(source.constData(), target.constData(), RENAME_EXCL) == 0) {
524
    return true;
525
  }
526
  if (errno == EINVAL || errno == ENOTSUP) {
527
    return std::nullopt;
528
  }
529
  return false;
530
#else
531
  Q_UNUSED(source);
532
  Q_UNUSED(target);
533
  return std::nullopt;
534
#endif
535
}
536

537
/**
538
 * @brief Gives @p source the name @p target unless something already has it.
539
 *
540
 * A hard link is the one way every POSIX system can do that in one step:
541
 * linkat() fails with EEXIST rather than replacing. Where the filesystem has
542
 * no hard links, an exclusive rename does it; where that is missing too, the
543
 * name is checked and then renamed, which leaves a short window for another
544
 * writer to create it in between.
545
 */
546
auto placeWithoutReplacing(const QByteArray &source, const QByteArray &target)
86 ✔
547
    -> bool {
548
  // linkat() without AT_SYMLINK_FOLLOW follows nothing; link() would, on
549
  // macOS and the BSDs, hard-link whatever a symlink planted under the
550
  // source's name points at.
551
  if (::linkat(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(), 0) ==
86 ✔
552
      0) {
553
    ::unlink(source.constData());
71 ✔
554
    return true;
71 ✔
555
  }
556
  if (!hasNoHardLinks(errno)) {
15 ✔
557
    return false;
558
  }
559
  if (const std::optional<bool> renamed = renameExclusive(source, target)) {
8 ✔
560
    return *renamed;
4 ✔
561
  }
562
  struct stat st{};
4 ✔
563
  if (::lstat(target.constData(), &st) == 0 || errno != ENOENT) {
4 ✔
564
    errno = EEXIST;
2 ✔
565
    return false;
2 ✔
566
  }
567
  return ::rename(source.constData(), target.constData()) == 0;
2 ✔
568
}
569

570
} // namespace
571
#endif
572

573
auto Util::replaceFile(const QString &from, const QString &to, bool replace)
210 ✔
574
    -> bool {
575
#ifdef Q_OS_WIN
576
  const std::wstring source =
577
      QDir::toNativeSeparators(QFileInfo(from).absoluteFilePath())
578
          .toStdWString();
579
  const std::wstring target =
580
      QDir::toNativeSeparators(QFileInfo(to).absoluteFilePath()).toStdWString();
581
  // WRITE_THROUGH: the new entry is on the device when this returns.
582
  return MoveFileExW(source.c_str(), target.c_str(),
583
                     (replace ? MOVEFILE_REPLACE_EXISTING : 0) |
584
                         MOVEFILE_WRITE_THROUGH) != 0;
585
#else
586
  const QByteArray source = QFile::encodeName(from);
587
  const QByteArray target = QFile::encodeName(to);
588
  if (replace) {
210 ✔
589
    if (::rename(source.constData(), target.constData()) != 0) {
124 ✔
590
      return false;
591
    }
592
  } else {
593
    if (!placeWithoutReplacing(source, target)) {
86 ✔
594
      return false;
595
    }
596
  }
597
  // Sync the directory entry so a crash does not lose the new name. Best
598
  // effort: the bytes are already synced, and some network filesystems
599
  // cannot sync a directory.
600
  const int dir = ::open(QFile::encodeName(QFileInfo(to).path()).constData(),
394 ✔
601
                         O_RDONLY | O_DIRECTORY | O_CLOEXEC);
602
  if (dir >= 0) {
197 ✔
603
    int rc;
604
    do {
605
      rc = ::fsync(dir);
197 ✔
606
    } while (rc != 0 && errno == EINTR);
197 ✔
607
    ::close(dir);
197 ✔
608
  }
609
  return true;
610
#endif
611
}
612

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

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

685
namespace {
686

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

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

701
/// The identity of the object @p file is open on, or an unknown one.
702
auto identityOf(const QFileDevice &file) -> FileIdentity {
387 ✔
703
#ifdef Q_OS_WIN
704
  const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
705
  BY_HANDLE_FILE_INFORMATION info;
706
  if (handle == INVALID_HANDLE_VALUE ||
707
      GetFileInformationByHandle(handle, &info) == 0) {
708
    return {};
709
  }
710
  // On FAT/exFAT the file ID is the directory entry's offset, which a rename
711
  // can move. They have no hard links either (the swap this catches needs
712
  // one), so the identity is left unknown there.
713
  DWORD flags = 0;
714
  if (GetVolumeInformationByHandleW(handle, nullptr, 0, nullptr, nullptr,
715
                                    &flags, nullptr, 0) == 0 ||
716
      (flags & FILE_SUPPORTS_HARD_LINKS) == 0) {
717
    return {};
718
  }
719
  return {info.dwVolumeSerialNumber,
720
          (static_cast<quint64>(info.nFileIndexHigh) << 32) |
721
              info.nFileIndexLow,
722
          true};
723
#else
724
  struct stat st{};
387 ✔
725
  if (::fstat(file.handle(), &st) != 0) {
387 ✔
726
    return {};
×
727
  }
728
  return {static_cast<quint64>(st.st_dev), static_cast<quint64>(st.st_ino),
387 ✔
729
          true};
387 ✔
730
#endif
731
}
732

733
/// Put @p text in @p error when there is one; false, for `return fail(...)`.
734
auto fail(QString *error, const QString &text) -> bool {
735
  if (error != nullptr) {
21 ✔
736
    *error = text;
21 ✔
737
  }
738
  return false;
739
}
740

741
/// A filled, synced temporary next to its destination.
742
struct Staged {
605 ✔
743
  QString path;
744
  FileIdentity identity;
745
};
746

747
/**
748
 * @brief Create the temporary next to @p path (opaque name, exclusive,
749
 * owner-only), fill it through its handle and sync it. The QTemporaryFile
750
 * goes out of scope before the caller renames: it keeps its handle open for
751
 * as long as it lives, and Windows does not rename an open file.
752
 */
753
auto stageNextTo(const QString &path, const Util::Filler &fill, QString *why)
207 ✔
754
    -> std::optional<Staged> {
755
  Staged staged;
207 ✔
756
  {
757
    QTemporaryFile file(QFileInfo(path).path() +
414 ✔
758
                        QStringLiteral("/.qtpass-XXXXXX.tmp"));
414 ✔
759
    file.setAutoRemove(false);
207 ✔
760
    if (!file.open()) {
207 ✔
761
      *why = QCoreApplication::translate(
8 ✔
762
                 "Util", "Cannot create a temporary file next to %1: %2")
763
                 .arg(path, file.errorString());
16 ✔
764
      return std::nullopt;
765
    }
766
    staged.path = file.fileName();
199 ✔
767
    // Owner-only: a .gpg-id names the keys a store is encrypted to, an
768
    // entry is an entry. QTemporaryFile creates 0600 already; say so for
769
    // platforms where it may not.
770
    file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
199 ✔
771
    *why = fill(file);
398 ✔
772
    if (why->isEmpty() && !Util::syncToDisk(file)) {
199 ✔
773
      *why = QCoreApplication::translate("Util", "Cannot write %1: %2")
×
774
                 .arg(path, file.errorString());
×
775
    }
776
    staged.identity = identityOf(file);
199 ✔
777
  }
207 ✔
778
  if (!why->isEmpty()) {
199 ✔
779
    QFile::remove(staged.path);
×
780
    return std::nullopt;
×
781
  }
782
  return staged;
783
}
784

785
/// Why replaceFile() could not put the temporary under @p path.
786
auto placementError(const QString &path, bool replace) -> QString {
9 ✔
787
  if (replace) {
9 ✔
788
    return QCoreApplication::translate("Util", "Failed to replace %1.")
4 ✔
789
        .arg(path);
2 ✔
790
  }
791
  const QFileInfo taken(path);
7 ✔
792
  return taken.exists() || taken.isSymLink()
8 ✔
793
             ? QCoreApplication::translate("Util", "%1 already exists.")
7 ✔
794
                   .arg(path)
795
             : QCoreApplication::translate("Util", "Failed to write %1.")
1 ✔
796
                   .arg(path);
8 ✔
797
}
7 ✔
798

799
/**
800
 * @brief Whether what is under @p path is the file that was filled: not a
801
 * link, and not another file (a hard link to something of the user's, say)
802
 * swapped in under the temporary's name. Opened without following, compared
803
 * by identity; only two known identities can prove a swap, otherwise every
804
 * write on such a store would be reported as tampering.
805
 */
806
auto isTheFileWritten(const QString &path, const FileIdentity &written)
190 ✔
807
    -> bool {
808
  QFile placed;
190 ✔
809
  if (!Util::openRegularFile(path, placed)) {
190 ✔
810
    return false;
811
  }
812
  const FileIdentity there = identityOf(placed);
188 ✔
813
  if (!written.known || !there.known) {
188 ✔
814
    qCWarning(lcQtPass) << "Cannot tell whether" << path
×
815
                        << "is the file that was written here";
×
816
    return true;
×
817
  }
818
  return there == written;
188 ✔
819
}
190 ✔
820

821
} // namespace
822

823
auto Util::stageFileReplacing(const QString &path, bool replace,
207 ✔
824
                              const Filler &fill, QString *error) -> bool {
825
  QString why;
207 ✔
826
  const std::optional<Staged> staged = stageNextTo(path, fill, &why);
207 ✔
827
  if (!staged) {
207 ✔
828
    return fail(error, why);
829
  }
830
  if (!replaceFile(staged->path, path, replace)) {
199 ✔
831
    QFile::remove(staged->path);
9 ✔
832
    return fail(error, placementError(path, replace));
18 ✔
833
  }
834
  // A mismatch is reported and left: removing by name could take another
835
  // writer's file.
836
  if (!isTheFileWritten(path, staged->identity)) {
190 ✔
837
    return fail(
838
        error,
839
        QCoreApplication::translate(
4 ✔
840
            "Util", "%1 was swapped for another file while it was written.")
841
            .arg(path));
8 ✔
842
  }
843
  return true;
844
}
845

846
auto Util::writeFileReplacing(const QString &path, const QByteArray &bytes,
91 ✔
847
                              bool replace, QString *error) -> bool {
848
  return stageFileReplacing(
91 ✔
849
      path, replace,
850
      [&path, &bytes](QFileDevice &staged) -> QString {
89 ✔
851
        if (staged.write(bytes) != bytes.size()) {
89 ✔
852
          return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
853
              .arg(path, staged.errorString());
×
854
        }
855
        return {};
856
      },
857
      error);
91 ✔
858
}
859

860
auto Util::copyFileReplacing(const QString &src, const QString &dst,
115 ✔
861
                             bool replace, QString *error) -> bool {
862
  QFile in;
115 ✔
863
  if (!openRegularFile(src, in)) {
115 ✔
864
    if (error)
4 ✔
865
      *error = QCoreApplication::translate("Util", "Cannot read %1.").arg(src);
8 ✔
866
    return false;
4 ✔
867
  }
868
  return stageFileReplacing(
111 ✔
869
      dst, replace,
870
      [&src, &dst, &in](QFileDevice &staged) -> QString {
327 ✔
871
        char buf[64 * 1024];
872
        for (;;) {
873
          const qint64 n = in.read(buf, sizeof buf);
222 ✔
874
          if (n < 0) {
222 ✔
875
            return QCoreApplication::translate("Util", "Cannot read %1: %2")
×
876
                .arg(src, in.errorString());
×
877
          }
878
          if (n == 0) {
222 ✔
879
            return {};
880
          }
881
          if (staged.write(buf, n) != n) {
117 ✔
882
            return QCoreApplication::translate("Util", "Cannot write %1: %2")
×
883
                .arg(dst, staged.errorString());
×
884
          }
885
        }
886
      },
887
      error);
888
}
115 ✔
889

890
namespace {
891

892
/// Remove one directory entry that is not a real directory: a link goes as
893
/// the entry itself, never its target (a junction or a directory symlink on
894
/// Windows is a directory entry and goes with rmdir); a read-only file gets
895
/// write access and one more try, as QDir::removeRecursively() does.
896
auto removeEntry(const QFileInfo &entry) -> bool {
11 ✔
897
  const QString path = entry.filePath();
11 ✔
898
  if (isLink(entry)) {
11 ✔
899
    if (QFile::remove(path) || QDir().rmdir(path)) {
6 ✔
900
      return true;
901
    }
902
    qCWarning(lcQtPass) << "Could not remove link" << path;
2 ✔
903
    return false;
1 ✔
904
  }
905
  if (QFile::remove(path)) {
6 ✔
906
    return true;
907
  }
908
  const QFile::Permissions perms = QFile::permissions(path);
1 ✔
909
  if (!perms.testFlag(QFile::WriteUser) &&
1 ✔
910
      QFile::setPermissions(path, perms | QFile::WriteUser) &&
2 ✔
911
      QFile::remove(path)) {
1 ✔
912
    return true;
913
  }
914
  qCWarning(lcQtPass) << "Could not remove" << path;
2 ✔
915
  return false;
1 ✔
916
}
917

918
} // namespace
919

920
auto Util::removeTree(const QString &dir) -> bool {
20 ✔
921
  // A trailing separator makes lstat follow a link ("link/" is the target
922
  // directory); the link itself is what this is about.
923
  const QString path = QDir::cleanPath(dir);
20 ✔
924
  const QFileInfo top(path);
20 ✔
925
  if (isLink(top)) {
20 ✔
926
    // rm -rf on a link removes the link.
927
    return QFile::remove(path) || QDir().rmdir(path);
15 ✔
928
  }
929
  if (!top.isDir()) {
7 ✔
930
    return false;
931
  }
932
  bool ok = true;
933
  const QFileInfoList entries =
934
      QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
12 ✔
935
                                   QDir::System | QDir::NoDotAndDotDot,
936
                               QDir::Name);
6 ✔
937
  for (const QFileInfo &entry : entries) {
18 ✔
938
    const bool removed = entry.isDir() && !isLink(entry)
17 ✔
939
                             ? removeTree(entry.filePath())
13 ✔
940
                             : removeEntry(entry);
11 ✔
941
    ok = removed && ok;
12 ✔
942
  }
943
  return ok && QDir().rmdir(path);
11 ✔
944
}
20 ✔
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