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

IJHack / QtPass / 35468717610

19 Sep 2026 08:52PM UTC coverage: 78.059% (-0.1%) from 78.167%
35468717610

Pull #1851

github

web-flow
Merge e377412f2 into 17bd44c92
Pull Request #1851: Store walks visit real directories only; junctions and links are not entries

84 of 129 new or added lines in 6 files covered. (65.12%)

9 existing lines in 3 files now uncovered.

6884 of 8819 relevant lines covered (78.06%)

74.73 hits per line

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

89.41
/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
#include "qtpasslogging.h"
32

33
QProcessEnvironment Util::_env;
34
bool Util::_envInitialised = false;
35

36
/**
37
 * @brief Initializes the process environment and augments PATH with
38
 * platform-specific GPG locations.
39
 * @example
40
 * Util::initialiseEnvironment();
41
 *
42
 * @note On macOS, appends common MacGPG2 and /usr/local/bin paths if available.
43
 * @note On Windows, appends common WinGPG and GnuPG installation paths if
44
 * available.
45
 */
46
void Util::initialiseEnvironment() {
257✔
47
  if (!_envInitialised) {
257✔
48
    _env = QProcessEnvironment::systemEnvironment();
6✔
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
    qCDebug(lcQtPass) << _env.value("PATH");
6✔
69
    _envInitialised = true;
6✔
70
  }
71
}
257✔
72

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

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

112
auto Util::normalizeFolderPath(const QString &path) -> QString {
117✔
113
  QString normalizedPath = path;
114
  if (!normalizedPath.endsWith('/')) {
117✔
115
    normalizedPath += '/';
111✔
116
  }
117
  return normalizedPath;
117✔
118
}
119

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

142
  initialiseEnvironment();
162✔
143

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

194
  return ret;
195
}
196

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

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

252
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
46✔
253

254
  if (const auto wsl = Executor::parseWslCommand(executable)) {
46✔
255
    // Probe WSL once per session — availability doesn't change at runtime
256
    // and the executeBlocking call is a blocking subprocess.
257
    static const bool wslAvailable = [&wsl]() {
×
258
      QString out;
×
259
      QString err;
×
260
      return Executor::executeBlocking(wsl->launcher,
×
261
                                       {QStringLiteral("--version")}, &out,
×
262
                                       &err) == 0 &&
×
263
             !out.isEmpty() && err.isEmpty();
×
264
    }();
×
265
    if (wslAvailable) {
×
266
      return true;
267
    }
268
  }
269
  return QFile(executable).exists();
46✔
270
}
271

272
/**
273
 * @brief Returns a regex matching strings that end with the .gpg extension.
274
 *
275
 * @return QRegularExpression reference
276
 */
277
auto Util::endsWithGpg() -> const QRegularExpression & {
757✔
278
  static const QRegularExpression expr{R"(\.gpg$)"};
757✔
279
  return expr;
757✔
280
}
281

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

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

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

386
/**
387
 * @brief Returns a regex matching newline characters (CR or LF).
388
 *
389
 * Useful for detecting or sanitising line breaks in text content.
390
 *
391
 * @return QRegularExpression reference
392
 */
393
auto Util::newLinesRegex() -> const QRegularExpression & {
109✔
394
  static const QRegularExpression regex{"[\r\n]"};
109✔
395
  return regex;
109✔
396
}
397

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

422
namespace {
423
/**
424
 * @brief Walk @p dir's real, visible directories in sorted pre-order and hand
425
 * every entry to @p visit before any recursion; a directory is entered only
426
 * when @p visit returns true for it.
427
 */
428
template <typename Visit> void walkStore(const QString &dir, Visit visit) {
80✔
429
  // Absolute, so the results are whatever the caller's cwd; not canonical,
430
  // so a linked root keeps the name it was configured under.
431
  QStringList pending{QDir(QDir::cleanPath(dir)).absolutePath()};
240✔
432
  while (!pending.isEmpty()) {
286✔
433
    const QDir current(pending.takeLast());
206✔
434
    // Every entry once, links and hidden entries included, so the decision
435
    // what to do with each is taken here, before any recursion. QDir::System
436
    // keeps dangling links in the listing.
437
    const QFileInfoList entries =
103✔
438
        current.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
439
                                  QDir::System | QDir::NoDotAndDotDot,
440
                              QDir::Name);
441
    QStringList subdirs;
103✔
442
    for (const QFileInfo &entry : entries) {
417✔
443
      if (visit(entry)) {
314✔
444
        subdirs << entry.filePath();
46✔
445
      }
446
    }
447
    // Pushed last-to-first so the next one taken is the first by name.
448
    for (auto it = subdirs.crbegin(); it != subdirs.crend(); ++it) {
126✔
449
      pending << *it;
450
    }
451
  }
452
}
160✔
453

454
/// A symlink or an NTFS junction: never entered, never listed.
455
auto isLink(const QFileInfo &entry) -> bool {
631✔
456
  return entry.isSymLink() || entry.isJunction();
631✔
457
}
458

