diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java
new file mode 100644
index 0000000..eae6f79
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java
@@ -0,0 +1,1941 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.fs;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.channels.OverlappingFileLockException;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.UUID;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Logger;
+
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.revocation.RevocationReason;
+import zeroecho.pki.api.revocation.RevocationState;
+import zeroecho.pki.api.revocation.RevocationTransition;
+import zeroecho.pki.spi.store.MetadataStoreId;
+
+/**
+ * Rebuildable, disk-backed current-state index for the authoritative revocation log.
+ *
+ *
The index never owns revocation authority. Every lookup revalidates the indexed
+ * locator against an authenticated transition frame, and rebuild always derives from
+ * the log beginning. Two superblocks and two cells per logical slot keep the preceding
+ * generation usable until both durability boundaries for an update succeed.
+ */
+final class FilesystemRevocationCurrentIndex implements AutoCloseable {
+
+ /* default */ static final int SUPERBLOCK_BYTES = 160;
+ /* default */ static final int CELL_BYTES = 160;
+ /* default */ static final int CELLS_PER_SLOT = 2;
+
+ private static final Logger LOGGER =
+ Logger.getLogger(FilesystemRevocationCurrentIndex.class.getName());
+ private static final String DIRECTORY_WARNING =
+ "Revocation current-index directory durability is limited; continuing in best-effort mode";
+ private static final String ENTRY_COUNT_OVERFLOW =
+ "Revocation index entry count overflow";
+ private static final int MAGIC_SUPERBLOCK = 0x5A455249;
+ private static final int MAGIC_CELL = 0x5A45524A;
+ private static final short FORMAT_VERSION = 1;
+ private static final short RESERVED_FLAGS = 0;
+ private static final int DIGEST_BYTES = 32;
+ private static final int STORE_ID_BYTES = 16;
+ private static final int SUPERBLOCK_FIELDS_BYTES = SUPERBLOCK_BYTES - DIGEST_BYTES;
+ private static final int CELL_FIELDS_BYTES = CELL_BYTES - DIGEST_BYTES;
+ private static final int KEY_DIGEST_HEX_CHARACTERS = DIGEST_BYTES * 2;
+ private static final int PRESENT = 1;
+ private static final int ABSENT = 0;
+ private static final int FIRST_GENERATION = 0;
+ private static final int SECOND_GENERATION = 1;
+ private static final int SINGLE_BYTE = 1;
+ private static final long NO_SLOT = -1L;
+ private static final long MINIMUM_FILE_SIZE = 1L;
+ private static final long GENESIS_REVISION = 0L;
+ private static final byte[] SUPERBLOCK_DOMAIN =
+ "ZeroEcho revocation current index superblock v1".getBytes(StandardCharsets.US_ASCII);
+ private static final byte[] CELL_DOMAIN =
+ "ZeroEcho revocation current index cell v1".getBytes(StandardCharsets.US_ASCII);
+ private static final byte[] KEY_DOMAIN =
+ "ZeroEcho revocation current index key v1".getBytes(StandardCharsets.US_ASCII);
+ private static final HexFormat HEX = HexFormat.of();
+
+ private final Path indexPath;
+ private final MetadataStoreId storeId;
+ private final Configuration configuration;
+ private final PublicationOperations operations;
+ private final FaultInjector faults;
+ private FileChannel indexChannel;
+ private final StableIndexLock stableLock;
+ private final FileChannel logChannel;
+ private final ReentrantLock lifecycleLock = new ReentrantLock();
+ private final RevocationTransitionFrameCodec frameCodec = new RevocationTransitionFrameCodec();
+ private Superblock active;
+ private int activeSuperblock;
+ private State state = State.OPEN;
+
+ private FilesystemRevocationCurrentIndex(
+ Path indexPath,
+ MetadataStoreId storeId,
+ Configuration configuration,
+ PublicationOperations operations,
+ FaultInjector faults,
+ FileChannel indexChannel,
+ StableIndexLock stableLock,
+ FileChannel logChannel,
+ SelectedSuperblock selected) {
+ this.indexPath = indexPath;
+ this.storeId = storeId;
+ this.configuration = configuration;
+ this.operations = operations;
+ this.faults = faults;
+ this.indexChannel = indexChannel;
+ this.stableLock = stableLock;
+ this.logChannel = logChannel;
+ active = selected.value();
+ activeSuperblock = selected.index();
+ }
+
+ /* default */ static FilesystemRevocationCurrentIndex open(
+ Path indexPath,
+ Path logPath,
+ MetadataStoreId expectedStoreId,
+ Configuration configuration) throws IOException {
+ return open(indexPath, logPath, expectedStoreId, configuration,
+ DefaultPublicationOperations.INSTANCE, FaultInjector.NONE);
+ }
+
+ /* default */ static FilesystemRevocationCurrentIndex open(
+ Path indexPath,
+ Path logPath,
+ MetadataStoreId expectedStoreId,
+ Configuration configuration,
+ PublicationOperations operations,
+ FaultInjector faults) throws IOException {
+ Objects.requireNonNull(indexPath, "indexPath");
+ Objects.requireNonNull(logPath, "logPath");
+ Objects.requireNonNull(expectedStoreId, "expectedStoreId");
+ Objects.requireNonNull(configuration, "configuration");
+ Objects.requireNonNull(operations, "operations");
+ Objects.requireNonNull(faults, "faults");
+ configuration.requireValid();
+ IoOperations.requireRegular(indexPath, "Revocation current index");
+ IoOperations.requireRegular(logPath, "Revocation transition log");
+ try (OpenResources resources = OpenResources.acquire(indexPath, logPath)) {
+ return openLocked(indexPath, expectedStoreId, configuration,
+ operations, faults, resources);
+ }
+ }
+
+ private static FilesystemRevocationCurrentIndex openLocked(
+ Path indexPath,
+ MetadataStoreId expectedStoreId,
+ Configuration configuration,
+ PublicationOperations operations,
+ FaultInjector faults,
+ OpenResources resources) throws IOException {
+ IoOperations.requireLogStore(resources.logChannel(), expectedStoreId);
+ SuperblockCandidates candidates =
+ IndexOperations.selectSuperblocks(
+ resources.indexChannel(), expectedStoreId, configuration);
+ SelectedSuperblock selected = CandidateSelection.select(
+ resources, expectedStoreId, candidates);
+ FilesystemRevocationCurrentIndex opened = new FilesystemRevocationCurrentIndex(
+ indexPath, expectedStoreId, configuration, operations, faults,
+ resources.indexChannel(), resources.stableLock(),
+ resources.logChannel(), selected);
+ opened.new IndexAccess().applySuffix();
+ resources.transferOwnership();
+ return opened;
+ }
+
+ /* default */ static FilesystemRevocationCurrentIndex rebuild(
+ Path indexPath,
+ Path logPath,
+ MetadataStoreId expectedStoreId,
+ Configuration configuration) throws IOException {
+ return rebuild(indexPath, logPath, expectedStoreId, configuration,
+ DefaultPublicationOperations.INSTANCE, FaultInjector.NONE);
+ }
+
+ /* default */ static FilesystemRevocationCurrentIndex rebuild(
+ Path indexPath,
+ Path logPath,
+ MetadataStoreId expectedStoreId,
+ Configuration configuration,
+ PublicationOperations operations,
+ FaultInjector faults) throws IOException {
+ Objects.requireNonNull(indexPath, "indexPath");
+ Objects.requireNonNull(logPath, "logPath");
+ Objects.requireNonNull(expectedStoreId, "expectedStoreId");
+ Objects.requireNonNull(configuration, "configuration");
+ Objects.requireNonNull(operations, "operations");
+ Objects.requireNonNull(faults, "faults");
+ configuration.requireValid();
+ IoOperations.requireRegular(logPath, "Revocation transition log");
+ Path parent = Objects.requireNonNull(indexPath.getParent(), "index parent");
+ Files.createDirectories(parent);
+ IoOperations.requireDirectory(parent);
+ Path temporary = parent.resolve("." + indexPath.getFileName() + ".building-" + UUID.randomUUID());
+ boolean published = false;
+ try (StableIndexLock stable = StableIndexLock.acquire(indexPath)) {
+ try {
+ build(temporary, logPath, expectedStoreId, configuration, operations, faults);
+ faults.fail(FaultPoint.PUBLISH_ATOMIC_MOVE);
+ try {
+ operations.atomicReplace(temporary, indexPath);
+ } catch (AtomicMoveNotSupportedException unsupported) {
+ throw new IOException("Atomic revocation index publication is unsupported", unsupported);
+ }
+ published = true;
+ IoOperations.forceDirectory(parent, operations, faults);
+ try (OpenResources resources = OpenResources.acquireLocked(
+ stable, indexPath, logPath)) {
+ return openLocked(indexPath, expectedStoreId, configuration,
+ operations, faults, resources);
+ }
+ } finally {
+ if (!published) {
+ try {
+ Files.deleteIfExists(temporary);
+ } catch (IOException ignored) {
+ // The authoritative rebuild failure remains primary.
+ }
+ }
+ }
+ }
+ }
+
+ /* default */ Optional lookup(PkiId credentialId)
+ throws IOException {
+ Objects.requireNonNull(credentialId, "credentialId");
+ lifecycleLock.lock();
+ try {
+ requireOperational();
+ IndexAccess access = new IndexAccess();
+ ProbeResult probe = access.probe(credentialId, active);
+ if (probe.match().isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(access.readAuthoritative(
+ probe.match().orElseThrow().cell(), credentialId));
+ } finally {
+ lifecycleLock.unlock();
+ }
+ }
+
+ /* default */ void update(RevocationTransitionFrameCodec.CompleteRecord supplied)
+ throws IOException {
+ Objects.requireNonNull(supplied, "supplied");
+ lifecycleLock.lock();
+ try {
+ requireOperational();
+ try {
+ RevocationTransitionFrameCodec.CompleteRecord authoritative =
+ new IndexAccess().requireSuppliedAuthoritative(supplied);
+ new IndexAccess().updateValidated(authoritative);
+ } catch (IOException failure) {
+ state = State.UNUSABLE;
+ throw failure;
+ }
+ } finally {
+ lifecycleLock.unlock();
+ }
+ }
+
+ /* default */ long coveredGlobalRevision() {
+ lifecycleLock.lock();
+ try {
+ requireOperational();
+ return active.coveredRevision();
+ } finally {
+ lifecycleLock.unlock();
+ }
+ }
+
+ /* default */ long entryCount() {
+ lifecycleLock.lock();
+ try {
+ requireOperational();
+ return active.entryCount();
+ } finally {
+ lifecycleLock.unlock();
+ }
+ }
+
+ /* default */ boolean unusable() {
+ lifecycleLock.lock();
+ try {
+ return state == State.UNUSABLE;
+ } finally {
+ lifecycleLock.unlock();
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ lifecycleLock.lock();
+ try {
+ if (state == State.CLOSED) {
+ return;
+ }
+ state = State.CLOSED;
+ IOException failure = null;
+ try {
+ logChannel.close();
+ } catch (IOException closeFailure) {
+ failure = closeFailure;
+ }
+ try {
+ indexChannel.close();
+ } catch (IOException closeFailure) {
+ failure = IoOperations.appendFailure(failure, closeFailure);
+ }
+ try {
+ stableLock.closeOwned();
+ } catch (IOException closeFailure) {
+ failure = IoOperations.appendFailure(failure, closeFailure);
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ } finally {
+ lifecycleLock.unlock();
+ }
+ }
+
+ /** Validates and advances one retained index without adding facade complexity. */
+ private final class IndexAccess {
+ private void applySuffix() throws IOException {
+ RevocationTransitionFrameCodec.ReadResult first =
+ frameCodec.read(logChannel, active.coveredBoundary());
+ switch (first.classification()) {
+ case END_OF_INPUT, INCOMPLETE_TAIL:
+ return;
+ case CORRUPT_RECORD:
+ throw new IOException("Revocation log suffix is corrupt");
+ case COMPLETE_RECORD:
+ publishSuffix(first.record().orElseThrow());
+ }
+ }
+
+ private void publishSuffix(RevocationTransitionFrameCodec.CompleteRecord first)
+ throws IOException {
+ Path parent = Objects.requireNonNull(indexPath.getParent(), "index parent");
+ Path temporary = parent.resolve(
+ "." + indexPath.getFileName() + ".suffix-" + UUID.randomUUID());
+ Superblock next;
+ boolean published = false;
+ try {
+ try (BuildIndex builder = BuildIndex.resume(
+ indexPath, temporary, storeId, configuration, operations,
+ faults, logChannel, active)) {
+ acceptSuffix(builder, first);
+ builder.finish();
+ next = builder.state();
+ }
+ faults.fail(FaultPoint.PUBLISH_ATOMIC_MOVE);
+ try {
+ operations.atomicReplace(temporary, indexPath);
+ } catch (AtomicMoveNotSupportedException unsupported) {
+ throw new IOException(
+ "Atomic revocation index suffix publication is unsupported", unsupported);
+ }
+ published = true;
+ installPublished(next);
+ IoOperations.forceDirectory(parent, operations, faults);
+ } finally {
+ if (!published) {
+ try {
+ Files.deleteIfExists(temporary);
+ } catch (IOException ignored) {
+ // The derived suffix-publication failure remains primary.
+ }
+ }
+ }
+ }
+
+ private void acceptSuffix(
+ BuildIndex builder,
+ RevocationTransitionFrameCodec.CompleteRecord first) throws IOException {
+ builder.accept(first);
+ long offset = first.recordEnd();
+ while (true) {
+ RevocationTransitionFrameCodec.ReadResult result = frameCodec.read(logChannel, offset);
+ switch (result.classification()) {
+ case END_OF_INPUT, INCOMPLETE_TAIL:
+ return;
+ case CORRUPT_RECORD:
+ throw new IOException("Revocation log suffix is corrupt");
+ case COMPLETE_RECORD:
+ RevocationTransitionFrameCodec.CompleteRecord record = result.record().orElseThrow();
+ builder.accept(record);
+ offset = record.recordEnd();
+ break;
+ }
+ }
+ }
+
+ private void installPublished(Superblock next) throws IOException {
+ try (ReplacementChannel replacement = ReplacementChannel.open(indexPath)) {
+ IndexValidation.validate(replacement.channel(), logChannel, storeId, next);
+ indexChannel.close();
+ indexChannel = replacement.transferOwnership();
+ active = next;
+ activeSuperblock = FIRST_GENERATION;
+ }
+ }
+
+ private void updateValidated(RevocationTransitionFrameCodec.CompleteRecord record)
+ throws IOException {
+ IoOperations.requireNextGlobal(
+ record, active.coveredRevision(), active.coveredCommitment());
+ PkiId credentialId = record.data().credentialId();
+ ProbeResult probe = probe(credentialId, active);
+ RevocationTransitionFrameCodec.CompleteRecord previous = null;
+ if (probe.match().isPresent()) {
+ previous = readAuthoritative(probe.match().orElseThrow().cell(), credentialId);
+ }
+ TransitionRules.validate(record.data(), previous);
+ boolean insertion = probe.match().isEmpty();
+ if (insertion && IoOperations.addExact(active.entryCount(), 1L,
+ ENTRY_COUNT_OVERFLOW) > active.loadThreshold()) {
+ applySuffix();
+ return;
+ }
+ long slot = insertion ? probe.insertionSlot() : probe.match().orElseThrow().slot();
+ ActiveCell prior = IndexOperations.activeCell(
+ indexChannel, slot, active.coveredRevision());
+ int inactiveCell = prior.cellIndex() == FIRST_GENERATION
+ ? SECOND_GENERATION : FIRST_GENERATION;
+ Cell nextCell = Cell.occupied(record, IoOperations.keyDigest(credentialId));
+ faults.fail(FaultPoint.CELL_WRITE);
+ IndexOperations.writeCell(indexChannel, slot, inactiveCell, nextCell);
+ faults.fail(FaultPoint.CELL_FORCE);
+ indexChannel.force(true);
+ long nextCount = insertion ? IoOperations.addExact(active.entryCount(), 1L,
+ ENTRY_COUNT_OVERFLOW) : active.entryCount();
+ Superblock next = active.advance(record, nextCount);
+ int inactiveSuperblock = activeSuperblock == FIRST_GENERATION
+ ? SECOND_GENERATION : FIRST_GENERATION;
+ faults.fail(FaultPoint.SUPERBLOCK_WRITE);
+ IndexOperations.writeSuperblock(indexChannel, inactiveSuperblock, next);
+ faults.fail(FaultPoint.SUPERBLOCK_FORCE);
+ indexChannel.force(true);
+ active = next;
+ activeSuperblock = inactiveSuperblock;
+ }
+
+ private RevocationTransitionFrameCodec.CompleteRecord requireSuppliedAuthoritative(
+ RevocationTransitionFrameCodec.CompleteRecord supplied) throws IOException {
+ RevocationTransitionFrameCodec.ReadResult decoded =
+ frameCodec.read(logChannel, supplied.recordOffset());
+ if (decoded.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
+ throw new IOException("Supplied revocation transition is not authoritative log content");
+ }
+ RevocationTransitionFrameCodec.CompleteRecord actual = decoded.record().orElseThrow();
+ if (actual.recordEnd() != supplied.recordEnd()
+ || !actual.commitment().equals(supplied.commitment())
+ || !actual.data().equals(supplied.data())) {
+ throw new IOException("Supplied revocation transition disagrees with the log");
+ }
+ return actual;
+ }
+
+ private ProbeResult probe(PkiId credentialId, Superblock superblock) throws IOException {
+ String digest = IoOperations.keyDigest(credentialId);
+ long first = IoOperations.initialSlot(digest, superblock.capacity());
+ long tombstone = NO_SLOT;
+ for (long distance = 0L; distance < superblock.capacity(); distance++) {
+ long slot = IoOperations.wrapSlot(first, distance, superblock.capacity());
+ ActiveCell selected = IndexOperations.activeCell(
+ indexChannel, slot, superblock.coveredRevision());
+ Cell cell = selected.cell();
+ if (cell.state() == SlotState.EMPTY) {
+ return ProbeResult.absent(
+ IoOperations.preferredInsertionSlot(tombstone, slot));
+ }
+ if (cell.state() == SlotState.TOMBSTONE) {
+ if (tombstone == NO_SLOT) {
+ tombstone = slot;
+ }
+ continue;
+ }
+ if (cell.keyDigest().equals(digest)) {
+ RevocationTransitionFrameCodec.CompleteRecord frame = readAuthoritative(cell, null);
+ if (frame.data().credentialId().equals(credentialId)) {
+ return ProbeResult.present(slot, selected.cellIndex(), cell);
+ }
+ }
+ }
+ if (tombstone != NO_SLOT) {
+ return ProbeResult.absent(tombstone);
+ }
+ throw new IOException("Revocation current index probe exhausted its finite capacity");
+ }
+
+ private RevocationTransitionFrameCodec.CompleteRecord readAuthoritative(
+ Cell cell, PkiId expectedIdentity) throws IOException {
+ if (cell.state() != SlotState.OCCUPIED) {
+ throw new IOException("Revocation current index cell is not occupied");
+ }
+ RevocationTransitionFrameCodec.ReadResult result = frameCodec.read(logChannel, cell.frameStart());
+ if (result.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
+ throw new IOException("Revocation current index frame locator is invalid");
+ }
+ RevocationTransitionFrameCodec.CompleteRecord record = result.record().orElseThrow();
+ PkiId actualIdentity = record.data().credentialId();
+ if (record.recordEnd() != cell.frameEnd()
+ || record.data().globalRevision() != cell.globalRevision()
+ || record.data().transition().revision() != cell.credentialRevision()
+ || !record.commitment().equals(cell.frameCommitment())
+ || !IoOperations.keyDigest(actualIdentity).equals(cell.keyDigest())
+ || expectedIdentity != null && !expectedIdentity.equals(actualIdentity)) {
+ throw new IOException("Revocation current index cell disagrees with its authoritative frame");
+ }
+ return record;
+ }
+
+ }
+
+ private void requireOperational() {
+ if (state != State.OPEN || !indexChannel.isOpen() || !logChannel.isOpen()) {
+ throw new IllegalStateException("Revocation current index is not operational");
+ }
+ }
+
+ private static void build(
+ Path temporary,
+ Path logPath,
+ MetadataStoreId expectedStoreId,
+ Configuration configuration,
+ PublicationOperations operations,
+ FaultInjector faults) throws IOException {
+ RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec();
+ try (FileChannel log = FileChannel.open(logPath, StandardOpenOption.READ);
+ BuildIndex builder = BuildIndex.create(
+ temporary, expectedStoreId, configuration, operations, faults, log)) {
+ MetadataStoreId actual = codec.readPreamble(log);
+ if (!expectedStoreId.equals(actual)) {
+ throw new IOException("Revocation current-index rebuild log authority is foreign");
+ }
+ long offset = RevocationTransitionFrameCodec.PREAMBLE_BYTES;
+ while (true) {
+ RevocationTransitionFrameCodec.ReadResult result = codec.read(log, offset);
+ switch (result.classification()) {
+ case END_OF_INPUT, INCOMPLETE_TAIL:
+ builder.finish();
+ return;
+ case CORRUPT_RECORD:
+ throw new IOException("Revocation log is corrupt during current-index rebuild");
+ case COMPLETE_RECORD:
+ RevocationTransitionFrameCodec.CompleteRecord record =
+ result.record().orElseThrow();
+ builder.accept(record);
+ offset = record.recordEnd();
+ break;
+ }
+ }
+ }
+ }
+
+ /** Strict binary format, probing, arithmetic, and local-POSIX primitives. */
+ private static final class IndexOperations {
+ private static SuperblockCandidates selectSuperblocks(
+ FileChannel channel, MetadataStoreId expectedStore, Configuration configuration)
+ throws IOException {
+ Optional first = decodeSuperblock(
+ IoOperations.readAt(channel, GENESIS_REVISION, SUPERBLOCK_BYTES));
+ Optional second = decodeSuperblock(
+ IoOperations.readAt(channel, SUPERBLOCK_BYTES, SUPERBLOCK_BYTES));
+ first = matching(first, expectedStore, configuration);
+ second = matching(second, expectedStore, configuration);
+ if (first.isEmpty() && second.isEmpty()) {
+ throw new IOException("Revocation current index has no valid superblock");
+ }
+ if (first.isEmpty()) {
+ return new SuperblockCandidates(
+ new SelectedSuperblock(SECOND_GENERATION, second.orElseThrow()), Optional.empty());
+ }
+ if (second.isEmpty()) {
+ return new SuperblockCandidates(
+ new SelectedSuperblock(FIRST_GENERATION, first.orElseThrow()), Optional.empty());
+ }
+ Superblock left = first.orElseThrow();
+ Superblock right = second.orElseThrow();
+ if (left.coveredRevision() == right.coveredRevision() && !left.equals(right)) {
+ throw new IOException("Revocation current index superblocks are ambiguous");
+ }
+ if (left.equals(right)) {
+ return new SuperblockCandidates(
+ new SelectedSuperblock(FIRST_GENERATION, left), Optional.empty());
+ }
+ SelectedSuperblock leftSelected = new SelectedSuperblock(FIRST_GENERATION, left);
+ SelectedSuperblock rightSelected = new SelectedSuperblock(SECOND_GENERATION, right);
+ return right.coveredRevision() > left.coveredRevision()
+ ? new SuperblockCandidates(rightSelected, Optional.of(leftSelected))
+ : new SuperblockCandidates(leftSelected, Optional.of(rightSelected));
+ }
+
+ private static Optional matching(
+ Optional candidate,
+ MetadataStoreId storeId,
+ Configuration configuration) {
+ return candidate.filter(value -> value.storeId().equals(storeId)
+ && value.loadNumerator() == configuration.loadNumerator()
+ && value.loadDenominator() == configuration.loadDenominator());
+ }
+
+ private static Optional decodeSuperblock(byte[] encoded) throws IOException {
+ if (IoOperations.allZero(encoded)) {
+ return Optional.empty();
+ }
+ if (!IoOperations.verifyDigest(
+ encoded, SUPERBLOCK_FIELDS_BYTES, SUPERBLOCK_DOMAIN)) {
+ return Optional.empty();
+ }
+ try {
+ ByteBuffer input = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN);
+ if (input.getInt() != MAGIC_SUPERBLOCK
+ || input.getShort() != FORMAT_VERSION
+ || input.getShort() != RESERVED_FLAGS) {
+ return Optional.empty();
+ }
+ byte[] rawStore = new byte[STORE_ID_BYTES];
+ input.get(rawStore);
+ long capacity = input.getLong();
+ long count = input.getLong();
+ long revision = input.getLong();
+ int present = Byte.toUnsignedInt(input.get());
+ byte[] reservedPresence = new byte[7];
+ input.get(reservedPresence);
+ long finalStart = input.getLong();
+ long boundary = input.getLong();
+ byte[] rawCommitment = new byte[DIGEST_BYTES];
+ input.get(rawCommitment);
+ int numerator = input.getInt();
+ int denominator = input.getInt();
+ byte[] reserved = new byte[16];
+ input.get(reserved);
+ if (!IoOperations.allZero(reservedPresence)
+ || !IoOperations.allZero(reserved)) {
+ return Optional.empty();
+ }
+ OptionalLong start;
+ if (revision == GENESIS_REVISION && present == ABSENT && finalStart == 0L) {
+ start = OptionalLong.empty();
+ } else if (revision > GENESIS_REVISION && present == PRESENT
+ && finalStart >= RevocationTransitionFrameCodec.PREAMBLE_BYTES) {
+ start = OptionalLong.of(finalStart);
+ } else {
+ return Optional.empty();
+ }
+ Superblock value = new Superblock(
+ new MetadataStoreId(HEX.formatHex(rawStore)), capacity, count, revision,
+ start, boundary,
+ new RevocationTransitionFrameCodec.Commitment(HEX.formatHex(rawCommitment)),
+ numerator, denominator);
+ value.requireValid();
+ return Optional.of(value);
+ } catch (IllegalArgumentException malformed) {
+ return Optional.empty();
+ }
+ }
+
+ private static void writeSuperblock(FileChannel channel, int index, Superblock value)
+ throws IOException {
+ ByteBuffer output = ByteBuffer.allocate(SUPERBLOCK_BYTES).order(ByteOrder.BIG_ENDIAN);
+ output.putInt(MAGIC_SUPERBLOCK);
+ output.putShort(FORMAT_VERSION);
+ output.putShort(RESERVED_FLAGS);
+ output.put(HEX.parseHex(value.storeId().value()));
+ output.putLong(value.capacity());
+ output.putLong(value.entryCount());
+ output.putLong(value.coveredRevision());
+ output.put((byte) (value.finalRecordStart().isPresent() ? PRESENT : ABSENT));
+ output.put(new byte[7]);
+ output.putLong(value.finalRecordStart().orElse(0L));
+ output.putLong(value.coveredBoundary());
+ output.put(HEX.parseHex(value.coveredCommitment().value()));
+ output.putInt(value.loadNumerator());
+ output.putInt(value.loadDenominator());
+ output.put(new byte[16]);
+ IoOperations.appendDigest(output, SUPERBLOCK_FIELDS_BYTES, SUPERBLOCK_DOMAIN);
+ output.flip();
+ IoOperations.writeFully(
+ channel, output, Math.multiplyExact((long) index, SUPERBLOCK_BYTES));
+ }
+
+ private static ActiveCell activeCell(
+ FileChannel channel, long slot, long coveredRevision) throws IOException {
+ DecodedCell first = decodeCell(IoOperations.readAt(
+ channel, IoOperations.cellOffset(slot, FIRST_GENERATION), CELL_BYTES));
+ DecodedCell second = decodeCell(IoOperations.readAt(
+ channel, IoOperations.cellOffset(slot, SECOND_GENERATION), CELL_BYTES));
+ Optional left = eligible(first, coveredRevision);
+ Optional right = eligible(second, coveredRevision);
+ if (left.isEmpty() && right.isEmpty()) {
+ return new ActiveCell(0, Cell.empty());
+ }
+ if (left.isEmpty()) {
+ return new ActiveCell(1, right.orElseThrow());
+ }
+ if (right.isEmpty()) {
+ return new ActiveCell(0, left.orElseThrow());
+ }
+ Cell leftCell = left.orElseThrow();
+ Cell rightCell = right.orElseThrow();
+ if (leftCell.generation() == rightCell.generation() && !leftCell.equals(rightCell)) {
+ throw new IOException("Revocation current index slot generations are ambiguous");
+ }
+ return rightCell.generation() > leftCell.generation()
+ ? new ActiveCell(1, rightCell) : new ActiveCell(0, leftCell);
+ }
+
+ private static Optional| eligible(DecodedCell decoded, long coveredRevision) {
+ return decoded.valid() && decoded.cell().generation() <= coveredRevision
+ ? Optional.of(decoded.cell()) : Optional.empty();
+ }
+
+ private static DecodedCell decodeCell(byte[] encoded) {
+ if (IoOperations.allZero(encoded)) {
+ return new DecodedCell(true, Cell.empty());
+ }
+ if (!IoOperations.verifyDigest(encoded, CELL_FIELDS_BYTES, CELL_DOMAIN)) {
+ return new DecodedCell(false, Cell.empty());
+ }
+ try {
+ ByteBuffer input = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN);
+ if (input.getInt() != MAGIC_CELL || input.getShort() != FORMAT_VERSION) {
+ return new DecodedCell(false, Cell.empty());
+ }
+ SlotState state = SlotState.decode(Byte.toUnsignedInt(input.get()));
+ if (input.get() != 0) {
+ return new DecodedCell(false, Cell.empty());
+ }
+ long generation = input.getLong();
+ byte[] key = new byte[DIGEST_BYTES];
+ input.get(key);
+ long globalRevision = input.getLong();
+ long credentialRevision = input.getLong();
+ long frameStart = input.getLong();
+ long frameEnd = input.getLong();
+ byte[] commitment = new byte[DIGEST_BYTES];
+ input.get(commitment);
+ byte[] reserved = new byte[16];
+ input.get(reserved);
+ if (!IoOperations.allZero(reserved)) {
+ return new DecodedCell(false, Cell.empty());
+ }
+ Cell cell = new Cell(state, generation, HEX.formatHex(key), globalRevision,
+ credentialRevision, frameStart, frameEnd,
+ new RevocationTransitionFrameCodec.Commitment(HEX.formatHex(commitment)));
+ cell.requireValid();
+ return new DecodedCell(true, cell);
+ } catch (IllegalArgumentException malformed) {
+ return new DecodedCell(false, Cell.empty());
+ }
+ }
+
+ private static void writeCell(
+ FileChannel channel, long slot, int cellIndex, Cell cell) throws IOException {
+ ByteBuffer output = ByteBuffer.allocate(CELL_BYTES).order(ByteOrder.BIG_ENDIAN);
+ output.putInt(MAGIC_CELL);
+ output.putShort(FORMAT_VERSION);
+ output.put((byte) cell.state().code());
+ output.put((byte) 0);
+ output.putLong(cell.generation());
+ output.put(HEX.parseHex(cell.keyDigest()));
+ output.putLong(cell.globalRevision());
+ output.putLong(cell.credentialRevision());
+ output.putLong(cell.frameStart());
+ output.putLong(cell.frameEnd());
+ output.put(HEX.parseHex(cell.frameCommitment().value()));
+ output.put(new byte[16]);
+ IoOperations.appendDigest(output, CELL_FIELDS_BYTES, CELL_DOMAIN);
+ output.flip();
+ IoOperations.writeFully(
+ channel, output, IoOperations.cellOffset(slot, cellIndex));
+ }
+
+ }
+
+ /** Checked I/O, hashing, path, and durability utilities. */
+ private static final class IoOperations {
+
+ private static String keyDigest(PkiId credentialId) throws IOException {
+ byte[] identity = strictUtf8(credentialId.value());
+ if (identity.length == 0 || identity.length > RevocationTransitionFrameCodec.MAX_COMPONENT_BYTES) {
+ throw new IOException("Revocation credential identity exceeds its technical bound");
+ }
+ MessageDigest digest = sha256();
+ digest.update(KEY_DOMAIN);
+ digest.update(ByteBuffer.allocate(Integer.BYTES).order(ByteOrder.BIG_ENDIAN)
+ .putInt(identity.length).array());
+ digest.update(identity);
+ return HEX.formatHex(digest.digest());
+ }
+
+ private static byte[] strictUtf8(String value) throws IOException {
+ try {
+ ByteBuffer encoded = StandardCharsets.UTF_8.newEncoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .encode(java.nio.CharBuffer.wrap(value));
+ byte[] result = new byte[encoded.remaining()];
+ encoded.get(result);
+ return result;
+ } catch (CharacterCodingException malformed) {
+ throw new IOException("Revocation credential identity is not strict UTF-8", malformed);
+ }
+ }
+
+ private static long initialSlot(String keyDigest, long capacity) {
+ long hash = ByteBuffer.wrap(HEX.parseHex(keyDigest)).order(ByteOrder.BIG_ENDIAN).getLong();
+ return Long.remainderUnsigned(hash, capacity);
+ }
+
+ private static long wrapSlot(long first, long distance, long capacity) {
+ long remaining = capacity - first;
+ return distance < remaining ? first + distance : distance - remaining;
+ }
+
+ private static long preferredInsertionSlot(long tombstone, long emptySlot) {
+ if (tombstone == NO_SLOT) {
+ return emptySlot;
+ }
+ return tombstone;
+ }
+
+ private static long cellOffset(long slot, int cell) throws IOException {
+ try {
+ long slotBytes = Math.multiplyExact(
+ slot, Math.multiplyExact((long) CELL_BYTES, CELLS_PER_SLOT));
+ long base = Math.multiplyExact((long) SUPERBLOCK_BYTES, 2L);
+ return Math.addExact(Math.addExact(base, slotBytes),
+ Math.multiplyExact((long) cell, CELL_BYTES));
+ } catch (ArithmeticException overflow) {
+ throw new IOException("Revocation current index offset exceeds the supported range", overflow);
+ }
+ }
+
+ private static long expectedSize(long capacity) throws IOException {
+ try {
+ return Math.addExact(Math.multiplyExact((long) SUPERBLOCK_BYTES, 2L),
+ Math.multiplyExact(capacity,
+ Math.multiplyExact((long) CELL_BYTES, CELLS_PER_SLOT)));
+ } catch (ArithmeticException overflow) {
+ throw new IOException("Revocation current index capacity exceeds the supported range", overflow);
+ }
+ }
+
+ private static void requireExpectedSize(FileChannel channel, long capacity) throws IOException {
+ if (channel.size() != expectedSize(capacity)) {
+ throw new IOException("Revocation current index physical size is invalid");
+ }
+ }
+
+ private static void extend(FileChannel channel, long size) throws IOException {
+ if (size < MINIMUM_FILE_SIZE) {
+ throw new IOException("Revocation current index size is invalid");
+ }
+ ByteBuffer zero = ByteBuffer.allocate(SINGLE_BYTE);
+ writeFully(channel, zero, size - SINGLE_BYTE);
+ }
+
+ private static byte[] readAt(FileChannel channel, long offset, int length) throws IOException {
+ ByteBuffer buffer = ByteBuffer.allocate(length);
+ long position = offset;
+ while (buffer.hasRemaining()) {
+ int read = channel.read(buffer, position);
+ if (read <= 0) {
+ throw new IOException("Revocation current index read made no progress");
+ }
+ position += read;
+ }
+ return buffer.array();
+ }
+
+ private static void writeFully(FileChannel channel, ByteBuffer source, long offset)
+ throws IOException {
+ long position = offset;
+ while (source.hasRemaining()) {
+ int written = channel.write(source, position);
+ if (written <= 0) {
+ throw new IOException("Revocation current index write made no progress");
+ }
+ position += written;
+ }
+ }
+
+ private static void appendDigest(ByteBuffer output, int fieldsLength, byte[] domain) {
+ MessageDigest digest = sha256();
+ digest.update(domain);
+ digest.update(output.array(), 0, fieldsLength);
+ output.put(digest.digest());
+ }
+
+ private static boolean verifyDigest(byte[] encoded, int fieldsLength, byte[] domain) {
+ MessageDigest digest = sha256();
+ digest.update(domain);
+ digest.update(encoded, 0, fieldsLength);
+ return MessageDigest.isEqual(digest.digest(),
+ Arrays.copyOfRange(encoded, fieldsLength, encoded.length));
+ }
+
+ private static MessageDigest sha256() {
+ try {
+ return MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException unavailable) {
+ throw new IllegalStateException("SHA-256 is unavailable", unavailable);
+ }
+ }
+
+ private static boolean allZero(byte[] value) {
+ int aggregate = 0;
+ for (byte current : value) {
+ aggregate |= current;
+ }
+ return aggregate == 0;
+ }
+
+ private static long addExact(long first, long second, String message) throws IOException {
+ try {
+ return Math.addExact(first, second);
+ } catch (ArithmeticException overflow) {
+ throw new IOException(message, overflow);
+ }
+ }
+
+ private static void requireNextGlobal(
+ RevocationTransitionFrameCodec.CompleteRecord record,
+ long currentRevision,
+ RevocationTransitionFrameCodec.Commitment currentCommitment) throws IOException {
+ long expected = addExact(currentRevision, 1L,
+ "Global revocation revision is exhausted");
+ if (record.data().globalRevision() != expected
+ || !record.data().previousGlobalCommitment().equals(currentCommitment)) {
+ throw new IOException("Revocation current index global chain is invalid");
+ }
+ }
+
+ private static void requireRegular(Path path, String description) throws IOException {
+ if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) {
+ throw new IOException(description + " is not a regular file");
+ }
+ }
+
+ private static void requireDirectory(Path directory) throws IOException {
+ if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)
+ || Files.isSymbolicLink(directory)) {
+ throw new IOException("Revocation current-index directory is invalid");
+ }
+ }
+
+ private static Path stableLockPath(Path indexPath) {
+ return indexPath.resolveSibling(indexPath.getFileName() + ".lock");
+ }
+
+ private static FileLock acquireLock(FileChannel channel) throws IOException {
+ try {
+ FileLock lock = channel.tryLock();
+ if (lock == null) {
+ throw new IOException("Revocation current index is already open for writing");
+ }
+ return lock;
+ } catch (OverlappingFileLockException overlapping) {
+ throw new IOException("Revocation current index is already open for writing", overlapping);
+ }
+ }
+
+ private static void requireLogStore(FileChannel log, MetadataStoreId expectedStoreId)
+ throws IOException {
+ MetadataStoreId actualStore = new RevocationTransitionFrameCodec().readPreamble(log);
+ if (!expectedStoreId.equals(actualStore)) {
+ throw new IOException("Revocation current index log authority is foreign");
+ }
+ }
+
+ private static void forceDirectory(
+ Path parent, PublicationOperations operations, FaultInjector faults) {
+ try {
+ faults.fail(FaultPoint.DIRECTORY_FORCE);
+ operations.forceDirectory(parent);
+ } catch (IOException unsupported) {
+ LOGGER.warning(DIRECTORY_WARNING);
+ }
+ }
+
+ private static IOException appendFailure(IOException primary, IOException secondary) {
+ if (primary == null) {
+ return secondary;
+ }
+ primary.addSuppressed(secondary);
+ return primary;
+ }
+
+ }
+
+ /** Exact construction capacity and rational load threshold. */
+ /* default */ record Configuration(
+ long initialCapacity, int loadNumerator, int loadDenominator) {
+ Configuration {
+ require(initialCapacity, loadNumerator, loadDenominator);
+ }
+
+ private void requireValid() throws IOException {
+ try {
+ require(initialCapacity, loadNumerator, loadDenominator);
+ IoOperations.expectedSize(initialCapacity);
+ } catch (IllegalArgumentException invalid) {
+ throw new IOException("Invalid revocation current-index configuration", invalid);
+ }
+ }
+
+ private static void require(long capacity, int numerator, int denominator) {
+ if (capacity < 2L || (capacity & (capacity - 1L)) != 0L
+ || numerator <= 0 || denominator <= numerator) {
+ throw new IllegalArgumentException(
+ "Index capacity must be a power of two with a proper load fraction");
+ }
+ }
+
+ private long threshold(long capacity) throws IOException {
+ long whole = Math.multiplyExact(capacity / loadDenominator, loadNumerator);
+ long remainder = Math.multiplyExact(capacity % loadDenominator, loadNumerator)
+ / loadDenominator;
+ long threshold = IoOperations.addExact(whole, remainder,
+ "Revocation index load threshold overflow");
+ if (threshold <= 0L || threshold >= capacity) {
+ throw new IOException("Revocation index load threshold is invalid");
+ }
+ return threshold;
+ }
+ }
+
+ /** Deterministic lifecycle fault seam; it is not a production extension point. */
+ /* default */ @FunctionalInterface
+ interface FaultInjector {
+ FaultInjector NONE = point -> { };
+
+ /** Fails one exact derived-index lifecycle boundary. */
+ void fail(FaultPoint point) throws IOException;
+ }
+
+ /** Exact durability and publication boundaries available to deterministic tests. */
+ /* default */ enum FaultPoint {
+ CELL_WRITE,
+ CELL_FORCE,
+ SUPERBLOCK_WRITE,
+ SUPERBLOCK_FORCE,
+ REBUILD_WRITE,
+ REBUILD_FORCE,
+ GROW_FORCE,
+ GROW_ATOMIC_MOVE,
+ PUBLISH_ATOMIC_MOVE,
+ DIRECTORY_FORCE
+ }
+
+ /** POSIX operations isolated for deterministic atomic-publication tests. */
+ /* default */ interface PublicationOperations {
+ /** Atomically replaces the target with a completed derived generation. */
+ void atomicReplace(Path source, Path target) throws IOException;
+
+ /** Forces the containing directory when supported. */
+ void forceDirectory(Path directory) throws IOException;
+ }
+
+ /** Default local-POSIX atomic publication operations. */
+ private enum DefaultPublicationOperations implements PublicationOperations {
+ INSTANCE;
+
+ @Override
+ public void atomicReplace(Path source, Path target) throws IOException {
+ Files.move(source, target,
+ StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ @Override
+ public void forceDirectory(Path directory) throws IOException {
+ try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
+ channel.force(true);
+ }
+ }
+ }
+
+ /** Terminal lifecycle state of one retained index handle. */
+ private enum State {
+ OPEN,
+ UNUSABLE,
+ CLOSED
+ }
+
+ /** Explicit persisted logical-slot state; tombstones preserve probe continuity. */
+ private enum SlotState {
+ EMPTY(0),
+ OCCUPIED(1),
+ TOMBSTONE(2);
+
+ private final int code;
+
+ SlotState(int code) {
+ this.code = code;
+ }
+
+ private int code() {
+ return code;
+ }
+
+ private static SlotState decode(int code) {
+ return switch (code) {
+ case 0 -> EMPTY;
+ case 1 -> OCCUPIED;
+ case 2 -> TOMBSTONE;
+ default -> throw new IllegalArgumentException("Unknown revocation index slot state");
+ };
+ }
+ }
+
+ private record Superblock(
+ MetadataStoreId storeId,
+ long capacity,
+ long entryCount,
+ long coveredRevision,
+ OptionalLong finalRecordStart,
+ long coveredBoundary,
+ RevocationTransitionFrameCodec.Commitment coveredCommitment,
+ int loadNumerator,
+ int loadDenominator) {
+ private Superblock {
+ Objects.requireNonNull(storeId, "storeId");
+ Objects.requireNonNull(finalRecordStart, "finalRecordStart");
+ Objects.requireNonNull(coveredCommitment, "coveredCommitment");
+ }
+
+ private static Superblock genesis(
+ MetadataStoreId storeId, long capacity, Configuration configuration)
+ throws IOException {
+ return new Superblock(storeId, capacity, 0L, GENESIS_REVISION,
+ OptionalLong.empty(), RevocationTransitionFrameCodec.PREAMBLE_BYTES,
+ RevocationTransitionFrameCodec.initialCommitment(storeId),
+ configuration.loadNumerator(), configuration.loadDenominator());
+ }
+
+ private Superblock advance(
+ RevocationTransitionFrameCodec.CompleteRecord record, long newCount) {
+ return new Superblock(storeId, capacity, newCount,
+ record.data().globalRevision(), OptionalLong.of(record.recordOffset()),
+ record.recordEnd(), record.commitment(), loadNumerator, loadDenominator);
+ }
+
+ private long loadThreshold() throws IOException {
+ return new Configuration(capacity, loadNumerator, loadDenominator).threshold(capacity);
+ }
+
+ private void requireValid() throws IOException {
+ new Configuration(capacity, loadNumerator, loadDenominator).requireValid();
+ if (entryCount < 0L || entryCount > loadThreshold() || coveredRevision < 0L
+ || coveredBoundary < RevocationTransitionFrameCodec.PREAMBLE_BYTES
+ || coveredRevision == 0L != finalRecordStart.isEmpty()) {
+ throw new IOException("Revocation current index superblock fields are invalid");
+ }
+ }
+ }
+
+ private record Cell(
+ SlotState state,
+ long generation,
+ String keyDigest,
+ long globalRevision,
+ long credentialRevision,
+ long frameStart,
+ long frameEnd,
+ RevocationTransitionFrameCodec.Commitment frameCommitment) {
+ private Cell {
+ Objects.requireNonNull(state, "state");
+ Objects.requireNonNull(keyDigest, "keyDigest");
+ Objects.requireNonNull(frameCommitment, "frameCommitment");
+ }
+
+ private static Cell empty() {
+ return new Cell(SlotState.EMPTY, 0L, "0".repeat(KEY_DIGEST_HEX_CHARACTERS),
+ 0L, 0L, 0L, 0L,
+ new RevocationTransitionFrameCodec.Commitment(
+ "0".repeat(KEY_DIGEST_HEX_CHARACTERS)));
+ }
+
+ private static Cell occupied(
+ RevocationTransitionFrameCodec.CompleteRecord record, String keyDigest) {
+ return new Cell(SlotState.OCCUPIED, record.data().globalRevision(), keyDigest,
+ record.data().globalRevision(), record.data().transition().revision(),
+ record.recordOffset(), record.recordEnd(), record.commitment());
+ }
+
+ private void requireValid() {
+ if (state == SlotState.EMPTY) {
+ if (generation != 0L || globalRevision != 0L || credentialRevision != 0L
+ || frameStart != 0L || frameEnd != 0L) {
+ throw new IllegalArgumentException("Invalid empty revocation index cell");
+ }
+ return;
+ }
+ if (generation <= 0L || keyDigest.length() != KEY_DIGEST_HEX_CHARACTERS
+ || globalRevision <= 0L || generation != globalRevision
+ || credentialRevision <= 0L
+ || frameStart < RevocationTransitionFrameCodec.PREAMBLE_BYTES
+ || frameEnd <= frameStart) {
+ throw new IllegalArgumentException("Invalid occupied revocation index cell");
+ }
+ HEX.parseHex(keyDigest);
+ }
+ }
+
+ private record DecodedCell(boolean valid, Cell cell) {
+ }
+
+ private record ActiveCell(int cellIndex, Cell cell) {
+ }
+
+ private record SlotMatch(long slot, int cellIndex, Cell cell) {
+ }
+
+ private record ProbeResult(Optional match, long insertionSlot) {
+ private ProbeResult {
+ Objects.requireNonNull(match, "match");
+ }
+
+ private static ProbeResult absent(long insertionSlot) {
+ return new ProbeResult(Optional.empty(), insertionSlot);
+ }
+
+ private static ProbeResult present(long slot, int cellIndex, Cell cell) {
+ return new ProbeResult(Optional.of(new SlotMatch(slot, cellIndex, cell)), slot);
+ }
+ }
+
+ private record SelectedSuperblock(int index, Superblock value) {
+ }
+
+ private record SuperblockCandidates(
+ SelectedSuperblock newest, Optional older) {
+ private SuperblockCandidates {
+ Objects.requireNonNull(newest, "newest");
+ Objects.requireNonNull(older, "older");
+ }
+ }
+
+ /** Independently checks one candidate without acquiring or transferring resource ownership. */
+ private static final class IndexValidation {
+ private static void validate(
+ FileChannel index,
+ FileChannel log,
+ MetadataStoreId storeId,
+ Superblock superblock) throws IOException {
+ IoOperations.requireExpectedSize(index, superblock.capacity());
+ validateBinding(log, storeId, superblock);
+ long occupied = GENESIS_REVISION;
+ for (long slot = GENESIS_REVISION; slot < superblock.capacity(); slot++) {
+ Cell cell = IndexOperations.activeCell(
+ index, slot, superblock.coveredRevision()).cell();
+ if (cell.state() == SlotState.OCCUPIED) {
+ occupied = IoOperations.addExact(occupied, 1L, ENTRY_COUNT_OVERFLOW);
+ RevocationTransitionFrameCodec.CompleteRecord frame = readFrame(log, cell);
+ if (probeSlot(index, log, superblock,
+ frame.data().credentialId()) != slot) {
+ throw new IOException(
+ "Revocation current index contains a duplicate or misplaced key");
+ }
+ }
+ }
+ if (occupied != superblock.entryCount()) {
+ throw new IOException("Revocation current index entry count is inconsistent");
+ }
+ }
+
+ private static void validateBinding(
+ FileChannel log, MetadataStoreId storeId, Superblock superblock)
+ throws IOException {
+ IoOperations.requireLogStore(log, storeId);
+ if (superblock.coveredBoundary() > log.size()) {
+ throw new IOException("Revocation current index exceeds the authoritative log");
+ }
+ if (superblock.coveredRevision() == GENESIS_REVISION) {
+ validateGenesis(storeId, superblock);
+ } else {
+ validateFinalFrame(log, superblock);
+ }
+ }
+
+ private static void validateGenesis(
+ MetadataStoreId storeId, Superblock superblock) throws IOException {
+ if (superblock.finalRecordStart().isPresent()
+ || superblock.coveredBoundary() != RevocationTransitionFrameCodec.PREAMBLE_BYTES
+ || !superblock.coveredCommitment().equals(
+ RevocationTransitionFrameCodec.initialCommitment(storeId))) {
+ throw new IOException("Revocation current index genesis binding is invalid");
+ }
+ }
+
+ private static void validateFinalFrame(FileChannel log, Superblock superblock)
+ throws IOException {
+ long start = superblock.finalRecordStart().orElseThrow(
+ () -> new IOException("Revocation current index final-frame binding is absent"));
+ RevocationTransitionFrameCodec.ReadResult result =
+ new RevocationTransitionFrameCodec().read(log, start);
+ if (result.classification()
+ != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
+ throw new IOException("Revocation current index final-frame binding is invalid");
+ }
+ RevocationTransitionFrameCodec.CompleteRecord frame = result.record().orElseThrow();
+ if (frame.data().globalRevision() != superblock.coveredRevision()
+ || frame.recordEnd() != superblock.coveredBoundary()
+ || !frame.commitment().equals(superblock.coveredCommitment())) {
+ throw new IOException(
+ "Revocation current index final-frame binding disagrees with the log");
+ }
+ }
+
+ private static long probeSlot(
+ FileChannel index,
+ FileChannel log,
+ Superblock superblock,
+ PkiId identity) throws IOException {
+ String digest = IoOperations.keyDigest(identity);
+ long first = IoOperations.initialSlot(digest, superblock.capacity());
+ for (long distance = GENESIS_REVISION;
+ distance < superblock.capacity(); distance++) {
+ long slot = IoOperations.wrapSlot(first, distance, superblock.capacity());
+ Cell cell = IndexOperations.activeCell(
+ index, slot, superblock.coveredRevision()).cell();
+ if (cell.state() == SlotState.EMPTY) {
+ return NO_SLOT;
+ }
+ if (cell.state() == SlotState.OCCUPIED
+ && cell.keyDigest().equals(digest)
+ && readFrame(log, cell).data().credentialId().equals(identity)) {
+ return slot;
+ }
+ }
+ return NO_SLOT;
+ }
+
+ private static RevocationTransitionFrameCodec.CompleteRecord readFrame(
+ FileChannel log, Cell cell) throws IOException {
+ RevocationTransitionFrameCodec.ReadResult result =
+ new RevocationTransitionFrameCodec().read(log, cell.frameStart());
+ if (result.classification()
+ != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
+ throw new IOException("Revocation current index frame locator is invalid");
+ }
+ RevocationTransitionFrameCodec.CompleteRecord frame = result.record().orElseThrow();
+ if (frame.recordEnd() != cell.frameEnd()
+ || frame.data().globalRevision() != cell.globalRevision()
+ || frame.data().transition().revision() != cell.credentialRevision()
+ || !frame.commitment().equals(cell.frameCommitment())
+ || !IoOperations.keyDigest(
+ frame.data().credentialId()).equals(cell.keyDigest())) {
+ throw new IOException(
+ "Revocation current index cell disagrees with its authoritative frame");
+ }
+ return frame;
+ }
+ }
+
+ /** Chooses the newest completely valid generation and falls back only to its predecessor. */
+ private static final class CandidateSelection {
+ private static SelectedSuperblock select(
+ OpenResources resources,
+ MetadataStoreId storeId,
+ SuperblockCandidates candidates) throws IOException {
+ IOException newestFailure = validate(
+ resources, storeId, candidates.newest());
+ if (newestFailure == null) {
+ return candidates.newest();
+ }
+ if (candidates.older().isEmpty()) {
+ throw newestFailure;
+ }
+ SelectedSuperblock older = candidates.older().orElseThrow();
+ IOException olderFailure = validate(resources, storeId, older);
+ if (olderFailure == null) {
+ return older;
+ }
+ newestFailure.addSuppressed(olderFailure);
+ throw newestFailure;
+ }
+
+ private static IOException validate(
+ OpenResources resources,
+ MetadataStoreId storeId,
+ SelectedSuperblock selected) {
+ try {
+ IndexValidation.validate(
+ resources.indexChannel(), resources.logChannel(), storeId, selected.value());
+ return null;
+ } catch (IOException invalid) {
+ return invalid;
+ }
+ }
+ }
+
+ /** Stable adjacent lock whose inode is never replaced with an index generation. */
+ private static final class StableIndexLock implements AutoCloseable {
+ private final FileChannel channel;
+ private final FileLock lock;
+ private boolean transferred;
+ private boolean closed;
+
+ private StableIndexLock(FileChannel channel, FileLock lock) {
+ this.channel = channel;
+ this.lock = lock;
+ }
+
+ private static StableIndexLock acquire(Path indexPath) throws IOException {
+ Path lockPath = IoOperations.stableLockPath(indexPath);
+ FileChannel channel = FileChannel.open(lockPath,
+ StandardOpenOption.CREATE, StandardOpenOption.READ,
+ StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
+ try {
+ IoOperations.requireRegular(lockPath, "Revocation current-index lock");
+ return new StableIndexLock(channel, IoOperations.acquireLock(channel));
+ } catch (IOException failure) {
+ try {
+ channel.close();
+ } catch (IOException closeFailure) {
+ failure.addSuppressed(closeFailure);
+ }
+ throw failure;
+ }
+ }
+
+ private void transferOwnership() {
+ transferred = true;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (transferred) {
+ return;
+ }
+ closeOwned();
+ }
+
+ private void closeOwned() throws IOException {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ IOException failure = null;
+ try {
+ if (lock.isValid()) {
+ lock.release();
+ }
+ } catch (IOException closeFailure) {
+ failure = closeFailure;
+ }
+ try {
+ channel.close();
+ } catch (IOException closeFailure) {
+ failure = IoOperations.appendFailure(failure, closeFailure);
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ }
+ }
+
+ /** Owns a replacement channel until validated installation transfers it. */
+ private static final class ReplacementChannel implements AutoCloseable {
+ private final FileChannel channel;
+ private boolean transferred;
+
+ private ReplacementChannel(FileChannel channel) {
+ this.channel = channel;
+ }
+
+ private static ReplacementChannel open(Path indexPath) throws IOException {
+ return new ReplacementChannel(FileChannel.open(indexPath,
+ StandardOpenOption.READ, StandardOpenOption.WRITE,
+ LinkOption.NOFOLLOW_LINKS));
+ }
+
+ private FileChannel channel() {
+ return channel;
+ }
+
+ private FileChannel transferOwnership() {
+ transferred = true;
+ return channel;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (!transferred) {
+ channel.close();
+ }
+ }
+ }
+
+ /** Transfers the stable lock and opened files only after validation succeeds. */
+ private static final class OpenResources implements AutoCloseable {
+ private final StableIndexLock stableLock;
+ private final FileChannel indexChannel;
+ private final FileChannel logChannel;
+ private boolean transferred;
+
+ private OpenResources(
+ StableIndexLock stableLock, FileChannel indexChannel, FileChannel logChannel) {
+ this.stableLock = stableLock;
+ this.indexChannel = indexChannel;
+ this.logChannel = logChannel;
+ }
+
+ private static OpenResources acquire(Path indexPath, Path logPath) throws IOException {
+ StableIndexLock stable = StableIndexLock.acquire(indexPath);
+ try {
+ return acquireLocked(stable, indexPath, logPath);
+ } catch (IOException failure) {
+ try {
+ stable.close();
+ } catch (IOException closeFailure) {
+ failure.addSuppressed(closeFailure);
+ }
+ throw failure;
+ }
+ }
+
+ private static OpenResources acquireLocked(
+ StableIndexLock stable, Path indexPath, Path logPath)
+ throws IOException {
+ FileChannel index = FileChannel.open(indexPath,
+ StandardOpenOption.READ, StandardOpenOption.WRITE,
+ LinkOption.NOFOLLOW_LINKS);
+ try {
+ FileChannel log = FileChannel.open(logPath, StandardOpenOption.READ);
+ return new OpenResources(stable, index, log);
+ } catch (IOException failure) {
+ try {
+ index.close();
+ } catch (IOException closeFailure) {
+ failure.addSuppressed(closeFailure);
+ }
+ throw failure;
+ }
+ }
+
+ private FileChannel indexChannel() {
+ return indexChannel;
+ }
+
+ private StableIndexLock stableLock() {
+ return stableLock;
+ }
+
+ private FileChannel logChannel() {
+ return logChannel;
+ }
+
+ private void transferOwnership() {
+ transferred = true;
+ stableLock.transferOwnership();
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (transferred) {
+ return;
+ }
+ IOException failure = null;
+ try {
+ logChannel.close();
+ } catch (IOException closeFailure) {
+ failure = closeFailure;
+ }
+ try {
+ indexChannel.close();
+ } catch (IOException closeFailure) {
+ failure = IoOperations.appendFailure(failure, closeFailure);
+ }
+ try {
+ stableLock.closeOwned();
+ } catch (IOException closeFailure) {
+ failure = IoOperations.appendFailure(failure, closeFailure);
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ }
+ }
+
+ /** Bounded-memory temporary generation builder backed solely by its slot file. */
+ private static final class BuildIndex implements AutoCloseable {
+ private final Path path;
+ private final MetadataStoreId storeId;
+ private final Configuration configuration;
+ private final PublicationOperations operations;
+ private final FaultInjector faults;
+ private final FileChannel log;
+ private FileChannel channel;
+ private Superblock state;
+
+ private BuildIndex(
+ Path path,
+ MetadataStoreId storeId,
+ Configuration configuration,
+ PublicationOperations operations,
+ FaultInjector faults,
+ FileChannel log,
+ FileChannel channel,
+ Superblock state) {
+ this.path = path;
+ this.storeId = storeId;
+ this.configuration = configuration;
+ this.operations = operations;
+ this.faults = faults;
+ this.log = log;
+ this.channel = channel;
+ this.state = state;
+ }
+
+ private static BuildIndex create(
+ Path path,
+ MetadataStoreId storeId,
+ Configuration configuration,
+ PublicationOperations operations,
+ FaultInjector faults,
+ FileChannel log) throws IOException {
+ FileChannel channel = FileChannel.open(path,
+ StandardOpenOption.CREATE_NEW, StandardOpenOption.READ,
+ StandardOpenOption.WRITE);
+ try {
+ IoOperations.extend(
+ channel, IoOperations.expectedSize(configuration.initialCapacity()));
+ Superblock genesis = Superblock.genesis(
+ storeId, configuration.initialCapacity(), configuration);
+ return new BuildIndex(path, storeId, configuration,
+ operations, faults, log, channel, genesis);
+ } catch (IOException failure) {
+ try {
+ channel.close();
+ } catch (IOException closeFailure) {
+ failure.addSuppressed(closeFailure);
+ }
+ throw failure;
+ }
+ }
+
+ private static BuildIndex resume(
+ Path source,
+ Path temporary,
+ MetadataStoreId storeId,
+ Configuration configuration,
+ PublicationOperations operations,
+ FaultInjector faults,
+ FileChannel log,
+ Superblock state) throws IOException {
+ Files.copy(source, temporary);
+ try {
+ FileChannel channel = FileChannel.open(temporary,
+ StandardOpenOption.READ, StandardOpenOption.WRITE,
+ LinkOption.NOFOLLOW_LINKS);
+ return new BuildIndex(temporary, storeId, configuration,
+ operations, faults, log, channel, state);
+ } catch (IOException failure) {
+ try {
+ Files.deleteIfExists(temporary);
+ } catch (IOException cleanupFailure) {
+ failure.addSuppressed(cleanupFailure);
+ }
+ throw failure;
+ }
+ }
+
+ private Superblock state() {
+ return state;
+ }
+
+ private void accept(RevocationTransitionFrameCodec.CompleteRecord record)
+ throws IOException {
+ IoOperations.requireNextGlobal(
+ record, state.coveredRevision(), state.coveredCommitment());
+ String digest = IoOperations.keyDigest(record.data().credentialId());
+ BuildProbe probe = probe(record.data().credentialId(), digest);
+ RevocationTransitionFrameCodec.CompleteRecord previous = probe.match().isPresent()
+ ? readFrame(probe.match().orElseThrow().cell(), record.data().credentialId()) : null;
+ TransitionRules.validate(record.data(), previous);
+ boolean insertion = probe.match().isEmpty();
+ if (insertion && IoOperations.addExact(state.entryCount(), 1L,
+ ENTRY_COUNT_OVERFLOW) > state.loadThreshold()) {
+ grow();
+ probe = probe(record.data().credentialId(), digest);
+ }
+ long slot = probe.match().isPresent()
+ ? probe.match().orElseThrow().slot() : probe.insertionSlot();
+ faults.fail(FaultPoint.REBUILD_WRITE);
+ IndexOperations.writeCell(
+ channel, slot, FIRST_GENERATION, Cell.occupied(record, digest));
+ long count = insertion ? IoOperations.addExact(state.entryCount(), 1L,
+ ENTRY_COUNT_OVERFLOW) : state.entryCount();
+ state = state.advance(record, count);
+ }
+
+ private BuildProbe probe(PkiId identity, String digest) throws IOException {
+ long first = IoOperations.initialSlot(digest, state.capacity());
+ long tombstone = NO_SLOT;
+ for (long distance = 0L; distance < state.capacity(); distance++) {
+ long slot = IoOperations.wrapSlot(first, distance, state.capacity());
+ ActiveCell activeCell = IndexOperations.activeCell(
+ channel, slot, state.coveredRevision());
+ Cell cell = activeCell.cell();
+ if (cell.state() == SlotState.EMPTY) {
+ return BuildProbe.absent(
+ IoOperations.preferredInsertionSlot(tombstone, slot));
+ }
+ if (cell.state() == SlotState.TOMBSTONE) {
+ if (tombstone == NO_SLOT) {
+ tombstone = slot;
+ }
+ } else if (cell.keyDigest().equals(digest)
+ && readFrame(cell, null).data().credentialId().equals(identity)) {
+ return BuildProbe.present(slot, cell);
+ }
+ }
+ if (tombstone != NO_SLOT) {
+ return BuildProbe.absent(tombstone);
+ }
+ throw new IOException("Revocation current-index build probe exhausted capacity");
+ }
+
+ private RevocationTransitionFrameCodec.CompleteRecord readFrame(Cell cell, PkiId identity)
+ throws IOException {
+ RevocationTransitionFrameCodec.ReadResult result =
+ new RevocationTransitionFrameCodec().read(log, cell.frameStart());
+ if (result.classification()
+ != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
+ throw new IOException("Revocation current-index build locator is invalid");
+ }
+ RevocationTransitionFrameCodec.CompleteRecord frame = result.record().orElseThrow();
+ if (frame.recordEnd() != cell.frameEnd()
+ || frame.data().globalRevision() != cell.globalRevision()
+ || frame.data().transition().revision() != cell.credentialRevision()
+ || !frame.commitment().equals(cell.frameCommitment())
+ || !IoOperations.keyDigest(
+ frame.data().credentialId()).equals(cell.keyDigest())
+ || identity != null && !identity.equals(frame.data().credentialId())) {
+ throw new IOException("Revocation current-index build cell disagrees with the log");
+ }
+ return frame;
+ }
+
+ private void grow() throws IOException {
+ long nextCapacity;
+ try {
+ nextCapacity = Math.multiplyExact(state.capacity(), 2L);
+ } catch (ArithmeticException overflow) {
+ throw new IOException("Revocation current index capacity is exhausted", overflow);
+ }
+ configuration.threshold(nextCapacity);
+ Path growing = path.resolveSibling(path.getFileName() + ".growing-" + UUID.randomUUID());
+ boolean moved = false;
+ try (FileChannel target = FileChannel.open(growing,
+ StandardOpenOption.CREATE_NEW, StandardOpenOption.READ,
+ StandardOpenOption.WRITE)) {
+ IoOperations.extend(target, IoOperations.expectedSize(nextCapacity));
+ for (long slot = 0L; slot < state.capacity(); slot++) {
+ Cell cell = IndexOperations.activeCell(
+ channel, slot, state.coveredRevision()).cell();
+ if (cell.state() == SlotState.OCCUPIED) {
+ insertRehashed(target, nextCapacity, cell);
+ }
+ }
+ Superblock grown = new Superblock(storeId, nextCapacity, state.entryCount(),
+ state.coveredRevision(), state.finalRecordStart(), state.coveredBoundary(),
+ state.coveredCommitment(), configuration.loadNumerator(),
+ configuration.loadDenominator());
+ IndexOperations.writeSuperblock(target, FIRST_GENERATION, grown);
+ IndexOperations.writeSuperblock(target, SECOND_GENERATION, grown);
+ }
+ try {
+ channel.close();
+ faults.fail(FaultPoint.GROW_ATOMIC_MOVE);
+ operations.atomicReplace(growing, path);
+ moved = true;
+ channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE);
+ state = new Superblock(storeId, nextCapacity, state.entryCount(),
+ state.coveredRevision(), state.finalRecordStart(), state.coveredBoundary(),
+ state.coveredCommitment(), configuration.loadNumerator(),
+ configuration.loadDenominator());
+ } finally {
+ if (!moved) {
+ try {
+ Files.deleteIfExists(growing);
+ } catch (IOException ignored) {
+ // The growth failure remains primary.
+ }
+ }
+ }
+ }
+
+ private static void insertRehashed(FileChannel target, long capacity, Cell cell)
+ throws IOException {
+ long first = IoOperations.initialSlot(cell.keyDigest(), capacity);
+ for (long distance = 0L; distance < capacity; distance++) {
+ long slot = IoOperations.wrapSlot(first, distance, capacity);
+ if (IndexOperations.activeCell(
+ target, slot, cell.generation()).cell().state() == SlotState.EMPTY) {
+ IndexOperations.writeCell(target, slot, FIRST_GENERATION, cell);
+ return;
+ }
+ }
+ throw new IOException("Revocation current-index growth has no free slot");
+ }
+
+ private void finish() throws IOException {
+ IndexOperations.writeSuperblock(channel, FIRST_GENERATION, state);
+ IndexOperations.writeSuperblock(channel, SECOND_GENERATION, state);
+ faults.fail(FaultPoint.REBUILD_FORCE);
+ channel.force(true);
+ }
+
+ @Override
+ public void close() throws IOException {
+ channel.close();
+ }
+ }
+
+ private record BuildMatch(long slot, Cell cell) {
+ }
+
+ private record BuildProbe(Optional match, long insertionSlot) {
+ private BuildProbe {
+ Objects.requireNonNull(match, "match");
+ }
+
+ private static BuildProbe absent(long insertionSlot) {
+ return new BuildProbe(Optional.empty(), insertionSlot);
+ }
+
+ private static BuildProbe present(long slot, Cell cell) {
+ return new BuildProbe(Optional.of(new BuildMatch(slot, cell)), slot);
+ }
+ }
+
+ /** Exact state-machine validation copied from the authoritative log implementation. */
+ private static final class TransitionRules {
+ private static void validate(
+ RevocationTransitionFrameCodec.TransitionData data,
+ RevocationTransitionFrameCodec.CompleteRecord previous) throws IOException {
+ RevocationTransition transition = data.transition();
+ if (previous == null) {
+ validateFirst(data, transition);
+ return;
+ }
+ long expectedLocal = nextLocalRevision(previous.data().transition().revision());
+ if (transition.revision() != expectedLocal
+ || data.previousCredentialGlobalRevision().isEmpty()
+ || data.previousCredentialGlobalRevision().getAsLong()
+ != previous.data().globalRevision()
+ || data.previousCredentialCommitment().isEmpty()
+ || !data.previousCredentialCommitment().orElseThrow()
+ .equals(previous.commitment())) {
+ throw new IOException("Credential revocation history chain is invalid");
+ }
+ validateSuccessor(previous.data().transition(), transition);
+ }
+
+ private static void validateFirst(
+ RevocationTransitionFrameCodec.TransitionData data,
+ RevocationTransition transition) throws IOException {
+ if (transition.revision() != 1L
+ || data.previousCredentialGlobalRevision().isPresent()
+ || data.previousCredentialCommitment().isPresent()
+ || !hasValidReason(transition)
+ || transition.state() != RevocationState.HELD
+ && transition.state() != RevocationState.PERMANENTLY_REVOKED) {
+ throw new IOException("First credential revocation transition is not canonical");
+ }
+ }
+
+ private static long nextLocalRevision(long current) throws IOException {
+ return IoOperations.addExact(
+ current, 1L, "Credential revocation revision is exhausted");
+ }
+
+ private static void validateSuccessor(
+ RevocationTransition previous, RevocationTransition current) throws IOException {
+ if (current.time().isBefore(previous.time()) || !hasValidReason(current)) {
+ throw new IOException("Revocation transition is not legal");
+ }
+ boolean legal = switch (previous.state()) {
+ case CLEAR -> current.state() == RevocationState.HELD
+ || current.state() == RevocationState.PERMANENTLY_REVOKED;
+ case HELD -> current.state() == RevocationState.CLEAR
+ || current.state() == RevocationState.PERMANENTLY_REVOKED;
+ case PERMANENTLY_REVOKED -> false;
+ };
+ if (!legal) {
+ throw new IOException("Revocation transition is not legal");
+ }
+ }
+
+ private static boolean hasValidReason(RevocationTransition transition) {
+ if (transition.state() != RevocationState.PERMANENTLY_REVOKED) {
+ return transition.permanentReason().isEmpty();
+ }
+ return transition.permanentReason()
+ .filter(reason -> reason != RevocationReason.CERTIFICATE_HOLD
+ && reason != RevocationReason.REMOVE_FROM_CRL)
+ .isPresent();
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
index b9def74..7e73673 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
@@ -181,6 +181,14 @@ final class FsPaths {
return this.root.resolve("revocations").resolve("checkpoints");
}
+ /* default */ Path revocationCurrentIndex() {
+ return this.root.resolve("revocations").resolve("current-state.idx");
+ }
+
+ /* default */ Path revocationCurrentIndexLock() {
+ return this.root.resolve("revocations").resolve("current-state.idx.lock");
+ }
+
/* default */ Path revocationSnapshotRoot() {
return this.root.resolve("revocation-snapshots");
}
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexTest.java
new file mode 100644
index 0000000..0d322d4
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexTest.java
@@ -0,0 +1,579 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.fs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.time.Instant;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.revocation.RevocationReason;
+import zeroecho.pki.api.revocation.RevocationState;
+import zeroecho.pki.api.revocation.RevocationTransition;
+import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
+import zeroecho.pki.spi.store.MetadataStoreId;
+
+final class FilesystemRevocationCurrentIndexTest {
+
+ private static final MetadataStoreId STORE_ID =
+ new MetadataStoreId("102030405060708090a0b0c0d0e0f001");
+ private static final PkiId FIRST = new PkiId("credential:first");
+ private static final PkiId SECOND = new PkiId("credential:second");
+ private static final PkiId THIRD = new PkiId("credential:third");
+ private static final FilesystemRevocationCurrentIndex.Configuration CONFIGURATION =
+ new FilesystemRevocationCurrentIndex.Configuration(4L, 3, 4);
+
+ @TempDir
+ private Path temporaryDirectory;
+
+ @Test
+ void rebuildProvidesExactAuthoritativeLookupAndStableReopen() throws Exception {
+ System.out.print("rebuildProvidesExactAuthoritativeLookupAndStableReopen ");
+ try (Fixture fixture = fixture("rebuild")) {
+ RevocationTransitionFrameCodec.CompleteRecord first =
+ fixture.log().append(FIRST, held(1L, 1L));
+ fixture.log().append(SECOND, permanent(1L, 2L));
+ try (FilesystemRevocationCurrentIndex index = fixture.rebuild(CONFIGURATION)) {
+ assertEquals(2L, index.coveredGlobalRevision());
+ assertEquals(2L, index.entryCount());
+ assertEquals(first.commitment(), index.lookup(FIRST).orElseThrow().commitment());
+ assertTrue(index.lookup(new PkiId("credential:missing")).isEmpty());
+ }
+ try (FilesystemRevocationCurrentIndex reopened = fixture.open(CONFIGURATION)) {
+ assertEquals(RevocationState.PERMANENTLY_REVOKED,
+ reopened.lookup(SECOND).orElseThrow().data().transition().state());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void openAppliesAuthoritativeSuffixAndPersistsIt() throws Exception {
+ System.out.print("openAppliesAuthoritativeSuffixAndPersistsIt ");
+ try (Fixture fixture = fixture("suffix")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ try (FilesystemRevocationCurrentIndex ignored = fixture.rebuild(CONFIGURATION)) {
+ assertEquals(1L, ignored.coveredGlobalRevision());
+ }
+ fixture.log().append(FIRST, clear(2L, 2L));
+ fixture.log().append(SECOND, held(1L, 3L));
+ try (FilesystemRevocationCurrentIndex opened = fixture.open(CONFIGURATION)) {
+ assertEquals(3L, opened.coveredGlobalRevision());
+ assertEquals(2L, opened.entryCount());
+ assertEquals(RevocationState.CLEAR,
+ opened.lookup(FIRST).orElseThrow().data().transition().state());
+ }
+ try (FilesystemRevocationCurrentIndex reopened = fixture.open(CONFIGURATION)) {
+ assertEquals(3L, reopened.coveredGlobalRevision());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void cellForceFailureLeavesPriorGenerationRecoverable() throws Exception {
+ System.out.print("cellForceFailureLeavesPriorGenerationRecoverable ");
+ try (Fixture fixture = fixture("cell-failure")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ AtomicInteger failures = new AtomicInteger();
+ FilesystemRevocationCurrentIndex.FaultInjector faults = point -> {
+ if (point == FilesystemRevocationCurrentIndex.FaultPoint.CELL_FORCE
+ && failures.getAndIncrement() == 0) {
+ throw new IOException("injected cell force failure");
+ }
+ };
+ try (FilesystemRevocationCurrentIndex index =
+ fixture.rebuild(CONFIGURATION, faults)) {
+ RevocationTransitionFrameCodec.CompleteRecord second =
+ fixture.log().append(FIRST, clear(2L, 2L));
+ assertThrows(IOException.class, () -> index.update(second));
+ assertTrue(index.unusable());
+ assertThrows(IllegalStateException.class, () -> index.lookup(FIRST));
+ }
+ try (FilesystemRevocationCurrentIndex recovered = fixture.open(CONFIGURATION)) {
+ assertEquals(2L, recovered.coveredGlobalRevision());
+ assertEquals(RevocationState.CLEAR,
+ recovered.lookup(FIRST).orElseThrow().data().transition().state());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void superblockForceFailureNeverLosesAuthoritativeTransition() throws Exception {
+ System.out.print("superblockForceFailureNeverLosesAuthoritativeTransition ");
+ try (Fixture fixture = fixture("superblock-failure")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ FilesystemRevocationCurrentIndex.FaultInjector faults = point -> {
+ if (point == FilesystemRevocationCurrentIndex.FaultPoint.SUPERBLOCK_FORCE) {
+ throw new IOException("injected superblock force failure");
+ }
+ };
+ try (FilesystemRevocationCurrentIndex index =
+ fixture.rebuild(CONFIGURATION, faults)) {
+ RevocationTransitionFrameCodec.CompleteRecord second =
+ fixture.log().append(SECOND, held(1L, 2L));
+ assertThrows(IOException.class, () -> index.update(second));
+ assertTrue(index.unusable());
+ }
+ try (FilesystemRevocationCurrentIndex recovered = fixture.open(CONFIGURATION)) {
+ assertEquals(2L, recovered.coveredGlobalRevision());
+ assertTrue(recovered.lookup(SECOND).isPresent());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void rebuildGrowsOnDiskWithoutAggregateState() throws Exception {
+ System.out.print("rebuildGrowsOnDiskWithoutAggregateState ");
+ FilesystemRevocationCurrentIndex.Configuration small =
+ new FilesystemRevocationCurrentIndex.Configuration(2L, 1, 2);
+ try (Fixture fixture = fixture("growth")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ fixture.log().append(SECOND, held(1L, 2L));
+ fixture.log().append(THIRD, held(1L, 3L));
+ try (FilesystemRevocationCurrentIndex index = fixture.rebuild(small)) {
+ assertEquals(3L, index.entryCount());
+ assertTrue(index.lookup(FIRST).isPresent());
+ assertTrue(index.lookup(SECOND).isPresent());
+ assertTrue(index.lookup(THIRD).isPresent());
+ assertTrue(Files.size(fixture.indexPath())
+ > 2L * FilesystemRevocationCurrentIndex.SUPERBLOCK_BYTES
+ + 2L * FilesystemRevocationCurrentIndex.CELLS_PER_SLOT
+ * FilesystemRevocationCurrentIndex.CELL_BYTES);
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void failedRebuildPreservesPublishedIndex() throws Exception {
+ System.out.print("failedRebuildPreservesPublishedIndex ");
+ try (Fixture fixture = fixture("preserve")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ try (FilesystemRevocationCurrentIndex ignored = fixture.rebuild(CONFIGURATION)) {
+ assertEquals(1L, ignored.coveredGlobalRevision());
+ }
+ byte[] before = Files.readAllBytes(fixture.indexPath());
+ FilesystemRevocationCurrentIndex.FaultInjector faults = point -> {
+ if (point == FilesystemRevocationCurrentIndex.FaultPoint.REBUILD_FORCE) {
+ throw new IOException("injected rebuild force failure");
+ }
+ };
+ assertThrows(IOException.class, () -> fixture.rebuild(CONFIGURATION, faults));
+ assertTrue(java.util.Arrays.equals(before, Files.readAllBytes(fixture.indexPath())));
+ try (FilesystemRevocationCurrentIndex existing = fixture.open(CONFIGURATION)) {
+ assertTrue(existing.lookup(FIRST).isPresent());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void corruptSuperblocksFailClosedAndRemainRebuildable() throws Exception {
+ System.out.print("corruptSuperblocksFailClosedAndRemainRebuildable ");
+ try (Fixture fixture = fixture("corrupt-superblocks")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ try (FilesystemRevocationCurrentIndex ignored = fixture.rebuild(CONFIGURATION)) {
+ assertEquals(1L, ignored.coveredGlobalRevision());
+ }
+ try (FileChannel channel = FileChannel.open(
+ fixture.indexPath(), StandardOpenOption.READ, StandardOpenOption.WRITE)) {
+ writeByte(channel, FilesystemRevocationCurrentIndex.SUPERBLOCK_BYTES - 1L, (byte) 0x44);
+ writeByte(channel, 2L * FilesystemRevocationCurrentIndex.SUPERBLOCK_BYTES - 1L,
+ (byte) 0x55);
+ }
+ assertThrows(IOException.class, () -> fixture.open(CONFIGURATION));
+ try (FilesystemRevocationCurrentIndex rebuilt = fixture.rebuild(CONFIGURATION)) {
+ assertTrue(rebuilt.lookup(FIRST).isPresent());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void corruptNewestSuperblockFallsBackAndReappliesSuffix() throws Exception {
+ System.out.print("corruptNewestSuperblockFallsBackAndReappliesSuffix ");
+ try (Fixture fixture = fixture("fallback")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ try (FilesystemRevocationCurrentIndex index = fixture.rebuild(CONFIGURATION)) {
+ RevocationTransitionFrameCodec.CompleteRecord second =
+ fixture.log().append(SECOND, held(1L, 2L));
+ index.update(second);
+ assertEquals(2L, index.coveredGlobalRevision());
+ }
+ try (FileChannel channel = FileChannel.open(
+ fixture.indexPath(), StandardOpenOption.READ, StandardOpenOption.WRITE)) {
+ writeByte(channel, 2L * FilesystemRevocationCurrentIndex.SUPERBLOCK_BYTES - 1L,
+ (byte) 0x33);
+ }
+ try (FilesystemRevocationCurrentIndex recovered = fixture.open(CONFIGURATION)) {
+ assertEquals(2L, recovered.coveredGlobalRevision());
+ assertTrue(recovered.lookup(SECOND).isPresent());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void exactFrameAndIdentityBindingRejectsForgedUpdate() throws Exception {
+ System.out.print("exactFrameAndIdentityBindingRejectsForgedUpdate ");
+ try (Fixture fixture = fixture("binding")) {
+ RevocationTransitionFrameCodec.CompleteRecord record =
+ fixture.log().append(FIRST, held(1L, 1L));
+ try (FilesystemRevocationCurrentIndex index = fixture.rebuild(CONFIGURATION)) {
+ RevocationTransitionFrameCodec.CompleteRecord forged =
+ new RevocationTransitionFrameCodec.CompleteRecord(
+ record.data(), record.recordOffset(), record.recordEnd() + 1L,
+ record.commitment());
+ assertThrows(IOException.class, () -> index.update(forged));
+ assertTrue(index.unusable());
+ }
+ try (FilesystemRevocationCurrentIndex recovered = fixture.open(CONFIGURATION)) {
+ assertEquals(FIRST,
+ recovered.lookup(FIRST).orElseThrow().data().credentialId());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void secondWriterAndClosedUseAreRejected() throws Exception {
+ System.out.print("secondWriterAndClosedUseAreRejected ");
+ try (Fixture fixture = fixture("locking")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ FilesystemRevocationCurrentIndex first = fixture.rebuild(CONFIGURATION);
+ assertThrows(IOException.class, () -> fixture.open(CONFIGURATION));
+ first.close();
+ first.close();
+ assertThrows(IllegalStateException.class, () -> first.lookup(FIRST));
+ try (FilesystemRevocationCurrentIndex reopened = fixture.open(CONFIGURATION)) {
+ assertTrue(reopened.lookup(FIRST).isPresent());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void rebuildCannotReplaceIndexHeldByLiveWriter() throws Exception {
+ System.out.print("rebuildCannotReplaceIndexHeldByLiveWriter ");
+ try (Fixture fixture = fixture("rebuild-lock")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ FilesystemRevocationCurrentIndex existing = fixture.rebuild(CONFIGURATION);
+ byte[] published = Files.readAllBytes(fixture.indexPath());
+ assertThrows(IOException.class, () -> fixture.rebuild(CONFIGURATION));
+ assertTrue(java.util.Arrays.equals(
+ published, Files.readAllBytes(fixture.indexPath())));
+ assertThrows(IOException.class, () -> fixture.open(CONFIGURATION));
+ assertTrue(existing.lookup(FIRST).isPresent());
+ existing.close();
+ try (FilesystemRevocationCurrentIndex rebuilt = fixture.rebuild(CONFIGURATION)) {
+ assertTrue(rebuilt.lookup(FIRST).isPresent());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void suffixReplayUsesOneFinalDerivedForce() throws Exception {
+ System.out.print("suffixReplayUsesOneFinalDerivedForce ");
+ try (Fixture fixture = fixture("suffix-force")) {
+ fixture.log().append(FIRST, held(1L, 1L));
+ try (FilesystemRevocationCurrentIndex ignored = fixture.rebuild(CONFIGURATION)) {
+ assertEquals(1L, ignored.coveredGlobalRevision());
+ }
+ fixture.log().append(FIRST, clear(2L, 2L));
+ fixture.log().append(SECOND, held(1L, 3L));
+ AtomicInteger cellForces = new AtomicInteger();
+ AtomicInteger superblockForces = new AtomicInteger();
+ AtomicInteger finalForces = new AtomicInteger();
+ FilesystemRevocationCurrentIndex.FaultInjector faults = point -> {
+ if (point == FilesystemRevocationCurrentIndex.FaultPoint.CELL_FORCE) {
+ cellForces.incrementAndGet();
+ } else if (point == FilesystemRevocationCurrentIndex.FaultPoint.SUPERBLOCK_FORCE) {
+ superblockForces.incrementAndGet();
+ } else if (point == FilesystemRevocationCurrentIndex.FaultPoint.REBUILD_FORCE) {
+ finalForces.incrementAndGet();
+ }
+ };
+ try (FilesystemRevocationCurrentIndex index =
+ fixture.open(CONFIGURATION, faults)) {
+ assertEquals(3L, index.coveredGlobalRevision());
+ }
+ assertEquals(0, cellForces.get());
+ assertEquals(0, superblockForces.get());
+ assertEquals(1, finalForces.get());
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void liveAndSuffixUpdatesGrowAcrossMultipleDoublings() throws Exception {
+ System.out.print("liveAndSuffixUpdatesGrowAcrossMultipleDoublings ");
+ FilesystemRevocationCurrentIndex.Configuration small =
+ new FilesystemRevocationCurrentIndex.Configuration(2L, 1, 2);
+ try (Fixture live = fixture("live-multi-growth")) {
+ try (FilesystemRevocationCurrentIndex index = live.rebuild(small)) {
+ for (int number = 1; number <= 9; number++) {
+ PkiId identity = new PkiId("credential:live:" + number);
+ RevocationTransitionFrameCodec.CompleteRecord record =
+ live.log().append(identity, held(1L, number));
+ index.update(record);
+ }
+ assertEquals(9L, index.entryCount());
+ assertTrue(index.lookup(new PkiId("credential:live:9")).isPresent());
+ }
+ }
+ try (Fixture suffix = fixture("suffix-multi-growth")) {
+ try (FilesystemRevocationCurrentIndex ignored = suffix.rebuild(small)) {
+ assertEquals(0L, ignored.entryCount());
+ }
+ for (int number = 1; number <= 9; number++) {
+ suffix.log().append(
+ new PkiId("credential:suffix:" + number), held(1L, number));
+ }
+ AtomicInteger intermediateForces = new AtomicInteger();
+ AtomicInteger directoryForces = new AtomicInteger();
+ FilesystemRevocationCurrentIndex.FaultInjector faults = point -> {
+ if (point == FilesystemRevocationCurrentIndex.FaultPoint.GROW_FORCE) {
+ intermediateForces.incrementAndGet();
+ }
+ };
+ try (FilesystemRevocationCurrentIndex index = suffix.open(
+ small, operations(directoryForces), faults)) {
+ assertEquals(9L, index.entryCount());
+ assertEquals(9L, index.coveredGlobalRevision());
+ assertTrue(index.lookup(new PkiId("credential:suffix:9")).isPresent());
+ }
+ assertEquals(0, intermediateForces.get());
+ assertEquals(1, directoryForces.get());
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void collidingKeysProbeAcrossPhysicalWrapBoundary() throws Exception {
+ System.out.print("collidingKeysProbeAcrossPhysicalWrapBoundary ");
+ FilesystemRevocationCurrentIndex.Configuration configuration =
+ new FilesystemRevocationCurrentIndex.Configuration(8L, 3, 4);
+ List colliding = collidingIdentities(8L, 7L, 4);
+ try (Fixture fixture = fixture("collision-wrap")) {
+ int globalRevision = 1;
+ for (PkiId identity : colliding) {
+ fixture.log().append(identity, held(1L, globalRevision));
+ globalRevision++;
+ }
+ try (FilesystemRevocationCurrentIndex index = fixture.rebuild(configuration)) {
+ for (PkiId identity : colliding) {
+ assertEquals(identity,
+ index.lookup(identity).orElseThrow().data().credentialId());
+ }
+ assertTrue(index.lookup(new PkiId("credential:collision:missing")).isEmpty());
+ }
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void structuralContractHasNoHistoryCollectionOrCheckpointDependency() throws Exception {
+ System.out.print("structuralContractHasNoHistoryCollectionOrCheckpointDependency ");
+ String source = Files.readString(Path.of(
+ "src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java"));
+ assertFalse(source.contains("List"));
+ assertFalse(source.contains("readAllBytes()"));
+ assertFalse(source.contains("FilesystemRevocationCheckpoint"));
+ assertFalse(source.contains("HashMap"));
+ assertFalse(source.contains("new Thread"));
+ assertFalse(source.contains("Executors."));
+ assertEquals(1L, source.lines().filter(line ->
+ line.startsWith("final class FilesystemRevocationCurrentIndex ")).count());
+ System.out.println("...ok");
+ }
+
+ @Test
+ void fsPathsUsesOneStableCurrentIndexLocation() {
+ System.out.print("fsPathsUsesOneStableCurrentIndexLocation ");
+ assertEquals(temporaryDirectory.resolve("revocations/current-state.idx"),
+ new FsPaths(temporaryDirectory).revocationCurrentIndex());
+ assertEquals(temporaryDirectory.resolve("revocations/current-state.idx.lock"),
+ new FsPaths(temporaryDirectory).revocationCurrentIndexLock());
+ System.out.println("...ok");
+ }
+
+ private Fixture fixture(String name) throws IOException {
+ Path root = temporaryDirectory.resolve(name);
+ FsPaths paths = new FsPaths(root);
+ Path logPath = paths.revocationTransitionLog();
+ Files.createDirectories(logPath.getParent());
+ FilesystemRevocationLog log = FilesystemRevocationLog.create(
+ logPath, STORE_ID, credential -> { });
+ return new Fixture(logPath, paths.revocationCurrentIndex(), log);
+ }
+
+ private static RevocationTransition held(long revision, long second) {
+ return new RevocationTransition(revision, RevocationState.HELD,
+ Instant.ofEpochSecond(second), Optional.empty(), new SimpleAttributeSet());
+ }
+
+ private static RevocationTransition clear(long revision, long second) {
+ return new RevocationTransition(revision, RevocationState.CLEAR,
+ Instant.ofEpochSecond(second), Optional.empty(), new SimpleAttributeSet());
+ }
+
+ private static RevocationTransition permanent(long revision, long second) {
+ return new RevocationTransition(revision, RevocationState.PERMANENTLY_REVOKED,
+ Instant.ofEpochSecond(second), Optional.of(RevocationReason.KEY_COMPROMISE),
+ new SimpleAttributeSet());
+ }
+
+ private static List collidingIdentities(
+ long capacity, long targetSlot, int count) throws Exception {
+ List result = new ArrayList<>();
+ int candidate = 0;
+ while (result.size() < count) {
+ PkiId identity = new PkiId("credential:collision:" + candidate);
+ if (initialSlot(identity, capacity) == targetSlot) {
+ result.add(identity);
+ }
+ candidate++;
+ }
+ return result;
+ }
+
+ private static long initialSlot(PkiId identity, long capacity) throws Exception {
+ byte[] raw = identity.value().getBytes(StandardCharsets.UTF_8);
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update("ZeroEcho revocation current index key v1"
+ .getBytes(StandardCharsets.US_ASCII));
+ digest.update(ByteBuffer.allocate(Integer.BYTES).order(ByteOrder.BIG_ENDIAN)
+ .putInt(raw.length).array());
+ digest.update(raw);
+ long hash = ByteBuffer.wrap(digest.digest())
+ .order(ByteOrder.BIG_ENDIAN).getLong();
+ return Long.remainderUnsigned(hash, capacity);
+ }
+
+ private static void writeByte(FileChannel channel, long offset, byte value) throws IOException {
+ ByteBuffer buffer = ByteBuffer.wrap(new byte[] { value });
+ while (buffer.hasRemaining()) {
+ int written = channel.write(buffer, offset);
+ if (written <= 0) {
+ throw new IOException("test mutation made no progress");
+ }
+ offset += written;
+ }
+ }
+
+ private static FilesystemRevocationCurrentIndex.PublicationOperations operations() {
+ return operations(new AtomicInteger());
+ }
+
+ private static FilesystemRevocationCurrentIndex.PublicationOperations operations(
+ AtomicInteger directoryForces) {
+ return new FilesystemRevocationCurrentIndex.PublicationOperations() {
+ @Override
+ public void atomicReplace(Path source, Path target) throws IOException {
+ Files.move(source, target,
+ StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ @Override
+ public void forceDirectory(Path directory) {
+ directoryForces.incrementAndGet();
+ }
+ };
+ }
+
+ private record Fixture(
+ Path logPath, Path indexPath, FilesystemRevocationLog log) implements AutoCloseable {
+ private FilesystemRevocationCurrentIndex rebuild(
+ FilesystemRevocationCurrentIndex.Configuration configuration) throws IOException {
+ return FilesystemRevocationCurrentIndex.rebuild(
+ indexPath, logPath, STORE_ID, configuration, operations(),
+ FilesystemRevocationCurrentIndex.FaultInjector.NONE);
+ }
+
+ private FilesystemRevocationCurrentIndex rebuild(
+ FilesystemRevocationCurrentIndex.Configuration configuration,
+ FilesystemRevocationCurrentIndex.FaultInjector faults) throws IOException {
+ return FilesystemRevocationCurrentIndex.rebuild(
+ indexPath, logPath, STORE_ID, configuration, operations(), faults);
+ }
+
+ private FilesystemRevocationCurrentIndex open(
+ FilesystemRevocationCurrentIndex.Configuration configuration) throws IOException {
+ return open(configuration, FilesystemRevocationCurrentIndex.FaultInjector.NONE);
+ }
+
+ private FilesystemRevocationCurrentIndex open(
+ FilesystemRevocationCurrentIndex.Configuration configuration,
+ FilesystemRevocationCurrentIndex.FaultInjector faults) throws IOException {
+ return open(configuration, operations(), faults);
+ }
+
+ private FilesystemRevocationCurrentIndex open(
+ FilesystemRevocationCurrentIndex.Configuration configuration,
+ FilesystemRevocationCurrentIndex.PublicationOperations publicationOperations,
+ FilesystemRevocationCurrentIndex.FaultInjector faults) throws IOException {
+ return FilesystemRevocationCurrentIndex.open(
+ indexPath, logPath, STORE_ID, configuration, publicationOperations, faults);
+ }
+
+ @Override
+ public void close() throws IOException {
+ log.close();
+ }
+ }
+}
| | |