From 7384c3ce5e651f86ef6e5e4dcb76773b83a8431f Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sat, 3 Oct 2026 08:55:59 +0300 Subject: [PATCH 1/5] [#1153] Parse the whole DN string, and build or split DN strings through DN instead of string operations The SDK DN parser: - DN.valueOf() no longer returns the DN parsed so far when an RDN is followed by anything else than a separator. ';' (RFC 2253) separates RDNs like ','; any other trailing content, such as after a closing quote or after a hex string and a space, is rejected. AVA.valueOf() rejects trailing content too. - A hex string may be followed directly by '+'. - An empty value is accepted wherever it appears (cn=,dc=x, cn="",dc=x, cn=x+sn=,dc=y), so toString() of DN.child("cn", "") parses back. - A trailing lone '\' is rejected. DN strings built or split by hand: - Name and optional UID syntax and uniqueMemberMatch skip an escaped "#'". - The external changelog cookie splits domains at an unescaped ';' and the base DN from its state at the last ':'. - dsreplication builds the global administrator DN with DN.child(). - Monitor providers escape the configured names (connection handler, backend ID, entry cache, replicated base DN) that they put into the relative DN of their entry, and a monitor name that does not name an entry below cn=monitor becomes the value of a single RDN instead of failing every cn=monitor search. - Referral URLs percent-encode the UTF-8 octets of the DN. - ACI userattr splits at the first '#'. - The control panel builds the DN of a new entry with DN.child(), and unescapeUtf8() leaves an escaped backslash alone. - JMX names percent-encode values that the old mapping lost characters of, and keep the old name for values made of letters, digits and spaces. - MakeLDIF <_DN> joins the RDNs instead of replacing every comma. - Task DNs are built with DN.child(). - PatternDN decodes hex pairs inside quotes, reads an empty value before ',', ';' or '+', and ends a hex string at '+'; PatternRDN matches the AVAs of a multi-valued RDN whatever their order. Fixes #1153 --- .../java/com/forgerock/opendj/cli/Utils.java | 3 +- .../forgerock/opendj/cli/UtilsTestCase.java | 21 ++ .../java/org/forgerock/opendj/ldap/AVA.java | 27 +- .../java/org/forgerock/opendj/ldap/DN.java | 9 +- .../schema/NameAndOptionalUIDSyntaxImpl.java | 36 ++- .../UniqueMemberEqualityMatchingRuleImpl.java | 14 +- .../forgerock/opendj/ldif/TemplateTag.java | 20 +- .../com/forgerock/opendj/ldap/core.properties | 7 + .../forgerock/opendj/ldap/AVATestCase.java | 37 +++ .../org/forgerock/opendj/ldap/DNTestCase.java | 79 +++++- ...inguishedNameEqualityMatchingRuleTest.java | 15 +- .../schema/NameAndOptionalUIDSyntaxTest.java | 48 ++++ .../UniqueMemberEqualityMatchingRuleTest.java | 8 + .../opendj/ldif/TemplateTagTestCase.java | 22 ++ .../reactive/LDAPConnectionHandler2.java | 2 +- .../ui/AbstractNewEntryPanel.java | 44 +++ .../controlpanel/ui/DuplicateEntryPanel.java | 2 +- .../controlpanel/ui/NewDomainPanel.java | 3 +- .../controlpanel/ui/NewGroupPanel.java | 2 +- .../controlpanel/ui/NewOrganizationPanel.java | 3 +- .../ui/NewOrganizationalUnitPanel.java | 3 +- .../controlpanel/ui/NewUserPanel.java | 3 +- .../guitools/controlpanel/util/Utilities.java | 8 + .../authorization/dseecompat/PatternDN.java | 31 ++- .../authorization/dseecompat/PatternRDN.java | 14 +- .../authorization/dseecompat/UserAttr.java | 6 +- .../opends/server/backends/jeb/JEStorage.java | 3 +- .../server/backends/pdb/PDBStorage.java | 3 +- .../backends/pluggable/RootContainer.java | 2 +- .../org/opends/server/config/JMXMBean.java | 106 +++++--- .../opends/server/core/DirectoryServer.java | 23 +- .../server/extensions/DiskSpaceMonitor.java | 2 +- .../ClientConnectionMonitorProvider.java | 4 +- .../monitors/ConnectionHandlerMonitor.java | 5 +- .../monitors/EntryCacheMonitorProvider.java | 4 +- .../server/monitors/LocalBackendMonitor.java | 3 +- .../protocols/ldap/LDAPConnectionHandler.java | 2 +- .../common/MultiDomainServerState.java | 58 +++- .../server/ReplicationServerDomain.java | 22 +- .../service/ReplicationMonitor.java | 25 +- .../opends/server/tools/tasks/TaskClient.java | 11 +- .../java/org/opends/server/types/LDAPURL.java | 38 ++- .../controlpanel/ui/NewEntryDNTestCase.java | 68 +++++ .../util/UnescapeUtf8TestCase.java | 60 ++++ .../dseecompat/PatternDNTest.java | 117 ++++++++ .../dseecompat/UserAttrTest.java | 86 ++++++ .../server/config/JMXMBeanNameTest.java | 81 ++++++ .../server/monitors/MonitorDNTestCase.java | 256 ++++++++++++++++++ .../common/MultiDomainServerStateTest.java | 32 +++ ...eplicationServerDomainMonitorNameTest.java | 48 ++++ .../service/ReplicationMonitorNameTest.java | 49 ++++ .../tools/tasks/TaskClientTaskDNTest.java | 72 +++++ .../opends/server/types/LDAPURLTestCase.java | 42 +++ 53 files changed, 1530 insertions(+), 159 deletions(-) create mode 100644 opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/NameAndOptionalUIDSyntaxTest.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/ui/NewEntryDNTestCase.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/util/UnescapeUtf8TestCase.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/UserAttrTest.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/config/JMXMBeanNameTest.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/monitors/MonitorDNTestCase.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerDomainMonitorNameTest.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/replication/service/ReplicationMonitorNameTest.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/tools/tasks/TaskClientTaskDNTest.java diff --git a/opendj-cli/src/main/java/com/forgerock/opendj/cli/Utils.java b/opendj-cli/src/main/java/com/forgerock/opendj/cli/Utils.java index d69a0142d1..5d2c77d095 100644 --- a/opendj-cli/src/main/java/com/forgerock/opendj/cli/Utils.java +++ b/opendj-cli/src/main/java/com/forgerock/opendj/cli/Utils.java @@ -49,7 +49,6 @@ import org.forgerock.opendj.ldap.AuthorizationException; import org.forgerock.opendj.ldap.ConnectionException; import org.forgerock.opendj.ldap.DN; -import org.forgerock.opendj.ldap.RDN; /** This class provides utility functions for all the client side tools. */ public final class Utils { @@ -541,7 +540,7 @@ public static boolean isDN(String dn) { * @return The DN of the administrator for the given UID. */ public static DN getAdministratorDN(String uid) { - return DN.valueOf(RDN.valueOf("cn=" + uid) + ",cn=Administrators, cn=admin data"); + return DN.valueOf("cn=Administrators,cn=admin data").child("cn", uid); } /** diff --git a/opendj-cli/src/test/java/com/forgerock/opendj/cli/UtilsTestCase.java b/opendj-cli/src/test/java/com/forgerock/opendj/cli/UtilsTestCase.java index c116cf4b2f..1ea51ca095 100644 --- a/opendj-cli/src/test/java/com/forgerock/opendj/cli/UtilsTestCase.java +++ b/opendj-cli/src/test/java/com/forgerock/opendj/cli/UtilsTestCase.java @@ -12,12 +12,17 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package com.forgerock.opendj.cli; import java.io.File; import java.io.IOException; +import org.forgerock.opendj.ldap.ByteString; +import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.RDN; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; @@ -88,5 +93,21 @@ public void isDN() { assertFalse(Utils.isDN("babs@example.com")); } + @DataProvider + public Object[][] administratorUIDs() { + return new Object[][] { { "admin" }, { "a,b" }, { "a+b" }, { "#1" }, { " lead" }, { "a\\b" }, { "a;b" }, + { "a=b" }, { "J\u00f6rg" } }; + } + + @Test(dataProvider = "administratorUIDs") + public void getAdministratorDNKeepsTheWholeUID(final String uid) { + final DN adminDN = Utils.getAdministratorDN(uid); + assertEquals(adminDN.parent(), DN.valueOf("cn=Administrators,cn=admin data")); + final RDN rdn = adminDN.rdn(); + assertEquals(rdn.size(), 1); + assertEquals(rdn.getFirstAVA().getAttributeType().getNameOrOID(), "cn"); + assertEquals(rdn.getFirstAVA().getAttributeValue(), ByteString.valueOfUtf8(uid)); + } + } diff --git a/opendj-core/src/main/java/org/forgerock/opendj/ldap/AVA.java b/opendj-core/src/main/java/org/forgerock/opendj/ldap/AVA.java index 7d8774c51a..04e54db75f 100644 --- a/opendj-core/src/main/java/org/forgerock/opendj/ldap/AVA.java +++ b/opendj-core/src/main/java/org/forgerock/opendj/ldap/AVA.java @@ -96,13 +96,20 @@ public static AVA valueOf(final String ava) { */ public static AVA valueOf(final String ava, final Schema schema) { final SubstringReader reader = new SubstringReader(ava); + final AVA parsedAva; try { - return decode(reader, schema); + parsedAva = decode(reader, schema); } catch (final UnknownSchemaElementException e) { final LocalizableMessage message = ERR_RDN_TYPE_NOT_FOUND.get(ava, e.getMessageObject()); throw new LocalizedIllegalArgumentException(message); } + reader.skipWhitespaces(); + if (reader.remaining() > 0) { + throw new LocalizedIllegalArgumentException( + ERR_AVA_TRAILING_GARBAGE.get(ava, reader.read(reader.remaining()))); + } + return parsedAva; } static AVA decode(final SubstringReader reader, final Schema schema) { @@ -285,7 +292,7 @@ private static ByteString readAttributeValueAsBER(final SubstringReader reader) // The rest of the value must be a multiple of two hex // characters. The end of the value may be designated by the - // end of the DN, a comma or semicolon, or a space. + // end of the DN, a comma or semicolon, a plus sign, or a space. while (reader.remaining() > 0) { char c = reader.read(); if (isHexDigit(c)) { @@ -303,7 +310,7 @@ private static ByteString readAttributeValueAsBER(final SubstringReader reader) throw new LocalizedIllegalArgumentException( ERR_ATTR_SYNTAX_DN_HEX_VALUE_TOO_SHORT.get(reader.getString())); } - } else if (c == ' ' || c == ',' || c == ';') { + } else if (c == ' ' || c == ',' || c == ';' || c == '+') { // This denotes the end of the value. break; } else { @@ -326,14 +333,8 @@ private static ByteString readAttributeValueAsBER(final SubstringReader reader) private static ByteString readAttributeValue(final SubstringReader reader, final boolean isQuoted) { reader.reset(); - final ByteString bytes = delimitAndEvaluateEscape(reader, isQuoted); - if (bytes.length() == 0) { - // We don't allow an empty attribute value. - final LocalizableMessage message = - ERR_ATTR_SYNTAX_DN_INVALID_REQUIRES_ESCAPE_CHAR.get(reader.getString(), reader.pos()); - throw new LocalizedIllegalArgumentException(message); - } - return bytes; + // RFC 4514 allows an empty value wherever it appears: whether the attribute allows it is a schema check. + return delimitAndEvaluateEscape(reader, isQuoted); } private static ByteString delimitAndEvaluateEscape(final SubstringReader reader, final boolean isQuoted) { @@ -397,6 +398,10 @@ private static ByteString delimitAndEvaluateEscape(final SubstringReader reader, // We hit the end of the AVA before the closing quote. That's an error. throw new LocalizedIllegalArgumentException(ERR_ATTR_SYNTAX_DN_UNMATCHED_QUOTE.get(reader.getString())); } + if (escaped) { + // A trailing escape character escapes nothing. + throw new LocalizedIllegalArgumentException(ERR_ATTR_SYNTAX_DN_TRAILING_ESCAPE.get(reader.getString())); + } reader.reset(); valueBuffer.setLength(valueBuffer.length() - trailingSpaces); return ByteString.valueOfUtf8(valueBuffer); diff --git a/opendj-core/src/main/java/org/forgerock/opendj/ldap/DN.java b/opendj-core/src/main/java/org/forgerock/opendj/ldap/DN.java index 1757cdef7d..23cfe93568 100644 --- a/opendj-core/src/main/java/org/forgerock/opendj/ldap/DN.java +++ b/opendj-core/src/main/java/org/forgerock/opendj/ldap/DN.java @@ -278,7 +278,14 @@ private static DN decode(final SubstringReader reader, final Schema schema, fina LinkedList> parentRDNs = null; DN parent = null; - while (reader.remaining() > 0 && reader.read() == ',') { + while (reader.remaining() > 0) { + // Only a separator may follow an RDN. RFC 2253 also allowed ';', so accept it as ',' rather than + // stopping there and silently returning the DN parsed so far. + final char c = reader.read(); + if (c != RDN_CHAR_SEPARATOR && c != ';') { + throw new LocalizedIllegalArgumentException( + ERR_DN_TRAILING_GARBAGE.get(reader.getString(), reader.getString().substring(reader.pos() - 1))); + } reader.skipWhitespaces(); if (reader.remaining() == 0) { throw new LocalizedIllegalArgumentException(ERR_ATTR_SYNTAX_DN_ATTR_NO_NAME.get(reader.getString())); diff --git a/opendj-core/src/main/java/org/forgerock/opendj/ldap/schema/NameAndOptionalUIDSyntaxImpl.java b/opendj-core/src/main/java/org/forgerock/opendj/ldap/schema/NameAndOptionalUIDSyntaxImpl.java index 9dfc6197c7..f77b006826 100644 --- a/opendj-core/src/main/java/org/forgerock/opendj/ldap/schema/NameAndOptionalUIDSyntaxImpl.java +++ b/opendj-core/src/main/java/org/forgerock/opendj/ldap/schema/NameAndOptionalUIDSyntaxImpl.java @@ -13,6 +13,7 @@ * * Copyright 2009 Sun Microsystems, Inc. * Portions Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.ldap.schema; @@ -35,6 +36,31 @@ */ final class NameAndOptionalUIDSyntaxImpl extends AbstractSyntaxImpl { + /** + * Returns the position of the octothorpe (#) that starts the "optional uid" of the provided value, or -1 if the + * value has no "optional uid". The DN may contain an escaped octothorpe, and such an octothorpe does not start + * the "optional uid". + * + * @param value + * The trimmed string representation of a name and optional UID value. + * @return The position of the octothorpe that starts the "optional uid", or -1 if there is none. + */ + static int optionalUidPosition(final String value) { + if (!value.endsWith("'B") && !value.endsWith("'b")) { + return -1; + } + // The bit string cannot contain an octothorpe, so only the last one can start the "optional uid". + final int sharpPos = value.lastIndexOf("#'"); + if (sharpPos <= 0) { + return -1; + } + int backslashes = 0; + for (int i = sharpPos - 1; i >= 0 && value.charAt(i) == '\\'; i--) { + backslashes++; + } + return backslashes % 2 == 0 ? sharpPos : -1; + } + @Override public String getEqualityMatchingRule() { return EMR_UNIQUE_MEMBER_OID; @@ -63,14 +89,8 @@ public boolean valueIsAcceptable(final Schema schema, final ByteSequence value, // See if the value contains the "optional uid" portion. If we think // it does, then mark its location. - int dnEndPos = valueLength; - int sharpPos = -1; - if (valueString.endsWith("'B") || valueString.endsWith("'b")) { - sharpPos = valueString.lastIndexOf("#'"); - if (sharpPos > 0) { - dnEndPos = sharpPos; - } - } + final int sharpPos = optionalUidPosition(valueString); + final int dnEndPos = sharpPos > 0 ? sharpPos : valueLength; // Take the DN portion of the string and try to normalize it. try { diff --git a/opendj-core/src/main/java/org/forgerock/opendj/ldap/schema/UniqueMemberEqualityMatchingRuleImpl.java b/opendj-core/src/main/java/org/forgerock/opendj/ldap/schema/UniqueMemberEqualityMatchingRuleImpl.java index 66fbe16005..3621bc2a7c 100644 --- a/opendj-core/src/main/java/org/forgerock/opendj/ldap/schema/UniqueMemberEqualityMatchingRuleImpl.java +++ b/opendj-core/src/main/java/org/forgerock/opendj/ldap/schema/UniqueMemberEqualityMatchingRuleImpl.java @@ -13,6 +13,7 @@ * * Copyright 2009 Sun Microsystems, Inc. * Portions copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.ldap.schema; @@ -41,16 +42,9 @@ final class UniqueMemberEqualityMatchingRuleImpl extends AbstractEqualityMatchin public ByteString normalizeAttributeValue(final Schema schema, final ByteSequence value) throws DecodeException { // Separate value into normalized DN and "optional uid" portion. final String stringValue = value.toString().trim(); - int dnEndPosition = stringValue.length(); - String optionalUid = ""; - int sharpPosition = -1; - if (stringValue.endsWith("'B") || stringValue.endsWith("'b")) { - sharpPosition = stringValue.lastIndexOf("#'"); - if (sharpPosition > 0) { - dnEndPosition = sharpPosition; - optionalUid = stringValue.substring(sharpPosition); - } - } + final int sharpPosition = NameAndOptionalUIDSyntaxImpl.optionalUidPosition(stringValue); + final int dnEndPosition = sharpPosition > 0 ? sharpPosition : stringValue.length(); + final String optionalUid = sharpPosition > 0 ? stringValue.substring(sharpPosition) : ""; try { DN dn = DN.valueOf(stringValue.substring(0, dnEndPosition), schema.asNonStrictSchema()); return new ByteStringBuilder() diff --git a/opendj-core/src/main/java/org/forgerock/opendj/ldif/TemplateTag.java b/opendj-core/src/main/java/org/forgerock/opendj/ldif/TemplateTag.java index 3df49c28b1..ea8e8acf9c 100644 --- a/opendj-core/src/main/java/org/forgerock/opendj/ldif/TemplateTag.java +++ b/opendj-core/src/main/java/org/forgerock/opendj/ldif/TemplateTag.java @@ -306,24 +306,26 @@ final TagResult generateValue(TemplateEntry templateEntry, TemplateValue templat return TagResult.SUCCESS; } - String dnAsString = ""; + final DN dnToFormat; if (numComponents == 0) { // Return the DN of the entry - dnAsString = dn.toString(); + dnToFormat = dn; } else if (numComponents > 0) { // Return the first numComponents RDNs of the DN - dnAsString = dn.localName(numComponents).toString(); + dnToFormat = dn.localName(numComponents); } else { // numComponents is negative // Return the last numComponents RDNs of the DN - dnAsString = dn.parent(dn.size() - Math.abs(numComponents)).toString(); + dnToFormat = dn.parent(dn.size() - Math.abs(numComponents)); } - // If expected separator is not standard separator - // Then substitute expected to standard - if (!separator.equals(",")) { - dnAsString = dnAsString.replaceAll(",", separator); + // Join the RDNs with the expected separator: replacing the commas of the DN string + // would also replace the escaped commas inside the attribute values. + for (int i = 0; i < dnToFormat.size(); i++) { + if (i > 0) { + templateValue.append(separator); + } + templateValue.append(dnToFormat.parent(i).rdn()); } - templateValue.append(dnAsString); return TagResult.SUCCESS; } diff --git a/opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties b/opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties index a85788719c..e0f2a3a363 100644 --- a/opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties +++ b/opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties @@ -483,6 +483,13 @@ ERR_RDN_DUPLICATE_AVA_TYPES=An RDN contained multiple components \ ERR_RDN_NO_AVAS=An RDN must contain at least one attribute type and value ERR_DN_TYPE_NOT_FOUND=The DN "%s" could not be parsed due to the \ following reason: %s +ERR_DN_TRAILING_GARBAGE=The DN "%s" could not be parsed because it \ + contained trailing content after an RDN: "%s" +ERR_AVA_TRAILING_GARBAGE=The attribute value assertion "%s" could not be \ + parsed because it contained trailing content after the attribute value: "%s" +ERR_ATTR_SYNTAX_DN_TRAILING_ESCAPE=The provided value "%s" could not be \ + parsed as a valid distinguished name because it ends with an escape \ + character (\\) that is not followed by the character it escapes ERR_ATTRIBUTE_DESCRIPTION_EMPTY=The attribute description \ "%s" could not be parsed because it was empty ERR_ATTRIBUTE_DESCRIPTION_ILLEGAL_CHARACTER=The attribute description \ diff --git a/opendj-core/src/test/java/org/forgerock/opendj/ldap/AVATestCase.java b/opendj-core/src/test/java/org/forgerock/opendj/ldap/AVATestCase.java index b7ac087a7a..ccbc97ca9b 100644 --- a/opendj-core/src/test/java/org/forgerock/opendj/ldap/AVATestCase.java +++ b/opendj-core/src/test/java/org/forgerock/opendj/ldap/AVATestCase.java @@ -12,11 +12,13 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Portions copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.ldap; import static org.assertj.core.api.Assertions.*; +import org.forgerock.i18n.LocalizedIllegalArgumentException; import org.forgerock.opendj.ldap.schema.AttributeType; import org.forgerock.opendj.ldap.schema.Schema; import org.testng.annotations.DataProvider; @@ -80,4 +82,39 @@ public void avaConstructedWithValueContainingLeadingAndTrailingSpacesShouldBeEsc public void valueOfDecodesTrailingEscapedChars() { assertThat(AVA.valueOf("dc=\\41\\42\\43").toString()).isEqualTo("dc=ABC"); } + + @DataProvider + public Object[][] illegalAVAs() { + // @formatter:off + return new Object[][] { + { "cn=a,dc=b" }, + { "cn=a;dc=b" }, + { "cn=a+sn=b" }, + { "cn=\"a\"b" }, + { "cn=#0402 junk" }, + { "cn=#0402+sn=b" }, + { "cn=a\\" }, + }; + // @formatter:on + } + + @Test(dataProvider = "illegalAVAs", expectedExceptions = LocalizedIllegalArgumentException.class) + public void valueOfShouldRejectTrailingContent(final String ava) { + AVA.valueOf(ava); + } + + @DataProvider + public Object[][] emptyValueAVAs() { + return new Object[][] { { "cn=" }, { "cn= " }, { "cn=\"\"" }, { "cn=\"\" " } }; + } + + @Test(dataProvider = "emptyValueAVAs") + public void valueOfShouldAcceptAnEmptyValue(final String ava) { + assertThat(AVA.valueOf(ava).getAttributeValue()).isEqualTo(ByteString.empty()); + } + + @Test + public void valueOfShouldAcceptTrailingSpacesAfterAHexString() { + assertThat(AVA.valueOf("2.5.4.3=#76616C7565 ").getAttributeValue()).isEqualTo(ByteString.valueOfUtf8("value")); + } } diff --git a/opendj-core/src/test/java/org/forgerock/opendj/ldap/DNTestCase.java b/opendj-core/src/test/java/org/forgerock/opendj/ldap/DNTestCase.java index d9960e71f8..f22d1d7786 100644 --- a/opendj-core/src/test/java/org/forgerock/opendj/ldap/DNTestCase.java +++ b/opendj-core/src/test/java/org/forgerock/opendj/ldap/DNTestCase.java @@ -272,10 +272,20 @@ public Object[][] createIllegalData() { { "cn=a\\b" }, { "cn=a\\bg" }, { "cn=\"hello" }, - { "cn=+mail=,dc=example,dc=com" }, - { "cn=xyz+sn=,dc=example,dc=com" }, - { "cn=,dc=example,dc=com" }, - { "cn=a+cn=b,dc=example,dc=com" } + { "cn=a+cn=b,dc=example,dc=com" }, + // Content after an RDN that is neither a separator nor the end of the DN + { "cn=\"a\"b,dc=c" }, + { "cn=\"a\" b" }, + { "dc=c,cn=\"a\"b" }, + { "cn=#0402 junk,dc=c" }, + { "cn=#0402 junk" }, + { "cn=Jim;" }, + { "cn=Jim; " }, + { "cn=Jim;dc=c;" }, + // A trailing escape character that escapes nothing + { "cn=a\\" }, + { "cn=a\\\\\\" }, + { "cn=a\\,dc=b\\" }, }; // @formatter:on } @@ -956,6 +966,67 @@ public void testValueOfString(final String rawDN, final String normDN, final Str assertEquals(raw, string); } + @DataProvider + public Object[][] rfc4514DNs() { + // @formatter:off + return new Object[][] { + // The RFC 2253 RDN separator ';' separates RDNs, it does not end the DN + { "cn=a;dc=b", "cn=a,dc=b", 2 }, + { "uid=a,ou=People;dc=example,dc=com", "uid=a,ou=People,dc=example,dc=com", 4 }, + { "cn=a ; dc=b", "cn=a,dc=b", 2 }, + { "cn=\"a\";dc=b", "cn=a,dc=b", 2 }, + { "cn=#0402 ;dc=b", "cn=\\04\\02,dc=b", 2 }, + // A hex string may be followed directly by '+' + { "cn=#04024869+sn=x", "cn=\\04\\02Hi+sn=x", 1 }, + { "cn=#04024869+sn=x,dc=y", "cn=\\04\\02Hi+sn=x,dc=y", 2 }, + { "sn=x+cn=#04024869,dc=y", "sn=x+cn=\\04\\02Hi,dc=y", 2 }, + // An empty value is accepted wherever it appears + { "cn=,dc=x", "cn=,dc=x", 2 }, + { "cn=\"\",dc=x", "cn=,dc=x", 2 }, + { "cn= ,dc=x", "cn=,dc=x", 2 }, + { "cn=x+sn=,dc=y", "cn=x+sn=,dc=y", 2 }, + { "sn=+cn=x,dc=y", "sn=+cn=x,dc=y", 2 }, + { "cn=+mail=,dc=example,dc=com", "cn=+mail=,dc=example,dc=com", 3 }, + { "dc=x,cn=", "dc=x,cn=", 2 }, + // Unescaped '=' and a non-leading '#' are legal in a value + { "cn=a=b", "cn=a\\=b", 1 }, + { "cn==", "cn=\\=", 1 }, + { "cn=a#b", "cn=a#b", 1 }, + { "cn=a#", "cn=a#", 1 }, + { "ou=https://idp.example.com/metadata#v1,dc=x", "ou=https://idp.example.com/metadata#v1,dc=x", 2 }, + }; + // @formatter:on + } + + @Test(dataProvider = "rfc4514DNs") + public void valueOfShouldParseTheWholeString(final String dn, final String expectedString, final int size) { + final DN parsed = DN.valueOf(dn); + assertThat(parsed.toString()).isEqualTo(expectedString); + assertThat(parsed.size()).isEqualTo(size); + assertEquals(DN.valueOf(parsed.toString()), parsed); + assertEquals(DN.valueOf(ByteString.valueOfUtf8(dn)), parsed); + } + + @Test + public void toStringOfChildWithEmptyValueShouldBeParseable() { + final DN child = DN.valueOf("dc=x").child("cn", ""); + assertThat(child.toString()).isEqualTo("cn=,dc=x"); + assertEquals(DN.valueOf(child.toString()), child); + } + + @Test + public void valueOfShouldNotTruncateWhenParentIsCached() { + // The parent cache is keyed by the string after the separator: a cached parent must not hide garbage. + DN.valueOf("cn=a,dc=example,dc=com"); + assertEquals(DN.valueOf("cn=b;dc=example,dc=com"), DN.valueOf("cn=b,dc=example,dc=com")); + try { + DN.valueOf("cn=\"b\"x,dc=example,dc=com"); + fail("Expected LocalizedIllegalArgumentException"); + } catch (LocalizedIllegalArgumentException expected) { + // Expected. + } + } + /** * Test data for testInScopeOf tests. * diff --git a/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/DistinguishedNameEqualityMatchingRuleTest.java b/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/DistinguishedNameEqualityMatchingRuleTest.java index c9f0228b60..06bda23897 100644 --- a/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/DistinguishedNameEqualityMatchingRuleTest.java +++ b/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/DistinguishedNameEqualityMatchingRuleTest.java @@ -55,8 +55,8 @@ public Object[][] createMatchingRuleInvalidAttributeValues() { { "oid.1." }, { "1.3.6.1.4.1.1466..0=#04024869" }, { "cn=#a" }, { "cn=#ag" }, { "cn=#ga" }, { "cn=#abcdefgh" }, { "cn=a\\b" }, // { "cn=a\\bg" }, { "cn=\"hello" }, - { "cn=+mail=,dc=example,dc=com" }, { "cn=xyz+sn=,dc=example,dc=com" }, - { "cn=,dc=example,dc=com" } }; + // Content after an RDN must not be dropped silently + { "cn=\"a\"b,dc=c" }, { "cn=#0402 junk,dc=c" }, { "cn=a\\" } }; } @Override @@ -114,7 +114,16 @@ public Object[][] createMatchingRuleTest() { "cn=j. smith+ou=sales,dc=example,dc=net", ConditionResult.TRUE }, { "cn=John+a=Doe", "a=Doe+cn=john", ConditionResult.TRUE }, { "O=\"Sue, Grabbit and Runn\",C=US", "o=sue\\, grabbit and runn,c=us", - ConditionResult.TRUE }, }; + ConditionResult.TRUE }, + // An empty value is accepted wherever it appears + { "cn=+mail=,dc=example,dc=com", "mail=+cn=,dc=example,dc=com", ConditionResult.TRUE }, + { "cn=xyz+sn=,dc=example,dc=com", "sn=+cn=xyz,dc=example,dc=com", ConditionResult.TRUE }, + { "cn=,dc=example,dc=com", "cn=\"\",dc=example,dc=com", ConditionResult.TRUE }, + // ';' separates RDNs: the DN after it is neither dropped nor ignored by the match + { "uid=bob,ou=People;dc=example,dc=com", "uid=bob,ou=People,dc=example,dc=com", ConditionResult.TRUE }, + { "uid=bob,ou=People,dc=example,dc=com;uid=alice,ou=People,dc=example,dc=com", + "uid=bob,ou=People,dc=example,dc=com", ConditionResult.FALSE }, + { "cn=#04024869+sn=x,dc=y", "sn=x+cn=hi,dc=y", ConditionResult.TRUE }, }; } /** diff --git a/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/NameAndOptionalUIDSyntaxTest.java b/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/NameAndOptionalUIDSyntaxTest.java new file mode 100644 index 0000000000..ff59ff86f5 --- /dev/null +++ b/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/NameAndOptionalUIDSyntaxTest.java @@ -0,0 +1,48 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.forgerock.opendj.ldap.schema; + +import static org.forgerock.opendj.ldap.schema.SchemaConstants.SYNTAX_NAME_AND_OPTIONAL_UID_OID; + +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** Name and optional UID syntax tests. */ +@Test +public class NameAndOptionalUIDSyntaxTest extends AbstractSyntaxTestCase { + @Override + @DataProvider(name = "acceptableValues") + public Object[][] createAcceptableValues() { + return new Object[][] { + { "dc=example,dc=com", true }, + { "dc=example,dc=com#'0101'B", true }, + { "dc=example,dc=com#'0102'B", false }, + // The escaped '#' is part of the last value "a#'01'B", so there is no uid to check + { "dc=x,o=a\\#'01'B", true }, + { "dc=x,o=a\\#'02'B", true }, + // An escaped backslash does not escape the '#' that follows it + { "dc=x,o=a\\\\#'01'B", true }, + { "dc=x,o=a\\\\#'02'B", false }, + // Not a DN + { "dc=x,o=\"a\"b#'01'B", false }, + }; + } + + @Override + protected Syntax getRule() { + return Schema.getCoreSchema().getSyntax(SYNTAX_NAME_AND_OPTIONAL_UID_OID); + } +} diff --git a/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/UniqueMemberEqualityMatchingRuleTest.java b/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/UniqueMemberEqualityMatchingRuleTest.java index 6c26461db8..bf4e01d7cb 100644 --- a/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/UniqueMemberEqualityMatchingRuleTest.java +++ b/opendj-core/src/test/java/org/forgerock/opendj/ldap/schema/UniqueMemberEqualityMatchingRuleTest.java @@ -12,6 +12,7 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.opendj.ldap.schema; @@ -43,6 +44,13 @@ public Object[][] createMatchingRuleTest() { "1.3.6.1.4.1.1466.0=#04024869,O=Test,C=GB#'0101'B", ConditionResult.TRUE }, { "1.3.6.1.4.1.1466.0=#04024869,O=Test,C=GB#'0101'B", "1.3.6.1.4.1.1466.0=#04024869,o=Test,C=GB#'0101'B", ConditionResult.TRUE }, + // An escaped '#' belongs to the DN: "a#'01'B" is the last value, there is no optional uid + { "dc=x,o=a\\#'01'B", "dc=x,o=a\\#'01'B", ConditionResult.TRUE }, + { "dc=x,o=a\\#'01'B", "dc=x,o=a\\23'01'B", ConditionResult.TRUE }, + { "dc=x,o=a\\#'01'B", "dc=x,o=a#'01'B", ConditionResult.FALSE }, + // An escaped backslash does not escape the '#' that follows it + { "dc=x,o=a\\\\#'01'B", "dc=x,o=a\\5C#'01'B", ConditionResult.TRUE }, + { "dc=x,o=a\\\\#'01'B", "dc=x,o=a\\5C\\23'01'B", ConditionResult.FALSE }, }; } diff --git a/opendj-core/src/test/java/org/forgerock/opendj/ldif/TemplateTagTestCase.java b/opendj-core/src/test/java/org/forgerock/opendj/ldif/TemplateTagTestCase.java index 1c26e86c45..e09bb491b8 100644 --- a/opendj-core/src/test/java/org/forgerock/opendj/ldif/TemplateTagTestCase.java +++ b/opendj-core/src/test/java/org/forgerock/opendj/ldif/TemplateTagTestCase.java @@ -122,6 +122,28 @@ public void testUnderscoreDNTagZeroComponent() throws Exception { assertThat(value.getValueAsString()).isEqualTo("ou=users_dc=example_dc=test"); } + @Test + public void testUnderscoreDNTagKeepsEscapedCommas() throws Exception { + TemplateTag tag = new TemplateTag.UnderscoreDNTag(); + tagWithArguments(tag); + + TemplateValue value = new TemplateValue(NULL_LINE); + tag.generateValue(templateEntry(DN.valueOf("cn=Smith\\, John,dc=example,dc=com")), value); + + assertThat(value.getValueAsString()).isEqualTo("cn=Smith\\, John_dc=example_dc=com"); + } + + @Test + public void testUnderscoreDNTagTwoComponentsKeepsEscapedCommas() throws Exception { + TemplateTag tag = new TemplateTag.UnderscoreDNTag(); + tagWithArguments(tag, "2"); + + TemplateValue value = new TemplateValue(NULL_LINE); + tag.generateValue(templateEntry(DN.valueOf("cn=Smith\\, John,ou=a\\,b,dc=com")), value); + + assertThat(value.getValueAsString()).isEqualTo("cn=Smith\\, John_ou=a\\,b"); + } + @Test public void testDNTagOneComponent() throws Exception { TemplateTag tag = new TemplateTag.DNTag(); diff --git a/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java b/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java index 5ac4ba1900..0af2017de5 100644 --- a/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java +++ b/opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java @@ -750,7 +750,7 @@ public void initializeConnectionHandler(ServerContext serverContext, LDAPConnect } // Create and register monitors. - statTracker = new LDAPStatistics(handlerName + " Statistics"); + statTracker = new LDAPStatistics(DN.escapeAttributeValue(handlerName) + " Statistics"); DirectoryServer.registerMonitorProvider(statTracker); connMonitor = new ClientConnectionMonitorProvider(this); diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/AbstractNewEntryPanel.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/AbstractNewEntryPanel.java index cd1cce46f8..b96ee63373 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/AbstractNewEntryPanel.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/AbstractNewEntryPanel.java @@ -13,6 +13,7 @@ * * Copyright 2008-2010 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.guitools.controlpanel.ui; @@ -25,7 +26,9 @@ import javax.swing.SwingUtilities; import org.forgerock.i18n.LocalizableMessage; +import org.forgerock.i18n.LocalizedIllegalArgumentException; import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.RDN; import org.opends.guitools.controlpanel.browser.BrowserController; import org.opends.guitools.controlpanel.event.ConfigurationChangeEvent; import org.opends.guitools.controlpanel.task.NewEntryTask; @@ -48,6 +51,47 @@ public abstract class AbstractNewEntryPanel extends StatusGenericPanel /** The parent node that was selected when the user clicked on the new entry action. */ protected BasicNode parentNode; + + /** + * Returns the DN of a new entry, with the naming value escaped: a value that contains ',', '+' or ';', + * or starts with '#', is part of the RDN, not a DN separator. + * + * @param rdnAttribute + * the naming attribute. + * @param rdnValue + * the naming value typed by the user. + * @param parentDN + * the DN of the parent entry. + * @return the string representation of the DN of the new entry. + */ + static String getNewEntryDN(String rdnAttribute, String rdnValue, DN parentDN) + { + return parentDN.child(rdnAttribute, rdnValue).toString(); + } + + /** + * Returns the DN of a new entry, with the naming value escaped, under a parent DN typed by the user. + * + * @param rdnAttribute + * the naming attribute. + * @param rdnValue + * the naming value typed by the user. + * @param parentDN + * the DN of the parent entry, as typed by the user. + * @return the string representation of the DN of the new entry. If the parent DN is not a valid DN yet, + * the escaped RDN followed by the parent DN as typed. + */ + static String getNewEntryDN(String rdnAttribute, String rdnValue, String parentDN) + { + try + { + return getNewEntryDN(rdnAttribute, rdnValue, DN.valueOf(parentDN)); + } + catch (LocalizedIllegalArgumentException e) + { + return new RDN(rdnAttribute, rdnValue) + "," + parentDN; + } + } /** The browser controller. */ protected BrowserController controller; diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/DuplicateEntryPanel.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/DuplicateEntryPanel.java index 4c85b85a07..61c5c312f8 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/DuplicateEntryPanel.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/DuplicateEntryPanel.java @@ -542,7 +542,7 @@ private void updateDNValue() // If it takes time to read the entry, the rdnAttribute might not be initialized yet. Don't try to use it then. if (value.length() > 0 && rdnAttribute != null) { - dn.setText(rdnAttribute + "=" + value + "," + parentDN.getText().trim()); + dn.setText(getNewEntryDN(rdnAttribute, value, parentDN.getText().trim())); } else { diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewDomainPanel.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewDomainPanel.java index d14bd1c842..98eec69148 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewDomainPanel.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewDomainPanel.java @@ -13,6 +13,7 @@ * * Copyright 2008 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.guitools.controlpanel.ui; @@ -68,7 +69,7 @@ protected void updateDNValue() String value = name.getText().trim(); if (value.length() > 0) { - dn.setText("dc" + "=" + value + "," + parentNode.getDN()); + dn.setText(getNewEntryDN("dc", value, parentNode.getDN())); } else { diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewGroupPanel.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewGroupPanel.java index 054874afcd..89f96ce20a 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewGroupPanel.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewGroupPanel.java @@ -589,7 +589,7 @@ private void updateDNValue() String value = name.getText().trim(); if (value.length() > 0) { - dn.setText("cn" + "=" + value + "," + parentNode.getDN()); + dn.setText(getNewEntryDN("cn", value, parentNode.getDN())); } else { diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewOrganizationPanel.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewOrganizationPanel.java index 1710ce7531..8ca08c2bf0 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewOrganizationPanel.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewOrganizationPanel.java @@ -13,6 +13,7 @@ * * Copyright 2008 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.guitools.controlpanel.ui; @@ -210,7 +211,7 @@ protected void updateDNValue() String value = name.getText().trim(); if (value.length() > 0) { - dn.setText("o" + "=" + value + "," + parentNode.getDN()); + dn.setText(getNewEntryDN("o", value, parentNode.getDN())); } else { diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewOrganizationalUnitPanel.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewOrganizationalUnitPanel.java index 394245dc8a..8bd8c93bd7 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewOrganizationalUnitPanel.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewOrganizationalUnitPanel.java @@ -13,6 +13,7 @@ * * Copyright 2008-2009 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.guitools.controlpanel.ui; @@ -223,7 +224,7 @@ private void updateDNValue() String value = name.getText().trim(); if (value.length() > 0) { - dn.setText("ou" + "=" + value + "," + parentNode.getDN()); + dn.setText(getNewEntryDN("ou", value, parentNode.getDN())); } else { diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewUserPanel.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewUserPanel.java index 27739532f1..c57bcf8747 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewUserPanel.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/ui/NewUserPanel.java @@ -13,6 +13,7 @@ * * Copyright 2008-2009 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.guitools.controlpanel.ui; @@ -312,7 +313,7 @@ private void updateDNValue() if (attr.equalsIgnoreCase(NAMING_ATTRIBUTES[i])) { String value = NAMING_ATTRIBUTE_TEXTFIELDS[i].getText().trim(); - dn.setText(attr + "=" + value + "," + parentNode.getDN()); + dn.setText(getNewEntryDN(attr, value, parentNode.getDN())); break; } } diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/util/Utilities.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/util/Utilities.java index bd73b3c52f..b1817395e8 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/util/Utilities.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/util/Utilities.java @@ -1294,6 +1294,14 @@ public static String unescapeUtf8(String v) for (int i = 0; i < stringBytes.length; i++) { if (stringBytes[i] == '\\' + && i + 1 < stringBytes.length + && stringBytes[i+1] == '\\') + { + // An escaped backslash: the character after it does not start a hex pair. + decodedBytes[pos++] = stringBytes[i++]; + decodedBytes[pos++] = stringBytes[i]; + } + else if (stringBytes[i] == '\\' && i + 2 < stringBytes.length && StaticUtils.isHexDigit(stringBytes[i+1]) && StaticUtils.isHexDigit(stringBytes[i+2])) diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternDN.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternDN.java index d93fea0934..4943addfe5 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternDN.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternDN.java @@ -13,6 +13,7 @@ * * Copyright 2008 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.authorization.dseecompat; @@ -973,7 +974,13 @@ private static int parseValuePattern(String dnString, int pos, // Look at the first character. If it is an octothorpe (#), then // that means that the value should be a hex string. char c = dnString.charAt(pos++); - if (c == '#') + if (c == ',' || c == ';' || c == '+') + { + // The value is empty and followed by the next RDN or AVA, as DN.valueOf() reads it. + attributeValues.add(ByteString.empty()); + return pos - 1; + } + else if (c == '#') { // The first two characters must be hex characters. StringBuilder hexString = new StringBuilder(); @@ -999,7 +1006,7 @@ private static int parseValuePattern(String dnString, int pos, // The rest of the value must be a multiple of two hex // characters. The end of the value may be designated by the - // end of the DN, a comma or semicolon, or a space. + // end of the DN, a comma or semicolon, a plus sign, or a space. while (pos < length) { c = dnString.charAt(pos++); @@ -1026,7 +1033,7 @@ private static int parseValuePattern(String dnString, int pos, throw new DirectoryException(ResultCode.INVALID_DN_SYNTAX, message); } } - else if (c == ' ' || c == ',' || c == ';') + else if (c == ' ' || c == ',' || c == ';' || c == '+') { // This denotes the end of the value. pos--; @@ -1063,6 +1070,7 @@ else if (c == '"') // Keep reading until we find an unescaped closing quotation mark. boolean escaped = false; StringBuilder valueString = new StringBuilder(); + StringBuilder hexChars = new StringBuilder(); while (true) { if (pos >= length) @@ -1076,9 +1084,18 @@ else if (c == '"') c = dnString.charAt(pos++); if (escaped) { - // The previous character was an escape, so we'll take this - // one no matter what. - valueString.append(c); + // The previous character was an escape. As in an unquoted value, and as DN.valueOf() reads + // a quoted value, an escaped pair of hex digits is one byte of the UTF-8 encoded value. + if (isHexDigit(c) && pos < length && isHexDigit(dnString.charAt(pos))) + { + hexChars.append(c); + hexChars.append(dnString.charAt(pos++)); + } + else + { + appendHexChars(dnString, valueString, hexChars); + valueString.append(c); + } escaped = false; } else if (c == '\\') @@ -1090,12 +1107,14 @@ else if (c == '\\') else if (c == '"') { // This is the end of the value. + appendHexChars(dnString, valueString, hexChars); break; } else { // This is just a regular character that should be in the // value. + appendHexChars(dnString, valueString, hexChars); valueString.append(c); } } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternRDN.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternRDN.java index 3ca7f2728a..6a4d637160 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternRDN.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternRDN.java @@ -21,7 +21,6 @@ import static org.opends.server.util.CollectionUtils.*; import java.util.Arrays; -import java.util.Iterator; import java.util.List; import java.util.TreeMap; @@ -178,7 +177,7 @@ public boolean matchesRDN(RDN rdn) return false; } - // Sort the attribute-value pairs by attribute type. + // Index the value patterns by attribute type. TreeMap> patternMap = new TreeMap<>(); for (int i = 0; i < typePatterns.length; i++) { @@ -189,13 +188,16 @@ public boolean matchesRDN(RDN rdn) } patternMap.put(type.getNameOrOID(), valuePatterns.get(i)); } + if (patternMap.size() != rdn.size()) + { + return false; + } - Iterator patternKeyIter = patternMap.keySet().iterator(); + // An RDN keeps its AVAs in the order of the DN string, so look each one up by its type. for (AVA ava : rdn) { - String rdnKey = ava.getAttributeType().getNameOrOID(); - if (!rdnKey.equals(patternKeyIter.next()) - || !matchValuePattern(patternMap.get(rdnKey), ava)) + List valuePattern = patternMap.get(ava.getAttributeType().getNameOrOID()); + if (valuePattern == null || !matchValuePattern(valuePattern, ava)) { return false; } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/UserAttr.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/UserAttr.java index ea1f069cdc..ce18c87d86 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/UserAttr.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/UserAttr.java @@ -130,12 +130,14 @@ private UserAttr(UserAttrType userAttrType, EnumBindRuleType type, public static KeywordBindRule decode(String expression, EnumBindRuleType type) throws AciException { - String[] vals=expression.split("#"); - if(vals.length != 2) { + // The attribute name cannot contain an octothorpe, but the value after it can. + final int sharpPos = expression.indexOf('#'); + if (sharpPos < 0 || sharpPos == expression.length() - 1) { LocalizableMessage message = WARN_ACI_SYNTAX_INVALID_USERATTR_EXPRESSION.get(expression); throw new AciException(message); } + final String[] vals = { expression.substring(0, sharpPos), expression.substring(sharpPos + 1) }; UserAttrType userAttrType = UserAttrType.getType(vals[1]); switch (userAttrType) { case GROUPDN: diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java index a70f9a0065..e7a16b6fed 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java @@ -47,6 +47,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; +import org.forgerock.opendj.ldap.DN; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.opendj.config.server.ConfigChangeResult; @@ -1003,7 +1004,7 @@ private void open0() throws ConfigException try { env = new Environment(backendDirectory, envConfig); - monitor = new JEMonitor(config.getBackendId() + " JE Database", env); + monitor = new JEMonitor(DN.escapeAttributeValue(config.getBackendId()) + " JE Database", env); DirectoryServer.registerMonitorProvider(monitor); } catch (DatabaseException e) diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java index ed60bc1310..d740649238 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java @@ -44,6 +44,7 @@ import java.util.Set; import java.util.concurrent.TimeUnit; +import org.forgerock.opendj.ldap.DN; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.opendj.config.server.ConfigChangeResult; @@ -1212,7 +1213,7 @@ private void open0(final Configuration dbCfg) throws ConfigException db.initialize(); volume = db.loadVolume(VOLUME_NAME); - monitor = new PDBMonitor(config.getBackendId() + " PDB Database", db); + monitor = new PDBMonitor(DN.escapeAttributeValue(config.getBackendId()) + " PDB Database", db); DirectoryServer.registerMonitorProvider(monitor); } catch(final InUseException e) { diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java index e1dbac5b3d..ee9d0cebec 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java @@ -336,7 +336,7 @@ BackendMonitor getMonitorProvider() { if (monitor == null) { - monitor = new BackendMonitor(backendId + " Storage", this); + monitor = new BackendMonitor(DN.escapeAttributeValue(backendId) + " Storage", this); } return monitor; } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/config/JMXMBean.java b/opendj-server-legacy/src/main/java/org/opends/server/config/JMXMBean.java index 8e065613f4..aa3719214c 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/config/JMXMBean.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/config/JMXMBean.java @@ -13,7 +13,7 @@ * * Portions Copyright 2006-2007-2008 Sun Microsystems, Inc. * Portions Copyright 2013-2016 ForgeRock AS. - * Portions Copyright 2023-2025 3A Systems LLC. + * Portions Copyright 2023-2026 3A Systems LLC. */ package org.opends.server.config; @@ -49,6 +49,7 @@ import static org.opends.server.protocols.internal.Requests.newSearchRequest; import static org.opends.server.util.CollectionUtils.newArrayList; import static org.opends.server.util.ServerConstants.MBEAN_BASE_DOMAIN; +import static org.opends.server.util.StaticUtils.byteToHex; import static org.opends.server.util.StaticUtils.isAlpha; import static org.opends.server.util.StaticUtils.isDigit; @@ -95,40 +96,16 @@ public static String getJmxName (DN configEntryDN) { try { - String typeStr = null; - String dnString = configEntryDN.toString(); - if (dnString != null && dnString.length() != 0) + StringBuilder buffer = new StringBuilder(); + // Walk the RDNs from the root, rather than splitting the DN string at every comma. + for (int j = configEntryDN.size() - 1; j >= 0; j--) { - StringBuilder buffer = new StringBuilder(dnString.length()); - String rdns[] = dnString.replace(',', ';').split(";"); - for (int j = rdns.length - 1; j >= 0; j--) - { - int rdnIndex = rdns.length - j; - buffer.append(",Rdn").append(rdnIndex).append("=") ; - for (int i = 0; i < rdns[j].length(); i++) - { - char c = rdns[j].charAt(i); - if (isAlpha(c) || isDigit(c)) - { - buffer.append(c); - } else - { - switch (c) - { - case ' ': - buffer.append("_"); - break; - case '=': - buffer.append("-"); - } - } - } - } - - typeStr = buffer.toString(); + int rdnIndex = configEntryDN.size() - j; + buffer.append(",Rdn").append(rdnIndex).append("="); + appendJmxRdn(buffer, configEntryDN.parent(j).rdn()); } - - return MBEAN_BASE_DOMAIN + ":" + "Name=rootDSE" + typeStr; + // The root DN keeps the name it always had, which ends with "null". + return MBEAN_BASE_DOMAIN + ":" + "Name=rootDSE" + (buffer.length() != 0 ? buffer : "null"); } catch (Exception e) { logger.traceException(e); @@ -137,6 +114,69 @@ public static String getJmxName (DN configEntryDN) } } + /** + * Appends the JMX form of an RDN to the provided buffer. The attribute name keeps only its letters and + * digits, and is followed by '-' and the value. A value made of letters, digits and spaces is written as it + * always was, with '_' for a space. Any other value would lose characters that way, and two DNs would share + * a name, so it is percent-encoded instead: its letters and digits are kept and every other character is + * written as the '%' encoded bytes of its UTF-8 form. The AVAs of a multi-valued RDN are joined with '+'. + */ + private static void appendJmxRdn(StringBuilder buffer, RDN rdn) + { + boolean first = true; + for (AVA ava : rdn) + { + if (!first) + { + buffer.append('+'); + } + first = false; + String name = ava.getAttributeName(); + for (int i = 0; i < name.length(); i++) + { + char c = name.charAt(i); + if (isAlpha(c) || isDigit(c)) + { + buffer.append(c); + } + } + buffer.append('-'); + String value = ava.getAttributeValue().toString(); + if (isPlainJmxValue(value)) + { + buffer.append(value.replace(' ', '_')); + } + else + { + for (byte b : ava.getAttributeValue().toByteArray()) + { + char c = (char) (b & 0xFF); + if (c < 0x80 && (isAlpha(c) || isDigit(c))) + { + buffer.append(c); + } + else + { + buffer.append('%').append(byteToHex(b)); + } + } + } + } + } + + private static boolean isPlainJmxValue(String value) + { + for (int i = 0; i < value.length(); i++) + { + char c = value.charAt(i); + if (!isAlpha(c) && !isDigit(c) && c != ' ') + { + return false; + } + } + return true; + } + /** * Creates a new dynamic JMX MBean for use with the Directory Server. * diff --git a/opendj-server-legacy/src/main/java/org/opends/server/core/DirectoryServer.java b/opendj-server-legacy/src/main/java/org/opends/server/core/DirectoryServer.java index 3b6ffa311a..aba9fbab8b 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/core/DirectoryServer.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/core/DirectoryServer.java @@ -63,6 +63,7 @@ import org.forgerock.http.routing.Router; import org.forgerock.i18n.LocalizableMessage; +import org.forgerock.i18n.LocalizedIllegalArgumentException; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.opendj.adapter.server3x.Converters; import org.forgerock.opendj.config.ConfigurationFramework; @@ -70,6 +71,7 @@ import org.forgerock.opendj.config.server.ServerManagementContext; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.ResultCode; +import org.forgerock.opendj.ldap.SearchScope; import org.forgerock.opendj.ldap.schema.Schema; import org.forgerock.opendj.server.config.server.AlertHandlerCfg; import org.forgerock.opendj.server.config.server.ConnectionHandlerCfg; @@ -5117,8 +5119,25 @@ else if (noDetach.isPresent() && timeout.isPresent()) { */ public static DN getMonitorProviderDN(MonitorProvider provider) { - // Get a complete DN which could be a tree naming schema - return DN.valueOf("cn=" + provider.getMonitorInstanceName() + "," + DN_MONITOR_ROOT); + final String name = provider.getMonitorInstanceName(); + final DN monitorRoot = DN.valueOf(DN_MONITOR_ROOT); + // The name may be a relative DN that names an entry below cn=monitor, possibly several levels down: + // the replication monitors build a tree that way. + try + { + final DN dn = DN.valueOf("cn=" + name + "," + DN_MONITOR_ROOT); + if (dn.isInScopeOf(monitorRoot, SearchScope.SUBORDINATES)) + { + return dn; + } + } + catch (LocalizedIllegalArgumentException e) + { + // Not a relative DN, see below. + } + // Otherwise the whole name is the value of the RDN of the entry. A name that is not a relative DN, such as + // "LDAP, internal 0.0.0.0 port 1389", must not make the whole monitor backend fail. + return monitorRoot.child("cn", name); } /** diff --git a/opendj-server-legacy/src/main/java/org/opends/server/extensions/DiskSpaceMonitor.java b/opendj-server-legacy/src/main/java/org/opends/server/extensions/DiskSpaceMonitor.java index effc0e5a27..f6b3dc86d5 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/extensions/DiskSpaceMonitor.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/extensions/DiskSpaceMonitor.java @@ -83,7 +83,7 @@ private MonitoredDirectory(File directory, String instanceName, String baseName, @Override public String getMonitorInstanceName() { - return instanceName + "," + "cn=" + baseName; + return DN.escapeAttributeValue(instanceName) + "," + "cn=" + baseName; } @Override diff --git a/opendj-server-legacy/src/main/java/org/opends/server/monitors/ClientConnectionMonitorProvider.java b/opendj-server-legacy/src/main/java/org/opends/server/monitors/ClientConnectionMonitorProvider.java index a87a8b6ba8..e60169da25 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/monitors/ClientConnectionMonitorProvider.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/monitors/ClientConnectionMonitorProvider.java @@ -13,6 +13,7 @@ * * Copyright 2006-2010 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.monitors; @@ -20,6 +21,7 @@ import java.util.Collection; import java.util.TreeMap; +import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.config.server.ConfigException; import org.forgerock.opendj.server.config.server.ClientConnectionMonitorProviderCfg; import org.opends.server.api.ClientConnection; @@ -101,7 +103,7 @@ public String getMonitorInstanceName() { // Client connections of a connection handler return "Client Connections" + ",cn=" - + handler.getConnectionHandlerName(); + + DN.escapeAttributeValue(handler.getConnectionHandlerName()); } } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/monitors/ConnectionHandlerMonitor.java b/opendj-server-legacy/src/main/java/org/opends/server/monitors/ConnectionHandlerMonitor.java index d598e34b06..f6e03d435a 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/monitors/ConnectionHandlerMonitor.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/monitors/ConnectionHandlerMonitor.java @@ -13,6 +13,7 @@ * * Copyright 2006-2010 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.monitors; @@ -22,6 +23,7 @@ import java.util.Collection; import java.util.LinkedList; +import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.server.config.server.ConnectionHandlerCfg; import org.forgerock.opendj.server.config.server.MonitorProviderCfg; import org.opends.server.api.ClientConnection; @@ -67,7 +69,8 @@ public ConnectionHandlerMonitor( @Override public void initializeMonitorProvider(MonitorProviderCfg configuration) { - monitorName = connectionHandler.getConnectionHandlerName(); + // The name of the connection handler is the value of the RDN of the monitor entry. + monitorName = DN.escapeAttributeValue(connectionHandler.getConnectionHandlerName()); } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/monitors/EntryCacheMonitorProvider.java b/opendj-server-legacy/src/main/java/org/opends/server/monitors/EntryCacheMonitorProvider.java index f143f55e04..72882aa97d 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/monitors/EntryCacheMonitorProvider.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/monitors/EntryCacheMonitorProvider.java @@ -13,11 +13,13 @@ * * Copyright 2008-2010 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.monitors; import static org.opends.messages.ConfigMessages.*; +import org.forgerock.opendj.ldap.DN; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.opendj.config.server.ConfigException; @@ -72,7 +74,7 @@ public EntryCacheMonitorProvider( String entryCacheName, EntryCache entryCache) { - this.entryCacheName = entryCacheName + " Entry Cache"; + this.entryCacheName = DN.escapeAttributeValue(entryCacheName) + " Entry Cache"; this.entryCache = entryCache; } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/monitors/LocalBackendMonitor.java b/opendj-server-legacy/src/main/java/org/opends/server/monitors/LocalBackendMonitor.java index d682df346f..a432289a6b 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/monitors/LocalBackendMonitor.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/monitors/LocalBackendMonitor.java @@ -13,6 +13,7 @@ * * Copyright 2006-2010 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.monitors; @@ -61,7 +62,7 @@ public LocalBackendMonitor(LocalBackend backend) @Override public void initializeMonitorProvider(MonitorProviderCfg configuration) { - monitorName = backend.getBackendID() + " Backend"; + monitorName = DN.escapeAttributeValue(backend.getBackendID()) + " Backend"; } @Override diff --git a/opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPConnectionHandler.java b/opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPConnectionHandler.java index e80cadc5a5..407862b0d8 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPConnectionHandler.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPConnectionHandler.java @@ -680,7 +680,7 @@ public void initializeConnectionHandler(ServerContext serverContext, LDAPConnect } // Create and register monitors. - statTracker = new LDAPStatistics(handlerName + " Statistics"); + statTracker = new LDAPStatistics(DN.escapeAttributeValue(handlerName) + " Statistics"); DirectoryServer.registerMonitorProvider(statTracker); connMonitor = new ClientConnectionMonitorProvider(this); diff --git a/opendj-server-legacy/src/main/java/org/opends/server/replication/common/MultiDomainServerState.java b/opendj-server-legacy/src/main/java/org/opends/server/replication/common/MultiDomainServerState.java index 2500547c90..a2275b8558 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/replication/common/MultiDomainServerState.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/replication/common/MultiDomainServerState.java @@ -13,11 +13,13 @@ * * Copyright 2006-2009 Sun Microsystems, Inc. * Portions Copyright 2011-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.replication.common; import static org.opends.messages.ReplicationMessages.*; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; @@ -395,23 +397,18 @@ private static Map splitGenStateToServerStates( try { // Split the provided multiDomainServerState into domains - String[] domains = multiDomainServerState.split(";"); - for (String domain : domains) + for (String domain : splitDomains(multiDomainServerState)) { // For each domain, split the CSNs by server // and build a server state (SHOULD BE OPTIMIZED) final ServerState serverStateByDomain = new ServerState(); - final String[] fields = domain.split(":"); - if (fields.length == 0) + // The base DN may contain ':' (o=urn:x), but a CSN never does: the state starts after the last one. + final int colonPos = domain.lastIndexOf(':'); + final String domainBaseDN = colonPos >= 0 ? domain.substring(0, colonPos) : domain; + if (colonPos >= 0 && colonPos + 1 < domain.length()) { - throw new DirectoryException(ResultCode.PROTOCOL_ERROR, - ERR_INVALID_COOKIE_SYNTAX.get(multiDomainServerState)); - } - final String domainBaseDN = fields[0]; - if (fields.length > 1) - { - final String serverStateStr = fields[1]; + final String serverStateStr = domain.substring(colonPos + 1); for (String csnStr : serverStateStr.split(" ")) { final CSN csn = new CSN(csnStr); @@ -421,10 +418,6 @@ private static Map splitGenStateToServerStates( startStates.put(DN.valueOf(domainBaseDN), serverStateByDomain); } } - catch (DirectoryException de) - { - throw de; - } catch (Exception e) { throw new DirectoryException( @@ -435,4 +428,39 @@ private static Map splitGenStateToServerStates( } return startStates; } + + /** + * Splits the provided cookie at each ';' that ends a domain. {@link DN#toString()} escapes a ';' of the base DN + * as "\;", so a ';' preceded by an unescaped backslash belongs to the base DN. As {@link String#split(String)} + * did, empty trailing domains are dropped. + */ + private static List splitDomains(String multiDomainServerState) + { + final List domains = new ArrayList<>(); + int start = 0; + boolean escaped = false; + for (int i = 0; i < multiDomainServerState.length(); i++) + { + final char c = multiDomainServerState.charAt(i); + if (escaped) + { + escaped = false; + } + else if (c == '\\') + { + escaped = true; + } + else if (c == ';') + { + domains.add(multiDomainServerState.substring(start, i)); + start = i + 1; + } + } + domains.add(multiDomainServerState.substring(start)); + while (!domains.isEmpty() && domains.get(domains.size() - 1).isEmpty()) + { + domains.remove(domains.size() - 1); + } + return domains; + } } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java b/opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java index 524476a445..c02518903a 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java @@ -2553,9 +2553,25 @@ public void initializeMonitorProvider(MonitorProviderCfg configuraiton) @Override public String getMonitorInstanceName() { - return "Replication server RS(" + localReplicationServer.getServerId() - + ") " + localReplicationServer.getServerURL() + ",cn=" - + baseDN.toString().replace(',', '_').replace('=', '_') + return getMonitorInstanceName(localReplicationServer.getServerId(), localReplicationServer.getServerURL(), baseDN); + } + + /** + * Returns the name of the monitor of a replication server domain: the relative DN of its entry below cn=monitor. + * + * @param serverId + * the server ID of the replication server + * @param serverURL + * the URL of the replication server + * @param baseDN + * the base DN of the domain + * @return the name of the monitor of the replication server domain + */ + static String getMonitorInstanceName(int serverId, String serverURL, DN baseDN) + { + // The base DN, with '_' for ',' and '=', is the value of an RDN: escape what is left, such as '+'. + return "Replication server RS(" + serverId + ") " + serverURL + + ",cn=" + DN.escapeAttributeValue(baseDN.toString().replace(',', '_').replace('=', '_')) + ",cn=Replication"; } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/replication/service/ReplicationMonitor.java b/opendj-server-legacy/src/main/java/org/opends/server/replication/service/ReplicationMonitor.java index 28719db3f1..8c934a0d37 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/replication/service/ReplicationMonitor.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/replication/service/ReplicationMonitor.java @@ -13,6 +13,7 @@ * * Copyright 2006-2010 Sun Microsystems, Inc. * Portions copyright 2013-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.replication.service; @@ -22,8 +23,10 @@ import java.util.Map.Entry; import org.opends.server.api.MonitorData; +import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.server.config.server.MonitorProviderCfg; import org.opends.server.api.MonitorProvider; +import org.opends.server.types.HostPort; import org.opends.server.replication.service.ReplicationDomain.ImportExportContext; /** @@ -58,9 +61,25 @@ public void initializeMonitorProvider(MonitorProviderCfg configuration) @Override public String getMonitorInstanceName() { - return "Directory server DS(" + domain.getServerId() + ") " - + domain.getLocalUrl() - + ",cn=" + domain.getBaseDN().toString().replace(',', '_').replace('=', '_') + return getMonitorInstanceName(domain.getServerId(), domain.getLocalUrl(), domain.getBaseDN()); + } + + /** + * Returns the name of the monitor of a replication domain: the relative DN of its entry below cn=monitor. + * + * @param serverId + * the server ID of the replication domain + * @param localUrl + * the URL of the replication domain + * @param baseDN + * the base DN of the replication domain + * @return the name of the monitor of the replication domain + */ + static String getMonitorInstanceName(int serverId, HostPort localUrl, DN baseDN) + { + // The base DN, with '_' for ',' and '=', is the value of an RDN: escape what is left, such as '+'. + return "Directory server DS(" + serverId + ") " + localUrl + + ",cn=" + DN.escapeAttributeValue(baseDN.toString().replace(',', '_').replace('=', '_')) + ",cn=Replication"; } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/tasks/TaskClient.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/tasks/TaskClient.java index 2a8a0c254e..68b2ee6aba 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/tools/tasks/TaskClient.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/tasks/TaskClient.java @@ -13,6 +13,7 @@ * * Copyright 2009-2010 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.tools.tasks; @@ -32,6 +33,7 @@ import org.forgerock.i18n.LocalizableMessage; import org.forgerock.opendj.ldap.ByteString; +import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.DecodeException; import org.forgerock.opendj.ldap.DereferenceAliasesPolicy; import org.forgerock.opendj.ldap.ModificationType; @@ -123,12 +125,13 @@ public static String getTaskDN(List taskAttributes) RawAttribute recurringIDAttr = getAttribute(ATTR_RECURRING_TASK_ID, taskAttributes); + // The task ID may be typed by the user (--recurringTask uses the --backupID): escape it. if (recurringIDAttr != null) { - entryDN = ATTR_RECURRING_TASK_ID + "=" + - taskID + "," + RECURRING_TASK_BASE_RDN + "," + DN_TASK_ROOT; + entryDN = DN.valueOf(RECURRING_TASK_BASE_RDN + "," + DN_TASK_ROOT) + .child(ATTR_RECURRING_TASK_ID, taskID).toString(); } else { - entryDN = ATTR_TASK_ID + "=" + taskID + "," + - SCHEDULED_TASK_BASE_RDN + "," + DN_TASK_ROOT; + entryDN = DN.valueOf(SCHEDULED_TASK_BASE_RDN + "," + DN_TASK_ROOT) + .child(ATTR_TASK_ID, taskID).toString(); } return entryDN; } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/types/LDAPURL.java b/opendj-server-legacy/src/main/java/org/opends/server/types/LDAPURL.java index 99f971f75b..66c6730877 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/types/LDAPURL.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/types/LDAPURL.java @@ -17,6 +17,7 @@ */ package org.opends.server.types; +import java.nio.charset.StandardCharsets; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.LinkedList; @@ -807,11 +808,19 @@ private static void urlEncode(String s, boolean isExtension, continue; } + if (Character.isHighSurrogate(c) && i + 1 < length && Character.isLowSurrogate(s.charAt(i + 1))) + { + // A character outside the BMP: encode both chars of the surrogate pair as one code point. + hexEncode(s.substring(i, i + 2), buffer); + i++; + continue; + } + if (c == ',') { if (isExtension) { - hexEncode(c, buffer); + hexEncode(String.valueOf(c), buffer); } else { @@ -846,7 +855,7 @@ private static void urlEncode(String s, boolean isExtension, buffer.append(c); break; default: - hexEncode(c, buffer); + hexEncode(String.valueOf(c), buffer); break; } } @@ -855,32 +864,19 @@ private static void urlEncode(String s, boolean isExtension, /** - * Appends a percent-encoded representation of the provided - * character to the given buffer. + * Appends the percent-encoded UTF-8 octets of the provided + * characters to the given buffer, as RFC 4516 section 2.1 requires. * - * @param c The character to add to the buffer. + * @param chars The characters to add to the buffer. * @param buffer The buffer to which the percent-encoded * representation should be written. */ - private static void hexEncode(char c, StringBuilder buffer) + private static void hexEncode(String chars, StringBuilder buffer) { - if ((c & (byte) 0xFF) == c) - { - // It's a single byte. - buffer.append('%'); - buffer.append(byteToHex((byte) c)); - } - else + for (byte b : chars.getBytes(StandardCharsets.UTF_8)) { - // It requires two bytes, and each should be prefixed by a - // percent sign. - buffer.append('%'); - byte b1 = (byte) ((c >>> 8) & 0xFF); - buffer.append(byteToHex(b1)); - buffer.append('%'); - byte b2 = (byte) (c & 0xFF); - buffer.append(byteToHex(b2)); + buffer.append(byteToHex(b)); } } diff --git a/opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/ui/NewEntryDNTestCase.java b/opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/ui/NewEntryDNTestCase.java new file mode 100644 index 0000000000..96c4d23ebc --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/ui/NewEntryDNTestCase.java @@ -0,0 +1,68 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions Copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.guitools.controlpanel.ui; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.forgerock.opendj.ldap.ByteString; +import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.RDN; +import org.opends.server.DirectoryServerTestCase; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** Tests the DN that the new entry panels show for the naming value typed by the user (issue #1153). */ +@SuppressWarnings("javadoc") +public class NewEntryDNTestCase extends DirectoryServerTestCase +{ + private static final String PARENT = "ou=People,dc=example,dc=com"; + + @DataProvider + public Object[][] namingValues() + { + return new Object[][] { { "John Smith" }, { "Smith, John" }, { "a+b" }, { "#1 fan" }, { "Smith;Jr" }, + { "a\\b" }, { "a=b" }, { " lead" } }; + } + + @Test(dataProvider = "namingValues") + public void newEntryDNUnderAParentNodeKeepsTheWholeValue(String value) + { + assertNewEntryDN(AbstractNewEntryPanel.getNewEntryDN("cn", value, DN.valueOf(PARENT)), value); + } + + @Test(dataProvider = "namingValues") + public void newEntryDNUnderATypedParentKeepsTheWholeValue(String value) + { + assertNewEntryDN(AbstractNewEntryPanel.getNewEntryDN("cn", value, PARENT), value); + } + + @Test + public void newEntryDNUnderAParentThatIsStillBeingTypedEscapesTheValue() + { + assertThat(AbstractNewEntryPanel.getNewEntryDN("cn", "Smith, John", "ou=People,dc")).isEqualTo( + "cn=Smith\\, John,ou=People,dc"); + } + + private static void assertNewEntryDN(String dnString, String value) + { + final DN dn = DN.valueOf(dnString); + assertThat((Object) dn.parent()).isEqualTo(DN.valueOf(PARENT)); + final RDN rdn = dn.rdn(); + assertThat(rdn.size()).isEqualTo(1); + assertThat(rdn.getFirstAVA().getAttributeType().hasName("cn")).isTrue(); + assertThat((Object) rdn.getFirstAVA().getAttributeValue()).isEqualTo(ByteString.valueOfUtf8(value)); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/util/UnescapeUtf8TestCase.java b/opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/util/UnescapeUtf8TestCase.java new file mode 100644 index 0000000000..99db925b79 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/util/UnescapeUtf8TestCase.java @@ -0,0 +1,60 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions Copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.guitools.controlpanel.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.forgerock.opendj.ldap.DN; +import org.opends.server.DirectoryServerTestCase; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** Tests {@link Utilities#unescapeUtf8(String)}, which the control panel applies to DN strings (issue #1153). */ +@SuppressWarnings("javadoc") +public class UnescapeUtf8TestCase extends DirectoryServerTestCase +{ + @DataProvider + public Object[][] dns() + { + return new Object[][] { + { "cn=a\\\\41,dc=x" }, + { "cn=a\\\\\\\\41,dc=x" }, + { "cn=J\\C3\\B6rg\\\\C3\\\\B6,dc=x" }, + { "cn=Before\\0dAfter,dc=x" }, + { "cn=Smith\\, John,dc=x" }, + }; + } + + /** The displayed DN must still be the same DN once the user selects it and the control panel parses it back. */ + @Test(dataProvider = "dns") + public void unescapedDNParsesBackToTheSameDN(String dnString) + { + final DN dn = DN.valueOf(dnString); + assertThat((Object) DN.valueOf(Utilities.unescapeUtf8(dn.toString()))).isEqualTo(dn); + } + + @Test + public void escapedBackslashIsNotTheStartOfAHexPair() + { + assertThat(Utilities.unescapeUtf8("cn=a\\\\41,dc=x")).isEqualTo("cn=a\\\\41,dc=x"); + } + + @Test + public void hexPairsAreDecodedAsUtf8() + { + assertThat(Utilities.unescapeUtf8("cn=J\\C3\\B6rg,dc=x")).isEqualTo("cn=Jörg,dc=x"); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java new file mode 100644 index 0000000000..53f56a95d1 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java @@ -0,0 +1,117 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions Copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.authorization.dseecompat; + +import static org.assertj.core.api.Assertions.*; + +import org.forgerock.opendj.ldap.DN; +import org.opends.server.DirectoryServerTestCase; +import org.opends.server.TestCaseUtils; +import org.opends.server.types.DirectoryException; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** + * Verifies that {@link PatternDN}, which parses the ACI target and userdn patterns and the DN criteria of + * the access log filtering, reads a pattern the same way {@link DN#valueOf(String)} reads a DN (issue #1153). + */ +@SuppressWarnings("javadoc") +public class PatternDNTest extends DirectoryServerTestCase +{ + @BeforeClass + public void setUp() throws Exception + { + TestCaseUtils.startFakeServer(); + } + + @AfterClass + public void tearDown() throws DirectoryException + { + TestCaseUtils.shutdownFakeServer(); + } + + /** Patterns without a wildcard: each one must match the DN that DN.valueOf() reads from the same string. */ + @DataProvider + public Object[][] patternsWithoutWildcard() + { + return new Object[][] { + { "cn=a\\2Cb,dc=x" }, + { "cn=\"a\\,b\",dc=x" }, + // A hex pair inside quotes is decoded as it is outside quotes + { "cn=\"a\\2Cb\",dc=x" }, + { "cn=\"J\\C3\\B6rg\",dc=x" }, + // An empty value is followed by the next AVA or RDN, it does not swallow it + { "cn=+sn=x,dc=y" }, + { "cn=x+sn=,dc=y" }, + { "cn=,dc=x" }, + { "cn= ,dc=x" }, + { "cn=;dc=x" }, + // A hex string may be followed directly by '+' + { "cn=#04024869+sn=x,dc=y" }, + { "sn=x+cn=#04024869,dc=y" }, + // The RFC 2253 separator + { "cn=a;dc=x" }, + // The AVAs of a multi-valued RDN match whatever their order + { "sn=x+cn=y,dc=z" }, + { "cn=y+sn=x,dc=z" }, + }; + } + + @Test(dataProvider = "patternsWithoutWildcard") + public void patternMatchesTheDNParsedFromTheSameString(String pattern) throws Exception + { + final DN dn = DN.valueOf(pattern); + assertThat(PatternDN.decode(pattern).matchesDN(dn)).as("pattern %s against DN %s", pattern, dn).isTrue(); + } + + @DataProvider + public Object[][] patternsAndOtherDNs() + { + return new Object[][] { + { "cn=\"a\\2Cb\",dc=x", "cn=a2Cb,dc=x" }, + { "cn=+sn=x,dc=y", "cn=\\+sn\\=x,dc=y" }, + { "cn=,dc=x", "dc=x" }, + { "cn=,dc=x", "cn=\\,dc\\=x" }, + { "cn=#04024869+sn=x,dc=y", "cn=#04024869,dc=y" }, + { "sn=x+cn=y,dc=z", "sn=y+cn=x,dc=z" }, + { "sn=x+cn=y,dc=z", "sn=x+givenName=y,dc=z" }, + }; + } + + @Test(dataProvider = "patternsAndOtherDNs") + public void patternDoesNotMatchADifferentDN(String pattern, String otherDN) throws Exception + { + assertThat(PatternDN.decode(pattern).matchesDN(DN.valueOf(otherDN))).isFalse(); + } + + @DataProvider + public Object[][] wildcardPatterns() + { + return new Object[][] { + { "cn=#04024869+sn=*,dc=y", "cn=#04024869+sn=x,dc=y" }, + { "cn=a\\2Cb*,dc=x", "cn=a\\,bcd,dc=x" }, + { "cn=*,dc=x", "cn=a,dc=x" }, + }; + } + + @Test(dataProvider = "wildcardPatterns") + public void wildcardPatternMatches(String pattern, String dn) throws Exception + { + assertThat(PatternDN.decode(pattern).matchesDN(DN.valueOf(dn))).isTrue(); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/UserAttrTest.java b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/UserAttrTest.java new file mode 100644 index 0000000000..abb3b3ce37 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/UserAttrTest.java @@ -0,0 +1,86 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions Copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.authorization.dseecompat; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Field; + +import org.opends.server.DirectoryServerTestCase; +import org.opends.server.TestCaseUtils; +import org.opends.server.types.DirectoryException; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** + * Verifies that {@link UserAttr#decode(String, EnumBindRuleType)} splits a userattr expression at its first + * octothorpe: the attribute name cannot contain one, the value after it can (issue #1153). + */ +@SuppressWarnings("javadoc") +public class UserAttrTest extends DirectoryServerTestCase +{ + @BeforeClass + public void setUp() throws Exception + { + TestCaseUtils.startFakeServer(); + } + + @AfterClass + public void tearDown() throws DirectoryException + { + TestCaseUtils.shutdownFakeServer(); + } + + @DataProvider + public Object[][] valueExpressions() + { + return new Object[][] { + { "departmentNumber#a1", "departmentNumber", "a1" }, + { "departmentNumber#a#1", "departmentNumber", "a#1" }, + { "departmentNumber##", "departmentNumber", "#" }, + }; + } + + @Test(dataProvider = "valueExpressions") + public void decodeSplitsAtTheFirstOctothorpe(String expression, String attrName, String value) throws Exception + { + final KeywordBindRule rule = UserAttr.decode(expression, EnumBindRuleType.EQUAL_BINDRULE_TYPE); + + assertThat(field(rule, "attrStr")).isEqualTo(attrName); + assertThat(field(rule, "attrVal")).isEqualTo(value); + } + + @DataProvider + public Object[][] invalidExpressions() + { + return new Object[][] { { "departmentNumber" }, { "departmentNumber#" } }; + } + + @Test(dataProvider = "invalidExpressions", expectedExceptions = AciException.class) + public void decodeRejectsAnExpressionWithoutValue(String expression) throws Exception + { + UserAttr.decode(expression, EnumBindRuleType.EQUAL_BINDRULE_TYPE); + } + + private static Object field(Object object, String name) throws Exception + { + final Field field = UserAttr.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(object); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/config/JMXMBeanNameTest.java b/opendj-server-legacy/src/test/java/org/opends/server/config/JMXMBeanNameTest.java new file mode 100644 index 0000000000..ed53f100a7 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/config/JMXMBeanNameTest.java @@ -0,0 +1,81 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions Copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.config; + +import static org.assertj.core.api.Assertions.*; + +import java.util.HashSet; +import java.util.Set; + +import javax.management.ObjectName; + +import org.forgerock.opendj.ldap.DN; +import org.opends.server.DirectoryServerTestCase; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** Tests the JMX object names that {@link JMXMBean#getJmxName(DN)} builds from DNs (issue #1153). */ +@SuppressWarnings("javadoc") +public class JMXMBeanNameTest extends DirectoryServerTestCase +{ + /** Names whose values hold only letters, digits and spaces keep the name they always had. */ + @DataProvider + public Object[][] unchangedNames() + { + return new Object[][] { + { "cn=monitor", "org.opends.server:Name=rootDSE,Rdn1=cn-monitor" }, + { "ds-cfg-backend-id=userRoot,cn=Backends,cn=config", + "org.opends.server:Name=rootDSE,Rdn1=cn-config,Rdn2=cn-Backends,Rdn3=dscfgbackendid-userRoot" }, + { "cn=JVM Memory Usage,cn=monitor", "org.opends.server:Name=rootDSE,Rdn1=cn-monitor,Rdn2=cn-JVM_Memory_Usage" }, + }; + } + + @Test(dataProvider = "unchangedNames") + public void plainNamesAreUnchanged(String dn, String expectedName) throws Exception + { + assertThat(JMXMBean.getJmxName(DN.valueOf(dn))).isEqualTo(expectedName); + } + + /** Each group holds DNs whose values only differ by characters that the old mapping dropped. */ + @DataProvider + public Object[][] distinctDNs() + { + return new Object[][] { + { new String[] { "ds-cfg-backend-id=user-root,cn=Backends,cn=config", + "ds-cfg-backend-id=userroot,cn=Backends,cn=config", + "ds-cfg-backend-id=user_root,cn=Backends,cn=config", + "ds-cfg-backend-id=user root,cn=Backends,cn=config", + "ds-cfg-backend-id=user%20root,cn=Backends,cn=config" } }, + { new String[] { "cn=a\\,b,cn=monitor", "cn=ab,cn=monitor", "cn=a,cn=b,cn=monitor", "cn=a+sn=b,cn=monitor", + "cn=a\\+sn\\=b,cn=monitor", "cn=asn\\=b,cn=monitor" } }, + { new String[] { "cn=J\\C3\\B6rg,cn=monitor", "cn=Jrg,cn=monitor", "cn=J\\C3\\A4rg,cn=monitor" } }, + }; + } + + @Test(dataProvider = "distinctDNs") + public void distinctDNsGetDistinctValidNames(String[] dns) throws Exception + { + final Set names = new HashSet<>(); + for (String dn : dns) + { + final String name = JMXMBean.getJmxName(DN.valueOf(dn)); + assertThat(name).as("JMX name of %s", dn).isNotNull(); + assertThat(new ObjectName(name).isPattern()).as("JMX name %s", name).isFalse(); + names.add(name); + } + assertThat(names).hasSize(dns.length); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/monitors/MonitorDNTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/monitors/MonitorDNTestCase.java new file mode 100644 index 0000000000..acdcce5148 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/monitors/MonitorDNTestCase.java @@ -0,0 +1,256 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions Copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.monitors; + +import static org.assertj.core.api.Assertions.*; +import static org.forgerock.opendj.ldap.SearchScope.*; +import static org.opends.server.protocols.internal.InternalClientConnection.*; +import static org.opends.server.protocols.internal.Requests.*; + +import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.ResultCode; +import org.forgerock.opendj.server.config.server.MonitorProviderCfg; +import org.opends.server.TestCaseUtils; +import org.opends.server.api.ConnectionHandler; +import org.opends.server.api.MonitorData; +import org.opends.server.api.MonitorProvider; +import org.opends.server.core.DirectoryServer; +import org.opends.server.protocols.internal.InternalSearchOperation; +import org.opends.server.protocols.ldap.LDAPConnectionHandler; +import org.opends.server.types.Entry; +import org.forgerock.opendj.reactive.LDAPConnectionHandler2; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** + * A monitor provider whose name holds ',', '+' or '\' must get the monitor entry it names, and must not + * break the rest of cn=monitor (issue #1153). + */ +@SuppressWarnings("javadoc") +public class MonitorDNTestCase extends MonitorTestCase +{ + private static final DN MONITOR_ROOT = DN.valueOf("cn=monitor"); + + /** A monitor provider of a third party, which gives its name as is. */ + private static final class NamedMonitorProvider extends MonitorProvider + { + private final String name; + + NamedMonitorProvider(String name) + { + this.name = name; + } + + @Override + public void initializeMonitorProvider(MonitorProviderCfg configuration) + { + // No implementation required. + } + + @Override + public String getMonitorInstanceName() + { + return name; + } + + @Override + public MonitorData getMonitorData() + { + return new MonitorData(0); + } + } + + @BeforeClass + public void startServer() throws Exception + { + TestCaseUtils.startServer(); + } + + /** The name of {@link TestMonitorProvider} is a relative DN: it still names an entry two levels down. */ + @Test + public void relativeDNNameKeepsItsTree() + { + assertThat((Object) DirectoryServer.getMonitorProviderDN(new TestMonitorProvider())) + .isEqualTo(DN.valueOf("cn=Test monitor for dc=example,dc=com,cn=monitor")); + } + + @DataProvider + public Object[][] namesThatAreNotRelativeDNs() + { + return new Object[][] { { "LDAP, internal 0.0.0.0 port 41390" }, { "a+b" }, { "a;b" }, { "x\\" } }; + } + + @Test(dataProvider = "namesThatAreNotRelativeDNs") + public void nameThatIsNotARelativeDNNamesOneEntry(String name) throws Exception + { + final NamedMonitorProvider provider = new NamedMonitorProvider(name); + assertThat((Object) DirectoryServer.getMonitorProviderDN(provider)).isEqualTo(MONITOR_ROOT.child("cn", name)); + + DirectoryServer.registerMonitorProvider(provider); + try + { + assertMonitorIsSearchable(); + assertEntryExists(MONITOR_ROOT.child("cn", name)); + } + finally + { + DirectoryServer.deregisterMonitorProvider(provider); + } + } + + @DataProvider + public Object[][] connectionHandlerClasses() + { + return new Object[][] { { LDAPConnectionHandler.class.getName() }, { LDAPConnectionHandler2.class.getName() } }; + } + + /** The connection handler, its client connections and its statistics each get the entry they name. */ + @Test(dataProvider = "connectionHandlerClasses") + public void connectionHandlerWithACommaInItsName(String javaClass) throws Exception + { + // Unescaped, "LDAP,ou=internal 127.0.0.1 port N" would be a relative DN two levels down + final String name = "LDAP,ou=internal"; + final int port = TestCaseUtils.findFreePort(); + final Entry handlerEntry = TestCaseUtils.makeEntry( + "dn: cn=LDAP\\,ou\\=internal,cn=Connection Handlers,cn=config", + "objectClass: top", + "objectClass: ds-cfg-connection-handler", + "objectClass: ds-cfg-ldap-connection-handler", + "cn: " + name, + "ds-cfg-java-class: " + javaClass, + "ds-cfg-enabled: true", + "ds-cfg-listen-address: 127.0.0.1", + "ds-cfg-listen-port: " + port, + "ds-cfg-allow-ldap-v2: false", + "ds-cfg-use-ssl: false", + "ds-cfg-allow-start-tls: false"); + TestCaseUtils.addEntry(handlerEntry); + try + { + final String handlerName = connectionHandlerName(name); + assertMonitorIsSearchable(); + assertEntryExists(MONITOR_ROOT.child("cn", handlerName)); + assertEntryExists(MONITOR_ROOT.child("cn", handlerName).child("cn", "Client Connections")); + assertEntryExists(MONITOR_ROOT.child("cn", handlerName + " Statistics")); + } + finally + { + TestCaseUtils.deleteEntry(handlerEntry); + } + } + + @DataProvider + public Object[][] storageBackends() + { + return new Object[][] { + { "ds-cfg-je-backend", "org.opends.server.backends.jeb.JEBackend", " JE Database" }, + { "ds-cfg-pdb-backend", "org.opends.server.backends.pdb.PDBBackend", " PDB Database" }, + }; + } + + /** A backend whose ID holds ',' and '=' gets its backend, storage, database and disk space monitor entries. */ + @Test(dataProvider = "storageBackends") + public void backendWithACommaInItsID(String objectClass, String javaClass, String databaseSuffix) throws Exception + { + // Unescaped, "monitor,ou=a b Backend" would be a relative DN two levels down + final String backendID = "monitor,ou=a b"; + final Entry backendEntry = TestCaseUtils.makeEntry( + "dn: ds-cfg-backend-id=monitor\\,ou\\=a b,cn=Backends,cn=config", + "objectClass: top", + "objectClass: ds-cfg-backend", + "objectClass: ds-cfg-local-backend", + "objectClass: ds-cfg-pluggable-backend", + "objectClass: " + objectClass, + "ds-cfg-enabled: true", + "ds-cfg-java-class: " + javaClass, + "ds-cfg-backend-id: " + backendID, + "ds-cfg-writability-mode: enabled", + "ds-cfg-base-dn: o=monitor dn test", + "ds-cfg-db-directory: db_monitor_dn_test", + "ds-cfg-db-cache-percent: 2"); + TestCaseUtils.addEntry(backendEntry); + try + { + assertMonitorIsSearchable(); + assertEntryExists(MONITOR_ROOT.child("cn", backendID + " Backend")); + assertEntryExists(MONITOR_ROOT.child("cn", backendID + " Storage")); + assertEntryExists(MONITOR_ROOT.child("cn", backendID + databaseSuffix)); + assertEntryExists(MONITOR_ROOT.child("cn", "Disk Space Monitor").child("cn", backendID + " backend")); + } + finally + { + TestCaseUtils.deleteEntry(backendEntry); + } + } + + /** An entry cache whose name holds ',' and '=' gets its monitor entry. */ + @Test + public void entryCacheWithACommaInItsName() throws Exception + { + // Unescaped, "FIFO,ou=a b Entry Cache" would be a relative DN two levels down + final String cacheName = "FIFO,ou=a b"; + final Entry cacheEntry = TestCaseUtils.makeEntry( + "dn: cn=FIFO\\,ou\\=a b,cn=Entry Caches,cn=config", + "objectClass: top", + "objectClass: ds-cfg-entry-cache", + "objectClass: ds-cfg-fifo-entry-cache", + "cn: " + cacheName, + "ds-cfg-cache-level: 7", + "ds-cfg-java-class: org.opends.server.extensions.FIFOEntryCache", + "ds-cfg-enabled: true", + "ds-cfg-max-entries: 10"); + TestCaseUtils.addEntry(cacheEntry); + try + { + assertMonitorIsSearchable(); + assertEntryExists(MONITOR_ROOT.child("cn", cacheName + " Entry Cache")); + } + finally + { + TestCaseUtils.deleteEntry(cacheEntry); + } + } + + /** The name of a connection handler starts with the name of its configuration entry, then its address and port. */ + private static String connectionHandlerName(String configName) + { + for (ConnectionHandler handler : DirectoryServer.getConnectionHandlers()) + { + if (handler.getConnectionHandlerName().startsWith(configName + " ")) + { + return handler.getConnectionHandlerName(); + } + } + return fail("no connection handler named " + configName); + } + + private static void assertMonitorIsSearchable() throws Exception + { + final InternalSearchOperation subtree = getRootConnection().processSearch(newSearchRequest(MONITOR_ROOT, WHOLE_SUBTREE)); + assertThat(subtree.getResultCode()).as(String.valueOf(subtree.getErrorMessage())).isEqualTo(ResultCode.SUCCESS); + assertEntryExists(DN.valueOf("cn=Version,cn=monitor")); + } + + /** Asserts that a monitor provider gives the entry: a branch (glue) entry would not do. */ + private static void assertEntryExists(DN dn) throws Exception + { + final InternalSearchOperation base = getRootConnection().processSearch( + newSearchRequest(dn, BASE_OBJECT, "(!(objectClass=ds-mon-branch))")); + assertThat(base.getResultCode()).as(dn + ": " + base.getErrorMessage()).isEqualTo(ResultCode.SUCCESS); + assertThat(base.getSearchEntries()).hasSize(1); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/common/MultiDomainServerStateTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/common/MultiDomainServerStateTest.java index 531cd95a28..caab710c09 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/replication/common/MultiDomainServerStateTest.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/common/MultiDomainServerStateTest.java @@ -12,6 +12,7 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2013-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.replication.common; @@ -66,6 +67,37 @@ public void decodeAndEncode(String cookie, String expectedCookie) throws Excepti assertEquals(state.toString(), expectedCookie); } + @DataProvider + public Object[][] specialBaseDNs() + { + return new Object[][] { + { "o=urn:x" }, + { "ou=https://idp.example.com/metadata#v1,dc=x" }, + // DN.toString() escapes ';' as "\;" + { "o=a\\;b" }, + // An escaped backslash does not escape the ';' that follows it + { "o=a\\\\\\;b" }, + { "o=a\\\\" }, + { "o=a\\:b" }, + }; + } + + /** The cookie written by toString() must decode to the same state whatever the base DN contains. */ + @Test(dataProvider = "specialBaseDNs") + public void decodeCookieWithSpecialBaseDN(String baseDN) throws Exception + { + final DN dn = DN.valueOf(baseDN); + final MultiDomainServerState state = new MultiDomainServerState(); + state.update(dn, csn1); + state.update(dn2, csn2); + final String cookie = state.toString(); + + final MultiDomainServerState decoded = new MultiDomainServerState(cookie); + assertEquals(decoded.getCSN(dn, csn1.getServerId()), csn1); + assertEquals(decoded.getCSN(dn2, csn2.getServerId()), csn2); + assertEquals(decoded.toString(), cookie); + } + @DataProvider public Object[][] copyCtorData() { diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerDomainMonitorNameTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerDomainMonitorNameTest.java new file mode 100644 index 0000000000..192237e552 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerDomainMonitorNameTest.java @@ -0,0 +1,48 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions Copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.replication.server; + +import static org.assertj.core.api.Assertions.*; + +import org.forgerock.opendj.ldap.DN; +import org.opends.server.DirectoryServerTestCase; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** Tests the monitor entry that the name of a {@link ReplicationServerDomain} gives (issue #1153). */ +@SuppressWarnings("javadoc") +public class ReplicationServerDomainMonitorNameTest extends DirectoryServerTestCase +{ + @DataProvider + public Object[][] baseDNs() + { + return new Object[][] { + { "dc=example,dc=com", "dc_example_dc_com" }, + // An RDN with two AVAs: the '+' must not start a second AVA in the monitor entry RDN + { "cn=a+sn=b,dc=x", "cn_a+sn_b_dc_x" }, + { "o=a\\,b", "o_a\\_b" }, + }; + } + + @Test(dataProvider = "baseDNs") + public void monitorNameIsTheRelativeDNOfTheDomainMonitorEntry(String baseDN, String domainName) + { + final String name = ReplicationServerDomain.getMonitorInstanceName(2, "host:8989", DN.valueOf(baseDN)); + + assertThat((Object) DN.valueOf("cn=" + name + ",cn=monitor")).isEqualTo(DN.valueOf("cn=monitor") + .child("cn", "Replication").child("cn", domainName).child("cn", "Replication server RS(2) host:8989")); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/service/ReplicationMonitorNameTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/service/ReplicationMonitorNameTest.java new file mode 100644 index 0000000000..3354dc2d99 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/service/ReplicationMonitorNameTest.java @@ -0,0 +1,49 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions Copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.replication.service; + +import static org.assertj.core.api.Assertions.*; + +import org.forgerock.opendj.ldap.DN; +import org.opends.server.DirectoryServerTestCase; +import org.opends.server.types.HostPort; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** Tests the monitor entry that the name of a {@link ReplicationMonitor} gives (issue #1153). */ +@SuppressWarnings("javadoc") +public class ReplicationMonitorNameTest extends DirectoryServerTestCase +{ + @DataProvider + public Object[][] baseDNs() + { + return new Object[][] { + { "dc=example,dc=com", "dc_example_dc_com" }, + // An RDN with two AVAs: the '+' must not start a second AVA in the monitor entry RDN + { "cn=a+sn=b,dc=x", "cn_a+sn_b_dc_x" }, + { "o=a\\,b", "o_a\\_b" }, + }; + } + + @Test(dataProvider = "baseDNs") + public void monitorNameIsTheRelativeDNOfTheDomainMonitorEntry(String baseDN, String domainName) + { + final String name = ReplicationMonitor.getMonitorInstanceName(1, new HostPort("host", 1389), DN.valueOf(baseDN)); + + assertThat((Object) DN.valueOf("cn=" + name + ",cn=monitor")).isEqualTo(DN.valueOf("cn=monitor") + .child("cn", "Replication").child("cn", domainName).child("cn", "Directory server DS(1) host:1389")); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/tools/tasks/TaskClientTaskDNTest.java b/opendj-server-legacy/src/test/java/org/opends/server/tools/tasks/TaskClientTaskDNTest.java new file mode 100644 index 0000000000..0437586892 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/tools/tasks/TaskClientTaskDNTest.java @@ -0,0 +1,72 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions Copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.tools.tasks; + +import static org.assertj.core.api.Assertions.*; +import static org.opends.server.config.ConfigConstants.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.forgerock.opendj.ldap.ByteString; +import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.RDN; +import org.opends.server.DirectoryServerTestCase; +import org.opends.server.types.RawAttribute; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** Tests the task entry DN that {@link TaskClient#getTaskDN(List)} builds from the task ID (issue #1153). */ +@SuppressWarnings("javadoc") +public class TaskClientTaskDNTest extends DirectoryServerTestCase +{ + @DataProvider + public Object[][] taskIDs() + { + return new Object[][] { { "daily" }, { "daily,full" }, { "a+b" }, { "#1" }, { "a\\b" }, { " lead" } }; + } + + @Test(dataProvider = "taskIDs") + public void recurringTaskDNKeepsTheWholeID(String taskID) + { + final List attributes = Arrays.asList( + RawAttribute.create(ATTR_TASK_ID, taskID), RawAttribute.create(ATTR_RECURRING_TASK_ID, taskID)); + + final DN taskDN = DN.valueOf(TaskClient.getTaskDN(attributes)); + + assertThat((Object) taskDN.parent()).isEqualTo(DN.valueOf(RECURRING_TASK_BASE_RDN + "," + DN_TASK_ROOT)); + assertSingleValuedRDN(taskDN.rdn(), ATTR_RECURRING_TASK_ID, taskID); + } + + @Test(dataProvider = "taskIDs") + public void scheduledTaskDNKeepsTheWholeID(String taskID) + { + final List attributes = Collections.singletonList(RawAttribute.create(ATTR_TASK_ID, taskID)); + + final DN taskDN = DN.valueOf(TaskClient.getTaskDN(attributes)); + + assertThat((Object) taskDN.parent()).isEqualTo(DN.valueOf(SCHEDULED_TASK_BASE_RDN + "," + DN_TASK_ROOT)); + assertSingleValuedRDN(taskDN.rdn(), ATTR_TASK_ID, taskID); + } + + private static void assertSingleValuedRDN(RDN rdn, String attributeName, String value) + { + assertThat(rdn.size()).isEqualTo(1); + assertThat(rdn.getFirstAVA().getAttributeType().hasName(attributeName)).isTrue(); + assertThat((Object) rdn.getFirstAVA().getAttributeValue()).isEqualTo(ByteString.valueOfUtf8(value)); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/types/LDAPURLTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/types/LDAPURLTestCase.java index 223ca31723..9742cc8c73 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/types/LDAPURLTestCase.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/types/LDAPURLTestCase.java @@ -17,7 +17,9 @@ package org.opends.server.types; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import org.forgerock.opendj.ldap.SearchScope; import org.opends.server.TestCaseUtils; import org.testng.annotations.BeforeClass; import org.testng.annotations.DataProvider; @@ -133,4 +135,44 @@ public void testTruncatedPercentEncoding(String urlString) throws Exception LDAPURL.decode(urlString, true); } + + + /** + * Test data for testNonAsciiBaseDNIsPercentEncodedAsUTF8. + * + * @return DNs with non-ASCII characters and their percent-encoded UTF-8 form. + */ + @DataProvider + public Object[][] nonAsciiBaseDNData() + { + return new Object[][] { + { "cn=J\u00f6rg \u0416,ou=Remote,dc=example,dc=com", + "cn=J%C3%B6rg%20%D0%96,ou=Remote,dc=example,dc=com" }, + // A character outside the BMP is one code point made of two Java chars + { "cn=\uD83D\uDE00,dc=x", "cn=%F0%9F%98%80,dc=x" }, + }; + } + + + + /** + * A referral URL percent-encodes the UTF-8 octets of the DN (RFC 4516 section 2.1), so that + * {@link LDAPURL#decode(String, boolean)} gives back the same DN - see issue #1153. + * + * @param dn + * The base DN. + * @param encodedDN + * The expected percent-encoded base DN. + * @throws Exception + * If an unexpected exception occurred. + */ + @Test(dataProvider = "nonAsciiBaseDNData") + public void testNonAsciiBaseDNIsPercentEncodedAsUTF8(String dn, String encodedDN) throws Exception + { + LDAPURL url = new LDAPURL("ldap", "other.example.com", 389, dn, null, SearchScope.BASE_OBJECT, null, null); + String urlString = url.toString(); + assertTrue(urlString.startsWith("ldap://other.example.com:389/" + encodedDN + "?"), urlString); + assertEquals(LDAPURL.decode(urlString, true).getRawBaseDN(), dn); + } + } From 7faaaf8fd22556c3482e6756301e4caf08861c6b Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sun, 4 Oct 2026 13:20:53 +0300 Subject: [PATCH 2/5] [#1153] Keep '_' for a space in JMX names, reject a trailing '\' in PatternDN, build backend config DNs through DN, verify DN indexes on upgrade Review round 1 of #1171: - JMXMBean: an encoded value still writes a space as '_' (a literal '_' is %5F), so the SNMP extension finds the connection handlers by "Connection_Handler" and their statistics by "_Statistics" again. - PatternDN: a lone trailing '\' is rejected with the message DN.valueOf() uses. - Control panel and installer: backend and index configuration DNs are built with DN.child() (Utilities.getBackendConfigDN(), getIndexConfigDN()), so a backend ID such as "monitor,ou=a b" names its own entry. - Upgrade: a 5.2.0 task verifies the equality indexes of the attributes compared as DNs (distinguishedNameMatch, uniqueMemberMatch) with verify-index, and rebuilds them only under the base DNs where a key is missing. The Docker image runs upgrade with --force, as the native packages do. - Upgrade chapter: the DN index verification, ACIs that no longer decode, and empty RDN values during a rolling upgrade. - Tests: SNMPMonitorConnectionHandlerNameTest, ConfigDNTestCase, DNEqualityIndexesUpgradeTestCase, new rows in JMXMBeanNameTest and PatternDNTest. --- .../asciidoc/install-guide/chap-upgrade.adoc | 20 ++ opendj-packages/opendj-docker/README.md | 6 + opendj-packages/opendj-docker/run.sh | 6 +- .../task/DeleteBaseDNAndBackendTask.java | 6 +- .../controlpanel/task/DeleteIndexTask.java | 13 +- .../guitools/controlpanel/util/Utilities.java | 32 +++ .../quicksetup/installer/InstallerHelper.java | 2 +- .../authorization/dseecompat/PatternDN.java | 7 + .../org/opends/server/config/JMXMBean.java | 13 +- .../opends/server/tools/upgrade/Upgrade.java | 7 + .../server/tools/upgrade/UpgradeTasks.java | 150 +++++++++++ .../server/tools/upgrade/UpgradeUtils.java | 127 +++++++++ .../org/opends/messages/tool.properties | 13 + .../controlpanel/util/ConfigDNTestCase.java | 70 +++++ .../dseecompat/PatternDNTest.java | 21 ++ .../backends/pluggable/IndexKeyRemover.java | 80 ++++++ .../server/config/JMXMBeanNameTest.java | 24 ++ .../SNMPMonitorConnectionHandlerNameTest.java | 91 +++++++ .../DNEqualityIndexesUpgradeTestCase.java | 240 ++++++++++++++++++ 19 files changed, 908 insertions(+), 20 deletions(-) create mode 100644 opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/util/ConfigDNTestCase.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/IndexKeyRemover.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/snmp/SNMPMonitorConnectionHandlerNameTest.java create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java diff --git a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc index 180e9b993d..04983971ef 100644 --- a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc +++ b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc @@ -298,6 +298,21 @@ C:\> net start "OpenDJ Server" ==== +[#upgrade-dn-parsing] +.To Check Stored DNs When Upgrading From a Release Before 5.2.0 +==== +Before 5.2.0, the server read a DN only up to the first `;`, up to the end of a quoted value, or up to a space after a hex string value, and silently dropped the rest. Since 5.2.0, it reads the whole string: `;` separates RDNs as `,` does (RFC 2253), any other content after an RDN is rejected, and an attribute value may be empty, as in `dc=,dc=example,dc=com`. Data written by clients before the upgrade can be affected as follows: + +. A value with DN syntax that a client wrote with `;`, such as `member: cn=a;dc=example,dc=com`, was indexed under the key of `cn=a`. It now reads as `cn=a,dc=example,dc=com`, so an indexed equality search for that DN misses the entry until the index is rebuilt. The same holds for a value that was stored while its syntax was not enforced, and that only now parses. ++ +The `upgrade` command therefore offers to verify the equality indexes of the attributes that hold DNs, such as `member`, `uniqueMember`, `owner` or `seeAlso`, in every enabled backend at the end of the upgrade, and rebuilds them under each base DN where they do not match the entries. The verification reads every entry of these backends, so on a large backend it takes a while; the indexes are only rebuilt where it finds a missing key. The default answer is yes, so `upgrade --no-prompt` performs the verification too, as do the Docker image and the native packages, which run `upgrade --no-prompt --force`. If you declined it, run `verify-index` on these indexes with the server stopped, and rebuild them where it reports errors, as described in xref:../admin-guide/chap-indexing.adoc#rebuild-index["Rebuilding Indexes"] in the __Administration Guide__. ++ +A stored DN value with other content after an RDN, such as `cn="a"x,dc=example,dc=com`, no longer parses: replace it with the DN that was meant. + +. An ACI whose target, `userdn` or `groupdn` DN uses `;` between RDNs now names the whole DN, not the part before the first `;`. An ACI whose DN has other content after an RDN no longer decodes: the server logs a warning naming the ACI when it loads it, and enters lockdown mode. Correct the ACI, then leave lockdown mode as described in xref:../admin-guide/chap-troubleshooting.adoc#troubleshoot-use-lockdown-mode["Preventing Access While You Fix Issues"] in the __Administration Guide__. + +==== + [#upgrade-repl] .To Upgrade Replicated Servers ==== @@ -308,6 +323,11 @@ The OpenDJ directory server upgrade process is designed to support a rolling (se Do not upgrade all replicated servers at once in parallel, as this removes all replication changelog data simultaneously, breaking replication. ====== + +[IMPORTANT] +====== +When the topology still has servers before 5.2.0, do not add or rename entries whose DN holds an empty attribute value, such as `dc=,dc=example,dc=com` or `cn=x+sn=,dc=example,dc=com`, until every server runs 5.2.0 or later. A server before 5.2.0 cannot read the DN of such a change: it closes its replication connection when it receives the change, receives the same change again when it reconnects, and so stops replicating altogether. Such an entry cannot be imported into a server before 5.2.0 either. +====== For each server in the replication topology, follow these steps: . Direct client application traffic away from the server to upgrade. diff --git a/opendj-packages/opendj-docker/README.md b/opendj-packages/opendj-docker/README.md index e4ceac5830..40415c940c 100644 --- a/opendj-packages/opendj-docker/README.md +++ b/opendj-packages/opendj-docker/README.md @@ -61,6 +61,12 @@ over an instance that is already there - never reports healthy: what failed is i logs`, and where the server is up at all the container is left running to be looked at, turning `unhealthy` once the start period is over. +Started over an instance of an older version, the container runs `upgrade --no-prompt --force` +before the server: the upgrade tasks that take long, such as verifying or rebuilding indexes, +are performed then, since nobody is there to run them afterwards. On a large instance they can +take longer than the start period, so the container may report `unhealthy` until the upgrade +is over; `docker logs` shows which task is running. + The server runs as PID 1 of the container, and a JVM does not reap the processes left behind to it - those of a health check that ran past its timeout, say. Run the container with `docker run --init` (`init: true` in Compose) to put a PID 1 in front of the server diff --git a/opendj-packages/opendj-docker/run.sh b/opendj-packages/opendj-docker/run.sh index 0ba6d021e0..168ddd1d4d 100755 --- a/opendj-packages/opendj-docker/run.sh +++ b/opendj-packages/opendj-docker/run.sh @@ -136,8 +136,10 @@ fi # Instance dir does exist? We start opendj without detach if [ -d ./data/config ]; then # nothing is bootstrapped here, the instance is already there - but a half-migrated one - # is not ready to serve either, so the marker follows the upgrade - if sh ./upgrade -n; then + # is not ready to serve either, so the marker follows the upgrade. + # --force performs the tasks that -n alone answers with their default, such as rebuilding or verifying + # indexes: nobody is there to run them by hand afterwards, as the native packages do too + if sh ./upgrade -n --force; then # A server whose volume holds the data of the topology is ready as soon as it serves: # gating it on its peers would deadlock a whole-cluster restart under OrderedReady, # where -0 would wait for peers the StatefulSet starts only once -0 is ready - and so diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/task/DeleteBaseDNAndBackendTask.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/task/DeleteBaseDNAndBackendTask.java index 428213769e..ead136015d 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/task/DeleteBaseDNAndBackendTask.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/task/DeleteBaseDNAndBackendTask.java @@ -13,6 +13,7 @@ * * Copyright 2008-2009 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.guitools.controlpanel.task; @@ -418,8 +419,7 @@ private void deleteBaseDNs(Set baseDNs) } newBaseDNs.removeAll(dnsToRemove); - String backendName = backend.getBackendID(); - DN dn = DN.valueOf("ds-cfg-backend-id=" + backendName + ",cn=Backends,cn=config"); + DN dn = Utilities.getBackendConfigDN(backend.getBackendID()); updateConfigEntryWithAttribute(dn, ATTR_BACKEND_BASE_DN, newBaseDNs); } @@ -470,7 +470,7 @@ private void deleteBaseDNs(ConnectionWrapper connWrapper, */ private void deleteBackend(BackendDescriptor backend) throws OpenDsException, ConfigException { - DN dn = DN.valueOf("ds-cfg-backend-id" + "=" + backend.getBackendID() + ",cn=Backends,cn=config"); + DN dn = Utilities.getBackendConfigDN(backend.getBackendID()); Utilities.deleteConfigSubtree(getServerContext().getConfigurationHandler(), dn); } diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/task/DeleteIndexTask.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/task/DeleteIndexTask.java index c051ff1aab..9e78897e4e 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/task/DeleteIndexTask.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/task/DeleteIndexTask.java @@ -240,17 +240,8 @@ private boolean isVLVIndex(AbstractIndexDescriptor index) */ private void deleteIndex(AbstractIndexDescriptor index) throws OpenDsException { - final String backendId = "ds-cfg-backend-id" + "=" + index.getBackend().getBackendID(); - String dn; - if (isVLVIndex(index)) - { - dn = "ds-cfg-name" + "=" + index.getName() + ",cn=VLV Index," + backendId + ",cn=Backends,cn=config"; - } - else - { - dn = "ds-cfg-attribute" + "=" + index.getName() + ",cn=Index," + backendId + ",cn=Backends,cn=config"; - } - DirectoryServer.getInstance().getServerContext().getConfigurationHandler().deleteEntry(DN.valueOf(dn)); + DN dn = getIndexConfigDN(index.getBackend().getBackendID(), index.getName(), isVLVIndex(index)); + DirectoryServer.getInstance().getServerContext().getConfigurationHandler().deleteEntry(dn); } /** diff --git a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/util/Utilities.java b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/util/Utilities.java index b1817395e8..a2edca8c92 100644 --- a/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/util/Utilities.java +++ b/opendj-server-legacy/src/main/java/org/opends/guitools/controlpanel/util/Utilities.java @@ -1279,6 +1279,38 @@ public static Window getParentDialog(Component comp) return null; } + /** + * Returns the DN of the configuration entry of a backend. The backend ID becomes the value of the RDN, so it may + * hold any character, a ',' for instance. + * + * @param backendID + * the ID of the backend + * @return the DN of the configuration entry of the backend + */ + public static DN getBackendConfigDN(String backendID) + { + return DN.valueOf(ConfigConstants.DN_BACKEND_BASE).child(ConfigConstants.ATTR_BACKEND_ID, backendID); + } + + /** + * Returns the DN of the configuration entry of an index. The backend ID and the index name become the values of + * RDNs, so they may hold any character. + * + * @param backendID + * the ID of the backend of the index + * @param indexName + * the name of the index: the attribute of an attribute index, the name of a VLV index + * @param isVLVIndex + * whether the index is a VLV index + * @return the DN of the configuration entry of the index + */ + public static DN getIndexConfigDN(String backendID, String indexName, boolean isVLVIndex) + { + final DN backendDN = getBackendConfigDN(backendID); + return isVLVIndex ? backendDN.child("cn", "VLV Index").child("ds-cfg-name", indexName) + : backendDN.child("cn", "Index").child("ds-cfg-attribute", indexName); + } + /** * Unescapes UTF-8 text and generates a String from it. * @param v the string in UTF-8 format. diff --git a/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/InstallerHelper.java b/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/InstallerHelper.java index 3c5e499dc5..48cdf03da7 100644 --- a/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/InstallerHelper.java +++ b/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/InstallerHelper.java @@ -333,7 +333,7 @@ public void deleteBackend(String backendName) try { // Read the configuration file. - DN dn = DN.valueOf("ds-cfg-backend-id" + "=" + backendName + ",cn=Backends,cn=config"); + DN dn = Utilities.getBackendConfigDN(backendName); Utilities.deleteConfigSubtree(DirectoryServer.getInstance().getServerContext().getConfigurationHandler(), dn); } catch (OpenDsException | ConfigException ode) diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternDN.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternDN.java index 4943addfe5..5275c411ce 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternDN.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/PatternDN.java @@ -17,6 +17,7 @@ */ package org.opends.server.authorization.dseecompat; +import static com.forgerock.opendj.ldap.CoreMessages.ERR_ATTR_SYNTAX_DN_TRAILING_ESCAPE; import static org.opends.messages.AccessControlMessages.*; import static org.opends.messages.SchemaMessages.*; import static org.opends.server.util.CollectionUtils.*; @@ -1150,6 +1151,12 @@ else if (c == '*') { if (pos >= length) { + if (escaped) + { + // A lone backslash at the end, which DN.valueOf() rejects with the same message. + throw new DirectoryException(ResultCode.INVALID_DN_SYNTAX, + ERR_ATTR_SYNTAX_DN_TRAILING_ESCAPE.get(dnString)); + } // This is the end of the DN and therefore the end of the value. // If there are any hex characters, then we need to deal with them accordingly. appendHexChars(dnString, valueString, hexChars); diff --git a/opendj-server-legacy/src/main/java/org/opends/server/config/JMXMBean.java b/opendj-server-legacy/src/main/java/org/opends/server/config/JMXMBean.java index aa3719214c..318101ca4a 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/config/JMXMBean.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/config/JMXMBean.java @@ -118,8 +118,11 @@ public static String getJmxName (DN configEntryDN) * Appends the JMX form of an RDN to the provided buffer. The attribute name keeps only its letters and * digits, and is followed by '-' and the value. A value made of letters, digits and spaces is written as it * always was, with '_' for a space. Any other value would lose characters that way, and two DNs would share - * a name, so it is percent-encoded instead: its letters and digits are kept and every other character is - * written as the '%' encoded bytes of its UTF-8 form. The AVAs of a multi-valued RDN are joined with '+'. + * a name, so it is percent-encoded instead: its letters and digits are kept, a space is still written as '_', + * and every other character, '_' included, is written as the '%' encoded bytes of its UTF-8 form. The SNMP + * extension relies on the '_': it finds the connection handlers, whose names hold an IP address, by + * "Connection_Handler" and their statistics by "_Statistics". The AVAs of a multi-valued RDN are joined + * with '+'. */ private static void appendJmxRdn(StringBuilder buffer, RDN rdn) { @@ -151,7 +154,11 @@ private static void appendJmxRdn(StringBuilder buffer, RDN rdn) for (byte b : ava.getAttributeValue().toByteArray()) { char c = (char) (b & 0xFF); - if (c < 0x80 && (isAlpha(c) || isDigit(c))) + if (c == ' ') + { + buffer.append('_'); + } + else if (c < 0x80 && (isAlpha(c) || isDigit(c))) { buffer.append(c); } diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/Upgrade.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/Upgrade.java index f20ba21cf9..e279c0a8b1 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/Upgrade.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/Upgrade.java @@ -654,6 +654,13 @@ public String toString() { REFERENTIAL_INTEGRITY_PLUGIN_FILTER, ADD_REFERENTIAL_INTEGRITY_PRE_OPERATION_PLUGIN_TYPES)); + /* See issue #1153: a DN is now read up to its end, ';' separating RDNs as ',' does, so a DN value that a client + * wrote with ';' gets another equality key than the one a previous version indexed. The value is found by + * reading the entries only, so the equality indexes of the attributes holding DNs are verified, and rebuilt + * where they miss a key, rather than rebuilt on every server. */ + register("5.2.0", + verifyAndRebuildDNEqualityIndexes(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES.get())); + /* * See issue #746. Builds before #661 (fixed in 5.1.2) shipped a duplicate * org.openidentityplatform.opendj.opendj-server-legacy.jar alongside opendj.jar in lib/. diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java index 370f28c688..6289491bbd 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java @@ -12,6 +12,7 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Portions Copyright 2013-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.tools.upgrade; @@ -24,6 +25,7 @@ import static javax.security.auth.callback.TextOutputCallback.*; import static org.forgerock.util.Utils.joinAsString; import static org.opends.messages.ToolMessages.*; +import static org.opends.server.schema.SchemaConstants.EMR_DN_NAME; import static org.opends.server.tools.upgrade.FileManager.copyRecursively; import static org.opends.server.tools.upgrade.UpgradeUtils.*; import static org.opends.server.util.StaticUtils.*; @@ -32,9 +34,11 @@ import java.io.File; import java.io.FileReader; import java.io.IOException; +import java.io.PrintStream; import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; @@ -67,6 +71,7 @@ import org.forgerock.opendj.ldif.LDIFEntryReader; import org.opends.server.backends.pluggable.spi.TreeName; import org.opends.server.tools.RebuildIndex; +import org.opends.server.tools.VerifyIndex; import org.opends.server.util.BuildVersion; import org.opends.server.util.ChangeOperationType; import org.opends.server.util.StaticUtils; @@ -661,6 +666,151 @@ public String toString() }; } + /** + * Creates a task that verifies, at the end of the upgrade, the equality indexes of the attributes whose values + * are compared as DNs, and rebuilds them under each base DN where they do not match the entries. Only an index + * that is missing a key needs the rebuild: the verification computes the keys of every entry with the matching + * rules of the upgraded server, and finds those that were computed differently, or not at all, by the previous + * version. Unlike {@link #rebuildIndexesNamed}, a backend whose indexes match its entries is only read. + * + * @param summary + * A message describing why the indexes are verified and asking whether to do it at the end of the + * upgrade. + * @return The verify and rebuild task. + */ + static UpgradeTask verifyAndRebuildDNEqualityIndexes(final LocalizableMessage summary) + { + return new AbstractUpgradeTask() + { + private boolean isATaskToPerform; + + @Override + public void prepare(UpgradeContext context) throws ClientException + { + Upgrade.needToRunPostUpgradePhase(); + // Requires answer from the user. Verifying only reads, so it is done unless the user declines. + isATaskToPerform = context.confirmYN(summary, YES) == YES; + } + + @Override + public void postUpgrade(final UpgradeContext context) throws ClientException + { + if (!isATaskToPerform) + { + postponePostUpgrade(context); + return; + } + if (isRebuildAllIndexesTaskAccepted || indexesToRebuild.contains("." + EMR_DN_NAME)) + { + // These indexes are rebuilt at the end of the upgrade anyway. + return; + } + + final Map> attributesPerBackend; + try + { + attributesPerBackend = getDNEqualityIndexedAttributesPerBackend(); + } + catch (IOException e) + { + throw new ClientException(ReturnCode.ERROR_UNEXPECTED, ERR_UPGRADE_READING_CONF_FILE.get(e.getMessage()), e); + } + final Map> baseDNsPerBackend = getBaseDNsPerBackendsFromConfig(); + for (final Map.Entry> backend : attributesPerBackend.entrySet()) + { + final Set baseDNs = baseDNsPerBackend.get(backend.getKey()); + if (baseDNs == null) + { + continue; + } + for (final String baseDN : baseDNs) + { + verifyAndRebuild(context, baseDN, backend.getValue()); + } + } + } + + private void verifyAndRebuild(final UpgradeContext context, final String baseDN, final Set attributes) + throws ClientException + { + final String indexes = joinAsString(", ", attributes); + final ProgressNotificationCallback pnc = new ProgressNotificationCallback( + INFORMATION, INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_STARTS.get(indexes, baseDN), 25); + context.notifyProgress(pnc); + final boolean rebuilt = verifyAndRebuildIndexes(configFile.getAbsolutePath(), baseDN, attributes, true, + UpgradeLog.getPrintStream()); + context.notifyProgress(pnc.setProgress(100)); + context.notify(rebuilt ? INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT.get(indexes, baseDN) + : INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT.get(indexes, baseDN)); + } + + @Override + public void postponePostUpgrade(UpgradeContext context) throws ClientException + { + if (!isRebuildAllIndexesIsPresent) + { + context.notify(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_DECLINED.get(), TextOutputCallback.WARNING); + } + } + + @Override + public String toString() + { + return String.valueOf(summary); + } + }; + } + + /** + * Verifies the provided indexes under a base DN with the verify-index tool, and rebuilds them with the + * rebuild-index tool when it reports any error. A verification that fails is taken as an error, so the indexes + * are rebuilt then too. The backend must not be in use by a running server. + * + * @param configFilePath + * The path of the configuration file. + * @param baseDN + * The base DN to verify. + * @param attributes + * The attributes whose indexes are verified, and rebuilt if needed. + * @param initializeServer + * Whether the tools have to initialize the server components. + * @param out + * The stream the tools write to. + * @return {@code true} if the indexes were rebuilt, {@code false} if they match the entries. + * @throws ClientException + * If the rebuild fails. + */ + static boolean verifyAndRebuildIndexes(final String configFilePath, final String baseDN, + final Collection attributes, final boolean initializeServer, final PrintStream out) + throws ClientException + { + final List args = new ArrayList<>(); + args.add("--configFile"); + args.add(configFilePath); + args.add("--baseDN"); + args.add(baseDN); + for (final String attribute : attributes) + { + args.add("--index"); + args.add(attribute); + } + + final List verifyArgs = new ArrayList<>(args); + verifyArgs.add("--countErrors"); + logger.debug(INFO_UPGRADE_REBUILD_INDEX_ARGUMENTS, verifyArgs); + if (VerifyIndex.mainVerifyIndex(verifyArgs.toArray(new String[0]), initializeServer, out) == 0) + { + return false; + } + + logger.debug(INFO_UPGRADE_REBUILD_INDEX_ARGUMENTS, args); + if (new RebuildIndex().rebuildIndexesWithinMultipleBackends(initializeServer, out, args) != 0) + { + throw new ClientException(ReturnCode.ERROR_UNEXPECTED, ERR_UPGRADE_PERFORMING_POST_TASKS_FAIL.get()); + } + return true; + } + /** * This task is processed at the end of the upgrade, rebuilding indexes. If a * rebuild all indexes has been registered before, it takes the flag diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeUtils.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeUtils.java index e9be4cb753..f698b37be0 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeUtils.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeUtils.java @@ -25,14 +25,18 @@ import java.io.FilenameFilter; import java.io.IOException; import java.nio.file.Files; +import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.opendj.ldap.Assertion; +import org.forgerock.opendj.ldap.AVA; import org.forgerock.opendj.ldap.Attribute; import org.forgerock.opendj.ldap.ByteString; import org.forgerock.opendj.ldap.ConditionResult; @@ -47,6 +51,7 @@ import org.forgerock.opendj.ldap.requests.ModifyRequest; import org.forgerock.opendj.ldap.requests.Requests; import org.forgerock.opendj.ldap.requests.SearchRequest; +import org.forgerock.opendj.ldap.schema.AttributeType; import org.forgerock.opendj.ldap.schema.CoreSchema; import org.forgerock.opendj.ldap.schema.MatchingRule; import org.forgerock.opendj.ldap.schema.Schema; @@ -65,6 +70,8 @@ import static org.forgerock.opendj.ldap.schema.SchemaOptions.*; import static org.opends.messages.ConfigMessages.*; import static org.opends.messages.ToolMessages.*; +import static org.opends.server.schema.SchemaConstants.EMR_DN_OID; +import static org.opends.server.schema.SchemaConstants.EMR_UNIQUE_MEMBER_OID; import static org.opends.server.tools.upgrade.FileManager.*; import static org.opends.server.tools.upgrade.Installation.*; import static org.opends.server.util.ChangeOperationType.*; @@ -303,6 +310,126 @@ static Map> getBaseDNsPerBackendsFromConfig() return baseDNs; } + /** + * Returns, for each enabled pluggable backend of the server, the attributes with an equality index whose matching + * rule compares DNs: "distinguishedNameMatch" (member, owner, seeAlso...) or "uniqueMemberMatch" (uniqueMember). + * Backends without such an index are left out. + * + * @return The names of these attributes, per backend ID. + * @throws IOException + * If the configuration or a schema file cannot be read. + */ + static Map> getDNEqualityIndexedAttributesPerBackend() throws IOException + { + return getDNEqualityIndexedAttributesPerBackend(configFile, configSchemaDirectory); + } + + /** + * Returns, for each enabled pluggable backend in the provided configuration, the attributes with an equality index + * whose matching rule compares DNs, as {@link #getDNEqualityIndexedAttributesPerBackend()} does. The matching rule + * of an attribute is read from the schema files of the instance, so that an attribute of a custom schema is found + * too. + * + * @param config + * The configuration file. + * @param schemaDirectory + * The directory of the schema files. + * @return The names of these attributes, per backend ID. + * @throws IOException + * If the configuration or a schema file cannot be read. + */ + static Map> getDNEqualityIndexedAttributesPerBackend(final File config, + final File schemaDirectory) throws IOException + { + final Schema schema = readSchemaFiles(schemaDirectory); + final Set enabledBackends = new HashSet<>(); + final Map> attributes = new TreeMap<>(); + try (LDIFEntryReader reader = new LDIFEntryReader(new FileInputStream(config))) + { + while (reader.hasNext()) + { + final Entry entry = reader.readEntry(); + if (hasObjectClass(entry, "ds-cfg-pluggable-backend") + && "true".equalsIgnoreCase(entry.parseAttribute("ds-cfg-enabled").asString())) + { + enabledBackends.add(entry.parseAttribute("ds-cfg-backend-id").asString()); + } + else if (hasObjectClass(entry, "ds-cfg-backend-index") + && hasValueIgnoringCase(entry, "ds-cfg-index-type", "equality")) + { + final String attribute = entry.parseAttribute("ds-cfg-attribute").asString(); + final String backendID = getBackendID(entry.getName()); + if (attribute != null && backendID != null && comparesDNs(schema.getAttributeType(attribute))) + { + attributes.computeIfAbsent(backendID, id -> new TreeSet<>(String.CASE_INSENSITIVE_ORDER)).add(attribute); + } + } + } + } + attributes.keySet().retainAll(enabledBackends); + return attributes; + } + + private static Schema readSchemaFiles(final File schemaDirectory) throws IOException + { + final SchemaBuilder builder = new SchemaBuilder(Schema.getCoreSchema()); + final File[] files = schemaDirectory.listFiles((dir, name) -> name.toLowerCase().endsWith(".ldif")); + if (files != null) + { + // The server reads them in this order, so a later file may redefine an attribute of an earlier one + Arrays.sort(files); + for (final File file : files) + { + try (LDIFEntryReader reader = new LDIFEntryReader(new FileInputStream(file))) + { + while (reader.hasNext()) + { + builder.addSchema(reader.readEntry(), true); + } + } + } + } + return builder.toSchema().asNonStrictSchema(); + } + + private static boolean comparesDNs(final AttributeType attributeType) + { + final MatchingRule equality = attributeType.getEqualityMatchingRule(); + return equality != null + && (EMR_DN_OID.equals(equality.getOID()) || EMR_UNIQUE_MEMBER_OID.equals(equality.getOID())); + } + + private static boolean hasObjectClass(final Entry entry, final String objectClass) + { + return hasValueIgnoringCase(entry, "objectClass", objectClass); + } + + private static boolean hasValueIgnoringCase(final Entry entry, final String attribute, final String value) + { + for (final String v : entry.parseAttribute(attribute).asSetOfString()) + { + if (value.equalsIgnoreCase(v)) + { + return true; + } + } + return false; + } + + /** Returns the ID of the backend whose configuration entry is, or is above, the provided DN. */ + private static String getBackendID(final DN dn) + { + for (DN d = dn; d != null && !d.isRootDN(); d = d.parent()) + { + final AVA ava = d.rdn().getFirstAVA(); + if ("ds-cfg-backend-id".equalsIgnoreCase(ava.getAttributeName())) + { + return ava.getAttributeValue().toString(); + } + } + return null; + } + static EntryReader searchConfigFile(final SearchRequest searchRequest) throws FileNotFoundException { final Schema schema = getUpgradeSchema(); diff --git a/opendj-server-legacy/src/messages/org/opends/messages/tool.properties b/opendj-server-legacy/src/messages/org/opends/messages/tool.properties index a45eee7d8a..8fc5c74793 100644 --- a/opendj-server-legacy/src/messages/org/opends/messages/tool.properties +++ b/opendj-server-legacy/src/messages/org/opends/messages/tool.properties @@ -2740,3 +2740,16 @@ INFO_UPGRADE_TASK_ADD_TRANSACTION_EXTENDED_OPERATIONS=Adding configuration for L extended operation handlers (RFC 5805) INFO_UPGRADE_TASK_ADD_REFERENTIAL_INTEGRITY_PRE_OPERATION_PLUGIN_TYPES=Adding the pre-operation add \ and modify plugin types to the referential integrity plugins +INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES=OpenDJ 5.2.0 reads a DN up to its end. A DN value that a \ + client wrote with ';' between RDNs, or that was stored while its syntax was not enforced, may lack \ + its key in the equality indexes of the attributes holding DNs, like "member", "owner", "seeAlso" and \ + "uniqueMember", so that an indexed search misses its entry. Do you want to verify these indexes at \ + the end of the upgrade, and rebuild them under each base DN where they do not match the entries? \ + The verification reads every entry of the backends that have such indexes +INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_STARTS=Verifying index(es) '%s' for base DN '%s' +INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT=Index(es) '%s' for base DN '%s' match the entries +INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT=Index(es) '%s' for base DN '%s' do not match \ + the entries and are rebuilt +INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_DECLINED=You have to verify the equality indexes of the \ + attributes holding DNs manually with verify-index, and rebuild them with rebuild-index where it reports \ + errors, to get a fully functional server diff --git a/opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/util/ConfigDNTestCase.java b/opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/util/ConfigDNTestCase.java new file mode 100644 index 0000000000..443542db07 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/guitools/controlpanel/util/ConfigDNTestCase.java @@ -0,0 +1,70 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.guitools.controlpanel.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.RDN; +import org.opends.server.DirectoryServerTestCase; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** + * Tests the configuration DNs that the control panel and the installer build from a backend ID and an index name + * (issue #1153). Neither has a pattern, so they may hold any character a DN has to escape. + */ +@SuppressWarnings("javadoc") +public class ConfigDNTestCase extends DirectoryServerTestCase +{ + private static final DN BACKENDS = DN.valueOf("cn=Backends,cn=config"); + + @DataProvider + public Object[][] names() + { + return new Object[][] { + { "userRoot" }, + { "monitor,ou=a b" }, + { "a+sn=b" }, + { "a\\b" }, + { " a " }, + }; + } + + @Test(dataProvider = "names") + public void backendConfigDNNamesTheBackendBelowCnBackends(String backendID) + { + final DN dn = Utilities.getBackendConfigDN(backendID); + assertThat((Object) dn.parent()).isEqualTo(BACKENDS); + assertThat((Object) dn.rdn()).isEqualTo(new RDN("ds-cfg-backend-id", backendID)); + } + + @Test(dataProvider = "names") + public void indexConfigDNNamesTheIndexBelowItsBackend(String name) + { + final DN backendDN = BACKENDS.child("ds-cfg-backend-id", name); + + final DN index = Utilities.getIndexConfigDN(name, name, false); + assertThat((Object) index.parent(2)).isEqualTo(backendDN); + assertThat((Object) index.parent().rdn()).isEqualTo(new RDN("cn", "Index")); + assertThat((Object) index.rdn()).isEqualTo(new RDN("ds-cfg-attribute", name)); + + final DN vlvIndex = Utilities.getIndexConfigDN(name, name, true); + assertThat((Object) vlvIndex.parent(2)).isEqualTo(backendDN); + assertThat((Object) vlvIndex.parent().rdn()).isEqualTo(new RDN("cn", "VLV Index")); + assertThat((Object) vlvIndex.rdn()).isEqualTo(new RDN("ds-cfg-name", name)); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java index 53f56a95d1..1fe41b3c37 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java @@ -55,6 +55,9 @@ public Object[][] patternsWithoutWildcard() // A hex pair inside quotes is decoded as it is outside quotes { "cn=\"a\\2Cb\",dc=x" }, { "cn=\"J\\C3\\B6rg\",dc=x" }, + // Hex pairs that are still pending at the closing quote, or before an escaped character + { "cn=\"ab\\2C\",dc=x" }, + { "cn=\"\\C3\\B6\\,\",dc=x" }, // An empty value is followed by the next AVA or RDN, it does not swallow it { "cn=+sn=x,dc=y" }, { "cn=x+sn=,dc=y" }, @@ -99,6 +102,24 @@ public void patternDoesNotMatchADifferentDN(String pattern, String otherDN) thro assertThat(PatternDN.decode(pattern).matchesDN(DN.valueOf(otherDN))).isFalse(); } + /** Patterns that end in a lone backslash, which DN.valueOf() rejects too. */ + @DataProvider + public Object[][] patternsWithATrailingBackslash() + { + return new Object[][] { + { "cn=a\\" }, + { "cn=\\" }, + { "cn=a,dc=x\\" }, + { "cn=a*\\" }, + }; + } + + @Test(dataProvider = "patternsWithATrailingBackslash", expectedExceptions = DirectoryException.class) + public void patternWithATrailingBackslashIsRejected(String pattern) throws Exception + { + PatternDN.decode(pattern); + } + @DataProvider public Object[][] wildcardPatterns() { diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/IndexKeyRemover.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/IndexKeyRemover.java new file mode 100644 index 0000000000..fb1154cbaf --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/IndexKeyRemover.java @@ -0,0 +1,80 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.backends.pluggable; + +import java.util.ArrayList; +import java.util.List; + +import org.forgerock.opendj.ldap.ByteString; +import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.schema.AttributeType; +import org.opends.server.api.LocalBackend; +import org.opends.server.backends.pluggable.spi.Cursor; +import org.opends.server.core.DirectoryServer; + +/** + * Removes the keys of an attribute's indexes while leaving them trusted, as an index whose keys a previous version + * computed differently looks to the current one. For the tests outside this package, which cannot reach the trees + * of a backend. + */ +public final class IndexKeyRemover +{ + private IndexKeyRemover() + { + // Utility class + } + + /** + * Removes every key of the indexes of an attribute under a base DN of an online pluggable backend, and marks + * these indexes trusted. + * + * @param backend + * The backend, which must be a pluggable one. + * @param baseDN + * The base DN of the entry container. + * @param attributeName + * The name of the indexed attribute. + * @throws Exception + * If the keys cannot be removed. + */ + public static void removeAllKeys(LocalBackend backend, DN baseDN, String attributeName) throws Exception + { + final RootContainer rootContainer = ((BackendImpl) backend).getRootContainer(); + final AttributeType attributeType = + DirectoryServer.getInstance().getServerContext().getSchema().getAttributeType(attributeName); + final AttributeIndex attributeIndex = rootContainer.getEntryContainer(baseDN).getAttributeIndex(attributeType); + rootContainer.getStorage().write(txn -> + { + for (AttributeIndex.MatchingRuleIndex index : attributeIndex.getNameToIndexes().values()) + { + final List keys = new ArrayList<>(); + try (Cursor cursor = txn.openCursor(index.getName())) + { + while (cursor.next()) + { + keys.add(cursor.getKey()); + } + } + for (ByteString key : keys) + { + txn.delete(index.getName(), key); + } + // A missing key of an untrusted index is not an error, as the index is known to be incomplete + index.setTrusted(txn, true); + } + }); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/config/JMXMBeanNameTest.java b/opendj-server-legacy/src/test/java/org/opends/server/config/JMXMBeanNameTest.java index ed53f100a7..5b6e332cc3 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/config/JMXMBeanNameTest.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/config/JMXMBeanNameTest.java @@ -49,6 +49,28 @@ public void plainNamesAreUnchanged(String dn, String expectedName) throws Except assertThat(JMXMBean.getJmxName(DN.valueOf(dn))).isEqualTo(expectedName); } + /** + * The SNMP extension finds the connection handlers and their statistics by "Connection_Handler" and + * "_Statistics" in these names, so a space stays '_' in a percent-encoded value too. + */ + @DataProvider + public Object[][] connectionHandlerNames() + { + return new Object[][] { + { "cn=LDAP Connection Handler 0.0.0.0 port 1389,cn=monitor", + "org.opends.server:Name=rootDSE,Rdn1=cn-monitor,Rdn2=cn-LDAP_Connection_Handler_0%2E0%2E0%2E0_port_1389" }, + { "cn=LDAP Connection Handler 0.0.0.0 port 1389 Statistics,cn=monitor", + "org.opends.server:Name=rootDSE,Rdn1=cn-monitor," + + "Rdn2=cn-LDAP_Connection_Handler_0%2E0%2E0%2E0_port_1389_Statistics" }, + }; + } + + @Test(dataProvider = "connectionHandlerNames") + public void encodedValuesKeepUnderscoreForSpace(String dn, String expectedName) throws Exception + { + assertThat(JMXMBean.getJmxName(DN.valueOf(dn))).isEqualTo(expectedName); + } + /** Each group holds DNs whose values only differ by characters that the old mapping dropped. */ @DataProvider public Object[][] distinctDNs() @@ -62,6 +84,8 @@ public Object[][] distinctDNs() { new String[] { "cn=a\\,b,cn=monitor", "cn=ab,cn=monitor", "cn=a,cn=b,cn=monitor", "cn=a+sn=b,cn=monitor", "cn=a\\+sn\\=b,cn=monitor", "cn=asn\\=b,cn=monitor" } }, { new String[] { "cn=J\\C3\\B6rg,cn=monitor", "cn=Jrg,cn=monitor", "cn=J\\C3\\A4rg,cn=monitor" } }, + { new String[] { "cn=a.b c,cn=monitor", "cn=a.b_c,cn=monitor", "cn=a.b%20c,cn=monitor", + "cn=a.b%5Fc,cn=monitor" } }, }; } diff --git a/opendj-server-legacy/src/test/java/org/opends/server/snmp/SNMPMonitorConnectionHandlerNameTest.java b/opendj-server-legacy/src/test/java/org/opends/server/snmp/SNMPMonitorConnectionHandlerNameTest.java new file mode 100644 index 0000000000..e62de038de --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/snmp/SNMPMonitorConnectionHandlerNameTest.java @@ -0,0 +1,91 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.snmp; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Constructor; + +import javax.management.MBeanServer; +import javax.management.MBeanServerFactory; +import javax.management.ObjectName; +import javax.management.modelmbean.RequiredModelMBean; + +import org.forgerock.opendj.ldap.DN; +import org.opends.server.DirectoryServerTestCase; +import org.opends.server.TestCaseUtils; +import org.opends.server.config.JMXMBean; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** + * Verifies that the SNMP extension still finds a connection handler and its statistics by the JMX names that + * {@link JMXMBean#getJmxName(DN)} gives their monitor entries, including a handler whose name holds an IP address + * and is therefore percent-encoded (issue #1153). + */ +@SuppressWarnings("javadoc") +public class SNMPMonitorConnectionHandlerNameTest extends DirectoryServerTestCase +{ + /** {@link SNMPMonitor} acts through the root internal connection, which needs a started server. */ + @BeforeClass + public void setUp() throws Exception + { + TestCaseUtils.startServer(); + } + + @DataProvider + public Object[][] handlerNames() + { + return new Object[][] { + { "LDAP Connection Handler 0.0.0.0 port 1389" }, + { "LDAPS Connection Handler 192.168.0.1 port 1636" }, + { "LDAP Connection Handler 0:0:0:0:0:0:0:0 port 1389" }, + { "LDAP Connection Handler localhost port 1389" }, + }; + } + + @Test(dataProvider = "handlerNames") + public void connectionHandlerAndItsStatisticsAreFound(String handlerName) throws Exception + { + // A server of its own, which MBeanServerFactory does not keep a reference to. + final MBeanServer server = MBeanServerFactory.newMBeanServer(); + final ObjectName handler = register(server, handlerName); + final ObjectName statistics = register(server, handlerName + " Statistics"); + final SNMPMonitor monitor = newMonitor(server); + + assertThat(monitor.getConnectionHandlers()).containsExactly(handler); + assertThat(monitor.getConnectionHandlersStatistics()).containsExactly(statistics); + assertThat(monitor.getConnectionHandlerStatistics(handler)).isEqualTo(statistics); + assertThat(monitor.getConnectionHandler(statistics)).isEqualTo(handler); + } + + private static ObjectName register(MBeanServer server, String monitorName) throws Exception + { + final ObjectName name = + new ObjectName(JMXMBean.getJmxName(DN.valueOf("cn=monitor").child("cn", monitorName))); + server.registerMBean(new RequiredModelMBean(), name); + return name; + } + + /** {@link SNMPMonitor#getMonitor(MBeanServer)} keeps the first server it is given, so build one per server. */ + private static SNMPMonitor newMonitor(MBeanServer server) throws Exception + { + final Constructor constructor = SNMPMonitor.class.getDeclaredConstructor(MBeanServer.class); + constructor.setAccessible(true); + return constructor.newInstance(server); + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java new file mode 100644 index 0000000000..fd0ea4b0f6 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java @@ -0,0 +1,240 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.server.tools.upgrade; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.file.Files; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.ModificationType; +import org.forgerock.opendj.ldap.ResultCode; +import org.forgerock.opendj.ldap.requests.ModifyRequest; +import org.forgerock.opendj.ldap.requests.Requests; +import org.opends.server.DirectoryServerTestCase; +import org.opends.server.TestCaseUtils; +import org.opends.server.backends.pluggable.IndexKeyRemover; +import org.opends.server.core.DirectoryServer; +import org.opends.server.core.ModifyOperation; +import org.opends.server.types.Entry; +import org.opends.server.util.StaticUtils; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import static org.opends.server.protocols.internal.InternalClientConnection.getRootConnection; + +/** + * Tests the upgrade task that verifies the equality indexes of the attributes holding DNs, and rebuilds them where + * they miss a key (issue #1153). + */ +@SuppressWarnings("javadoc") +public class DNEqualityIndexesUpgradeTestCase extends DirectoryServerTestCase +{ + private static final String BACKEND_ID = "dnEqualityIndexes"; + private static final String BACKEND_DN = "ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config"; + private static final String BASE_DN = "o=dn equality indexes"; + + @BeforeClass + public void setUp() throws Exception + { + TestCaseUtils.startServer(); + } + + /** + * Only the equality indexes whose matching rule compares DNs are found, in enabled pluggable backends; the + * matching rule of an attribute of a custom schema is read from the schema files. + */ + @Test + public void findsTheEqualityIndexesThatCompareDNs() throws Exception + { + final File directory = Files.createTempDirectory("dn-equality-indexes").toFile(); + try + { + final File config = new File(directory, "config.ldif"); + Files.write(config.toPath(), String.join("\n", + "dn: cn=config", + "objectClass: top", + "objectClass: ds-cfg-root-config", + "cn: config", + "", + "dn: cn=Backends,cn=config", + "objectClass: top", + "objectClass: ds-cfg-branch", + "cn: Backends", + "", + backend("userRoot", "userRoot", "true"), + index("userRoot", "member", "equality"), + index("userRoot", "uniqueMember", "equality", "presence"), + index("userRoot", "seeAlso", "Equality"), + index("userRoot", "myManager", "equality"), + index("userRoot", "owner", "presence"), + index("userRoot", "cn", "equality", "substring"), + // A backend ID that a DN has to escape + backend("a\\,b", "a,b", "true"), + index("a\\,b", "member", "equality"), + // A disabled backend, and one without an index that compares DNs + backend("disabledRoot", "disabledRoot", "false"), + index("disabledRoot", "member", "equality"), + backend("otherRoot", "otherRoot", "true"), + index("otherRoot", "cn", "equality")).getBytes(UTF_8)); + + final File schemaDirectory = new File(directory, "schema"); + schemaDirectory.mkdir(); + Files.write(new File(schemaDirectory, "99-user.ldif").toPath(), String.join("\n", + "dn: cn=schema", + "objectClass: top", + "objectClass: ldapSubentry", + "objectClass: subschema", + "attributeTypes: ( 1.3.6.1.4.1.26027.1.999.1153 NAME 'myManager' SUP distinguishedName )", + "").getBytes(UTF_8)); + + final Map> attributes = + UpgradeUtils.getDNEqualityIndexedAttributesPerBackend(config, schemaDirectory); + + assertThat(attributes.keySet()).containsExactly("a,b", "userRoot"); + assertThat(attributes.get("userRoot")).containsExactly("member", "myManager", "seeAlso", "uniqueMember"); + assertThat(attributes.get("a,b")).containsExactly("member"); + } + finally + { + StaticUtils.recursiveDelete(directory); + } + } + + private static String backend(String rdnValue, String backendID, String enabled) + { + return String.join("\n", + "dn: ds-cfg-backend-id=" + rdnValue + ",cn=Backends,cn=config", + "objectClass: top", + "objectClass: ds-cfg-backend", + "objectClass: ds-cfg-pluggable-backend", + "objectClass: ds-cfg-pdb-backend", + "ds-cfg-backend-id: " + backendID, + "ds-cfg-enabled: " + enabled, + "ds-cfg-base-dn: o=" + backendID.replace(",", "\\,"), + "", + "dn: cn=Index,ds-cfg-backend-id=" + rdnValue + ",cn=Backends,cn=config", + "objectClass: top", + "objectClass: ds-cfg-branch", + "cn: Index", + ""); + } + + private static String index(String backendRDNValue, String attribute, String... indexTypes) + { + final StringBuilder entry = new StringBuilder() + .append("dn: ds-cfg-attribute=").append(attribute) + .append(",cn=Index,ds-cfg-backend-id=").append(backendRDNValue).append(",cn=Backends,cn=config\n") + .append("objectClass: top\n") + .append("objectClass: ds-cfg-backend-index\n") + .append("ds-cfg-attribute: ").append(attribute).append('\n'); + for (String indexType : indexTypes) + { + entry.append("ds-cfg-index-type: ").append(indexType).append('\n'); + } + return entry.append('\n').toString(); + } + + /** + * A trusted equality index that misses the keys of its entries is rebuilt, and an index that matches its + * entries is only verified. + */ + @Test + public void rebuildsAnIndexOnlyWhenItMissesKeys() throws Exception + { + final Entry backend = TestCaseUtils.makeEntry( + "dn: " + BACKEND_DN, + "objectClass: top", + "objectClass: ds-cfg-backend", + "objectClass: ds-cfg-local-backend", + "objectClass: ds-cfg-pluggable-backend", + "objectClass: ds-cfg-pdb-backend", + "ds-cfg-enabled: true", + "ds-cfg-java-class: org.opends.server.backends.pdb.PDBBackend", + "ds-cfg-backend-id: " + BACKEND_ID, + "ds-cfg-writability-mode: enabled", + "ds-cfg-base-dn: " + BASE_DN, + "ds-cfg-db-directory: db_dn_equality_indexes", + "ds-cfg-db-cache-percent: 2"); + final Entry indexBranch = TestCaseUtils.makeEntry( + "dn: cn=Index," + BACKEND_DN, + "objectClass: top", + "objectClass: ds-cfg-branch", + "cn: Index"); + final Entry memberIndex = TestCaseUtils.makeEntry( + "dn: ds-cfg-attribute=member,cn=Index," + BACKEND_DN, + "objectClass: top", + "objectClass: ds-cfg-backend-index", + "ds-cfg-attribute: member", + "ds-cfg-index-type: equality"); + TestCaseUtils.addEntry(backend); + try + { + TestCaseUtils.addEntry(indexBranch); + TestCaseUtils.addEntry(memberIndex); + TestCaseUtils.addEntries( + "dn: " + BASE_DN, + "objectClass: top", + "objectClass: organization", + "o: dn equality indexes", + "", + "dn: cn=group," + BASE_DN, + "objectClass: top", + "objectClass: groupOfNames", + "cn: group", + "member: cn=a," + BASE_DN, + "member: cn=b;" + BASE_DN); + // A trusted index without the keys of these entries, as one whose keys a previous version computed + // differently + IndexKeyRemover.removeAllKeys( + DirectoryServer.getInstance().getServerContext().getBackendConfigManager().getLocalBackendById(BACKEND_ID), + DN.valueOf(BASE_DN), "member"); + setBackendEnabled(false); + + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final PrintStream out = new PrintStream(output, true, "UTF-8"); + final Set attributes = Collections.singleton("member"); + final String configFile = DirectoryServer.getConfigFile(); + + assertThat(UpgradeTasks.verifyAndRebuildIndexes(configFile, BASE_DN, attributes, false, out)) + .as("rebuilt an index missing keys; output: %s", output).isTrue(); + assertThat(UpgradeTasks.verifyAndRebuildIndexes(configFile, BASE_DN, attributes, false, out)) + .as("rebuilt an index matching its entries; output: %s", output).isFalse(); + } + finally + { + setBackendEnabled(false); + TestCaseUtils.deleteEntry(memberIndex.getName()); + TestCaseUtils.deleteEntry(indexBranch.getName()); + TestCaseUtils.deleteEntry(backend.getName()); + } + } + + private static void setBackendEnabled(boolean enabled) + { + final ModifyRequest modifyRequest = Requests.newModifyRequest(BACKEND_DN) + .addModification(ModificationType.REPLACE, "ds-cfg-enabled", Boolean.toString(enabled)); + final ModifyOperation modifyOperation = getRootConnection().processModify(modifyRequest); + assertThat(modifyOperation.getResultCode()).isEqualTo(ResultCode.SUCCESS); + } +} From 4a6c4849e7e3494d62daf8ce59ae5be80b476e00 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Mon, 5 Oct 2026 17:03:18 +0300 Subject: [PATCH 3/5] [#1153] Escape the HTTP statistics monitor name, warn instead of failing the upgrade when DN indexes cannot be verified Review round 2 of #1171: - HTTPConnectionHandler: the statistics monitor name escapes the handler name, as the LDAP connection handlers do, so a handler named "HTTP,ou=x" gets its statistics entry right below cn=monitor. - Upgrade: when the DN equality indexes of a base DN can be neither verified nor rebuilt, for example a JDBC or Cassandra backend whose database is down, the upgrade warns and goes on instead of failing. The base DN selection moved to getDNEqualityIndexesToVerify(). - Upgrade chapter: the new JMX names of the monitor MBeans, and what to do for a backend the upgrade could not read. - run.sh: --force is not needed to verify indexes, only to rebuild them. - Tests: an HTTP row in MonitorDNTestCase, DeleteBackendTestCase for InstallerHelper.deleteBackend(), the result code and message of a trailing '\' in PatternDNTest, the schema file order, the base DN selection and the warning in DNEqualityIndexesUpgradeTestCase, whose cleanup no longer hides a failed setup. --- .../asciidoc/install-guide/chap-upgrade.adoc | 4 +- opendj-packages/opendj-docker/run.sh | 4 +- .../protocols/http/HTTPConnectionHandler.java | 2 +- .../server/tools/upgrade/UpgradeTasks.java | 104 ++++++--- .../org/opends/messages/tool.properties | 3 + .../installer/DeleteBackendTestCase.java | 85 +++++++ .../dseecompat/PatternDNTest.java | 18 +- .../server/monitors/MonitorDNTestCase.java | 26 ++- .../DNEqualityIndexesUpgradeTestCase.java | 210 +++++++++++++++--- 9 files changed, 393 insertions(+), 63 deletions(-) create mode 100644 opendj-server-legacy/src/test/java/org/opends/quicksetup/installer/DeleteBackendTestCase.java diff --git a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc index 04983971ef..d187dffaa6 100644 --- a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc +++ b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc @@ -305,12 +305,14 @@ Before 5.2.0, the server read a DN only up to the first `;`, up to the end of a . A value with DN syntax that a client wrote with `;`, such as `member: cn=a;dc=example,dc=com`, was indexed under the key of `cn=a`. It now reads as `cn=a,dc=example,dc=com`, so an indexed equality search for that DN misses the entry until the index is rebuilt. The same holds for a value that was stored while its syntax was not enforced, and that only now parses. + -The `upgrade` command therefore offers to verify the equality indexes of the attributes that hold DNs, such as `member`, `uniqueMember`, `owner` or `seeAlso`, in every enabled backend at the end of the upgrade, and rebuilds them under each base DN where they do not match the entries. The verification reads every entry of these backends, so on a large backend it takes a while; the indexes are only rebuilt where it finds a missing key. The default answer is yes, so `upgrade --no-prompt` performs the verification too, as do the Docker image and the native packages, which run `upgrade --no-prompt --force`. If you declined it, run `verify-index` on these indexes with the server stopped, and rebuild them where it reports errors, as described in xref:../admin-guide/chap-indexing.adoc#rebuild-index["Rebuilding Indexes"] in the __Administration Guide__. +The `upgrade` command therefore offers to verify the equality indexes of the attributes that hold DNs, such as `member`, `uniqueMember`, `owner` or `seeAlso`, in every enabled backend at the end of the upgrade, and rebuilds them under each base DN where they do not match the entries. The verification reads every entry of these backends, so on a large backend it takes a while; the indexes are only rebuilt where it finds a missing key. The default answer is yes, so `upgrade --no-prompt` performs the verification too, as do the Docker image and the native packages, which run `upgrade --no-prompt --force`. If you declined it, run `verify-index` on these indexes with the server stopped, and rebuild them where it reports errors, as described in xref:../admin-guide/chap-indexing.adoc#rebuild-index["Rebuilding Indexes"] in the __Administration Guide__. Do the same for a backend that the upgrade could not read, such as a JDBC or Cassandra backend whose database was unreachable: the upgrade then warns, names the indexes and the base DN, and goes on. + A stored DN value with other content after an RDN, such as `cn="a"x,dc=example,dc=com`, no longer parses: replace it with the DN that was meant. . An ACI whose target, `userdn` or `groupdn` DN uses `;` between RDNs now names the whole DN, not the part before the first `;`. An ACI whose DN has other content after an RDN no longer decodes: the server logs a warning naming the ACI when it loads it, and enters lockdown mode. Correct the ACI, then leave lockdown mode as described in xref:../admin-guide/chap-troubleshooting.adoc#troubleshoot-use-lockdown-mode["Preventing Access While You Fix Issues"] in the __Administration Guide__. +The JMX names of monitor MBeans whose RDN value holds characters other than letters, digits and spaces change: these characters are now percent-encoded instead of dropped, so that two monitors no longer share a name. For example, `cn-LDAP_Connection_Handler_0000_port_1389` becomes `cn-LDAP_Connection_Handler_0%2E0%2E0%2E0_port_1389`, and the names of the replication monitors, which hold `(`, `)` and `:`, change too. Update the JMX monitoring rules, such as exporter rules or checks, that match these names after the upgrade. The SNMP connection handler finds the connection handlers under their new names. + ==== [#upgrade-repl] diff --git a/opendj-packages/opendj-docker/run.sh b/opendj-packages/opendj-docker/run.sh index 168ddd1d4d..b66314a69e 100755 --- a/opendj-packages/opendj-docker/run.sh +++ b/opendj-packages/opendj-docker/run.sh @@ -137,8 +137,8 @@ fi if [ -d ./data/config ]; then # nothing is bootstrapped here, the instance is already there - but a half-migrated one # is not ready to serve either, so the marker follows the upgrade. - # --force performs the tasks that -n alone answers with their default, such as rebuilding or verifying - # indexes: nobody is there to run them by hand afterwards, as the native packages do too + # --force performs the tasks that -n alone answers with their default no, such as rebuilding indexes: + # nobody is there to run them by hand afterwards, as the native packages do too if sh ./upgrade -n --force; then # A server whose volume holds the data of the topology is ready as soon as it serves: # gating it on its peers would deadlock a whole-cluster restart under OrderedReady, diff --git a/opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java b/opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java index a3a61d9204..fd6e3f0a49 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java @@ -474,7 +474,7 @@ public void initializeConnectionHandler(ServerContext serverContext, HTTPConnect } // Create and register monitors. - statTracker = new HTTPStatistics(handlerName + " Statistics"); + statTracker = new HTTPStatistics(DN.escapeAttributeValue(handlerName) + " Statistics"); DirectoryServer.registerMonitorProvider(statTracker); connMonitor = new ClientConnectionMonitorProvider(this); diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java index 6289491bbd..e2ee3e98fb 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java @@ -715,35 +715,15 @@ public void postUpgrade(final UpgradeContext context) throws ClientException { throw new ClientException(ReturnCode.ERROR_UNEXPECTED, ERR_UPGRADE_READING_CONF_FILE.get(e.getMessage()), e); } - final Map> baseDNsPerBackend = getBaseDNsPerBackendsFromConfig(); - for (final Map.Entry> backend : attributesPerBackend.entrySet()) + final Map> indexesPerBaseDN = + getDNEqualityIndexesToVerify(attributesPerBackend, getBaseDNsPerBackendsFromConfig()); + for (final Map.Entry> baseDN : indexesPerBaseDN.entrySet()) { - final Set baseDNs = baseDNsPerBackend.get(backend.getKey()); - if (baseDNs == null) - { - continue; - } - for (final String baseDN : baseDNs) - { - verifyAndRebuild(context, baseDN, backend.getValue()); - } + verifyAndRebuildOrWarn(context, configFile.getAbsolutePath(), baseDN.getKey(), baseDN.getValue(), true, + UpgradeLog.getPrintStream()); } } - private void verifyAndRebuild(final UpgradeContext context, final String baseDN, final Set attributes) - throws ClientException - { - final String indexes = joinAsString(", ", attributes); - final ProgressNotificationCallback pnc = new ProgressNotificationCallback( - INFORMATION, INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_STARTS.get(indexes, baseDN), 25); - context.notifyProgress(pnc); - final boolean rebuilt = verifyAndRebuildIndexes(configFile.getAbsolutePath(), baseDN, attributes, true, - UpgradeLog.getPrintStream()); - context.notifyProgress(pnc.setProgress(100)); - context.notify(rebuilt ? INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT.get(indexes, baseDN) - : INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT.get(indexes, baseDN)); - } - @Override public void postponePostUpgrade(UpgradeContext context) throws ClientException { @@ -761,6 +741,80 @@ public String toString() }; } + /** + * Returns the base DNs whose DN equality indexes are verified, with the attributes of these indexes: those of the + * backend holding each base DN. A backend whose base DNs are unknown is left out. + * + * @param attributesPerBackend + * The attributes with a DN equality index, per backend ID. + * @param baseDNsPerBackend + * The base DNs, per backend ID. + * @return The attributes whose indexes are verified, per base DN. + */ + static Map> getDNEqualityIndexesToVerify(final Map> attributesPerBackend, + final Map> baseDNsPerBackend) + { + final Map> attributesPerBaseDN = new TreeMap<>(); + for (final Map.Entry> backend : attributesPerBackend.entrySet()) + { + final Set baseDNs = baseDNsPerBackend.get(backend.getKey()); + if (baseDNs != null) + { + for (final String baseDN : baseDNs) + { + attributesPerBaseDN.put(baseDN, backend.getValue()); + } + } + } + return attributesPerBaseDN; + } + + /** + * Verifies the provided DN equality indexes under a base DN, and rebuilds them if needed, as + * {@link #verifyAndRebuildIndexes} does, then tells the user the outcome. When the indexes can be neither verified + * nor rebuilt, the user is warned instead of the upgrade failing: the server works without this task, and a + * backend that cannot be read now, such as a JDBC or Cassandra backend whose database is down, is left to the + * administrator. + * + * @param context + * The upgrade context, which is notified of the outcome. + * @param configFilePath + * The path of the configuration file. + * @param baseDN + * The base DN to verify. + * @param attributes + * The attributes whose indexes are verified, and rebuilt if needed. + * @param initializeServer + * Whether the tools have to initialize the server components. + * @param out + * The stream the tools write to. + * @throws ClientException + * If the outcome cannot be reported. + */ + static void verifyAndRebuildOrWarn(final UpgradeContext context, final String configFilePath, final String baseDN, + final Set attributes, final boolean initializeServer, final PrintStream out) throws ClientException + { + final String indexes = joinAsString(", ", attributes); + final ProgressNotificationCallback pnc = new ProgressNotificationCallback( + INFORMATION, INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_STARTS.get(indexes, baseDN), 25); + context.notifyProgress(pnc); + final boolean rebuilt; + try + { + rebuilt = verifyAndRebuildIndexes(configFilePath, baseDN, attributes, initializeServer, out); + } + catch (final ClientException e) + { + logger.error(e.getMessageObject()); + context.notifyProgress(pnc.setProgress(100)); + context.notify(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get(indexes, baseDN), WARNING); + return; + } + context.notifyProgress(pnc.setProgress(100)); + context.notify(rebuilt ? INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT.get(indexes, baseDN) + : INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT.get(indexes, baseDN)); + } + /** * Verifies the provided indexes under a base DN with the verify-index tool, and rebuilds them with the * rebuild-index tool when it reports any error. A verification that fails is taken as an error, so the indexes diff --git a/opendj-server-legacy/src/messages/org/opends/messages/tool.properties b/opendj-server-legacy/src/messages/org/opends/messages/tool.properties index 8fc5c74793..e7dd386f9f 100644 --- a/opendj-server-legacy/src/messages/org/opends/messages/tool.properties +++ b/opendj-server-legacy/src/messages/org/opends/messages/tool.properties @@ -2750,6 +2750,9 @@ INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_STARTS=Verifying index(es) '%s' for base INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT=Index(es) '%s' for base DN '%s' match the entries INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT=Index(es) '%s' for base DN '%s' do not match \ the entries and are rebuilt +WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED=Index(es) '%s' for base DN '%s' could not be verified \ + or rebuilt, see the upgrade log. The upgrade goes on: verify them manually with verify-index once the \ + backend can be read, and rebuild them with rebuild-index where it reports errors INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_DECLINED=You have to verify the equality indexes of the \ attributes holding DNs manually with verify-index, and rebuild them with rebuild-index where it reports \ errors, to get a fully functional server diff --git a/opendj-server-legacy/src/test/java/org/opends/quicksetup/installer/DeleteBackendTestCase.java b/opendj-server-legacy/src/test/java/org/opends/quicksetup/installer/DeleteBackendTestCase.java new file mode 100644 index 0000000000..f119f98540 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/quicksetup/installer/DeleteBackendTestCase.java @@ -0,0 +1,85 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.opends.quicksetup.installer; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; + +import org.forgerock.opendj.ldap.DN; +import org.opends.server.DirectoryServerTestCase; +import org.opends.server.TestCaseUtils; +import org.opends.server.core.DirectoryServer; +import org.opends.server.types.Entry; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +/** + * Tests that the installer deletes the configuration of the backend it names, whatever its ID holds (issue #1153). + */ +@SuppressWarnings("javadoc") +public class DeleteBackendTestCase extends DirectoryServerTestCase +{ + @BeforeClass + public void startServer() throws Exception + { + TestCaseUtils.startServer(); + } + + /** Concatenated into a DN, the ID "a,b" would name "ds-cfg-backend-id=a,b,cn=Backends,cn=config". */ + @Test + public void deletesTheBackendWhoseIDADNHasToEscape() throws Exception + { + final Entry backend = TestCaseUtils.makeEntry( + "dn: ds-cfg-backend-id=a\\,b,cn=Backends,cn=config", + "objectClass: top", + "objectClass: ds-cfg-backend", + "objectClass: ds-cfg-local-backend", + "objectClass: ds-cfg-pluggable-backend", + "objectClass: ds-cfg-pdb-backend", + "ds-cfg-enabled: false", + "ds-cfg-java-class: org.opends.server.backends.pdb.PDBBackend", + "ds-cfg-backend-id: a,b", + "ds-cfg-writability-mode: enabled", + "ds-cfg-base-dn: o=delete backend test", + "ds-cfg-db-directory: db_delete_backend_test"); + final Entry indexBranch = TestCaseUtils.makeEntry( + "dn: cn=Index,ds-cfg-backend-id=a\\,b,cn=Backends,cn=config", + "objectClass: top", + "objectClass: ds-cfg-branch", + "cn: Index"); + try + { + TestCaseUtils.addEntry(backend); + TestCaseUtils.addEntry(indexBranch); + + new InstallerHelper().deleteBackend("a,b"); + + assertThat(DirectoryServer.entryExists(indexBranch.getName())).isFalse(); + assertThat(DirectoryServer.entryExists(backend.getName())).isFalse(); + } + finally + { + for (final DN dn : Arrays.asList(indexBranch.getName(), backend.getName())) + { + if (DirectoryServer.entryExists(dn)) + { + TestCaseUtils.deleteEntry(dn); + } + } + } + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java index 1fe41b3c37..8cac115c53 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/PatternDNTest.java @@ -15,9 +15,12 @@ */ package org.opends.server.authorization.dseecompat; +import static com.forgerock.opendj.ldap.CoreMessages.ERR_ATTR_SYNTAX_DN_TRAILING_ESCAPE; import static org.assertj.core.api.Assertions.*; +import org.forgerock.i18n.LocalizedIllegalArgumentException; import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.ResultCode; import org.opends.server.DirectoryServerTestCase; import org.opends.server.TestCaseUtils; import org.opends.server.types.DirectoryException; @@ -114,10 +117,21 @@ public Object[][] patternsWithATrailingBackslash() }; } - @Test(dataProvider = "patternsWithATrailingBackslash", expectedExceptions = DirectoryException.class) + /** The pattern is rejected with the result code and the message of DN.valueOf(). */ + @Test(dataProvider = "patternsWithATrailingBackslash") public void patternWithATrailingBackslashIsRejected(String pattern) throws Exception { - PatternDN.decode(pattern); + final Throwable patternError = catchThrowable(() -> PatternDN.decode(pattern)); + assertThat(patternError).isInstanceOf(DirectoryException.class); + assertThat(((DirectoryException) patternError).getResultCode()).isEqualTo(ResultCode.INVALID_DN_SYNTAX); + assertThat(((DirectoryException) patternError).getMessageObject().toString()) + .isEqualTo(ERR_ATTR_SYNTAX_DN_TRAILING_ESCAPE.get(pattern).toString()); + if (!pattern.contains("*")) + { + final Throwable dnError = catchThrowable(() -> DN.valueOf(pattern)); + assertThat(dnError).isInstanceOf(LocalizedIllegalArgumentException.class); + assertThat(dnError.getMessage()).isEqualTo(patternError.getMessage()); + } } @DataProvider diff --git a/opendj-server-legacy/src/test/java/org/opends/server/monitors/MonitorDNTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/monitors/MonitorDNTestCase.java index acdcce5148..c787d731a6 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/monitors/MonitorDNTestCase.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/monitors/MonitorDNTestCase.java @@ -20,6 +20,11 @@ import static org.opends.server.protocols.internal.InternalClientConnection.*; import static org.opends.server.protocols.internal.Requests.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.server.config.server.MonitorProviderCfg; @@ -28,6 +33,7 @@ import org.opends.server.api.MonitorData; import org.opends.server.api.MonitorProvider; import org.opends.server.core.DirectoryServer; +import org.opends.server.protocols.http.HTTPConnectionHandler; import org.opends.server.protocols.internal.InternalSearchOperation; import org.opends.server.protocols.ldap.LDAPConnectionHandler; import org.opends.server.types.Entry; @@ -115,29 +121,35 @@ public void nameThatIsNotARelativeDNNamesOneEntry(String name) throws Exception @DataProvider public Object[][] connectionHandlerClasses() { - return new Object[][] { { LDAPConnectionHandler.class.getName() }, { LDAPConnectionHandler2.class.getName() } }; + final String[] ldap = { "ds-cfg-allow-ldap-v2: false", "ds-cfg-allow-start-tls: false" }; + return new Object[][] { + { LDAPConnectionHandler.class.getName(), "ds-cfg-ldap-connection-handler", ldap }, + { LDAPConnectionHandler2.class.getName(), "ds-cfg-ldap-connection-handler", ldap }, + { HTTPConnectionHandler.class.getName(), "ds-cfg-http-connection-handler", new String[0] }, + }; } /** The connection handler, its client connections and its statistics each get the entry they name. */ @Test(dataProvider = "connectionHandlerClasses") - public void connectionHandlerWithACommaInItsName(String javaClass) throws Exception + public void connectionHandlerWithACommaInItsName(String javaClass, String objectClass, String[] attributes) + throws Exception { // Unescaped, "LDAP,ou=internal 127.0.0.1 port N" would be a relative DN two levels down final String name = "LDAP,ou=internal"; final int port = TestCaseUtils.findFreePort(); - final Entry handlerEntry = TestCaseUtils.makeEntry( + final List ldif = new ArrayList<>(Arrays.asList( "dn: cn=LDAP\\,ou\\=internal,cn=Connection Handlers,cn=config", "objectClass: top", "objectClass: ds-cfg-connection-handler", - "objectClass: ds-cfg-ldap-connection-handler", + "objectClass: " + objectClass, "cn: " + name, "ds-cfg-java-class: " + javaClass, "ds-cfg-enabled: true", "ds-cfg-listen-address: 127.0.0.1", "ds-cfg-listen-port: " + port, - "ds-cfg-allow-ldap-v2: false", - "ds-cfg-use-ssl: false", - "ds-cfg-allow-start-tls: false"); + "ds-cfg-use-ssl: false")); + Collections.addAll(ldif, attributes); + final Entry handlerEntry = TestCaseUtils.makeEntry(ldif.toArray(new String[0])); TestCaseUtils.addEntry(handlerEntry); try { diff --git a/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java index fd0ea4b0f6..3efbfde2db 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java @@ -17,15 +17,28 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.opends.messages.ToolMessages.INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT; +import static org.opends.messages.ToolMessages.WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.PrintStream; import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.TextOutputCallback; + +import com.forgerock.opendj.cli.ClientException; + import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.ModificationType; import org.forgerock.opendj.ldap.ResultCode; @@ -39,6 +52,7 @@ import org.opends.server.types.Entry; import org.opends.server.util.StaticUtils; import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import static org.opends.server.protocols.internal.InternalClientConnection.getRootConnection; @@ -53,6 +67,10 @@ public class DNEqualityIndexesUpgradeTestCase extends DirectoryServerTestCase private static final String BACKEND_ID = "dnEqualityIndexes"; private static final String BACKEND_DN = "ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config"; private static final String BASE_DN = "o=dn equality indexes"; + private static final String MY_MANAGER_AS_DN = + "( 1.3.6.1.4.1.26027.1.999.1153 NAME 'myManager' SUP distinguishedName )"; + private static final String MY_MANAGER_AS_STRING = "( 1.3.6.1.4.1.26027.1.999.1153 NAME 'myManager' " + + "EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )"; @BeforeClass public void setUp() throws Exception @@ -70,18 +88,7 @@ public void findsTheEqualityIndexesThatCompareDNs() throws Exception final File directory = Files.createTempDirectory("dn-equality-indexes").toFile(); try { - final File config = new File(directory, "config.ldif"); - Files.write(config.toPath(), String.join("\n", - "dn: cn=config", - "objectClass: top", - "objectClass: ds-cfg-root-config", - "cn: config", - "", - "dn: cn=Backends,cn=config", - "objectClass: top", - "objectClass: ds-cfg-branch", - "cn: Backends", - "", + final File config = writeConfig(directory, backend("userRoot", "userRoot", "true"), index("userRoot", "member", "equality"), index("userRoot", "uniqueMember", "equality", "presence"), @@ -96,17 +103,9 @@ public void findsTheEqualityIndexesThatCompareDNs() throws Exception backend("disabledRoot", "disabledRoot", "false"), index("disabledRoot", "member", "equality"), backend("otherRoot", "otherRoot", "true"), - index("otherRoot", "cn", "equality")).getBytes(UTF_8)); - + index("otherRoot", "cn", "equality")); final File schemaDirectory = new File(directory, "schema"); - schemaDirectory.mkdir(); - Files.write(new File(schemaDirectory, "99-user.ldif").toPath(), String.join("\n", - "dn: cn=schema", - "objectClass: top", - "objectClass: ldapSubentry", - "objectClass: subschema", - "attributeTypes: ( 1.3.6.1.4.1.26027.1.999.1153 NAME 'myManager' SUP distinguishedName )", - "").getBytes(UTF_8)); + writeSchema(schemaDirectory, "99-user.ldif", MY_MANAGER_AS_DN); final Map> attributes = UpgradeUtils.getDNEqualityIndexedAttributesPerBackend(config, schemaDirectory); @@ -121,6 +120,101 @@ public void findsTheEqualityIndexesThatCompareDNs() throws Exception } } + /** + * The schema files are read in the order of their names, as the server reads them, so the definition of an + * attribute in a later file replaces the one in an earlier file. + */ + @DataProvider + public Object[][] schemaFilesInOrder() + { + return new Object[][] { + { MY_MANAGER_AS_STRING, MY_MANAGER_AS_DN, true }, + { MY_MANAGER_AS_DN, MY_MANAGER_AS_STRING, false }, + }; + } + + @Test(dataProvider = "schemaFilesInOrder") + public void aLaterSchemaFileDecidesTheMatchingRule(String first, String last, boolean comparesDNs) throws Exception + { + final File directory = Files.createTempDirectory("dn-equality-indexes").toFile(); + try + { + final File config = writeConfig(directory, + backend("userRoot", "userRoot", "true"), + index("userRoot", "myManager", "equality")); + final File schemaDirectory = new File(directory, "schema"); + // Written in the reverse order, so that the order of the directory listing does not give the expected one + writeSchema(schemaDirectory, "99-user.ldif", last); + writeSchema(schemaDirectory, "10-first.ldif", first); + + final Map> attributes = + UpgradeUtils.getDNEqualityIndexedAttributesPerBackend(config, schemaDirectory); + + if (comparesDNs) + { + assertThat(attributes).containsOnlyKeys("userRoot"); + assertThat(attributes.get("userRoot")).containsExactly("myManager"); + } + else + { + assertThat(attributes).isEmpty(); + } + } + finally + { + StaticUtils.recursiveDelete(directory); + } + } + + /** Each base DN gets the attributes of its backend; a backend without known base DNs is left out. */ + @Test + public void eachBaseDNGetsTheIndexesOfItsBackend() + { + final Map> attributesPerBackend = new HashMap<>(); + attributesPerBackend.put("a,b", Collections.singleton("member")); + attributesPerBackend.put("c", Collections.singleton("uniqueMember")); + final Map> baseDNsPerBackend = new HashMap<>(); + baseDNsPerBackend.put("a,b", new HashSet<>(Arrays.asList("o=a", "o=b"))); + baseDNsPerBackend.put("d", Collections.singleton("o=d")); + + final Map> indexes = + UpgradeTasks.getDNEqualityIndexesToVerify(attributesPerBackend, baseDNsPerBackend); + + assertThat(indexes).containsOnlyKeys("o=a", "o=b"); + assertThat(indexes.get("o=a")).containsExactly("member"); + assertThat(indexes.get("o=b")).containsExactly("member"); + } + + private static File writeConfig(File directory, String... entries) throws Exception + { + final File config = new File(directory, "config.ldif"); + Files.write(config.toPath(), String.join("\n", + "dn: cn=config", + "objectClass: top", + "objectClass: ds-cfg-root-config", + "cn: config", + "", + "dn: cn=Backends,cn=config", + "objectClass: top", + "objectClass: ds-cfg-branch", + "cn: Backends", + "", + String.join("\n", entries)).getBytes(UTF_8)); + return config; + } + + private static void writeSchema(File schemaDirectory, String fileName, String attributeType) throws Exception + { + schemaDirectory.mkdir(); + Files.write(new File(schemaDirectory, fileName).toPath(), String.join("\n", + "dn: cn=schema", + "objectClass: top", + "objectClass: ldapSubentry", + "objectClass: subschema", + "attributeTypes: " + attributeType, + "").getBytes(UTF_8)); + } + private static String backend(String rdnValue, String backendID, String enabled) { return String.join("\n", @@ -220,14 +314,80 @@ public void rebuildsAnIndexOnlyWhenItMissesKeys() throws Exception .as("rebuilt an index missing keys; output: %s", output).isTrue(); assertThat(UpgradeTasks.verifyAndRebuildIndexes(configFile, BASE_DN, attributes, false, out)) .as("rebuilt an index matching its entries; output: %s", output).isFalse(); + + final List notifications = new ArrayList<>(); + UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), configFile, BASE_DN, attributes, false, out); + assertThat(messagesOf(notifications)) + .contains(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT.get("member", BASE_DN).toString()) + .doesNotContain(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get("member", BASE_DN).toString()); } finally { setBackendEnabled(false); - TestCaseUtils.deleteEntry(memberIndex.getName()); - TestCaseUtils.deleteEntry(indexBranch.getName()); - TestCaseUtils.deleteEntry(backend.getName()); + // A failed setup must not be hidden by the cleanup, nor leave the backend configured for the next tests + for (final DN dn : Arrays.asList(memberIndex.getName(), indexBranch.getName(), backend.getName())) + { + if (DirectoryServer.entryExists(dn)) + { + TestCaseUtils.deleteEntry(dn); + } + } + } + } + + /** + * Indexes that can be neither verified nor rebuilt, as those of a JDBC or Cassandra backend whose database is + * down, do not fail the upgrade: the user is warned instead. + */ + @Test + public void indexesThatCannotBeVerifiedOnlyWarn() throws Exception + { + final String baseDN = "o=held by no backend"; + final Set attributes = Collections.singleton("member"); + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final PrintStream out = new PrintStream(output, true, "UTF-8"); + final String configFile = DirectoryServer.getConfigFile(); + final Throwable failure = + catchThrowable(() -> UpgradeTasks.verifyAndRebuildIndexes(configFile, baseDN, attributes, false, out)); + assertThat(failure).as("output: %s", output).isInstanceOf(ClientException.class); + + final List notifications = new ArrayList<>(); + UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), configFile, baseDN, attributes, false, out); + + final String warning = WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get("member", baseDN).toString(); + assertThat(messagesOf(notifications)).contains(warning); + for (final TextOutputCallback notification : notifications) + { + if (notification.getMessage().equals(warning)) + { + assertThat(((FormattedNotificationCallback) notification).getMessageSubType()) + .isEqualTo(TextOutputCallback.WARNING); + } + } + } + + /** Returns an upgrade context that records what it notifies. */ + private static UpgradeContext newContext(final List notifications) throws Exception + { + return new UpgradeContext(callbacks -> { + for (final Callback callback : callbacks) + { + if (callback instanceof TextOutputCallback) + { + notifications.add((TextOutputCallback) callback); + } + } + }); + } + + private static List messagesOf(final List notifications) + { + final List messages = new ArrayList<>(); + for (final TextOutputCallback notification : notifications) + { + messages.add(notification.getMessage()); } + return messages; } private static void setBackendEnabled(boolean enabled) From 56fde9d34c11edd5851b6a231a83dfa2c98d9ef6 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Tue, 6 Oct 2026 11:05:31 +0300 Subject: [PATCH 4/5] [#1153] Fail the upgrade when a DN index rebuild fails, warn only when the indexes cannot be verified Review round 3 of #1171: - Upgrade: verifyAndRebuildOrWarn() warns and goes on only when the DN equality indexes of a base DN cannot be verified; they are then not rebuilt and stay as they were. A rebuild that fails, after the verification found missing keys, fails the upgrade again, as the other index rebuilds of the upgrade do: it may have left the indexes untrusted, so that searches cannot use them. - VerifyIndex: countIndexErrors() returns the number of errors, or -1 when the indexes could not be verified; with --countErrors the tool exits with 1 in both cases. mainVerifyIndex() keeps its exit codes. - UpgradeUtils: the schema files are listed in read order by schemaFilesInReadOrder(). - Upgrade chapter: a global ACI that no longer decodes stops the server from starting, and a failed rebuild fails the upgrade. - Tests: DNEqualityIndexesUpgradeTestCase checks the notice for rebuilt indexes, a failed rebuild, indexes that cannot be verified and are not rebuilt, and the order of 26 schema files. --- .../asciidoc/install-guide/chap-upgrade.adoc | 4 +- .../org/opends/server/tools/VerifyIndex.java | 67 ++++--- .../server/tools/upgrade/UpgradeTasks.java | 70 ++++--- .../server/tools/upgrade/UpgradeUtils.java | 22 ++- .../org/opends/messages/tool.properties | 6 +- .../DNEqualityIndexesUpgradeTestCase.java | 185 +++++++++++++----- 6 files changed, 253 insertions(+), 101 deletions(-) diff --git a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc index d187dffaa6..29061789fe 100644 --- a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc +++ b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc @@ -305,11 +305,11 @@ Before 5.2.0, the server read a DN only up to the first `;`, up to the end of a . A value with DN syntax that a client wrote with `;`, such as `member: cn=a;dc=example,dc=com`, was indexed under the key of `cn=a`. It now reads as `cn=a,dc=example,dc=com`, so an indexed equality search for that DN misses the entry until the index is rebuilt. The same holds for a value that was stored while its syntax was not enforced, and that only now parses. + -The `upgrade` command therefore offers to verify the equality indexes of the attributes that hold DNs, such as `member`, `uniqueMember`, `owner` or `seeAlso`, in every enabled backend at the end of the upgrade, and rebuilds them under each base DN where they do not match the entries. The verification reads every entry of these backends, so on a large backend it takes a while; the indexes are only rebuilt where it finds a missing key. The default answer is yes, so `upgrade --no-prompt` performs the verification too, as do the Docker image and the native packages, which run `upgrade --no-prompt --force`. If you declined it, run `verify-index` on these indexes with the server stopped, and rebuild them where it reports errors, as described in xref:../admin-guide/chap-indexing.adoc#rebuild-index["Rebuilding Indexes"] in the __Administration Guide__. Do the same for a backend that the upgrade could not read, such as a JDBC or Cassandra backend whose database was unreachable: the upgrade then warns, names the indexes and the base DN, and goes on. +The `upgrade` command therefore offers to verify the equality indexes of the attributes that hold DNs, such as `member`, `uniqueMember`, `owner` or `seeAlso`, in every enabled backend at the end of the upgrade, and rebuilds them under each base DN where they do not match the entries. The verification reads every entry of these backends, so on a large backend it takes a while; the indexes are only rebuilt where it finds a missing key. The default answer is yes, so `upgrade --no-prompt` performs the verification too, as do the Docker image and the native packages, which run `upgrade --no-prompt --force`. If you declined it, run `verify-index` on these indexes with the server stopped, and rebuild them where it reports errors, as described in xref:../admin-guide/chap-indexing.adoc#rebuild-index["Rebuilding Indexes"] in the __Administration Guide__. Do the same for a backend that the upgrade could not read, such as a JDBC or Cassandra backend whose database was unreachable: the upgrade then warns, names the indexes and the base DN, and goes on, leaving these indexes as they were. A rebuild that the upgrade started and that fails, for instance because the temporary directory is full, fails the upgrade, as the index rebuilds of other upgrade tasks do: such indexes may be left untrusted, so that searches cannot use them, until they are rebuilt. + A stored DN value with other content after an RDN, such as `cn="a"x,dc=example,dc=com`, no longer parses: replace it with the DN that was meant. -. An ACI whose target, `userdn` or `groupdn` DN uses `;` between RDNs now names the whole DN, not the part before the first `;`. An ACI whose DN has other content after an RDN no longer decodes: the server logs a warning naming the ACI when it loads it, and enters lockdown mode. Correct the ACI, then leave lockdown mode as described in xref:../admin-guide/chap-troubleshooting.adoc#troubleshoot-use-lockdown-mode["Preventing Access While You Fix Issues"] in the __Administration Guide__. +. An ACI whose target, `userdn` or `groupdn` DN uses `;` between RDNs now names the whole DN, not the part before the first `;`. An ACI whose DN has other content after an RDN no longer decodes: the server logs a warning naming the ACI when it loads it, and enters lockdown mode. Correct the ACI, then leave lockdown mode as described in xref:../admin-guide/chap-troubleshooting.adoc#troubleshoot-use-lockdown-mode["Preventing Access While You Fix Issues"] in the __Administration Guide__. A global ACI (`ds-cfg-global-aci` in `config.ldif`) of this form stops the server from starting instead, with an error that names the `ds-cfg-global-aci` attribute of the access control handler: correct it in `config.ldif` while the server is stopped. The JMX names of monitor MBeans whose RDN value holds characters other than letters, digits and spaces change: these characters are now percent-encoded instead of dropped, so that two monitors no longer share a name. For example, `cn-LDAP_Connection_Handler_0000_port_1389` becomes `cn-LDAP_Connection_Handler_0%2E0%2E0%2E0_port_1389`, and the names of the replication monitors, which hold `(`, `)` and `:`, change too. Update the JMX monitoring rules, such as exporter rules or checks, that match these names after the upgrade. The SNMP connection handler finds the connection handlers under their new names. diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/VerifyIndex.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/VerifyIndex.java index 9ae753d113..38680f651d 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/tools/VerifyIndex.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/VerifyIndex.java @@ -13,6 +13,7 @@ * * Copyright 2006-2008 Sun Microsystems, Inc. * Portions Copyright 2012-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.tools; @@ -82,6 +83,36 @@ public static void main(String[] args) */ public static int mainVerifyIndex(String[] args, boolean initializeServer, OutputStream errStream) + { + final long result = verifyIndex(args, initializeServer, errStream, false); + return result == VERIFY_FAILED ? 1 : (int) Math.min(result, Integer.MAX_VALUE); + } + + /** + * Verifies the indexes as {@link #mainVerifyIndex} does with {@code --countErrors}, but tells a verification that + * could not be performed apart from one that found errors: the tool exits with 1 in both cases. + * + * @param args The command-line arguments provided to this + * program. + * @param initializeServer Indicates whether to initialize the server. + * @param errStream The output stream to use for standard error, or + * {@code null} if standard error is not needed. + * @return The number of errors found in the indexes, or {@code -1} if they could not be verified. + */ + public static long countIndexErrors(String[] args, boolean initializeServer, OutputStream errStream) + { + return verifyIndex(args, initializeServer, errStream, true); + } + + /** What {@link #verifyIndex} returns when the indexes could not be verified. */ + private static final long VERIFY_FAILED = -1; + + /** + * Returns the number of errors found when {@code --countErrors} is present or {@code countErrorsAlways} is + * {@code true}, else 0, or {@link #VERIFY_FAILED} if the indexes could not be verified. + */ + private static long verifyIndex(String[] args, boolean initializeServer, OutputStream errStream, + boolean countErrorsAlways) { PrintStream err = NullOutputStream.wrapOrNullStream(errStream); JDKLogging.enableConsoleLoggingForOpenDJTool(); @@ -145,7 +176,7 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, catch (ArgumentException ae) { printWrappedText(err, ERR_CANNOT_INITIALIZE_ARGS.get(ae.getMessage())); - return 1; + return VERIFY_FAILED; } @@ -157,7 +188,7 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, catch (ArgumentException ae) { argParser.displayMessageAndUsageReference(err, ERR_ERROR_PARSING_ARGS.get(ae.getMessage())); - return 1; + return VERIFY_FAILED; } @@ -171,7 +202,7 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, if (cleanMode.isPresent() && indexList.getValues().size() != 1) { argParser.displayMessageAndUsageReference(err, ERR_VERIFYINDEX_VERIFY_CLEAN_REQUIRES_SINGLE_INDEX.get()); - return 1; + return VERIFY_FAILED; } // Checks the version - if upgrade required, the tool is unusable @@ -182,7 +213,7 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, catch (InitializationException e) { printWrappedText(err, e.getMessage()); - return 1; + return VERIFY_FAILED; } if (initializeServer) @@ -196,7 +227,7 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, catch (InitializationException ie) { printWrappedText(err, ERR_CANNOT_INITIALIZE_SERVER_COMPONENTS.get(ie.getLocalizedMessage())); - return 1; + return VERIFY_FAILED; } } @@ -209,7 +240,7 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, catch (Exception e) { printWrappedText(err, ERR_CANNOT_DECODE_BASE_DN.get(baseDNString.getValue(), getExceptionMessage(e))); - return 1; + return VERIFY_FAILED; } @@ -232,7 +263,7 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, if (backend != null) { printWrappedText(err, ERR_MULTIPLE_BACKENDS_FOR_BASE.get(baseDNString.getValue())); - return 1; + return VERIFY_FAILED; } backend = b; } @@ -241,13 +272,13 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, if (backend == null) { printWrappedText(err, ERR_NO_BACKENDS_FOR_BASE.get(baseDNString.getValue())); - return 1; + return VERIFY_FAILED; } if (!backend.supports(BackendOperation.INDEXING)) { printWrappedText(err, ERR_BACKEND_NO_INDEXING_SUPPORT.get()); - return 1; + return VERIFY_FAILED; } // Initialize the verify configuration. @@ -277,13 +308,13 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, if (! LockFileManager.acquireSharedLock(lockFile, failureReason)) { printWrappedText(err, ERR_VERIFYINDEX_CANNOT_LOCK_BACKEND.get(backend.getBackendID(), failureReason)); - return 1; + return VERIFY_FAILED; } } catch (Exception e) { printWrappedText(err, ERR_VERIFYINDEX_CANNOT_LOCK_BACKEND.get(backend.getBackendID(), getExceptionMessage(e))); - return 1; + return VERIFY_FAILED; } @@ -291,25 +322,17 @@ public static int mainVerifyIndex(String[] args, boolean initializeServer, { // Launch the verify process. final long errorCount = backend.verifyBackend(verifyConfig); - if (countErrors.isPresent()) - { - if (errorCount > Integer.MAX_VALUE) - { - return Integer.MAX_VALUE; - } - return (int) errorCount; - } - return 0; + return countErrors.isPresent() || countErrorsAlways ? errorCount : 0; } catch (InitializationException e) { printWrappedText(err, ERR_VERIFYINDEX_ERROR_DURING_VERIFY.get(e.getMessage())); - return 1; + return VERIFY_FAILED; } catch (Exception e) { printWrappedText(err, ERR_VERIFYINDEX_ERROR_DURING_VERIFY.get(stackTraceToSingleLineString(e))); - return 1; + return VERIFY_FAILED; } finally { diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java index e2ee3e98fb..81281d0b39 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java @@ -769,12 +769,23 @@ static Map> getDNEqualityIndexesToVerify(final Map> getDNEqualityIndexesToVerify(final Map attributes, final boolean initializeServer, final PrintStream out) throws ClientException @@ -798,27 +809,36 @@ static void verifyAndRebuildOrWarn(final UpgradeContext context, final String co final ProgressNotificationCallback pnc = new ProgressNotificationCallback( INFORMATION, INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_STARTS.get(indexes, baseDN), 25); context.notifyProgress(pnc); - final boolean rebuilt; + final IndexVerification verification; try { - rebuilt = verifyAndRebuildIndexes(configFilePath, baseDN, attributes, initializeServer, out); + verification = verifyAndRebuildIndexes(configFilePath, baseDN, attributes, initializeServer, out); } catch (final ClientException e) { - logger.error(e.getMessageObject()); - context.notifyProgress(pnc.setProgress(100)); - context.notify(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get(indexes, baseDN), WARNING); - return; + context.notifyProgress(pnc.setProgress(-100)); + throw e; } context.notifyProgress(pnc.setProgress(100)); - context.notify(rebuilt ? INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT.get(indexes, baseDN) - : INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT.get(indexes, baseDN)); + switch (verification) + { + case CONSISTENT: + context.notify(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT.get(indexes, baseDN)); + break; + case REBUILT: + context.notify(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT.get(indexes, baseDN)); + break; + default: + context.notify(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get(indexes, baseDN), WARNING); + break; + } } /** * Verifies the provided indexes under a base DN with the verify-index tool, and rebuilds them with the - * rebuild-index tool when it reports any error. A verification that fails is taken as an error, so the indexes - * are rebuilt then too. The backend must not be in use by a running server. + * rebuild-index tool when it reports any error. Indexes that cannot be verified are not rebuilt: whatever keeps + * them from being read would most likely make the rebuild fail too, after it has deleted them. The backend must + * not be in use by a running server. * * @param configFilePath * The path of the configuration file. @@ -830,11 +850,11 @@ static void verifyAndRebuildOrWarn(final UpgradeContext context, final String co * Whether the tools have to initialize the server components. * @param out * The stream the tools write to. - * @return {@code true} if the indexes were rebuilt, {@code false} if they match the entries. + * @return What the verification found. * @throws ClientException * If the rebuild fails. */ - static boolean verifyAndRebuildIndexes(final String configFilePath, final String baseDN, + static IndexVerification verifyAndRebuildIndexes(final String configFilePath, final String baseDN, final Collection attributes, final boolean initializeServer, final PrintStream out) throws ClientException { @@ -849,20 +869,22 @@ static boolean verifyAndRebuildIndexes(final String configFilePath, final String args.add(attribute); } - final List verifyArgs = new ArrayList<>(args); - verifyArgs.add("--countErrors"); - logger.debug(INFO_UPGRADE_REBUILD_INDEX_ARGUMENTS, verifyArgs); - if (VerifyIndex.mainVerifyIndex(verifyArgs.toArray(new String[0]), initializeServer, out) == 0) + logger.debug(INFO_UPGRADE_REBUILD_INDEX_ARGUMENTS, args); + final long errors = VerifyIndex.countIndexErrors(args.toArray(new String[0]), initializeServer, out); + if (errors < 0) { - return false; + return IndexVerification.NOT_VERIFIED; + } + if (errors == 0) + { + return IndexVerification.CONSISTENT; } - logger.debug(INFO_UPGRADE_REBUILD_INDEX_ARGUMENTS, args); if (new RebuildIndex().rebuildIndexesWithinMultipleBackends(initializeServer, out, args) != 0) { throw new ClientException(ReturnCode.ERROR_UNEXPECTED, ERR_UPGRADE_PERFORMING_POST_TASKS_FAIL.get()); } - return true; + return IndexVerification.REBUILT; } /** diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeUtils.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeUtils.java index f698b37be0..2e1d4e0c13 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeUtils.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeUtils.java @@ -373,11 +373,9 @@ && hasValueIgnoringCase(entry, "ds-cfg-index-type", "equality")) private static Schema readSchemaFiles(final File schemaDirectory) throws IOException { final SchemaBuilder builder = new SchemaBuilder(Schema.getCoreSchema()); - final File[] files = schemaDirectory.listFiles((dir, name) -> name.toLowerCase().endsWith(".ldif")); + final File[] files = schemaFilesInReadOrder(schemaDirectory); if (files != null) { - // The server reads them in this order, so a later file may redefine an attribute of an earlier one - Arrays.sort(files); for (final File file : files) { try (LDIFEntryReader reader = new LDIFEntryReader(new FileInputStream(file))) @@ -392,6 +390,24 @@ private static Schema readSchemaFiles(final File schemaDirectory) throws IOExcep return builder.toSchema().asNonStrictSchema(); } + /** + * Returns the LDIF files of a schema directory in the order the server reads them, so that a later file may + * redefine an attribute of an earlier one. + * + * @param schemaDirectory + * The schema directory. + * @return The LDIF files, or {@code null} if the directory cannot be listed. + */ + static File[] schemaFilesInReadOrder(final File schemaDirectory) + { + final File[] files = schemaDirectory.listFiles((dir, name) -> name.toLowerCase().endsWith(".ldif")); + if (files != null) + { + Arrays.sort(files); + } + return files; + } + private static boolean comparesDNs(final AttributeType attributeType) { final MatchingRule equality = attributeType.getEqualityMatchingRule(); diff --git a/opendj-server-legacy/src/messages/org/opends/messages/tool.properties b/opendj-server-legacy/src/messages/org/opends/messages/tool.properties index e7dd386f9f..774de87aeb 100644 --- a/opendj-server-legacy/src/messages/org/opends/messages/tool.properties +++ b/opendj-server-legacy/src/messages/org/opends/messages/tool.properties @@ -2750,9 +2750,9 @@ INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_STARTS=Verifying index(es) '%s' for base INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT=Index(es) '%s' for base DN '%s' match the entries INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT=Index(es) '%s' for base DN '%s' do not match \ the entries and are rebuilt -WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED=Index(es) '%s' for base DN '%s' could not be verified \ - or rebuilt, see the upgrade log. The upgrade goes on: verify them manually with verify-index once the \ - backend can be read, and rebuild them with rebuild-index where it reports errors +WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED=Index(es) '%s' for base DN '%s' could not be verified, \ + see the upgrade log. They are left as they were, and the upgrade goes on: verify them manually with \ + verify-index once the backend can be read, and rebuild them with rebuild-index where it reports errors INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_DECLINED=You have to verify the equality indexes of the \ attributes holding DNs manually with verify-index, and rebuild them with rebuild-index where it reports \ errors, to get a fully functional server diff --git a/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java index 3efbfde2db..bd4ffe9c89 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java @@ -19,7 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; import static org.opends.messages.ToolMessages.INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT; +import static org.opends.messages.ToolMessages.INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT; import static org.opends.messages.ToolMessages.WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED; +import static org.opends.server.util.StaticUtils.getFileForPath; import java.io.ByteArrayOutputStream; import java.io.File; @@ -67,6 +69,7 @@ public class DNEqualityIndexesUpgradeTestCase extends DirectoryServerTestCase private static final String BACKEND_ID = "dnEqualityIndexes"; private static final String BACKEND_DN = "ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config"; private static final String BASE_DN = "o=dn equality indexes"; + private static final Set MEMBER = Collections.singleton("member"); private static final String MY_MANAGER_AS_DN = "( 1.3.6.1.4.1.26027.1.999.1153 NAME 'myManager' SUP distinguishedName )"; private static final String MY_MANAGER_AS_STRING = "( 1.3.6.1.4.1.26027.1.999.1153 NAME 'myManager' " @@ -143,7 +146,7 @@ public void aLaterSchemaFileDecidesTheMatchingRule(String first, String last, bo backend("userRoot", "userRoot", "true"), index("userRoot", "myManager", "equality")); final File schemaDirectory = new File(directory, "schema"); - // Written in the reverse order, so that the order of the directory listing does not give the expected one + // The order of the directory listing depends on the file system, see schemaFilesAreReadInNameOrder writeSchema(schemaDirectory, "99-user.ldif", last); writeSchema(schemaDirectory, "10-first.ldif", first); @@ -166,6 +169,36 @@ public void aLaterSchemaFileDecidesTheMatchingRule(String first, String last, bo } } + /** + * The schema files are read in the order of their names whatever order the file system lists them in: with 26 + * files, a directory listing that happens to be sorted is unlikely. + */ + @Test + public void schemaFilesAreReadInNameOrder() throws Exception + { + final File directory = Files.createTempDirectory("dn-equality-indexes").toFile(); + try + { + for (char c = 'z'; c >= 'a'; c--) + { + assertThat(new File(directory, "50-" + c + ".ldif").createNewFile()).isTrue(); + } + assertThat(new File(directory, "50-not-a-schema-file.txt").createNewFile()).isTrue(); + + final List names = new ArrayList<>(); + for (final File file : UpgradeUtils.schemaFilesInReadOrder(directory)) + { + names.add(file.getName()); + } + + assertThat(names).hasSize(26).isSorted(); + } + finally + { + StaticUtils.recursiveDelete(directory); + } + } + /** Each base DN gets the attributes of its backend; a backend without known base DNs is left out. */ @Test public void eachBaseDNGetsTheIndexesOfItsBackend() @@ -255,6 +288,108 @@ private static String index(String backendRDNValue, String attribute, String... */ @Test public void rebuildsAnIndexOnlyWhenItMissesKeys() throws Exception + { + withAGroupWhoseMemberIndexMissesItsKeys("db_dn_equality_indexes", (configFile, out, output) -> { + final List rebuildNotifications = new ArrayList<>(); + UpgradeTasks.verifyAndRebuildOrWarn(newContext(rebuildNotifications), configFile, BASE_DN, MEMBER, false, out); + assertThat(messagesOf(rebuildNotifications)).as("output: %s", output) + .contains(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT.get("member", BASE_DN).toString()); + assertThat(UpgradeTasks.verifyAndRebuildIndexes(configFile, BASE_DN, MEMBER, false, out)) + .as("verification of the rebuilt index; output: %s", output) + .isEqualTo(UpgradeTasks.IndexVerification.CONSISTENT); + + final List notifications = new ArrayList<>(); + UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), configFile, BASE_DN, MEMBER, false, out); + assertThat(messagesOf(notifications)) + .contains(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT.get("member", BASE_DN).toString()) + .doesNotContain(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT.get("member", BASE_DN).toString()) + .doesNotContain(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get("member", BASE_DN).toString()); + }); + } + + /** + * A rebuild that fails, after the verification found missing keys, fails the upgrade: the rebuild may have left + * the indexes untrusted, so that searches cannot use them, and a warning would let the upgrade succeed. + */ + @Test + public void aRebuildThatFailsFailsTheUpgrade() throws Exception + { + withAGroupWhoseMemberIndexMissesItsKeys("db_dn_equality_indexes_failed_rebuild", (configFile, out, output) -> { + // A file where the rebuild creates its temporary directory makes it fail + final File tmpDirectory = getFileForPath("import-tmp"); + final File setAside = new File(tmpDirectory.getPath() + ".set-aside"); + final boolean existed = tmpDirectory.exists(); + if (existed) + { + assertThat(tmpDirectory.renameTo(setAside)).isTrue(); + } + try + { + assertThat(tmpDirectory.createNewFile()).isTrue(); + + final List notifications = new ArrayList<>(); + final Throwable failure = catchThrowable(() -> UpgradeTasks.verifyAndRebuildOrWarn( + newContext(notifications), configFile, BASE_DN, MEMBER, false, out)); + + assertThat(failure).as("output: %s", output).isInstanceOf(ClientException.class); + assertThat(output.toString("UTF-8")).as("the rebuild failed, not the verification") + .contains("An error occurs during the rebuild index process in " + BASE_DN); + assertThat(messagesOf(notifications)) + .doesNotContain(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get("member", BASE_DN).toString()); + } + finally + { + tmpDirectory.delete(); + if (existed) + { + assertThat(setAside.renameTo(tmpDirectory)).isTrue(); + } + } + }); + } + + /** + * Indexes that cannot be verified, as those of a JDBC or Cassandra backend whose database is down, are not + * rebuilt and do not fail the upgrade: the user is warned instead. + */ + @Test + public void indexesThatCannotBeVerifiedOnlyWarn() throws Exception + { + final String baseDN = "o=held by no backend"; + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final PrintStream out = new PrintStream(output, true, "UTF-8"); + final String configFile = DirectoryServer.getConfigFile(); + assertThat(UpgradeTasks.verifyAndRebuildIndexes(configFile, baseDN, MEMBER, false, out)) + .as("output: %s", output).isEqualTo(UpgradeTasks.IndexVerification.NOT_VERIFIED); + + final List notifications = new ArrayList<>(); + UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), configFile, baseDN, MEMBER, false, out); + + final String warning = WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get("member", baseDN).toString(); + assertThat(messagesOf(notifications)).contains(warning); + for (final TextOutputCallback notification : notifications) + { + if (notification.getMessage().equals(warning)) + { + assertThat(((FormattedNotificationCallback) notification).getMessageSubType()) + .isEqualTo(TextOutputCallback.WARNING); + } + } + } + + /** What a test runs against the backend of {@link #withAGroupWhoseMemberIndexMissesItsKeys}. */ + private interface IndexAction + { + void run(String configFile, PrintStream out, ByteArrayOutputStream output) throws Exception; + } + + /** + * Runs an action against a disabled PDB backend holding a group whose trusted member equality index misses the + * keys of its entries, then removes the backend. Each test gives its own database directory, since removing the + * backend leaves its database behind. + */ + private static void withAGroupWhoseMemberIndexMissesItsKeys(String dbDirectory, IndexAction action) + throws Exception { final Entry backend = TestCaseUtils.makeEntry( "dn: " + BACKEND_DN, @@ -268,7 +403,7 @@ public void rebuildsAnIndexOnlyWhenItMissesKeys() throws Exception "ds-cfg-backend-id: " + BACKEND_ID, "ds-cfg-writability-mode: enabled", "ds-cfg-base-dn: " + BASE_DN, - "ds-cfg-db-directory: db_dn_equality_indexes", + "ds-cfg-db-directory: " + dbDirectory, "ds-cfg-db-cache-percent: 2"); final Entry indexBranch = TestCaseUtils.makeEntry( "dn: cn=Index," + BACKEND_DN, @@ -306,20 +441,7 @@ public void rebuildsAnIndexOnlyWhenItMissesKeys() throws Exception setBackendEnabled(false); final ByteArrayOutputStream output = new ByteArrayOutputStream(); - final PrintStream out = new PrintStream(output, true, "UTF-8"); - final Set attributes = Collections.singleton("member"); - final String configFile = DirectoryServer.getConfigFile(); - - assertThat(UpgradeTasks.verifyAndRebuildIndexes(configFile, BASE_DN, attributes, false, out)) - .as("rebuilt an index missing keys; output: %s", output).isTrue(); - assertThat(UpgradeTasks.verifyAndRebuildIndexes(configFile, BASE_DN, attributes, false, out)) - .as("rebuilt an index matching its entries; output: %s", output).isFalse(); - - final List notifications = new ArrayList<>(); - UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), configFile, BASE_DN, attributes, false, out); - assertThat(messagesOf(notifications)) - .contains(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT.get("member", BASE_DN).toString()) - .doesNotContain(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get("member", BASE_DN).toString()); + action.run(DirectoryServer.getConfigFile(), new PrintStream(output, true, "UTF-8"), output); } finally { @@ -335,37 +457,6 @@ public void rebuildsAnIndexOnlyWhenItMissesKeys() throws Exception } } - /** - * Indexes that can be neither verified nor rebuilt, as those of a JDBC or Cassandra backend whose database is - * down, do not fail the upgrade: the user is warned instead. - */ - @Test - public void indexesThatCannotBeVerifiedOnlyWarn() throws Exception - { - final String baseDN = "o=held by no backend"; - final Set attributes = Collections.singleton("member"); - final ByteArrayOutputStream output = new ByteArrayOutputStream(); - final PrintStream out = new PrintStream(output, true, "UTF-8"); - final String configFile = DirectoryServer.getConfigFile(); - final Throwable failure = - catchThrowable(() -> UpgradeTasks.verifyAndRebuildIndexes(configFile, baseDN, attributes, false, out)); - assertThat(failure).as("output: %s", output).isInstanceOf(ClientException.class); - - final List notifications = new ArrayList<>(); - UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), configFile, baseDN, attributes, false, out); - - final String warning = WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get("member", baseDN).toString(); - assertThat(messagesOf(notifications)).contains(warning); - for (final TextOutputCallback notification : notifications) - { - if (notification.getMessage().equals(warning)) - { - assertThat(((FormattedNotificationCallback) notification).getMessageSubType()) - .isEqualTo(TextOutputCallback.WARNING); - } - } - } - /** Returns an upgrade context that records what it notifies. */ private static UpgradeContext newContext(final List notifications) throws Exception { From c726f100c3da0e693c76b32c42dda78ae0a40274 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Tue, 6 Oct 2026 20:13:05 +0300 Subject: [PATCH 5/5] [#1153] Name the DN indexes left unverified after a failed rebuild, pin a backend that fails to verify Review round 4 of #1171: - Upgrade: once the rebuild of the DN equality indexes of a base DN has failed, the indexes of the next base DNs are neither verified nor rebuilt: the cause of the failure, such as a full temporary directory, would most likely make their rebuild fail too, after it had deleted them. Each of them is named in a warning, then the upgrade fails as before. Before, the loop stopped at the failure and the next base DNs were not mentioned. The stream each base DN writes to is closed once it is done. - Upgrade chapter: says that the base DNs after a failed rebuild are named and left to the administrator. - Tests: DNEqualityIndexesUpgradeTestCase checks the warning for the base DN after a failed rebuild, and a backend whose verification throws (an index that it does not have): the indexes are reported as not verified and keep their missing keys. The rebuild test writes to a stream of its own after the rebuild closed the first one. The fixture's cleanup no longer fails when the backend does not take being disabled, so that it neither hides the failure of a test nor leaves the backend configured for the next tests. --- .../asciidoc/install-guide/chap-upgrade.adoc | 2 +- .../server/tools/upgrade/UpgradeTasks.java | 77 +++++++++- .../org/opends/messages/tool.properties | 3 + .../DNEqualityIndexesUpgradeTestCase.java | 136 +++++++++++++----- 4 files changed, 178 insertions(+), 40 deletions(-) diff --git a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc index 29061789fe..a8a038c5f3 100644 --- a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc +++ b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc @@ -305,7 +305,7 @@ Before 5.2.0, the server read a DN only up to the first `;`, up to the end of a . A value with DN syntax that a client wrote with `;`, such as `member: cn=a;dc=example,dc=com`, was indexed under the key of `cn=a`. It now reads as `cn=a,dc=example,dc=com`, so an indexed equality search for that DN misses the entry until the index is rebuilt. The same holds for a value that was stored while its syntax was not enforced, and that only now parses. + -The `upgrade` command therefore offers to verify the equality indexes of the attributes that hold DNs, such as `member`, `uniqueMember`, `owner` or `seeAlso`, in every enabled backend at the end of the upgrade, and rebuilds them under each base DN where they do not match the entries. The verification reads every entry of these backends, so on a large backend it takes a while; the indexes are only rebuilt where it finds a missing key. The default answer is yes, so `upgrade --no-prompt` performs the verification too, as do the Docker image and the native packages, which run `upgrade --no-prompt --force`. If you declined it, run `verify-index` on these indexes with the server stopped, and rebuild them where it reports errors, as described in xref:../admin-guide/chap-indexing.adoc#rebuild-index["Rebuilding Indexes"] in the __Administration Guide__. Do the same for a backend that the upgrade could not read, such as a JDBC or Cassandra backend whose database was unreachable: the upgrade then warns, names the indexes and the base DN, and goes on, leaving these indexes as they were. A rebuild that the upgrade started and that fails, for instance because the temporary directory is full, fails the upgrade, as the index rebuilds of other upgrade tasks do: such indexes may be left untrusted, so that searches cannot use them, until they are rebuilt. +The `upgrade` command therefore offers to verify the equality indexes of the attributes that hold DNs, such as `member`, `uniqueMember`, `owner` or `seeAlso`, in every enabled backend at the end of the upgrade, and rebuilds them under each base DN where they do not match the entries. The verification reads every entry of these backends, so on a large backend it takes a while; the indexes are only rebuilt where it finds a missing key. The default answer is yes, so `upgrade --no-prompt` performs the verification too, as do the Docker image and the native packages, which run `upgrade --no-prompt --force`. If you declined it, run `verify-index` on these indexes with the server stopped, and rebuild them where it reports errors, as described in xref:../admin-guide/chap-indexing.adoc#rebuild-index["Rebuilding Indexes"] in the __Administration Guide__. Do the same for a backend that the upgrade could not read, such as a JDBC or Cassandra backend whose database was unreachable: the upgrade then warns, names the indexes and the base DN, and goes on, leaving these indexes as they were. A rebuild that the upgrade started and that fails, for instance because the temporary directory is full, fails the upgrade, as the index rebuilds of other upgrade tasks do: such indexes may be left untrusted, so that searches cannot use them, until they are rebuilt. The upgrade then neither verifies nor rebuilds the indexes of the base DNs that come after, as the same cause would most likely make their rebuild fail too: it names them, and you verify them and rebuild them where needed once the cause is fixed. + A stored DN value with other content after an RDN, such as `cn="a"x,dc=example,dc=com`, no longer parses: replace it with the DN that was meant. diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java index 81281d0b39..5367bdd7db 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeTasks.java @@ -715,13 +715,9 @@ public void postUpgrade(final UpgradeContext context) throws ClientException { throw new ClientException(ReturnCode.ERROR_UNEXPECTED, ERR_UPGRADE_READING_CONF_FILE.get(e.getMessage()), e); } - final Map> indexesPerBaseDN = - getDNEqualityIndexesToVerify(attributesPerBackend, getBaseDNsPerBackendsFromConfig()); - for (final Map.Entry> baseDN : indexesPerBaseDN.entrySet()) - { - verifyAndRebuildOrWarn(context, configFile.getAbsolutePath(), baseDN.getKey(), baseDN.getValue(), true, - UpgradeLog.getPrintStream()); - } + verifyAndRebuildOrWarn(context, configFile.getAbsolutePath(), + getDNEqualityIndexesToVerify(attributesPerBackend, getBaseDNsPerBackendsFromConfig()), true, + UpgradeLog::getPrintStream); } @Override @@ -769,6 +765,73 @@ static Map> getDNEqualityIndexesToVerify(final Map> indexesPerBaseDN, final boolean initializeServer, final ToolOutput output) + throws ClientException + { + ClientException failedRebuild = null; + for (final Map.Entry> baseDN : indexesPerBaseDN.entrySet()) + { + if (failedRebuild != null) + { + context.notify(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_SKIPPED.get( + joinAsString(", ", baseDN.getValue()), baseDN.getKey()), WARNING); + continue; + } + final PrintStream out = output.open(); + try + { + verifyAndRebuildOrWarn(context, configFilePath, baseDN.getKey(), baseDN.getValue(), initializeServer, out); + } + catch (final ClientException e) + { + failedRebuild = e; + } + finally + { + // A rebuild closes it too: closing it again does nothing + close(out); + } + } + if (failedRebuild != null) + { + throw failedRebuild; + } + } + /** What {@link #verifyAndRebuildIndexes} found. */ enum IndexVerification { diff --git a/opendj-server-legacy/src/messages/org/opends/messages/tool.properties b/opendj-server-legacy/src/messages/org/opends/messages/tool.properties index 774de87aeb..9eb82ba9e9 100644 --- a/opendj-server-legacy/src/messages/org/opends/messages/tool.properties +++ b/opendj-server-legacy/src/messages/org/opends/messages/tool.properties @@ -2753,6 +2753,9 @@ INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT=Index(es) '%s' for base DN WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED=Index(es) '%s' for base DN '%s' could not be verified, \ see the upgrade log. They are left as they were, and the upgrade goes on: verify them manually with \ verify-index once the backend can be read, and rebuild them with rebuild-index where it reports errors +WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_SKIPPED=Index(es) '%s' for base DN '%s' were not verified, as \ + the rebuild of other indexes failed. Once the cause of that failure is fixed, verify them manually with \ + verify-index, and rebuild them with rebuild-index where it reports errors INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_DECLINED=You have to verify the equality indexes of the \ attributes holding DNs manually with verify-index, and rebuild them with rebuild-index where it reports \ errors, to get a fully functional server diff --git a/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java index bd4ffe9c89..517a1dcd33 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/tools/upgrade/DNEqualityIndexesUpgradeTestCase.java @@ -20,7 +20,9 @@ import static org.assertj.core.api.Assertions.catchThrowable; import static org.opends.messages.ToolMessages.INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT; import static org.opends.messages.ToolMessages.INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT; +import static org.opends.messages.ToolMessages.INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_STARTS; import static org.opends.messages.ToolMessages.WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED; +import static org.opends.messages.ToolMessages.WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_SKIPPED; import static org.opends.server.util.StaticUtils.getFileForPath; import java.io.ByteArrayOutputStream; @@ -35,6 +37,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; import javax.security.auth.callback.Callback; import javax.security.auth.callback.TextOutputCallback; @@ -51,6 +55,7 @@ import org.opends.server.backends.pluggable.IndexKeyRemover; import org.opends.server.core.DirectoryServer; import org.opends.server.core.ModifyOperation; +import org.opends.server.tools.VerifyIndex; import org.opends.server.types.Entry; import org.opends.server.util.StaticUtils; import org.testng.annotations.BeforeClass; @@ -294,12 +299,15 @@ public void rebuildsAnIndexOnlyWhenItMissesKeys() throws Exception UpgradeTasks.verifyAndRebuildOrWarn(newContext(rebuildNotifications), configFile, BASE_DN, MEMBER, false, out); assertThat(messagesOf(rebuildNotifications)).as("output: %s", output) .contains(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT.get("member", BASE_DN).toString()); - assertThat(UpgradeTasks.verifyAndRebuildIndexes(configFile, BASE_DN, MEMBER, false, out)) + // The rebuild closed `out`: the calls after it write to a stream of their own + final PrintStream afterRebuild = new PrintStream(output, true, UTF_8); + assertThat(UpgradeTasks.verifyAndRebuildIndexes(configFile, BASE_DN, MEMBER, false, afterRebuild)) .as("verification of the rebuilt index; output: %s", output) .isEqualTo(UpgradeTasks.IndexVerification.CONSISTENT); final List notifications = new ArrayList<>(); - UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), configFile, BASE_DN, MEMBER, false, out); + UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), configFile, BASE_DN, MEMBER, false, + afterRebuild); assertThat(messagesOf(notifications)) .contains(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_CONSISTENT.get("member", BASE_DN).toString()) .doesNotContain(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_INCONSISTENT.get("member", BASE_DN).toString()) @@ -314,19 +322,8 @@ public void rebuildsAnIndexOnlyWhenItMissesKeys() throws Exception @Test public void aRebuildThatFailsFailsTheUpgrade() throws Exception { - withAGroupWhoseMemberIndexMissesItsKeys("db_dn_equality_indexes_failed_rebuild", (configFile, out, output) -> { - // A file where the rebuild creates its temporary directory makes it fail - final File tmpDirectory = getFileForPath("import-tmp"); - final File setAside = new File(tmpDirectory.getPath() + ".set-aside"); - final boolean existed = tmpDirectory.exists(); - if (existed) - { - assertThat(tmpDirectory.renameTo(setAside)).isTrue(); - } - try - { - assertThat(tmpDirectory.createNewFile()).isTrue(); - + withAGroupWhoseMemberIndexMissesItsKeys("db_dn_equality_indexes_failed_rebuild", (configFile, out, output) -> + withTheRebuildFailing(() -> { final List notifications = new ArrayList<>(); final Throwable failure = catchThrowable(() -> UpgradeTasks.verifyAndRebuildOrWarn( newContext(notifications), configFile, BASE_DN, MEMBER, false, out)); @@ -336,21 +333,40 @@ public void aRebuildThatFailsFailsTheUpgrade() throws Exception .contains("An error occurs during the rebuild index process in " + BASE_DN); assertThat(messagesOf(notifications)) .doesNotContain(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get("member", BASE_DN).toString()); - } - finally - { - tmpDirectory.delete(); - if (existed) - { - assertThat(setAside.renameTo(tmpDirectory)).isTrue(); - } - } - }); + })); } /** - * Indexes that cannot be verified, as those of a JDBC or Cassandra backend whose database is down, are not - * rebuilt and do not fail the upgrade: the user is warned instead. + * After a rebuild that failed, the indexes of the next base DNs are neither verified nor rebuilt, since the cause + * of the failure, such as a full temporary directory, would most likely make their rebuild fail too, after it had + * deleted them: the user is told which indexes were left, and the upgrade fails. + */ + @Test + public void aFailedRebuildLeavesTheIndexesOfTheNextBaseDNsUnverified() throws Exception + { + withAGroupWhoseMemberIndexMissesItsKeys("db_dn_equality_indexes_next_base_dns", (configFile, out, output) -> + withTheRebuildFailing(() -> { + final String nextBaseDN = "o=held by no backend"; + final Map> indexesPerBaseDN = new TreeMap<>(); + indexesPerBaseDN.put(nextBaseDN, MEMBER); + indexesPerBaseDN.put(BASE_DN, MEMBER); + assertThat(indexesPerBaseDN.keySet()).as("the rebuild fails first").containsExactly(BASE_DN, nextBaseDN); + + final List notifications = new ArrayList<>(); + final Throwable failure = catchThrowable(() -> UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), + configFile, indexesPerBaseDN, false, () -> new PrintStream(output, true, UTF_8))); + + assertThat(failure).as("output: %s", output).isInstanceOf(ClientException.class); + assertThat(messagesOf(notifications)) + .contains(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_SKIPPED.get("member", nextBaseDN).toString()) + .doesNotContain(INFO_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_STARTS.get("member", nextBaseDN).toString()) + .doesNotContain(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_SKIPPED.get("member", BASE_DN).toString()); + })); + } + + /** + * Indexes under a base DN that no backend holds cannot be verified: they are not rebuilt, and do not fail the + * upgrade, the user is warned instead. */ @Test public void indexesThatCannotBeVerifiedOnlyWarn() throws Exception @@ -377,12 +393,63 @@ public void indexesThatCannotBeVerifiedOnlyWarn() throws Exception } } + /** + * Indexes that their backend fails to verify, as a JDBC or Cassandra backend whose database is down does, are + * left as they were, and the user is warned. Here the backend fails on an index that it does not have. + */ + @Test + public void indexesThatTheBackendFailsToVerifyAreLeftAsTheyWere() throws Exception + { + withAGroupWhoseMemberIndexMissesItsKeys("db_dn_equality_indexes_failed_verify", (configFile, out, output) -> { + final Set attributes = new TreeSet<>(Arrays.asList("member", "uniqueMember")); + final List notifications = new ArrayList<>(); + UpgradeTasks.verifyAndRebuildOrWarn(newContext(notifications), configFile, BASE_DN, attributes, false, out); + + assertThat(messagesOf(notifications)).as("output: %s", output) + .contains(WARN_UPGRADE_VERIFY_DN_EQUALITY_INDEXES_FAILED.get("member, uniqueMember", BASE_DN).toString()); + final String[] verifyMember = { "--configFile", configFile, "--baseDN", BASE_DN, "--index", "member" }; + assertThat(VerifyIndex.countIndexErrors(verifyMember, false, out)) + .as("the member index still misses its keys; output: %s", output).isPositive(); + }); + } + /** What a test runs against the backend of {@link #withAGroupWhoseMemberIndexMissesItsKeys}. */ private interface IndexAction { void run(String configFile, PrintStream out, ByteArrayOutputStream output) throws Exception; } + /** What a test runs while {@link #withTheRebuildFailing} makes the rebuilds fail. */ + private interface RebuildAction + { + void run() throws Exception; + } + + /** Runs an action while a file where the rebuild creates its temporary directory makes every rebuild fail. */ + private static void withTheRebuildFailing(RebuildAction action) throws Exception + { + final File tmpDirectory = getFileForPath("import-tmp"); + final File setAside = new File(tmpDirectory.getPath() + ".set-aside"); + final boolean existed = tmpDirectory.exists(); + if (existed) + { + assertThat(tmpDirectory.renameTo(setAside)).isTrue(); + } + try + { + assertThat(tmpDirectory.createNewFile()).isTrue(); + action.run(); + } + finally + { + tmpDirectory.delete(); + if (existed) + { + assertThat(setAside.renameTo(tmpDirectory)).isTrue(); + } + } + } + /** * Runs an action against a disabled PDB backend holding a group whose trusted member equality index misses the * keys of its entries, then removes the backend. Each test gives its own database directory, since removing the @@ -445,8 +512,9 @@ private static void withAGroupWhoseMemberIndexMissesItsKeys(String dbDirectory, } finally { - setBackendEnabled(false); - // A failed setup must not be hidden by the cleanup, nor leave the backend configured for the next tests + // A failed setup or test must not be hidden by the cleanup, nor leave the backend configured for the next + // tests: the backend may not take the change, as when a tool run in this process left it half initialized + getRootConnection().processModify(enableBackend(false)); for (final DN dn : Arrays.asList(memberIndex.getName(), indexBranch.getName(), backend.getName())) { if (DirectoryServer.entryExists(dn)) @@ -483,9 +551,13 @@ private static List messagesOf(final List notificati private static void setBackendEnabled(boolean enabled) { - final ModifyRequest modifyRequest = Requests.newModifyRequest(BACKEND_DN) - .addModification(ModificationType.REPLACE, "ds-cfg-enabled", Boolean.toString(enabled)); - final ModifyOperation modifyOperation = getRootConnection().processModify(modifyRequest); + final ModifyOperation modifyOperation = getRootConnection().processModify(enableBackend(enabled)); assertThat(modifyOperation.getResultCode()).isEqualTo(ResultCode.SUCCESS); } + + private static ModifyRequest enableBackend(boolean enabled) + { + return Requests.newModifyRequest(BACKEND_DN) + .addModification(ModificationType.REPLACE, "ds-cfg-enabled", Boolean.toString(enabled)); + } }