feat(pki): add immutable revocation checkpoints

Add strict log-bound immutable current-state checkpoint generations
with atomic POSIX publication and constant-memory ordered cursors.

Keep the global revocation transition log as the sole authority while
providing the derived snapshot primitive required for scalable CRL
generation and later index integration.
This commit is contained in:
2026-08-02 10:52:20 +02:00
parent 7e11129332
commit 6545b7b5b6
5 changed files with 2778 additions and 0 deletions

View File

@@ -0,0 +1,932 @@
/*******************************************************************************
* 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.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Logger;
import zeroecho.core.io.CancellationSignal;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.spi.store.MetadataStoreId;
/** Immutable POSIX checkpoint view derived from the authoritative revocation log. */
final class FilesystemRevocationCheckpoint implements AutoCloseable {
private static final Logger LOGGER =
Logger.getLogger(FilesystemRevocationCheckpoint.class.getName());
private static final String DIRECTORY_WARNING =
"Revocation checkpoint directory durability is limited; continuing in best-effort mode";
private static final String RETIREMENT_WARNING =
"An obsolete revocation checkpoint generation could not be retired";
private static final String FINAL_PREFIX = "g-";
private static final String FINAL_SUFFIX = ".chk";
private static final String BUILDING_PREFIX = ".building-";
private static final int GENERATION_HEX_LENGTH = 64;
private static final long ZERO_REVISION = 0L;
private static final HexFormat HEX = HexFormat.of();
private final FileChannel channel;
private final RevocationCheckpointCodec codec;
private final RevocationCheckpointCodec.ValidatedFile validated;
private final ReentrantLock lifecycleLock = new ReentrantLock();
private final Set<CursorState> cursors = new HashSet<>();
private boolean closed;
private FilesystemRevocationCheckpoint(
FileChannel channel,
RevocationCheckpointCodec codec,
RevocationCheckpointCodec.ValidatedFile validated) {
this.channel = channel;
this.codec = codec;
this.validated = validated;
}
/* default */ static PublishedGeneration publish(
Path directory,
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery,
Coverage coverage,
SortedSource source) throws IOException {
return publish(directory, logPath, recovery, coverage, source,
DefaultPublicationOperations.INSTANCE, FaultInjector.NONE);
}
/* default */ static PublishedGeneration publish(
Path directory,
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery,
Coverage coverage,
SortedSource source,
PublicationOperations operations,
FaultInjector faults) throws IOException {
Objects.requireNonNull(directory, "directory");
Objects.requireNonNull(source, "source");
Objects.requireNonNull(operations, "operations");
Objects.requireNonNull(faults, "faults");
Binding.requireCurrentPublication(recovery, coverage);
Binding.validate(logPath, recovery, coverage);
Files.createDirectories(directory);
requireDirectory(directory);
long declaredCount = source.entryCount();
if (declaredCount < ZERO_REVISION) {
throw new IllegalArgumentException("Checkpoint source count must be non-negative");
}
RevocationCheckpointCodec.HeaderData header = coverage.header(declaredCount);
Path temporary = directory.resolve(BUILDING_PREFIX + UUID.randomUUID());
BuildResult build = buildTemporary(temporary, header, source, faults);
Path target = directory.resolve(finalName(coverage.coveredRevision(), build.generationId()));
boolean published = false;
try {
Binding.validateGeneration(temporary, logPath, recovery);
published = publishAtomically(temporary, target, operations, faults);
forceDirectory(directory, operations, faults);
return new PublishedGeneration(target, build.generationId(), coverage.coveredRevision(), published);
} catch (IOException failure) {
if (!published && Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)) {
try {
Files.delete(temporary);
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
throw failure;
}
}
/* default */ static Optional<FilesystemRevocationCheckpoint> discover(
Path directory,
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery) throws IOException {
return Discovery.discover(directory, logPath, recovery);
}
/* default */ static FilesystemRevocationCheckpoint open(
Path checkpointPath,
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery) throws IOException {
Objects.requireNonNull(checkpointPath, "checkpointPath");
FinalName name = FinalName.parse(checkpointPath.getFileName().toString())
.orElseThrow(() -> new IOException("Invalid revocation checkpoint generation name"));
if (!Files.isRegularFile(checkpointPath, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(checkpointPath)) {
throw new IOException("Revocation checkpoint generation is not a regular file");
}
FileChannel channel = FileChannel.open(checkpointPath, StandardOpenOption.READ);
try {
RevocationCheckpointCodec codec = new RevocationCheckpointCodec();
RevocationCheckpointCodec.ValidatedFile validated = codec.validate(channel);
name.requireAgreement(validated);
Binding.validate(logPath, recovery, Coverage.from(validated.header()));
return new FilesystemRevocationCheckpoint(channel, codec, validated);
} catch (IOException failure) {
try {
channel.close();
} catch (IOException closeFailure) {
failure.addSuppressed(closeFailure);
}
throw failure;
}
}
/* default */ static long retireObsolete(Path directory, PublishedGeneration retained) {
return Retirement.retire(directory, retained);
}
/* default */ String generationId() {
return validated.generationId();
}
/* default */ MetadataStoreId storeId() {
return validated.header().storeId();
}
/* default */ long coveredRevision() {
return validated.header().coveredRevision();
}
/* default */ long coveredBoundary() {
return validated.header().coveredBoundary();
}
/* default */ RevocationTransitionFrameCodec.Commitment coveredCommitment() {
return validated.header().globalCommitment();
}
/* default */ long entryCount() {
return validated.header().entryCount();
}
/* default */ Cursor allCurrentStates() {
return newCursor(false);
}
/* default */ Cursor currentRevokedStates() {
return newCursor(true);
}
private Cursor newCursor(boolean revokedOnly) {
lifecycleLock.lock();
try {
requireOpen();
return new CheckpointCursor(revokedOnly);
} finally {
lifecycleLock.unlock();
}
}
@Override
public void close() throws IOException {
lifecycleLock.lock();
try {
if (closed) {
return;
}
closed = true;
for (CursorState cursorState : cursors) {
cursorState.closeFromOwner();
}
cursors.clear();
channel.close();
} finally {
lifecycleLock.unlock();
}
}
private void requireOpen() {
if (closed || !channel.isOpen()) {
throw new IllegalStateException("Revocation checkpoint is closed");
}
}
private static BuildResult buildTemporary(
Path temporary,
RevocationCheckpointCodec.HeaderData header,
SortedSource source,
FaultInjector faults) throws IOException {
boolean complete = false;
try {
BuildResult result;
try (SortedSource ownedSource = source;
FileChannel channel = FileChannel.open(temporary,
StandardOpenOption.CREATE_NEW, StandardOpenOption.READ,
StandardOpenOption.WRITE);
SourceCursor cursor = ownedSource.openCursor()) {
result = writeTemporary(channel, header, cursor, faults);
}
complete = true;
return result;
} finally {
if (!complete) {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// The primary build failure remains authoritative.
}
}
}
}
private static BuildResult writeTemporary(
FileChannel channel,
RevocationCheckpointCodec.HeaderData header,
SourceCursor cursor,
FaultInjector faults) throws IOException {
RevocationCheckpointCodec codec = new RevocationCheckpointCodec();
RevocationCheckpointCodec.Encoder encoder = codec.encoder(channel, header);
byte[] previous = null;
for (long index = 0L; index < header.entryCount(); index++) {
if (!cursor.advance()) {
throw new IOException("Checkpoint source ended before its declared count");
}
RevocationCheckpointCodec.CurrentStateEntry entry = Objects.requireNonNull(
cursor.current(), "checkpoint source current entry");
byte[] identity = RevocationCheckpointCodec.strictUtf8(entry.credentialId().value());
if (previous != null
&& RevocationCheckpointCodec.compareUnsigned(previous, identity) >= 0) {
throw new IOException("Checkpoint source identities are not strictly increasing");
}
faults.fail(FaultPoint.WRITE);
encoder.write(entry);
previous = identity;
}
if (cursor.advance()) {
throw new IOException("Checkpoint source exceeds its declared count");
}
String generation = encoder.finish();
faults.fail(FaultPoint.FILE_FORCE);
channel.force(true);
return new BuildResult(generation);
}
private static boolean publishAtomically(
Path temporary,
Path target,
PublicationOperations operations,
FaultInjector faults) throws IOException {
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
if (sameBytes(temporary, target)) {
Files.delete(temporary);
return false;
}
throw new IOException("Conflicting revocation checkpoint generation exists");
}
try {
faults.fail(FaultPoint.ATOMIC_MOVE);
operations.atomicMove(temporary, target);
return true;
} catch (FileAlreadyExistsException exists) {
if (sameBytes(temporary, target)) {
Files.delete(temporary);
return false;
}
throw new IOException("Conflicting revocation checkpoint generation exists", exists);
} catch (AtomicMoveNotSupportedException unsupported) {
throw new IOException("Atomic revocation checkpoint publication is unsupported", unsupported);
}
}
private static boolean sameBytes(Path first, Path second) throws IOException {
if (Files.size(first) != Files.size(second)) {
return false;
}
byte[] firstBytes = new byte[16 * 1024];
byte[] secondBytes = new byte[firstBytes.length];
try (FileChannel firstChannel = FileChannel.open(first, StandardOpenOption.READ);
FileChannel secondChannel = FileChannel.open(second, StandardOpenOption.READ)) {
long offset = 0L;
long size = firstChannel.size();
while (offset < size) {
int requested = (int) Math.min(firstBytes.length, size - offset);
readExact(firstChannel, firstBytes, requested, offset);
readExact(secondChannel, secondBytes, requested, offset);
if (!equalPrefix(firstBytes, secondBytes, requested)) {
return false;
}
offset += requested;
}
}
return true;
}
private static boolean equalPrefix(byte[] first, byte[] second, int length) {
for (int index = 0; index < length; index++) {
if (first[index] != second[index]) {
return false;
}
}
return true;
}
private static void readExact(FileChannel channel, byte[] bytes, int length, long offset)
throws IOException {
ByteBuffer buffer = ByteBuffer.wrap(bytes, 0, length);
long position = offset;
while (buffer.hasRemaining()) {
int read = channel.read(buffer, position);
if (read <= 0) {
throw new IOException("Checkpoint comparison made no read progress");
}
position += read;
}
}
private static void forceDirectory(
Path directory, PublicationOperations operations, FaultInjector faults) {
try {
faults.fail(FaultPoint.DIRECTORY_FORCE);
operations.forceDirectory(directory);
} catch (IOException | UnsupportedOperationException unavailable) {
warn(DIRECTORY_WARNING);
}
}
private static void warn(String message) {
try {
LOGGER.warning(message);
} catch (IllegalStateException ignored) {
// Advisory logging cannot change derived-state publication or retirement.
}
}
private static void requireDirectory(Path directory) throws IOException {
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(directory)) {
throw new IOException("Revocation checkpoint directory is invalid");
}
}
private static String finalName(long revision, String generationId) {
return FINAL_PREFIX + revision + "-" + generationId + FINAL_SUFFIX;
}
/** Current-state source that transfers deterministic close ownership to publication. */
/* default */ interface SortedSource extends AutoCloseable {
/** Returns the exact non-negative entry count. */
long entryCount();
/** Opens the owned one-entry-at-a-time cursor. */
SourceCursor openCursor() throws IOException;
@Override
void close() throws IOException;
}
/** One-entry-at-a-time source cursor; current is invalidated by advancement. */
/* default */ interface SourceCursor extends AutoCloseable {
/** Advances to the next source entry. */
boolean advance() throws IOException;
/** Returns the current source entry. */
RevocationCheckpointCodec.CurrentStateEntry current();
@Override
void close() throws IOException;
}
/** Read-only ordered checkpoint cursor. */
/* default */ interface Cursor extends AutoCloseable {
/** Advances to the next matching checkpoint entry. */
boolean advance(CancellationSignal cancellation) throws IOException;
/** Returns the current checkpoint entry. */
RevocationCheckpointCodec.CurrentStateEntry current();
@Override
void close();
}
/** Exact authoritative coverage declared by a derived generation. */
/* default */ record Coverage(
MetadataStoreId storeId,
long coveredRevision,
OptionalLong finalRecordStart,
long coveredBoundary,
RevocationTransitionFrameCodec.Commitment globalCommitment) {
Coverage {
Objects.requireNonNull(storeId, "storeId");
Objects.requireNonNull(finalRecordStart, "finalRecordStart");
Objects.requireNonNull(globalCommitment, "globalCommitment");
}
private RevocationCheckpointCodec.HeaderData header(long count) {
return new RevocationCheckpointCodec.HeaderData(
storeId, coveredRevision, finalRecordStart,
coveredBoundary, globalCommitment, count);
}
private static Coverage from(RevocationCheckpointCodec.HeaderData header) {
return new Coverage(
header.storeId(), header.coveredRevision(), header.finalRecordStart(),
header.coveredBoundary(), header.globalCommitment());
}
}
/** Immutable publication result; {@code newlyPublished} distinguishes idempotent reuse. */
/* default */ record PublishedGeneration(
Path path, String generationId, long coveredRevision, boolean newlyPublished) {
PublishedGeneration {
Objects.requireNonNull(path, "path");
Objects.requireNonNull(generationId, "generationId");
}
}
/** Deterministic publication fault seam; not production API. */
/* default */ @FunctionalInterface
interface FaultInjector {
FaultInjector NONE = point -> { };
/** Fails one deterministic lifecycle point. */
void fail(FaultPoint point) throws IOException;
}
/** Finite lifecycle fault points used by deterministic tests. */
/* default */ enum FaultPoint {
WRITE,
FILE_FORCE,
ATOMIC_MOVE,
DIRECTORY_FORCE
}
/** POSIX publication operations isolated for deterministic fault validation. */
/* default */ interface PublicationOperations {
/** Publishes one completed file using an atomic move. */
void atomicMove(Path source, Path target) throws IOException;
/** Forces the containing directory when supported. */
void forceDirectory(Path directory) throws IOException;
}
/** Default POSIX publication operations. */
private enum DefaultPublicationOperations implements PublicationOperations {
/** Stateless singleton. */
INSTANCE;
@Override
public void atomicMove(Path source, Path target) throws IOException {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
}
@Override
public void forceDirectory(Path directory) throws IOException {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
}
}
}
/** Log-binding validation never replays the covered authoritative prefix. */
private static final class Binding {
private static void validateGeneration(
Path checkpointPath,
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery) throws IOException {
try (FileChannel checkpoint = FileChannel.open(
checkpointPath, StandardOpenOption.READ)) {
RevocationCheckpointCodec codec = new RevocationCheckpointCodec();
RevocationCheckpointCodec.ValidatedFile validated = codec.validate(checkpoint);
validate(logPath, recovery, Coverage.from(validated.header()));
validateCurrentEntries(logPath, recovery, checkpoint, codec, validated);
}
}
private static void requireCurrentPublication(
FilesystemRevocationLog.RecoveryResult recovery,
Coverage coverage) throws IOException {
Objects.requireNonNull(recovery, "recovery");
Objects.requireNonNull(coverage, "coverage");
if (coverage.coveredRevision() != recovery.globalRevision()
|| coverage.coveredBoundary() != recovery.lastCompleteRecordBoundary()
|| !coverage.globalCommitment().equals(recovery.globalCommitment())) {
throw new IOException(
"Checkpoint publication must cover the complete validated log state");
}
}
private static void validateCurrentEntries(
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery,
FileChannel checkpoint,
RevocationCheckpointCodec checkpointCodec,
RevocationCheckpointCodec.ValidatedFile validated) throws IOException {
if (validated.header().entryCount() != recovery.latestStates().size()) {
throw new IOException("Checkpoint omits current revocation state");
}
try (FileChannel log = FileChannel.open(logPath, StandardOpenOption.READ)) {
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());
validateCurrentEntry(log, recovery, decoded.entry());
offset = decoded.nextOffset();
}
}
}
private static void validateCurrentEntry(
FileChannel log,
FilesystemRevocationLog.RecoveryResult recovery,
RevocationCheckpointCodec.CurrentStateEntry entry) throws IOException {
FilesystemRevocationLog.LatestState latest = recovery.latestStates().get(entry.credentialId());
if (latest == null
|| latest.globalRevision() != entry.globalRevision()
|| latest.recordOffset() != entry.frameStart()
|| !latest.commitment().equals(entry.transitionCommitment())
|| !RevocationTransitionFrameCodec.transitionsEqual(
latest.transition(), entry.transition())) {
throw new IOException("Checkpoint entry is not the current authoritative state");
}
RevocationTransitionFrameCodec.ReadResult result =
new RevocationTransitionFrameCodec().read(log, entry.frameStart());
if (result.classification()
!= RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD
|| result.record().orElseThrow().recordEnd() != entry.frameEnd()) {
throw new IOException("Checkpoint entry has an invalid authoritative frame boundary");
}
}
private static void validate(
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery,
Coverage coverage) throws IOException {
Objects.requireNonNull(logPath, "logPath");
Objects.requireNonNull(recovery, "recovery");
Objects.requireNonNull(coverage, "coverage");
if (!coverage.storeId().equals(recovery.storeId())
|| coverage.coveredRevision() > recovery.globalRevision()
|| coverage.coveredBoundary() > recovery.lastCompleteRecordBoundary()) {
throw new IOException("Checkpoint does not match the validated revocation log");
}
try (FileChannel log = FileChannel.open(logPath, StandardOpenOption.READ)) {
RevocationTransitionFrameCodec frameCodec = new RevocationTransitionFrameCodec();
if (!coverage.storeId().equals(frameCodec.readPreamble(log))) {
throw new IOException("Checkpoint belongs to another revocation log");
}
if (coverage.coveredRevision() == ZERO_REVISION) {
validateGenesis(coverage);
} else {
validateFrame(log, frameCodec, coverage);
}
}
}
private static void validateGenesis(Coverage coverage) throws IOException {
if (coverage.finalRecordStart().isPresent()
|| coverage.coveredBoundary() != RevocationTransitionFrameCodec.PREAMBLE_BYTES
|| !coverage.globalCommitment().equals(
RevocationTransitionFrameCodec.initialCommitment(coverage.storeId()))) {
throw new IOException("Checkpoint genesis binding is invalid");
}
}
private static void validateFrame(
FileChannel log,
RevocationTransitionFrameCodec codec,
Coverage coverage) throws IOException {
if (coverage.finalRecordStart().isEmpty()) {
throw new IOException("Checkpoint final-frame binding is absent");
}
RevocationTransitionFrameCodec.ReadResult result =
codec.read(log, coverage.finalRecordStart().getAsLong());
if (result.classification()
!= RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
throw new IOException("Checkpoint final-frame binding is invalid");
}
RevocationTransitionFrameCodec.CompleteRecord frame = result.record().orElseThrow();
if (frame.data().globalRevision() != coverage.coveredRevision()
|| !frame.commitment().equals(coverage.globalCommitment())
|| frame.recordEnd() != coverage.coveredBoundary()) {
throw new IOException("Checkpoint final-frame binding does not match the log");
}
}
}
/** Streaming discovery keeps one candidate descriptor and repeats only for ambiguity fallback. */
private static final class Discovery {
private static Optional<FilesystemRevocationCheckpoint> discover(
Path directory,
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery) throws IOException {
Objects.requireNonNull(directory, "directory");
Objects.requireNonNull(logPath, "logPath");
Objects.requireNonNull(recovery, "recovery");
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
return Optional.empty();
}
long upperExclusive = Long.MAX_VALUE;
boolean includeMaximum = true;
while (true) {
Selection selection = select(
directory, logPath, recovery, upperExclusive, includeMaximum);
if (selection.candidate().isEmpty()) {
return Optional.empty();
}
if (!selection.ambiguous()) {
return Optional.of(openSelected(selection.candidate().orElseThrow()));
}
upperExclusive = selection.revision();
includeMaximum = false;
}
}
private static Selection select(
Path directory,
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery,
long upper,
boolean includeUpper) throws IOException {
long selectedRevision = -1L;
Path selectedPath = null;
RevocationCheckpointCodec.ValidatedFile selectedValidated = null;
String selectedGeneration = null;
boolean ambiguous = false;
try (DirectoryStream<Path> entries = Files.newDirectoryStream(directory)) {
for (Path candidate : entries) {
Optional<FinalName> parsed = FinalName.parse(candidate.getFileName().toString());
if (parsed.isEmpty() || !eligible(parsed.orElseThrow().revision(), upper, includeUpper)) {
continue;
}
Optional<RevocationCheckpointCodec.ValidatedFile> validated =
validateCandidate(candidate, logPath, recovery, parsed.orElseThrow());
if (validated.isEmpty()) {
continue;
}
long revision = validated.orElseThrow().header().coveredRevision();
if (revision > selectedRevision) {
selectedRevision = revision;
selectedPath = candidate;
selectedValidated = validated.orElseThrow();
selectedGeneration = validated.orElseThrow().generationId();
ambiguous = false;
} else if (revision == selectedRevision
&& !validated.orElseThrow().generationId().equals(selectedGeneration)) {
ambiguous = true;
}
}
}
Candidate selected = selectedPath == null
? null : new Candidate(selectedPath, selectedValidated);
return new Selection(Optional.ofNullable(selected), selectedRevision, ambiguous);
}
private static FilesystemRevocationCheckpoint openSelected(Candidate selected)
throws IOException {
FileChannel channel = FileChannel.open(selected.path(), StandardOpenOption.READ);
return new FilesystemRevocationCheckpoint(
channel, new RevocationCheckpointCodec(), selected.validated());
}
private static boolean eligible(long revision, long upper, boolean includeUpper) {
return includeUpper ? revision <= upper : revision < upper;
}
private static Optional<RevocationCheckpointCodec.ValidatedFile> validateCandidate(
Path candidate,
Path logPath,
FilesystemRevocationLog.RecoveryResult recovery,
FinalName name) {
try (FileChannel channel = FileChannel.open(candidate, StandardOpenOption.READ)) {
RevocationCheckpointCodec.ValidatedFile validated =
new RevocationCheckpointCodec().validate(channel);
name.requireAgreement(validated);
Binding.validate(logPath, recovery, Coverage.from(validated.header()));
return Optional.of(validated);
} catch (IOException invalid) {
return Optional.empty();
}
}
}
/** Sequential cursor with lifecycle ownership held by its checkpoint. */
private final class CheckpointCursor implements Cursor, CursorState {
private final boolean revokedOnly;
private long offset = RevocationCheckpointCodec.HEADER_BYTES;
private long visited;
private final RevocationCheckpointCodec.SequentialDecoder decoder =
codec.sequentialDecoder();
private RevocationCheckpointCodec.CurrentStateEntry current;
private boolean cursorClosed;
private CheckpointCursor(boolean revokedOnly) {
this.revokedOnly = revokedOnly;
cursors.add(this);
}
@Override
public boolean advance(CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(cancellation, "cancellation");
lifecycleLock.lock();
try {
requireCursorOpen();
invalidateCurrent();
cancellation.throwIfCancelled();
while (visited < validated.header().entryCount()) {
RevocationCheckpointCodec.DecodedRecord record =
decoder.read(channel, offset, validated.trailerOffset());
offset = record.nextOffset();
visited++;
if (!revokedOnly || record.entry().transition().state() != RevocationState.CLEAR) {
current = record.entry();
return true;
}
cancellation.throwIfCancelled();
}
return false;
} finally {
lifecycleLock.unlock();
}
}
private void invalidateCurrent() {
current = null;
}
@Override
public RevocationCheckpointCodec.CurrentStateEntry current() {
lifecycleLock.lock();
try {
requireCursorOpen();
if (current == null) {
throw new IllegalStateException("Checkpoint cursor has no current entry");
}
return current;
} finally {
lifecycleLock.unlock();
}
}
@Override
public void close() {
lifecycleLock.lock();
try {
if (cursorClosed) {
return;
}
cursorClosed = true;
current = null;
cursors.remove(this);
} finally {
lifecycleLock.unlock();
}
}
@Override
public void closeFromOwner() {
cursorClosed = true;
current = null;
}
private void requireCursorOpen() {
requireOpen();
if (cursorClosed) {
throw new IllegalStateException("Revocation checkpoint cursor is closed");
}
}
}
/** Non-resource invalidation view retained by the checkpoint owner. */
@FunctionalInterface
private interface CursorState {
/** Invalidates the cursor without touching the shared file channel. */
void closeFromOwner();
}
/** Non-authoritative retirement of exact obsolete generation names. */
private static final class Retirement {
private static long retire(Path directory, PublishedGeneration retained) {
Objects.requireNonNull(directory, "directory");
Objects.requireNonNull(retained, "retained");
long retired = 0L;
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
return retired;
}
try (DirectoryStream<Path> entries = Files.newDirectoryStream(directory)) {
for (Path candidate : entries) {
if (candidate.equals(retained.path())
|| FinalName.parse(candidate.getFileName().toString()).isEmpty()) {
continue;
}
retired = retireCandidate(candidate, retired);
}
} catch (IOException failure) {
warn(RETIREMENT_WARNING);
}
return retired;
}
private static long retireCandidate(Path candidate, long retired) {
try {
return Files.deleteIfExists(candidate) ? Math.addExact(retired, 1L) : retired;
} catch (IOException | ArithmeticException failure) {
warn(RETIREMENT_WARNING);
return retired;
}
}
}
private record BuildResult(String generationId) {
}
private record Candidate(Path path, RevocationCheckpointCodec.ValidatedFile validated) {
private Candidate {
Objects.requireNonNull(path, "path");
Objects.requireNonNull(validated, "validated");
}
}
private record Selection(Optional<Candidate> candidate, long revision, boolean ambiguous) {
private Selection {
Objects.requireNonNull(candidate, "candidate");
}
}
private record FinalName(long revision, String generationId) {
private void requireAgreement(RevocationCheckpointCodec.ValidatedFile validated)
throws IOException {
if (revision != validated.header().coveredRevision()
|| !generationId.equals(validated.generationId())) {
throw new IOException("Revocation checkpoint filename and contents disagree");
}
}
private static Optional<FinalName> parse(String value) {
if (!value.startsWith(FINAL_PREFIX) || !value.endsWith(FINAL_SUFFIX)) {
return Optional.empty();
}
int separator = value.indexOf('-', FINAL_PREFIX.length());
if (separator < 0) {
return Optional.empty();
}
String revisionText = value.substring(FINAL_PREFIX.length(), separator);
String generation = value.substring(separator + 1, value.length() - FINAL_SUFFIX.length());
if (generation.length() != GENERATION_HEX_LENGTH || !isLowerHex(generation)) {
return Optional.empty();
}
try {
long revision = Long.parseLong(revisionText);
return revision < 0L ? Optional.empty() : Optional.of(new FinalName(revision, generation));
} catch (NumberFormatException invalid) {
return Optional.empty();
}
}
private static boolean isLowerHex(String value) {
try {
return HEX.formatHex(HEX.parseHex(value)).equals(value);
} catch (IllegalArgumentException invalid) {
return false;
}
}
}
}

