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");
|
||||
Binding.requireCurrentPublication(recovery, coverage);
|
||||
Binding.validate(logPath, recovery, coverage);
|
||||
return publishBound(directory, coverage, source, operations, faults,
|
||||
checkpoint -> Binding.validateGeneration(checkpoint, logPath, recovery));
|
||||
}
|
||||
|
||||
/* default */ static PublishedGeneration publishHistorical(
|
||||
Path directory,
|
||||
FilesystemRevocationCurrentIndex.FrozenSnapshot frozen,
|
||||
SortedSource source) throws IOException {
|
||||
Objects.requireNonNull(directory, "directory");
|
||||
Objects.requireNonNull(frozen, "frozen");
|
||||
Objects.requireNonNull(source, "source");
|
||||
Coverage coverage = frozen.coverage();
|
||||
return publishBound(
|
||||
directory, coverage, source,
|
||||
DefaultPublicationOperations.INSTANCE, FaultInjector.NONE,
|
||||
checkpoint -> HistoricalBinding.validateGeneration(
|
||||
checkpoint, frozen));
|
||||
}
|
||||
|
||||
private static PublishedGeneration publishBound(
|
||||
Path directory,
|
||||
Coverage coverage,
|
||||
SortedSource source,
|
||||
PublicationOperations operations,
|
||||
FaultInjector faults,
|
||||
GenerationValidator validator) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
requireDirectory(directory);
|
||||
long declaredCount = source.entryCount();
|
||||
@@ -126,7 +152,7 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable {
|
||||
Path target = directory.resolve(finalName(coverage.coveredRevision(), build.generationId()));
|
||||
boolean published = false;
|
||||
try {
|
||||
Binding.validateGeneration(temporary, logPath, recovery);
|
||||
validator.validate(temporary);
|
||||
published = publishAtomically(temporary, target, operations, faults);
|
||||
forceDirectory(directory, operations, faults);
|
||||
return new PublishedGeneration(target, build.generationId(), coverage.coveredRevision(), published);
|
||||
@@ -142,6 +168,13 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact post-build binding check selected by the publication boundary. */
|
||||
@FunctionalInterface
|
||||
private interface GenerationValidator {
|
||||
/** Validates the completed temporary generation before publication. */
|
||||
void validate(Path checkpoint) throws IOException;
|
||||
}
|
||||
|
||||
/* default */ static Optional<FilesystemRevocationCheckpoint> discover(
|
||||
Path directory,
|
||||
Path logPath,
|
||||
@@ -652,6 +685,66 @@ final class FilesystemRevocationCheckpoint implements AutoCloseable {
|
||||
|
||||
}
|
||||
|
||||
/** Exact set-equality proof between a generation and its immutable frozen index. */
|
||||
private static final class HistoricalBinding {
|
||||
private static void validateGeneration(
|
||||
Path checkpointPath,
|
||||
FilesystemRevocationCurrentIndex.FrozenSnapshot frozen) throws IOException {
|
||||
try (FileChannel checkpoint = FileChannel.open(
|
||||
checkpointPath, StandardOpenOption.READ)) {
|
||||
RevocationCheckpointCodec codec = new RevocationCheckpointCodec();
|
||||
RevocationCheckpointCodec.ValidatedFile validated = codec.validate(checkpoint);
|
||||
Coverage expectedCoverage = frozen.coverage();
|
||||
Coverage actualCoverage = Coverage.from(validated.header());
|
||||
if (!actualCoverage.equals(expectedCoverage)) {
|
||||
throw new IOException("Checkpoint coverage changed during construction");
|
||||
}
|
||||
if (validated.header().entryCount() != frozen.entryCount()) {
|
||||
throw new IOException("Checkpoint count differs from its frozen index");
|
||||
}
|
||||
validateEntries(frozen, checkpoint, codec, validated);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateEntries(
|
||||
FilesystemRevocationCurrentIndex.FrozenSnapshot frozen,
|
||||
FileChannel checkpoint,
|
||||
RevocationCheckpointCodec checkpointCodec,
|
||||
RevocationCheckpointCodec.ValidatedFile validated) throws IOException {
|
||||
long offset = RevocationCheckpointCodec.HEADER_BYTES;
|
||||
RevocationCheckpointCodec.SequentialDecoder decoder =
|
||||
checkpointCodec.sequentialDecoder();
|
||||
for (long index = 0L; index < validated.header().entryCount(); index++) {
|
||||
RevocationCheckpointCodec.DecodedRecord decoded = decoder.read(
|
||||
checkpoint, offset, validated.trailerOffset());
|
||||
validateEntry(frozen, decoded.entry());
|
||||
offset = decoded.nextOffset();
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateEntry(
|
||||
FilesystemRevocationCurrentIndex.FrozenSnapshot frozen,
|
||||
RevocationCheckpointCodec.CurrentStateEntry entry) throws IOException {
|
||||
RevocationTransitionFrameCodec.CompleteRecord frame = frozen.lookup(
|
||||
entry.credentialId()).orElseThrow(
|
||||
() -> new IOException(
|
||||
"Checkpoint entry is absent from its frozen index"));
|
||||
if (!frame.data().credentialId().equals(entry.credentialId())
|
||||
|| frame.data().globalRevision() != entry.globalRevision()
|
||||
|| frame.data().transition().revision() != entry.credentialRevision()
|
||||
|| !frame.commitment().equals(entry.transitionCommitment())
|
||||
|| !RevocationTransitionFrameCodec.transitionsEqual(
|
||||
frame.data().transition(), entry.transition())
|
||||
|| !new FrameBounds(frame.recordOffset(), frame.recordEnd()).equals(
|
||||
new FrameBounds(entry.frameStart(), entry.frameEnd()))) {
|
||||
throw new IOException("Checkpoint entry disagrees with its frozen current state");
|
||||
}
|
||||
}
|
||||
|
||||
private record FrameBounds(long start, long end) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Streaming discovery keeps one candidate descriptor and repeats only for ambiguity fallback. */
|
||||
private static final class Discovery {
|
||||
private static Optional<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 final Path indexPath;
|
||||
private final Path logPath;
|
||||
private final MetadataStoreId storeId;
|
||||
private final Configuration configuration;
|
||||
private final PublicationOperations operations;
|
||||
@@ -126,6 +127,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
||||
|
||||
private FilesystemRevocationCurrentIndex(
|
||||
Path indexPath,
|
||||
Path logPath,
|
||||
MetadataStoreId storeId,
|
||||
Configuration configuration,
|
||||
PublicationOperations operations,
|
||||
@@ -135,6 +137,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
||||
FileChannel logChannel,
|
||||
SelectedSuperblock selected) {
|
||||
this.indexPath = indexPath;
|
||||
this.logPath = logPath;
|
||||
this.storeId = storeId;
|
||||
this.configuration = configuration;
|
||||
this.operations = operations;
|
||||
@@ -172,13 +175,14 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
||||
IoOperations.requireRegular(indexPath, "Revocation current index");
|
||||
IoOperations.requireRegular(logPath, "Revocation transition log");
|
||||
try (OpenResources resources = OpenResources.acquire(indexPath, logPath)) {
|
||||
return openLocked(indexPath, expectedStoreId, configuration,
|
||||
return openLocked(indexPath, logPath, expectedStoreId, configuration,
|
||||
operations, faults, resources);
|
||||
}
|
||||
}
|
||||
|
||||
private static FilesystemRevocationCurrentIndex openLocked(
|
||||
Path indexPath,
|
||||
Path logPath,
|
||||
MetadataStoreId expectedStoreId,
|
||||
Configuration configuration,
|
||||
PublicationOperations operations,
|
||||
@@ -191,7 +195,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
||||
SelectedSuperblock selected = CandidateSelection.select(
|
||||
resources, expectedStoreId, candidates);
|
||||
FilesystemRevocationCurrentIndex opened = new FilesystemRevocationCurrentIndex(
|
||||
indexPath, expectedStoreId, configuration, operations, faults,
|
||||
indexPath, logPath, expectedStoreId, configuration, operations, faults,
|
||||
resources.indexChannel(), resources.stableLock(),
|
||||
resources.logChannel(), selected);
|
||||
opened.new IndexAccess().applySuffix();
|
||||
@@ -241,7 +245,7 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
||||
IoOperations.forceDirectory(parent, operations, faults);
|
||||
try (OpenResources resources = OpenResources.acquireLocked(
|
||||
stable, indexPath, logPath)) {
|
||||
return openLocked(indexPath, expectedStoreId, configuration,
|
||||
return openLocked(indexPath, logPath, expectedStoreId, configuration,
|
||||
operations, faults, resources);
|
||||
}
|
||||
} finally {
|
||||
@@ -322,6 +326,43 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/* default */ FrozenSnapshot freeze(Path snapshotPath, int transferBufferBytes)
|
||||
throws IOException {
|
||||
return freeze(snapshotPath, transferBufferBytes, FrozenFaultInjector.NONE);
|
||||
}
|
||||
|
||||
/* default */ FrozenSnapshot freeze(
|
||||
Path snapshotPath,
|
||||
int transferBufferBytes,
|
||||
FrozenFaultInjector faults) throws IOException {
|
||||
Objects.requireNonNull(snapshotPath, "snapshotPath");
|
||||
Objects.requireNonNull(faults, "faults");
|
||||
if (transferBufferBytes <= 0) {
|
||||
throw new IllegalArgumentException("Freeze transfer buffer must be positive");
|
||||
}
|
||||
lifecycleLock.lock();
|
||||
try {
|
||||
requireOperational();
|
||||
try (FrozenResources resources = FrozenResources.create(snapshotPath, logPath)) {
|
||||
long expectedSize = IoOperations.expectedSize(active.capacity());
|
||||
faults.fail(FrozenFaultPoint.COPY);
|
||||
IoOperations.copyExact(
|
||||
indexChannel, resources.indexChannel(), expectedSize, transferBufferBytes);
|
||||
faults.fail(FrozenFaultPoint.FORCE);
|
||||
resources.indexChannel().force(true);
|
||||
IndexValidation.validate(
|
||||
resources.indexChannel(), resources.logChannel(), storeId, active);
|
||||
FrozenSnapshot snapshot = new FrozenSnapshot(
|
||||
snapshotPath, logPath,
|
||||
resources.indexChannel(), resources.logChannel(), active);
|
||||
resources.transferOwnership();
|
||||
return snapshot;
|
||||
}
|
||||
} finally {
|
||||
lifecycleLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
lifecycleLock.lock();
|
||||
@@ -879,6 +920,34 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyExact(
|
||||
FileChannel source,
|
||||
FileChannel target,
|
||||
long length,
|
||||
int transferBufferBytes) throws IOException {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(transferBufferBytes);
|
||||
long offset = 0L;
|
||||
while (offset < length) {
|
||||
buffer.clear();
|
||||
buffer.limit((int) Math.min((long) buffer.capacity(), length - offset));
|
||||
int read = source.read(buffer, offset);
|
||||
if (read <= 0) {
|
||||
throw new IOException("Revocation current-index freeze made no read progress");
|
||||
}
|
||||
buffer.flip();
|
||||
long writeOffset = offset;
|
||||
while (buffer.hasRemaining()) {
|
||||
int written = target.write(buffer, writeOffset);
|
||||
if (written <= 0) {
|
||||
throw new IOException("Revocation current-index freeze made no write progress");
|
||||
}
|
||||
writeOffset += written;
|
||||
}
|
||||
offset = writeOffset;
|
||||
}
|
||||
target.truncate(length);
|
||||
}
|
||||
|
||||
private static void requireExpectedSize(FileChannel channel, long capacity) throws IOException {
|
||||
if (channel.size() != expectedSize(capacity)) {
|
||||
throw new IOException("Revocation current index physical size is invalid");
|
||||
@@ -1428,6 +1497,270 @@ final class FilesystemRevocationCurrentIndex implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic frozen-copy failure seam; package access is intentional for tests. */
|
||||
/* default */ @FunctionalInterface
|
||||
interface FrozenFaultInjector {
|
||||
FrozenFaultInjector NONE = point -> { };
|
||||
|
||||
/** Fails at one copy or force boundary. */
|
||||
void fail(FrozenFaultPoint point) throws IOException;
|
||||
}
|
||||
|
||||
/** Finite lifecycle boundaries for the frozen derived-index copy. */
|
||||
/* default */ enum FrozenFaultPoint {
|
||||
COPY,
|
||||
FORCE
|
||||
}
|
||||
|
||||
/** Immutable physical-slot copy used by bounded checkpoint construction. */
|
||||
/* default */ static final class FrozenSnapshot implements AutoCloseable {
|
||||
private final Path path;
|
||||
private final Path logPath;
|
||||
private final FileChannel indexChannel;
|
||||
private final FileChannel logChannel;
|
||||
private final Superblock state;
|
||||
private boolean closed;
|
||||
|
||||
private FrozenSnapshot(
|
||||
Path path,
|
||||
Path logPath,
|
||||
FileChannel indexChannel,
|
||||
FileChannel logChannel,
|
||||
Superblock state) {
|
||||
this.path = path;
|
||||
this.logPath = logPath;
|
||||
this.indexChannel = indexChannel;
|
||||
this.logChannel = logChannel;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
/* default */ long entryCount() {
|
||||
requireOpen();
|
||||
return state.entryCount();
|
||||
}
|
||||
|
||||
/* default */ FilesystemRevocationCheckpoint.Coverage coverage() {
|
||||
requireOpen();
|
||||
return new FilesystemRevocationCheckpoint.Coverage(
|
||||
state.storeId(), state.coveredRevision(), state.finalRecordStart(),
|
||||
state.coveredBoundary(), state.coveredCommitment());
|
||||
}
|
||||
|
||||
/* default */ FrozenCursor openCursor() {
|
||||
requireOpen();
|
||||
return new PhysicalCursor(this);
|
||||
}
|
||||
|
||||
/* default */ Path logPath() {
|
||||
requireOpen();
|
||||
return logPath;
|
||||
}
|
||||
|
||||
/* default */ Optional<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. */
|
||||
private static final class StableIndexLock implements AutoCloseable {
|
||||
private final FileChannel channel;
|
||||
|
||||
@@ -60,6 +60,7 @@ final class FsPaths {
|
||||
private static final String BINARY_EXTENSION = ".bin";
|
||||
|
||||
private static final String BY_ID = "by-id";
|
||||
private static final String REVOCATIONS_DIRECTORY = "revocations";
|
||||
|
||||
/* default */ static final String VERSION_FILE = "VERSION";
|
||||
/* default */ static final String LOCK_DIR = ".lock";
|
||||
@@ -166,7 +167,8 @@ final class FsPaths {
|
||||
|
||||
/* default */ Path revocationDir(final PkiId credentialId) {
|
||||
Objects.requireNonNull(credentialId, "credentialId");
|
||||
return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId));
|
||||
return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("by-credential")
|
||||
.resolve(FsUtil.safeId(credentialId));
|
||||
}
|
||||
|
||||
/* default */ Path revocationJournal(final PkiId credentialId) {
|
||||
@@ -174,19 +176,23 @@ final class FsPaths {
|
||||
}
|
||||
|
||||
/* default */ Path revocationTransitionLog() {
|
||||
return this.root.resolve("revocations").resolve("transitions.log");
|
||||
return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("transitions.log");
|
||||
}
|
||||
|
||||
/* default */ Path revocationCheckpointDirectory() {
|
||||
return this.root.resolve("revocations").resolve("checkpoints");
|
||||
return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("checkpoints");
|
||||
}
|
||||
|
||||
/* default */ Path revocationCheckpointWorkDirectory() {
|
||||
return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("checkpoint-work");
|
||||
}
|
||||
|
||||
/* default */ Path revocationCurrentIndex() {
|
||||
return this.root.resolve("revocations").resolve("current-state.idx");
|
||||
return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("current-state.idx");
|
||||
}
|
||||
|
||||
/* default */ Path revocationCurrentIndexLock() {
|
||||
return this.root.resolve("revocations").resolve("current-state.idx.lock");
|
||||
return this.root.resolve(REVOCATIONS_DIRECTORY).resolve("current-state.idx.lock");
|
||||
}
|
||||
|
||||
/* default */ Path revocationSnapshotRoot() {
|
||||
|
||||
@@ -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
|
||||
void structuralContractHasNoHistoryCollectionOrCheckpointDependency() throws Exception {
|
||||
System.out.print("structuralContractHasNoHistoryCollectionOrCheckpointDependency ");
|
||||
void structuralContractHasNoHistoryCollectionOrCheckpointPublication() throws Exception {
|
||||
System.out.print("structuralContractHasNoHistoryCollectionOrCheckpointPublication ");
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationCurrentIndex.java"));
|
||||
assertFalse(source.contains("List<RevocationTransition>"));
|
||||
assertFalse(source.contains("readAllBytes()"));
|
||||
assertFalse(source.contains("FilesystemRevocationCheckpoint"));
|
||||
assertFalse(source.contains("publishHistorical"));
|
||||
assertFalse(source.contains("HashMap"));
|
||||
assertFalse(source.contains("new Thread"));
|
||||
assertFalse(source.contains("Executors."));
|
||||
|
||||
Reference in New Issue
Block a user