Line data Source code
1 : // SPDX-FileCopyrightText: 2014 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements Util; the staged, link-safe store writes
6 : * (stageFileReplacing() and its helpers) and the store walk (walkStore())
7 : * live here.
8 : */
9 : #include "util.h"
10 : #include "appsettings.h"
11 : #include "executor.h"
12 : #include <QCoreApplication>
13 : #include <QDebug>
14 : #include <QDir>
15 : #include <QFile>
16 : #include <QFileDevice>
17 : #include <QFileInfo>
18 : #include <QHash>
19 : #include <QRegularExpression>
20 : #include <QRegularExpressionMatchIterator>
21 : #include <QStandardPaths>
22 : #include <QTemporaryFile>
23 : #include <QUrl>
24 :
25 : #include <optional>
26 :
27 : #ifdef Q_OS_WIN
28 : #include <fcntl.h>
29 : #include <io.h>
30 : #include <windows.h>
31 : #else
32 : #include <cerrno>
33 : #include <cstdio>
34 : #include <fcntl.h>
35 : #include <sys/stat.h>
36 : #include <sys/time.h>
37 : #include <unistd.h>
38 : #endif
39 :
40 : #include "qtpasslogging.h"
41 :
42 : QProcessEnvironment Util::_env;
43 : bool Util::_envInitialised = false;
44 :
45 : // macOS and Windows: append the usual GPG install directories to PATH.
46 681 : void Util::initialiseEnvironment() {
47 681 : if (!_envInitialised) {
48 6 : _env = QProcessEnvironment::systemEnvironment();
49 : #ifdef __APPLE__
50 : QString path = _env.value("PATH");
51 : if (!path.contains("/usr/local/MacGPG2/bin") &&
52 : QDir("/usr/local/MacGPG2/bin").exists())
53 : path += ":/usr/local/MacGPG2/bin";
54 : if (!path.contains("/usr/local/bin"))
55 : path += ":/usr/local/bin";
56 : _env.insert("PATH", path);
57 : #endif
58 : #ifdef Q_OS_WIN
59 : QString path = _env.value("PATH");
60 : if (!path.contains("C:\\Program Files\\WinGPG\\x86") &&
61 : QDir("C:\\Program Files\\WinGPG\\x86").exists())
62 : path += ";C:\\Program Files\\WinGPG\\x86";
63 : if (!path.contains("C:\\Program Files\\GnuPG\\bin") &&
64 : QDir("C:\\Program Files\\GnuPG\\bin").exists())
65 : path += ";C:\\Program Files\\GnuPG\\bin";
66 : _env.insert("PATH", path);
67 : #endif
68 6 : qCDebug(lcQtPass) << _env.value("PATH");
69 6 : _envInitialised = true;
70 : }
71 681 : }
72 :
73 273 : auto Util::findPasswordStore() -> QString {
74 273 : QString path;
75 273 : initialiseEnvironment();
76 546 : if (_env.contains("PASSWORD_STORE_DIR")) {
77 6 : path = Util::expandTilde(_env.value("PASSWORD_STORE_DIR"));
78 : } else {
79 : #ifdef Q_OS_WIN
80 : path = QDir(QDir::homePath()).filePath("password-store");
81 : #else
82 810 : path = QDir(QDir::homePath()).filePath(".password-store");
83 : #endif
84 : }
85 546 : return Util::normalizeFolderPath(QDir::cleanPath(path));
86 : }
87 :
88 12 : auto Util::expandTilde(const QString &path) -> QString {
89 12 : if (path == QLatin1String("~")) {
90 1 : return QDir::homePath();
91 : }
92 11 : if (path.startsWith(QLatin1String("~/"))) {
93 10 : return QDir::homePath() + path.mid(1);
94 : }
95 : return path;
96 : }
97 :
98 393 : auto Util::normalizeFolderPath(const QString &path) -> QString {
99 : QString normalizedPath = path;
100 393 : if (!normalizedPath.endsWith('/')) {
101 378 : normalizedPath += '/';
102 : }
103 393 : return normalizedPath;
104 : }
105 :
106 1 : void Util::applyPassProcessDefaults() {
107 : // pass unsets these before it runs git: a GIT_DIR inherited from the
108 : // environment (QtPass started from a git hook, or an IDE terminal) would
109 : // send every commit and push to that repository instead of the store.
110 11 : for (const char *variable :
111 : {"GIT_DIR", "GIT_WORK_TREE", "GIT_NAMESPACE", "GIT_INDEX_FILE",
112 : "GIT_INDEX_VERSION", "GIT_OBJECT_DIRECTORY", "GIT_COMMON_DIR",
113 : // QtPass sets GIT_LITERAL_PATHSPECS for its git; git refuses a
114 : // pathspec when one of these is set as well.
115 11 : "GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS"}) {
116 10 : qunsetenv(variable);
117 : }
118 1 : Executor::setChildUmask(
119 1 : passUmask(qEnvironmentVariable("PASSWORD_STORE_UMASK")));
120 1 : }
121 :
122 11 : auto Util::passUmask(const QString &value) -> int {
123 : // Taken as it is, as bash's umask builtin (which pass hands it to) takes
124 : // it: " 022" is no umask there either. pass then keeps the session's;
125 : // QtPass falls back to 077.
126 : // anchoredPattern(), not ^...$: $ also matches before a final newline.
127 : static const QRegularExpression octal(
128 35 : QRegularExpression::anchoredPattern(QStringLiteral("[0-7]{1,4}")));
129 11 : if (!octal.match(value).hasMatch()) {
130 : return 077;
131 : }
132 4 : return value.toInt(nullptr, 8);
133 : }
134 :
135 1178 : auto Util::gitCeiling(const QString &store) -> QString {
136 1178 : if (store.isEmpty()) {
137 0 : return {};
138 : }
139 1178 : QString path = QFileInfo(store).canonicalFilePath();
140 1178 : if (path.isEmpty()) {
141 0 : path = QDir::cleanPath(QDir(store).absolutePath());
142 : }
143 1178 : return QFileInfo(path).absolutePath();
144 : }
145 :
146 40 : auto Util::repositoryPassClimbsTo(const QString &store) -> QString {
147 : #ifdef Q_OS_WIN
148 : Q_UNUSED(store);
149 : return {};
150 : #else
151 40 : if (!store.contains(QLatin1Char(':'))) {
152 10 : return {};
153 : }
154 : // git walks up from the real folder, so the real path is the one to walk,
155 : // and for a store that does not exist yet the one it will have: its
156 : // nearest existing folder resolved, the rest appended. By the raw string:
157 : // QDir and QFileInfo clean a path by name, and "link/.." is the parent
158 : // of the link's target, not the folder the link is in. On macOS the
159 : // temporary folder is under /var, a link to /private/var.
160 : // Absolute as git means it, a leading "/" (Qt would take ":name" for a
161 : // resource path).
162 : QString path =
163 60 : store.startsWith(u'/') ? store : QDir::currentPath() + u'/' + store;
164 33 : while (path.size() > 1 && path.endsWith(u'/')) {
165 3 : path.chop(1);
166 : }
167 30 : QString rest;
168 40 : while (!QFileInfo::exists(path)) {
169 10 : const qsizetype slash = path.lastIndexOf(u'/');
170 10 : if (slash < 0) {
171 0 : return {};
172 : }
173 10 : const QString name = path.mid(slash + 1);
174 24 : rest = rest.isEmpty() ? name : name + u'/' + rest;
175 20 : path = slash == 0 ? QStringLiteral("/") : path.left(slash);
176 : }
177 30 : const QString real = QFileInfo(path).canonicalFilePath();
178 30 : QString start = real.isEmpty() ? QDir::cleanPath(path) : real;
179 30 : if (!rest.isEmpty()) {
180 8 : start = QDir::cleanPath(start + u'/' + rest);
181 : }
182 : // The pieces of pass's ceiling as git takes them (setup.c,
183 : // canonicalize_ceiling_entry): relative ones are dropped, the rest
184 : // resolved, one that cannot be resolved is dropped; after an empty piece
185 : // ("::") they are kept exactly as written. A piece can still be a real
186 : // ceiling: "/repo/:vault/.." holds "/repo/".
187 30 : QString ceiling;
188 : bool afterEmpty = false;
189 : for (const QString &piece :
190 123 : (store + QStringLiteral("/..")).split(QLatin1Char(':'))) {
191 63 : if (piece.isEmpty()) {
192 : afterEmpty = true;
193 31 : continue;
194 : }
195 61 : if (!piece.startsWith(u'/')) {
196 29 : continue;
197 : }
198 : const QString entry =
199 33 : afterEmpty ? piece : QFileInfo(piece).canonicalFilePath();
200 : // git stops below the longest entry the store lies under
201 : // (longest_ancestor_length: "/" is above everything).
202 32 : const QString under = entry == u'/' ? entry : entry + u'/';
203 32 : if (!entry.isEmpty() && start.startsWith(under) &&
204 : entry.size() > ceiling.size()) {
205 4 : ceiling = entry;
206 : }
207 : }
208 : // What git takes for a repository (setup.c, is_git_directory()): HEAD
209 : // naming a ref or an object, objects/ and refs/. An empty or broken .git
210 : // is passed by, as git passes it by.
211 81 : const auto isGitDirectory = [](const QString &candidate) {
212 81 : const QDir dir(candidate);
213 185 : if (!QFileInfo(dir.filePath(QStringLiteral("objects"))).isDir() ||
214 150 : !QFileInfo(dir.filePath(QStringLiteral("refs"))).isDir()) {
215 : return false;
216 : }
217 46 : QFile head(dir.filePath(QStringLiteral("HEAD")));
218 23 : if (!head.open(QIODevice::ReadOnly)) {
219 : return false;
220 : }
221 : static const QRegularExpression valid(
222 27 : QStringLiteral("^(?:ref: refs/|[0-9a-f]{40}\\s*$|[0-9a-f]{64}\\s*$)"));
223 69 : return valid.match(QString::fromUtf8(head.read(256))).hasMatch();
224 81 : };
225 : // At each folder, as git: a .git folder that is a repository, or a .git
226 : // file naming one ("gitdir: ..."), is the repository. Any other .git file
227 : // stops git with an error, and pass then commits nothing; a .git folder
228 : // that is no repository is passed by.
229 : enum class Found { Nothing, Repository, Refused };
230 80 : const auto repositoryAt = [&isGitDirectory](const QString &dir) {
231 160 : const QString dotGit = QDir(dir).filePath(QStringLiteral(".git"));
232 80 : const QFileInfo info(dotGit);
233 80 : if (info.isDir()) {
234 21 : return isGitDirectory(dotGit) ? Found::Repository : Found::Nothing;
235 : }
236 59 : if (!info.exists()) {
237 : return Found::Nothing;
238 : }
239 6 : QFile file(dotGit);
240 6 : if (!file.open(QIODevice::ReadOnly)) {
241 : return Found::Refused;
242 : }
243 6 : const QString line = QString::fromUtf8(file.readLine(4096)).trimmed();
244 6 : if (!line.startsWith(QLatin1String("gitdir: "))) {
245 : return Found::Refused;
246 : }
247 4 : const QString target = line.mid(8);
248 8 : return isGitDirectory(target.startsWith(u'/') ? target
249 7 : : QDir(dir).filePath(target))
250 4 : ? Found::Repository
251 : : Found::Refused;
252 110 : };
253 : // The store's own repository comes first; only without one does git look
254 : // further up, and never into the ceiling. By name from here: the real
255 : // path holds no link or "..".
256 : for (QString dir = start;;) {
257 80 : switch (repositoryAt(dir)) {
258 : case Found::Repository:
259 48 : return dir == start ? QString() : dir;
260 3 : case Found::Refused:
261 3 : return {};
262 : case Found::Nothing:
263 : break;
264 : }
265 : // A bare repository: git stops there, and pass, finding no work tree,
266 : // commits nothing.
267 56 : if (isGitDirectory(dir)) {
268 1 : return {};
269 : }
270 55 : const QString up = QFileInfo(dir).absolutePath();
271 55 : if (up == dir || up == ceiling) {
272 5 : return {};
273 : }
274 50 : dir = up;
275 : }
276 : #endif
277 : }
278 :
279 : namespace {
280 : /// setGitOwnerForTests(): who owns "the user's own" store, when set.
281 : std::optional<uint> s_gitOwnerForTests;
282 : } // namespace
283 :
284 4 : void Util::setGitOwnerForTests(std::optional<uint> user) {
285 4 : s_gitOwnerForTests = user;
286 4 : }
287 :
288 : #ifdef Q_OS_UNIX
289 : namespace {
290 : /**
291 : * @brief Whether git's `safe.directory` trusts the repository at @p root,
292 : * as git's ownership check would judge it in place of ownership.
293 : *
294 : * Read as git reads it: only from system, global and command-line
295 : * configuration (a repository cannot vouch for itself), in order. `*`
296 : * trusts every repository, an empty value forgets the entries before it,
297 : * a path ending in a slash and `*` trusts what lies below it, and any other
298 : * absolute path trusts that one folder, compared after resolving links.
299 : * Relative entries (including git's ".") and paths that do not exist trust
300 : * nothing.
301 : * @param root The store's canonical path.
302 : * @param git The git executable; "git" from PATH when empty.
303 : * @return True when an entry names @p root; false when none does or git
304 : * cannot be asked.
305 : */
306 23 : auto isSafeDirectory(const QString &root, const QString &git) -> bool {
307 : // Outside any repository, as git reads its protected config: no
308 : // repository's own config, and no includeIf "gitdir:" or "onbranch:"
309 : // that matches (a GIT_DIR, even one that cannot exist, would be matched
310 : // against them, and one that cannot exist makes git config fail). From
311 : // "/", which a user cannot make a repository; a local scope is dropped
312 : // below in any case.
313 23 : QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
314 46 : env.remove(QStringLiteral("GIT_DIR"));
315 46 : env.remove(QStringLiteral("GIT_WORK_TREE"));
316 46 : env.remove(QStringLiteral("GIT_COMMON_DIR"));
317 : // git config alone reads GIT_CONFIG in place of the system and global
318 : // configuration (and reports it as "command"); the ownership check never
319 : // reads it.
320 46 : env.remove(QStringLiteral("GIT_CONFIG"));
321 23 : QProcess process;
322 23 : process.setProcessEnvironment(env);
323 46 : process.setWorkingDirectory(QStringLiteral("/"));
324 : // Bounded: Git calls wait for this, and a git that never answers trusts
325 : // nothing.
326 : constexpr int kQueryTimeoutMs = 5000;
327 23 : QString out;
328 184 : if (Executor::executeBlocking(
329 46 : process, git.isEmpty() ? QStringLiteral("git") : git,
330 23 : {QStringLiteral("config"), QStringLiteral("--show-scope"),
331 23 : QStringLiteral("--type=path"), QStringLiteral("--get-all"),
332 23 : QStringLiteral("safe.directory")},
333 46 : QString(), &out, nullptr, nullptr, kQueryTimeoutMs) != 0) {
334 : return false;
335 : }
336 : bool safe = false;
337 14 : const QStringList lines = out.split(QLatin1Char('\n'));
338 47 : for (const QString &line : lines) {
339 33 : const qsizetype tab = line.indexOf(QLatin1Char('\t'));
340 33 : if (tab < 0) {
341 18 : continue;
342 : }
343 19 : const QString scope = line.left(tab);
344 19 : if (scope != QLatin1String("system") && scope != QLatin1String("global") &&
345 1 : scope != QLatin1String("command")) {
346 0 : continue;
347 : }
348 19 : const QString value = line.mid(tab + 1);
349 19 : if (value.isEmpty()) {
350 : safe = false;
351 17 : } else if (value == QLatin1String("*")) {
352 : safe = true;
353 13 : } else if (!QDir::isAbsolutePath(value)) {
354 3 : continue;
355 10 : } else if (value.endsWith(QLatin1String("/*"))) {
356 : const QString base = value.chopped(2);
357 12 : QString prefix = QFileInfo(base.isEmpty() ? QStringLiteral("/") : base)
358 3 : .canonicalFilePath();
359 3 : if (prefix.isEmpty()) {
360 : continue;
361 : }
362 2 : if (!prefix.endsWith(QLatin1Char('/'))) {
363 2 : prefix += QLatin1Char('/');
364 : }
365 2 : safe = safe || root.startsWith(prefix);
366 : } else {
367 7 : const QString named = QFileInfo(value).canonicalFilePath();
368 7 : safe = safe || (!named.isEmpty() && named == root);
369 : }
370 : }
371 : return safe;
372 23 : }
373 : } // namespace
374 : #endif
375 :
376 1178 : auto Util::gitStoreVariables(const QString &store, const QString &git,
377 : std::optional<uint> asUser)
378 : -> QList<std::pair<QString, QString>> {
379 1178 : const QString ceiling = gitCeiling(store);
380 1178 : if (ceiling.isEmpty()) {
381 0 : return {};
382 : }
383 : QList<std::pair<QString, QString>> vars{
384 3534 : {QStringLiteral("GIT_CEILING_DIRECTORIES"), ceiling}};
385 : #ifdef Q_OS_WIN
386 : const QChar separator = QLatin1Char(';');
387 : #else
388 : const QChar separator = QLatin1Char(':');
389 : #endif
390 : // git splits the ceiling list on the separator: "/home/a:b" would be
391 : // "/home/a" and "b", neither of them the parent, and git would climb.
392 1178 : if (ceiling.contains(separator)) {
393 28 : QString root = QFileInfo(store).canonicalFilePath();
394 28 : if (root.isEmpty()) {
395 0 : root = QDir::cleanPath(QDir(store).absolutePath());
396 : }
397 56 : QString gitDir = QDir(root).filePath(QStringLiteral(".git"));
398 : #ifdef Q_OS_UNIX
399 : // What discovery's ownership check would have looked at: the work tree,
400 : // the .git, and the directory a gitfile names. Not the user's: no
401 : // repository at all, under a path that cannot be created (its parent
402 : // is a device), rather than another user's hooks run as this one.
403 : const uint me = asUser.value_or(
404 28 : s_gitOwnerForTests.value_or(static_cast<uint>(::geteuid())));
405 33 : const auto ownedOrMissing = [me](const QString &path) {
406 33 : const QFileInfo info(path);
407 66 : return !info.exists() || info.ownerId() == me;
408 33 : };
409 28 : bool owned = ownedOrMissing(root) && ownedOrMissing(gitDir);
410 28 : if (owned && QFileInfo(gitDir).isFile()) {
411 0 : QFile gitFile(gitDir);
412 0 : if (gitFile.open(QIODevice::ReadOnly)) {
413 : const QString line =
414 0 : QString::fromUtf8(gitFile.readLine(4096)).trimmed();
415 0 : if (line.startsWith(QLatin1String("gitdir:"))) {
416 0 : const QString target = line.mid(7).trimmed();
417 0 : owned = ownedOrMissing(QDir(root).absoluteFilePath(target));
418 : }
419 : }
420 0 : }
421 : // Or trusted as git would trust it in place of ownership.
422 28 : if (!owned && !isSafeDirectory(root, git)) {
423 14 : gitDir = QStringLiteral("/dev/null/.git");
424 : }
425 : #else
426 : // Git for Windows runs the same ownership check during discovery, on
427 : // SIDs, with its own rules for the Administrators group; this pin skips
428 : // it there and does not repeat it (a known gap, #2089).
429 : Q_UNUSED(git);
430 : Q_UNUSED(asUser);
431 : #endif
432 56 : vars.append({QStringLiteral("GIT_DIR"), gitDir});
433 56 : vars.append({QStringLiteral("GIT_WORK_TREE"), root});
434 : }
435 : return vars;
436 1178 : }
437 :
438 54 : auto Util::mkpathForStore(const QString &path) -> bool {
439 108 : const QString clean = QDir::cleanPath(QDir(path).absolutePath());
440 54 : if (QFileInfo(clean).isDir()) {
441 : return true;
442 : }
443 31 : const QString parent = QFileInfo(clean).absolutePath();
444 31 : if (parent != clean && !mkpathForStore(parent)) {
445 : return false;
446 : }
447 : #ifdef Q_OS_UNIX
448 : // The store's umask, as git and gpg get it: a store shared with a group
449 : // (PASSWORD_STORE_UMASK=007) gets group folders, the default owner-only.
450 28 : const int umask = Executor::childUmask() >= 0 ? Executor::childUmask() : 077;
451 : QFile::Permissions permissions;
452 28 : const QFile::Permission bits[] = {
453 : QFile::ReadOwner, QFile::WriteOwner, QFile::ExeOwner,
454 : QFile::ReadGroup, QFile::WriteGroup, QFile::ExeGroup,
455 : QFile::ReadOther, QFile::WriteOther, QFile::ExeOther};
456 280 : for (int i = 0; i < 9; ++i) {
457 252 : if ((umask & (0400 >> i)) == 0) {
458 90 : permissions |= bits[i];
459 : }
460 : }
461 : // mkdir() still takes QtPass's own umask off (a session's 022 would drop
462 : // the group's write bit); set the mode exactly afterwards. That only ever
463 : // widens it to what was asked, never past.
464 56 : const bool made = QDir().mkdir(clean, permissions) &&
465 23 : QFile::setPermissions(clean, permissions);
466 : #else
467 : // Windows: permissions here would become an ACL of their own, replacing
468 : // the inherited one; the folder takes its parent's, as before.
469 : const bool made = QDir().mkdir(clean);
470 : #endif
471 28 : return made || QFileInfo(clean).isDir();
472 : }
473 :
474 409 : auto Util::findBinaryInPath(const QString &binary) -> QString {
475 409 : if (binary.isEmpty()) {
476 1 : return {};
477 : }
478 :
479 408 : initialiseEnvironment();
480 :
481 : const QStringList dirs =
482 816 : _env.value(QStringLiteral("PATH"))
483 408 : .split(QDir::listSeparator(), Qt::SkipEmptyParts);
484 408 : QString ret;
485 408 : if (QDir::fromNativeSeparators(binary).contains(u'/')) {
486 : // An explicit path is not a PATH search: an absolute path is checked
487 : // as-is, a relative one is resolved against the PATH directories. The
488 : // directory-list overload refuses such names, so handle them here.
489 3 : if (QDir::isAbsolutePath(binary)) {
490 4 : ret = QStandardPaths::findExecutable(binary);
491 1 : } else if (!dirs.isEmpty()) {
492 2 : ret = QStandardPaths::findExecutable(binary, dirs);
493 : }
494 : } else {
495 810 : ret = findBinaryInPath(binary, dirs);
496 : }
497 : #ifdef Q_OS_WIN
498 : if (ret.isEmpty()) {
499 : // Cache per-binary WSL lookup result — the wsl --version probe is a
500 : // blocking subprocess that can run several times per session for
501 : // missing binaries; once decided, the answer doesn't change at runtime.
502 : static QHash<QString, QString> wslBinaryCache;
503 : const bool hasWhitespace =
504 : std::any_of(binary.cbegin(), binary.cend(),
505 : [](const QChar ch) { return ch.isSpace(); });
506 : if (!hasWhitespace) {
507 : auto cached = wslBinaryCache.constFind(binary);
508 : if (cached != wslBinaryCache.constEnd()) {
509 : ret = cached.value();
510 : } else {
511 : QString wslCommand = QStringLiteral("wsl ") + binary;
512 : qCDebug(lcQtPass)
513 : << "Util::findBinaryInPath(): falling back to WSL for binary"
514 : << binary;
515 : QString out, err;
516 : QString cachedResult;
517 : if (Executor::executeBlocking(wslCommand, {"--version"}, &out, &err) ==
518 : 0 &&
519 : !out.isEmpty() && err.isEmpty()) {
520 : qCDebug(lcQtPass)
521 : << "Util::findBinaryInPath(): using WSL binary" << wslCommand;
522 : cachedResult = wslCommand;
523 : }
524 : wslBinaryCache.insert(binary, cachedResult);
525 : ret = cachedResult;
526 : }
527 : }
528 : }
529 : #endif
530 :
531 : return ret;
532 : }
533 :
534 : // Bare names only: QStandardPaths::findExecutable() returns an absolute binary
535 : // without consulting searchPaths, and a relative one with ".." could escape
536 : // them.
537 419 : auto Util::findBinaryInPath(const QString &binary,
538 : const QStringList &searchPaths) -> QString {
539 837 : if (binary.isEmpty() || QDir::fromNativeSeparators(binary).contains(u'/')) {
540 4 : return {};
541 : }
542 415 : QStringList dirs;
543 415 : dirs.reserve(searchPaths.size());
544 6162 : for (const QString &dir : searchPaths) {
545 5747 : if (!dir.isEmpty()) {
546 : dirs.append(dir);
547 : }
548 : }
549 415 : if (dirs.isEmpty()) {
550 : // QStandardPaths::findExecutable() treats an empty list as "use PATH".
551 4 : return {};
552 : }
553 411 : return QStandardPaths::findExecutable(binary, dirs);
554 : }
555 :
556 213 : auto Util::configIsValid(const AppSettings &s) -> bool {
557 426 : const QString configFilePath = QDir(s.passStore).filePath(".gpg-id");
558 213 : if (!QFile(configFilePath).exists()) {
559 : return false;
560 : }
561 :
562 138 : const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
563 :
564 138 : if (const auto wsl = Executor::parseWslCommand(executable)) {
565 : // Probe WSL once per session — availability doesn't change at runtime
566 : // and the executeBlocking call is a blocking subprocess.
567 1 : static const bool wslAvailable = [&wsl]() {
568 1 : QString out;
569 1 : QString err;
570 3 : return Executor::executeBlocking(wsl->launcher,
571 1 : {QStringLiteral("--version")}, &out,
572 0 : &err) == 0 &&
573 2 : !out.isEmpty() && err.isEmpty();
574 1 : }();
575 1 : if (wslAvailable) {
576 : return true;
577 : }
578 : }
579 138 : return QFile(executable).exists();
580 : }
581 :
582 7535 : auto Util::endsWithGpg() -> const QRegularExpression & {
583 7542 : static const QRegularExpression expr{R"(\.gpg$)"};
584 7535 : return expr;
585 : }
586 :
587 : // Stops at whitespace, quotes and brackets so a URL on its own line keeps no
588 : // line break. file:/// is excluded on purpose (local, not network); the test
589 : // relies on that.
590 168 : auto Util::protocolRegex() -> const QRegularExpression & {
591 : static const QRegularExpression regex{
592 173 : R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
593 168 : return regex;
594 : }
595 :
596 109 : auto Util::isLaunchableWebUrl(const QString &value) -> bool {
597 : const QString trimmed = value.trimmed();
598 109 : if (trimmed.isEmpty()) {
599 : return false;
600 : }
601 : // Reject control characters first, before QUrl normalisation can hide a
602 : // CR/LF/NUL injection into the OS URL handler.
603 2348 : for (const QChar &c : trimmed) {
604 : if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
605 : c == QChar(QChar::Null)) {
606 : return false;
607 : }
608 : }
609 105 : const QUrl url(trimmed, QUrl::StrictMode);
610 105 : if (!url.isValid()) {
611 : return false;
612 : }
613 92 : const QString scheme = url.scheme().toLower();
614 92 : if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
615 35 : return false;
616 : }
617 114 : if (url.host().isEmpty()) {
618 : return false;
619 : }
620 : // Embedded userinfo (user:pass@host) would leak into browser history.
621 107 : if (!url.userName().isEmpty() || !url.password().isEmpty()) {
622 : return false;
623 : }
624 : return true;
625 105 : }
626 :
627 164 : auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
628 164 : if (linked != nullptr) {
629 115 : *linked = false;
630 : }
631 164 : QString html;
632 164 : html.reserve(text.size());
633 : qsizetype lastIndex = 0;
634 164 : QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
635 205 : while (it.hasNext()) {
636 41 : const QRegularExpressionMatch match = it.next();
637 41 : const QString url = match.captured(0);
638 41 : if (!isLaunchableWebUrl(url)) {
639 : // Not a web URL (or it carries credentials): leave it in the escaped
640 : // plain-text run instead of making it clickable.
641 : continue;
642 : }
643 28 : const qsizetype start = match.capturedStart(0);
644 28 : html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
645 28 : const QString escapedUrl = url.toHtmlEscaped();
646 56 : html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
647 28 : lastIndex = match.capturedEnd(0);
648 28 : if (linked != nullptr) {
649 22 : *linked = true;
650 : }
651 41 : }
652 164 : html += text.mid(lastIndex).toHtmlEscaped();
653 164 : return html;
654 164 : }
655 :
656 280 : auto Util::newLinesRegex() -> const QRegularExpression & {
657 289 : static const QRegularExpression regex{"[\r\n]"};
658 280 : return regex;
659 : }
660 :
661 : // No content heuristics: a line rejected here is erased the next time .gpg-id
662 : // is rewritten. A leading `-` is refused because the recipients also go
663 : // positionally to `gpg --list-keys`, where it would parse as an option.
664 222 : auto Util::isValidKeyId(const QString &keyId) -> bool {
665 222 : return !keyId.isEmpty() && !keyId.startsWith('-');
666 : }
667 :
668 : namespace {
669 : /**
670 : * @brief Walk @p dir's real, visible directories in sorted pre-order and hand
671 : * every entry to @p visit before any recursion; a directory is entered only
672 : * when @p visit returns true for it.
673 : */
674 206 : template <typename Visit> void walkStore(const QString &dir, Visit visit) {
675 : // Absolute, so the results are whatever the caller's cwd; not canonical,
676 : // so a linked root keeps the name it was configured under.
677 618 : QStringList pending{QDir(QDir::cleanPath(dir)).absolutePath()};
678 748 : while (!pending.isEmpty()) {
679 271 : const QDir current(pending.takeLast());
680 : // Every entry once, links and hidden entries included, so the decision
681 : // what to do with each is taken here, before any recursion. QDir::System
682 : // keeps dangling links in the listing.
683 271 : const QFileInfoList entries =
684 : current.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
685 : QDir::System | QDir::NoDotAndDotDot,
686 : QDir::Name);
687 271 : QStringList subdirs;
688 1509 : for (const QFileInfo &entry : entries) {
689 1238 : if (visit(entry)) {
690 130 : subdirs << entry.filePath();
691 : }
692 : }
693 : // Pushed last-to-first so the next one taken is the first by name.
694 336 : for (auto it = subdirs.crbegin(); it != subdirs.crend(); ++it) {
695 : pending << *it;
696 : }
697 : }
698 412 : }
699 :
700 : /// A symlink or an NTFS junction: never entered, never listed.
701 3346 : auto isLink(const QFileInfo &entry) -> bool {
702 3346 : return entry.isSymLink() || entry.isJunction();
703 : }
704 :
705 : /// Hidden by attribute, or by the dot convention on every platform: Qt 6.11
706 : /// on macOS answers isHidden() from the UF_HIDDEN flag alone once an entry
707 : /// was lstat()ed (qfilesystemengine_unix.cpp marks the attribute known there
708 : /// without the dot check), and Windows does not consider .stversions hidden
709 : /// at all.
710 623 : auto isHiddenEntry(const QFileInfo &entry) -> bool {
711 1231 : return entry.isHidden() || entry.fileName().startsWith(QLatin1Char('.'));
712 : }
713 :
714 : /// A real directory that is part of the store: .git, .stversions,
715 : /// .Trash-1000 are not, as with QDirIterator without QDir::Hidden.
716 1238 : auto isStoreDirectory(const QFileInfo &entry) -> bool {
717 1238 : return !isLink(entry) && entry.isDir() && !isHiddenEntry(entry);
718 : }
719 : } // namespace
720 :
721 203 : auto Util::regularFilesUnder(const QString &dir, const QStringList &nameFilters,
722 : QStringList *skipped, bool hiddenFiles)
723 : -> QStringList {
724 203 : QStringList files;
725 203 : walkStore(dir, [&](const QFileInfo &entry) {
726 1183 : if (isStoreDirectory(entry)) {
727 : return true;
728 : }
729 1136 : const bool named = QDir::match(nameFilters, entry.fileName());
730 1136 : if (isLink(entry) || (!entry.isDir() && !entry.isFile())) {
731 : // A linked directory hides everything behind it; a linked file, or a
732 : // FIFO, socket or device, is only of interest under a name the caller
733 : // asked for.
734 25 : if (skipped != nullptr && (entry.isDir() || named)) {
735 24 : qCWarning(lcQtPass) << "Skipping" << entry.filePath()
736 12 : << ": not a regular file or directory";
737 24 : *skipped << entry.filePath();
738 : }
739 25 : return false;
740 : }
741 1111 : if (entry.isFile() && named && (hiddenFiles || !isHiddenEntry(entry))) {
742 1126 : files << entry.filePath();
743 : }
744 : return false;
745 : });
746 203 : return files;
747 : }
748 :
749 3 : auto Util::directoriesUnder(const QString &dir) -> QStringList {
750 3 : QStringList dirs;
751 3 : walkStore(dir, [&](const QFileInfo &entry) {
752 55 : if (!isStoreDirectory(entry)) {
753 : return false;
754 : }
755 18 : dirs << entry.filePath();
756 18 : return true;
757 : });
758 3 : return dirs;
759 : }
760 :
761 935 : auto Util::isLinkedFolder(const QString &path) -> bool {
762 1870 : return isLink(QFileInfo(QDir::cleanPath(path)));
763 : }
764 :
765 625 : auto Util::isUnderLink(const QString &path, const QString &storeRoot,
766 : bool includeSelf) -> bool {
767 : // "C:/Store" and "c:/store" are one directory on Windows; a path spelled
768 : // the other way must not skip the walk (as in Pass::getGpgIdPath).
769 : #ifdef Q_OS_WIN
770 : constexpr auto cs = Qt::CaseInsensitive;
771 : #else
772 : constexpr auto cs = Qt::CaseSensitive;
773 : #endif
774 625 : const QString root = QDir::cleanPath(storeRoot);
775 : // A root of "/" or "C:/" already ends in the separator.
776 : const QString prefix =
777 625 : root.endsWith(QLatin1Char('/')) ? root : root + QLatin1Char('/');
778 : const auto isRoot = [&](const QString &p) {
779 1083 : return p.compare(root, cs) == 0;
780 : };
781 625 : QString current = QDir::cleanPath(path);
782 625 : if (!current.startsWith(prefix, cs)) {
783 : // Not under the store as named: only the entry itself can be judged.
784 406 : return includeSelf && !isRoot(current) && isLinkedFolder(current);
785 : }
786 422 : if (!includeSelf) {
787 58 : current = QFileInfo(current).path();
788 : }
789 880 : for (; !isRoot(current) && current.startsWith(prefix, cs);
790 916 : current = QFileInfo(current).path()) {
791 495 : if (isLinkedFolder(current)) {
792 : return true;
793 : }
794 : }
795 : return false;
796 : }
797 :
798 : #ifndef Q_OS_WIN
799 : namespace {
800 :
801 : /// The filesystem cannot make hard links at all (FAT/exFAT refuse with
802 : /// EPERM, some FUSE filesystems with ENOTSUP or ENOSYS).
803 : auto hasNoHardLinks(int error) -> bool {
804 2 : return error == EPERM || error == ENOTSUP || error == EOPNOTSUPP ||
805 : error == ENOSYS;
806 : }
807 :
808 : /// rename() that fails with EEXIST instead of replacing, where the platform
809 : /// has one; nothing when neither the platform nor the filesystem does.
810 8 : auto renameExclusive(const QByteArray &source, const QByteArray &target)
811 : -> std::optional<bool> {
812 : #if defined(__GLIBC__) && \
813 : (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 28))
814 8 : if (::renameat2(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(),
815 : RENAME_NOREPLACE) == 0) {
816 2 : return true;
817 : }
818 6 : if (errno == EINVAL || errno == ENOSYS || hasNoHardLinks(errno)) {
819 4 : return std::nullopt;
820 : }
821 2 : return false;
822 : #elif defined(Q_OS_MACOS)
823 : if (::renamex_np(source.constData(), target.constData(), RENAME_EXCL) == 0) {
824 : return true;
825 : }
826 : if (errno == EINVAL || errno == ENOTSUP) {
827 : return std::nullopt;
828 : }
829 : return false;
830 : #else
831 : Q_UNUSED(source);
832 : Q_UNUSED(target);
833 : return std::nullopt;
834 : #endif
835 : }
836 :
837 : /**
838 : * @brief Gives @p source the name @p target unless something already has it.
839 : *
840 : * A hard link is the one way every POSIX system can do that in one step:
841 : * linkat() fails with EEXIST rather than replacing. Where the filesystem has
842 : * no hard links, an exclusive rename does it; where that is missing too, the
843 : * name is checked and then renamed, which leaves a short window for another
844 : * writer to create it in between.
845 : */
846 91 : auto placeWithoutReplacing(const QByteArray &source, const QByteArray &target)
847 : -> bool {
848 : // linkat() without AT_SYMLINK_FOLLOW follows nothing; link() would, on
849 : // macOS and the BSDs, hard-link whatever a symlink planted under the
850 : // source's name points at.
851 91 : if (::linkat(AT_FDCWD, source.constData(), AT_FDCWD, target.constData(), 0) ==
852 : 0) {
853 76 : ::unlink(source.constData());
854 76 : return true;
855 : }
856 15 : if (!hasNoHardLinks(errno)) {
857 : return false;
858 : }
859 8 : if (const std::optional<bool> renamed = renameExclusive(source, target)) {
860 4 : return *renamed;
861 : }
862 4 : struct stat st{};
863 4 : if (::lstat(target.constData(), &st) == 0 || errno != ENOENT) {
864 2 : errno = EEXIST;
865 2 : return false;
866 : }
867 2 : return ::rename(source.constData(), target.constData()) == 0;
868 : }
869 :
870 : } // namespace
871 : #endif
872 :
873 230 : auto Util::replaceFile(const QString &from, const QString &to, bool replace)
874 : -> bool {
875 : #ifdef Q_OS_WIN
876 : const std::wstring source =
877 : QDir::toNativeSeparators(QFileInfo(from).absoluteFilePath())
878 : .toStdWString();
879 : const std::wstring target =
880 : QDir::toNativeSeparators(QFileInfo(to).absoluteFilePath()).toStdWString();
881 : // WRITE_THROUGH: the new entry is on the device when this returns.
882 : return MoveFileExW(source.c_str(), target.c_str(),
883 : (replace ? MOVEFILE_REPLACE_EXISTING : 0) |
884 : MOVEFILE_WRITE_THROUGH) != 0;
885 : #else
886 : const QByteArray source = QFile::encodeName(from);
887 : const QByteArray target = QFile::encodeName(to);
888 230 : if (replace) {
889 139 : if (::rename(source.constData(), target.constData()) != 0) {
890 : return false;
891 : }
892 : } else {
893 91 : if (!placeWithoutReplacing(source, target)) {
894 : return false;
895 : }
896 : }
897 : // Sync the directory entry so a crash does not lose the new name. Best
898 : // effort: the bytes are already synced, and some network filesystems
899 : // cannot sync a directory.
900 434 : const int dir = ::open(QFile::encodeName(QFileInfo(to).path()).constData(),
901 : O_RDONLY | O_DIRECTORY | O_CLOEXEC);
902 217 : if (dir >= 0) {
903 : int rc;
904 : do {
905 217 : rc = ::fsync(dir);
906 217 : } while (rc != 0 && errno == EINTR);
907 217 : ::close(dir);
908 : }
909 : return true;
910 : #endif
911 : }
912 :
913 767 : auto Util::openRegularFile(const QString &path, QFile &file) -> bool {
914 : #ifdef Q_OS_WIN
915 : // FILE_FLAG_OPEN_REPARSE_POINT opens a symbolic link or junction itself
916 : // rather than its target, so the handle's attributes say what the name
917 : // was at the moment of the open.
918 : const std::wstring native =
919 : QDir::toNativeSeparators(QFileInfo(path).absoluteFilePath())
920 : .toStdWString();
921 : HANDLE handle = CreateFileW(
922 : native.c_str(), GENERIC_READ,
923 : FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
924 : OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
925 : if (handle == INVALID_HANDLE_VALUE) {
926 : return false;
927 : }
928 : BY_HANDLE_FILE_INFORMATION info{};
929 : if (!GetFileInformationByHandle(handle, &info) ||
930 : (info.dwFileAttributes &
931 : (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY |
932 : FILE_ATTRIBUTE_DEVICE)) != 0 ||
933 : GetFileType(handle) != FILE_TYPE_DISK) {
934 : CloseHandle(handle);
935 : return false;
936 : }
937 : const int fd = _open_osfhandle(reinterpret_cast<intptr_t>(handle),
938 : _O_RDONLY | _O_BINARY);
939 : if (fd < 0) {
940 : CloseHandle(handle);
941 : return false;
942 : }
943 : if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) {
944 : _close(fd);
945 : return false;
946 : }
947 : return true;
948 : #else
949 : // O_NOFOLLOW fails with ELOOP on a symbolic link; O_NONBLOCK keeps a FIFO
950 : // from blocking the open until fstat() can refuse it.
951 767 : const int fd = ::open(QFile::encodeName(path).constData(),
952 : O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
953 767 : if (fd < 0) {
954 : return false;
955 : }
956 714 : struct stat st{};
957 714 : if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
958 7 : ::close(fd);
959 7 : return false;
960 : }
961 : // The descriptor, not the name, is what QFile opens here: the object
962 : // fstat() judged is the object read. CodeQL's check-then-use pattern
963 : // matcher sees an open after a check and cannot tell.
964 707 : if (!file.open(fd, QIODevice::ReadOnly, // codeql[cpp/toctou-race-condition]
965 : QFileDevice::AutoCloseHandle)) {
966 0 : ::close(fd);
967 0 : return false;
968 : }
969 : return true;
970 : #endif
971 : }
972 :
973 245 : auto Util::readRegularFile(const QString &path, qint64 maxBytes)
974 : -> std::optional<QByteArray> {
975 245 : QFile file;
976 245 : if (!openRegularFile(path, file)) {
977 11 : return std::nullopt;
978 : }
979 : // A regular file's size is reliable, also when sparse: refuse a large one
980 : // before reading anything. One byte past the limit still guards a file
981 : // that grows meanwhile.
982 234 : if (file.size() > maxBytes) {
983 3 : return std::nullopt;
984 : }
985 231 : QByteArray bytes = file.read(maxBytes + 1);
986 231 : if (bytes.size() > maxBytes || file.error() != QFileDevice::NoError) {
987 0 : return std::nullopt;
988 : }
989 : return bytes;
990 245 : }
991 :
992 219 : auto Util::syncToDisk(QFileDevice &file) -> bool {
993 219 : if (!file.flush()) {
994 : return false;
995 : }
996 : #ifdef Q_OS_WIN
997 : const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
998 : return handle != INVALID_HANDLE_VALUE && FlushFileBuffers(handle) != 0;
999 : #else
1000 219 : return ::fsync(file.handle()) == 0;
1001 : #endif
1002 : }
1003 :
1004 : namespace {
1005 :
1006 : /**
1007 : * @brief What tells one file object from another on its filesystem: device
1008 : * and inode on POSIX, volume serial and file index on Windows.
1009 : */
1010 : struct FileIdentity {
1011 : quint64 volume = 0;
1012 : quint64 index = 0;
1013 : bool known = false;
1014 : };
1015 :
1016 208 : auto operator==(const FileIdentity &a, const FileIdentity &b) -> bool {
1017 208 : return a.known && b.known && a.volume == b.volume && a.index == b.index;
1018 : }
1019 :
1020 : /// The identity of the object @p file is open on, or an unknown one.
1021 427 : auto identityOf(const QFileDevice &file) -> FileIdentity {
1022 : #ifdef Q_OS_WIN
1023 : const HANDLE handle = reinterpret_cast<HANDLE>(_get_osfhandle(file.handle()));
1024 : BY_HANDLE_FILE_INFORMATION info;
1025 : if (handle == INVALID_HANDLE_VALUE ||
1026 : GetFileInformationByHandle(handle, &info) == 0) {
1027 : return {};
1028 : }
1029 : // On FAT/exFAT the file ID is the directory entry's offset, which a rename
1030 : // can move. They have no hard links either (the swap this catches needs
1031 : // one), so the identity is left unknown there.
1032 : DWORD flags = 0;
1033 : if (GetVolumeInformationByHandleW(handle, nullptr, 0, nullptr, nullptr,
1034 : &flags, nullptr, 0) == 0 ||
1035 : (flags & FILE_SUPPORTS_HARD_LINKS) == 0) {
1036 : return {};
1037 : }
1038 : return {info.dwVolumeSerialNumber,
1039 : (static_cast<quint64>(info.nFileIndexHigh) << 32) |
1040 : info.nFileIndexLow,
1041 : true};
1042 : #else
1043 427 : struct stat st{};
1044 427 : if (::fstat(file.handle(), &st) != 0) {
1045 0 : return {};
1046 : }
1047 427 : return {static_cast<quint64>(st.st_dev), static_cast<quint64>(st.st_ino),
1048 427 : true};
1049 : #endif
1050 : }
1051 :
1052 : /// Put @p text in @p error when there is one; false, for `return fail(...)`.
1053 : auto fail(QString *error, const QString &text) -> bool {
1054 21 : if (error != nullptr) {
1055 21 : *error = text;
1056 : }
1057 : return false;
1058 : }
1059 :
1060 : /// A filled, synced temporary next to its destination.
1061 665 : struct Staged {
1062 : QString path;
1063 : FileIdentity identity;
1064 : };
1065 :
1066 : /**
1067 : * @brief Create the temporary next to @p path (opaque name, exclusive,
1068 : * owner-only), fill it through its handle and sync it. The QTemporaryFile
1069 : * goes out of scope before the caller renames: it keeps its handle open for
1070 : * as long as it lives, and Windows does not rename an open file.
1071 : */
1072 227 : auto stageNextTo(const QString &path, const Util::Filler &fill, QString *why)
1073 : -> std::optional<Staged> {
1074 227 : Staged staged;
1075 : {
1076 454 : QTemporaryFile file(QFileInfo(path).path() +
1077 454 : QStringLiteral("/.qtpass-XXXXXX.tmp"));
1078 227 : file.setAutoRemove(false);
1079 227 : if (!file.open()) {
1080 8 : *why = QCoreApplication::translate(
1081 : "Util", "Cannot create a temporary file next to %1: %2")
1082 16 : .arg(path, file.errorString());
1083 8 : return std::nullopt;
1084 : }
1085 219 : staged.path = file.fileName();
1086 : // Owner-only: a .gpg-id names the keys a store is encrypted to, an
1087 : // entry is an entry. QTemporaryFile creates 0600 already; say so for
1088 : // platforms where it may not.
1089 219 : file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
1090 438 : *why = fill(file);
1091 219 : if (why->isEmpty() && !Util::syncToDisk(file)) {
1092 0 : *why = QCoreApplication::translate("Util", "Cannot write %1: %2")
1093 0 : .arg(path, file.errorString());
1094 : }
1095 219 : staged.identity = identityOf(file);
1096 227 : }
1097 219 : if (!why->isEmpty()) {
1098 0 : QFile::remove(staged.path);
1099 0 : return std::nullopt;
1100 : }
1101 : return staged;
1102 : }
1103 :
1104 : /// Why replaceFile() could not put the temporary under @p path.
1105 9 : auto placementError(const QString &path, bool replace) -> QString {
1106 9 : if (replace) {
1107 4 : return QCoreApplication::translate("Util", "Failed to replace %1.")
1108 2 : .arg(path);
1109 : }
1110 7 : const QFileInfo taken(path);
1111 8 : return taken.exists() || taken.isSymLink()
1112 7 : ? QCoreApplication::translate("Util", "%1 already exists.")
1113 : .arg(path)
1114 1 : : QCoreApplication::translate("Util", "Failed to write %1.")
1115 8 : .arg(path);
1116 7 : }
1117 :
1118 : /**
1119 : * @brief Whether what is under @p path is the file that was filled: not a
1120 : * link, and not another file (a hard link to something of the user's, say)
1121 : * swapped in under the temporary's name. Opened without following, compared
1122 : * by identity; only two known identities can prove a swap, otherwise every
1123 : * write on such a store would be reported as tampering.
1124 : */
1125 210 : auto isTheFileWritten(const QString &path, const FileIdentity &written)
1126 : -> bool {
1127 210 : QFile placed;
1128 210 : if (!Util::openRegularFile(path, placed)) {
1129 : return false;
1130 : }
1131 208 : const FileIdentity there = identityOf(placed);
1132 208 : if (!written.known || !there.known) {
1133 0 : qCWarning(lcQtPass) << "Cannot tell whether" << path
1134 0 : << "is the file that was written here";
1135 0 : return true;
1136 : }
1137 208 : return there == written;
1138 210 : }
1139 :
1140 : } // namespace
1141 :
1142 227 : auto Util::stageFileReplacing(const QString &path, bool replace,
1143 : const Filler &fill, QString *error) -> bool {
1144 227 : QString why;
1145 227 : const std::optional<Staged> staged = stageNextTo(path, fill, &why);
1146 227 : if (!staged) {
1147 : return fail(error, why);
1148 : }
1149 219 : if (!replaceFile(staged->path, path, replace)) {
1150 9 : QFile::remove(staged->path);
1151 18 : return fail(error, placementError(path, replace));
1152 : }
1153 : // A mismatch is reported and left: removing by name could take another
1154 : // writer's file.
1155 210 : if (!isTheFileWritten(path, staged->identity)) {
1156 : return fail(
1157 : error,
1158 4 : QCoreApplication::translate(
1159 : "Util", "%1 was swapped for another file while it was written.")
1160 8 : .arg(path));
1161 : }
1162 : return true;
1163 : }
1164 :
1165 106 : auto Util::writeFileReplacing(const QString &path, const QByteArray &bytes,
1166 : bool replace, QString *error) -> bool {
1167 106 : return stageFileReplacing(
1168 : path, replace,
1169 210 : [&path, &bytes](QFileDevice &staged) -> QString {
1170 104 : if (staged.write(bytes) != bytes.size()) {
1171 0 : return QCoreApplication::translate("Util", "Cannot write %1: %2")
1172 0 : .arg(path, staged.errorString());
1173 : }
1174 104 : return {};
1175 : },
1176 106 : error);
1177 : }
1178 :
1179 120 : auto Util::copyFileReplacing(const QString &src, const QString &dst,
1180 : bool replace, QString *error) -> bool {
1181 120 : QFile in;
1182 120 : if (!openRegularFile(src, in)) {
1183 4 : if (error)
1184 8 : *error = QCoreApplication::translate("Util", "Cannot read %1.").arg(src);
1185 4 : return false;
1186 : }
1187 116 : return stageFileReplacing(
1188 : dst, replace,
1189 342 : [&src, &dst, &in](QFileDevice &staged) -> QString {
1190 : char buf[64 * 1024];
1191 : for (;;) {
1192 232 : const qint64 n = in.read(buf, sizeof buf);
1193 232 : if (n < 0) {
1194 0 : return QCoreApplication::translate("Util", "Cannot read %1: %2")
1195 0 : .arg(src, in.errorString());
1196 : }
1197 232 : if (n == 0) {
1198 110 : return {};
1199 : }
1200 122 : if (staged.write(buf, n) != n) {
1201 0 : return QCoreApplication::translate("Util", "Cannot write %1: %2")
1202 0 : .arg(dst, staged.errorString());
1203 : }
1204 : }
1205 : },
1206 : error);
1207 120 : }
1208 :
1209 : namespace {
1210 :
1211 : /// Remove one directory entry that is not a real directory: a link goes as
1212 : /// the entry itself, never its target (a junction or a directory symlink on
1213 : /// Windows is a directory entry and goes with rmdir); a read-only file gets
1214 : /// write access and one more try, as QDir::removeRecursively() does.
1215 11 : auto removeEntry(const QFileInfo &entry) -> bool {
1216 11 : const QString path = entry.filePath();
1217 11 : if (isLink(entry)) {
1218 6 : if (QFile::remove(path) || QDir().rmdir(path)) {
1219 : return true;
1220 : }
1221 2 : qCWarning(lcQtPass) << "Could not remove link" << path;
1222 1 : return false;
1223 : }
1224 6 : if (QFile::remove(path)) {
1225 : return true;
1226 : }
1227 1 : const QFile::Permissions perms = QFile::permissions(path);
1228 1 : if (!perms.testFlag(QFile::WriteUser) &&
1229 2 : QFile::setPermissions(path, perms | QFile::WriteUser) &&
1230 1 : QFile::remove(path)) {
1231 : return true;
1232 : }
1233 2 : qCWarning(lcQtPass) << "Could not remove" << path;
1234 1 : return false;
1235 : }
1236 :
1237 : } // namespace
1238 :
1239 21 : auto Util::removeTree(const QString &dir) -> bool {
1240 : // A trailing separator makes lstat follow a link ("link/" is the target
1241 : // directory); the link itself is what this is about.
1242 21 : const QString path = QDir::cleanPath(dir);
1243 21 : const QFileInfo top(path);
1244 21 : if (isLink(top)) {
1245 : // rm -rf on a link removes the link.
1246 16 : return QFile::remove(path) || QDir().rmdir(path);
1247 : }
1248 7 : if (!top.isDir()) {
1249 : return false;
1250 : }
1251 : bool ok = true;
1252 : const QFileInfoList entries =
1253 12 : QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
1254 : QDir::System | QDir::NoDotAndDotDot,
1255 6 : QDir::Name);
1256 18 : for (const QFileInfo &entry : entries) {
1257 17 : const bool removed = entry.isDir() && !isLink(entry)
1258 13 : ? removeTree(entry.filePath())
1259 11 : : removeEntry(entry);
1260 12 : ok = removed && ok;
1261 : }
1262 11 : return ok && QDir().rmdir(path);
1263 21 : }
|