diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java index 2b5fa10..6be0b5d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java @@ -179,13 +179,29 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { Path directory, Path logPath, FilesystemRevocationLog.RecoveryResult recovery) throws IOException { - return Discovery.discover(directory, logPath, recovery); + Objects.requireNonNull(recovery, "recovery"); + return Discovery.discover(directory, logPath, LogBinding.from(recovery)); + } + + /* default */ static Optional discoverBelow( + Path directory, + Path logPath, + FilesystemRevocationCurrentIndex.RecoveryBoundary target, + long upper, + boolean includeUpper, + FilesystemRevocationCurrentIndex.FaultInjector observer) throws IOException { + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(observer, "observer"); + return Discovery.discoverBelow( + directory, logPath, LogBinding.from(target), upper, + includeUpper, observer); } /* default */ static FilesystemRevocationCheckpoint open( Path checkpointPath, Path logPath, FilesystemRevocationLog.RecoveryResult recovery) throws IOException { + Objects.requireNonNull(recovery, "recovery"); Objects.requireNonNull(checkpointPath, "checkpointPath"); FinalName name = FinalName.parse(checkpointPath.getFileName().toString()) .orElseThrow(() -> new IOException("Invalid revocation checkpoint generation name")); @@ -198,7 +214,8 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); RevocationCheckpointCodec.ValidatedFile validated = codec.validate(channel); name.requireAgreement(validated); - Binding.validate(logPath, recovery, Coverage.from(validated.header())); + Binding.validate( + logPath, LogBinding.from(recovery), Coverage.from(validated.header())); return new FilesystemRevocationCheckpoint(channel, codec, validated); } catch (IOException failure) { try { @@ -467,6 +484,11 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { /** Read-only ordered checkpoint cursor. */ /* default */ interface Cursor extends AutoCloseable { + /** Advances without an externally cancellable recovery operation. */ + default boolean advance() throws IOException { + return advance(CancellationSignal.NONE); + } + /** Advances to the next matching checkpoint entry. */ boolean advance(CancellationSignal cancellation) throws IOException; @@ -635,9 +657,19 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { Objects.requireNonNull(logPath, "logPath"); Objects.requireNonNull(recovery, "recovery"); Objects.requireNonNull(coverage, "coverage"); - if (!coverage.storeId().equals(recovery.storeId()) - || coverage.coveredRevision() > recovery.globalRevision() - || coverage.coveredBoundary() > recovery.lastCompleteRecordBoundary()) { + validate(logPath, LogBinding.from(recovery), coverage); + } + + private static void validate( + Path logPath, + LogBinding binding, + Coverage coverage) throws IOException { + Objects.requireNonNull(logPath, "logPath"); + Objects.requireNonNull(binding, "binding"); + Objects.requireNonNull(coverage, "coverage"); + if (!coverage.storeId().equals(binding.storeId()) + || coverage.coveredRevision() > binding.globalRevision() + || coverage.coveredBoundary() > binding.boundary()) { throw new IOException("Checkpoint does not match the validated revocation log"); } try (FileChannel log = FileChannel.open(logPath, StandardOpenOption.READ)) { @@ -750,20 +782,51 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { private static Optional discover( Path directory, Path logPath, - FilesystemRevocationLog.RecoveryResult recovery) throws IOException { + LogBinding binding) throws IOException { Objects.requireNonNull(directory, "directory"); Objects.requireNonNull(logPath, "logPath"); - Objects.requireNonNull(recovery, "recovery"); + Objects.requireNonNull(binding, "binding"); if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { return Optional.empty(); } - long upperExclusive = Long.MAX_VALUE; - boolean includeMaximum = true; + return selectUnambiguous( + directory, logPath, binding, binding.globalRevision(), true, null); + } + + private static Optional discoverBelow( + Path directory, + Path logPath, + LogBinding binding, + long upper, + boolean includeUpper, + FilesystemRevocationCurrentIndex.FaultInjector observer) throws IOException { + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(logPath, "logPath"); + Objects.requireNonNull(binding, "binding"); + return selectUnambiguous( + directory, logPath, binding, upper, includeUpper, observer); + } + + private static Optional selectUnambiguous( + Path directory, + Path logPath, + LogBinding binding, + long initialUpper, + boolean initialInclusive, + FilesystemRevocationCurrentIndex.FaultInjector observer) throws IOException { + long upperExclusive = initialUpper; + boolean includeMaximum = initialInclusive; while (true) { Selection selection = select( - directory, logPath, recovery, upperExclusive, includeMaximum); + directory, logPath, binding, upperExclusive, + includeMaximum, observer); if (selection.candidate().isEmpty()) { - return Optional.empty(); + if (selection.revision() < ZERO_REVISION) { + return Optional.empty(); + } + upperExclusive = selection.revision(); + includeMaximum = false; + continue; } if (!selection.ambiguous()) { return Optional.of(openSelected(selection.candidate().orElseThrow())); @@ -776,10 +839,44 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { private static Selection select( Path directory, Path logPath, - FilesystemRevocationLog.RecoveryResult recovery, + LogBinding binding, + long upper, + boolean includeUpper, + FilesystemRevocationCurrentIndex.FaultInjector observer) throws IOException { + long selectedRevision = highestEligibleRevision( + directory, upper, includeUpper); + if (selectedRevision < ZERO_REVISION) { + return new Selection(Optional.empty(), selectedRevision, false); + } + return validateTier( + directory, logPath, binding, selectedRevision, observer); + } + + private static long highestEligibleRevision( + Path directory, long upper, boolean includeUpper) throws IOException { long selectedRevision = -1L; + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path candidate : entries) { + Optional parsed = FinalName.parse( + candidate.getFileName().toString()); + if (parsed.isPresent() + && eligible(parsed.orElseThrow().revision(), upper, includeUpper)) { + selectedRevision = Math.max( + selectedRevision, parsed.orElseThrow().revision()); + } + } + } + return selectedRevision; + } + + private static Selection validateTier( + Path directory, + Path logPath, + LogBinding binding, + long selectedRevision, + FilesystemRevocationCurrentIndex.FaultInjector observer) throws IOException { Path selectedPath = null; RevocationCheckpointCodec.ValidatedFile selectedValidated = null; String selectedGeneration = null; @@ -787,23 +884,23 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { try (DirectoryStream entries = Files.newDirectoryStream(directory)) { for (Path candidate : entries) { Optional parsed = FinalName.parse(candidate.getFileName().toString()); - if (parsed.isEmpty() || !eligible(parsed.orElseThrow().revision(), upper, includeUpper)) { + if (parsed.isEmpty() + || parsed.orElseThrow().revision() != selectedRevision) { continue; } + if (observer != null) { + observer.observeRecoveryCheckpointCandidate(selectedRevision); + } Optional validated = - validateCandidate(candidate, logPath, recovery, parsed.orElseThrow()); + validateCandidate(candidate, logPath, binding, parsed.orElseThrow()); if (validated.isEmpty()) { continue; } - long revision = validated.orElseThrow().header().coveredRevision(); - if (revision > selectedRevision) { - selectedRevision = revision; + if (selectedPath == null) { selectedPath = candidate; selectedValidated = validated.orElseThrow(); selectedGeneration = validated.orElseThrow().generationId(); - ambiguous = false; - } else if (revision == selectedRevision - && !validated.orElseThrow().generationId().equals(selectedGeneration)) { + } else if (!validated.orElseThrow().generationId().equals(selectedGeneration)) { ambiguous = true; } } @@ -827,13 +924,13 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { private static Optional validateCandidate( Path candidate, Path logPath, - FilesystemRevocationLog.RecoveryResult recovery, + LogBinding binding, FinalName name) { try (FileChannel channel = FileChannel.open(candidate, StandardOpenOption.READ)) { RevocationCheckpointCodec.ValidatedFile validated = new RevocationCheckpointCodec().validate(channel); name.requireAgreement(validated); - Binding.validate(logPath, recovery, Coverage.from(validated.header())); + Binding.validate(logPath, binding, Coverage.from(validated.header())); return Optional.of(validated); } catch (IOException invalid) { return Optional.empty(); @@ -841,6 +938,31 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { } } + /** Scalar authoritative prefix used without retaining recovered current state. */ + private record LogBinding( + MetadataStoreId storeId, + long globalRevision, + long boundary, + RevocationTransitionFrameCodec.Commitment commitment) { + private LogBinding { + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(commitment, "commitment"); + } + + private static LogBinding from(FilesystemRevocationLog.RecoveryResult recovery) { + return new LogBinding( + recovery.storeId(), recovery.globalRevision(), + recovery.lastCompleteRecordBoundary(), recovery.globalCommitment()); + } + + private static LogBinding from( + FilesystemRevocationCurrentIndex.RecoveryBoundary target) { + return new LogBinding( + target.storeId(), target.globalRevision(), + target.boundary(), target.globalCommitment()); + } + } + /** Sequential cursor with lifecycle ownership held by its checkpoint. */ private final class CheckpointCursor implements Cursor, CursorState { private final boolean revokedOnly; diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java index a6be2c3..66f4420 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java @@ -39,8 +39,6 @@ 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; @@ -57,12 +55,8 @@ 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; /** @@ -79,10 +73,6 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { /* 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; @@ -106,8 +96,6 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { "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; @@ -212,6 +200,32 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { DefaultPublicationOperations.INSTANCE, FaultInjector.NONE); } + /* default */ static FilesystemRevocationCurrentIndex recover( + Path indexPath, + Path checkpointDirectory, + Path logPath, + MetadataStoreId expectedStoreId, + Configuration configuration, + RecoveryBoundary target) throws IOException { + return recover(indexPath, checkpointDirectory, logPath, expectedStoreId, + configuration, target, DefaultPublicationOperations.INSTANCE, + FaultInjector.NONE); + } + + /* default */ static FilesystemRevocationCurrentIndex recover( + Path indexPath, + Path checkpointDirectory, + Path logPath, + MetadataStoreId expectedStoreId, + Configuration configuration, + RecoveryBoundary target, + PublicationOperations operations, + FaultInjector faults) throws IOException { + return RecoveryCoordinator.recover( + indexPath, checkpointDirectory, logPath, expectedStoreId, + configuration, target, operations, faults); + } + /* default */ static FilesystemRevocationCurrentIndex rebuild( Path indexPath, Path logPath, @@ -487,7 +501,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { if (probe.match().isPresent()) { previous = readAuthoritative(probe.match().orElseThrow().cell(), credentialId); } - TransitionRules.validate(record.data(), previous); + FilesystemRevocationLog.validateTransition(record.data(), previous); boolean insertion = probe.match().isEmpty(); if (insertion && IoOperations.addExact(active.entryCount(), 1L, ENTRY_COUNT_OVERFLOW) > active.loadThreshold()) { @@ -630,6 +644,428 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { } } + /** Bounded recovery orchestration over one immutable authoritative prefix. */ + private static final class RecoveryCoordinator { + private final Path indexPath; + private final Path checkpointDirectory; + private final Path logPath; + private final MetadataStoreId storeId; + private final Configuration configuration; + private final RecoveryBoundary target; + private final PublicationOperations operations; + private final FaultInjector faults; + private Optional originalFingerprint = Optional.empty(); + private Optional publishedState = Optional.empty(); + + private RecoveryCoordinator( + Path indexPath, + Path checkpointDirectory, + Path logPath, + MetadataStoreId storeId, + Configuration configuration, + RecoveryBoundary target, + PublicationOperations operations, + FaultInjector faults) { + this.indexPath = indexPath; + this.checkpointDirectory = checkpointDirectory; + this.logPath = logPath; + this.storeId = storeId; + this.configuration = configuration; + this.target = target; + this.operations = operations; + this.faults = faults; + } + + private static FilesystemRevocationCurrentIndex recover( + Path indexPath, + Path checkpointDirectory, + Path logPath, + MetadataStoreId expectedStoreId, + Configuration configuration, + RecoveryBoundary target, + PublicationOperations operations, + FaultInjector faults) throws IOException { + requireArguments(indexPath, checkpointDirectory, logPath, expectedStoreId, + configuration, target, operations, faults); + return new RecoveryCoordinator( + indexPath, checkpointDirectory, logPath, expectedStoreId, + configuration, target, operations, faults).execute(); + } + + private FilesystemRevocationCurrentIndex execute() throws IOException { + Path parent = Objects.requireNonNull(indexPath.getParent(), "index parent"); + Files.createDirectories(parent); + IoOperations.requireDirectory(parent); + Path seedCopy = parent.resolve("." + indexPath.getFileName() + + ".seed-" + UUID.randomUUID()); + Path temporary = parent.resolve("." + indexPath.getFileName() + + ".recovering-" + UUID.randomUUID()); + Optional exact = + capturePublishedSeed(seedCopy); + if (exact.isPresent()) { + return exact.orElseThrow(); + } + boolean published = false; + try { + buildTarget(temporary, seedCopy); + FilesystemRevocationCurrentIndex result = publish( + temporary, originalFingerprint); + published = true; + return result; + } finally { + deleteDerived(seedCopy); + if (!published) { + deleteDerived(temporary); + } + } + } + + private static void requireArguments( + Path indexPath, + Path checkpointDirectory, + Path logPath, + MetadataStoreId expectedStoreId, + Configuration configuration, + RecoveryBoundary target, + PublicationOperations operations, + FaultInjector faults) throws IOException { + Objects.requireNonNull(indexPath, "indexPath"); + Objects.requireNonNull(checkpointDirectory, "checkpointDirectory"); + Objects.requireNonNull(logPath, "logPath"); + Objects.requireNonNull(expectedStoreId, "expectedStoreId"); + Objects.requireNonNull(configuration, "configuration"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(operations, "operations"); + Objects.requireNonNull(faults, "faults"); + configuration.requireValid(); + IoOperations.requireRegular(logPath, "Revocation transition log"); + if (!expectedStoreId.equals(target.storeId())) { + throw new IOException("Revocation recovery target belongs to another store"); + } + } + + private Optional capturePublishedSeed( + Path seedCopy) throws IOException { + try (StableIndexLock stable = StableIndexLock.acquire(indexPath)) { + originalFingerprint = fingerprint(indexPath); + if (originalFingerprint.isEmpty()) { + return Optional.empty(); + } + try (FileChannel index = FileChannel.open(indexPath, StandardOpenOption.READ); + FileChannel log = FileChannel.open(logPath, StandardOpenOption.READ)) { + Optional selected = selectDerivedSeed( + index, log, storeId, configuration); + if (selected.isEmpty()) { + return Optional.empty(); + } + Superblock state = selected.orElseThrow().value(); + if (state.coveredRevision() > target.globalRevision()) { + throw new IOException( + "Published revocation index is newer than the recovery target"); + } + if (matches(state, target)) { + return Optional.of(openExactWhileLocked( + stable, indexPath, logPath, storeId, + configuration, target, operations, faults)); + } + Files.copy(indexPath, seedCopy); + publishedState = Optional.of(state); + return Optional.empty(); + } + } + } + + private static Optional selectDerivedSeed( + FileChannel index, + FileChannel log, + MetadataStoreId storeId, + Configuration configuration) { + try { + SuperblockCandidates candidates = IndexOperations.selectSuperblocks( + index, storeId, configuration); + return Optional.of(selectValid(index, log, storeId, candidates)); + } catch (IOException invalidDerived) { + return Optional.empty(); + } + } + + private static SelectedSuperblock selectValid( + FileChannel index, + FileChannel log, + MetadataStoreId storeId, + SuperblockCandidates candidates) throws IOException { + IOException newestFailure = validateCandidate( + index, log, storeId, candidates.newest()); + if (newestFailure == null) { + return candidates.newest(); + } + if (candidates.older().isPresent()) { + SelectedSuperblock older = candidates.older().orElseThrow(); + IOException olderFailure = validateCandidate(index, log, storeId, older); + if (olderFailure == null) { + return older; + } + newestFailure.addSuppressed(olderFailure); + } + throw newestFailure; + } + + private static IOException validateCandidate( + FileChannel index, + FileChannel log, + MetadataStoreId storeId, + SelectedSuperblock selected) { + try { + IndexValidation.validate(index, log, storeId, selected.value()); + return null; + } catch (IOException invalid) { + return invalid; + } + } + + private static FilesystemRevocationCurrentIndex openExactWhileLocked( + StableIndexLock stable, + Path indexPath, + Path logPath, + MetadataStoreId storeId, + Configuration configuration, + RecoveryBoundary target, + PublicationOperations operations, + FaultInjector faults) throws IOException { + try (OpenResources resources = OpenResources.acquireLocked( + stable, indexPath, logPath)) { + IoOperations.requireLogStore(resources.logChannel(), storeId); + SuperblockCandidates candidates = IndexOperations.selectSuperblocks( + resources.indexChannel(), storeId, configuration); + SelectedSuperblock selected = CandidateSelection.select( + resources, storeId, candidates); + requireTarget(selected.value(), target); + FilesystemRevocationCurrentIndex opened = new FilesystemRevocationCurrentIndex( + indexPath, logPath, storeId, configuration, + operations, faults, + resources.indexChannel(), resources.stableLock(), + resources.logChannel(), selected); + resources.transferOwnership(); + return opened; + } + } + + private void buildTarget(Path temporary, Path seedCopy) throws IOException { + try (FileChannel log = FileChannel.open( + logPath, StandardOpenOption.READ)) { + IoOperations.requireLogStore(log, storeId); + if (publishedState.isPresent()) { + try (BuildIndex builder = BuildIndex.resume( + seedCopy, temporary, storeId, + configuration, operations, faults, + log, publishedState.orElseThrow())) { + replayToTarget(builder, log, target, faults); + } + return; + } + if (buildFromCheckpoint(temporary, log)) { + return; + } + try (BuildIndex builder = BuildIndex.create( + temporary, storeId, configuration, + operations, faults, log)) { + replayToTarget(builder, log, target, faults); + } + } + } + + private boolean buildFromCheckpoint( + Path temporary, FileChannel log) throws IOException { + if (!Files.isDirectory( + checkpointDirectory, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + long upper = target.globalRevision(); + boolean includeUpper = true; + while (true) { + Optional candidate = + FilesystemRevocationCheckpoint.discoverBelow( + checkpointDirectory, logPath, target, upper, + includeUpper, faults); + if (candidate.isEmpty()) { + return false; + } + try (FilesystemRevocationCheckpoint checkpoint = candidate.orElseThrow()) { + try (BuildIndex builder = BuildIndex.createSeeded( + temporary, storeId, configuration, + operations, faults, log, checkpoint)) { + seed(builder, checkpoint, faults); + replayToTarget(builder, log, target, faults); + return true; + } catch (CheckpointRejectedException rejected) { + deleteDerived(temporary); + if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException( + "Rejected checkpoint recovery file could not be removed", + rejected); + } + upper = checkpoint.coveredRevision(); + includeUpper = false; + } + } + } + } + + private static void seed( + BuildIndex builder, + FilesystemRevocationCheckpoint checkpoint, + FaultInjector faults) throws IOException { + builder.seed(checkpoint, faults); + } + + private static void replayToTarget( + BuildIndex builder, + FileChannel log, + RecoveryBoundary target, + FaultInjector faults) throws IOException { + RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); + long offset = builder.state().coveredBoundary(); + while (offset < target.boundary()) { + faults.observeRecoveryLogOffset(offset); + RevocationTransitionFrameCodec.ReadResult result = codec.read(log, offset); + if (result.classification() + != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) { + throw new IOException( + "Authoritative revocation log ends before the recovery target"); + } + RevocationTransitionFrameCodec.CompleteRecord record = + result.record().orElseThrow(); + if (record.recordEnd() > target.boundary()) { + throw new IOException( + "Authoritative revocation frame crosses the recovery target"); + } + builder.accept(record); + offset = record.recordEnd(); + } + requireTarget(builder.state(), target); + builder.finish(); + } + + private FilesystemRevocationCurrentIndex publish( + Path temporary, + Optional originalFingerprint) throws IOException { + Path parent = Objects.requireNonNull(indexPath.getParent(), "index parent"); + try (StableIndexLock stable = StableIndexLock.acquire(indexPath)) { + Optional currentFingerprint = fingerprint(indexPath); + if (!currentFingerprint.equals(originalFingerprint)) { + Optional exact = tryOpenExact( + stable); + if (exact.isPresent()) { + deleteDerived(temporary); + return exact.orElseThrow(); + } + throw new IOException( + "Published revocation index changed during bounded recovery"); + } + requireSeedUnchanged(); + faults.fail(FaultPoint.PUBLISH_ATOMIC_MOVE); + try { + operations.atomicReplace(temporary, indexPath); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException( + "Atomic revocation index recovery publication is unsupported", + unsupported); + } + IoOperations.forceDirectory(parent, operations, faults); + return openExactWhileLocked( + stable, indexPath, logPath, storeId, configuration, + target, operations, faults); + } + } + + private void requireSeedUnchanged() throws IOException { + if (publishedState.isEmpty()) { + return; + } + try (FileChannel index = FileChannel.open(indexPath, StandardOpenOption.READ); + FileChannel log = FileChannel.open(logPath, StandardOpenOption.READ)) { + SuperblockCandidates candidates = IndexOperations.selectSuperblocks( + index, storeId, configuration); + SelectedSuperblock selected = selectValid( + index, log, storeId, candidates); + if (!selected.value().equals(publishedState.orElseThrow())) { + throw new IOException( + "Published revocation index seed changed during recovery"); + } + } + } + + private Optional tryOpenExact( + StableIndexLock stable) { + try { + return Optional.of(openExactWhileLocked( + stable, indexPath, logPath, storeId, configuration, + target, operations, faults)); + } catch (IOException notExact) { + return Optional.empty(); + } + } + + private static Optional fingerprint(Path path) throws IOException { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + return Optional.empty(); + } + IoOperations.requireRegular(path, "Revocation current index"); + MessageDigest digest = IoOperations.sha256(); + ByteBuffer buffer = ByteBuffer.allocate(8192); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + long offset = 0L; + while (offset < channel.size()) { + buffer.clear(); + int read = channel.read(buffer, offset); + if (read <= 0) { + throw new IOException( + "Revocation current-index fingerprint made no progress"); + } + digest.update(buffer.array(), 0, read); + offset += read; + } + } + return Optional.of(HEX.formatHex(digest.digest())); + } + + private static boolean matches( + Superblock state, + RecoveryBoundary target) { + return state.storeId().equals(target.storeId()) + && state.coveredRevision() == target.globalRevision() + && state.finalRecordStart().equals(target.finalRecordStart()) + && state.coveredBoundary() == target.boundary() + && state.coveredCommitment().equals(target.globalCommitment()); + } + + private static void requireTarget( + Superblock state, + RecoveryBoundary target) throws IOException { + if (!matches(state, target)) { + throw new IOException( + "Recovered revocation index does not match its immutable target"); + } + } + + private static void deleteDerived(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // Derived temporary cleanup cannot alter revocation authority. + } + } + } + + /** A self-consistent derived checkpoint cannot seed the exact authoritative frames. */ + private static final class CheckpointRejectedException extends IOException { + private static final long serialVersionUID = 2411479676961225172L; + + private CheckpointRejectedException(String message) { + super(message); + } + } + /** Strict binary format, probing, arithmetic, and local-POSIX primitives. */ private static final class IndexOperations { private static SuperblockCandidates selectSuperblocks( @@ -855,30 +1291,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { 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); - } + return FilesystemRevocationLog.indexKeyDigest(credentialId); } private static long initialSlot(String keyDigest, long capacity) { @@ -1081,7 +1494,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { faults.fail(FaultPoint.DIRECTORY_FORCE); operations.forceDirectory(parent); } catch (IOException unsupported) { - LOGGER.warning(DIRECTORY_WARNING); + FilesystemRevocationLog.warnIndexDirectoryDurability(); } } @@ -1132,6 +1545,24 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { } } + /** Immutable scalar prefix accepted by bounded derived-index recovery. */ + /* default */ interface RecoveryBoundary { + /** Returns the authoritative store identity. */ + MetadataStoreId storeId(); + + /** Returns the covered global revision. */ + long globalRevision(); + + /** Returns the final covered frame start, when one exists. */ + OptionalLong finalRecordStart(); + + /** Returns the exact covered log boundary. */ + long boundary(); + + /** Returns the covered global commitment. */ + RevocationTransitionFrameCodec.Commitment globalCommitment(); + } + /** Deterministic lifecycle fault seam; it is not a production extension point. */ /* default */ @FunctionalInterface interface FaultInjector { @@ -1139,6 +1570,21 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { /** Fails one exact derived-index lifecycle boundary. */ void fail(FaultPoint point) throws IOException; + + /** Observes one checkpoint entry admitted during bounded recovery. */ + default void observeRecoveryCheckpointEntry(long index) { + // Optional structural test observation. + } + + /** Observes one checkpoint generation selected for full validation. */ + default void observeRecoveryCheckpointCandidate(long revision) { + // Optional structural test observation. + } + + /** Observes one authoritative suffix offset read during bounded recovery. */ + default void observeRecoveryLogOffset(long offset) { + // Optional structural test observation. + } } /** Exact durability and publication boundaries available to deterministic tests. */ @@ -1240,6 +1686,32 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { configuration.loadNumerator(), configuration.loadDenominator()); } + private static Superblock checkpoint( + FilesystemRevocationCheckpoint checkpoint, + long capacity, + Configuration configuration) { + return new Superblock( + checkpoint.storeId(), capacity, 0L, + checkpoint.coveredRevision(), + OptionalLong.empty(), + checkpoint.coveredBoundary(), checkpoint.coveredCommitment(), + configuration.loadNumerator(), configuration.loadDenominator()); + } + + private Superblock withEntryCount(long count) { + return new Superblock( + storeId, capacity, count, coveredRevision, finalRecordStart, + coveredBoundary, coveredCommitment, + loadNumerator, loadDenominator); + } + + private Superblock withFinalRecordStart(OptionalLong start) { + return new Superblock( + storeId, capacity, entryCount, coveredRevision, start, + coveredBoundary, coveredCommitment, + loadNumerator, loadDenominator); + } + private Superblock advance( RevocationTransitionFrameCodec.CompleteRecord record, long newCount) { return new Superblock(storeId, capacity, newCount, @@ -1960,6 +2432,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { private final FileChannel log; private FileChannel channel; private Superblock state; + private OptionalLong seededFinalStart = OptionalLong.empty(); private BuildIndex( Path path, @@ -2007,6 +2480,49 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { } } + private static BuildIndex createSeeded( + Path path, + MetadataStoreId storeId, + Configuration configuration, + PublicationOperations operations, + FaultInjector faults, + FileChannel log, + FilesystemRevocationCheckpoint checkpoint) throws IOException { + long capacity = capacityFor( + checkpoint.entryCount(), configuration); + FileChannel channel = FileChannel.open(path, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, + StandardOpenOption.WRITE); + try { + IoOperations.extend(channel, IoOperations.expectedSize(capacity)); + Superblock state = Superblock.checkpoint( + checkpoint, capacity, configuration); + return new BuildIndex(path, storeId, configuration, + operations, faults, log, channel, state); + } catch (IOException failure) { + try { + channel.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + private static long capacityFor( + long entryCount, Configuration configuration) throws IOException { + long capacity = configuration.initialCapacity(); + while (configuration.threshold(capacity) < entryCount) { + try { + capacity = Math.multiplyExact(capacity, 2L); + } catch (ArithmeticException overflow) { + throw new IOException( + "Revocation current index capacity is exhausted", overflow); + } + } + return capacity; + } + private static BuildIndex resume( Path source, Path temporary, @@ -2037,6 +2553,95 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { return state; } + private void seed( + FilesystemRevocationCheckpoint checkpoint, + FaultInjector recoveryFaults) throws IOException { + long count = 0L; + try (FilesystemRevocationCheckpoint.Cursor cursor = + checkpoint.allCurrentStates()) { + while (cursor.advance()) { + recoveryFaults.observeRecoveryCheckpointEntry(count); + seedCurrent(cursor); + count = IoOperations.addExact(count, 1L, ENTRY_COUNT_OVERFLOW); + } + } + if (count != checkpoint.entryCount() || state.entryCount() != count) { + throw new CheckpointRejectedException( + "Revocation checkpoint entry count is inconsistent"); + } + if (state.coveredRevision() > GENESIS_REVISION) { + if (seededFinalStart.isEmpty()) { + throw new CheckpointRejectedException( + "Checkpoint omits its covered final record"); + } + state = state.withFinalRecordStart(seededFinalStart); + } + } + + private void seedCurrent(FilesystemRevocationCheckpoint.Cursor cursor) + throws IOException { + if (cursor.current().globalRevision() > state.coveredRevision()) { + throw new CheckpointRejectedException( + "Checkpoint entry exceeds its declared coverage"); + } + RevocationTransitionFrameCodec.ReadResult result = + new RevocationTransitionFrameCodec().read( + log, cursor.current().frameStart()); + if (result.classification() + != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) { + throw new IOException( + "Checkpoint locator reaches corrupt authoritative log content"); + } + RevocationTransitionFrameCodec.CompleteRecord record = + result.record().orElseThrow(); + if (!entryMatches(cursor, record)) { + throw new CheckpointRejectedException( + "Checkpoint entry disagrees with its authoritative frame"); + } + if (record.data().globalRevision() == state.coveredRevision()) { + if (seededFinalStart.isPresent()) { + throw new CheckpointRejectedException( + "Checkpoint duplicates its covered final record"); + } + seededFinalStart = OptionalLong.of(record.recordOffset()); + } + String digest = IoOperations.keyDigest(cursor.current().credentialId()); + BuildProbe probe = probe(cursor.current().credentialId(), digest); + if (probe.match().isPresent()) { + throw new CheckpointRejectedException( + "Checkpoint contains a duplicate current-state identity"); + } + long nextCount = IoOperations.addExact( + state.entryCount(), 1L, ENTRY_COUNT_OVERFLOW); + if (nextCount > state.loadThreshold()) { + grow(); + probe = probe(cursor.current().credentialId(), digest); + } + faults.fail(FaultPoint.REBUILD_WRITE); + IndexOperations.writeCell( + channel, probe.insertionSlot(), FIRST_GENERATION, + Cell.occupied(record, digest)); + state = state.withEntryCount(nextCount); + } + + private static boolean entryMatches( + FilesystemRevocationCheckpoint.Cursor cursor, + RevocationTransitionFrameCodec.CompleteRecord record) { + return record.recordOffset() == cursor.current().frameStart() + && record.recordEnd() == cursor.current().frameEnd() + && record.data().credentialId().equals( + cursor.current().credentialId()) + && record.data().globalRevision() + == cursor.current().globalRevision() + && record.data().transition().revision() + == cursor.current().credentialRevision() + && record.commitment().equals( + cursor.current().transitionCommitment()) + && RevocationTransitionFrameCodec.transitionsEqual( + record.data().transition(), cursor.current().transition()); + } + + private void accept(RevocationTransitionFrameCodec.CompleteRecord record) throws IOException { IoOperations.requireNextGlobal( @@ -2045,7 +2650,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { 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); + FilesystemRevocationLog.validateTransition(record.data(), previous); boolean insertion = probe.match().isEmpty(); if (insertion && IoOperations.addExact(state.entryCount(), 1L, ENTRY_COUNT_OVERFLOW) > state.loadThreshold()) { @@ -2203,72 +2808,4 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { } } - /** 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/FilesystemRevocationLog.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java index 354650f..9eb6b82 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java @@ -34,6 +34,11 @@ package zeroecho.pki.impl.fs; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; @@ -45,12 +50,17 @@ import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.FileAttribute; import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.HashSet; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; import java.util.Set; +import java.util.HexFormat; import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Logger; @@ -66,6 +76,11 @@ final class FilesystemRevocationLog implements AutoCloseable { private static final Logger LOGGER = Logger.getLogger(FilesystemRevocationLog.class.getName()); private static final String CAPABILITY_WARNING = "POSIX revocation-log creation durability is limited; continuing in best-effort mode"; + private static final String INDEX_DIRECTORY_WARNING = + "Revocation current-index directory durability is limited; continuing in best-effort mode"; + private static final byte[] INDEX_KEY_DOMAIN = + "ZeroEcho revocation current index key v1".getBytes(StandardCharsets.US_ASCII); + private static final HexFormat HEX = HexFormat.of(); private static final Set LOCAL_FILE_SYSTEMS = Set.of("apfs", "btrfs", "ext2", "ext3", "ext4", "tmpfs", "ufs", "xfs", "zfs"); private static final Set OWNER_ONLY = @@ -80,6 +95,8 @@ final class FilesystemRevocationLog implements AutoCloseable { private final RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec(); private final Map latest; private long globalRevision; + private OptionalLong finalRecordStart; + private long lastCompleteRecordBoundary; private RevocationTransitionFrameCodec.Commitment globalCommitment; private long scanInvocations; private State state = State.OPEN; @@ -98,6 +115,8 @@ final class FilesystemRevocationLog implements AutoCloseable { this.faults = faults; latest = new HashMap<>(recovery.latestStates()); globalRevision = recovery.globalRevision(); + finalRecordStart = recovery.finalRecordStart(); + lastCompleteRecordBoundary = recovery.lastCompleteRecordBoundary(); globalCommitment = recovery.globalCommitment(); scanInvocations = 1L; } @@ -158,6 +177,8 @@ final class FilesystemRevocationLog implements AutoCloseable { LatestState next = latestState(record); latest.put(credentialId, next); globalRevision = data.globalRevision(); + finalRecordStart = OptionalLong.of(record.recordOffset()); + lastCompleteRecordBoundary = record.recordEnd(); globalCommitment = record.commitment(); return record; } catch (IOException failure) { @@ -213,6 +234,24 @@ final class FilesystemRevocationLog implements AutoCloseable { } } + /** + * Captures one immutable authoritative prefix without retaining the append lock. + * + *

Records appended after this method returns belong to a later prefix and do + * not change the returned target.

+ */ + /* default */ RecoveryTarget recoveryTarget() throws IOException { + appendLock.lock(); + try { + requireOperational(); + return new RecoveryTarget( + storeId, globalRevision, finalRecordStart, + lastCompleteRecordBoundary, globalCommitment); + } finally { + appendLock.unlock(); + } + } + /* default */ boolean recoveryRequired() { appendLock.lock(); try { @@ -230,6 +269,64 @@ final class FilesystemRevocationLog implements AutoCloseable { return Scanner.scan(channel, expectedStoreId, credentialAuthority, sink); } + /* default */ static void validateTransition( + RevocationTransitionFrameCodec.TransitionData data, + RevocationTransitionFrameCodec.CompleteRecord previous) throws IOException { + Objects.requireNonNull(data, "data"); + LatestState prior = previous == null ? null : latestState(previous); + try { + TransitionRules.validate(data, prior); + } catch (IllegalArgumentException invalid) { + throw new IOException("Revocation transition is not legal", invalid); + } + } + + /* default */ static void warnIndexDirectoryDurability() { + try { + LOGGER.warning(INDEX_DIRECTORY_WARNING); + } catch (IllegalStateException ignored) { + // Advisory logging cannot alter derived-index publication. + } + } + + /* default */ static String indexKeyDigest(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(INDEX_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 MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException("SHA-256 is unavailable", unavailable); + } + } + private RevocationTransitionFrameCodec.TransitionData nextData( PkiId credentialId, RevocationTransition transition) throws IOException { LatestState previous = latest.get(credentialId); @@ -242,12 +339,12 @@ final class FilesystemRevocationLog implements AutoCloseable { if (previous == null) { return new RevocationTransitionFrameCodec.TransitionData( nextGlobal, globalCommitment, credentialId, - java.util.OptionalLong.empty(), java.util.Optional.empty(), transition); + OptionalLong.empty(), Optional.empty(), transition); } return new RevocationTransitionFrameCodec.TransitionData( nextGlobal, globalCommitment, credentialId, - java.util.OptionalLong.of(previous.globalRevision()), - java.util.Optional.of(previous.commitment()), transition); + OptionalLong.of(previous.globalRevision()), + Optional.of(previous.commitment()), transition); } private static void validateGlobal( @@ -580,6 +677,7 @@ final class FilesystemRevocationLog implements AutoCloseable { sink.accept(record); state.states.put(record.data().credentialId(), latestState(record)); state.globalRevision = record.data().globalRevision(); + state.finalRecordStart = OptionalLong.of(record.recordOffset()); state.globalCommitment = record.commitment(); state.boundary = record.recordEnd(); } @@ -587,7 +685,8 @@ final class FilesystemRevocationLog implements AutoCloseable { private static RecoveryResult finish(ScanState state, boolean incomplete) { return new RecoveryResult( state.storeId, state.boundary, state.physicalEnd, incomplete, - state.globalRevision, state.globalCommitment, state.states); + state.globalRevision, state.finalRecordStart, + state.globalCommitment, state.states); } private static CorruptLogException corrupt(String message) { @@ -681,6 +780,7 @@ final class FilesystemRevocationLog implements AutoCloseable { private final Map states = new HashMap<>(); private long boundary = RevocationTransitionFrameCodec.PREAMBLE_BYTES; private long globalRevision; + private OptionalLong finalRecordStart = OptionalLong.empty(); private RevocationTransitionFrameCodec.Commitment globalCommitment; private ScanState(MetadataStoreId storeId, long physicalEnd) { @@ -732,14 +832,17 @@ final class FilesystemRevocationLog implements AutoCloseable { long physicalEnd, boolean incompleteTail, long globalRevision, + OptionalLong finalRecordStart, RevocationTransitionFrameCodec.Commitment globalCommitment, Map latestStates) { RecoveryResult { Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(finalRecordStart, "finalRecordStart"); Objects.requireNonNull(globalCommitment, "globalCommitment"); latestStates = Map.copyOf(latestStates); if (lastCompleteRecordBoundary < RevocationTransitionFrameCodec.PREAMBLE_BYTES - || physicalEnd < lastCompleteRecordBoundary || globalRevision < 0L) { + || physicalEnd < lastCompleteRecordBoundary || globalRevision < 0L + || globalRevision == 0L != finalRecordStart.isEmpty()) { throw new IllegalArgumentException("Invalid revocation recovery boundaries"); } } @@ -748,13 +851,46 @@ final class FilesystemRevocationLog implements AutoCloseable { return new RecoveryResult( storeId, RevocationTransitionFrameCodec.PREAMBLE_BYTES, RevocationTransitionFrameCodec.PREAMBLE_BYTES, false, 0L, + OptionalLong.empty(), RevocationTransitionFrameCodec.initialCommitment(storeId), Map.of()); } private RecoveryResult afterTailRepair() { return new RecoveryResult( storeId, lastCompleteRecordBoundary, lastCompleteRecordBoundary, - false, globalRevision, globalCommitment, latestStates); + false, globalRevision, finalRecordStart, + globalCommitment, latestStates); + } + } + + /** Immutable scalar boundary used by bounded derived-state recovery. */ + /* default */ record RecoveryTarget( + MetadataStoreId storeId, + long globalRevision, + OptionalLong finalRecordStart, + long boundary, + RevocationTransitionFrameCodec.Commitment globalCommitment) + implements FilesystemRevocationCurrentIndex.RecoveryBoundary { + RecoveryTarget { + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(finalRecordStart, "finalRecordStart"); + Objects.requireNonNull(globalCommitment, "globalCommitment"); + if (globalRevision < 0L + || boundary < RevocationTransitionFrameCodec.PREAMBLE_BYTES + || globalRevision == 0L != finalRecordStart.isEmpty()) { + throw new IllegalArgumentException("Invalid revocation recovery target"); + } + if (globalRevision == 0L + && (boundary != RevocationTransitionFrameCodec.PREAMBLE_BYTES + || !globalCommitment.equals( + RevocationTransitionFrameCodec.initialCommitment(storeId)))) { + throw new IllegalArgumentException("Invalid genesis revocation recovery target"); + } + if (finalRecordStart.isPresent() + && (finalRecordStart.getAsLong() < RevocationTransitionFrameCodec.PREAMBLE_BYTES + || finalRecordStart.getAsLong() >= boundary)) { + throw new IllegalArgumentException("Invalid final revocation record boundary"); + } } } diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexRecoveryTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexRecoveryTest.java new file mode 100644 index 0000000..40deb43 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexRecoveryTest.java @@ -0,0 +1,496 @@ +/******************************************************************************* + * 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.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.PkiId; +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 FilesystemRevocationCurrentIndexRecoveryTest { + + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("1234567890abcdef1234567890abcdef"); + private static final PkiId FIRST = new PkiId("credential:recovery:first"); + private static final PkiId SECOND = new PkiId("credential:recovery:second"); + private static final PkiId THIRD = new PkiId("credential:recovery:third"); + private static final FilesystemRevocationCurrentIndex.Configuration INDEX_CONFIGURATION = + new FilesystemRevocationCurrentIndex.Configuration(4L, 3, 4); + + @TempDir + Path temporaryDirectory; + + @Test + void scalarTargetTracksExactAuthoritativePrefix() throws Exception { + System.out.print("scalarTargetTracksExactAuthoritativePrefix "); + try (Fixture fixture = fixture("target")) { + FilesystemRevocationLog.RecoveryTarget genesis = fixture.log.recoveryTarget(); + assertEquals(0L, genesis.globalRevision()); + assertTrue(genesis.finalRecordStart().isEmpty()); + RevocationTransitionFrameCodec.CompleteRecord record = + fixture.log.append(FIRST, held(1L, 1L)); + FilesystemRevocationLog.RecoveryTarget current = fixture.log.recoveryTarget(); + assertEquals(1L, current.globalRevision()); + assertEquals(record.recordOffset(), current.finalRecordStart().orElseThrow()); + assertEquals(record.recordEnd(), current.boundary()); + assertEquals(record.commitment(), current.globalCommitment()); + } + System.out.println("...ok"); + } + + @Test + void checkpointSeedsIndexAndOnlySuffixIsReplayed() throws Exception { + System.out.print("checkpointSeedsIndexAndOnlySuffixIsReplayed "); + try (Fixture fixture = fixture("checkpoint-suffix")) { + fixture.log.append(FIRST, held(1L, 1L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints, fixture.builderConfiguration()); + } + fixture.log.append(SECOND, held(1L, 2L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints, fixture.builderConfiguration()); + } + Files.delete(fixture.indexPath); + RevocationTransitionFrameCodec.CompleteRecord firstSuffix = + fixture.log.append(FIRST, clear(2L, 3L)); + RevocationTransitionFrameCodec.CompleteRecord secondSuffix = + fixture.log.append(SECOND, clear(2L, 4L)); + FilesystemRevocationLog.RecoveryTarget target = fixture.log.recoveryTarget(); + fixture.log.append(THIRD, held(1L, 5L)); + AtomicInteger suffixReads = new AtomicInteger(); + AtomicLong firstOffset = new AtomicLong(Long.MIN_VALUE); + AtomicLong lastOffset = new AtomicLong(Long.MIN_VALUE); + AtomicInteger checkpointEntries = new AtomicInteger(); + AtomicInteger checkpointCandidates = new AtomicInteger(); + FilesystemRevocationCurrentIndex.FaultInjector observer = + new FilesystemRevocationCurrentIndex.FaultInjector() { + @Override + public void fail(FilesystemRevocationCurrentIndex.FaultPoint point) { + // Observation-only seam. + } + + @Override + public void observeRecoveryCheckpointEntry(long index) { + checkpointEntries.incrementAndGet(); + } + + @Override + public void observeRecoveryLogOffset(long offset) { + firstOffset.compareAndSet(Long.MIN_VALUE, offset); + lastOffset.set(offset); + suffixReads.incrementAndGet(); + } + + @Override + public void observeRecoveryCheckpointCandidate(long revision) { + checkpointCandidates.incrementAndGet(); + } + }; + try (FilesystemRevocationCurrentIndex recovered = fixture.recover( + target, observer)) { + assertEquals(4L, recovered.coveredGlobalRevision()); + assertEquals(2L, recovered.entryCount()); + assertEquals(RevocationState.CLEAR, + recovered.lookup(FIRST).orElseThrow().data().transition().state()); + assertEquals(RevocationState.CLEAR, + recovered.lookup(SECOND).orElseThrow().data().transition().state()); + assertTrue(recovered.lookup(THIRD).isEmpty()); + } + assertEquals(1, checkpointCandidates.get()); + assertEquals(2, checkpointEntries.get()); + assertEquals(2, suffixReads.get()); + assertEquals(firstSuffix.recordOffset(), firstOffset.get()); + assertEquals(secondSuffix.recordOffset(), lastOffset.get()); + try (FilesystemRevocationCurrentIndex advanced = + FilesystemRevocationCurrentIndex.open( + fixture.indexPath, fixture.logPath, STORE_ID, + INDEX_CONFIGURATION)) { + assertEquals(5L, advanced.coveredGlobalRevision()); + assertTrue(advanced.lookup(THIRD).isPresent()); + } + } + System.out.println("...ok"); + } + + @Test + void genesisTargetReplaysNoLaterAuthoritativeRecord() throws Exception { + System.out.print("genesisTargetReplaysNoLaterAuthoritativeRecord "); + try (Fixture fixture = fixture("genesis-target")) { + FilesystemRevocationLog.RecoveryTarget genesis = fixture.log.recoveryTarget(); + fixture.log.append(FIRST, held(1L, 1L)); + AtomicInteger suffixReads = new AtomicInteger(); + FilesystemRevocationCurrentIndex.FaultInjector observer = + new FilesystemRevocationCurrentIndex.FaultInjector() { + @Override + public void fail(FilesystemRevocationCurrentIndex.FaultPoint point) { + // Observation-only seam. + } + + @Override + public void observeRecoveryLogOffset(long offset) { + suffixReads.incrementAndGet(); + } + }; + try (FilesystemRevocationCurrentIndex recovered = fixture.recover( + genesis, observer)) { + assertEquals(0L, recovered.coveredGlobalRevision()); + assertEquals(0L, recovered.entryCount()); + assertTrue(recovered.lookup(FIRST).isEmpty()); + } + assertEquals(0, suffixReads.get()); + } + System.out.println("...ok"); + } + + @Test + void clearStateSurvivesCheckpointSeed() throws Exception { + System.out.print("clearStateSurvivesCheckpointSeed "); + try (Fixture fixture = fixture("clear")) { + fixture.log.append(FIRST, held(1L, 1L)); + fixture.log.append(FIRST, clear(2L, 2L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints, fixture.builderConfiguration()); + } + Files.delete(fixture.indexPath); + try (FilesystemRevocationCurrentIndex recovered = fixture.recover( + fixture.log.recoveryTarget(), + FilesystemRevocationCurrentIndex.FaultInjector.NONE)) { + assertEquals(1L, recovered.entryCount()); + assertEquals(RevocationState.CLEAR, + recovered.lookup(FIRST).orElseThrow().data().transition().state()); + } + } + System.out.println("...ok"); + } + + @Test + void appendAfterTargetCaptureIsExcludedUntilNormalOpen() throws Exception { + System.out.print("appendAfterTargetCaptureIsExcludedUntilNormalOpen "); + try (Fixture fixture = fixture("target-race")) { + fixture.log.append(FIRST, held(1L, 1L)); + FilesystemRevocationLog.RecoveryTarget target = fixture.log.recoveryTarget(); + fixture.log.append(SECOND, held(1L, 2L)); + try (FilesystemRevocationCurrentIndex recovered = fixture.recover( + target, FilesystemRevocationCurrentIndex.FaultInjector.NONE)) { + assertEquals(1L, recovered.coveredGlobalRevision()); + assertTrue(recovered.lookup(FIRST).isPresent()); + assertTrue(recovered.lookup(SECOND).isEmpty()); + } + try (FilesystemRevocationCurrentIndex advanced = + FilesystemRevocationCurrentIndex.open( + fixture.indexPath, fixture.logPath, STORE_ID, + INDEX_CONFIGURATION)) { + assertEquals(2L, advanced.coveredGlobalRevision()); + assertTrue(advanced.lookup(SECOND).isPresent()); + } + } + System.out.println("...ok"); + } + + @Test + void failedRecoveryPreservesPublishedIndex() throws Exception { + System.out.print("failedRecoveryPreservesPublishedIndex "); + try (Fixture fixture = fixture("preserve")) { + fixture.log.append(FIRST, held(1L, 1L)); + try (FilesystemRevocationCurrentIndex ignored = fixture.rebuild()) { + assertEquals(1L, ignored.coveredGlobalRevision()); + } + byte[] before = Files.readAllBytes(fixture.indexPath); + fixture.log.append(SECOND, held(1L, 2L)); + FilesystemRevocationCurrentIndex.FaultInjector faults = point -> { + if (point == FilesystemRevocationCurrentIndex.FaultPoint.REBUILD_FORCE) { + throw new IOException("injected recovery force failure"); + } + }; + assertThrows(IOException.class, () -> fixture.recover( + fixture.log.recoveryTarget(), faults)); + assertTrue(java.util.Arrays.equals( + before, Files.readAllBytes(fixture.indexPath))); + try (FilesystemRevocationCurrentIndex opened = + FilesystemRevocationCurrentIndex.open( + fixture.indexPath, fixture.logPath, STORE_ID, + INDEX_CONFIGURATION)) { + assertEquals(2L, opened.coveredGlobalRevision()); + } + } + System.out.println("...ok"); + } + + @Test + void validCurrentIndexIsPreferredWithoutCheckpointReads() throws Exception { + System.out.print("validCurrentIndexIsPreferredWithoutCheckpointReads "); + try (Fixture fixture = fixture("current-seed")) { + fixture.log.append(FIRST, held(1L, 1L)); + try (FilesystemRevocationCurrentIndex ignored = fixture.rebuild()) { + assertEquals(1L, ignored.coveredGlobalRevision()); + } + fixture.log.append(FIRST, clear(2L, 2L)); + AtomicInteger checkpointReads = new AtomicInteger(); + FilesystemRevocationCurrentIndex.FaultInjector observer = + new FilesystemRevocationCurrentIndex.FaultInjector() { + @Override + public void fail(FilesystemRevocationCurrentIndex.FaultPoint point) { + // Observation-only seam. + } + + @Override + public void observeRecoveryCheckpointEntry(long index) { + checkpointReads.incrementAndGet(); + } + }; + try (FilesystemRevocationCurrentIndex recovered = fixture.recover( + fixture.log.recoveryTarget(), observer)) { + assertEquals(2L, recovered.coveredGlobalRevision()); + assertEquals(RevocationState.CLEAR, + recovered.lookup(FIRST).orElseThrow().data().transition().state()); + } + assertEquals(0, checkpointReads.get()); + } + System.out.println("...ok"); + } + + @Test + void corruptCurrentIndexFallsBackToValidatedCheckpoint() throws Exception { + System.out.print("corruptCurrentIndexFallsBackToValidatedCheckpoint "); + try (Fixture fixture = fixture("corrupt-current")) { + fixture.log.append(FIRST, held(1L, 1L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints, fixture.builderConfiguration()); + } + Files.write(fixture.indexPath, new byte[] {0x01, 0x02, 0x03}); + AtomicInteger checkpointEntries = new AtomicInteger(); + FilesystemRevocationCurrentIndex.FaultInjector observer = + new FilesystemRevocationCurrentIndex.FaultInjector() { + @Override + public void fail(FilesystemRevocationCurrentIndex.FaultPoint point) { + // Observation-only seam. + } + + @Override + public void observeRecoveryCheckpointEntry(long index) { + checkpointEntries.incrementAndGet(); + } + }; + try (FilesystemRevocationCurrentIndex recovered = fixture.recover( + fixture.log.recoveryTarget(), observer)) { + assertEquals(1L, recovered.coveredGlobalRevision()); + assertEquals(RevocationState.HELD, + recovered.lookup(FIRST).orElseThrow().data().transition().state()); + } + assertEquals(1, checkpointEntries.get()); + } + System.out.println("...ok"); + } + + @Test + void foreignAndFutureTargetsFailWithoutChangingAuthority() throws Exception { + System.out.print("foreignAndFutureTargetsFailWithoutChangingAuthority "); + try (Fixture fixture = fixture("targets")) { + RevocationTransitionFrameCodec.CompleteRecord first = + fixture.log.append(FIRST, held(1L, 1L)); + byte[] logBytes = Files.readAllBytes(fixture.logPath); + MetadataStoreId foreign = + new MetadataStoreId("abcdef1234567890abcdef1234567890"); + FilesystemRevocationLog.RecoveryTarget foreignTarget = + new FilesystemRevocationLog.RecoveryTarget( + foreign, 0L, java.util.OptionalLong.empty(), + RevocationTransitionFrameCodec.PREAMBLE_BYTES, + RevocationTransitionFrameCodec.initialCommitment(foreign)); + assertThrows(IOException.class, () -> fixture.recover( + foreignTarget, FilesystemRevocationCurrentIndex.FaultInjector.NONE)); + FilesystemRevocationLog.RecoveryTarget future = + new FilesystemRevocationLog.RecoveryTarget( + STORE_ID, 2L, java.util.OptionalLong.of(first.recordOffset()), + first.recordEnd(), first.commitment()); + assertThrows(IOException.class, () -> fixture.recover( + future, FilesystemRevocationCurrentIndex.FaultInjector.NONE)); + assertTrue(java.util.Arrays.equals( + logBytes, Files.readAllBytes(fixture.logPath))); + assertFalse(Files.exists(fixture.indexPath)); + } + System.out.println("...ok"); + } + + @Test + void writeForceAndMoveFailuresLeaveNoPublishedPartialIndex() throws Exception { + System.out.print("writeForceAndMoveFailuresLeaveNoPublishedPartialIndex "); + List points = List.of( + FilesystemRevocationCurrentIndex.FaultPoint.REBUILD_WRITE, + FilesystemRevocationCurrentIndex.FaultPoint.REBUILD_FORCE, + FilesystemRevocationCurrentIndex.FaultPoint.PUBLISH_ATOMIC_MOVE); + for (FilesystemRevocationCurrentIndex.FaultPoint selected : points) { + try (Fixture fixture = fixture("fault-" + selected.name())) { + fixture.log.append(FIRST, held(1L, 1L)); + byte[] logBytes = Files.readAllBytes(fixture.logPath); + FilesystemRevocationCurrentIndex.FaultInjector faults = point -> { + if (point == selected) { + throw new IOException("injected recovery failure"); + } + }; + assertThrows(IOException.class, () -> fixture.recover( + fixture.log.recoveryTarget(), faults)); + assertFalse(Files.exists(fixture.indexPath)); + assertTrue(java.util.Arrays.equals( + logBytes, Files.readAllBytes(fixture.logPath))); + assertFalse(hasRecoveryTemporary(fixture.indexPath.getParent())); + try (FilesystemRevocationCurrentIndex recovered = fixture.recover( + fixture.log.recoveryTarget(), + FilesystemRevocationCurrentIndex.FaultInjector.NONE)) { + assertEquals(1L, recovered.coveredGlobalRevision()); + } + } + } + System.out.println("...ok"); + } + + @Test + void recoverySourcesHaveNoPopulationMapOrCollection() throws Exception { + System.out.print("recoverySourcesHaveNoPopulationMapOrCollection "); + String index = Files.readString(Path.of( + "src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java")); + String checkpoint = Files.readString(Path.of( + "src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java")); + assertFalse(index.contains("Map { }); + return new Fixture( + logPath, paths.revocationCurrentIndex(), + paths.revocationCheckpointDirectory(), + paths.revocationCheckpointWorkDirectory(), 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 boolean hasRecoveryTemporary(Path directory) throws IOException { + try (java.nio.file.DirectoryStream entries = + Files.newDirectoryStream(directory)) { + for (Path entry : entries) { + String name = entry.getFileName().toString(); + if (name.contains(".recovering-") || name.contains(".seed-")) { + return true; + } + } + return false; + } + } + + private record Fixture( + Path logPath, + Path indexPath, + Path checkpoints, + Path work, + FilesystemRevocationLog log) implements AutoCloseable { + private FilesystemRevocationCurrentIndex rebuild() throws IOException { + return FilesystemRevocationCurrentIndex.rebuild( + indexPath, logPath, STORE_ID, INDEX_CONFIGURATION); + } + + private FilesystemRevocationCurrentIndex recover( + FilesystemRevocationLog.RecoveryTarget target, + FilesystemRevocationCurrentIndex.FaultInjector faults) throws IOException { + return FilesystemRevocationCurrentIndex.recover( + indexPath, checkpoints, logPath, STORE_ID, + INDEX_CONFIGURATION, target, operations(), faults); + } + + private FilesystemRevocationCheckpointBuilder.Configuration builderConfiguration() { + return new FilesystemRevocationCheckpointBuilder.Configuration( + 128L, 2, 31, work); + } + + private static FilesystemRevocationCurrentIndex.PublicationOperations operations() { + return new FilesystemRevocationCurrentIndex.PublicationOperations() { + @Override + public void atomicReplace(Path source, Path target) throws IOException { + Files.move(source, target, + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + + @Override + public void forceDirectory(Path directory) { + // Test fixtures do not need an additional directory fsync. + } + }; + } + + @Override + public void close() throws IOException { + log.close(); + } + } +}