diff --git a/docs/topics/Reference.adoc b/docs/topics/Reference.adoc index 4171ed9a5..ce55b8a76 100644 --- a/docs/topics/Reference.adoc +++ b/docs/topics/Reference.adoc @@ -18,6 +18,8 @@ This section contains full details on advanced features available in KeePassXC. |{NOTES} |Notes |{TOTP} |Current TOTP value (if configured) |{S:<ATTRIBUTE_NAME>} |Value for the given attribute (case sensitive) +|{T-CONV:/<PLACEHOLDER>/<METHOD>/} |Text conversion for resolved placeholder (eg, {USERNAME}) using the following methods: UPPER, LOWER, BASE64, HEX, URI, URI-DEC +|{T-REPLACE-RX:/<PLACEHOLDER>/<REGEX>/<REPLACE>/} |Use a regular expression to find and replace data from a resolved placeholder (eg, {USERNAME}). Refer to match groups using $1, $2, etc. |{URL:RMVSCM} |URL without scheme (e.g., https) |{URL:WITHOUTSCHEME} |URL without scheme |{URL:SCM} |URL Scheme diff --git a/src/autotype/AutoType.cpp b/src/autotype/AutoType.cpp index 1af6b3835..e87357aa3 100644 --- a/src/autotype/AutoType.cpp +++ b/src/autotype/AutoType.cpp @@ -689,74 +689,23 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin } else if (placeholder.startsWith("t-conv:")) { // Reset to the original capture to preserve case placeholder = match.captured(3); - placeholder.replace("t-conv:", "", Qt::CaseInsensitive); - if (!placeholder.isEmpty()) { - auto sep = placeholder[0]; - auto parts = placeholder.split(sep); - if (parts.size() >= 4) { - auto resolved = entry->resolveMultiplePlaceholders(parts[1]); - auto type = parts[2].toLower(); - - if (type == "base64") { - resolved = resolved.toUtf8().toBase64(); - } else if (type == "hex") { - resolved = resolved.toUtf8().toHex(); - } else if (type == "uri") { - resolved = QUrl::toPercentEncoding(resolved.toUtf8()); - } else if (type == "uri-dec") { - resolved = QUrl::fromPercentEncoding(resolved.toUtf8()); - } else if (type.startsWith("u")) { - resolved = resolved.toUpper(); - } else if (type.startsWith("l")) { - resolved = resolved.toLower(); - } else { - error = tr("Invalid conversion type: %1").arg(type); - return {}; - } - for (const QChar& ch : resolved) { - actions << QSharedPointer::create(ch); - } - } else { - error = tr("Invalid conversion syntax: %1").arg(fullPlaceholder); - return {}; - } - } else { - error = tr("Invalid conversion syntax: %1").arg(fullPlaceholder); + auto resolved = entry->resolveConversionPlaceholder(placeholder, &error); + if (!error.isEmpty()) { return {}; } + for (const QChar& ch : resolved) { + actions << QSharedPointer::create(ch); + } } else if (placeholder.startsWith("t-replace-rx:")) { // Reset to the original capture to preserve case placeholder = match.captured(3); - placeholder.replace("t-replace-rx:", "", Qt::CaseInsensitive); - if (!placeholder.isEmpty()) { - auto sep = placeholder[0]; - auto parts = placeholder.split(sep); - if (parts.size() >= 5) { - auto resolvedText = entry->resolveMultiplePlaceholders(parts[1]); - auto resolvedSearch = entry->resolveMultiplePlaceholders(parts[2]); - auto resolvedReplace = entry->resolveMultiplePlaceholders(parts[3]); - // Replace $ with \\ to support Qt substitutions - resolvedReplace.replace(QRegularExpression(R"(\$(\d+))"), R"(\\1)"); - - auto searchRegex = QRegularExpression(resolvedSearch); - if (!searchRegex.isValid()) { - error = tr("Invalid regular expression syntax %1\n%2") - .arg(resolvedSearch, searchRegex.errorString()); - return {}; - } - - auto resolved = resolvedText.replace(searchRegex, resolvedReplace); - for (const QChar& ch : resolved) { - actions << QSharedPointer::create(ch); - } - } else { - error = tr("Invalid conversion syntax: %1").arg(fullPlaceholder); - return {}; - } - } else { - error = tr("Invalid conversion syntax: %1").arg(fullPlaceholder); + auto resolved = entry->resolveRegexPlaceholder(placeholder, &error); + if (!error.isEmpty()) { return {}; } + for (const QChar& ch : resolved) { + actions << QSharedPointer::create(ch); + } } else if (placeholder.startsWith("mode=")) { auto mode = AutoTypeExecutor::Mode::NORMAL; if (placeholder.endsWith("virtual")) { diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 9a85685b7..9587e8d67 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -1030,9 +1030,9 @@ void Entry::updateModifiedSinceBegin() QString Entry::resolveMultiplePlaceholdersRecursive(const QString& str, int maxDepth) const { - static const QRegularExpression placeholderRegEx(R"(\{[^}]+\})"); + static const QRegularExpression placeholderRegEx("({(?>[^{}]+?|(?1))+?})"); - if (maxDepth <= 0) { + if (--maxDepth < 0) { qWarning("Maximum depth of replacement has been reached. Entry uuid: %s", uuid().toString().toLatin1().data()); return str; } @@ -1043,7 +1043,7 @@ QString Entry::resolveMultiplePlaceholdersRecursive(const QString& str, int maxD while (matches.hasNext()) { const auto match = matches.next(); result += str.midRef(capEnd, match.capturedStart() - capEnd); - result += resolvePlaceholderRecursive(match.captured(), maxDepth - 1); + result += resolvePlaceholderRecursive(match.captured(), maxDepth); capEnd = match.capturedEnd(); } result += str.rightRef(str.length() - capEnd); @@ -1052,7 +1052,7 @@ QString Entry::resolveMultiplePlaceholdersRecursive(const QString& str, int maxD QString Entry::resolvePlaceholderRecursive(const QString& placeholder, int maxDepth) const { - if (maxDepth <= 0) { + if (--maxDepth < 0) { qWarning("Maximum depth of replacement has been reached. Entry uuid: %s", uuid().toString().toLatin1().data()); return placeholder; } @@ -1060,21 +1060,20 @@ QString Entry::resolvePlaceholderRecursive(const QString& placeholder, int maxDe const PlaceholderType typeOfPlaceholder = placeholderType(placeholder); switch (typeOfPlaceholder) { case PlaceholderType::NotPlaceholder: - return resolveMultiplePlaceholdersRecursive(placeholder, maxDepth - 1); + return resolveMultiplePlaceholdersRecursive(placeholder, maxDepth); case PlaceholderType::Unknown: { - return "{" % resolveMultiplePlaceholdersRecursive(placeholder.mid(1, placeholder.length() - 2), maxDepth - 1) - % "}"; + return "{" % resolveMultiplePlaceholdersRecursive(placeholder.mid(1, placeholder.length() - 2), maxDepth) % "}"; } case PlaceholderType::Title: - return resolveMultiplePlaceholdersRecursive(title(), maxDepth - 1); + return resolveMultiplePlaceholdersRecursive(title(), maxDepth); case PlaceholderType::UserName: - return resolveMultiplePlaceholdersRecursive(username(), maxDepth - 1); + return resolveMultiplePlaceholdersRecursive(username(), maxDepth); case PlaceholderType::Password: - return resolveMultiplePlaceholdersRecursive(password(), maxDepth - 1); + return resolveMultiplePlaceholdersRecursive(password(), maxDepth); case PlaceholderType::Notes: - return resolveMultiplePlaceholdersRecursive(notes(), maxDepth - 1); + return resolveMultiplePlaceholdersRecursive(notes(), maxDepth); case PlaceholderType::Url: - return resolveMultiplePlaceholdersRecursive(url(), maxDepth - 1); + return resolveMultiplePlaceholdersRecursive(url(), maxDepth); case PlaceholderType::DbDir: { QFileInfo fileInfo(database()->filePath()); return fileInfo.absoluteDir().absolutePath(); @@ -1089,7 +1088,7 @@ QString Entry::resolvePlaceholderRecursive(const QString& placeholder, int maxDe case PlaceholderType::UrlUserInfo: case PlaceholderType::UrlUserName: case PlaceholderType::UrlPassword: { - const QString strUrl = resolveMultiplePlaceholdersRecursive(url(), maxDepth - 1); + const QString strUrl = resolveMultiplePlaceholdersRecursive(url(), maxDepth); return resolveUrlPlaceholder(strUrl, typeOfPlaceholder); } case PlaceholderType::Totp: @@ -1097,10 +1096,11 @@ QString Entry::resolvePlaceholderRecursive(const QString& placeholder, int maxDe return totp(); case PlaceholderType::CustomAttribute: { const QString key = placeholder.mid(3, placeholder.length() - 4); // {S:attr} => mid(3, len - 4) - return attributes()->hasKey(key) ? attributes()->value(key) : QString(); + return attributes()->hasKey(key) ? resolveMultiplePlaceholdersRecursive(attributes()->value(key), maxDepth) + : QString(); } case PlaceholderType::Reference: - return resolveReferencePlaceholderRecursive(placeholder, maxDepth); + return resolveReferencePlaceholderRecursive(placeholder, ++maxDepth); case PlaceholderType::DateTimeSimple: case PlaceholderType::DateTimeYear: case PlaceholderType::DateTimeMonth: @@ -1115,7 +1115,11 @@ QString Entry::resolvePlaceholderRecursive(const QString& placeholder, int maxDe case PlaceholderType::DateTimeUtcHour: case PlaceholderType::DateTimeUtcMinute: case PlaceholderType::DateTimeUtcSecond: - return resolveMultiplePlaceholdersRecursive(resolveDateTimePlaceholder(typeOfPlaceholder), maxDepth - 1); + return resolveMultiplePlaceholdersRecursive(resolveDateTimePlaceholder(typeOfPlaceholder), maxDepth); + case PlaceholderType::Conversion: + return resolveMultiplePlaceholdersRecursive(resolveConversionPlaceholder(placeholder), maxDepth); + case PlaceholderType::Regex: + return resolveMultiplePlaceholdersRecursive(resolveRegexPlaceholder(placeholder), maxDepth); } return placeholder; @@ -1164,9 +1168,93 @@ QString Entry::resolveDateTimePlaceholder(Entry::PlaceholderType placeholderType return {}; } +QString Entry::resolveConversionPlaceholder(const QString& str, QString* error) const +{ + if (error) { + error->clear(); + } + + // Extract the inner conversion from the placeholder + QRegularExpression conversionRegEx("^{?t-conv:(.*)}?$", QRegularExpression::CaseInsensitiveOption); + auto placeholder = conversionRegEx.match(str).captured(1); + if (!placeholder.isEmpty()) { + // Determine the separator character and split, include empty groups + auto sep = placeholder[0]; + auto parts = placeholder.split(sep); + if (parts.size() >= 4) { + auto resolved = resolveMultiplePlaceholders(parts[1]); + auto type = parts[2].toLower(); + + if (type == "base64") { + resolved = resolved.toUtf8().toBase64(); + } else if (type == "hex") { + resolved = resolved.toUtf8().toHex(); + } else if (type == "uri") { + resolved = QUrl::toPercentEncoding(resolved.toUtf8()); + } else if (type == "uri-dec") { + resolved = QUrl::fromPercentEncoding(resolved.toUtf8()); + } else if (type.startsWith("u")) { + resolved = resolved.toUpper(); + } else if (type.startsWith("l")) { + resolved = resolved.toLower(); + } else { + if (error) { + *error = tr("Invalid conversion type: %1").arg(type); + } + return {}; + } + return resolved; + } + } + + if (error) { + *error = tr("Invalid conversion syntax: %1").arg(str); + } + return {}; +} + +QString Entry::resolveRegexPlaceholder(const QString& str, QString* error) const +{ + if (error) { + error->clear(); + } + + // Extract the inner regex from the placeholder + QRegularExpression conversionRegEx("^{?t-replace-rx:(.*)}?$", QRegularExpression::CaseInsensitiveOption); + auto placeholder = conversionRegEx.match(str).captured(1); + if (!placeholder.isEmpty()) { + // Determine the separator character and split, include empty groups + auto sep = placeholder[0]; + auto parts = placeholder.split(sep); + if (parts.size() >= 5) { + auto resolvedText = resolveMultiplePlaceholders(parts[1]); + auto resolvedSearch = resolveMultiplePlaceholders(parts[2]); + auto resolvedReplace = resolveMultiplePlaceholders(parts[3]); + // Replace $ with \\ to support Qt substitutions + resolvedReplace.replace(QRegularExpression(R"(\$(\d+))"), R"(\\1)"); + + auto searchRegex = QRegularExpression(resolvedSearch); + if (!searchRegex.isValid()) { + if (error) { + *error = + tr("Invalid regular expression syntax %1\n%2").arg(resolvedSearch, searchRegex.errorString()); + } + return {}; + } + + return resolvedText.replace(searchRegex, resolvedReplace); + } + } + + if (error) { + *error = tr("Invalid conversion syntax: %1").arg(str); + } + return {}; +} + QString Entry::resolveReferencePlaceholderRecursive(const QString& placeholder, int maxDepth) const { - if (maxDepth <= 0) { + if (--maxDepth < 0) { qWarning("Maximum depth of replacement has been reached. Entry uuid: %s", uuid().toString().toLatin1().data()); return placeholder; } @@ -1194,7 +1282,7 @@ QString Entry::resolveReferencePlaceholderRecursive(const QString& placeholder, // Referencing fields of other entries only works with standard fields, not with custom user strings. // If you want to reference a custom user string, you need to place a redirection in a standard field // of the entry with the custom string, using {S:}, and reference the standard field. - result = refEntry->resolveMultiplePlaceholdersRecursive(result, maxDepth - 1); + result = refEntry->resolveMultiplePlaceholdersRecursive(result, maxDepth); } return result; @@ -1369,15 +1457,21 @@ QString Entry::resolveUrlPlaceholder(const QString& str, Entry::PlaceholderType Entry::PlaceholderType Entry::placeholderType(const QString& placeholder) const { - if (!placeholder.startsWith(QLatin1Char('{')) || !placeholder.endsWith(QLatin1Char('}'))) { + if (!placeholder.startsWith(QStringLiteral("{")) || !placeholder.endsWith(QStringLiteral("}"))) { return PlaceholderType::NotPlaceholder; } - if (placeholder.startsWith(QLatin1Literal("{S:"))) { + if (placeholder.startsWith(QStringLiteral("{S:"))) { return PlaceholderType::CustomAttribute; } - if (placeholder.startsWith(QLatin1Literal("{REF:"))) { + if (placeholder.startsWith(QStringLiteral("{REF:"))) { return PlaceholderType::Reference; } + if (placeholder.startsWith(QStringLiteral("{T-CONV:"), Qt::CaseInsensitive)) { + return PlaceholderType::Conversion; + } + if (placeholder.startsWith(QStringLiteral("{T-REPLACE-RX:"), Qt::CaseInsensitive)) { + return PlaceholderType::Regex; + } static const QMap placeholders{ {QStringLiteral("{TITLE}"), PlaceholderType::Title}, diff --git a/src/core/Entry.h b/src/core/Entry.h index e434d8417..749a9fe54 100644 --- a/src/core/Entry.h +++ b/src/core/Entry.h @@ -225,7 +225,9 @@ public: DateTimeUtcHour, DateTimeUtcMinute, DateTimeUtcSecond, - DbDir + DbDir, + Conversion, + Regex }; static const int DefaultIconNumber; @@ -244,6 +246,8 @@ public: QString resolvePlaceholder(const QString& str) const; QString resolveUrlPlaceholder(const QString& str, PlaceholderType placeholderType) const; QString resolveDateTimePlaceholder(PlaceholderType placeholderType) const; + QString resolveConversionPlaceholder(const QString& str, QString* error = nullptr) const; + QString resolveRegexPlaceholder(const QString& str, QString* error = nullptr) const; PlaceholderType placeholderType(const QString& placeholder) const; QString resolveUrl(const QString& url) const; diff --git a/tests/TestEntry.cpp b/tests/TestEntry.cpp index 5d4accd54..51cb4799c 100644 --- a/tests/TestEntry.cpp +++ b/tests/TestEntry.cpp @@ -514,6 +514,86 @@ void TestEntry::testResolveNonIdPlaceholdersToUuid() } } +void TestEntry::testResolveConversionPlaceholders() +{ + Database db; + auto* root = db.rootGroup(); + + auto* entry1 = new Entry(); + entry1->setGroup(root); + entry1->setUuid(QUuid::createUuid()); + entry1->setTitle("Title1 {T-CONV:/{USERNAME}/lower/} {T-CONV:/{PASSWORD}/upper/}"); + entry1->setUsername("Username1"); + entry1->setPassword("Password1"); + entry1->setUrl("https://example.com/?test=3423&h=sdsds"); + + auto* entry2 = new Entry(); + entry2->setGroup(root); + entry2->setUuid(QUuid::createUuid()); + entry2->setTitle("Title2"); + entry2->setUsername(QString("{T-CONV:/{REF:U@I:%1}/UPPER/}").arg(entry1->uuidToHex())); + entry2->setPassword(QString("{REF:P@I:%1}").arg(entry1->uuidToHex())); + entry2->setUrl("cmd://ssh {USERNAME}@server.com -p {PASSWORD}"); + + // Test complicated and nested conversions + QCOMPARE(entry1->resolveMultiplePlaceholders(entry1->title()), QString("Title1 username1 PASSWORD1")); + QCOMPARE(entry2->resolveMultiplePlaceholders(entry2->url()), + QString("cmd://ssh USERNAME1@server.com -p Password1")); + // Test base64 and hex conversions + QCOMPARE(entry1->resolveMultiplePlaceholders("{T-CONV:/{PASSWORD}/base64/}"), QString("UGFzc3dvcmQx")); + QCOMPARE(entry1->resolveMultiplePlaceholders("{T-CONV:/{PASSWORD}/hex/}"), QString("50617373776f726431")); + // Test URL encode and decode + auto encodedURL = entry1->resolveMultiplePlaceholders("{T-CONV:/{URL}/uri/}"); + QCOMPARE(encodedURL, QString("https%3A%2F%2Fexample.com%2F%3Ftest%3D3423%26h%3Dsdsds")); + QCOMPARE(entry1->resolveMultiplePlaceholders( + "{T-CONV:/https%3A%2F%2Fexample.com%2F%3Ftest%3D3423%26h%3Dsdsds/uri-dec/}"), + entry1->url()); + // Test invalid syntax + QString error; + entry1->resolveConversionPlaceholder("{T-CONV:/{USERNAME}/junk/}", &error); + QVERIFY(!error.isEmpty()); + entry1->resolveConversionPlaceholder("{T-CONV:}", &error); + QVERIFY(!error.isEmpty()); + // Check that error gets cleared + entry1->resolveConversionPlaceholder("{T-CONV:/a/upper/}", &error); + QVERIFY(error.isEmpty()); +} + +void TestEntry::testResolveReplacePlaceholders() +{ + Database db; + auto* root = db.rootGroup(); + + auto* entry1 = new Entry(); + entry1->setGroup(root); + entry1->setUuid(QUuid::createUuid()); + entry1->setTitle("Title1"); + entry1->setUsername("Username1"); + entry1->setPassword("Password1"); + + auto* entry2 = new Entry(); + entry2->setGroup(root); + entry2->setUuid(QUuid::createUuid()); + entry2->setTitle("SAP server1 12345"); + entry2->setUsername(QString("{T-REPLACE-RX:/{REF:U@I:%1}/\\d$/2/}").arg(entry1->uuidToHex())); + entry2->setPassword(QString("{REF:P@I:%1}").arg(entry1->uuidToHex())); + entry2->setUrl( + R"(cmd://sap.exe -system={T-REPLACE-RX:/{Title}/(?i)^(.* )?(\w+(?=(\s* \d+$)))\3/$2/} -client={T-REPLACE-RX:/{Title}/(?i)^.* (?=\d+$)//} -user={USERNAME} -pw={PASSWORD})"); + + // Test complicated and nested replacements + QCOMPARE(entry2->resolveMultiplePlaceholders(entry2->url()), + QString("cmd://sap.exe -system=server1 -client=12345 -user=Username2 -pw=Password1")); + // Test invalid syntax + QString error; + entry1->resolveRegexPlaceholder("{T-REPLACE-RX:/{USERNAME}/.*+?/test/}", &error); // invalid regex + QVERIFY(!error.isEmpty()); + entry1->resolveRegexPlaceholder("{T-REPLACE-RX:/{USERNAME}/.*/}", &error); // no replacement + QVERIFY(!error.isEmpty()); + // Check that error gets cleared + entry1->resolveRegexPlaceholder("{T-REPLACE-RX:/{USERNAME}/\\d/2/}", &error); + QVERIFY(error.isEmpty()); +} + void TestEntry::testResolveClonedEntry() { Database db; diff --git a/tests/TestEntry.h b/tests/TestEntry.h index 3bfd8f52d..69f5b0d46 100644 --- a/tests/TestEntry.h +++ b/tests/TestEntry.h @@ -36,6 +36,8 @@ private slots: void testResolveRecursivePlaceholders(); void testResolveReferencePlaceholders(); void testResolveNonIdPlaceholdersToUuid(); + void testResolveConversionPlaceholders(); + void testResolveReplacePlaceholders(); void testResolveClonedEntry(); void testIsRecycled(); void testMoveUpDown();