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/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts
index eee9960d6..2a7ddf5ea 100644
--- a/share/translations/keepassxc_en.ts
+++ b/share/translations/keepassxc_en.ts
@@ -677,19 +677,6 @@
Very long delay detected, max is %1: %2
-
- Invalid conversion type: %1
-
-
-
- Invalid conversion syntax: %1
-
-
-
- Invalid regular expression syntax %1
-%2
-
-
Invalid placeholder: %1
@@ -3685,6 +3672,19 @@ This may cause the affected plugins to malfunction.
Passkey
+
+ Invalid conversion type: %1
+
+
+
+ Invalid conversion syntax: %1
+
+
+
+ Invalid regular expression syntax %1
+%2
+
+
EntryAttachments
diff --git a/src/autotype/AutoType.cpp b/src/autotype/AutoType.cpp
index ae53efa6e..cfa030308 100644
--- a/src/autotype/AutoType.cpp
+++ b/src/autotype/AutoType.cpp
@@ -688,74 +688,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 eeaccc5e6..be3c9dfbd 100644
--- a/src/core/Entry.cpp
+++ b/src/core/Entry.cpp
@@ -1021,9 +1021,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;
}
@@ -1034,7 +1034,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);
@@ -1043,7 +1043,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;
}
@@ -1051,21 +1051,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();
@@ -1080,7 +1079,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:
@@ -1088,10 +1087,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:
@@ -1106,7 +1106,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;
@@ -1155,9 +1159,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;
}
@@ -1185,7 +1273,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;
@@ -1360,15 +1448,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 b1bd13e8a..f5aca8fd2 100644
--- a/src/core/Entry.h
+++ b/src/core/Entry.h
@@ -223,7 +223,9 @@ public:
DateTimeUtcHour,
DateTimeUtcMinute,
DateTimeUtcSecond,
- DbDir
+ DbDir,
+ Conversion,
+ Regex
};
static const int DefaultIconNumber;
@@ -242,6 +244,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 e0bc820fd..fe070ac56 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();