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

IJHack / QtPass / 35078061277

16 Sep 2026 09:12AM UTC coverage: 70.406% (+0.06%) from 70.348%
35078061277

push

github

web-flow
Merge pull request #1759 from IJHack/test/2.0-isolate-and-prune

tests: isolate the last two suites, replace three phantom tests; single-source the version (#1682)

5 of 6 new or added lines in 1 file covered. (83.33%)

5222 of 7417 relevant lines covered (70.41%)

69.96 hits per line

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

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

4
/**
5
 * @class Util
6
 * @brief Static utility functions implementation.
7
 *
8
 * Implementation of utility functions for path handling, binary discovery,
9
 * and configuration validation.
10
 *
11
 * @see util.h
12
 */
13

14
#include "util.h"
15
#include "appsettings.h"
16
#include "executor.h"
17
#include <QDebug>
18
#include <QDir>
19
#include <QFile>
20
#include <QFileInfo>
21
#include <QHash>
22
#include <QRegularExpressionMatchIterator>
23
#include <QStandardPaths>
24
#include <QUrl>
25
#ifdef Q_OS_WIN
26
#include <windows.h>
27
#else
28
#include <sys/time.h>
29
#endif
30

31
#ifdef QT_DEBUG
32
#include "debughelper.h"
33
#endif
34

35
QProcessEnvironment Util::_env;
36
bool Util::_envInitialised = false;
37

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

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

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

116
auto Util::normalizeFolderPath(const QString &path) -> QString {
76 ✔
117
  QString normalizedPath = path;
118
  if (!normalizedPath.endsWith('/')) {
76 ✔
119
    normalizedPath += '/';
69 ✔
120
  }
121
  return normalizedPath;
76 ✔
122
}
123

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

146
  initialiseEnvironment();
114 ✔
147

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

200
  return ret;
201
}
202

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

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

258
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
30 ✔
259

260
  if (executable.startsWith(QStringLiteral("wsl "))) {
60 ✔
261
    // Probe WSL once per session — availability doesn't change at runtime
262
    // and the executeBlocking call is a blocking subprocess.
263
    static const bool wslAvailable = []() {
×
264
      QString out;
×
265
      QString err;
×
266
      return Executor::executeBlocking(QStringLiteral("wsl"),
×
267
                                       {QStringLiteral("--version")}, &out,
×
268
                                       &err) == 0 &&
×
269
             !out.isEmpty() && err.isEmpty();
×
270
    }();
×
271
    if (wslAvailable) {
×
272
      return true;
273
    }
274
  }
275
  return QFile(executable).exists();
30 ✔
276
}
277

278
/**
279
 * @brief Returns a directory path derived from a model index, optionally
280
 * relative to the pass store.
281
 * @example
282
 * QString result = Util::getDir(index, true, model, storeModel, passStore);
283
 * std::cout << result.toStdString() << std::endl; // Expected output: relative
284
 * directory path with trailing separator
285
 *
286
 * @param index Source index used to resolve the file or directory path.
287
 * @param forPass If true, returns a path relative to the pass store;
288
 * otherwise returns an absolute path.
289
 * @param model File system model used to obtain file information.
290
 * @param storeModel Proxy model used to map the provided index to the source
291
 * model.
292
 * @param passStore Absolute path to the password store root directory.
293
 * @return QString - The resolved directory path, always ending with the
294
 * platform's directory separator.
295
 */
296
auto Util::getDir(const QModelIndex &index, bool forPass,
5 ✔
297
                  const QFileSystemModel &model, const StoreModel &storeModel,
298
                  const QString &passStore) -> QString {
299
  QString abspath = QDir(passStore).absolutePath() + QDir::separator();
10 ✔
300
  if (!index.isValid()) {
301
    return forPass ? "" : abspath;
2 ✔
302
  }
303
  QFileInfo info = model.fileInfo(storeModel.mapToSource(index));
3 ✔
304
  QString filePath =
305
      (info.isFile() ? info.absolutePath() : info.absoluteFilePath());
3 ✔
306
  if (forPass) {
3 ✔
307
    filePath = QDir(abspath).relativeFilePath(filePath);
×
308
  }
309
  filePath += QDir::separator();
3 ✔
310
  return filePath;
311
}
3 ✔
312

313
/**
314
 * @brief Returns a regex matching strings that end with the .gpg extension.
315
 *
316
 * @return QRegularExpression reference
317
 */
318
auto Util::endsWithGpg() -> const QRegularExpression & {
245 ✔
319
  static const QRegularExpression expr{R"(\.gpg$)"};
245 ✔
320
  return expr;
245 ✔
321
}
322

323
/**
324
 * @brief Returns a regex matching common remote/network protocol schemes.
325
 *
326
 * Matches http://, https://, ftp://, ftps://, ssh://, sftp://, webdav://,
327
 * webdavs://
328
 *
329
 * The URL text ends at the first whitespace character (space, tab, CR, LF),
330
 * quote or bracket, so a URL on its own line in multi-line text (pass file
331
 * bodies, gpg stderr) is captured without the line break that follows it.
332
 *
333
 * Note: Local file URLs (file:///) are intentionally excluded by design, as
334
 * they represent local paths rather than network protocols. If this behavior
335
 * needs to change, update both this function and the corresponding test.
336
 *
337
 * @return QRegularExpression reference
338
 */
