From 8582ad6fd40f1b474f459232f64543d2eedad073 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Sun, 2 Aug 2026 12:56:16 +0200 Subject: [PATCH] feat(pki): build revocation checkpoints with bounded memory Build immutable revocation checkpoints from frozen current-index views using bounded external sorting and canonical multi-pass merging. Keep the transition log authoritative while preserving exact historical revision binding, atomic checkpoint publication, and bounded resources. --- .../fs/FilesystemRevocationCheckpoint.java | 95 +- ...FilesystemRevocationCheckpointBuilder.java | 934 ++++++++++++++++++ .../fs/FilesystemRevocationCurrentIndex.java | 339 ++++++- .../java/zeroecho/pki/impl/fs/FsPaths.java | 16 +- ...systemRevocationCheckpointBuilderTest.java | 893 +++++++++++++++++ .../FilesystemRevocationCurrentIndexTest.java | 6 +- 6 files changed, 2271 insertions(+), 12 deletions(-) create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointBuilder.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointBuilderTest.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 bd67adc..2b5fa10 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpoint.java @@ -114,6 +114,32 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { Objects.requireNonNull(faults, "faults"); Binding.requireCurrentPublication(recovery, coverage); Binding.validate(logPath, recovery, coverage); + return publishBound(directory, coverage, source, operations, faults, + checkpoint -> Binding.validateGeneration(checkpoint, logPath, recovery)); + } + + /* default */ static PublishedGeneration publishHistorical( + Path directory, + FilesystemRevocationCurrentIndex.FrozenSnapshot frozen, + SortedSource source) throws IOException { + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(frozen, "frozen"); + Objects.requireNonNull(source, "source"); + Coverage coverage = frozen.coverage(); + return publishBound( + directory, coverage, source, + DefaultPublicationOperations.INSTANCE, FaultInjector.NONE, + checkpoint -> HistoricalBinding.validateGeneration( + checkpoint, frozen)); + } + + private static PublishedGeneration publishBound( + Path directory, + Coverage coverage, + SortedSource source, + PublicationOperations operations, + FaultInjector faults, + GenerationValidator validator) throws IOException { Files.createDirectories(directory); requireDirectory(directory); long declaredCount = source.entryCount(); @@ -126,7 +152,7 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { Path target = directory.resolve(finalName(coverage.coveredRevision(), build.generationId())); boolean published = false; try { - Binding.validateGeneration(temporary, logPath, recovery); + validator.validate(temporary); published = publishAtomically(temporary, target, operations, faults); forceDirectory(directory, operations, faults); return new PublishedGeneration(target, build.generationId(), coverage.coveredRevision(), published); @@ -142,6 +168,13 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { } } + /** Exact post-build binding check selected by the publication boundary. */ + @FunctionalInterface + private interface GenerationValidator { + /** Validates the completed temporary generation before publication. */ + void validate(Path checkpoint) throws IOException; + } + /* default */ static Optional discover( Path directory, Path logPath, @@ -652,6 +685,66 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable { } + /** Exact set-equality proof between a generation and its immutable frozen index. */ + private static final class HistoricalBinding { + private static void validateGeneration( + Path checkpointPath, + FilesystemRevocationCurrentIndex.FrozenSnapshot frozen) throws IOException { + try (FileChannel checkpoint = FileChannel.open( + checkpointPath, StandardOpenOption.READ)) { + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.ValidatedFile validated = codec.validate(checkpoint); + Coverage expectedCoverage = frozen.coverage(); + Coverage actualCoverage = Coverage.from(validated.header()); + if (!actualCoverage.equals(expectedCoverage)) { + throw new IOException("Checkpoint coverage changed during construction"); + } + if (validated.header().entryCount() != frozen.entryCount()) { + throw new IOException("Checkpoint count differs from its frozen index"); + } + validateEntries(frozen, checkpoint, codec, validated); + } + } + + private static void validateEntries( + FilesystemRevocationCurrentIndex.FrozenSnapshot frozen, + FileChannel checkpoint, + RevocationCheckpointCodec checkpointCodec, + RevocationCheckpointCodec.ValidatedFile validated) throws IOException { + long offset = RevocationCheckpointCodec.HEADER_BYTES; + RevocationCheckpointCodec.SequentialDecoder decoder = + checkpointCodec.sequentialDecoder(); + for (long index = 0L; index < validated.header().entryCount(); index++) { + RevocationCheckpointCodec.DecodedRecord decoded = decoder.read( + checkpoint, offset, validated.trailerOffset()); + validateEntry(frozen, decoded.entry()); + offset = decoded.nextOffset(); + } + } + + private static void validateEntry( + FilesystemRevocationCurrentIndex.FrozenSnapshot frozen, + RevocationCheckpointCodec.CurrentStateEntry entry) throws IOException { + RevocationTransitionFrameCodec.CompleteRecord frame = frozen.lookup( + entry.credentialId()).orElseThrow( + () -> new IOException( + "Checkpoint entry is absent from its frozen index")); + if (!frame.data().credentialId().equals(entry.credentialId()) + || frame.data().globalRevision() != entry.globalRevision() + || frame.data().transition().revision() != entry.credentialRevision() + || !frame.commitment().equals(entry.transitionCommitment()) + || !RevocationTransitionFrameCodec.transitionsEqual( + frame.data().transition(), entry.transition()) + || !new FrameBounds(frame.recordOffset(), frame.recordEnd()).equals( + new FrameBounds(entry.frameStart(), entry.frameEnd()))) { + throw new IOException("Checkpoint entry disagrees with its frozen current state"); + } + } + + private record FrameBounds(long start, long end) { + } + } + /** Streaming discovery keeps one candidate descriptor and repeats only for ambiguity fallback. */ private static final class Discovery { private static Optional discover( diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointBuilder.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointBuilder.java new file mode 100644 index 0000000..fd78196 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointBuilder.java @@ -0,0 +1,934 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.PriorityQueue; +import java.util.Queue; +import java.util.UUID; +import java.util.logging.Logger; + +/** + * Builds one immutable checkpoint from a frozen disk copy of the derived current index. + * + *

Run formation and merge fan-in are explicitly bounded. Temporary run files reuse + * the strict checkpoint record codec but remain outside the discoverable checkpoint + * directory. The authoritative revocation log is only read and is never modified.

+ */ +final class FilesystemRevocationCheckpointBuilder { + + private static final Logger LOGGER = + Logger.getLogger(FilesystemRevocationCheckpointBuilder.class.getName()); + private static final String CLEANUP_WARNING = + "Revocation checkpoint temporary cleanup was incomplete"; + private static final long ZERO = 0L; + + private FilesystemRevocationCheckpointBuilder() { + // Static package implementation. + } + + /* default */ static FilesystemRevocationCheckpoint.PublishedGeneration build( + FilesystemRevocationCurrentIndex index, + Path checkpointDirectory, + Configuration configuration) throws IOException { + return build(index, checkpointDirectory, configuration, FaultInjector.NONE); + } + + /* default */ static FilesystemRevocationCheckpoint.PublishedGeneration build( + FilesystemRevocationCurrentIndex index, + Path checkpointDirectory, + Configuration configuration, + FaultInjector faults) throws IOException { + Objects.requireNonNull(index, "index"); + Objects.requireNonNull(checkpointDirectory, "checkpointDirectory"); + Objects.requireNonNull(configuration, "configuration"); + Objects.requireNonNull(faults, "faults"); + configuration.requireValid(); + try (Workspace workspace = Workspace.create( + configuration.temporaryDirectory(), faults)) { + return buildInWorkspace( + index, checkpointDirectory, configuration, faults, workspace); + } + } + + private static FilesystemRevocationCheckpoint.PublishedGeneration buildInWorkspace( + FilesystemRevocationCurrentIndex index, + Path checkpointDirectory, + Configuration configuration, + FaultInjector faults, + Workspace workspace) throws IOException { + faults.fail(FaultPoint.BEFORE_FREEZE); + try (FrozenBuild frozenBuild = FrozenBuild.open( + index, workspace.frozenPath(), configuration.transferBufferBytes(), faults)) { + FilesystemRevocationCheckpoint.PublishedGeneration result = buildFrozen( + checkpointDirectory, configuration, faults, workspace, + frozenBuild.snapshot()); + workspace.markOutcomeAuthoritative(); + frozenBuild.markOutcomeAuthoritative(); + return result; + } + } + + private static FilesystemRevocationCheckpoint.PublishedGeneration buildFrozen( + Path checkpointDirectory, + Configuration configuration, + FaultInjector faults, + Workspace workspace, + FilesystemRevocationCurrentIndex.FrozenSnapshot frozen) throws IOException { + RunFormation formation = new RunFormation( + workspace, frozen.coverage(), configuration, faults); + long runCount = formation.createRuns(frozen); + Path finalRun = new MergePass( + workspace, frozen.coverage(), configuration, faults) + .merge(runCount); + faults.fail(FaultPoint.BEFORE_PUBLICATION); + FilesystemRevocationCheckpoint.SortedSource source = runCount == ZERO + ? new EmptySource() : new RunSource(finalRun); + return FilesystemRevocationCheckpoint.publishHistorical( + checkpointDirectory, frozen, source); + } + + private static void warnCleanup() { + try { + LOGGER.warning(CLEANUP_WARNING); + } catch (IllegalStateException ignored) { + // Advisory logging cannot change an already published derived checkpoint. + } + } + + /** Explicit resource bounds and temporary location for one build. */ + /* default */ record Configuration( + long maxEncodedRunBytes, + int maxOpenMergeInputs, + int transferBufferBytes, + Path temporaryDirectory) { + Configuration { + Objects.requireNonNull(temporaryDirectory, "temporaryDirectory"); + if (maxEncodedRunBytes <= ZERO || maxOpenMergeInputs < 2 + || transferBufferBytes <= 0) { + throw new IllegalArgumentException("Invalid revocation checkpoint builder bounds"); + } + } + + private void requireValid() throws IOException { + if (Files.exists(temporaryDirectory, LinkOption.NOFOLLOW_LINKS) + && (!Files.isDirectory(temporaryDirectory, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(temporaryDirectory))) { + throw new IOException("Revocation checkpoint temporary root is invalid"); + } + } + } + + /** Deterministic builder lifecycle faults; not production API. */ + /* default */ @FunctionalInterface + interface FaultInjector { + FaultInjector NONE = point -> { }; + + /** Fails at one deterministic lifecycle boundary. */ + void fail(FaultPoint point) throws IOException; + + /** Observes the bounded entry buffer after one encoded entry is admitted. */ + default void observeRunBuffer(long entryCount, long encodedBytes) { + // Optional structural test observation. + } + + /** Observes one merge group after all of its bounded readers are open. */ + default void observeOpenMergeInputs(int openInputs) { + // Optional structural test observation. + } + + /** Observes one completed temporary run and its strict encoded size. */ + default void observeTemporaryRun(int pass, long entryCount, long encodedBytes) { + // Optional structural test observation. + } + + /** Observes population conservation across one complete merge pass. */ + default void observeMergePass( + int pass, + long inputRuns, + long outputRuns, + long inputEntries, + long outputEntries) { + // Optional structural test observation. + } + + /** Records one strict UTF-8 encoding used to create a merge head. */ + default void observeMergeHeadEncoding() { + // Optional structural test observation. + } + + /** Observes one completed strict run before it is consumed or published. */ + default void afterRunWritten(Path run) throws IOException { + // Optional deterministic corruption seam. + } + } + + /** Finite fault points for frozen-copy, run, merge, publication, and cleanup tests. */ + /* default */ enum FaultPoint { + BEFORE_FREEZE, + FROZEN_COPY, + FROZEN_FORCE, + FROZEN_CLOSE, + RUN_WRITE, + RUN_FORCE, + MERGE_WRITE, + MERGE_FORCE, + BEFORE_PUBLICATION, + CLEANUP + } + + /** Forms individually authenticated sorted runs with a bounded entry buffer. */ + private static final class RunFormation { + private final Workspace workspace; + private final FilesystemRevocationCheckpoint.Coverage coverage; + private final Configuration configuration; + private final FaultInjector faults; + + private RunFormation( + Workspace workspace, + FilesystemRevocationCheckpoint.Coverage coverage, + Configuration configuration, + FaultInjector faults) { + this.workspace = workspace; + this.coverage = coverage; + this.configuration = configuration; + this.faults = faults; + } + + private long createRuns(FilesystemRevocationCurrentIndex.FrozenSnapshot frozen) + throws IOException { + if (frozen.entryCount() == ZERO) { + return ZERO; + } + long run = ZERO; + try (FilesystemRevocationCurrentIndex.FrozenCursor cursor = frozen.openCursor(); + RunAccumulator accumulator = new RunAccumulator( + workspace.measurementPath(), coverage, + configuration.maxEncodedRunBytes(), faults)) { + while (cursor.advance()) { + if (accumulator.add(cursor.current())) { + writeRun(accumulator.drain(), workspace.runPath(0, run)); + run = addExact(run, 1L, "Revocation checkpoint run count overflow"); + } + } + if (!accumulator.isEmpty()) { + writeRun(accumulator.drain(), workspace.runPath(0, run)); + run = addExact(run, 1L, "Revocation checkpoint run count overflow"); + } + } + return run; + } + + private void writeRun(List entries, Path path) throws IOException { + entries.sort(SortableEntry.ORDER); + byte[] previous = null; + try (FileChannel channel = FileChannel.open(path, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, + StandardOpenOption.WRITE)) { + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.Encoder encoder = codec.encoder( + channel, header(coverage, entries.size())); + for (SortableEntry sortable : entries) { + if (previous != null + && RevocationCheckpointCodec.compareUnsigned( + previous, sortable.identity()) >= 0) { + throw new IOException("Frozen revocation index contains duplicate identities"); + } + faults.fail(FaultPoint.RUN_WRITE); + encoder.write(sortable.entry()); + previous = sortable.identity(); + } + encoder.finish(); + faults.fail(FaultPoint.RUN_FORCE); + channel.force(true); + codec.validate(channel); + } + faults.observeTemporaryRun(0, entries.size(), Files.size(path)); + faults.afterRunWritten(path); + } + } + + /** Measures actual encoded records while retaining only one configured run. */ + private static final class RunAccumulator implements AutoCloseable { + private final Path measurementPath; + private final FilesystemRevocationCheckpoint.Coverage coverage; + private final long limit; + private final FaultInjector faults; + private List entries = new ArrayList<>(); + private FileChannel channel; + private RevocationCheckpointCodec.Encoder encoder; + + private RunAccumulator( + Path measurementPath, + FilesystemRevocationCheckpoint.Coverage coverage, + long limit, + FaultInjector faults) throws IOException { + this.measurementPath = measurementPath; + this.coverage = coverage; + this.limit = limit; + this.faults = faults; + openMeasurement(); + } + + private boolean add(RevocationCheckpointCodec.CurrentStateEntry entry) + throws IOException { + encoder.write(entry); + entries.add(new SortableEntry( + RevocationCheckpointCodec.strictUtf8(entry.credentialId().value()), entry)); + long encodedBytes = channel.position() - RevocationCheckpointCodec.HEADER_BYTES; + faults.observeRunBuffer(entries.size(), encodedBytes); + return encodedBytes >= limit; + } + + private boolean isEmpty() { + return entries.isEmpty(); + } + + private List drain() throws IOException { + List result = entries; + entries = new ArrayList<>(); + channel.close(); + Files.deleteIfExists(measurementPath); + openMeasurement(); + return result; + } + + private void openMeasurement() throws IOException { + channel = FileChannel.open(measurementPath, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, + StandardOpenOption.WRITE); + encoder = new RevocationCheckpointCodec().encoder( + channel, header(coverage, Long.MAX_VALUE)); + } + + @Override + public void close() throws IOException { + IOException failure = null; + try { + channel.close(); + } catch (IOException closeFailure) { + failure = closeFailure; + } + try { + Files.deleteIfExists(measurementPath); + } catch (IOException cleanupFailure) { + failure = appendFailure(failure, cleanupFailure); + } + if (failure != null) { + throw failure; + } + } + } + + private record SortableEntry( + byte[] identity, + RevocationCheckpointCodec.CurrentStateEntry entry) { + private static final Comparator ORDER = + (first, second) -> RevocationCheckpointCodec.compareUnsigned( + first.identity, second.identity); + + private SortableEntry { + identity = identity.clone(); + Objects.requireNonNull(entry, "entry"); + } + + @Override + public byte[] identity() { + return identity.clone(); + } + } + + /** Multi-pass bounded fan-in merge over strict temporary run files. */ + private static final class MergePass { + private final Workspace workspace; + private final FilesystemRevocationCheckpoint.Coverage coverage; + private final Configuration configuration; + private final FaultInjector faults; + + private MergePass( + Workspace workspace, + FilesystemRevocationCheckpoint.Coverage coverage, + Configuration configuration, + FaultInjector faults) { + this.workspace = workspace; + this.coverage = coverage; + this.configuration = configuration; + this.faults = faults; + } + + private Path merge(long initialRunCount) throws IOException { + long runCount = initialRunCount; + int pass = 0; + while (runCount > 1L) { + long outputCount = ZERO; + long inputEntries = ZERO; + long outputEntries = ZERO; + long first = ZERO; + while (first < runCount) { + int groupSize = (int) Math.min( + (long) configuration.maxOpenMergeInputs(), runCount - first); + Path output = workspace.runPath( + addExact(pass, 1, "Revocation checkpoint merge pass overflow"), + outputCount); + long mergedEntries = mergeGroup(pass, first, groupSize, output); + inputEntries = addExact( + inputEntries, mergedEntries, + "Revocation checkpoint merge input population overflow"); + outputEntries = addExact( + outputEntries, mergedEntries, + "Revocation checkpoint merge output population overflow"); + outputCount = addExact( + outputCount, 1L, "Revocation checkpoint merge count overflow"); + first = addExact(first, groupSize, + "Revocation checkpoint merge offset overflow"); + } + int completedPass = addExact( + pass, 1, "Revocation checkpoint merge pass overflow"); + faults.observeMergePass( + completedPass, runCount, outputCount, inputEntries, outputEntries); + runCount = outputCount; + pass = completedPass; + } + return initialRunCount == ZERO ? null : workspace.runPath(pass, ZERO); + } + + private long mergeGroup( + int pass, long first, int groupSize, Path output) throws IOException { + long count; + try (MergeInputs inputs = MergeInputs.open( + workspace, pass, first, groupSize, faults)) { + count = inputs.entryCount(); + try (FileChannel channel = FileChannel.open(output, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, + StandardOpenOption.WRITE)) { + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.Encoder encoder = + codec.encoder(channel, header(coverage, count)); + Queue queue = inputs.initialQueue(faults); + byte[] previous = null; + while (!queue.isEmpty()) { + RunHead head = queue.remove(); + byte[] identity = head.identity; + if (previous != null + && RevocationCheckpointCodec.compareUnsigned( + previous, identity) >= 0) { + throw new IOException("Revocation checkpoint merge contains duplicates"); + } + faults.fail(FaultPoint.MERGE_WRITE); + encoder.write(head.entry()); + previous = identity; + if (head.input().advance()) { + queue.add(RunHead.create( + head.input(), head.input().current(), faults)); + } + } + encoder.finish(); + faults.fail(FaultPoint.MERGE_FORCE); + channel.force(true); + codec.validate(channel); + } + } + for (int index = 0; index < groupSize; index++) { + Files.delete(workspace.runPath(pass, addExact( + first, index, "Revocation checkpoint merge input overflow"))); + } + faults.observeTemporaryRun( + addExact(pass, 1, "Revocation checkpoint merge pass overflow"), + count, Files.size(output)); + return count; + } + } + + /** Owns at most the configured fan-in of run readers. */ + private static final class MergeInputs implements AutoCloseable { + private final List inputs; + private final long entryCount; + + private MergeInputs(List inputs, long entryCount) { + this.inputs = inputs; + this.entryCount = entryCount; + } + + private static MergeInputs open( + Workspace workspace, + int pass, + long first, + int groupSize, + FaultInjector faults) throws IOException { + List inputs = new ArrayList<>(groupSize); + long count = ZERO; + try { + for (int index = 0; index < groupSize; index++) { + RunCursor input = RunCursor.open( + workspace.runPath(pass, addExact( + first, index, + "Revocation checkpoint merge input overflow"))); + inputs.add(input); + count = addExact(count, input.entryCount(), + "Revocation checkpoint merge entry count overflow"); + } + faults.observeOpenMergeInputs(inputs.size()); + return new MergeInputs(inputs, count); + } catch (IOException failure) { + closeInputs(inputs, failure); + throw failure; + } + } + + private long entryCount() { + return entryCount; + } + + private Queue initialQueue(FaultInjector faults) throws IOException { + Queue queue = new PriorityQueue<>( + Math.max(1, inputs.size()), RunHead.ORDER); + for (RunCursor input : inputs) { + if (input.advance()) { + queue.add(RunHead.create(input, input.current(), faults)); + } + } + return queue; + } + + @Override + public void close() throws IOException { + IOException failure = null; + for (RunCursor input : inputs) { + try { + input.close(); + } catch (IOException closeFailure) { + failure = appendFailure(failure, closeFailure); + } + } + if (failure != null) { + throw failure; + } + } + + private static void closeInputs(List inputs, IOException failure) { + for (RunCursor input : inputs) { + try { + input.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + } + } + + private record RunHead( + RunCursor input, + RevocationCheckpointCodec.CurrentStateEntry entry, + byte[] identity) { + private static final Comparator ORDER = (first, second) -> + RevocationCheckpointCodec.compareUnsigned( + first.identity, second.identity); + + private RunHead { + Objects.requireNonNull(input, "input"); + Objects.requireNonNull(entry, "entry"); + identity = identity.clone(); + } + + private static RunHead create( + RunCursor input, + RevocationCheckpointCodec.CurrentStateEntry entry, + FaultInjector faults) { + faults.observeMergeHeadEncoding(); + return new RunHead(input, entry, RevocationCheckpointCodec.strictUtf8( + entry.credentialId().value())); + } + + @Override + public byte[] identity() { + return identity.clone(); + } + } + + /** Sequential reader for one validated temporary run. */ + private static final class RunCursor { + private final FileChannel channel; + private final RevocationCheckpointCodec.SequentialDecoder decoder; + private final long trailerOffset; + private final long entryCount; + private long offset = RevocationCheckpointCodec.HEADER_BYTES; + private long readCount; + private RevocationCheckpointCodec.CurrentStateEntry current; + + private RunCursor( + FileChannel channel, + RevocationCheckpointCodec.SequentialDecoder decoder, + long trailerOffset, + long entryCount) { + this.channel = channel; + this.decoder = decoder; + this.trailerOffset = trailerOffset; + this.entryCount = entryCount; + } + + private static RunCursor open(Path path) throws IOException { + FileChannel channel = FileChannel.open(path, StandardOpenOption.READ); + try { + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.ValidatedFile validated = codec.validate(channel); + return new RunCursor( + channel, codec.sequentialDecoder(), validated.trailerOffset(), + validated.header().entryCount()); + } catch (IOException failure) { + try { + channel.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + private long entryCount() { + return entryCount; + } + + private boolean advance() throws IOException { + if (readCount == entryCount) { + current = null; + return false; + } + RevocationCheckpointCodec.DecodedRecord decoded = + decoder.read(channel, offset, trailerOffset); + current = decoded.entry(); + offset = decoded.nextOffset(); + readCount = addExact( + readCount, 1L, "Revocation checkpoint run count overflow"); + return true; + } + + private RevocationCheckpointCodec.CurrentStateEntry current() { + if (current == null) { + throw new IllegalStateException("Temporary revocation run has no current entry"); + } + return current; + } + + private void close() throws IOException { + channel.close(); + } + } + + /** Final merged run transferred to the existing checkpoint publisher. */ + private static final class RunSource implements FilesystemRevocationCheckpoint.SortedSource { + private final Path path; + private final long count; + private boolean opened; + private boolean closed; + + private RunSource(Path path) throws IOException { + this.path = Objects.requireNonNull(path, "path"); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + count = new RevocationCheckpointCodec().validate(channel).header().entryCount(); + } + } + + @Override + public long entryCount() { + requireOpen(); + return count; + } + + @Override + public FilesystemRevocationCheckpoint.SourceCursor openCursor() throws IOException { + requireOpen(); + if (opened) { + throw new IllegalStateException("Revocation run source cursor is already opened"); + } + opened = true; + return new SourceCursorAdapter(RunCursor.open(path)); + } + + @Override + public void close() { + closed = true; + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Revocation run source is closed"); + } + } + } + + /** Transfers one temporary run reader into the checkpoint source cursor contract. */ + private static final class SourceCursorAdapter + implements FilesystemRevocationCheckpoint.SourceCursor { + private final RunCursor cursor; + + private SourceCursorAdapter(RunCursor cursor) { + this.cursor = cursor; + } + + @Override + public boolean advance() throws IOException { + return cursor.advance(); + } + + @Override + public RevocationCheckpointCodec.CurrentStateEntry current() { + return cursor.current(); + } + + @Override + public void close() throws IOException { + cursor.close(); + } + } + + /** Zero-entry source for an authenticated genesis checkpoint. */ + private static final class EmptySource implements FilesystemRevocationCheckpoint.SortedSource { + @Override + public long entryCount() { + return ZERO; + } + + @Override + public FilesystemRevocationCheckpoint.SourceCursor openCursor() { + return new EmptyCursor(); + } + + @Override + public void close() { + // No resource. + } + } + + /** Stateless cursor paired with the zero-entry source. */ + private static final class EmptyCursor implements FilesystemRevocationCheckpoint.SourceCursor { + @Override + public boolean advance() { + return false; + } + + @Override + public RevocationCheckpointCodec.CurrentStateEntry current() { + throw new IllegalStateException("Empty revocation source has no current entry"); + } + + @Override + public void close() { + // No resource. + } + } + + /** One isolated build directory; cleanup never touches published generations. */ + private static final class Workspace implements AutoCloseable { + private final Path root; + private final FaultInjector faults; + private boolean closed; + private boolean outcomeAuthoritative; + + private Workspace(Path root, FaultInjector faults) { + this.root = root; + this.faults = faults; + } + + private static Workspace create(Path temporaryRoot, FaultInjector faults) + throws IOException { + Files.createDirectories(temporaryRoot); + if (!Files.isDirectory(temporaryRoot, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(temporaryRoot)) { + throw new IOException("Revocation checkpoint temporary root is invalid"); + } + Path root = temporaryRoot.resolve(".checkpoint-build-" + UUID.randomUUID()); + Files.createDirectory(root); + return new Workspace(root, faults); + } + + private Path frozenPath() { + return root.resolve("frozen-index"); + } + + private Path measurementPath() { + return root.resolve("measurement"); + } + + private Path runPath(int pass, long run) { + return root.resolve("p-" + pass + "-r-" + run + ".run"); + } + + private void markOutcomeAuthoritative() { + outcomeAuthoritative = true; + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + try { + faults.fail(FaultPoint.CLEANUP); + } catch (IOException cleanupFailure) { + failure = cleanupFailure; + } + try (DirectoryStream paths = Files.newDirectoryStream(root)) { + for (Path path : paths) { + try { + Files.deleteIfExists(path); + } catch (IOException cleanupFailure) { + failure = appendFailure(failure, cleanupFailure); + } + } + } catch (IOException cleanupFailure) { + failure = appendFailure(failure, cleanupFailure); + } + try { + Files.deleteIfExists(root); + } catch (IOException cleanupFailure) { + failure = appendFailure(failure, cleanupFailure); + } + if (failure != null) { + if (outcomeAuthoritative) { + warnCleanup(); + } else { + throw failure; + } + } + } + } + + /** Owns the immutable index copy and preserves an established publication outcome. */ + private static final class FrozenBuild implements AutoCloseable { + private final FilesystemRevocationCurrentIndex.FrozenSnapshot snapshot; + private final FaultInjector faults; + private boolean outcomeAuthoritative; + private boolean closed; + + private FrozenBuild( + FilesystemRevocationCurrentIndex.FrozenSnapshot snapshot, + FaultInjector faults) { + this.snapshot = snapshot; + this.faults = faults; + } + + private static FrozenBuild open( + FilesystemRevocationCurrentIndex index, + Path frozenPath, + int transferBufferBytes, + FaultInjector faults) throws IOException { + FilesystemRevocationCurrentIndex.FrozenSnapshot snapshot = index.freeze( + frozenPath, transferBufferBytes, point -> { + if (point == FilesystemRevocationCurrentIndex.FrozenFaultPoint.COPY) { + faults.fail(FaultPoint.FROZEN_COPY); + } else { + faults.fail(FaultPoint.FROZEN_FORCE); + } + }); + return new FrozenBuild(snapshot, faults); + } + + private FilesystemRevocationCurrentIndex.FrozenSnapshot snapshot() { + return snapshot; + } + + private void markOutcomeAuthoritative() { + outcomeAuthoritative = true; + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + try { + faults.fail(FaultPoint.FROZEN_CLOSE); + } catch (IOException injected) { + failure = injected; + } + try { + snapshot.close(); + } catch (IOException closeFailure) { + failure = appendFailure(failure, closeFailure); + } + if (failure != null) { + if (outcomeAuthoritative) { + warnCleanup(); + } else { + throw failure; + } + } + } + } + + private static RevocationCheckpointCodec.HeaderData header( + FilesystemRevocationCheckpoint.Coverage coverage, long count) { + return new RevocationCheckpointCodec.HeaderData( + coverage.storeId(), coverage.coveredRevision(), coverage.finalRecordStart(), + coverage.coveredBoundary(), coverage.globalCommitment(), count); + } + + private static long addExact(long first, long second, String message) throws IOException { + try { + return Math.addExact(first, second); + } catch (ArithmeticException overflow) { + throw new IOException(message, overflow); + } + } + + private static int addExact(int first, int second, String message) throws IOException { + try { + return Math.addExact(first, second); + } catch (ArithmeticException overflow) { + throw new IOException(message, overflow); + } + } + + private static IOException appendFailure(IOException primary, IOException secondary) { + if (primary == null) { + return secondary; + } + primary.addSuppressed(secondary); + return primary; + } +} 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 eae6f79..a6be2c3 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java @@ -111,6 +111,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { private static final HexFormat HEX = HexFormat.of(); private final Path indexPath; + private final Path logPath; private final MetadataStoreId storeId; private final Configuration configuration; private final PublicationOperations operations; @@ -126,6 +127,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { private FilesystemRevocationCurrentIndex( Path indexPath, + Path logPath, MetadataStoreId storeId, Configuration configuration, PublicationOperations operations, @@ -135,6 +137,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { FileChannel logChannel, SelectedSuperblock selected) { this.indexPath = indexPath; + this.logPath = logPath; this.storeId = storeId; this.configuration = configuration; this.operations = operations; @@ -172,13 +175,14 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { IoOperations.requireRegular(indexPath, "Revocation current index"); IoOperations.requireRegular(logPath, "Revocation transition log"); try (OpenResources resources = OpenResources.acquire(indexPath, logPath)) { - return openLocked(indexPath, expectedStoreId, configuration, + return openLocked(indexPath, logPath, expectedStoreId, configuration, operations, faults, resources); } } private static FilesystemRevocationCurrentIndex openLocked( Path indexPath, + Path logPath, MetadataStoreId expectedStoreId, Configuration configuration, PublicationOperations operations, @@ -191,7 +195,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { SelectedSuperblock selected = CandidateSelection.select( resources, expectedStoreId, candidates); FilesystemRevocationCurrentIndex opened = new FilesystemRevocationCurrentIndex( - indexPath, expectedStoreId, configuration, operations, faults, + indexPath, logPath, expectedStoreId, configuration, operations, faults, resources.indexChannel(), resources.stableLock(), resources.logChannel(), selected); opened.new IndexAccess().applySuffix(); @@ -241,7 +245,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { IoOperations.forceDirectory(parent, operations, faults); try (OpenResources resources = OpenResources.acquireLocked( stable, indexPath, logPath)) { - return openLocked(indexPath, expectedStoreId, configuration, + return openLocked(indexPath, logPath, expectedStoreId, configuration, operations, faults, resources); } } finally { @@ -322,6 +326,43 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { } } + /* default */ FrozenSnapshot freeze(Path snapshotPath, int transferBufferBytes) + throws IOException { + return freeze(snapshotPath, transferBufferBytes, FrozenFaultInjector.NONE); + } + + /* default */ FrozenSnapshot freeze( + Path snapshotPath, + int transferBufferBytes, + FrozenFaultInjector faults) throws IOException { + Objects.requireNonNull(snapshotPath, "snapshotPath"); + Objects.requireNonNull(faults, "faults"); + if (transferBufferBytes <= 0) { + throw new IllegalArgumentException("Freeze transfer buffer must be positive"); + } + lifecycleLock.lock(); + try { + requireOperational(); + try (FrozenResources resources = FrozenResources.create(snapshotPath, logPath)) { + long expectedSize = IoOperations.expectedSize(active.capacity()); + faults.fail(FrozenFaultPoint.COPY); + IoOperations.copyExact( + indexChannel, resources.indexChannel(), expectedSize, transferBufferBytes); + faults.fail(FrozenFaultPoint.FORCE); + resources.indexChannel().force(true); + IndexValidation.validate( + resources.indexChannel(), resources.logChannel(), storeId, active); + FrozenSnapshot snapshot = new FrozenSnapshot( + snapshotPath, logPath, + resources.indexChannel(), resources.logChannel(), active); + resources.transferOwnership(); + return snapshot; + } + } finally { + lifecycleLock.unlock(); + } + } + @Override public void close() throws IOException { lifecycleLock.lock(); @@ -879,6 +920,34 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { } } + private static void copyExact( + FileChannel source, + FileChannel target, + long length, + int transferBufferBytes) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(transferBufferBytes); + long offset = 0L; + while (offset < length) { + buffer.clear(); + buffer.limit((int) Math.min((long) buffer.capacity(), length - offset)); + int read = source.read(buffer, offset); + if (read <= 0) { + throw new IOException("Revocation current-index freeze made no read progress"); + } + buffer.flip(); + long writeOffset = offset; + while (buffer.hasRemaining()) { + int written = target.write(buffer, writeOffset); + if (written <= 0) { + throw new IOException("Revocation current-index freeze made no write progress"); + } + writeOffset += written; + } + offset = writeOffset; + } + target.truncate(length); + } + private static void requireExpectedSize(FileChannel channel, long capacity) throws IOException { if (channel.size() != expectedSize(capacity)) { throw new IOException("Revocation current index physical size is invalid"); @@ -1428,6 +1497,270 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable { } } + /** Deterministic frozen-copy failure seam; package access is intentional for tests. */ + /* default */ @FunctionalInterface + interface FrozenFaultInjector { + FrozenFaultInjector NONE = point -> { }; + + /** Fails at one copy or force boundary. */ + void fail(FrozenFaultPoint point) throws IOException; + } + + /** Finite lifecycle boundaries for the frozen derived-index copy. */ + /* default */ enum FrozenFaultPoint { + COPY, + FORCE + } + + /** Immutable physical-slot copy used by bounded checkpoint construction. */ + /* default */ static final class FrozenSnapshot implements AutoCloseable { + private final Path path; + private final Path logPath; + private final FileChannel indexChannel; + private final FileChannel logChannel; + private final Superblock state; + private boolean closed; + + private FrozenSnapshot( + Path path, + Path logPath, + FileChannel indexChannel, + FileChannel logChannel, + Superblock state) { + this.path = path; + this.logPath = logPath; + this.indexChannel = indexChannel; + this.logChannel = logChannel; + this.state = state; + } + + /* default */ long entryCount() { + requireOpen(); + return state.entryCount(); + } + + /* default */ FilesystemRevocationCheckpoint.Coverage coverage() { + requireOpen(); + return new FilesystemRevocationCheckpoint.Coverage( + state.storeId(), state.coveredRevision(), state.finalRecordStart(), + state.coveredBoundary(), state.coveredCommitment()); + } + + /* default */ FrozenCursor openCursor() { + requireOpen(); + return new PhysicalCursor(this); + } + + /* default */ Path logPath() { + requireOpen(); + return logPath; + } + + /* default */ Optional lookup( + PkiId credentialId) throws IOException { + Objects.requireNonNull(credentialId, "credentialId"); + requireOpen(); + long slot = IndexValidation.probeSlot( + indexChannel, logChannel, state, credentialId); + if (slot == NO_SLOT) { + return Optional.empty(); + } + Cell cell = IndexOperations.activeCell( + indexChannel, slot, state.coveredRevision()).cell(); + return Optional.of(IndexValidation.readFrame(logChannel, cell)); + } + + private void requireOpen() { + if (closed || !indexChannel.isOpen() || !logChannel.isOpen()) { + throw new IllegalStateException("Frozen revocation current index is closed"); + } + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + try { + logChannel.close(); + } catch (IOException closeFailure) { + failure = closeFailure; + } + try { + indexChannel.close(); + } catch (IOException closeFailure) { + failure = IoOperations.appendFailure(failure, closeFailure); + } + try { + Files.deleteIfExists(path); + } catch (IOException closeFailure) { + failure = IoOperations.appendFailure(failure, closeFailure); + } + if (failure != null) { + throw failure; + } + } + } + + /** One-entry-at-a-time physical-slot cursor over a frozen index. */ + /* default */ interface FrozenCursor extends AutoCloseable { + /** Advances to the next occupied physical slot. */ + boolean advance() throws IOException; + + /** Returns the entry selected by the most recent successful advancement. */ + RevocationCheckpointCodec.CurrentStateEntry current(); + + @Override + void close(); + } + + /** Cursor state remains constant regardless of frozen index population. */ + private static final class PhysicalCursor implements FrozenCursor { + private final FrozenSnapshot owner; + private long slot; + private long emitted; + private RevocationCheckpointCodec.CurrentStateEntry current; + private boolean closed; + + private PhysicalCursor(FrozenSnapshot owner) { + this.owner = owner; + } + + @Override + public boolean advance() throws IOException { + requireOpen(); + while (slot < owner.state.capacity()) { + Cell cell = IndexOperations.activeCell( + owner.indexChannel, slot, owner.state.coveredRevision()).cell(); + slot++; + if (cell.state() == SlotState.OCCUPIED) { + RevocationTransitionFrameCodec.CompleteRecord record = + IndexValidation.readFrame(owner.logChannel, cell); + emitted = IoOperations.addExact( + emitted, 1L, ENTRY_COUNT_OVERFLOW); + if (emitted > owner.state.entryCount()) { + throw new IOException("Frozen revocation index contains excess entries"); + } + current = checkpointEntry(record); + return true; + } + } + if (emitted != owner.state.entryCount()) { + throw new IOException("Frozen revocation index entry count is inconsistent"); + } + current = null; + return false; + } + + @Override + public RevocationCheckpointCodec.CurrentStateEntry current() { + requireOpen(); + if (current == null) { + throw new IllegalStateException("Frozen revocation cursor has no current entry"); + } + return current; + } + + @Override + public void close() { + closed = true; + current = null; + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Frozen revocation cursor is closed"); + } + owner.requireOpen(); + } + + private static RevocationCheckpointCodec.CurrentStateEntry checkpointEntry( + RevocationTransitionFrameCodec.CompleteRecord record) { + return new RevocationCheckpointCodec.CurrentStateEntry( + record.data().credentialId(), record.data().globalRevision(), + record.data().transition().revision(), record.commitment(), + record.data().transition(), record.recordOffset(), record.recordEnd()); + } + } + + /** Owns a frozen copy and independent log reader until snapshot transfer. */ + private static final class FrozenResources implements AutoCloseable { + private final Path path; + private final FileChannel indexChannel; + private final FileChannel logChannel; + private boolean transferred; + + private FrozenResources( + Path path, FileChannel indexChannel, FileChannel logChannel) { + this.path = path; + this.indexChannel = indexChannel; + this.logChannel = logChannel; + } + + private static FrozenResources create(Path path, Path logPath) throws IOException { + FileChannel index = FileChannel.open(path, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, + StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS); + try { + FileChannel log = FileChannel.open( + logPath, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS); + return new FrozenResources(path, index, log); + } catch (IOException failure) { + try { + index.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + try { + Files.deleteIfExists(path); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + } + + private FileChannel indexChannel() { + return indexChannel; + } + + private FileChannel logChannel() { + return logChannel; + } + + private void transferOwnership() { + transferred = true; + } + + @Override + public void close() throws IOException { + if (transferred) { + return; + } + IOException failure = null; + try { + logChannel.close(); + } catch (IOException closeFailure) { + failure = closeFailure; + } + try { + indexChannel.close(); + } catch (IOException closeFailure) { + failure = IoOperations.appendFailure(failure, closeFailure); + } + try { + Files.deleteIfExists(path); + } catch (IOException cleanupFailure) { + failure = IoOperations.appendFailure(failure, cleanupFailure); + } + if (failure != null) { + throw failure; + } + } + } + /** Stable adjacent lock whose inode is never replaced with an index generation. */ private static final class StableIndexLock implements AutoCloseable { private final FileChannel channel; diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java index 7e73673..0e7da7c 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -60,6 +60,7 @@ final class FsPaths { private static final String BINARY_EXTENSION = ".bin"; private static final String BY_ID = "by-id"; + private static final String REVOCATIONS_DIRECTORY = "revocations"; /* default */ static final String VERSION_FILE = "VERSION"; /* default */ static final String LOCK_DIR = ".lock"; @@ -166,7 +167,8 @@ final class FsPaths { /* default */ Path revocationDir(final PkiId credentialId) { Objects.requireNonNull(credentialId, "credentialId"); - return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId)); + return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("by-credential") + .resolve(FsUtil.safeId(credentialId)); } /* default */ Path revocationJournal(final PkiId credentialId) { @@ -174,19 +176,23 @@ final class FsPaths { } /* default */ Path revocationTransitionLog() { - return this.root.resolve("revocations").resolve("transitions.log"); + return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("transitions.log"); } /* default */ Path revocationCheckpointDirectory() { - return this.root.resolve("revocations").resolve("checkpoints"); + return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("checkpoints"); + } + + /* default */ Path revocationCheckpointWorkDirectory() { + return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("checkpoint-work"); } /* default */ Path revocationCurrentIndex() { - return this.root.resolve("revocations").resolve("current-state.idx"); + return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("current-state.idx"); } /* default */ Path revocationCurrentIndexLock() { - return this.root.resolve("revocations").resolve("current-state.idx.lock"); + return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("current-state.idx.lock"); } /* default */ Path revocationSnapshotRoot() { diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointBuilderTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointBuilderTest.java new file mode 100644 index 0000000..82d5971 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCheckpointBuilderTest.java @@ -0,0 +1,893 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +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.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.revocation.RevocationState; +import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.spi.store.MetadataStoreId; + +final class FilesystemRevocationCheckpointBuilderTest { + + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("102030405060708090a0b0c0d0e0f001"); + private static final FilesystemRevocationCurrentIndex.Configuration INDEX_CONFIGURATION = + new FilesystemRevocationCurrentIndex.Configuration(2L, 1, 2); + + @TempDir + private Path temporaryDirectory; + + @Test + void boundedMultiPassBuildPublishesCanonicalCurrentState() throws Exception { + System.out.print("boundedMultiPassBuildPublishesCanonicalCurrentState "); + try (Fixture fixture = fixture("multi-pass")) { + List identities = List.of( + new PkiId("credential:zeta"), new PkiId("credential:beta"), + new PkiId("credential:eta"), new PkiId("credential:alpha"), + new PkiId("credential:delta"), new PkiId("credential:gamma"), + new PkiId("credential:epsilon")); + fixture.appendAll(identities); + AtomicInteger mergeWrites = new AtomicInteger(); + FilesystemRevocationCheckpointBuilder.FaultInjector faults = point -> { + if (point == FilesystemRevocationCheckpointBuilder.FaultPoint.MERGE_WRITE) { + mergeWrites.incrementAndGet(); + } + }; + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpoint.PublishedGeneration generation = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration(), faults); + try (FilesystemRevocationCheckpoint checkpoint = FilesystemRevocationCheckpoint.open( + generation.path(), fixture.logPath(), fixture.log().scan())) { + assertEquals(identities.size(), checkpoint.entryCount()); + assertEquals(List.of( + "credential:alpha", "credential:beta", "credential:delta", + "credential:epsilon", "credential:eta", "credential:gamma", + "credential:zeta"), collect(checkpoint)); + } + } + assertTrue(mergeWrites.get() > identities.size()); + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + @Test + void frozenSnapshotRemainsAtCapturedRevisionDuringLiveUpdate() throws Exception { + System.out.print("frozenSnapshotRemainsAtCapturedRevisionDuringLiveUpdate "); + try (Fixture fixture = fixture("frozen")) { + PkiId first = new PkiId("credential:first"); + PkiId second = new PkiId("credential:second"); + fixture.log().append(first, held(1L, 1L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + Path frozenPath = fixture.workRoot().resolve("manual-frozen"); + Files.createDirectories(fixture.workRoot()); + try (FilesystemRevocationCurrentIndex.FrozenSnapshot frozen = + index.freeze(frozenPath, 31)) { + RevocationTransitionFrameCodec.CompleteRecord appended = + fixture.log().append(second, held(1L, 2L)); + index.update(appended); + assertEquals(1L, frozen.coverage().coveredRevision()); + assertEquals(List.of(first), collect(frozen)); + } + assertFalse(Files.exists(frozenPath)); + assertEquals(2L, index.coveredGlobalRevision()); + } + } + System.out.println("...ok"); + } + + @Test + void logAppendAfterFreezeProducesValidHistoricalCheckpoint() throws Exception { + System.out.print("logAppendAfterFreezeProducesValidHistoricalCheckpoint "); + try (Fixture fixture = fixture("historical")) { + PkiId first = new PkiId("credential:first"); + fixture.log().append(first, held(1L, 1L)); + AtomicBoolean appended = new AtomicBoolean(); + FilesystemRevocationCheckpointBuilder.FaultInjector faults = point -> { + if (point == FilesystemRevocationCheckpointBuilder.FaultPoint.BEFORE_PUBLICATION + && appended.compareAndSet(false, true)) { + fixture.log().append( + new PkiId("credential:later"), held(1L, 2L)); + } + }; + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpoint.PublishedGeneration generation = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration(), faults); + FilesystemRevocationLog.RecoveryResult recovery = fixture.log().scan(); + try (FilesystemRevocationCheckpoint checkpoint = FilesystemRevocationCheckpoint.open( + generation.path(), fixture.logPath(), recovery)) { + assertEquals(1L, checkpoint.coveredRevision()); + assertEquals(List.of(first.value()), collect(checkpoint)); + } + assertEquals(2L, recovery.globalRevision()); + } + } + System.out.println("...ok"); + } + + @Test + void emptyIndexBuildsGenesisCheckpoint() throws Exception { + System.out.print("emptyIndexBuildsGenesisCheckpoint "); + try (Fixture fixture = fixture("empty")) { + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpoint.PublishedGeneration generation = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration()); + try (FilesystemRevocationCheckpoint checkpoint = FilesystemRevocationCheckpoint.open( + generation.path(), fixture.logPath(), fixture.log().scan())) { + assertEquals(0L, checkpoint.coveredRevision()); + assertEquals(0L, checkpoint.entryCount()); + } + } + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + @Test + void runFailurePublishesNothingAndCleansWorkspace() throws Exception { + System.out.print("runFailurePublishesNothingAndCleansWorkspace "); + try (Fixture fixture = fixture("failure")) { + fixture.log().append(new PkiId("credential:first"), held(1L, 1L)); + FilesystemRevocationCheckpointBuilder.FaultInjector faults = point -> { + if (point == FilesystemRevocationCheckpointBuilder.FaultPoint.RUN_WRITE) { + throw new IOException("injected run failure"); + } + }; + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + assertThrows(IOException.class, () -> + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), + fixture.builderConfiguration(), faults)); + assertEquals(1L, index.coveredGlobalRevision()); + } + assertFalse(hasCheckpoint(fixture.checkpoints())); + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + @Test + void boundsAreValidatedWithoutCreatingWorkspace() throws Exception { + System.out.print("boundsAreValidatedWithoutCreatingWorkspace "); + Path work = temporaryDirectory.resolve("invalid-work"); + assertThrows(IllegalArgumentException.class, () -> + new FilesystemRevocationCheckpointBuilder.Configuration(0L, 2, 64, work)); + assertThrows(IllegalArgumentException.class, () -> + new FilesystemRevocationCheckpointBuilder.Configuration(64L, 1, 64, work)); + assertThrows(IllegalArgumentException.class, () -> + new FilesystemRevocationCheckpointBuilder.Configuration(64L, 2, 0, work)); + assertFalse(Files.exists(work)); + System.out.println("...ok"); + } + + @Test + void frozenCopyAndForceFailuresReleaseBuilderResources() throws Exception { + System.out.print("frozenCopyAndForceFailuresReleaseBuilderResources "); + try (Fixture fixture = fixture("frozen-failures")) { + PkiId identity = new PkiId("credential:frozen-failure"); + fixture.log().append(identity, held(1L, 1L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + for (FilesystemRevocationCheckpointBuilder.FaultPoint failurePoint : List.of( + FilesystemRevocationCheckpointBuilder.FaultPoint.FROZEN_COPY, + FilesystemRevocationCheckpointBuilder.FaultPoint.FROZEN_FORCE)) { + FilesystemRevocationCheckpointBuilder.FaultInjector faults = point -> { + if (point == failurePoint) { + throw new IOException("injected frozen failure"); + } + }; + assertThrows(IOException.class, () -> + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), + fixture.builderConfiguration(), faults)); + assertEquals(identity, + index.lookup(identity).orElseThrow().data().credentialId()); + assertTrue(directoryEmpty(fixture.workRoot())); + } + FilesystemRevocationCheckpoint.PublishedGeneration generation = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration()); + assertTrue(Files.isRegularFile(generation.path())); + } + } + System.out.println("...ok"); + } + + @Test + void runAndMergeForceFailuresPreservePreviousGeneration() throws Exception { + System.out.print("runAndMergeForceFailuresPreservePreviousGeneration "); + try (Fixture fixture = fixture("force-failures")) { + PkiId first = new PkiId("credential:first"); + PkiId second = new PkiId("credential:second"); + fixture.log().append(first, held(1L, 1L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpoint.PublishedGeneration previous = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration()); + RevocationTransitionFrameCodec.CompleteRecord appended = + fixture.log().append(second, held(1L, 2L)); + index.update(appended); + for (FilesystemRevocationCheckpointBuilder.FaultPoint failurePoint : List.of( + FilesystemRevocationCheckpointBuilder.FaultPoint.RUN_FORCE, + FilesystemRevocationCheckpointBuilder.FaultPoint.MERGE_FORCE)) { + FilesystemRevocationCheckpointBuilder.FaultInjector faults = point -> { + if (point == failurePoint) { + throw new IOException("injected temporary force failure"); + } + }; + assertThrows(IOException.class, () -> + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), + fixture.builderConfiguration(), faults)); + assertTrue(Files.isRegularFile(previous.path())); + assertTrue(directoryEmpty(fixture.workRoot())); + } + try (FilesystemRevocationCheckpoint checkpoint = + FilesystemRevocationCheckpoint.open( + previous.path(), fixture.logPath(), fixture.log().scan())) { + assertEquals(List.of(first.value()), collect(checkpoint)); + } + } + } + System.out.println("...ok"); + } + + @Test + void boundedResourcesAndRepeatedBuildAreDeterministic() throws Exception { + System.out.print("boundedResourcesAndRepeatedBuildAreDeterministic "); + try (Fixture fixture = fixture("bounded-observation")) { + fixture.appendAll(List.of( + new PkiId("credential:h"), new PkiId("credential:d"), + new PkiId("credential:f"), new PkiId("credential:b"), + new PkiId("credential:g"), new PkiId("credential:c"), + new PkiId("credential:e"), new PkiId("credential:a"))); + AtomicLong maximumRunEntries = new AtomicLong(); + AtomicLong maximumRunBytes = new AtomicLong(); + AtomicLong maximumTemporaryBytes = new AtomicLong(); + AtomicLong initialRunEntries = new AtomicLong(); + AtomicLong expectedHeadEncodings = new AtomicLong(); + AtomicLong actualHeadEncodings = new AtomicLong(); + AtomicInteger maximumOpenInputs = new AtomicInteger(); + AtomicInteger initialRunCount = new AtomicInteger(); + AtomicInteger mergePassCount = new AtomicInteger(); + FilesystemRevocationCheckpointBuilder.FaultInjector observer = + new FilesystemRevocationCheckpointBuilder.FaultInjector() { + @Override + public void fail(FilesystemRevocationCheckpointBuilder.FaultPoint point) { + // Observation only. + } + + @Override + public void observeRunBuffer(long entryCount, long encodedBytes) { + maximumRunEntries.accumulateAndGet(entryCount, Math::max); + maximumRunBytes.accumulateAndGet(encodedBytes, Math::max); + } + + @Override + public void observeOpenMergeInputs(int openInputs) { + maximumOpenInputs.accumulateAndGet(openInputs, Math::max); + } + + @Override + public void observeTemporaryRun( + int pass, long entryCount, long encodedBytes) { + maximumTemporaryBytes.accumulateAndGet(encodedBytes, Math::max); + if (pass == 0) { + initialRunCount.incrementAndGet(); + initialRunEntries.addAndGet(entryCount); + } + } + + @Override + public void observeMergePass( + int pass, + long inputRuns, + long outputRuns, + long inputEntries, + long outputEntries) { + mergePassCount.incrementAndGet(); + assertEquals(inputEntries, outputEntries); + assertEquals((inputRuns + 1L) / 2L, outputRuns); + expectedHeadEncodings.addAndGet(inputEntries); + } + + @Override + public void observeMergeHeadEncoding() { + actualHeadEncodings.incrementAndGet(); + } + }; + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpoint.PublishedGeneration first = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), + fixture.builderConfiguration(), observer); + FilesystemRevocationCheckpoint.PublishedGeneration second = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), + fixture.builderConfiguration(), observer); + assertEquals(first.generationId(), second.generationId()); + assertEquals(first.path(), second.path()); + assertFalse(second.newlyPublished()); + } + assertEquals(1L, maximumRunEntries.get()); + assertTrue(maximumRunBytes.get() > 0L); + assertTrue(maximumTemporaryBytes.get() > maximumRunBytes.get()); + assertEquals(2, maximumOpenInputs.get()); + assertEquals(16, initialRunCount.get()); + assertEquals(16L, initialRunEntries.get()); + assertTrue(mergePassCount.get() >= 6); + assertEquals(expectedHeadEncodings.get(), actualHeadEncodings.get()); + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + @Test + void currentStateFieldsAndAuthoritativeLocatorsAreExact() throws Exception { + System.out.print("currentStateFieldsAndAuthoritativeLocatorsAreExact "); + try (Fixture fixture = fixture("entry-fields")) { + PkiId clearIdentity = new PkiId("credential:clear"); + PkiId revokedIdentity = new PkiId("credential:revoked"); + fixture.log().append(clearIdentity, held(1L, 1L)); + RevocationTransitionFrameCodec.CompleteRecord clearRecord = + fixture.log().append(clearIdentity, clear(2L, 2L)); + RevocationTransitionFrameCodec.CompleteRecord revokedRecord = + fixture.log().append(revokedIdentity, permanentWithAttributes(1L, 3L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpoint.PublishedGeneration generation = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration()); + try (FilesystemRevocationCheckpoint checkpoint = + FilesystemRevocationCheckpoint.open( + generation.path(), fixture.logPath(), fixture.log().scan())) { + List entries = + collectEntries(checkpoint); + assertEquals(2, entries.size()); + assertEquals(RevocationState.CLEAR, entries.get(0).transition().state()); + assertEquals(clearRecord.recordOffset(), entries.get(0).frameStart()); + assertEquals(clearRecord.recordEnd(), entries.get(0).frameEnd()); + assertEquals(revokedRecord.commitment(), entries.get(1).transitionCommitment()); + assertEquals(revokedRecord.recordOffset(), entries.get(1).frameStart()); + assertEquals(revokedRecord.recordEnd(), entries.get(1).frameEnd()); + assertEquals(1, entries.get(1).transition().attributes().ids().size()); + } + } + } + System.out.println("...ok"); + } + + @Test + void corruptOrTruncatedTemporaryRunFailsClosed() throws Exception { + System.out.print("corruptOrTruncatedTemporaryRunFailsClosed "); + for (boolean truncate : List.of(false, true)) { + try (Fixture fixture = fixture("temporary-corruption-" + truncate)) { + fixture.appendAll(List.of( + new PkiId("credential:a"), new PkiId("credential:b"))); + AtomicBoolean changed = new AtomicBoolean(); + FilesystemRevocationCheckpointBuilder.FaultInjector faults = + new FilesystemRevocationCheckpointBuilder.FaultInjector() { + @Override + public void fail( + FilesystemRevocationCheckpointBuilder.FaultPoint point) { + // Corruption occurs after the strict run is complete. + } + + @Override + public void afterRunWritten(Path run) throws IOException { + if (changed.compareAndSet(false, true)) { + corruptRun(run, truncate); + } + } + }; + FilesystemRevocationCheckpointBuilder.Configuration oneRun = + new FilesystemRevocationCheckpointBuilder.Configuration( + Long.MAX_VALUE, 2, 37, fixture.workRoot()); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + assertThrows(IOException.class, () -> + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), oneRun, faults)); + } + assertFalse(hasCheckpoint(fixture.checkpoints())); + assertTrue(directoryEmpty(fixture.workRoot())); + } + } + System.out.println("...ok"); + } + + @Test + void publicationFailureAndCleanupFailureHaveSafeIndependentOutcomes() throws Exception { + System.out.print("publicationFailureAndCleanupFailureHaveSafeIndependentOutcomes "); + try (Fixture fixture = fixture("publication-cleanup")) { + PkiId first = new PkiId("credential:first"); + PkiId second = new PkiId("credential:second"); + fixture.log().append(first, held(1L, 1L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpoint.PublishedGeneration previous = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration()); + index.update(fixture.log().append(second, held(1L, 2L))); + FilesystemRevocationCheckpointBuilder.FaultInjector publicationFailure = point -> { + if (point == FilesystemRevocationCheckpointBuilder.FaultPoint.BEFORE_PUBLICATION) { + throw new IOException("injected publication failure"); + } + }; + assertThrows(IOException.class, () -> + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration(), + publicationFailure)); + assertTrue(Files.isRegularFile(previous.path())); + + FilesystemRevocationCheckpointBuilder.FaultInjector cleanupFailure = point -> { + if (point == FilesystemRevocationCheckpointBuilder.FaultPoint.CLEANUP) { + throw new IOException("injected cleanup failure"); + } + }; + FilesystemRevocationCheckpoint.PublishedGeneration current = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration(), + cleanupFailure); + assertTrue(Files.isRegularFile(current.path())); + assertEquals(2L, current.coveredRevision()); + } + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + @Test + void frozenSnapshotCloseInvalidatesCursorAndDeletesCopy() throws Exception { + System.out.print("frozenSnapshotCloseInvalidatesCursorAndDeletesCopy "); + try (Fixture fixture = fixture("frozen-close")) { + fixture.log().append(new PkiId("credential:first"), held(1L, 1L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + Files.createDirectories(fixture.workRoot()); + Path copy = fixture.workRoot().resolve("frozen-copy"); + FilesystemRevocationCurrentIndex.FrozenSnapshot frozen = + index.freeze(copy, 17); + FilesystemRevocationCurrentIndex.FrozenCursor cursor = frozen.openCursor(); + frozen.close(); + frozen.close(); + assertThrows(IllegalStateException.class, cursor::advance); + assertThrows(IllegalStateException.class, frozen::openCursor); + cursor.close(); + cursor.close(); + assertFalse(Files.exists(copy)); + } + } + System.out.println("...ok"); + } + + @Test + void frozenCloseFailureCannotReplacePublishedGeneration() throws Exception { + System.out.print("frozenCloseFailureCannotReplacePublishedGeneration "); + try (Fixture fixture = fixture("frozen-close-outcome")) { + fixture.log().append(new PkiId("credential:first"), held(1L, 1L)); + FilesystemRevocationCheckpointBuilder.FaultInjector faults = point -> { + if (point == FilesystemRevocationCheckpointBuilder.FaultPoint.FROZEN_CLOSE) { + throw new IOException("injected frozen close failure"); + } + }; + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpoint.PublishedGeneration generation = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration(), + faults); + assertTrue(Files.isRegularFile(generation.path())); + } + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + @Test + void frozenCloseFailureIsSuppressedBeforePublication() throws Exception { + System.out.print("frozenCloseFailureIsSuppressedBeforePublication "); + try (Fixture fixture = fixture("frozen-close-primary")) { + fixture.log().append(new PkiId("credential:first"), held(1L, 1L)); + FilesystemRevocationCheckpointBuilder.FaultInjector faults = point -> { + if (point == FilesystemRevocationCheckpointBuilder.FaultPoint.RUN_WRITE) { + throw new IOException("primary run failure"); + } + if (point == FilesystemRevocationCheckpointBuilder.FaultPoint.FROZEN_CLOSE) { + throw new IOException("secondary frozen close failure"); + } + }; + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + IOException failure = assertThrows(IOException.class, () -> + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration(), + faults)); + assertEquals("primary run failure", failure.getMessage()); + assertEquals(1, failure.getSuppressed().length); + assertEquals("secondary frozen close failure", + failure.getSuppressed()[0].getMessage()); + } + assertFalse(hasCheckpoint(fixture.checkpoints())); + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + @Test + void malformedFrozenSlotPopulationFailsBeforePublication() throws Exception { + System.out.print("malformedFrozenSlotPopulationFailsBeforePublication "); + try (Fixture fixture = fixture("malformed-frozen")) { + fixture.log().append(new PkiId("credential:first"), held(1L, 1L)); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + clearIndexCells(fixture.indexPath()); + assertThrows(IOException.class, () -> + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration())); + } + assertFalse(hasCheckpoint(fixture.checkpoints())); + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + @Test + void maximumComponentEntryIsOneBoundedSingletonRun() throws Exception { + System.out.print("maximumComponentEntryIsOneBoundedSingletonRun "); + try (Fixture fixture = fixture("maximum-singleton")) { + PkiId identity = new PkiId( + "x".repeat(RevocationTransitionFrameCodec.MAX_COMPONENT_BYTES)); + fixture.log().append(identity, held(1L, 1L)); + AtomicLong maximumEntries = new AtomicLong(); + FilesystemRevocationCheckpointBuilder.FaultInjector observer = + new FilesystemRevocationCheckpointBuilder.FaultInjector() { + @Override + public void fail(FilesystemRevocationCheckpointBuilder.FaultPoint point) { + // Observation only. + } + + @Override + public void observeRunBuffer(long entryCount, long encodedBytes) { + maximumEntries.accumulateAndGet(entryCount, Math::max); + } + }; + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + FilesystemRevocationCheckpoint.PublishedGeneration generation = + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration(), + observer); + try (FilesystemRevocationCheckpoint checkpoint = + FilesystemRevocationCheckpoint.open( + generation.path(), fixture.logPath(), fixture.log().scan())) { + assertEquals(1L, checkpoint.entryCount()); + } + } + assertEquals(1L, maximumEntries.get()); + } + System.out.println("...ok"); + } + + @Test + void duplicateIdentityInjectedIntoTemporaryRunFailsClosed() throws Exception { + System.out.print("duplicateIdentityInjectedIntoTemporaryRunFailsClosed "); + try (Fixture fixture = fixture("duplicate-run")) { + fixture.log().append(new PkiId("credential:first"), held(1L, 1L)); + AtomicBoolean rewritten = new AtomicBoolean(); + FilesystemRevocationCheckpointBuilder.FaultInjector faults = + new FilesystemRevocationCheckpointBuilder.FaultInjector() { + @Override + public void fail(FilesystemRevocationCheckpointBuilder.FaultPoint point) { + // Duplicate injection occurs after strict run creation. + } + + @Override + public void afterRunWritten(Path run) throws IOException { + if (rewritten.compareAndSet(false, true)) { + rewriteRunWithDuplicate(run); + } + } + }; + FilesystemRevocationCheckpointBuilder.Configuration oneRun = + new FilesystemRevocationCheckpointBuilder.Configuration( + Long.MAX_VALUE, 2, 37, fixture.workRoot()); + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + assertThrows(IOException.class, () -> + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), oneRun, faults)); + } + assertFalse(hasCheckpoint(fixture.checkpoints())); + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + @Test + void authenticSupersededTransitionCannotReplaceFrozenCurrentState() throws Exception { + System.out.print("authenticSupersededTransitionCannotReplaceFrozenCurrentState "); + try (Fixture fixture = fixture("superseded-authentic")) { + PkiId identity = new PkiId("credential:first"); + RevocationTransitionFrameCodec.CompleteRecord heldRecord = + fixture.log().append(identity, held(1L, 1L)); + fixture.log().append(identity, clear(2L, 2L)); + RevocationCheckpointCodec.CurrentStateEntry superseded = entry(heldRecord); + AtomicBoolean rewritten = new AtomicBoolean(); + FilesystemRevocationCheckpointBuilder.FaultInjector faults = + new FilesystemRevocationCheckpointBuilder.FaultInjector() { + @Override + public void fail(FilesystemRevocationCheckpointBuilder.FaultPoint point) { + // Authentic replacement occurs after strict run creation. + } + + @Override + public void afterRunWritten(Path run) throws IOException { + if (rewritten.compareAndSet(false, true)) { + rewriteRunWithEntry(run, superseded); + } + } + }; + try (FilesystemRevocationCurrentIndex index = fixture.rebuild()) { + assertThrows(IOException.class, () -> + FilesystemRevocationCheckpointBuilder.build( + index, fixture.checkpoints(), fixture.builderConfiguration(), + faults)); + } + assertFalse(hasCheckpoint(fixture.checkpoints())); + assertTrue(directoryEmpty(fixture.workRoot())); + } + System.out.println("...ok"); + } + + private Fixture fixture(String name) throws IOException { + Path root = temporaryDirectory.resolve(name); + FsPaths paths = new FsPaths(root); + Path logPath = paths.revocationTransitionLog(); + Files.createDirectories(logPath.getParent()); + FilesystemRevocationLog log = FilesystemRevocationLog.create( + logPath, STORE_ID, credential -> { }); + return new Fixture( + logPath, paths.revocationCurrentIndex(), + 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 RevocationTransition permanentWithAttributes(long revision, long second) { + SimpleAttributeSet attributes = new SimpleAttributeSet(List.of( + new SimpleAttributeSet.Entry( + new AttributeId("audit.example"), + List.of(new AttributeValue.StringValue("retained"))))); + return new RevocationTransition( + revision, RevocationState.PERMANENTLY_REVOKED, + Instant.ofEpochSecond(second), Optional.of(RevocationReason.KEY_COMPROMISE), + attributes); + } + + private static RevocationCheckpointCodec.CurrentStateEntry entry( + RevocationTransitionFrameCodec.CompleteRecord record) { + return new RevocationCheckpointCodec.CurrentStateEntry( + record.data().credentialId(), record.data().globalRevision(), + record.data().transition().revision(), record.commitment(), + record.data().transition(), record.recordOffset(), record.recordEnd()); + } + + private static List collect(FilesystemRevocationCheckpoint checkpoint) + throws Exception { + List values = new ArrayList<>(); + try (FilesystemRevocationCheckpoint.Cursor cursor = checkpoint.allCurrentStates()) { + while (cursor.advance(CancellationSignal.NONE)) { + values.add(cursor.current().credentialId().value()); + } + } + return values; + } + + private static List collectEntries( + FilesystemRevocationCheckpoint checkpoint) throws Exception { + List values = new ArrayList<>(); + try (FilesystemRevocationCheckpoint.Cursor cursor = checkpoint.allCurrentStates()) { + while (cursor.advance(CancellationSignal.NONE)) { + values.add(cursor.current()); + } + } + return values; + } + + private static void corruptRun(Path run, boolean truncate) throws IOException { + try (FileChannel channel = FileChannel.open( + run, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + long last = channel.size() - 1L; + if (truncate) { + channel.truncate(last); + } else { + ByteBuffer byteValue = ByteBuffer.allocate(1); + channel.read(byteValue, last); + byteValue.flip(); + byteValue.put(0, (byte) (byteValue.get(0) ^ 0x5a)); + channel.write(byteValue, last); + } + } + } + + private static void clearIndexCells(Path indexPath) throws IOException { + try (FileChannel channel = FileChannel.open( + indexPath, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + long offset = 2L * FilesystemRevocationCurrentIndex.SUPERBLOCK_BYTES; + ByteBuffer zeros = ByteBuffer.allocate(512); + while (offset < channel.size()) { + zeros.clear(); + zeros.limit((int) Math.min((long) zeros.capacity(), channel.size() - offset)); + while (zeros.hasRemaining()) { + offset += channel.write(zeros, offset); + } + } + channel.force(true); + } + } + + private static void rewriteRunWithDuplicate(Path run) throws IOException { + try (FileChannel channel = FileChannel.open( + run, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.ValidatedFile validated = codec.validate(channel); + RevocationCheckpointCodec.DecodedRecord decoded = codec.sequentialDecoder().read( + channel, RevocationCheckpointCodec.HEADER_BYTES, validated.trailerOffset()); + RevocationCheckpointCodec.HeaderData original = validated.header(); + RevocationCheckpointCodec.HeaderData duplicateHeader = + new RevocationCheckpointCodec.HeaderData( + original.storeId(), original.coveredRevision(), + original.finalRecordStart(), original.coveredBoundary(), + original.globalCommitment(), 2L); + RevocationCheckpointCodec.Encoder encoder = codec.encoder(channel, duplicateHeader); + encoder.write(decoded.entry()); + encoder.write(decoded.entry()); + encoder.finish(); + channel.force(true); + } + } + + private static void rewriteRunWithEntry( + Path run, RevocationCheckpointCodec.CurrentStateEntry entry) throws IOException { + try (FileChannel channel = FileChannel.open( + run, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + RevocationCheckpointCodec codec = new RevocationCheckpointCodec(); + RevocationCheckpointCodec.HeaderData original = + codec.validate(channel).header(); + RevocationCheckpointCodec.HeaderData replacementHeader = + new RevocationCheckpointCodec.HeaderData( + original.storeId(), original.coveredRevision(), + original.finalRecordStart(), original.coveredBoundary(), + original.globalCommitment(), 1L); + RevocationCheckpointCodec.Encoder encoder = + codec.encoder(channel, replacementHeader); + encoder.write(entry); + encoder.finish(); + channel.force(true); + } + } + + private static List collect( + FilesystemRevocationCurrentIndex.FrozenSnapshot frozen) throws Exception { + List values = new ArrayList<>(); + try (FilesystemRevocationCurrentIndex.FrozenCursor cursor = frozen.openCursor()) { + while (cursor.advance()) { + values.add(cursor.current().credentialId()); + } + } + return values; + } + + private static boolean directoryEmpty(Path directory) throws IOException { + if (!Files.isDirectory(directory)) { + return true; + } + try (DirectoryStream paths = Files.newDirectoryStream(directory)) { + return !paths.iterator().hasNext(); + } + } + + private static boolean hasCheckpoint(Path directory) throws IOException { + if (!Files.isDirectory(directory)) { + return false; + } + try (DirectoryStream paths = Files.newDirectoryStream(directory, "*.chk")) { + return paths.iterator().hasNext(); + } + } + + private record Fixture( + Path logPath, + Path indexPath, + Path checkpoints, + Path workRoot, + FilesystemRevocationLog log) implements AutoCloseable { + + private FilesystemRevocationCurrentIndex rebuild() throws IOException { + return FilesystemRevocationCurrentIndex.rebuild( + indexPath, logPath, STORE_ID, INDEX_CONFIGURATION); + } + + private FilesystemRevocationCheckpointBuilder.Configuration builderConfiguration() { + return new FilesystemRevocationCheckpointBuilder.Configuration( + 1L, 2, 37, workRoot); + } + + private void appendAll(List identities) throws IOException { + long globalRevision = 1L; + for (PkiId identity : identities) { + log.append(identity, held(1L, globalRevision)); + globalRevision++; + } + } + + @Override + public void close() throws IOException { + log.close(); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexTest.java index 0d322d4..d3826d9 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndexTest.java @@ -427,13 +427,13 @@ final class FilesystemRevocationCurrentIndexTest { } @Test - void structuralContractHasNoHistoryCollectionOrCheckpointDependency() throws Exception { - System.out.print("structuralContractHasNoHistoryCollectionOrCheckpointDependency "); + void structuralContractHasNoHistoryCollectionOrCheckpointPublication() throws Exception { + System.out.print("structuralContractHasNoHistoryCollectionOrCheckpointPublication "); String source = Files.readString(Path.of( "src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java")); assertFalse(source.contains("List")); assertFalse(source.contains("readAllBytes()")); - assertFalse(source.contains("FilesystemRevocationCheckpoint")); + assertFalse(source.contains("publishHistorical")); assertFalse(source.contains("HashMap")); assertFalse(source.contains("new Thread")); assertFalse(source.contains("Executors."));