Implement T-CONV and T-REPLACE-RX entry placeholders

* Closes #7293
* Move existing T-CONV and T-REPLACE-RX code from AutoType to Entry. Replumb AutoType to use the entry functions.
* Improve placeholder code in various place
This commit is contained in:
Jonathan White 2024-11-09 10:32:27 -05:00
parent 4acb3774e6
commit b9c5869806
6 changed files with 214 additions and 83 deletions

View File

@ -18,6 +18,8 @@ This section contains full details on advanced features available in KeePassXC.
|{NOTES} |Notes |{NOTES} |Notes
|{TOTP} |Current TOTP value (if configured) |{TOTP} |Current TOTP value (if configured)
|{S:<ATTRIBUTE_NAME>} |Value for the given attribute (case sensitive) |{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:RMVSCM} |URL without scheme (e.g., https)
|{URL:WITHOUTSCHEME} |URL without scheme |{URL:WITHOUTSCHEME} |URL without scheme
|{URL:SCM} |URL Scheme |{URL:SCM} |URL Scheme

View File

@ -689,74 +689,23 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin
} else if (placeholder.startsWith("t-conv:")) { } else if (placeholder.startsWith("t-conv:")) {
// Reset to the original capture to preserve case // Reset to the original capture to preserve case
placeholder = match.captured(3); placeholder = match.captured(3);
placeholder.replace("t-conv:", "", Qt::CaseInsensitive); auto resolved = entry->resolveConversionPlaceholder(placeholder, &error);
if (!placeholder.isEmpty()) { if (!error.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 {}; return {};
} }
for (const QChar& ch : resolved) { for (const QChar& ch : resolved) {
actions << QSharedPointer<AutoTypeKey>::create(ch); actions << QSharedPointer<AutoTypeKey>::create(ch);
} }
} else {
error = tr("Invalid conversion syntax: %1").arg(fullPlaceholder);
return {};
}
} else {
error = tr("Invalid conversion syntax: %1").arg(fullPlaceholder);
return {};
}
} else if (placeholder.startsWith("t-replace-rx:")) { } else if (placeholder.startsWith("t-replace-rx:")) {
// Reset to the original capture to preserve case // Reset to the original capture to preserve case
placeholder = match.captured(3); placeholder = match.captured(3);
placeholder.replace("t-replace-rx:", "", Qt::CaseInsensitive); auto resolved = entry->resolveRegexPlaceholder(placeholder, &error);
if (!placeholder.isEmpty()) { if (!error.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 $<num> with \\<num> 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 {}; return {};
} }
auto resolved = resolvedText.replace(searchRegex, resolvedReplace);
for (const QChar& ch : resolved) { for (const QChar& ch : resolved) {
actions << QSharedPointer<AutoTypeKey>::create(ch); actions << QSharedPointer<AutoTypeKey>::create(ch);
} }
} else {
error = tr("Invalid conversion syntax: %1").arg(fullPlaceholder);
return {};
}
} else {
error = tr("Invalid conversion syntax: %1").arg(fullPlaceholder);
return {};
}
} else if (placeholder.startsWith("mode=")) { } else if (placeholder.startsWith("mode=")) {
auto mode = AutoTypeExecutor::Mode::NORMAL; auto mode = AutoTypeExecutor::Mode::NORMAL;
if (placeholder.endsWith("virtual")) { if (placeholder.endsWith("virtual")) {

View File

@ -1030,9 +1030,9 @@ void Entry::updateModifiedSinceBegin()
QString Entry::resolveMultiplePlaceholdersRecursive(const QString& str, int maxDepth) const 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()); qWarning("Maximum depth of replacement has been reached. Entry uuid: %s", uuid().toString().toLatin1().data());
return str; return str;
} }
@ -1043,7 +1043,7 @@ QString Entry::resolveMultiplePlaceholdersRecursive(const QString& str, int maxD
while (matches.hasNext()) { while (matches.hasNext()) {
const auto match = matches.next(); const auto match = matches.next();
result += str.midRef(capEnd, match.capturedStart() - capEnd); result += str.midRef(capEnd, match.capturedStart() - capEnd);
result += resolvePlaceholderRecursive(match.captured(), maxDepth - 1); result += resolvePlaceholderRecursive(match.captured(), maxDepth);
capEnd = match.capturedEnd(); capEnd = match.capturedEnd();
} }
result += str.rightRef(str.length() - capEnd); 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 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()); qWarning("Maximum depth of replacement has been reached. Entry uuid: %s", uuid().toString().toLatin1().data());
return placeholder; return placeholder;
} }
@ -1060,21 +1060,20 @@ QString Entry::resolvePlaceholderRecursive(const QString& placeholder, int maxDe
const PlaceholderType typeOfPlaceholder = placeholderType(placeholder); const PlaceholderType typeOfPlaceholder = placeholderType(placeholder);
switch (typeOfPlaceholder) { switch (typeOfPlaceholder) {
case PlaceholderType::NotPlaceholder: case PlaceholderType::NotPlaceholder:
return resolveMultiplePlaceholdersRecursive(placeholder, maxDepth - 1); return resolveMultiplePlaceholdersRecursive(placeholder, maxDepth);
case PlaceholderType::Unknown: { 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: case PlaceholderType::Title:
return resolveMultiplePlaceholdersRecursive(title(), maxDepth - 1); return resolveMultiplePlaceholdersRecursive(title(), maxDepth);
case PlaceholderType::UserName: case PlaceholderType::UserName:
return resolveMultiplePlaceholdersRecursive(username(), maxDepth - 1); return resolveMultiplePlaceholdersRecursive(username(), maxDepth);
case PlaceholderType::Password: case PlaceholderType::Password:
return resolveMultiplePlaceholdersRecursive(password(), maxDepth - 1); return resolveMultiplePlaceholdersRecursive(password(), maxDepth);
case PlaceholderType::Notes: case PlaceholderType::Notes:
return resolveMultiplePlaceholdersRecursive(notes(), maxDepth - 1); return resolveMultiplePlaceholdersRecursive(notes(), maxDepth);
case PlaceholderType::Url: case PlaceholderType::Url:
return resolveMultiplePlaceholdersRecursive(url(), maxDepth - 1); return resolveMultiplePlaceholdersRecursive(url(), maxDepth);
case PlaceholderType::DbDir: { case PlaceholderType::DbDir: {
QFileInfo fileInfo(database()->filePath()); QFileInfo fileInfo(database()->filePath());
return fileInfo.absoluteDir().absolutePath(); return fileInfo.absoluteDir().absolutePath();
@ -1089,7 +1088,7 @@ QString Entry::resolvePlaceholderRecursive(const QString& placeholder, int maxDe
case PlaceholderType::UrlUserInfo: case PlaceholderType::UrlUserInfo:
case PlaceholderType::UrlUserName: case PlaceholderType::UrlUserName:
case PlaceholderType::UrlPassword: { case PlaceholderType::UrlPassword: {
const QString strUrl = resolveMultiplePlaceholdersRecursive(url(), maxDepth - 1); const QString strUrl = resolveMultiplePlaceholdersRecursive(url(), maxDepth);
return resolveUrlPlaceholder(strUrl, typeOfPlaceholder); return resolveUrlPlaceholder(strUrl, typeOfPlaceholder);
} }
case PlaceholderType::Totp: case PlaceholderType::Totp:
@ -1097,10 +1096,11 @@ QString Entry::resolvePlaceholderRecursive(const QString& placeholder, int maxDe
return totp(); return totp();
case PlaceholderType::CustomAttribute: { case PlaceholderType::CustomAttribute: {
const QString key = placeholder.mid(3, placeholder.length() - 4); // {S:attr} => mid(3, len - 4) 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: case PlaceholderType::Reference:
return resolveReferencePlaceholderRecursive(placeholder, maxDepth); return resolveReferencePlaceholderRecursive(placeholder, ++maxDepth);
case PlaceholderType::DateTimeSimple: case PlaceholderType::DateTimeSimple:
case PlaceholderType::DateTimeYear: case PlaceholderType::DateTimeYear:
case PlaceholderType::DateTimeMonth: case PlaceholderType::DateTimeMonth:
@ -1115,7 +1115,11 @@ QString Entry::resolvePlaceholderRecursive(const QString& placeholder, int maxDe
case PlaceholderType::DateTimeUtcHour: case PlaceholderType::DateTimeUtcHour:
case PlaceholderType::DateTimeUtcMinute: case PlaceholderType::DateTimeUtcMinute:
case PlaceholderType::DateTimeUtcSecond: 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; return placeholder;
@ -1164,9 +1168,93 @@ QString Entry::resolveDateTimePlaceholder(Entry::PlaceholderType placeholderType
return {}; 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 $<num> with \\<num> 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 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()); qWarning("Maximum depth of replacement has been reached. Entry uuid: %s", uuid().toString().toLatin1().data());
return placeholder; 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. // 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 // 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:<Name>}, and reference the standard field. // of the entry with the custom string, using {S:<Name>}, and reference the standard field.
result = refEntry->resolveMultiplePlaceholdersRecursive(result, maxDepth - 1); result = refEntry->resolveMultiplePlaceholdersRecursive(result, maxDepth);
} }
return result; return result;
@ -1369,15 +1457,21 @@ QString Entry::resolveUrlPlaceholder(const QString& str, Entry::PlaceholderType
Entry::PlaceholderType Entry::placeholderType(const QString& placeholder) const 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; return PlaceholderType::NotPlaceholder;
} }
if (placeholder.startsWith(QLatin1Literal("{S:"))) { if (placeholder.startsWith(QStringLiteral("{S:"))) {
return PlaceholderType::CustomAttribute; return PlaceholderType::CustomAttribute;
} }
if (placeholder.startsWith(QLatin1Literal("{REF:"))) { if (placeholder.startsWith(QStringLiteral("{REF:"))) {
return PlaceholderType::Reference; 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<QString, PlaceholderType> placeholders{ static const QMap<QString, PlaceholderType> placeholders{
{QStringLiteral("{TITLE}"), PlaceholderType::Title}, {QStringLiteral("{TITLE}"), PlaceholderType::Title},

View File

@ -225,7 +225,9 @@ public:
DateTimeUtcHour, DateTimeUtcHour,
DateTimeUtcMinute, DateTimeUtcMinute,
DateTimeUtcSecond, DateTimeUtcSecond,
DbDir DbDir,
Conversion,
Regex
}; };
static const int DefaultIconNumber; static const int DefaultIconNumber;
@ -244,6 +246,8 @@ public:
QString resolvePlaceholder(const QString& str) const; QString resolvePlaceholder(const QString& str) const;
QString resolveUrlPlaceholder(const QString& str, PlaceholderType placeholderType) const; QString resolveUrlPlaceholder(const QString& str, PlaceholderType placeholderType) const;
QString resolveDateTimePlaceholder(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; PlaceholderType placeholderType(const QString& placeholder) const;
QString resolveUrl(const QString& url) const; QString resolveUrl(const QString& url) const;

View File

@ -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() void TestEntry::testResolveClonedEntry()
{ {
Database db; Database db;

View File

@ -36,6 +36,8 @@ private slots:
void testResolveRecursivePlaceholders(); void testResolveRecursivePlaceholders();
void testResolveReferencePlaceholders(); void testResolveReferencePlaceholders();
void testResolveNonIdPlaceholdersToUuid(); void testResolveNonIdPlaceholdersToUuid();
void testResolveConversionPlaceholders();
void testResolveReplacePlaceholders();
void testResolveClonedEntry(); void testResolveClonedEntry();
void testIsRecycled(); void testIsRecycled();
void testMoveUpDown(); void testMoveUpDown();