459
/// A real directory that is part of the store: .git, .stversions,
460
/// .Trash-1000 are not, as with QDirIterator without QDir::Hidden.
461
auto isStoreDirectory(const QFileInfo &entry) -> bool {
314✔
462
  return !isLink(entry) && entry.isDir() && !entry.isHidden();
314✔
463
}
464
} // namespace
465

466
auto Util::regularFilesUnder(const QString &dir, const QStringList &nameFilters,
78✔
467
                             QStringList *skipped, bool hiddenFiles)
468
    -> QStringList {
469
  QStringList files;
78✔
470
  walkStore(dir, [&](const QFileInfo &entry) {
78✔
471
    if (isStoreDirectory(entry)) {
304✔
472
      return true;
473
    }
474
    const bool named = QDir::match(nameFilters, entry.fileName());
285✔
475
    if (isLink(entry) || (!entry.isDir() && !entry.isFile())) {
285✔
476
      // A linked directory hides everything behind it; a linked file, or a
477
      // FIFO, socket or device, is only of interest under a name the caller
478
      // asked for.
479
      if (skipped != nullptr && (entry.isDir() || named)) {
15✔
480
        qCWarning(lcQtPass) << "Skipping" << entry.filePath()
20✔
481
                            << ": not a regular file or directory";
10✔
482
        *skipped << entry.filePath();
20✔
483
      }
484
      return false;
15✔
485
    }
486
    if (entry.isFile() && named && (hiddenFiles || !entry.isHidden())) {
270✔
487
      files << entry.filePath();
144✔
488
    }
489
    return false;
490
  });
491
  return files;
78✔
492
}
493

494
auto Util::directoriesUnder(const QString &dir) -> QStringList {
2✔
495
  QStringList dirs;
2✔
496
  walkStore(dir, [&](const QFileInfo &entry) {
2✔
497
    if (!isStoreDirectory(entry)) {
10✔
498
      return false;
499
    }
500
    dirs << entry.filePath();
4✔
501
    return true;
4✔
502
  });
503
  return dirs;
2✔
504
}
505

506
auto Util::isLinkedFolder(const QString &path) -> bool {
18✔
507
  return isLink(QFileInfo(QDir::cleanPath(path)));
36✔
508
}
509

510
auto Util::isUnderLink(const QString &path, const QString &storeRoot) -> bool {
9✔
511
  const QString root = QDir::cleanPath(storeRoot);
9✔
512
  const QString prefix = root + QLatin1Char('/');
9✔
513
  QString current = QDir::cleanPath(path);
9✔
514
  if (!current.startsWith(prefix)) {
9✔
515
    // Not under the store as named: only the entry itself can be judged.
516
    return current != root && isLinkedFolder(current);
3✔
517
  }
518
  for (; current != root && current.startsWith(prefix);
13✔
519
       current = QFileInfo(current).path()) {
14✔
520
    if (isLinkedFolder(current)) {
11✔
521
      return true;
522
    }
523
  }
524
  return false;
525
}
526

527
auto Util::removeTree(const QString &dir) -> bool {
7✔
528
  // A trailing separator makes lstat follow a link ("link/" is the target
529
  // directory); the link itself is what this is about.
530
  const QString path = QDir::cleanPath(dir);
7✔
531
  const QFileInfo top(path);
7✔
532
  if (isLink(top)) {
7✔
533
    // rm -rf on a link removes the link.
534
    return QFile::remove(path) || QDir().rmdir(path);
3✔
535
  }
536
  if (!top.isDir()) {
4✔
537
    return false;
538
  }
539
  bool ok = true;
540
  const QFileInfoList entries =
541
      QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden |
6✔
542
                                   QDir::System | QDir::NoDotAndDotDot,
543
                               QDir::Name);
3✔
544
  for (const QFileInfo &entry : entries) {
10✔
545
    const QString entryPath = entry.filePath();
7✔
546
    if (isLink(entry)) {
7✔
547
      // The entry itself, never the target. A junction or a directory
548
      // symlink on Windows is a directory entry and goes with rmdir.
549
      if (!QFile::remove(entryPath) && !QDir().rmdir(entryPath)) {
3✔
NEW
550
        qCWarning(lcQtPass) << "Could not remove link" << entryPath;
×
551
        ok = false;
552
      }
553
    } else if (entry.isDir()) {
4✔
554
      ok = removeTree(entryPath) && ok;
1✔
555
    } else if (!QFile::remove(entryPath)) {
3✔
556
      // A read-only file blocks deletion on Windows; give it write access
557
      // and try once more, as QDir::removeRecursively() does.
NEW
558
      const QFile::Permissions perms = QFile::permissions(entryPath);
×
NEW
559
      if (perms.testFlag(QFile::WriteUser) ||
×
NEW
560
          !QFile::setPermissions(entryPath, perms | QFile::WriteUser) ||
×
NEW
561
          !QFile::remove(entryPath)) {
×
NEW
562
        qCWarning(lcQtPass) << "Could not remove" << entryPath;
×
563
        ok = false;
564
      }
565
    }
566
  }
567
  return ok && QDir().rmdir(path);
6✔
568
}
7✔
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