From 6d775b38178edee0bb129c7edb34e6ef618b11d5 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Sun, 2 Aug 2026 13:54:37 +0200 Subject: [PATCH] feat(pki): recover revocation index from checkpoints Recover the disk-backed revocation current-state index from the newest valid immutable checkpoint and replay only the authoritative log suffix. Preserve bounded memory, deterministic seed convergence, and the global transition log as the sole revocation authority. --- .../fs/FilesystemRevocationCheckpoint.java | 166 +++- .../fs/FilesystemRevocationCurrentIndex.java | 751 +++++++++++++++--- .../pki/impl/fs/FilesystemRevocationLog.java | 148 +++- ...temRevocationCurrentIndexRecoveryTest.java | 496 ++++++++++++ 4 files changed, 1426 insertions(+), 135 deletions(-) create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexRecoveryTest.java 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(); + } + } +}