View File

@@ -177,6 +177,10 @@ final class FsPaths {
return this.root.resolve("revocations").resolve("transitions.log"); return this.root.resolve("revocations").resolve("transitions.log");
} }
/* default */ Path revocationCheckpointDirectory() {
return this.root.resolve("revocations").resolve("checkpoints");
}
/* default */ Path revocationSnapshotRoot() { /* default */ Path revocationSnapshotRoot() {
return this.root.resolve("revocation-snapshots"); return this.root.resolve("revocation-snapshots");
} }

View File

@@ -0,0 +1,999 @@
/*******************************************************************************
* 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.io.OutputStream;
import java.security.DigestOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.CharBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.DateTimeException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet;
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.pki.spi.store.MetadataStoreId;
/** Strict streaming codec for one immutable current-state checkpoint. */
final class RevocationCheckpointCodec {
/* default */ static final int HEADER_BYTES = 128;
/* default */ static final int TRAILER_BYTES = 48;
/* default */ static final int DIGEST_BYTES = 32;
/* default */ static final int MAX_COMPONENT_BYTES =
RevocationTransitionFrameCodec.MAX_COMPONENT_BYTES;
private static final int HEADER_MAGIC = 0x5A455243;
private static final int RECORD_MAGIC = 0x5A455245;
private static final int TRAILER_MAGIC = 0x5A455246;
private static final short FORMAT_VERSION = 1;
private static final short RESERVED_FLAGS = 0;
private static final int STORE_ID_BYTES = 16;
private static final int COMMITMENT_BYTES = 32;
private static final int HEADER_FIELDS_BYTES = HEADER_BYTES - DIGEST_BYTES;
private static final int TRAILER_FIELDS_BYTES = TRAILER_BYTES - DIGEST_BYTES;
private static final int PRESENT = 1;
private static final int ABSENT = 0;
private static final long ZERO_REVISION = 0L;
private static final int TRANSFER_BYTES = 16 * 1024;
private static final byte[] RECORD_DOMAIN =
"ZeroEcho revocation checkpoint record v1".getBytes(StandardCharsets.US_ASCII);
private static final HexFormat HEX = HexFormat.of();
/* default */ Encoder encoder(FileChannel channel, HeaderData header) throws IOException {
return new Encoder(channel, header);
}
/* default */ ValidatedFile validate(FileChannel channel) throws IOException {
return DecoderOperations.validate(channel);
}
/* default */ DecodedRecord readRecord(
FileChannel channel, long recordOffset, long trailerOffset) throws IOException {
return new SequentialDecoder().read(channel, recordOffset, trailerOffset);
}
/* default */ SequentialDecoder sequentialDecoder() {
return new SequentialDecoder();
}
/** Structural validation is isolated to keep the codec facade cohesive. */
private static final class DecoderOperations {
private static ValidatedFile validate(FileChannel channel) throws IOException {
Objects.requireNonNull(channel, "channel");
try {
long size = channel.size();
if (size < HEADER_BYTES + TRAILER_BYTES) {
throw corrupt("Revocation checkpoint is truncated");
}
HeaderData header = decodeHeader(readAt(channel, 0L, HEADER_BYTES));
long offset = HEADER_BYTES;
byte[] previousIdentity = null;
SequentialDecoder decoder = new SequentialDecoder();
for (long index = 0L; index < header.entryCount(); index++) {
DecodedRecord decoded = decoder.read(channel, offset, size - TRAILER_BYTES);
byte[] identity = strictUtf8(decoded.entry().credentialId().value());
if (previousIdentity != null
&& compareUnsigned(previousIdentity, identity) >= 0) {
throw corrupt("Revocation checkpoint identities are not canonical");
}
previousIdentity = identity;
offset = decoded.nextOffset();
}
Trailer trailer = decodeTrailer(readAt(channel, offset, TRAILER_BYTES));
long expectedEnd = addExact(
offset, TRAILER_BYTES, "Revocation checkpoint boundary overflow");
if (expectedEnd != size || trailer.entryCount() != header.entryCount()) {
throw corrupt("Revocation checkpoint framing is inconsistent");
}
byte[] calculated = digestPrefix(channel, size - DIGEST_BYTES);
if (!MessageDigest.isEqual(calculated, trailer.commitment())) {
throw corrupt("Revocation checkpoint commitment is invalid");
}
return new ValidatedFile(header, HEX.formatHex(calculated), offset);
} catch (IllegalArgumentException | DateTimeException malformed) {
throw corrupt("Revocation checkpoint contains malformed semantic data", malformed);
}
}
private static HeaderData decodeHeader(byte[] bytes) throws IOException {
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
if (buffer.getInt() != HEADER_MAGIC) {
throw corrupt("Revocation checkpoint header magic is invalid");
}
byte[] suppliedDigest = java.util.Arrays.copyOfRange(
bytes, HEADER_FIELDS_BYTES, HEADER_BYTES);
MessageDigest digest = sha256();
digest.update(bytes, 0, HEADER_FIELDS_BYTES);
if (!MessageDigest.isEqual(digest.digest(), suppliedDigest)) {
throw corrupt("Revocation checkpoint header authentication failed");
}
short version = buffer.getShort();
short flags = buffer.getShort();
byte[] store = new byte[STORE_ID_BYTES];
buffer.get(store);
long revision = buffer.getLong();
int startPresent = Byte.toUnsignedInt(buffer.get());
byte[] reserved = new byte[7];
buffer.get(reserved);
long start = buffer.getLong();
long boundary = buffer.getLong();
byte[] commitment = new byte[COMMITMENT_BYTES];
buffer.get(commitment);
long count = buffer.getLong();
if (version != FORMAT_VERSION || flags != RESERVED_FLAGS || !allZero(reserved)
|| count < 0L || revision < 0L) {
throw corrupt("Revocation checkpoint header fields are unsupported");
}
OptionalLong finalStart = decodeStart(revision, startPresent, start);
MetadataStoreId storeId = new MetadataStoreId(HEX.formatHex(store));
RevocationTransitionFrameCodec.Commitment chain =
new RevocationTransitionFrameCodec.Commitment(HEX.formatHex(commitment));
HeaderData header = new HeaderData(
storeId, revision, finalStart, boundary, chain, count);
requireHeader(header);
return header;
}
private static OptionalLong decodeStart(long revision, int present, long value)
throws IOException {
if (present == ABSENT && revision == ZERO_REVISION && value == ZERO_REVISION) {
return OptionalLong.empty();
}
if (present == PRESENT && revision > ZERO_REVISION) {
return OptionalLong.of(value);
}
throw corrupt("Revocation checkpoint final-record presence is invalid");
}
private static DecodedRecord decodeRecord(
FileChannel channel,
long offset,
long trailerOffset,
ByteBuffer input) throws IOException {
if (offset < HEADER_BYTES || trailerOffset < offset) {
throw corrupt("Revocation checkpoint record boundary is invalid");
}
byte[] prefix = readAt(channel, offset, Integer.BYTES + Long.BYTES);
ByteBuffer framing = ByteBuffer.wrap(prefix).order(ByteOrder.BIG_ENDIAN);
if (framing.getInt() != RECORD_MAGIC) {
throw corrupt("Revocation checkpoint record magic is invalid");
}
long payloadLength = framing.getLong();
if (payloadLength < ZERO_REVISION) {
throw corrupt("Revocation checkpoint record length is invalid");
}
long payloadStart = addExact(
offset, prefix.length, "Revocation checkpoint record boundary overflow");
long digestOffset = addExact(
payloadStart, payloadLength, "Revocation checkpoint record boundary overflow");
long nextOffset = addExact(
digestOffset, DIGEST_BYTES, "Revocation checkpoint record boundary overflow");
if (nextOffset > trailerOffset) {
throw corrupt("Revocation checkpoint record exceeds its bounded region");
}
MessageDigest digest = sha256();
digest.update(RECORD_DOMAIN);
digest.update(prefix);
RecordReader reader = new RecordReader(
channel, payloadStart, payloadLength, digest, input);
CurrentStateEntry entry;
try {
entry = reader.readEntry();
} catch (IllegalArgumentException | DateTimeException malformed) {
throw corrupt("Revocation checkpoint record is malformed", malformed);
}
if (reader.remaining() != ZERO_REVISION) {
throw corrupt("Revocation checkpoint record contains trailing data");
}
byte[] supplied = readAt(channel, digestOffset, DIGEST_BYTES);
if (!MessageDigest.isEqual(digest.digest(), supplied)) {
throw corrupt("Revocation checkpoint record commitment is invalid");
}
return new DecodedRecord(entry, nextOffset);
}
private static Trailer decodeTrailer(byte[] bytes) throws IOException {
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
long count = buffer.getLong();
int magic = buffer.getInt();
short version = buffer.getShort();
short flags = buffer.getShort();
byte[] commitment = new byte[DIGEST_BYTES];
buffer.get(commitment);
if (count < 0L || magic != TRAILER_MAGIC || version != FORMAT_VERSION
|| flags != RESERVED_FLAGS) {
throw corrupt("Revocation checkpoint trailer is invalid");
}
return new Trailer(count, commitment);
}
}
/** Reuses one bounded transfer buffer across a sequential record pass. */
/* default */ static final class SequentialDecoder {
private final ByteBuffer input = ByteBuffer.allocate(TRANSFER_BYTES);
/* default */ DecodedRecord read(
FileChannel channel, long recordOffset, long trailerOffset) throws IOException {
return DecoderOperations.decodeRecord(channel, recordOffset, trailerOffset, input);
}
}
private static byte[] encodeHeader(HeaderData header) throws IOException {
requireHeader(header);
ByteBuffer buffer = ByteBuffer.allocate(HEADER_BYTES).order(ByteOrder.BIG_ENDIAN);
buffer.putInt(HEADER_MAGIC);
buffer.putShort(FORMAT_VERSION);
buffer.putShort(RESERVED_FLAGS);
buffer.put(HEX.parseHex(header.storeId().value()));
buffer.putLong(header.coveredRevision());
if (header.finalRecordStart().isPresent()) {
buffer.put((byte) PRESENT);
buffer.put(new byte[7]);
buffer.putLong(header.finalRecordStart().getAsLong());
} else {
buffer.put((byte) ABSENT);
buffer.put(new byte[7]);
buffer.putLong(0L);
}
buffer.putLong(header.coveredBoundary());
buffer.put(HEX.parseHex(header.globalCommitment().value()));
buffer.putLong(header.entryCount());
MessageDigest digest = sha256();
digest.update(buffer.array(), 0, HEADER_FIELDS_BYTES);
buffer.put(digest.digest());
return buffer.array();
}
private static void requireHeader(HeaderData header) {
Objects.requireNonNull(header, "header");
if (header.coveredRevision() < 0L || header.entryCount() < 0L
|| header.coveredBoundary() < RevocationTransitionFrameCodec.PREAMBLE_BYTES) {
throw new IllegalArgumentException("Invalid revocation checkpoint header values");
}
if (header.coveredRevision() == 0L != header.finalRecordStart().isEmpty()) {
throw new IllegalArgumentException("Checkpoint revision and final record disagree");
}
if (header.coveredRevision() == ZERO_REVISION
&& (header.entryCount() != ZERO_REVISION
|| header.coveredBoundary() != RevocationTransitionFrameCodec.PREAMBLE_BYTES
|| !header.globalCommitment().equals(
RevocationTransitionFrameCodec.initialCommitment(header.storeId())))) {
throw new IllegalArgumentException("Checkpoint genesis framing is invalid");
}
if (header.finalRecordStart().isPresent()
&& (header.finalRecordStart().getAsLong() < RevocationTransitionFrameCodec.PREAMBLE_BYTES
|| header.finalRecordStart().getAsLong() >= header.coveredBoundary())) {
throw new IllegalArgumentException("Invalid covered revocation frame start");
}
}
private static byte[] digestPrefix(FileChannel channel, long length) throws IOException {
MessageDigest digest = sha256();
byte[] bytes = new byte[TRANSFER_BYTES];
long offset = 0L;
while (offset < length) {
int request = (int) Math.min(bytes.length, length - offset);
ByteBuffer target = ByteBuffer.wrap(bytes, 0, request);
readFullyAt(channel, target, offset);
digest.update(bytes, 0, request);
offset += request;
}
return digest.digest();
}
private static byte[] readAt(FileChannel channel, long offset, int length) throws IOException {
if (offset < 0L || length < 0) {
throw corrupt("Revocation checkpoint read boundary is invalid");
}
byte[] bytes = new byte[length];
readFullyAt(channel, ByteBuffer.wrap(bytes), offset);
return bytes;
}
private static void readFullyAt(FileChannel channel, ByteBuffer target, long offset) throws IOException {
long position = offset;
while (target.hasRemaining()) {
int read = channel.read(target, position);
if (read < 0) {
throw corrupt("Revocation checkpoint is truncated");
}
if (read == 0) {
throw new IOException("Revocation checkpoint channel made no read progress");
}
position += read;
}
}
private static void writeFully(FileChannel channel, ByteBuffer source) throws IOException {
while (source.hasRemaining()) {
if (channel.write(source) == 0) {
throw new IOException("Revocation checkpoint channel made no write progress");
}
}
}
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 MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException("SHA-256 is unavailable", unavailable);
}
}
private static boolean allZero(byte[] bytes) {
for (byte value : bytes) {
if (value != 0) {
return false;
}
}
return true;
}
/* default */ static byte[] strictUtf8(String value) {
Objects.requireNonNull(value, "value");
try {
ByteBuffer encoded = StandardCharsets.UTF_8.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(value));
if (encoded.remaining() > MAX_COMPONENT_BYTES) {
throw new IllegalArgumentException("Checkpoint component exceeds its technical limit");
}
byte[] bytes = new byte[encoded.remaining()];
encoded.get(bytes);
return bytes;
} catch (CharacterCodingException malformed) {
throw new IllegalArgumentException("Checkpoint text is not valid Unicode", malformed);
}
}
private static String decodeUtf8(byte[] bytes) {
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException malformed) {
throw new IllegalArgumentException("Checkpoint contains malformed UTF-8", malformed);
}
}
/* default */ static int compareUnsigned(byte[] first, byte[] second) {
int common = Math.min(first.length, second.length);
for (int index = 0; index < common; index++) {
int difference = Byte.toUnsignedInt(first[index]) - Byte.toUnsignedInt(second[index]);
if (difference != 0) {
return difference;
}
}
return first.length - second.length;
}
private static IOException corrupt(String message) {
return new IOException(message);
}
private static IOException corrupt(String message, Throwable cause) {
return new IOException(message, cause);
}
/** Streaming encoder retaining only one current-state entry. */
/* default */ static final class Encoder {
private final FileChannel channel;
private final HeaderData header;
private final DigestOutputStream wholeDigest = digestStream();
private long count;
private boolean finished;
private Encoder(FileChannel channel, HeaderData header) throws IOException {
this.channel = Objects.requireNonNull(channel, "channel");
this.header = Objects.requireNonNull(header, "header");
channel.position(0L);
channel.truncate(0L);
writeCommitted(encodeHeader(header));
}
/* default */ void write(CurrentStateEntry entry) throws IOException {
if (finished) {
throw new IllegalStateException("Checkpoint encoder is finished");
}
EntryPlan plan = EntryPlan.create(entry);
ByteBuffer framing = ByteBuffer.allocate(Integer.BYTES + Long.BYTES)
.order(ByteOrder.BIG_ENDIAN);
framing.putInt(RECORD_MAGIC).putLong(plan.length()).flip();
byte[] prefix = new byte[framing.remaining()];
framing.get(prefix);
try (DigestOutputStream recordDigest = digestStream()) {
recordDigest.write(RECORD_DOMAIN);
recordDigest.write(prefix);
writeCommitted(prefix);
FieldWriter writer = new FieldWriter(channel, wholeDigest, recordDigest);
plan.write(writer, entry);
writeCommitted(recordDigest.getMessageDigest().digest());
}
try {
count = Math.addExact(count, 1L);
} catch (ArithmeticException overflow) {
throw new IOException("Checkpoint entry count is exhausted", overflow);
}
}
/* default */ String finish() throws IOException {
if (finished) {
throw new IllegalStateException("Checkpoint encoder is finished");
}
if (count != header.entryCount()) {
throw new IOException("Checkpoint source count does not match its declaration");
}
ByteBuffer fields = ByteBuffer.allocate(TRAILER_FIELDS_BYTES).order(ByteOrder.BIG_ENDIAN);
fields.putLong(count).putInt(TRAILER_MAGIC)
.putShort(FORMAT_VERSION).putShort(RESERVED_FLAGS);
writeCommitted(fields.array());
byte[] generation = wholeDigest.getMessageDigest().digest();
writeFully(channel, ByteBuffer.wrap(generation));
finished = true;
return HEX.formatHex(generation);
}
private void writeCommitted(byte[] bytes) throws IOException {
wholeDigest.write(bytes);
writeFully(channel, ByteBuffer.wrap(bytes));
}
}
/** Authenticated checkpoint identity and covered authoritative log position. */
/* default */ record HeaderData(
MetadataStoreId storeId,
long coveredRevision,
OptionalLong finalRecordStart,
long coveredBoundary,
RevocationTransitionFrameCodec.Commitment globalCommitment,
long entryCount) {
HeaderData {
Objects.requireNonNull(storeId, "storeId");
Objects.requireNonNull(finalRecordStart, "finalRecordStart");
Objects.requireNonNull(globalCommitment, "globalCommitment");
}
}
/** One finite latest state and its authoritative frame locator. */
/* default */ record CurrentStateEntry(
PkiId credentialId,
long globalRevision,
long credentialRevision,
RevocationTransitionFrameCodec.Commitment transitionCommitment,
RevocationTransition transition,
long frameStart,
long frameEnd) {
CurrentStateEntry {
Objects.requireNonNull(credentialId, "credentialId");
Objects.requireNonNull(transitionCommitment, "transitionCommitment");
Objects.requireNonNull(transition, "transition");
if (globalRevision <= 0L || credentialRevision <= 0L
|| transition.revision() != credentialRevision
|| frameStart < RevocationTransitionFrameCodec.PREAMBLE_BYTES
|| frameEnd <= frameStart) {
throw new IllegalArgumentException("Invalid revocation checkpoint entry");
}
}
}
/** Fully validated checkpoint framing used by immutable readers. */
/* default */ record ValidatedFile(HeaderData header, String generationId, long trailerOffset) {
ValidatedFile {
Objects.requireNonNull(header, "header");
Objects.requireNonNull(generationId, "generationId");
}
}
/** One decoded record plus the next sequential offset. */
/* default */ record DecodedRecord(CurrentStateEntry entry, long nextOffset) {
DecodedRecord {
Objects.requireNonNull(entry, "entry");
}
}
private record Trailer(long entryCount, byte[] commitment) {
private Trailer {
commitment = commitment.clone();
}
}
/** Canonical per-entry plan, bounded by one current state. */
private record EntryPlan(long length, List<PlannedAttribute> attributes) {
private static EntryPlan create(CurrentStateEntry entry) throws IOException {
Objects.requireNonNull(entry, "entry");
List<PlannedAttribute> attributes = capture(entry.transition().attributes());
attributes.sort((first, second) -> compareUnsigned(first.id(), second.id()));
long length = Short.BYTES * 2L;
length = addComponent(length, strictUtf8(entry.credentialId().value()).length);
length = add(length, Long.BYTES * 2L + COMMITMENT_BYTES);
length = add(length, 1L + 1L + Short.BYTES + Long.BYTES + Integer.BYTES);
length = add(length, Integer.BYTES);
for (PlannedAttribute attribute : attributes) {
length = addComponent(length, attribute.id().length);
length = add(length, Integer.BYTES);
for (AttributeValue value : attribute.values()) {
length = add(length, valueLength(value));
}
}
length = add(length, Long.BYTES * 2L);
return new EntryPlan(length, List.copyOf(attributes));
}
private static List<PlannedAttribute> capture(AttributeSet set) {
List<PlannedAttribute> result = new ArrayList<>();
for (AttributeId id : set.ids()) {
result.add(new PlannedAttribute(strictUtf8(id.value()), List.copyOf(set.getAll(id))));
}
return result;
}
private void write(FieldWriter writer, CurrentStateEntry entry) throws IOException {
writer.writeShort(FORMAT_VERSION);
writer.writeShort(RESERVED_FLAGS);
writer.writeComponent(strictUtf8(entry.credentialId().value()));
writer.writeLong(entry.globalRevision());
writer.writeLong(entry.credentialRevision());
writer.writeBytes(HEX.parseHex(entry.transitionCommitment().value()));
writer.writeByte(encodeState(entry.transition().state()));
writer.writeByte(encodeReason(entry.transition().permanentReason()));
writer.writeShort(RESERVED_FLAGS);
writer.writeLong(entry.transition().time().getEpochSecond());
writer.writeInt(entry.transition().time().getNano());
writer.writeInt(attributes.size());
for (PlannedAttribute attribute : attributes) {
writer.writeComponent(attribute.id());
writer.writeInt(attribute.values().size());
for (AttributeValue value : attribute.values()) {
writeValue(writer, value);
}
}
writer.writeLong(entry.frameStart());
writer.writeLong(entry.frameEnd());
}
private static long valueLength(AttributeValue value) throws IOException {
Objects.requireNonNull(value, "attribute value");
return switch (value) {
case AttributeValue.StringValue string ->
addComponent(1L, strictUtf8(string.value()).length);
case AttributeValue.BooleanValue ignored -> 2L;
case AttributeValue.IntegerValue ignored -> 1L + Long.BYTES;
case AttributeValue.InstantValue ignored -> 1L + Long.BYTES + Integer.BYTES;
case AttributeValue.BytesValue bytes -> addComponent(1L, bytes.value().length);
};
}
private static void writeValue(FieldWriter writer, AttributeValue value) throws IOException {
switch (value) {
case AttributeValue.StringValue string -> {
writer.writeByte(1);
writer.writeComponent(strictUtf8(string.value()));
}
case AttributeValue.BooleanValue bool -> {
writer.writeByte(2);
writer.writeByte(bool.value() ? 1 : 0);
}
case AttributeValue.IntegerValue integer -> {
writer.writeByte(3);
writer.writeLong(integer.value());
}
case AttributeValue.InstantValue instant -> {
writer.writeByte(4);
writer.writeLong(instant.value().getEpochSecond());
writer.writeInt(instant.value().getNano());
}
case AttributeValue.BytesValue bytes -> {
writer.writeByte(5);
writer.writeComponent(bytes.value());
}
}
}
private static long addComponent(long current, int length) throws IOException {
requireComponent(length);
return add(current, Integer.BYTES + (long) length);
}
private static long add(long first, long second) throws IOException {
return addExact(first, second, "Revocation checkpoint record is too large");
}
}
private record PlannedAttribute(byte[] id, List<AttributeValue> values) {
private PlannedAttribute {
id = id.clone();
values = List.copyOf(values);
}
}
/** Writes primitive fields to the channel and both incremental commitments. */
private static final class FieldWriter {
private final FileChannel channel;
private final DigestOutputStream whole;
private final DigestOutputStream record;
private final ByteBuffer primitive = ByteBuffer.allocate(Long.BYTES).order(ByteOrder.BIG_ENDIAN);
private FieldWriter(
FileChannel channel, DigestOutputStream whole, DigestOutputStream record) {
this.channel = channel;
this.whole = whole;
this.record = record;
}
private void writeByte(int value) throws IOException {
primitive(1, buffer -> buffer.put((byte) value));
}
private void writeShort(int value) throws IOException {
primitive(Short.BYTES, buffer -> buffer.putShort((short) value));
}
private void writeInt(int value) throws IOException {
primitive(Integer.BYTES, buffer -> buffer.putInt(value));
}
private void writeLong(long value) throws IOException {
primitive(Long.BYTES, buffer -> buffer.putLong(value));
}
private void writeComponent(byte[] bytes) throws IOException {
requireComponent(bytes.length);
writeInt(bytes.length);
writeBytes(bytes);
}
private void primitive(int length, PrimitiveEncoder encoder) throws IOException {
primitive.clear();
encoder.encode(primitive);
primitive.flip();
primitive.limit(length);
byte[] bytes = new byte[length];
primitive.get(bytes);
writeBytes(bytes);
}
private void writeBytes(byte[] bytes) throws IOException {
whole.write(bytes);
record.write(bytes);
writeFully(channel, ByteBuffer.wrap(bytes));
}
}
/** Bounded positional reader for one authenticated record payload. */
private static final class RecordReader {
private final FileChannel channel;
private final DigestOutputStream digest;
private final ByteBuffer input;
private long sourcePosition;
private long remaining;
private RecordReader(
FileChannel channel,
long position,
long remaining,
MessageDigest digest,
ByteBuffer input) {
this.channel = channel;
sourcePosition = position;
this.remaining = remaining;
this.digest = new DigestOutputStream(OutputStream.nullOutputStream(), digest);
this.input = input;
this.input.clear();
this.input.limit(0);
}
private long remaining() {
return remaining;
}
private CurrentStateEntry readEntry() throws IOException {
if (readShort() != FORMAT_VERSION || readShort() != RESERVED_FLAGS) {
throw new IllegalArgumentException("Unsupported checkpoint record version");
}
PkiId credential = new PkiId(readString());
long globalRevision = readLong();
long localRevision = readLong();
RevocationTransitionFrameCodec.Commitment commitment =
new RevocationTransitionFrameCodec.Commitment(HEX.formatHex(readBytes(COMMITMENT_BYTES)));
RevocationState state = decodeState(readUnsignedByte());
Optional<RevocationReason> reason = decodeReason(readUnsignedByte());
if (readShort() != RESERVED_FLAGS) {
throw new IllegalArgumentException("Invalid checkpoint record reserved field");
}
Instant time = Instant.ofEpochSecond(readLong(), readInt());
AttributeSet attributes = readAttributes();
long start = readLong();
long end = readLong();
RevocationTransition transition =
new RevocationTransition(localRevision, state, time, reason, attributes);
return new CurrentStateEntry(
credential, globalRevision, localRevision, commitment, transition, start, end);
}
private AttributeSet readAttributes() throws IOException {
int count = readCount();
List<SimpleAttributeSet.Entry> entries = new ArrayList<>(count);
byte[] previous = null;
for (int index = 0; index < count; index++) {
byte[] encodedId = readComponent();
if (previous != null && compareUnsigned(previous, encodedId) >= 0) {
throw new IllegalArgumentException("Checkpoint attributes are not canonical");
}
entries.add(readAttributeEntry(encodedId));
previous = encodedId;
}
return new SimpleAttributeSet(entries);
}
private SimpleAttributeSet.Entry readAttributeEntry(byte[] encodedId) throws IOException {
AttributeId id = new AttributeId(decodeUtf8(encodedId));
int valueCount = readCount();
return new SimpleAttributeSet.Entry(id, readAttributeValues(valueCount));
}
private List<AttributeValue> readAttributeValues(int valueCount) throws IOException {
List<AttributeValue> values = new ArrayList<>(valueCount);
for (int valueIndex = 0; valueIndex < valueCount; valueIndex++) {
values.add(readValue());
}
return values;
}
private AttributeValue readValue() throws IOException {
return switch (readUnsignedByte()) {
case 1 -> new AttributeValue.StringValue(readString());
case 2 -> new AttributeValue.BooleanValue(readBoolean());
case 3 -> new AttributeValue.IntegerValue(readLong());
case 4 -> new AttributeValue.InstantValue(Instant.ofEpochSecond(readLong(), readInt()));
case 5 -> new AttributeValue.BytesValue(readComponent());
default -> throw new IllegalArgumentException("Unknown checkpoint attribute value code");
};
}
private String readString() throws IOException {
return decodeUtf8(readComponent());
}
private byte[] readComponent() throws IOException {
int length = readInt();
requireComponent(length);
return readBytes(length);
}
private int readCount() throws IOException {
int count = readInt();
requireComponent(count);
return count;
}
private boolean readBoolean() throws IOException {
int code = readUnsignedByte();
if (code != 0 && code != 1) {
throw new IllegalArgumentException("Invalid checkpoint boolean code");
}
return code == 1;
}
private int readUnsignedByte() throws IOException {
return Byte.toUnsignedInt(readByte());
}
private short readShort() throws IOException {
return (short) (readUnsignedByte() << Byte.SIZE | readUnsignedByte());
}
private int readInt() throws IOException {
return readUnsignedByte() << 24
| readUnsignedByte() << 16
| readUnsignedByte() << 8
| readUnsignedByte();
}
private long readLong() throws IOException {
return (long) readUnsignedByte() << 56
| (long) readUnsignedByte() << 48
| (long) readUnsignedByte() << 40
| (long) readUnsignedByte() << 32
| (long) readUnsignedByte() << 24
| (long) readUnsignedByte() << 16
| (long) readUnsignedByte() << 8
| readUnsignedByte();
}
private byte[] readBytes(int length) throws IOException {
if (length < 0 || length > remaining) {
throw new IllegalArgumentException("Checkpoint field exceeds its bounded record");
}
byte[] bytes = new byte[length];
int offset = 0;
while (offset < length) {
requireInput();
int count = Math.min(length - offset, input.remaining());
input.get(bytes, offset, count);
digest.write(bytes, offset, count);
offset += count;
remaining -= count;
}
return bytes;
}
private byte readByte() throws IOException {
if (remaining == ZERO_REVISION) {
throw new IllegalArgumentException("Checkpoint field exceeds its bounded record");
}
requireInput();
byte value = input.get();
digest.write(value);
remaining--;
return value;
}
private void requireInput() throws IOException {
if (input.hasRemaining()) {
return;
}
input.clear();
input.limit((int) Math.min(input.capacity(), remaining));
int read = channel.read(input, sourcePosition);
if (read < 0) {
throw corrupt("Revocation checkpoint is truncated");
}
if (read == 0) {
throw new IOException("Revocation checkpoint channel made no read progress");
}
sourcePosition += read;
input.flip();
}
}
private static void requireComponent(int value) {
if (value < 0 || value > MAX_COMPONENT_BYTES) {
throw new IllegalArgumentException("Checkpoint component exceeds its technical limit");
}
}
private static DigestOutputStream digestStream() {
return new DigestOutputStream(OutputStream.nullOutputStream(), sha256());
}
private static int encodeState(RevocationState state) {
return ValueCodes.encodeState(state);
}
private static RevocationState decodeState(int code) {
return ValueCodes.decodeState(code);
}
private static int encodeReason(Optional<RevocationReason> reason) {
return ValueCodes.encodeReason(reason);
}
private static Optional<RevocationReason> decodeReason(int code) {
return ValueCodes.decodeReason(code);
}
/** Stable numeric codes are isolated from the surrounding framing logic. */
private static final class ValueCodes {
private static int encodeState(RevocationState state) {
return switch (state) {
case CLEAR -> 1;
case HELD -> 2;
case PERMANENTLY_REVOKED -> 3;
};
}
private static RevocationState decodeState(int code) {
return switch (code) {
case 1 -> RevocationState.CLEAR;
case 2 -> RevocationState.HELD;
case 3 -> RevocationState.PERMANENTLY_REVOKED;
default -> throw new IllegalArgumentException(
"Unknown checkpoint revocation state code");
};
}
private static int encodeReason(Optional<RevocationReason> reason) {
if (reason.isEmpty()) {
return 0;
}
return switch (reason.orElseThrow()) {
case UNSPECIFIED -> 1;
case KEY_COMPROMISE -> 2;
case CA_COMPROMISE -> 3;
case AFFILIATION_CHANGED -> 4;
case SUPERSEDED -> 5;
case CESSATION_OF_OPERATION -> 6;
case CERTIFICATE_HOLD -> 7;
case REMOVE_FROM_CRL -> 8;
case PRIVILEGE_WITHDRAWN -> 9;
case AA_COMPROMISE -> 10;
};
}
private static Optional<RevocationReason> decodeReason(int code) {
return switch (code) {
case 0 -> Optional.empty();
case 1 -> Optional.of(RevocationReason.UNSPECIFIED);
case 2 -> Optional.of(RevocationReason.KEY_COMPROMISE);
case 3 -> Optional.of(RevocationReason.CA_COMPROMISE);
case 4 -> Optional.of(RevocationReason.AFFILIATION_CHANGED);
case 5 -> Optional.of(RevocationReason.SUPERSEDED);
case 6 -> Optional.of(RevocationReason.CESSATION_OF_OPERATION);
case 7 -> Optional.of(RevocationReason.CERTIFICATE_HOLD);
case 8 -> Optional.of(RevocationReason.REMOVE_FROM_CRL);
case 9 -> Optional.of(RevocationReason.PRIVILEGE_WITHDRAWN);
case 10 -> Optional.of(RevocationReason.AA_COMPROMISE);
default -> throw new IllegalArgumentException(
"Unknown checkpoint revocation reason code");
};
}
}
/** Encodes one fixed-width primitive into a reusable buffer. */
@FunctionalInterface
private interface PrimitiveEncoder {
/** Writes one primitive value. */
void encode(ByteBuffer buffer);
}
}

