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 : }
|