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.
This commit is contained in:
@@ -114,6 +114,32 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable {
|
|||||||
Objects.requireNonNull(faults, "faults");
|
Objects.requireNonNull(faults, "faults");
|
||||||
Binding.requireCurrentPublication(recovery, coverage);
|
Binding.requireCurrentPublication(recovery, coverage);
|
||||||
Binding.validate(logPath, 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);
|
Files.createDirectories(directory);
|
||||||
requireDirectory(directory);
|
requireDirectory(directory);
|
||||||
long declaredCount = source.entryCount();
|
long declaredCount = source.entryCount();
|
||||||
@@ -126,7 +152,7 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable {
|
|||||||
Path target = directory.resolve(finalName(coverage.coveredRevision(), build.generationId()));
|
Path target = directory.resolve(finalName(coverage.coveredRevision(), build.generationId()));
|
||||||
boolean published = false;
|
boolean published = false;
|
||||||
try {
|
try {
|
||||||
Binding.validateGeneration(temporary, logPath, recovery);
|
validator.validate(temporary);
|
||||||
published = publishAtomically(temporary, target, operations, faults);
|
published = publishAtomically(temporary, target, operations, faults);
|
||||||
forceDirectory(directory, operations, faults);
|
forceDirectory(directory, operations, faults);
|
||||||
return new PublishedGeneration(target, build.generationId(), coverage.coveredRevision(), published);
|
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<FilesystemRevocationCheckpoint> discover(
|
/* default */ static Optional<FilesystemRevocationCheckpoint> discover(
|
||||||
Path directory,
|
Path directory,
|
||||||
Path logPath,
|
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. */
|
/** Streaming discovery keeps one candidate descriptor and repeats only for ambiguity fallback. */
|
||||||
private static final class Discovery {
|
private static final class Discovery {
|
||||||
private static Optional<FilesystemRevocationCheckpoint> discover(
|
private static Optional<FilesystemRevocationCheckpoint> discover(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
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<SortableEntry> 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<SortableEntry> 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<SortableEntry> drain() throws IOException {
|
||||||
|
List<SortableEntry> 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<SortableEntry> 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<RunHead> 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<RunCursor> inputs;
|
||||||
|
private final long entryCount;
|
||||||
|
|
||||||
|
private MergeInputs(List<RunCursor> 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<RunCursor> 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<RunHead> initialQueue(FaultInjector faults) throws IOException {
|
||||||
|
Queue<RunHead> 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<RunCursor> 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<RunHead> 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<Path> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
|||||||
private static final HexFormat HEX = HexFormat.of();
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
private final Path indexPath;
|
private final Path indexPath;
|
||||||
|
private final Path logPath;
|
||||||
private final MetadataStoreId storeId;
|
private final MetadataStoreId storeId;
|
||||||
private final Configuration configuration;
|
private final Configuration configuration;
|
||||||
private final PublicationOperations operations;
|
private final PublicationOperations operations;
|
||||||
@@ -126,6 +127,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
|||||||
|
|
||||||
private FilesystemRevocationCurrentIndex(
|
private FilesystemRevocationCurrentIndex(
|
||||||
Path indexPath,
|
Path indexPath,
|
||||||
|
Path logPath,
|
||||||
MetadataStoreId storeId,
|
MetadataStoreId storeId,
|
||||||
Configuration configuration,
|
Configuration configuration,
|
||||||
PublicationOperations operations,
|
PublicationOperations operations,
|
||||||
@@ -135,6 +137,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
|||||||
FileChannel logChannel,
|
FileChannel logChannel,
|
||||||
SelectedSuperblock selected) {
|
SelectedSuperblock selected) {
|
||||||
this.indexPath = indexPath;
|
this.indexPath = indexPath;
|
||||||
|
this.logPath = logPath;
|
||||||
this.storeId = storeId;
|
this.storeId = storeId;
|
||||||
this.configuration = configuration;
|
this.configuration = configuration;
|
||||||
this.operations = operations;
|
this.operations = operations;
|
||||||
@@ -172,13 +175,14 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
|||||||
IoOperations.requireRegular(indexPath, "Revocation current index");
|
IoOperations.requireRegular(indexPath, "Revocation current index");
|
||||||
IoOperations.requireRegular(logPath, "Revocation transition log");
|
IoOperations.requireRegular(logPath, "Revocation transition log");
|
||||||
try (OpenResources resources = OpenResources.acquire(indexPath, logPath)) {
|
try (OpenResources resources = OpenResources.acquire(indexPath, logPath)) {
|
||||||
return openLocked(indexPath, expectedStoreId, configuration,
|
return openLocked(indexPath, logPath, expectedStoreId, configuration,
|
||||||
operations, faults, resources);
|
operations, faults, resources);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FilesystemRevocationCurrentIndex openLocked(
|
private static FilesystemRevocationCurrentIndex openLocked(
|
||||||
Path indexPath,
|
Path indexPath,
|
||||||
|
Path logPath,
|
||||||
MetadataStoreId expectedStoreId,
|
MetadataStoreId expectedStoreId,
|
||||||
Configuration configuration,
|
Configuration configuration,
|
||||||
PublicationOperations operations,
|
PublicationOperations operations,
|
||||||
@@ -191,7 +195,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
|||||||
SelectedSuperblock selected = CandidateSelection.select(
|
SelectedSuperblock selected = CandidateSelection.select(
|
||||||
resources, expectedStoreId, candidates);
|
resources, expectedStoreId, candidates);
|
||||||
FilesystemRevocationCurrentIndex opened = new FilesystemRevocationCurrentIndex(
|
FilesystemRevocationCurrentIndex opened = new FilesystemRevocationCurrentIndex(
|
||||||
indexPath, expectedStoreId, configuration, operations, faults,
|
indexPath, logPath, expectedStoreId, configuration, operations, faults,
|
||||||
resources.indexChannel(), resources.stableLock(),
|
resources.indexChannel(), resources.stableLock(),
|
||||||
resources.logChannel(), selected);
|
resources.logChannel(), selected);
|
||||||
opened.new IndexAccess().applySuffix();
|
opened.new IndexAccess().applySuffix();
|
||||||
@@ -241,7 +245,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
|||||||
IoOperations.forceDirectory(parent, operations, faults);
|
IoOperations.forceDirectory(parent, operations, faults);
|
||||||
try (OpenResources resources = OpenResources.acquireLocked(
|
try (OpenResources resources = OpenResources.acquireLocked(
|
||||||
stable, indexPath, logPath)) {
|
stable, indexPath, logPath)) {
|
||||||
return openLocked(indexPath, expectedStoreId, configuration,
|
return openLocked(indexPath, logPath, expectedStoreId, configuration,
|
||||||
operations, faults, resources);
|
operations, faults, resources);
|
||||||
}
|
}
|
||||||
} finally {
|
} 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
|
@Override
|
||||||
public void close() throws IOException {
|
public void close() throws IOException {
|
||||||
lifecycleLock.lock();
|
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 {
|
private static void requireExpectedSize(FileChannel channel, long capacity) throws IOException {
|
||||||
if (channel.size() != expectedSize(capacity)) {
|
if (channel.size() != expectedSize(capacity)) {
|
||||||
throw new IOException("Revocation current index physical size is invalid");
|
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<RevocationTransitionFrameCodec.CompleteRecord> 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. */
|
/** Stable adjacent lock whose inode is never replaced with an index generation. */
|
||||||
private static final class StableIndexLock implements AutoCloseable {
|
private static final class StableIndexLock implements AutoCloseable {
|
||||||
private final FileChannel channel;
|
private final FileChannel channel;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ final class FsPaths {
|
|||||||
private static final String BINARY_EXTENSION = ".bin";
|
private static final String BINARY_EXTENSION = ".bin";
|
||||||
|
|
||||||
private static final String BY_ID = "by-id";
|
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 VERSION_FILE = "VERSION";
|
||||||
/* default */ static final String LOCK_DIR = ".lock";
|
/* default */ static final String LOCK_DIR = ".lock";
|
||||||
@@ -166,7 +167,8 @@ final class FsPaths {
|
|||||||
|
|
||||||
/* default */ Path revocationDir(final PkiId credentialId) {
|
/* default */ Path revocationDir(final PkiId credentialId) {
|
||||||
Objects.requireNonNull(credentialId, "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) {
|
/* default */ Path revocationJournal(final PkiId credentialId) {
|
||||||
@@ -174,19 +176,23 @@ final class FsPaths {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* default */ Path revocationTransitionLog() {
|
/* default */ Path revocationTransitionLog() {
|
||||||
return this.root.resolve("revocations").resolve("transitions.log");
|
return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("transitions.log");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* default */ Path revocationCheckpointDirectory() {
|
/* 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() {
|
/* 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() {
|
/* 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() {
|
/* default */ Path revocationSnapshotRoot() {
|
||||||
|
|||||||
@@ -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<PkiId> 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<RevocationCheckpointCodec.CurrentStateEntry> 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<String> collect(FilesystemRevocationCheckpoint checkpoint)
|
||||||
|
throws Exception {
|
||||||
|
List<String> 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<RevocationCheckpointCodec.CurrentStateEntry> collectEntries(
|
||||||
|
FilesystemRevocationCheckpoint checkpoint) throws Exception {
|
||||||
|
List<RevocationCheckpointCodec.CurrentStateEntry> 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<PkiId> collect(
|
||||||
|
FilesystemRevocationCurrentIndex.FrozenSnapshot frozen) throws Exception {
|
||||||
|
List<PkiId> 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<Path> paths = Files.newDirectoryStream(directory)) {
|
||||||
|
return !paths.iterator().hasNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasCheckpoint(Path directory) throws IOException {
|
||||||
|
if (!Files.isDirectory(directory)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try (DirectoryStream<Path> 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<PkiId> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -427,13 +427,13 @@ final class FilesystemRevocationCurrentIndexTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void structuralContractHasNoHistoryCollectionOrCheckpointDependency() throws Exception {
|
void structuralContractHasNoHistoryCollectionOrCheckpointPublication() throws Exception {
|
||||||
System.out.print("structuralContractHasNoHistoryCollectionOrCheckpointDependency ");
|
System.out.print("structuralContractHasNoHistoryCollectionOrCheckpointPublication ");
|
||||||
String source = Files.readString(Path.of(
|
String source = Files.readString(Path.of(
|
||||||
"src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java"));
|
"src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java"));
|
||||||
assertFalse(source.contains("List<RevocationTransition>"));
|
assertFalse(source.contains("List<RevocationTransition>"));
|
||||||
assertFalse(source.contains("readAllBytes()"));
|
assertFalse(source.contains("readAllBytes()"));
|
||||||
assertFalse(source.contains("FilesystemRevocationCheckpoint"));
|
assertFalse(source.contains("publishHistorical"));
|
||||||
assertFalse(source.contains("HashMap"));
|
assertFalse(source.contains("HashMap"));
|
||||||
assertFalse(source.contains("new Thread"));
|
assertFalse(source.contains("new Thread"));
|
||||||
assertFalse(source.contains("Executors."));
|
assertFalse(source.contains("Executors."));
|
||||||
|
|||||||
Reference in New Issue
Block a user