View File

@@ -0,0 +1,569 @@
/*******************************************************************************
* 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.io.InterruptedIOException;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.io.CancellationSignal;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.store.MetadataStoreId;
final class FilesystemRevocationCheckpointTest {
private static final MetadataStoreId STORE_ID =
new MetadataStoreId("00112233445566778899aabbccddeeff");
private static final MetadataStoreId FOREIGN_ID =
new MetadataStoreId("ffeeddccbbaa99887766554433221100");
private static final PkiId FIRST = new PkiId("credential:a");
private static final PkiId SECOND = new PkiId("credential:b");
@TempDir
Path temporaryDirectory;
@Test
void publicationDiscoveryAndOrderedCursorsPreserveAllCurrentStates() throws Exception {
System.out.print("publicationDiscoveryAndOrderedCursorsPreserveAllCurrentStates ");
try (Fixture fixture = fixture("publish")) {
RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L));
RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L));
RevocationTransitionFrameCodec.CompleteRecord clear = fixture.log.append(FIRST, clear(2L, 3L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
CountingSource source = source(List.of(entry(clear), entry(second)));
FilesystemRevocationCheckpoint.PublishedGeneration published =
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, recovery, coverage(clear), source);
assertTrue(published.path().getFileName().toString().matches("g-3-[0-9a-f]{64}\\.chk"));
assertEquals(1, source.closeCount.get());
try (FilesystemRevocationCheckpoint checkpoint =
FilesystemRevocationCheckpoint.discover(
fixture.checkpoints, fixture.logPath, recovery).orElseThrow()) {
assertEquals(3L, checkpoint.coveredRevision());
assertEquals(2L, checkpoint.entryCount());
List<PkiId> all = collect(checkpoint.allCurrentStates());
List<PkiId> revoked = collect(checkpoint.currentRevokedStates());
assertEquals(List.of(FIRST, SECOND), all);
assertEquals(List.of(SECOND), revoked);
}
assertTrue(first.recordEnd() < clear.recordEnd());
}
System.out.println("...ok");
}
@Test
void revisionZeroAndStaleHistoricalCoverageBindWithoutPrefixReplay() throws Exception {
System.out.print("revisionZeroAndStaleHistoricalCoverageBindWithoutPrefixReplay ");
try (Fixture fixture = fixture("stale")) {
FilesystemRevocationLog.RecoveryResult empty = fixture.log.scan();
FilesystemRevocationCheckpoint.PublishedGeneration zero =
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, empty,
genesisCoverage(), source(List.of()));
assertEquals(0L, zero.coveredRevision());
RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L));
FilesystemRevocationLog.RecoveryResult firstRecovery = fixture.log.scan();
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, firstRecovery,
coverage(first), source(List.of(entry(first))));
RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
try (FilesystemRevocationCheckpoint stale = FilesystemRevocationCheckpoint.discover(
fixture.checkpoints, fixture.logPath, recovery).orElseThrow()) {
assertEquals(1L, stale.coveredRevision());
assertEquals(List.of(FIRST), collect(stale.allCurrentStates()));
}
assertEquals(2L, second.data().globalRevision());
}
System.out.println("...ok");
}
@Test
void bindingRejectsForeignIdentityWrongFrameBoundaryCommitmentAndEntry() throws Exception {
System.out.print("bindingRejectsForeignIdentityWrongFrameBoundaryCommitmentAndEntry ");
try (Fixture fixture = fixture("binding")) {
RevocationTransitionFrameCodec.CompleteRecord record = fixture.log.append(FIRST, held(1L, 1L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
List<FilesystemRevocationCheckpoint.Coverage> invalid = List.of(
new FilesystemRevocationCheckpoint.Coverage(
FOREIGN_ID, 1L, OptionalLong.of(record.recordOffset()),
record.recordEnd(), record.commitment()),
new FilesystemRevocationCheckpoint.Coverage(
STORE_ID, 1L, OptionalLong.of(record.recordOffset() + 1L),
record.recordEnd(), record.commitment()),
new FilesystemRevocationCheckpoint.Coverage(
STORE_ID, 1L, OptionalLong.of(record.recordOffset()),
record.recordEnd() + 1L, record.commitment()),
new FilesystemRevocationCheckpoint.Coverage(
STORE_ID, 1L, OptionalLong.of(record.recordOffset()),
record.recordEnd(), commitment(9)));
for (int index = 0; index < invalid.size(); index++) {
final int caseIndex = index;
Path directory = fixture.root.resolve("invalid-" + caseIndex);
assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish(
directory, fixture.logPath, recovery, invalid.get(caseIndex),
source(List.of(entry(record)))));
}
RevocationCheckpointCodec.CurrentStateEntry wrong =
new RevocationCheckpointCodec.CurrentStateEntry(
SECOND, record.data().globalRevision(), 1L, record.commitment(),
held(1L, 1L), record.recordOffset(), record.recordEnd());
assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish(
fixture.root.resolve("wrong-entry"), fixture.logPath, recovery,
coverage(record), source(List.of(wrong))));
RevocationCheckpointCodec.CurrentStateEntry wrongEnd =
new RevocationCheckpointCodec.CurrentStateEntry(
FIRST, record.data().globalRevision(), 1L, record.commitment(),
held(1L, 1L), record.recordOffset(), record.recordEnd() + 1L);
assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish(
fixture.root.resolve("wrong-end"), fixture.logPath, recovery,
coverage(record), source(List.of(wrongEnd))));
}
System.out.println("...ok");
}
@Test
void sortedSourceCountAndLifecycleAreStrict() throws Exception {
System.out.print("sortedSourceCountAndLifecycleAreStrict ");
try (Fixture fixture = fixture("source")) {
RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L));
RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
List<List<RevocationCheckpointCodec.CurrentStateEntry>> invalid = List.of(
List.of(entry(second), entry(first)),
List.of(entry(first), entry(first)));
for (int index = 0; index < invalid.size(); index++) {
final int caseIndex = index;
assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish(
fixture.root.resolve("order-" + caseIndex), fixture.logPath, recovery,
coverage(second), source(invalid.get(caseIndex))));
}
CountingSource tooFew = new CountingSource(2L, List.of(entry(first)));
assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish(
fixture.root.resolve("few"), fixture.logPath, recovery,
coverage(second), tooFew));
assertEquals(1, tooFew.closeCount.get());
CountingSource tooMany = new CountingSource(1L, List.of(entry(first), entry(second)));
assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish(
fixture.root.resolve("many"), fixture.logPath, recovery,
coverage(second), tooMany));
assertEquals(1, tooMany.closeCount.get());
}
System.out.println("...ok");
}
@Test
void publicationFaultsNeverExposePartialGenerationAndDirectoryForceIsAdvisory() throws Exception {
System.out.print("publicationFaultsNeverExposePartialGenerationAndDirectoryForceIsAdvisory ");
try (Fixture fixture = fixture("faults")) {
RevocationTransitionFrameCodec.CompleteRecord record = fixture.log.append(FIRST, held(1L, 1L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
for (FilesystemRevocationCheckpoint.FaultPoint point : List.of(
FilesystemRevocationCheckpoint.FaultPoint.WRITE,
FilesystemRevocationCheckpoint.FaultPoint.FILE_FORCE,
FilesystemRevocationCheckpoint.FaultPoint.ATOMIC_MOVE)) {
Path directory = fixture.root.resolve("fault-" + point);
assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish(
directory, fixture.logPath, recovery, coverage(record),
source(List.of(entry(record))), operations(), candidate -> {
if (candidate == point) {
throw new IOException("injected");
}
}));
assertFalse(hasFinalGeneration(directory));
assertFalse(hasBuildingFile(directory));
}
Path advisory = fixture.root.resolve("directory-warning");
FilesystemRevocationCheckpoint.PublishedGeneration published =
FilesystemRevocationCheckpoint.publish(
advisory, fixture.logPath, recovery, coverage(record),
source(List.of(entry(record))), operations(), point -> {
if (point == FilesystemRevocationCheckpoint.FaultPoint.DIRECTORY_FORCE) {
throw new IOException("injected");
}
});
assertTrue(Files.exists(published.path()));
}
System.out.println("...ok");
}
@Test
void idempotentPublicationReusesIdenticalGenerationAndRejectsFilenameMismatch() throws Exception {
System.out.print("idempotentPublicationReusesIdenticalGenerationAndRejectsFilenameMismatch ");
try (Fixture fixture = fixture("idempotent")) {
RevocationTransitionFrameCodec.CompleteRecord record = fixture.log.append(FIRST, held(1L, 1L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
FilesystemRevocationCheckpoint.PublishedGeneration first =
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, recovery,
coverage(record), source(List.of(entry(record))));
FilesystemRevocationCheckpoint.PublishedGeneration second =
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, recovery,
coverage(record), source(List.of(entry(record))));
assertEquals(first.path(), second.path());
assertFalse(second.newlyPublished());
Path mismatched = fixture.checkpoints.resolve("g-1-" + "0".repeat(64) + ".chk");
Files.copy(first.path(), mismatched);
assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.open(
mismatched, fixture.logPath, recovery));
Files.writeString(fixture.checkpoints.resolve(".building-ignored"), "partial");
Files.writeString(fixture.checkpoints.resolve("unrelated"), "ignored");
assertTrue(FilesystemRevocationCheckpoint.discover(
fixture.checkpoints, fixture.logPath, recovery).isPresent());
}
System.out.println("...ok");
}
@Test
void incompleteCurrentGenerationIsRejectedAndOlderGenerationRemainsUsable() throws Exception {
System.out.print("incompleteCurrentGenerationIsRejectedAndOlderGenerationRemainsUsable ");
try (Fixture fixture = fixture("ambiguity")) {
RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L));
FilesystemRevocationLog.RecoveryResult firstRecovery = fixture.log.scan();
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, firstRecovery,
coverage(first), source(List.of(entry(first))));
RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, recovery,
coverage(second), source(List.of(entry(first), entry(second))));
assertThrows(IOException.class, () -> FilesystemRevocationCheckpoint.publish(
fixture.root.resolve("incomplete"), fixture.logPath, recovery,
coverage(second), source(List.of(entry(first)))));
try (FilesystemRevocationCheckpoint selected = FilesystemRevocationCheckpoint.discover(
fixture.checkpoints, fixture.logPath, recovery).orElseThrow()) {
assertEquals(2L, selected.coveredRevision());
}
}
System.out.println("...ok");
}
@Test
void ambiguousHighestRevisionFallsBackToOlderUnambiguousGeneration() throws Exception {
System.out.print("ambiguousHighestRevisionFallsBackToOlderUnambiguousGeneration ");
try (Fixture fixture = fixture("ambiguous-fallback")) {
FilesystemRevocationLog.RecoveryResult empty = fixture.log.scan();
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, empty,
genesisCoverage(), source(List.of()));
RevocationTransitionFrameCodec.CompleteRecord record =
fixture.log.append(FIRST, held(1L, 1L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, recovery,
coverage(record), source(List.of(entry(record))));
writeAlternateGeneration(fixture.checkpoints, coverage(record));
try (FilesystemRevocationCheckpoint selected =
FilesystemRevocationCheckpoint.discover(
fixture.checkpoints, fixture.logPath, recovery).orElseThrow()) {
assertEquals(0L, selected.coveredRevision());
}
}
System.out.println("...ok");
}
@Test
void cursorCancellationCloseAndOwnerCloseAreStrict() throws Exception {
System.out.print("cursorCancellationCloseAndOwnerCloseAreStrict ");
try (Fixture fixture = fixture("cursor")) {
fixture.log.append(FIRST, held(1L, 1L));
RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, clear(2L, 2L));
RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 3L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
FilesystemRevocationCheckpoint.PublishedGeneration generation =
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, recovery,
coverage(second), source(List.of(entry(first), entry(second))));
FilesystemRevocationCheckpoint checkpoint = FilesystemRevocationCheckpoint.open(
generation.path(), fixture.logPath, recovery);
FilesystemRevocationCheckpoint.Cursor cursor = checkpoint.currentRevokedStates();
AtomicInteger checks = new AtomicInteger();
CancellationSignal cancellation = () -> checks.incrementAndGet() == 2;
assertThrows(InterruptedIOException.class, () -> cursor.advance(cancellation));
assertEquals(2, checks.get());
assertThrows(IllegalStateException.class, cursor::current);
FilesystemRevocationCheckpoint.Cursor exhausted = checkpoint.allCurrentStates();
assertTrue(exhausted.advance(CancellationSignal.NONE));
assertTrue(exhausted.advance(CancellationSignal.NONE));
assertFalse(exhausted.advance(CancellationSignal.NONE));
assertThrows(InterruptedIOException.class, () -> exhausted.advance(() -> true));
exhausted.close();
cursor.close();
cursor.close();
assertThrows(IllegalStateException.class,
() -> cursor.advance(CancellationSignal.NONE));
FilesystemRevocationCheckpoint.Cursor child = checkpoint.allCurrentStates();
checkpoint.close();
checkpoint.close();
assertThrows(IllegalStateException.class,
() -> child.advance(CancellationSignal.NONE));
}
System.out.println("...ok");
}
@Test
void openReaderSurvivesPosixFilenameRetirement() throws Exception {
System.out.print("openReaderSurvivesPosixFilenameRetirement ");
try (Fixture fixture = fixture("retirement")) {
RevocationTransitionFrameCodec.CompleteRecord first = fixture.log.append(FIRST, held(1L, 1L));
FilesystemRevocationLog.RecoveryResult firstRecovery = fixture.log.scan();
FilesystemRevocationCheckpoint.PublishedGeneration old =
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, firstRecovery,
coverage(first), source(List.of(entry(first))));
FilesystemRevocationCheckpoint openOld = FilesystemRevocationCheckpoint.open(
old.path(), fixture.logPath, firstRecovery);
RevocationTransitionFrameCodec.CompleteRecord second = fixture.log.append(SECOND, held(1L, 2L));
FilesystemRevocationLog.RecoveryResult recovery = fixture.log.scan();
FilesystemRevocationCheckpoint.PublishedGeneration current =
FilesystemRevocationCheckpoint.publish(
fixture.checkpoints, fixture.logPath, recovery,
coverage(second), source(List.of(entry(first), entry(second))));
assertEquals(1L, FilesystemRevocationCheckpoint.retireObsolete(
fixture.checkpoints, current));
assertFalse(Files.exists(old.path()));
assertEquals(List.of(FIRST), collect(openOld.allCurrentStates()));
openOld.close();
assertTrue(Files.exists(current.path()));
}
System.out.println("...ok");
}
private Fixture fixture(String name) throws Exception {
Path root = temporaryDirectory.resolve(name);
Path logPath = new FsPaths(root).revocationTransitionLog();
Files.createDirectories(logPath.getParent());
FilesystemRevocationLog log = FilesystemRevocationLog.create(
logPath, STORE_ID, credential -> { });
return new Fixture(root, logPath, new FsPaths(root).revocationCheckpointDirectory(), log);
}
private static FilesystemRevocationCheckpoint.Coverage coverage(
RevocationTransitionFrameCodec.CompleteRecord record) {
return new FilesystemRevocationCheckpoint.Coverage(
STORE_ID, record.data().globalRevision(), OptionalLong.of(record.recordOffset()),
record.recordEnd(), record.commitment());
}
private static FilesystemRevocationCheckpoint.Coverage genesisCoverage() {
return new FilesystemRevocationCheckpoint.Coverage(
STORE_ID, 0L, OptionalLong.empty(), RevocationTransitionFrameCodec.PREAMBLE_BYTES,
RevocationTransitionFrameCodec.initialCommitment(STORE_ID));
}
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 CountingSource source(
List<RevocationCheckpointCodec.CurrentStateEntry> entries) {
return new CountingSource(entries.size(), entries);
}
private static List<PkiId> collect(FilesystemRevocationCheckpoint.Cursor cursor) throws Exception {
List<PkiId> values = new ArrayList<>();
try (cursor) {
while (cursor.advance(CancellationSignal.NONE)) {
values.add(cursor.current().credentialId());
}
}
return values;
}
private static boolean hasFinalGeneration(Path directory) throws IOException {
if (!Files.isDirectory(directory)) {
return false;
}
try (java.util.stream.Stream<Path> paths = Files.list(directory)) {
return paths.anyMatch(path -> path.getFileName().toString().endsWith(".chk"));
}
}
private static void writeAlternateGeneration(
Path directory, FilesystemRevocationCheckpoint.Coverage coverage) throws IOException {
Path building = directory.resolve(".building-test-alternate");
RevocationCheckpointCodec codec = new RevocationCheckpointCodec();
RevocationCheckpointCodec.ValidatedFile validated;
try (FileChannel channel = FileChannel.open(building,
StandardOpenOption.CREATE_NEW, StandardOpenOption.READ,
StandardOpenOption.WRITE)) {
RevocationCheckpointCodec.HeaderData header =
new RevocationCheckpointCodec.HeaderData(
coverage.storeId(), coverage.coveredRevision(),
coverage.finalRecordStart(), coverage.coveredBoundary(),
coverage.globalCommitment(), 0L);
codec.encoder(channel, header).finish();
validated = codec.validate(channel);
}
Path target = directory.resolve(
"g-" + coverage.coveredRevision() + "-" + validated.generationId() + ".chk");
Files.move(building, target, StandardCopyOption.ATOMIC_MOVE);
}
private static boolean hasBuildingFile(Path directory) throws IOException {
if (!Files.isDirectory(directory)) {
return false;
}
try (java.util.stream.Stream<Path> paths = Files.list(directory)) {
return paths.anyMatch(path -> path.getFileName().toString().startsWith(".building-"));
}
}
private static FilesystemRevocationCheckpoint.PublicationOperations operations() {
return new FilesystemRevocationCheckpoint.PublicationOperations() {
@Override
public void atomicMove(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException unsupported) {
throw unsupported;
}
}
@Override
public void forceDirectory(Path directory) {
// File-content durability and atomic-move ordering are the behavior under test.
}
};
}
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 RevocationTransitionFrameCodec.Commitment commitment(int seed) {
return new RevocationTransitionFrameCodec.Commitment(
String.format("%064x", Integer.toUnsignedLong(seed)));
}
private static final class CountingSource implements FilesystemRevocationCheckpoint.SortedSource {
private final long declared;
private final List<RevocationCheckpointCodec.CurrentStateEntry> entries;
private final AtomicInteger closeCount = new AtomicInteger();
private CountingSource(
long declared, List<RevocationCheckpointCodec.CurrentStateEntry> entries) {
this.declared = declared;
this.entries = List.copyOf(entries);
}
@Override
public long entryCount() {
return declared;
}
@Override
public FilesystemRevocationCheckpoint.SourceCursor openCursor() {
return new FilesystemRevocationCheckpoint.SourceCursor() {
private int index = -1;
private boolean closed;
@Override
public boolean advance() {
if (closed) {
throw new IllegalStateException("source cursor is closed");
}
index++;
return index < entries.size();
}
@Override
public RevocationCheckpointCodec.CurrentStateEntry current() {
if (closed || index < 0 || index >= entries.size()) {
throw new IllegalStateException("source cursor has no current entry");
}
return entries.get(index);
}
@Override
public void close() {
closed = true;
}
};
}
@Override
public void close() {
closeCount.incrementAndGet();
}
}
private record Fixture(
Path root,
Path logPath,
Path checkpoints,
FilesystemRevocationLog log) implements AutoCloseable {
@Override
public void close() throws IOException {
log.close();
}
}
}

View File

@@ -0,0 +1,274 @@
/*******************************************************************************
* 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.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.DateTimeException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.OptionalLong;
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.pki.spi.store.MetadataStoreId;
final class RevocationCheckpointCodecTest {
private static final MetadataStoreId STORE_ID =
new MetadataStoreId("00112233445566778899aabbccddeeff");
private static final RevocationTransitionFrameCodec.Commitment GENESIS =
RevocationTransitionFrameCodec.initialCommitment(STORE_ID);
@TempDir
Path temporaryDirectory;
@Test
void revisionZeroCheckpointHasStrictEmptyFraming() throws Exception {
System.out.print("revisionZeroCheckpointHasStrictEmptyFraming ");
Path first = encode("zero-a.chk", zeroHeader(), List.of());
Path second = encode("zero-b.chk", zeroHeader(), List.of());
assertArrayEquals(Files.readAllBytes(first), Files.readAllBytes(second));
try (FileChannel channel = FileChannel.open(first, StandardOpenOption.READ)) {
RevocationCheckpointCodec.ValidatedFile validated =
new RevocationCheckpointCodec().validate(channel);
assertEquals(0L, validated.header().coveredRevision());
assertTrue(validated.header().finalRecordStart().isEmpty());
assertEquals(0L, validated.header().entryCount());
}
List<RevocationCheckpointCodec.HeaderData> invalid = List.of(
new RevocationCheckpointCodec.HeaderData(
STORE_ID, 0L, OptionalLong.empty(),
RevocationTransitionFrameCodec.PREAMBLE_BYTES, GENESIS, 1L),
new RevocationCheckpointCodec.HeaderData(
STORE_ID, 0L, OptionalLong.empty(),
RevocationTransitionFrameCodec.PREAMBLE_BYTES + 1L, GENESIS, 0L),
new RevocationCheckpointCodec.HeaderData(
STORE_ID, 0L, OptionalLong.empty(),
RevocationTransitionFrameCodec.PREAMBLE_BYTES, commitment(7), 0L));
for (int index = 0; index < invalid.size(); index++) {
Path path = temporaryDirectory.resolve("invalid-genesis-" + index + ".chk");
try (FileChannel channel = FileChannel.open(path,
StandardOpenOption.CREATE_NEW, StandardOpenOption.READ,
StandardOpenOption.WRITE)) {
RevocationCheckpointCodec.HeaderData header = invalid.get(index);
assertThrows(IllegalArgumentException.class,
() -> new RevocationCheckpointCodec().encoder(channel, header));
}
}
System.out.println("...ok");
}
@Test
void canonicalRecordsRoundTripEveryStateReasonAndAttributeShape() throws Exception {
System.out.print("canonicalRecordsRoundTripEveryStateReasonAndAttributeShape ");
List<RevocationCheckpointCodec.CurrentStateEntry> entries = List.of(
entry("credential:a", 1L, RevocationState.CLEAR, Optional.empty(), 128L),
entry("credential:b", 2L, RevocationState.HELD, Optional.empty(), 512L),
entry("credential:c", 3L, RevocationState.PERMANENTLY_REVOKED,
Optional.of(RevocationReason.KEY_COMPROMISE), 896L));
Path file = encode("round-trip.chk", header(3L, entries.size()), entries);
RevocationCheckpointCodec codec = new RevocationCheckpointCodec();
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
RevocationCheckpointCodec.ValidatedFile validated = codec.validate(channel);
long offset = RevocationCheckpointCodec.HEADER_BYTES;
for (RevocationCheckpointCodec.CurrentStateEntry expected : entries) {
RevocationCheckpointCodec.DecodedRecord decoded =
codec.readRecord(channel, offset, validated.trailerOffset());
assertEquals(expected.credentialId(), decoded.entry().credentialId());
assertEquals(expected.globalRevision(), decoded.entry().globalRevision());
assertTrue(RevocationTransitionFrameCodec.transitionsEqual(
expected.transition(), decoded.entry().transition()));
offset = decoded.nextOffset();
}
}
System.out.println("...ok");
}
@Test
void truncatedHeaderRecordAndTrailerFailClosed() throws Exception {
System.out.print("truncatedHeaderRecordAndTrailerFailClosed ");
Path complete = encode("complete.chk", header(1L, 1L),
List.of(entry("credential:a", 1L, RevocationState.HELD, Optional.empty(), 128L)));
byte[] bytes = Files.readAllBytes(complete);
int[] cuts = { 1, RevocationCheckpointCodec.HEADER_BYTES - 1,
RevocationCheckpointCodec.HEADER_BYTES + 4, bytes.length - 1 };
for (int cut : cuts) {
Path truncated = temporaryDirectory.resolve("truncated-" + cut + ".chk");
Files.write(truncated, java.util.Arrays.copyOf(bytes, cut));
try (FileChannel channel = FileChannel.open(truncated, StandardOpenOption.READ)) {
assertThrows(IOException.class, () -> new RevocationCheckpointCodec().validate(channel));
}
}
System.out.println("...ok");
}
@Test
void headerRecordAndWholeFileIntegrityFailuresAreDistinctlyRejected() throws Exception {
System.out.print("headerRecordAndWholeFileIntegrityFailuresAreDistinctlyRejected ");
Path complete = encode("integrity.chk", header(1L, 1L),
List.of(entry("credential:a", 1L, RevocationState.HELD, Optional.empty(), 128L)));
byte[] bytes = Files.readAllBytes(complete);
int[] positions = { 5, RevocationCheckpointCodec.HEADER_BYTES + 20, bytes.length - 1 };
for (int index = 0; index < positions.length; index++) {
byte[] corrupt = bytes.clone();
corrupt[positions[index]] ^= 0x01;
Path path = temporaryDirectory.resolve("corrupt-" + index + ".chk");
Files.write(path, corrupt);
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
assertThrows(IOException.class, () -> new RevocationCheckpointCodec().validate(channel));
}
}
System.out.println("...ok");
}
@Test
void invalidCountsOffsetsTimestampsAndSemanticCodesFailClosed() throws Exception {
System.out.print("invalidCountsOffsetsTimestampsAndSemanticCodesFailClosed ");
Path invalidHeader = temporaryDirectory.resolve("invalid-header.chk");
try (FileChannel channel = FileChannel.open(invalidHeader,
StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
assertThrows(IllegalArgumentException.class,
() -> new RevocationCheckpointCodec().encoder(channel,
new RevocationCheckpointCodec.HeaderData(
STORE_ID, -1L, OptionalLong.empty(), 56L, GENESIS, 0L)));
}
assertThrows(DateTimeException.class, () -> entry(
"credential:a", 1L, RevocationState.HELD, Optional.empty(), 127L)
.transition().time().with(java.time.temporal.ChronoField.NANO_OF_SECOND, -1L));
assertThrows(IllegalArgumentException.class, () -> new RevocationCheckpointCodec.CurrentStateEntry(
new PkiId("credential:a"), 1L, 1L, commitment(1),
transition(1L, RevocationState.HELD, Optional.empty()), 100L, 99L));
Path file = encode("codes.chk", header(1L, 1L),
List.of(entry("credential:a", 1L, RevocationState.HELD, Optional.empty(), 128L)));
byte[] bytes = Files.readAllBytes(file);
long payloadLength = ByteBuffer.wrap(bytes,
RevocationCheckpointCodec.HEADER_BYTES + Integer.BYTES, Long.BYTES)
.order(ByteOrder.BIG_ENDIAN).getLong();
assertTrue(payloadLength > 0L);
System.out.println("...ok");
}
@Test
void encoderRejectsDeclaredCountMismatchWithoutAggregateLimit() throws Exception {
System.out.print("encoderRejectsDeclaredCountMismatchWithoutAggregateLimit ");
Path file = temporaryDirectory.resolve("count.chk");
try (FileChannel channel = FileChannel.open(file,
StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
RevocationCheckpointCodec.Encoder encoder =
new RevocationCheckpointCodec().encoder(channel, header(1L, Long.MAX_VALUE));
encoder.write(entry("credential:a", 1L, RevocationState.HELD, Optional.empty(), 128L));
assertThrows(IOException.class, encoder::finish);
}
System.out.println("...ok");
}
private Path encode(
String name,
RevocationCheckpointCodec.HeaderData header,
List<RevocationCheckpointCodec.CurrentStateEntry> entries) throws Exception {
Path path = temporaryDirectory.resolve(name);
try (FileChannel channel = FileChannel.open(path,
StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
RevocationCheckpointCodec.Encoder encoder =
new RevocationCheckpointCodec().encoder(channel, header);
for (RevocationCheckpointCodec.CurrentStateEntry entry : entries) {
encoder.write(entry);
}
encoder.finish();
}
return path;
}
private static RevocationCheckpointCodec.HeaderData zeroHeader() {
return new RevocationCheckpointCodec.HeaderData(
STORE_ID, 0L, OptionalLong.empty(), RevocationTransitionFrameCodec.PREAMBLE_BYTES,
GENESIS, 0L);
}
private static RevocationCheckpointCodec.HeaderData header(long revision, long count) {
return new RevocationCheckpointCodec.HeaderData(
STORE_ID, revision, OptionalLong.of(128L), 1_000L,
commitment(99), count);
}
private static RevocationCheckpointCodec.CurrentStateEntry entry(
String id,
long globalRevision,
RevocationState state,
Optional<RevocationReason> reason,
long frameStart) {
return new RevocationCheckpointCodec.CurrentStateEntry(
new PkiId(id), globalRevision, 1L, commitment((int) globalRevision),
transition(1L, state, reason), frameStart, frameStart + 100L);
}
private static RevocationTransition transition(
long revision, RevocationState state, Optional<RevocationReason> reason) {
SimpleAttributeSet attributes = new SimpleAttributeSet(List.of(
new SimpleAttributeSet.Entry(new AttributeId("z.example"), List.of(
new AttributeValue.StringValue("text"),
new AttributeValue.BooleanValue(true),
new AttributeValue.IntegerValue(Long.MAX_VALUE),
new AttributeValue.InstantValue(Instant.ofEpochSecond(8L, 9)),
new AttributeValue.BytesValue(new byte[] { 0x01, 0x02 }))),
new SimpleAttributeSet.Entry(new AttributeId("a.example"), List.of())));
return new RevocationTransition(
revision, state, Instant.ofEpochSecond(123L, 456), reason, attributes);
}
private static RevocationTransitionFrameCodec.Commitment commitment(int seed) {
return new RevocationTransitionFrameCodec.Commitment(
String.format("%064x", Integer.toUnsignedLong(seed)));
}
}