From 7b63f139bf871299f8759c93e9b4001c7f0df908 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Sun, 2 Aug 2026 11:58:35 +0200 Subject: [PATCH] feat(pki): add derived revocation current-state index Add a crash-safe rebuildable disk-backed index for expected constant-time current revocation lookup and bounded-memory suffix replay. Keep the global transition log as the sole authority and validate every derived lookup against its authoritative transition frame. --- .../fs/FilesystemRevocationCurrentIndex.java | 1941 +++++++++++++++++ .../java/zeroecho/pki/impl/fs/FsPaths.java | 8 + .../FilesystemRevocationCurrentIndexTest.java | 579 +++++ 3 files changed, 2528 insertions(+) create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexTest.java 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(); + } + } +}