339
auto Util::protocolRegex() -> const QRegularExpression & {
87 ✔
340
  static const QRegularExpression regex{
341
      R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
87 ✔
342
  return regex;
87 ✔
343
}
344

345
/**
346
 * @brief Validate a value as a launchable http(s) URL.
347
 *
348
 * Security gate for the "open in browser" action. See util.h for the full
349
 * contract. Deliberately stricter than protocolRegex(): only http/https,
350
 * valid host, no embedded credentials, no control characters.
351
 *
352
 * @param value Candidate URL string.
353
 * @return true if launchable in a browser, false otherwise.
354
 */
355
auto Util::isLaunchableWebUrl(const QString &value) -> bool {
76 ✔
356
  const QString trimmed = value.trimmed();
357
  if (trimmed.isEmpty()) {
76 ✔
358
    return false;
359
  }
360
  // Reject control characters first, before QUrl normalisation can hide a
361
  // CR/LF/NUL injection into the OS URL handler.
362
  for (const QChar &c : trimmed) {
1,846 ✔
363
    if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
364
        c == QChar(QChar::Null)) {
365
      return false;
366
    }
367
  }
368
  const QUrl url(trimmed, QUrl::StrictMode);
72 ✔
369
  if (!url.isValid()) {
72 ✔
370
    return false;
371
  }
372
  const QString scheme = url.scheme().toLower();
124 ✔
373
  if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
119 ✔
374
    return false;
22 ✔
375
  }
376
  if (url.host().isEmpty()) {
80 ✔
377
    return false;
378
  }
379
  // Embedded userinfo (user:pass@host) would leak into browser history.
380
  if (!url.userName().isEmpty() || !url.password().isEmpty()) {
73 ✔
381
    return false;
382
  }
383
  return true;
384
}
72 ✔
385

386
/**
387
 * @brief Escape text as HTML and link only launchable http(s) URLs.
388
 *
389
 * See util.h for the contract. Detection uses protocolRegex() so that the
390
 * URL text is delimited the same way everywhere; the decision whether a
391
 * match becomes an anchor is isLaunchableWebUrl(), the same predicate that
392
 * gates the "open in browser" button.
393
 *
394
 * @param text Plain text, not yet HTML-escaped.
395
 * @param linked Set to true when at least one anchor was emitted.
396
 * @return HTML string safe to hand to QTextBrowser::setHtml().
397
 */
398
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
83 ✔
399
  if (linked != nullptr) {
83 ✔
400
    *linked = false;
74 ✔
401
  }
402
  QString html;
83 ✔
403
  html.reserve(text.size());
83 ✔
404
  qsizetype lastIndex = 0;
405
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
83 ✔
406
  while (it.hasNext()) {
115 ✔
407
    const QRegularExpressionMatch match = it.next();
32 ✔
408
    const QString url = match.captured(0);
32 ✔
409
    if (!isLaunchableWebUrl(url)) {
32 ✔
410
      // Not a web URL (or it carries credentials): leave it in the escaped
411
      // plain-text run instead of making it clickable.
412
      continue;
413
    }
414
    const qsizetype start = match.capturedStart(0);
19 ✔
415
    html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
19 ✔
416
    const QString escapedUrl = url.toHtmlEscaped();
19 ✔
417
    html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
38 ✔
418
    lastIndex = match.capturedEnd(0);
19 ✔
419
    if (linked != nullptr) {
19 ✔
420
      *linked = true;
14 ✔
421
    }
422
  }
32 ✔
423
  html += text.mid(lastIndex).toHtmlEscaped();
83 ✔
424
  return html;
83 ✔
425
}
83 ✔
426

427
/**
428
 * @brief Returns a regex matching newline characters (CR or LF).
429
 *
430
 * Useful for detecting or sanitising line breaks in text content.
431
 *
432
 * @return QRegularExpression reference
433
 */
434
auto Util::newLinesRegex() -> const QRegularExpression & {
64 ✔
435
  static const QRegularExpression regex{"[\r\n]"};
64 ✔
436
  return regex;
64 ✔
437
}
438

439
/**
440
 * @brief Validate whether a string is an accepted GPG key identifier.
441
 *
442
 * Mirrors what `pass` itself accepts in `.gpg-id`: every non-empty token is
443
 * handed to gpg as a `-r` argument, and gpg resolves it — key ID or
444
 * fingerprint of any version (v4 hex, v6 hex, with or without `0x`),
445
 * `<email>`, `=Exact User ID`, a plain name substring, or a `@`/`/`/`#`/`&`
446
 * routing prefix. No content heuristics are applied here: they can only
447
 * reject recipients gpg would have accepted, and a rejected line is not just
448
 * skipped but erased the next time `.gpg-id` is rewritten.
449
 *
450
 * The one thing rejected is a token starting with `-`: the recipient list is
451
 * also passed positionally to `gpg --list-keys`, where such a token would be
452
 * parsed as an option instead of a key selector.
453
 *
454
 * Empty input is invalid.
455
 *
456
 * @param keyId Input key identifier string to validate.
457
 * @return true unless the input is empty or starts with `-`.
458
 */
459
auto Util::isValidKeyId(const QString &keyId) -> bool {
113 ✔
460
  return !keyId.isEmpty() && !keyId.startsWith('-');
113 ✔
461
}
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