QtPass Developer documentation
QtPass test coverage
Current view: top level - src/filecontent.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 100.0 % 121 121
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 23 23

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2018 Anne Jan Brouwer
       2              : // SPDX-License-Identifier: GPL-3.0-or-later
       3              : /**
       4              :  * @file
       5              :  * @brief Implements FileContent::parse() as a single streaming pass over the
       6              :  * entry, with the field cap, the display-line cap and the OTP candidate
       7              :  * search, plus the cached secret-field-name matcher.
       8              :  */
       9              : #include "filecontent.h"
      10              : #include <QRegularExpression>
      11              : 
      12              : #include <QCoreApplication>
      13              : #include <QStringTokenizer>
      14              : 
      15              : #include <algorithm>
      16              : #include <limits>
      17              : 
      18              : #include <utility>
      19              : 
      20              : /**
      21              :  * @brief Shorthand for the public FileContent::isOtpUriValue.
      22              :  * @param value A line or field value.
      23              :  * @return true when it is an `otpauth://` URI.
      24              :  */
      25              : static auto isOtpUri(const QString &value) -> bool {
      26        23292 :   return FileContent::isOtpUriValue(value);
      27              : }
      28              : 
      29              : /**
      30              :  * @brief Whether a body line carries a TOTP shared secret and must stay out
      31              :  * of the parsed display: a bare `otpauth://` line, an `OTP:`/`TOTP:` field,
      32              :  * or any field whose value is an otpauth URI. "Display the file's content
      33              :  * as-is" bypasses this filter and shows the entry raw.
      34              :  * @param line One line of the entry body.
      35              :  * @return true when the line is kept out of getRemainingDataForDisplay().
      36              :  */
      37        20156 : static auto isLineHidden(const QString &line) -> bool {
      38        20156 :   if (isOtpUri(line)) {
      39              :     return true;
      40              :   }
      41        20141 :   const qsizetype colon = line.indexOf(':');
      42        20141 :   if (colon < 0) {
      43              :     return false;
      44              :   }
      45           82 :   return FileContent::isOtpFieldName(line.left(colon)) ||
      46           77 :          isOtpUri(line.right(line.length() - colon - 1));
      47              : }
      48              : 
      49              : namespace {
      50              : 
      51              : /**
      52              :  * @brief Where an OTP configuration was found, in the order getOtpUri()
      53              :  * prefers: a named field first, then a bare `otpauth://` line, then an
      54              :  * `OTP:` line the template did not promote to a field.
      55              :  */
      56              : struct OtpCandidates {
      57              :   QString field;          ///< Value of the first OTP-named or URI-valued field.
      58              :   QString bareLine;       ///< First bare `otpauth://` line (password line too).
      59              :   QString remainingField; ///< Value of the first `OTP:` line left as text.
      60              : 
      61              :   /**
      62              :    * @brief The first of the three that was found.
      63              :    * @return The configuration string, or empty when none was found.
      64              :    */
      65          143 :   auto pick() const -> QString {
      66          143 :     if (!field.isEmpty()) {
      67              :       return field;
      68              :     }
      69          123 :     return bareLine.isEmpty() ? remainingField : bareLine;
      70              :   }
      71              : };
      72              : 
      73              : /**
      74              :  * @brief Takes @p line as a field when the template lists it, "Template all
      75              :  * fields" is on and the value is not a URL, or @p alsoField asks for its
      76              :  * name; records any OTP configuration in @p otp either way.
      77              :  * @param line One line of the entry body.
      78              :  * @param templateFields Field names the template promotes.
      79              :  * @param allFields The "Template all fields" setting.
      80              :  * @param alsoField Extra predicate on the trimmed name; may be empty.
      81              :  * @param namedValues Receives the field when it is taken.
      82              :  * @param otp Receives the first OTP candidate of each kind.
      83              :  * @return true when the line became a field and is not remaining text.
      84              :  */
      85        23241 : auto takeNamedValue(const QString &line, const QStringList &templateFields,
      86              :                     bool allFields,
      87              :                     const std::function<bool(const QString &)> &alsoField,
      88              :                     NamedValues &namedValues, OtpCandidates &otp) -> bool {
      89        23241 :   const qsizetype colon = line.indexOf(u':');
      90        23241 :   if (colon < 0) {
      91              :     return false;
      92              :   }
      93         3141 :   const QString name = line.left(colon);
      94         3141 :   const QString value = line.right(line.length() - colon - 1);
      95              :   // A value starting with // means the colon is probably from a URL.
      96         3091 :   if (!templateFields.contains(name) &&
      97         9373 :       !(allFields && !value.startsWith(QStringLiteral("//"))) &&
      98         3150 :       !(alsoField && alsoField(name.trimmed()))) {
      99              :     // An `OTP:` line the template did not promote to a field.
     100           32 :     if (otp.remainingField.isEmpty() && FileContent::isOtpFieldName(name)) {
     101            3 :       otp.remainingField = value.trimmed();
     102              :     }
     103           32 :     return false;
     104              :   }
     105         6218 :   namedValues.append({name.trimmed(), value.trimmed(), line});
     106              :   // Keyed on the value as well as the name: a field called anything at all
     107              :   // whose value is an otpauth URI is still a shared secret.
     108         6197 :   if (otp.field.isEmpty() && (FileContent::isOtpFieldName(name) ||
     109         3088 :                               FileContent::isOtpUriValue(value))) {
     110           20 :     otp.field = value.trimmed();
     111              :   }
     112              :   return true;
     113              : }
     114              : 
     115              : /**
     116              :  * @brief The line ending a text cut at FileContent::kMaxDisplayLines.
     117              :  * @param count How many lines were left out.
     118              :  * @return The translated "… %n more line(s) not shown" line.
     119              :  */
     120            2 : auto moreLinesNotShown(qsizetype count) -> QString {
     121              :   // The count in a variable of its own: lupdate up to 6.8 loses a
     122              :   // translate() call with template arguments among its arguments and would
     123              :   // mark the string vanished.
     124              :   const int n = static_cast<int>(
     125            2 :       std::min<qsizetype>(count, std::numeric_limits<int>::max()));
     126              :   return QCoreApplication::translate("FileContent",
     127            2 :                                      "… %n more line(s) not shown", nullptr, n);
     128              : }
     129              : 
     130              : } // namespace
     131              : 
     132          143 : auto FileContent::parse(const QString &fileContent,
     133              :                         const QStringList &templateFields, bool allFields,
     134              :                         const std::function<bool(const QString &)> &alsoField)
     135              :     -> FileContent {
     136              :   // Line by line over the text, never a list of every line: a compressed
     137              :   // entry can expand into tens of millions of lines within the plaintext
     138              :   // limit, and a QString each (empty ones too) would take gigabytes.
     139              :   QStringTokenizer tokens = QStringView(fileContent).tokenize(u'\n');
     140              :   auto it = tokens.begin();
     141          143 :   QString password;
     142          143 :   if (it != tokens.end()) {
     143          143 :     password = it->toString();
     144              :     ++it;
     145              :   }
     146          143 :   QString remainingData;
     147          143 :   QString remainingDataDisplay;
     148              :   qsizetype remainingLines = 0;
     149              :   qsizetype displayedLines = 0;
     150              :   qsizetype notShown = 0;
     151              :   // Past kMaxNamedFields a line is still offered to takeNamedValue(), into
     152              :   // this throwaway list, for what it notes about OTP: the line stays text.
     153          143 :   NamedValues pastTheCap;
     154          143 :   NamedValues namedValues;
     155          143 :   OtpCandidates otp;
     156              :   // An entry from `pass otp insert` has the URI as its only (password) line,
     157              :   // which the loop below never sees.
     158          143 :   if (isOtpUri(password)) {
     159            3 :     otp.bareLine = password.trimmed();
     160              :   }
     161        23399 :   for (; it != tokens.end(); ++it) {
     162              :     const QString line = it->toString();
     163              :     bool secretPastTheCap = false;
     164              :     // A bare otpauth:// line is never a field, also not when the template
     165              :     // lists "otpauth": split at its colon, the value would lose the scheme
     166              :     // that marks it as a shared secret and be shown seed and all. Past
     167              :     // kMaxNamedFields, lines stay text.
     168        23256 :     if (!isOtpUri(line)) {
     169        23241 :       if (namedValues.size() < kMaxNamedFields) {
     170        23231 :         if (takeNamedValue(line, templateFields, allFields, alsoField,
     171              :                            namedValues, otp)) {
     172         3100 :           continue;
     173              :         }
     174              :       } else {
     175           10 :         takeNamedValue(line, templateFields, allFields, alsoField, pastTheCap,
     176              :                        otp);
     177              :         // A secret past the cap stays text, but not on screen: the panel
     178              :         // would have hidden it.
     179           13 :         secretPastTheCap = !pastTheCap.isEmpty() && alsoField &&
     180            3 :                            alsoField(pastTheCap.first().name);
     181           10 :         pastTheCap.clear();
     182              :       }
     183              :     }
     184              :     // Joined with "\n" as they go, as split() and join() did.
     185        20156 :     if (remainingLines > 0) {
     186        20058 :       remainingData += QLatin1Char('\n');
     187              :     }
     188              :     remainingData += line;
     189        20156 :     ++remainingLines;
     190        20156 :     if (isOtpUri(line) && otp.bareLine.isEmpty()) {
     191              :       // A bare otpauth:// line, the convention the pass-otp extension uses.
     192           14 :       otp.bareLine = line.trimmed();
     193              :     }
     194        20156 :     if (!isLineHidden(line) && !secretPastTheCap) {
     195        20135 :       if (displayedLines == kMaxDisplayLines) {
     196            7 :         ++notShown;
     197            7 :         continue;
     198              :       }
     199        20128 :       if (displayedLines > 0) {
     200        20039 :         remainingDataDisplay += QLatin1Char('\n');
     201              :       }
     202              :       remainingDataDisplay += line;
     203        20128 :       ++displayedLines;
     204              :     }
     205              :   }
     206          143 :   if (notShown > 0) {
     207            2 :     remainingDataDisplay += QLatin1Char('\n') + moreLinesNotShown(notShown);
     208              :   }
     209              :   // The OTP value stays in namedValues/remainingData: the edit dialog reads it
     210              :   // from there and PasswordDialog::getPassword() drops empty fields, so
     211              :   // stripping it here would delete the secret on the next save. Suppression
     212              :   // happens in getRemainingDataForDisplay() and PasswordDisplayPanel.
     213              :   return {password,   namedValues,   remainingData, remainingDataDisplay,
     214          429 :           otp.pick(), remainingLines};
     215          143 : }
     216              : 
     217         3357 : auto FileContent::isOtpFieldName(const QString &name) -> bool {
     218              :   const QString trimmed = name.trimmed();
     219        10012 :   return trimmed.compare(QStringLiteral("OTP"), Qt::CaseInsensitive) == 0 ||
     220         6655 :          trimmed.compare(QStringLiteral("TOTP"), Qt::CaseInsensitive) == 0;
     221              : }
     222              : 
     223           28 : auto FileContent::isSecretFieldName(const QString &name,
     224              :                                     const QStringList &secrets) -> bool {
     225              :   // One pattern for the whole list, compiled once per list: the panel asks
     226              :   // for every field of every entry shown, and the list rarely changes.
     227           28 :   thread_local QStringList cachedSecrets;
     228           28 :   thread_local QStringList cachedWords;
     229           28 :   thread_local QRegularExpression cachedPattern;
     230              :   thread_local bool cachedYet = false;
     231              :   constexpr auto options = QRegularExpression::CaseInsensitiveOption |
     232              :                            QRegularExpression::UseUnicodePropertiesOption;
     233        80008 :   const auto whole = [](const QString &alternatives) {
     234        80008 :     return QStringLiteral("(?<!\\w)(?:%1)(?!\\w)").arg(alternatives);
     235              :   };
     236           28 :   if (!cachedYet || secrets != cachedSecrets) {
     237            8 :     cachedWords.clear();
     238        40040 :     for (const QString &secret : secrets) {
     239              :       const QString word = secret.trimmed();
     240        40032 :       if (!word.isEmpty()) {
     241        40031 :         cachedWords << QRegularExpression::escape(word);
     242              :       }
     243              :     }
     244              :     cachedSecrets = secrets;
     245            8 :     cachedYet = true;
     246              :     cachedPattern =
     247           16 :         QRegularExpression(whole(cachedWords.join(QLatin1Char('|'))), options);
     248              :   }
     249           28 :   if (cachedWords.isEmpty()) {
     250              :     return false;
     251              :   }
     252           26 :   if (cachedPattern.isValid()) {
     253           24 :     return cachedPattern.match(name).hasMatch();
     254              :   }
     255              :   // Too many words for one pattern (PCRE2's size limit): word by word,
     256              :   // rather than every secret field shown in plain view.
     257            2 :   return std::any_of(
     258              :       cachedWords.cbegin(), cachedWords.cend(),
     259        80000 :       [&name, &whole, options](const QString &word) {
     260       160000 :         return QRegularExpression(whole(word), options).match(name).hasMatch();
     261            2 :       });
     262              : }
     263              : 
     264        66991 : auto FileContent::isOtpUriValue(const QString &value) -> bool {
     265       133982 :   return value.trimmed().startsWith(QStringLiteral("otpauth://"),
     266        66991 :                                     Qt::CaseInsensitive);
     267              : }
     268              : 
     269           84 : auto FileContent::getPassword() const -> QString { return this->password; }
     270              : 
     271           18 : auto FileContent::getPasswordForDisplay() const -> QString {
     272           18 :   return isOtpUri(this->password) ? QString() : this->password;
     273              : }
     274              : 
     275          108 : auto FileContent::getNamedValues() const -> NamedValues {
     276          108 :   return this->namedValues;
     277              : }
     278              : 
     279           86 : auto FileContent::getRemainingData() const -> QString {
     280           86 :   return this->remainingData;
     281              : }
     282              : 
     283           32 : auto FileContent::getRemainingDataForDisplay() const -> QString {
     284           32 :   return this->remainingDataDisplay;
     285              : }
     286              : 
     287           29 : auto FileContent::getOtpUri() const -> QString { return this->otpUri; }
     288              : 
     289          143 : FileContent::FileContent(QString password, NamedValues namedValues,
     290              :                          QString remainingData, QString remainingDataDisplay,
     291          143 :                          QString otpUri, qsizetype remainingLines)
     292              :     : password(std::move(password)), namedValues(std::move(namedValues)),
     293              :       remainingData(std::move(remainingData)),
     294              :       remainingDataDisplay(std::move(remainingDataDisplay)),
     295          143 :       otpUri(std::move(otpUri)), remainingLines(remainingLines) {}
     296              : 
     297            3 : auto FileContent::boundedForDisplay(const QString &text) -> QString {
     298              :   // Where the line after the last one shown starts, if there is one.
     299              :   qsizetype end = -1;
     300        20002 :   for (int line = 0; line < kMaxDisplayLines; ++line) {
     301        20001 :     end = text.indexOf(QLatin1Char('\n'), end + 1);
     302        20001 :     if (end < 0) {
     303              :       return text;
     304              :     }
     305              :   }
     306              :   const qsizetype more =
     307            1 :       text.count(QLatin1Char('\n'), Qt::CaseSensitive) - (kMaxDisplayLines - 1);
     308            2 :   return text.left(end) + QLatin1Char('\n') + moreLinesNotShown(more);
     309              : }
     310              : 
     311            1 : auto FileContent::getRemainingLineCount() const -> qsizetype {
     312            1 :   return remainingLines;
     313              : }
     314              : 
     315          299 : NamedValues::NamedValues() = default;
     316              : 
     317           48 : NamedValues::NamedValues(std::initializer_list<NamedValue> values)
     318           48 :     : QList(values) {}
     319              : 
     320           11 : auto NamedValues::takeValue(const QString &name) -> QString {
     321           11 :   return takeNamedValue(name).value;
     322              : }
     323              : 
     324           29 : auto NamedValues::takeNamedValue(const QString &name) -> NamedValue {
     325           37 :   for (int i = 0; i < length(); ++i) {
     326           28 :     if (at(i).name == name) {
     327           20 :       return takeAt(i);
     328              :     }
     329              :   }
     330            9 :   return {};
     331              : }
        

Generated by: LCOV version 2.0-1