/*
|
* 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<Integer, String> storedAttributes = new LinkedHashMap<>();
|
private final Map<Integer, Collection<String>> 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<String> attributeOptions) throws DirectoryException
|
{
|
attributeStoreCount++;
|
awaitIfGated();
|
failIfRequested();
|
storedAttributes.put(token(encodedAttribute), attributeName);
|
}
|
|
@Override
|
protected void storeObjectClasses(final byte[] encodedObjectClasses, final Collection<String> 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.<String> emptySet());
|
}
|
|
private void loadObjectClassesAt(final int id, final Collection<String> objectClassNames)
|
{
|
loadObjectClasses(encodedToken(id), objectClassNames);
|
}
|
|
/** The tokens the whole content would be saved under, as DefaultCompressedSchema saves it. */
|
private List<Integer> savedAttributeTokens()
|
{
|
final List<Integer> tokens = new ArrayList<>();
|
for (final Entry<byte[], Entry<String, Iterable<String>>> attribute : getAllAttributes())
|
{
|
tokens.add(token(attribute.getKey()));
|
}
|
return tokens;
|
}
|
|
private List<Integer> savedObjectClassTokens()
|
{
|
final List<Integer> tokens = new ArrayList<>();
|
for (final Entry<byte[], Collection<String>> 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<ObjectClass, String> 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.
|
* <p>
|
* 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<ObjectClass, String> first = objectClasses("top", "person");
|
final Map<ObjectClass, String> 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<Integer> 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<Thread> concurrentThread = new AtomicReference<>();
|
final Future<Integer> 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<ObjectClass, String> objectClasses = objectClasses("top", "person");
|
final ExecutorService executor = Executors.newFixedThreadPool(2);
|
try
|
{
|
compressedSchema.enteredStore = new CountDownLatch(1);
|
compressedSchema.leaveStore = new CountDownLatch(1);
|
final Future<Integer> registering = executor.submit(encoding(compressedSchema, objectClasses, null));
|
assertTrue(compressedSchema.enteredStore.await(30, TimeUnit.SECONDS), "the store was never reached");
|
|
final AtomicReference<Thread> concurrentThread = new AtomicReference<>();
|
final Future<Integer> 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.
|
* <p>
|
* 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<Integer> encode, final AtomicReference<Thread> 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<Integer> encoding(final CompressedSchema compressedSchema, final Attribute attribute,
|
final AtomicReference<Thread> runningOn)
|
{
|
return new Callable<Integer>()
|
{
|
@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<Integer> encoding(final CompressedSchema compressedSchema,
|
final Map<ObjectClass, String> objectClasses, final AtomicReference<Thread> runningOn)
|
{
|
return new Callable<Integer>()
|
{
|
@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<ByteString> 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<ObjectClass, String> objectClassesAt(final TestCompressedSchema compressedSchema, final int id)
|
throws Exception
|
{
|
return compressedSchema.decodeObjectClasses(encodedToken(id, new ByteStringBuilder()).asReader());
|
}
|
|
private static Map<ObjectClass, String> objectClasses(final String... names)
|
{
|
final Map<ObjectClass, String> 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);
|
}
|
}
|