/* * 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.api; import static org.opends.messages.CoreMessages.*; import static org.testng.Assert.*; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.opendj.ldap.ByteString; import org.forgerock.opendj.ldap.ByteStringBuilder; import org.forgerock.opendj.ldap.ByteSequenceReader; import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.ldap.schema.ObjectClass; import org.opends.server.TestCaseUtils; import org.opends.server.core.DirectoryServer; import org.opends.server.types.Attribute; import org.opends.server.types.Attributes; import org.opends.server.types.DirectoryException; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; /** * Tests that a compressed schema never hands out a token whose definition was not persisted, and * that a token it holds no definition for is reported rather than let out of the decode path as an * unchecked exception. */ @SuppressWarnings("javadoc") public class CompressedSchemaTestCase extends APITestCase { /** A compressed schema whose store can be made to fail, recording what it did persist. */ private static final class TestCompressedSchema extends CompressedSchema { private final Map storedAttributes = new LinkedHashMap<>(); private final Map> storedObjectClasses = new LinkedHashMap<>(); private int attributeStoreCount; private int objectClassStoreCount; private boolean failStore; /** Counted down when a store is entered, when the store is gated. */ private CountDownLatch enteredStore; /** Awaited by a gated store, which holds the exclusive lock while it waits. */ private CountDownLatch leaveStore; private TestCompressedSchema() { super(DirectoryServer.getInstance().getServerContext()); } @Override protected void storeAttribute(final byte[] encodedAttribute, final String attributeName, final Iterable attributeOptions) throws DirectoryException { attributeStoreCount++; awaitIfGated(); failIfRequested(); storedAttributes.put(token(encodedAttribute), attributeName); } @Override protected void storeObjectClasses(final byte[] encodedObjectClasses, final Collection objectClassNames) throws DirectoryException { objectClassStoreCount++; awaitIfGated(); failIfRequested(); storedObjectClasses.put(token(encodedObjectClasses), new ArrayList<>(objectClassNames)); } private void failIfRequested() throws DirectoryException { if (failStore) { throw new DirectoryException(ResultCode.OTHER, LocalizableMessage.raw("the store failed")); } } private void awaitIfGated() throws DirectoryException { if (enteredStore == null) { return; } enteredStore.countDown(); try { if (!leaveStore.await(30, TimeUnit.SECONDS)) { throw new DirectoryException(ResultCode.OTHER, LocalizableMessage.raw("the gated store timed out")); } } catch (final InterruptedException e) { Thread.currentThread().interrupt(); throw new DirectoryException(ResultCode.OTHER, LocalizableMessage.raw("the gated store was interrupted"), e); } } /** Loads a definition under the provided token, as an implementation does at startup. */ private void loadAttributeAt(final int id, final String attributeName) { loadAttribute(encodedToken(id), attributeName, Collections. emptySet()); } private void loadObjectClassesAt(final int id, final Collection objectClassNames) { loadObjectClasses(encodedToken(id), objectClassNames); } /** The tokens the whole content would be saved under, as DefaultCompressedSchema saves it. */ private List savedAttributeTokens() { final List tokens = new ArrayList<>(); for (final Entry>> attribute : getAllAttributes()) { tokens.add(token(attribute.getKey())); } return tokens; } private List savedObjectClassTokens() { final List tokens = new ArrayList<>(); for (final Entry> objectClasses : getAllObjectClasses()) { tokens.add(token(objectClasses.getKey())); } return tokens; } } @BeforeClass public void setUp() throws Exception { TestCaseUtils.startServer(); } /** * A registration whose store failed must be withdrawn: the next encode of the same attribute has * to allocate and store the token again, rather than take the lock-free fast path and write an * entry carrying a token whose definition is nowhere. */ @Test public void attributeTokenIsWithdrawnWhenItCannotBeStored() throws Exception { final TestCompressedSchema compressedSchema = new TestCompressedSchema(); final Attribute attribute = Attributes.create("description", "a value"); compressedSchema.failStore = true; try { compressedSchema.encodeAttribute(new ByteStringBuilder(), attribute); fail("the encode should have failed with the store"); } catch (final DirectoryException expected) { // The operation fails, which is what the caller is told. } assertEquals(compressedSchema.attributeStoreCount, 1); assertTrue(compressedSchema.storedAttributes.isEmpty(), "nothing was persisted"); compressedSchema.failStore = false; final ByteStringBuilder builder = new ByteStringBuilder(); compressedSchema.encodeAttribute(builder, attribute); assertEquals(compressedSchema.attributeStoreCount, 2, "the failed registration was left behind"); final int encodedToken = tokenOf(builder.toByteString()); assertEquals(encodedToken, 0, "the withdrawn id was not allocated again"); assertTrue(compressedSchema.storedAttributes.containsKey(encodedToken), "the entry carries token " + encodedToken + ", which was never stored"); // What the withdrawal exists for: an element left behind by the failed registration would be // saved here under a token whose store never returned. Asserting the store count and the token // of the retry is not enough on its own - a registration that leaks its decode map element // simply allocates the next id, and every other assertion of this test still holds. assertEquals(compressedSchema.savedAttributeTokens(), Collections.singletonList(0), "the whole content still holds the element of the failed registration"); final Attribute decoded = compressedSchema.decodeAttribute(builder.toByteString().asReader()); assertEquals(decoded.getAttributeDescription(), attribute.getAttributeDescription()); assertEquals(decoded.iterator().next().toString(), "a value"); } /** The same for an object class set. */ @Test public void objectClassTokenIsWithdrawnWhenItCannotBeStored() throws Exception { final TestCompressedSchema compressedSchema = new TestCompressedSchema(); final Map objectClasses = objectClasses("top", "person"); compressedSchema.failStore = true; try { compressedSchema.encodeObjectClasses(new ByteStringBuilder(), objectClasses); fail("the encode should have failed with the store"); } catch (final DirectoryException expected) { // The operation fails, which is what the caller is told. } assertEquals(compressedSchema.objectClassStoreCount, 1); assertTrue(compressedSchema.storedObjectClasses.isEmpty(), "nothing was persisted"); compressedSchema.failStore = false; final ByteStringBuilder builder = new ByteStringBuilder(); compressedSchema.encodeObjectClasses(builder, objectClasses); assertEquals(compressedSchema.objectClassStoreCount, 2, "the failed registration was left behind"); final int encodedToken = tokenOf(builder.toByteString()); assertEquals(encodedToken, 0, "the withdrawn id was not allocated again"); assertTrue(compressedSchema.storedObjectClasses.containsKey(encodedToken), "the entry carries token " + encodedToken + ", which was never stored"); // As in attributeTokenIsWithdrawnWhenItCannotBeStored(). assertEquals(compressedSchema.savedObjectClassTokens(), Collections.singletonList(0), "the whole content still holds the element of the failed registration"); assertEquals(compressedSchema.decodeObjectClasses(builder.toByteString().asReader()), objectClasses); } /** * The withdrawal has to take the element the failed registration appended, and only that one. * On a decode map holding a single element every removal looks alike - index 0 is also the last * index, and the sole element is also the one that was appended - so a withdrawal taking the * wrong element is only visible once something was registered before the one that fails. *

* What it costs is this defect from the other end: removing an element the registration did not * append shifts the ids of everything after it, and the entries already written carry them. */ @Test public void theAttributeWithdrawalTakesTheElementItAppended() throws Exception { final TestCompressedSchema compressedSchema = new TestCompressedSchema(); final Attribute first = Attributes.create("description", "a value"); final Attribute second = Attributes.create("cn", "a value"); compressedSchema.encodeAttribute(new ByteStringBuilder(), first); compressedSchema.failStore = true; try { compressedSchema.encodeAttribute(new ByteStringBuilder(), second); fail("the encode should have failed with the store"); } catch (final DirectoryException expected) { // The operation fails, which is what the caller is told. } compressedSchema.failStore = false; final ByteStringBuilder builder = new ByteStringBuilder(); compressedSchema.encodeAttribute(builder, second); assertEquals(compressedSchema.attributeStoreCount, 3, "the failed registration was left behind"); assertEquals(tokenOf(builder.toByteString()), 1, "the withdrawn id was not allocated again"); assertEquals(compressedSchema.savedAttributeTokens(), Arrays.asList(0, 1), "the whole content does not span the ids that were registered"); // The tokens alone do not separate a withdrawal of the last element from one of the first: // both leave two elements behind, under the tokens 0 and 1. What they decode to does. assertEquals(attributeNameAt(compressedSchema, 0), "description", "the id registered before the failure decodes as another attribute"); assertEquals(attributeNameAt(compressedSchema, 1), "cn"); } /** The same for an object class set. */ @Test public void theObjectClassWithdrawalTakesTheElementItAppended() throws Exception { final TestCompressedSchema compressedSchema = new TestCompressedSchema(); final Map first = objectClasses("top", "person"); final Map second = objectClasses("top", "organizationalUnit"); compressedSchema.encodeObjectClasses(new ByteStringBuilder(), first); compressedSchema.failStore = true; try { compressedSchema.encodeObjectClasses(new ByteStringBuilder(), second); fail("the encode should have failed with the store"); } catch (final DirectoryException expected) { // The operation fails, which is what the caller is told. } compressedSchema.failStore = false; final ByteStringBuilder builder = new ByteStringBuilder(); compressedSchema.encodeObjectClasses(builder, second); assertEquals(compressedSchema.objectClassStoreCount, 3, "the failed registration was left behind"); assertEquals(tokenOf(builder.toByteString()), 1, "the withdrawn id was not allocated again"); assertEquals(compressedSchema.savedObjectClassTokens(), Arrays.asList(0, 1), "the whole content does not span the ids that were registered"); // As in theAttributeWithdrawalTakesTheElementItAppended(). assertEquals(objectClassesAt(compressedSchema, 0), first, "the id registered before the failure decodes as another object class set"); assertEquals(objectClassesAt(compressedSchema, 1), second); } /** * The id of a registration reaches the encode map - the lock-free path an encode takes to it - * only once the definition is persisted, so that no other thread can write an entry carrying an * id that a failing store is about to withdraw. */ @Test public void aTokenIsPublishedOnlyOnceItIsStored() throws Exception { final TestCompressedSchema compressedSchema = new TestCompressedSchema(); final Attribute attribute = Attributes.create("description", "a value"); final ExecutorService executor = Executors.newFixedThreadPool(2); try { // One encode is held inside the store of the registration it made, holding the exclusive lock. compressedSchema.enteredStore = new CountDownLatch(1); compressedSchema.leaveStore = new CountDownLatch(1); final Future registering = executor.submit(encoding(compressedSchema, attribute, null)); assertTrue(compressedSchema.enteredStore.await(30, TimeUnit.SECONDS), "the store was never reached"); // Another encode of the same attribute must not be handed the id being stored: it has to // park on the exclusive lock until the store returns. final AtomicReference concurrentThread = new AtomicReference<>(); final Future concurrent = executor.submit(encoding(compressedSchema, attribute, concurrentThread)); awaitParkedOnTheLock(concurrent, concurrentThread); assertFalse(concurrent.isDone(), "the token was handed out before it was stored"); compressedSchema.leaveStore.countDown(); assertEquals(registering.get(30, TimeUnit.SECONDS), Integer.valueOf(0)); assertEquals(concurrent.get(30, TimeUnit.SECONDS), Integer.valueOf(0)); assertEquals(compressedSchema.attributeStoreCount, 1, "the same token was stored twice"); } finally { executor.shutdownNow(); } } /** The same for an object class set, whose registration orders the two maps the same way. */ @Test public void anObjectClassTokenIsPublishedOnlyOnceItIsStored() throws Exception { final TestCompressedSchema compressedSchema = new TestCompressedSchema(); final Map objectClasses = objectClasses("top", "person"); final ExecutorService executor = Executors.newFixedThreadPool(2); try { compressedSchema.enteredStore = new CountDownLatch(1); compressedSchema.leaveStore = new CountDownLatch(1); final Future registering = executor.submit(encoding(compressedSchema, objectClasses, null)); assertTrue(compressedSchema.enteredStore.await(30, TimeUnit.SECONDS), "the store was never reached"); final AtomicReference concurrentThread = new AtomicReference<>(); final Future concurrent = executor.submit(encoding(compressedSchema, objectClasses, concurrentThread)); awaitParkedOnTheLock(concurrent, concurrentThread); assertFalse(concurrent.isDone(), "the token was handed out before it was stored"); compressedSchema.leaveStore.countDown(); assertEquals(registering.get(30, TimeUnit.SECONDS), Integer.valueOf(0)); assertEquals(concurrent.get(30, TimeUnit.SECONDS), Integer.valueOf(0)); assertEquals(compressedSchema.objectClassStoreCount, 1, "the same token was stored twice"); } finally { executor.shutdownNow(); } } /** * Waits for the provided encode to park on the exclusive lock, which is what it must do while * another thread holds that lock inside a store. Waiting for the thread to park is what makes * this prove the encode reached the lock-free read of the encode map: a latch counted down * inside the task only proves the task body started, so a build that published an id before * storing it would be recorded as a pass whenever the thread was slow between the two. *

* Where the thread is parked is checked as well as that it is parked. A state on its own says * nothing about what the thread waits for, and a build handing out an id before storing it * parks nowhere: it takes the lock-free path, completes, and leaves the whole discrimination to * a non-atomic isDone() sample - so any unrelated park, sampled in the instant before the task * publishes its completion, would record that build as a pass. */ private static void awaitParkedOnTheLock(final Future encode, final AtomicReference runningOn) throws Exception { final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); while (System.nanoTime() < deadline) { if (encode.isDone()) { fail("the token was handed out before it was stored: " + encode.get()); } final Thread thread = runningOn.get(); // WAITING alone: the exclusive lock is a ReentrantLock, which parks through LockSupport, so // this is the state it puts a thread in. BLOCKED is monitor entry and cannot come from that // lock at all - accepting it would admit only parks this test is not about. if (thread != null && thread.getState() == Thread.State.WAITING && parkedOnTheLockOfAnId(thread)) { return; } Thread.sleep(1); } fail("the concurrent encode never parked on the exclusive lock"); } /** * Returns whether the provided thread is parked on a lock taken on the way to an id of a * compressed schema, rather than anywhere else - the executor parking its idle worker on the * task queue is a park too, and so is a logger or a class initializer. */ private static boolean parkedOnTheLockOfAnId(final Thread thread) { boolean parkedOnALock = false; // The frames run from the park outwards, so the lock is seen before whoever is taking it. for (final StackTraceElement frame : thread.getStackTrace()) { if (frame.getClassName().startsWith("java.util.concurrent.locks.")) { parkedOnALock = true; } else if (parkedOnALock && CompressedSchema.class.getName().equals(frame.getClassName())) { return "getAttributeId".equals(frame.getMethodName()) || "getObjectClassId".equals(frame.getMethodName()); } } return false; } private static Callable encoding(final CompressedSchema compressedSchema, final Attribute attribute, final AtomicReference runningOn) { return new Callable() { @Override public Integer call() throws Exception { if (runningOn != null) { runningOn.set(Thread.currentThread()); } final ByteStringBuilder builder = new ByteStringBuilder(); compressedSchema.encodeAttribute(builder, attribute); return tokenOf(builder.toByteString()); } }; } private static Callable encoding(final CompressedSchema compressedSchema, final Map objectClasses, final AtomicReference runningOn) { return new Callable() { @Override public Integer call() throws Exception { if (runningOn != null) { runningOn.set(Thread.currentThread()); } final ByteStringBuilder builder = new ByteStringBuilder(); compressedSchema.encodeObjectClasses(builder, objectClasses); return tokenOf(builder.toByteString()); } }; } /** * A token with no definition is reported, whether it is below the range of the decode map, the * first id past its end, or well beyond it - and against a populated map as well as an empty * one, since it is the size of that map the lookup is measured against. */ @Test public void unknownAttributeTokenIsReported() throws Exception { final TestCompressedSchema empty = new TestCompressedSchema(); for (final int unknownToken : new int[] { -1, 0, 7 }) { assertAttributeTokenIsReported(empty, unknownToken); } final TestCompressedSchema populated = new TestCompressedSchema(); populated.loadAttributeAt(0, "description"); populated.loadAttributeAt(1, "cn"); for (final int unknownToken : new int[] { -1, 2, 7 }) { assertAttributeTokenIsReported(populated, unknownToken); } } @Test public void unknownObjectClassTokenIsReported() throws Exception { final TestCompressedSchema empty = new TestCompressedSchema(); for (final int unknownToken : new int[] { -1, 0, 7 }) { assertObjectClassTokenIsReported(empty, unknownToken); } final TestCompressedSchema populated = new TestCompressedSchema(); populated.loadObjectClassesAt(0, Arrays.asList("top", "person")); populated.loadObjectClassesAt(1, Arrays.asList("top", "organizationalUnit")); for (final int unknownToken : new int[] { -1, 2, 7 }) { assertObjectClassTokenIsReported(populated, unknownToken); } } /** * A record the token cannot even be read from - it ends inside the token, or the length of the * token names more bytes than the record holds - is reported like a token no definition was * stored for. The read is what precedes the lookup, so leaving it unguarded would let the * decode path of a {@code PublicAPI} class raise NegativeArraySizeException or * IndexOutOfBoundsException at a caller written for DirectoryException. */ @Test public void aTokenThatCannotBeReadIsReported() throws Exception { final TestCompressedSchema compressedSchema = new TestCompressedSchema(); for (final ByteString unreadable : unreadableTokens()) { try { compressedSchema.decodeAttribute(unreadable.asReader()); fail("the token of " + unreadable + " cannot be read and should have been reported"); } catch (final DirectoryException expected) { assertMessageIs(expected, ERR_COMPRESSEDSCHEMA_UNREADABLE_AD_TOKEN.get(unreadable), "the unreadable token " + unreadable); } try { compressedSchema.decodeObjectClasses(unreadable.asReader()); fail("the token of " + unreadable + " cannot be read and should have been reported"); } catch (final DirectoryException expected) { assertMessageIs(expected, ERR_COMPRESSEDSCHEMA_UNREADABLE_OC_TOKEN.get(unreadable), "the unreadable token " + unreadable); } } } /** * A token padded past the four bytes an id is ever encoded in decodes to the same id as the * canonical token of that id, because the decode folds whatever it is handed. A record carrying * one would therefore read as a live definition rather than be reported, which is the one shape * of a corrupt token that answers with data instead of an error. */ @Test public void anOverlongTokenIsNotDecodedAsTheIdItPadsTo() throws Exception { final TestCompressedSchema compressedSchema = new TestCompressedSchema(); compressedSchema.loadAttributeAt(2, "description"); compressedSchema.loadObjectClassesAt(2, Arrays.asList("top", "person")); // The canonical token of the id 2 is 0x03, and this is that value padded to five bytes. final byte[] padded = new byte[] { 0x00, 0x00, 0x00, 0x00, 0x03 }; try { compressedSchema.decodeAttribute(recordWithToken(padded, true).asReader()); fail("the token is longer than an id is ever encoded in and should have been reported"); } catch (final DirectoryException expected) { assertMessageIs(expected, ERR_COMPRESSEDSCHEMA_UNREADABLE_AD_TOKEN.get("padded"), "the overlong token"); } try { compressedSchema.decodeObjectClasses(recordWithToken(padded, false).asReader()); fail("the token is longer than an id is ever encoded in and should have been reported"); } catch (final DirectoryException expected) { assertMessageIs(expected, ERR_COMPRESSEDSCHEMA_UNREADABLE_OC_TOKEN.get("padded"), "the overlong token"); } } /** A record carrying the provided token, with a single value where an attribute is asked for. */ private static ByteString recordWithToken(final byte[] idBytes, final boolean withAValue) { final ByteStringBuilder builder = new ByteStringBuilder(); builder.appendBERLength(idBytes.length); builder.appendBytes(idBytes); if (withAValue) { builder.appendBERLength(1); builder.appendBERLength(1); builder.appendBytes(new byte[] { 'x' }); } return builder.toByteString(); } /** Records a decode path cannot read a token from, as a corrupt or truncated store holds them. */ private static List unreadableTokens() { return Arrays.asList( // The record ends before the length of the token. ByteString.empty(), // The length names one byte the record does not hold. ByteString.wrap(new byte[] { 0x01 }), // A four byte length composing to 0xFFFFFFFF, which is -1 as an int. ByteString.wrap(new byte[] { (byte) 0x84, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF }), // A four byte length of 0x7FFFFFFF: two gigabytes, which must not be allocated to find // out that the record does not hold them. ByteString.wrap(new byte[] { (byte) 0x84, 0x7F, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF })); } private static void assertAttributeTokenIsReported(final TestCompressedSchema compressedSchema, final int unknownToken) throws Exception { try { compressedSchema.decodeAttribute(encodedAttribute(unknownToken).asReader()); fail("the token " + unknownToken + " has no definition and should have been reported"); } catch (final DirectoryException expected) { // Reported as the unknown token it is, and named as such: nothing else in this decode path // is allowed to answer for a token, and the message the operator gets is what says which. assertMessageIs(expected, ERR_COMPRESSEDSCHEMA_UNRECOGNIZED_AD_TOKEN.get(unknownToken), "the token " + unknownToken); assertTokenIsNamed(expected, unknownToken); } } private static void assertObjectClassTokenIsReported(final TestCompressedSchema compressedSchema, final int unknownToken) throws Exception { try { compressedSchema.decodeObjectClasses(encodedToken(unknownToken, new ByteStringBuilder()).asReader()); fail("the token " + unknownToken + " has no definition and should have been reported"); } catch (final DirectoryException expected) { assertMessageIs(expected, ERR_COMPRESSEDSCHEMA_UNKNOWN_OC_TOKEN.get(unknownToken), "the token " + unknownToken); assertTokenIsNamed(expected, unknownToken); } } /** Asserts that the exception carries the expected message, by resource and id rather than text. */ private static void assertMessageIs(final DirectoryException reported, final LocalizableMessage expected, final String context) { final LocalizableMessage message = reported.getMessageObject(); assertEquals(message.resourceName() + "-" + message.ordinal(), expected.resourceName() + "-" + expected.ordinal(), context + " was reported as something else: " + message); } /** * Asserts that the message names the token the way an operator has to read it: the key the * storage holds, with the id it decodes to. Asserted on the text, because comparing two * messages by resource and ordinal says nothing about the arguments they carry - the accessors * take an Object, so the id this test passes and the rendering production passes do not differ * at compile time either, and a rendering returning an empty string would keep the suite green. */ private static void assertTokenIsNamed(final DirectoryException reported, final int unknownToken) { final String named = String.format("0x%02X (id %d)", (unknownToken + 1) & 0xFF, unknownToken); final LocalizableMessage message = reported.getMessageObject(); assertTrue(message.toString().contains(named), "the token is not named as the storage holds it, expected " + named + " in: " + message); } /** * A compressed schema loaded from a storage that holds no definition for some of the tokens * carries a gap. Decoding across the gap, reloading the maps for a changed schema and saving the * whole content must all walk over it, and the ids around it must not shift - the entries already * written carry them. */ @Test public void aGapInTheDecodeMapsIsCarriedRatherThanDereferenced() throws Exception { final TestCompressedSchema compressedSchema = new TestCompressedSchema(); compressedSchema.loadAttributeAt(2, "description"); compressedSchema.loadObjectClassesAt(2, Arrays.asList("top", "person")); // The first decode also rebuilds the maps for the current schema, which is what used to walk // into the gap with no null check. try { compressedSchema.decodeAttribute(encodedAttribute(0).asReader()); fail("the token 0 falls in the gap and should have been reported"); } catch (final DirectoryException expected) { // Reported as the unknown token it is. } try { compressedSchema.decodeObjectClasses(encodedToken(1, new ByteStringBuilder()).asReader()); fail("the token 1 falls in the gap and should have been reported"); } catch (final DirectoryException expected) { // Reported as the unknown token it is. } // What is around the gap is still reachable under the ids it was loaded with. assertEquals(compressedSchema.decodeAttribute(encodedAttribute(2).asReader()) .getAttributeDescription().getAttributeType().getNameOrOID(), "description"); assertEquals(compressedSchema.decodeObjectClasses(encodedToken(2, new ByteStringBuilder()).asReader()), objectClasses("top", "person")); // And the next registration allocates the id after the gap, not one inside it. final ByteStringBuilder attributeBuilder = new ByteStringBuilder(); compressedSchema.encodeAttribute(attributeBuilder, Attributes.create("cn", "a value")); assertEquals(tokenOf(attributeBuilder.toByteString()), 3); final ByteStringBuilder objectClassesBuilder = new ByteStringBuilder(); compressedSchema.encodeObjectClasses(objectClassesBuilder, objectClasses("top", "organizationalUnit")); assertEquals(tokenOf(objectClassesBuilder.toByteString()), 3); // The whole content is still saveable, which is how DefaultCompressedSchema persists a store. assertEquals(compressedSchema.savedAttributeTokens(), Arrays.asList(2, 3)); assertEquals(compressedSchema.savedObjectClassTokens(), Arrays.asList(2, 3)); } /** The attribute the provided token decodes to, named as the schema names it. */ private static String attributeNameAt(final TestCompressedSchema compressedSchema, final int id) throws Exception { return compressedSchema.decodeAttribute(encodedAttribute(id).asReader()) .getAttributeDescription().getAttributeType().getNameOrOID(); } /** The object class set the provided token decodes to. */ private static Map objectClassesAt(final TestCompressedSchema compressedSchema, final int id) throws Exception { return compressedSchema.decodeObjectClasses(encodedToken(id, new ByteStringBuilder()).asReader()); } private static Map objectClasses(final String... names) { final Map objectClasses = new LinkedHashMap<>(names.length); for (final String name : names) { objectClasses.put(DirectoryServer.getInstance().getServerContext().getSchema().getObjectClass(name), name); } return objectClasses; } /** Encodes an attribute holding a single value under the provided token. */ private static ByteString encodedAttribute(final int id) { final ByteStringBuilder builder = new ByteStringBuilder(); encodedToken(id, builder); builder.appendBERLength(1); builder.appendBERLength(1); builder.appendBytes(new byte[] { 'x' }); return builder.toByteString(); } private static ByteString encodedToken(final int id, final ByteStringBuilder builder) { final byte[] idBytes = encodedToken(id); builder.appendBERLength(idBytes.length); builder.appendBytes(idBytes); return builder.toByteString(); } /** Encodes a token the way CompressedSchema does, one byte being enough for the tests. */ private static byte[] encodedToken(final int id) { return new byte[] { (byte) ((id + 1) & 0xFF) }; } /** Decodes a token the way CompressedSchema does. */ private static int token(final byte[] idBytes) { int id = 0; for (final byte b : idBytes) { id <<= 8; id |= b & 0xFF; } return id - 1; } /** Reads the token an encoded attribute or object class set starts with. */ private static int tokenOf(final ByteString encoded) { final ByteSequenceReader reader = encoded.asReader(); final byte[] idBytes = new byte[reader.readBERLength()]; reader.readBytes(idBytes); return token(idBytes); } }