From f27a656c9786ed813d9383e4b6682ac9c57e7de1 Mon Sep 17 00:00:00 2001
From: Leo Galambos
Date: Mon, 3 Aug 2026 00:33:07 +0200
Subject: [PATCH] refactor(pki): cut over revocation authority
Move production revocation writes, lookups, histories, CRL views and
snapshot handling to the global append-only transition-log architecture.
Keep indexes and checkpoints derived while removing all active runtime
dependence on per-credential complete journals.
---
.../zeroecho/pki/api/RevocationService.java | 39 +-
.../pki/api/revocation/RevocationRecord.java | 28 +
.../impl/core/DefaultRevocationService.java | 173 ++++-
.../impl/core/DefaultStatusObjectService.java | 22 +-
...ckedEffectiveCredentialStatusResolver.java | 12 +-
.../pki/impl/fs/FilesystemPkiStore.java | 317 ++++----
.../fs/FilesystemRevocationAuthority.java | 725 ++++++++++++++++++
.../pki/impl/fs/FilesystemRevocationLog.java | 150 +++-
.../fs/FilesystemTemporaryUniqueIndex.java | 34 +-
.../java/zeroecho/pki/impl/fs/FsPaths.java | 5 +
.../pki/impl/fs/FsSnapshotExporter.java | 44 +-
.../java/zeroecho/pki/spi/store/PkiStore.java | 29 +-
.../pki/spi/store/RevocationHistory.java | 60 ++
.../pki/spi/store/RevocationSnapshot.java | 45 +-
.../core/DefaultRevocationServiceTest.java | 88 ++-
.../DefaultStatusObjectServiceCrlTest.java | 68 +-
...EffectiveCredentialStatusResolverTest.java | 6 +-
.../fs/FilesystemPkiStoreOwnershipTest.java | 3 +-
.../pki/impl/fs/FilesystemPkiStoreTest.java | 202 ++++-
.../fs/FilesystemRevocationJournalTest.java | 57 +-
.../zeroecho/pki/testkit/PkiTestRuntime.java | 20 +-
21 files changed, 1794 insertions(+), 333 deletions(-)
create mode 100644 pki/src/main/java/zeroecho/pki/api/revocation/RevocationRecord.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java
create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/RevocationHistory.java
diff --git a/pki/src/main/java/zeroecho/pki/api/RevocationService.java b/pki/src/main/java/zeroecho/pki/api/RevocationService.java
index 9c89e06..543727b 100644
--- a/pki/src/main/java/zeroecho/pki/api/RevocationService.java
+++ b/pki/src/main/java/zeroecho/pki/api/RevocationService.java
@@ -33,15 +33,16 @@
******************************************************************************/
package zeroecho.pki.api;
-import java.util.List;
import java.util.Optional;
import zeroecho.pki.api.revocation.RevocationCommand;
-import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.revocation.RevocationQuery;
+import zeroecho.pki.spi.store.RevocationHistory;
+import zeroecho.pki.spi.store.RevocationSnapshot;
/**
- * Authoritative revocation transition and journal administration.
+ * Authoritative revocation transition, current-state, and history administration.
*/
public interface RevocationService {
@@ -49,39 +50,47 @@ public interface RevocationService {
* Places a credential on hold.
*
* @param command hold command
- * @return committed journal
+ * @return committed current state
*/
- RevocationJournal hold(RevocationCommand.Hold command);
+ RevocationRecord hold(RevocationCommand.Hold command);
/**
* Removes an existing hold.
*
* @param command unhold command
- * @return committed journal
+ * @return committed current state
*/
- RevocationJournal unhold(RevocationCommand.Unhold command);
+ RevocationRecord unhold(RevocationCommand.Unhold command);
/**
* Permanently revokes a credential.
*
* @param command permanent revocation command
- * @return committed journal
+ * @return committed current state
*/
- RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command);
+ RevocationRecord revokePermanently(RevocationCommand.RevokePermanently command);
/**
- * Retrieves the authoritative journal for one credential.
+ * Retrieves the validated current state for one credential.
*
* @param credentialId credential identifier
- * @return journal when present
+ * @return current state when present
*/
- Optional get(PkiId credentialId);
+ Optional get(PkiId credentialId);
/**
- * Searches authoritative journals by their latest transition.
+ * Opens the authoritative transition history for one credential.
+ *
+ * @param credentialId credential identifier
+ * @return closeable bounded-memory history cursor
+ */
+ RevocationHistory history(PkiId credentialId);
+
+ /**
+ * Searches a stable ordered population view by current transition.
*
* @param query administrative query
- * @return matching journals
+ * @return closeable stable ordered view whose cursor yields matching states
*/
- List search(RevocationQuery query);
+ RevocationSnapshot search(RevocationQuery query);
}
diff --git a/pki/src/main/java/zeroecho/pki/api/revocation/RevocationRecord.java b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationRecord.java
new file mode 100644
index 0000000..c1f588d
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/revocation/RevocationRecord.java
@@ -0,0 +1,28 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.pki.api.revocation;
+
+import java.util.Objects;
+
+import zeroecho.pki.api.PkiId;
+
+/**
+ * Immutable current revocation state for one credential.
+ *
+ * @param credentialId exact credential identity
+ * @param transition latest committed transition
+ */
+public record RevocationRecord(PkiId credentialId, RevocationTransition transition) {
+
+ /**
+ * Creates a current-state record.
+ *
+ * @throws NullPointerException if either value is {@code null}
+ */
+ public RevocationRecord {
+ Objects.requireNonNull(credentialId, "credentialId");
+ Objects.requireNonNull(transition, "transition");
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java
index ad2ad7d..e90a018 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java
@@ -33,13 +33,16 @@
******************************************************************************/
package zeroecho.pki.impl.core;
+import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.OptionalLong;
+import zeroecho.core.io.CancellationSignal;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.RevocationService;
@@ -47,11 +50,13 @@ import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.api.revocation.RevocationCommand;
-import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.revocation.RevocationQuery;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.store.PkiStore;
+import zeroecho.pki.spi.store.RevocationHistory;
+import zeroecho.pki.spi.store.RevocationSnapshot;
/**
* Store-backed authoritative revocation transition service.
@@ -63,7 +68,7 @@ public final class DefaultRevocationService implements RevocationService {
private static final List SAFE_STORE_CODES = List.of("REVOCATION_CREDENTIAL_NOT_FOUND",
"REVOCATION_TRANSITION_ILLEGAL", "REVOCATION_TERMINAL", "REVOCATION_TRANSITION_CONFLICT",
"REVOCATION_STATE_CORRUPT", "REVOCATION_PERSIST_FAILED", "REVOCATION_DURABILITY_UNCONFIRMED",
- "STORE_DURABILITY_UNCONFIRMED");
+ "REVOCATION_RECOVERY_REQUIRED", "STORE_DURABILITY_UNCONFIRMED");
private final PkiStore store;
private final Clock clock;
@@ -83,26 +88,26 @@ public final class DefaultRevocationService implements RevocationService {
}
@Override
- public RevocationJournal hold(RevocationCommand.Hold command) {
+ public RevocationRecord hold(RevocationCommand.Hold command) {
return transition(Objects.requireNonNull(command, "command"), "HOLD");
}
@Override
- public RevocationJournal unhold(RevocationCommand.Unhold command) {
+ public RevocationRecord unhold(RevocationCommand.Unhold command) {
return transition(Objects.requireNonNull(command, "command"), "UNHOLD");
}
@Override
- public RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command) {
+ public RevocationRecord revokePermanently(RevocationCommand.RevokePermanently command) {
return transition(Objects.requireNonNull(command, "command"), "REVOKE_PERMANENTLY");
}
@Override
@SuppressWarnings("PMD.AvoidCatchingGenericException")
- public Optional get(PkiId credentialId) {
+ public Optional get(PkiId credentialId) {
Objects.requireNonNull(credentialId, "credentialId");
try {
- return store.getRevocationJournal(credentialId);
+ return store.getRevocation(credentialId);
} catch (RuntimeException failure) {
throw sanitized(failure);
}
@@ -110,33 +115,34 @@ public final class DefaultRevocationService implements RevocationService {
@Override
@SuppressWarnings("PMD.AvoidCatchingGenericException")
- public List search(RevocationQuery query) {
+ public RevocationHistory history(PkiId credentialId) {
+ Objects.requireNonNull(credentialId, "credentialId");
+ try {
+ return store.openRevocationHistory(credentialId);
+ } catch (RuntimeException failure) {
+ throw sanitized(failure);
+ }
+ }
+
+ @Override
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ public RevocationSnapshot search(RevocationQuery query) {
Objects.requireNonNull(query, "query");
- try (zeroecho.pki.spi.store.RevocationSnapshot snapshot = store.openRevocationSnapshot();
- zeroecho.pki.spi.store.RevocationSnapshot.Cursor cursor = snapshot.openCursor()) {
- List matching = new java.util.ArrayList<>();
- while (cursor.next()) {
- RevocationJournal journal = cursor.current();
- if (matches(journal, query)) {
- matching.add(journal);
- }
- }
- return List.copyOf(matching);
- } catch (java.io.IOException failure) {
- throw new PkiException("Revocation snapshot failed: code=STORE_FAILED", failure);
+ try {
+ return new FilteredSnapshot(store.openRevocationSnapshot(), query);
} catch (RuntimeException failure) {
throw sanitized(failure);
}
}
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
- private RevocationJournal transition(RevocationCommand command, String operation) {
+ private RevocationRecord transition(RevocationCommand command, String operation) {
Instant time = null;
try {
time = Objects.requireNonNull(clock.instant(), "clock instant");
- RevocationJournal journal = store.transitionRevocation(command, time);
- auditSuccess(time, journal, operation);
- return journal;
+ RevocationRecord record = store.transitionRevocation(command, time);
+ auditSuccess(time, record, operation);
+ return record;
} catch (RuntimeException failure) {
PkiException sanitized = time == null
? new PkiException("Revocation operation failed: code=REVOCATION_TIME_UNAVAILABLE")
@@ -147,8 +153,8 @@ public final class DefaultRevocationService implements RevocationService {
}
}
- private boolean matches(RevocationJournal journal, RevocationQuery query) {
- RevocationTransition latest = journal.latest();
+ private boolean matches(RevocationRecord record, RevocationQuery query) {
+ RevocationTransition latest = record.transition();
if (query.reason().isPresent() && !latest.permanentReason().filter(query.reason().get()::equals).isPresent()) {
return false;
}
@@ -158,23 +164,23 @@ public final class DefaultRevocationService implements RevocationService {
if (query.revokedBefore().isPresent() && !latest.time().isBefore(query.revokedBefore().get())) {
return false;
}
- return query.issuerCaId().isEmpty() || store.getCredential(journal.credentialId())
+ return query.issuerCaId().isEmpty() || store.getCredential(record.credentialId())
.map(credential -> query.issuerCaId().get().equals(credential.issuerRef().caId())).orElse(false);
}
@SuppressWarnings("PMD.AvoidCatchingGenericException")
private Optional currentState(RevocationCommand command) {
try {
- return store.getRevocationJournal(command.credentialId()).map(RevocationJournal::latest)
+ return store.getRevocation(command.credentialId()).map(RevocationRecord::transition)
.map(transition -> transition.state().name());
} catch (RuntimeException ignored) {
return Optional.empty();
}
}
- private void auditSuccess(Instant time, RevocationJournal journal, String operation) {
- RevocationTransition latest = journal.latest();
- audit(time, journal.credentialId(), operation, Map.of("result", "COMMITTED", "state", latest.state().name(),
+ private void auditSuccess(Instant time, RevocationRecord record, String operation) {
+ RevocationTransition latest = record.transition();
+ audit(time, record.credentialId(), operation, Map.of("result", "COMMITTED", "state", latest.state().name(),
"revision", Long.toString(latest.revision())));
}
@@ -217,4 +223,109 @@ public final class DefaultRevocationService implements RevocationService {
int marker = message.indexOf("code=");
return marker < 0 ? "REVOCATION_STATE_UPDATE_FAILED" : message.substring(marker + 5);
}
+
+ /** Query-filtered ownership wrapper over one stable store snapshot. */
+ private final class FilteredSnapshot implements RevocationSnapshot {
+ private final RevocationSnapshot source;
+ private final RevocationQuery query;
+
+ private FilteredSnapshot(RevocationSnapshot source, RevocationQuery query) {
+ this.source = source;
+ this.query = query;
+ }
+
+ @Override
+ public String snapshotId() {
+ return source.snapshotId();
+ }
+
+ @Override
+ public long revision() {
+ return source.revision();
+ }
+
+ @Override
+ public long boundary() {
+ return source.boundary();
+ }
+
+ @Override
+ public String commitment() {
+ return source.commitment();
+ }
+
+ @Override
+ public OptionalLong count() {
+ return OptionalLong.empty();
+ }
+
+ @Override
+ @SuppressWarnings("PMD.AvoidCatchingGenericException")
+ public Cursor openCursor() throws IOException {
+ try {
+ return new FilteredCursor(source.openCursor(), query);
+ } catch (RuntimeException failure) {
+ throw sanitized(failure);
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ source.close();
+ }
+ }
+
+ /** Lazy query filter retaining only one current state. */
+ private final class FilteredCursor implements RevocationSnapshot.Cursor {
+ private final RevocationSnapshot.Cursor source;
+ private final RevocationQuery query;
+ private RevocationRecord current;
+ private long ordinal = -1L;
+
+ private FilteredCursor(RevocationSnapshot.Cursor source, RevocationQuery query) {
+ this.source = source;
+ this.query = query;
+ }
+
+ @Override
+ @SuppressWarnings({ "PMD.UnusedAssignment", "PMD.AvoidCatchingGenericException" })
+ public boolean next(CancellationSignal cancellation) throws IOException {
+ try {
+ current = null;
+ while (source.next(cancellation)) {
+ RevocationRecord candidate = source.current();
+ if (matches(candidate, query)) {
+ current = candidate;
+ ordinal = Math.addExact(ordinal, 1L);
+ return true;
+ }
+ }
+ return false;
+ } catch (RuntimeException failure) {
+ throw sanitized(failure);
+ }
+ }
+
+ @Override
+ public RevocationRecord current() {
+ if (current == null) {
+ throw new IllegalStateException("Revocation search cursor is not positioned");
+ }
+ return current;
+ }
+
+ @Override
+ public long ordinal() {
+ if (current == null) {
+ throw new IllegalStateException("Revocation search cursor is not positioned");
+ }
+ return ordinal;
+ }
+
+ @Override
+ public void close() throws IOException {
+ current = null;
+ source.close();
+ }
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java
index 9d01636..d2b335d 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java
@@ -58,7 +58,7 @@ import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
-import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
@@ -288,19 +288,19 @@ public final class DefaultStatusObjectService implements StatusObjectService {
}
private CrlEntrySource openCrlEntries(PkiId issuerCaId, Instant evaluationTime) {
- return new JournalCrlEntrySource(store.openRevocationSnapshot(), issuerCaId, evaluationTime);
+ return new CheckpointCrlEntrySource(store.openRevocationSnapshot(), issuerCaId, evaluationTime);
}
- private Optional collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal) {
- Objects.requireNonNull(journal, "journal");
- RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition");
+ private Optional collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationRecord record) {
+ Objects.requireNonNull(record, "record");
+ RevocationTransition latest = Objects.requireNonNull(record.transition(), "latest transition");
if (latest.time().isAfter(evaluationTime)) {
throw crlGenerationFailure();
}
if (latest.state() == RevocationState.CLEAR) {
return Optional.empty();
}
- Credential credential = store.getCredential(journal.credentialId())
+ Credential credential = store.getCredential(record.credentialId())
.orElseThrow(DefaultStatusObjectService::crlGenerationFailure);
if (!issuerCaId.equals(credential.issuerRef().caId())) {
return Optional.empty();
@@ -320,12 +320,12 @@ public final class DefaultStatusObjectService implements StatusObjectService {
}
/** Stable restartable view over one revocation-store snapshot. */
- private final class JournalCrlEntrySource implements CrlEntrySource {
+ private final class CheckpointCrlEntrySource implements CrlEntrySource {
private final RevocationSnapshot snapshot;
private final PkiId issuerCaId;
private final Instant evaluationTime;
- private JournalCrlEntrySource(RevocationSnapshot snapshot, PkiId issuerCaId, Instant evaluationTime) {
+ private CheckpointCrlEntrySource(RevocationSnapshot snapshot, PkiId issuerCaId, Instant evaluationTime) {
this.snapshot = snapshot;
this.issuerCaId = issuerCaId;
this.evaluationTime = evaluationTime;
@@ -333,7 +333,7 @@ public final class DefaultStatusObjectService implements StatusObjectService {
@Override
public Cursor openCursor() throws IOException {
- return new JournalCrlCursor(snapshot.openCursor(), issuerCaId, evaluationTime);
+ return new CheckpointCrlCursor(snapshot.openCursor(), issuerCaId, evaluationTime);
}
@Override
@@ -348,14 +348,14 @@ public final class DefaultStatusObjectService implements StatusObjectService {
}
/** Bounded cursor translating authoritative journals into CRL entries. */
- private final class JournalCrlCursor implements CrlEntrySource.Cursor {
+ private final class CheckpointCrlCursor implements CrlEntrySource.Cursor {
private final RevocationSnapshot.Cursor cursor;
private final PkiId issuerCaId;
private final Instant evaluationTime;
private CrlEntry current;
private long ordinal = -1L;
- private JournalCrlCursor(RevocationSnapshot.Cursor cursor, PkiId issuerCaId, Instant evaluationTime) {
+ private CheckpointCrlCursor(RevocationSnapshot.Cursor cursor, PkiId issuerCaId, Instant evaluationTime) {
this.cursor = cursor;
this.issuerCaId = issuerCaId;
this.evaluationTime = evaluationTime;
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java b/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java
index dc70ad8..27b73ee 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolver.java
@@ -44,7 +44,7 @@ import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
-import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.spi.store.PkiStore;
@@ -125,9 +125,9 @@ public final class StoreBackedEffectiveCredentialStatusResolver implements Effec
// removed from the stable redacted status-resolution exception.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
private RevocationTransition currentRevocation(Credential credential) {
- Optional current;
+ Optional current;
try {
- current = store.getRevocationJournal(credential.credentialId());
+ current = store.getRevocation(credential.credentialId());
} catch (RuntimeException exception) {
throw resolutionFailure();
}
@@ -137,13 +137,13 @@ public final class StoreBackedEffectiveCredentialStatusResolver implements Effec
if (current.isEmpty()) {
return null;
}
- RevocationJournal journal = current.get();
- if (!credential.credentialId().equals(journal.credentialId())) {
+ RevocationRecord record = current.get();
+ if (!credential.credentialId().equals(record.credentialId())) {
throw resolutionFailure();
}
RevocationTransition latest;
try {
- latest = journal.latest();
+ latest = record.transition();
} catch (RuntimeException exception) {
throw resolutionFailure();
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
index c7f03a6..fd8c705 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
@@ -63,7 +63,6 @@ import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
-import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -88,7 +87,7 @@ import zeroecho.pki.api.profile.ImportedCertificateProfileVersion;
import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand;
-import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
@@ -111,6 +110,7 @@ import zeroecho.pki.spi.store.StagedContentStore;
import zeroecho.pki.spi.store.SignWorkflowStore;
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
import zeroecho.pki.spi.store.RevocationSnapshot;
+import zeroecho.pki.spi.store.RevocationHistory;
/**
* Filesystem-based reference implementation of {@link PkiStore}.
@@ -133,9 +133,9 @@ import zeroecho.pki.spi.store.RevocationSnapshot;
* anomalous behavior for audit and incident analysis.
*
* Audit history for mutable entities: CA records and
- * profiles append history before updating {@code current.bin}. Revocation state
- * is instead one atomically replaced, internally ordered journal per
- * credential.
+ * profiles append history before updating {@code current.bin}. Revocation
+ * transitions append to one authenticated global log; current indexes and
+ * ordered checkpoints are derived and rebuildable.
*
* Deterministic behavior: filenames, ordering, and cleanup
* semantics are deterministic. Cleanup occurs only during writes
@@ -161,12 +161,14 @@ import zeroecho.pki.spi.store.RevocationSnapshot;
* are limited to object type, safe IDs, and file operation outcomes.
*
*/
-@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods" })
+@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods",
+ "PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl",
+ "PMD.PreserveStackTrace" })
public final class FilesystemPkiStore implements PkiStore, Closeable {
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
- /* package */ static final String CURRENT_STORE_VERSION = "v3";
+ /* package */ static final String CURRENT_STORE_VERSION = "v4";
private static final String SIGN_RECORD_NAMESPACE = "io.zeroecho.pki.signing-record";
private static final String SIGN_OWNER_NAMESPACE = "io.zeroecho.pki.signing-owner";
private static final String STATUS_RECORD_NAMESPACE = "io.zeroecho.pki.status-object-record";
@@ -195,6 +197,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private final FilesystemStagedContentStore stagedContent;
private final CredentialContentTransaction credentialContentTransactions;
private final PosixTransactionalMetadataStore metadataStore;
+ private final FilesystemRevocationAuthority revocations;
private final StoreOwnership ownership;
@@ -224,8 +227,13 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
// close();
// try-with-resources here would release process ownership at constructor
// return.
- @SuppressWarnings("PMD.CloseResource")
public FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock) {
+ this(root, options, clock, FilesystemRevocationAuthority.IndexUpdateFaultInjector.NONE);
+ }
+
+ @SuppressWarnings("PMD.CloseResource")
+ /* package */ FilesystemPkiStore(final Path root, final FsPkiStoreOptions options, final Clock clock,
+ final FilesystemRevocationAuthority.IndexUpdateFaultInjector indexUpdateFaults) {
this.options = Objects.requireNonNull(options, "options");
Objects.requireNonNull(root, "root");
this.clock = Objects.requireNonNull(clock, "clock");
@@ -247,7 +255,9 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
boolean ownershipTransferred = false;
PosixTransactionalMetadataStore openedMetadata = null;
+ FilesystemRevocationAuthority openedRevocations = null;
try {
+ boolean newStore = !Files.exists(this.paths.versionFile());
ensureVersionFile();
this.signingNamespace = ensureSigningNamespace();
this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(),
@@ -260,6 +270,21 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
this.historySeq = new AtomicLong(0L);
recoverStagedContent();
+ boolean snapshotRestore = requireSnapshotBoundary();
+ openedRevocations = FilesystemRevocationAuthority.open(
+ this.paths, new MetadataStoreId(this.signingNamespace), credentialId -> {
+ if (getCredential(credentialId).isEmpty()) {
+ throw new IOException("Revocation credential authority is missing");
+ }
+ }, newStore, indexUpdateFaults);
+ this.revocations = openedRevocations;
+ if (snapshotRestore) {
+ Files.delete(this.paths.revocationSnapshotBoundary());
+ try (FileChannel directory = FileChannel.open(
+ this.paths.root(), StandardOpenOption.READ)) {
+ directory.force(true);
+ }
+ }
LOG.log(Level.INFO, "running in {0}", root);
this.ownership = acquiredOwnership;
@@ -268,6 +293,13 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
throw new IllegalStateException("failed to open filesystem store at " + root, e);
} finally {
if (!ownershipTransferred) {
+ if (openedRevocations != null) {
+ try {
+ openedRevocations.close();
+ } catch (IOException closeFailure) {
+ LOG.log(Level.WARNING, "Revocation-store cleanup failed during initialization");
+ }
+ }
if (openedMetadata != null) {
try {
closeMetadataAfterFailedOpen(openedMetadata);
@@ -280,6 +312,45 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
}
+ private boolean requireSnapshotBoundary() throws IOException {
+ Path marker = paths.revocationSnapshotBoundary();
+ if (!Files.exists(marker, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ if (!Files.isRegularFile(marker, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException("Revocation snapshot boundary is invalid");
+ }
+ String encoded;
+ try (FileChannel channel = FileChannel.open(marker, StandardOpenOption.READ,
+ java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
+ long size = channel.size();
+ if (size < 1L || size > 19L) {
+ throw new IOException("Revocation snapshot boundary is invalid");
+ }
+ ByteBuffer buffer = ByteBuffer.allocate(19);
+ while (buffer.position() < size) {
+ int read = channel.read(buffer);
+ if (read <= 0) {
+ throw new IOException("Revocation snapshot boundary is invalid");
+ }
+ }
+ encoded = new String(buffer.array(), 0, (int) size, StandardCharsets.US_ASCII);
+ }
+ long expected;
+ try {
+ expected = Long.parseLong(encoded);
+ } catch (NumberFormatException failure) {
+ throw new IOException("Revocation snapshot boundary is invalid");
+ }
+ if (!Long.toString(expected).equals(encoded)
+ || expected < RevocationTransitionFrameCodec.PREAMBLE_BYTES
+ || !Files.isRegularFile(paths.revocationTransitionLog())
+ || Files.size(paths.revocationTransitionLog()) != expected) {
+ throw new IOException("Revocation snapshot authority boundary is invalid");
+ }
+ return true;
+ }
+
private static void closeMetadataAfterFailedOpen(PosixTransactionalMetadataStore openedMetadata)
throws IOException {
openedMetadata.close();
@@ -438,6 +509,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
return paths.root();
}
+ /* default */ FilesystemRevocationLog.RecoveryTarget copyRevocationPrefix(Path target)
+ throws IOException {
+ requireStoreUsable();
+ return revocations.copyAuthoritativePrefix(target);
+ }
+
/* default */ Set snapshotNonCredentialContentIds() {
requireStoreUsable();
Set contentIds = new HashSet<>();
@@ -531,7 +608,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override
// Store failures are intentionally replaced by one stable redacted boundary.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
- public RevocationJournal transitionRevocation(final RevocationCommand command, final Instant transitionTime) {
+ public RevocationRecord transitionRevocation(final RevocationCommand command, final Instant transitionTime) {
requireStoreUsable();
Objects.requireNonNull(command, "command");
Objects.requireNonNull(transitionTime, "transitionTime");
@@ -547,33 +624,49 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
if (credential.isEmpty()) {
throw new PkiException("Revocation target unavailable: code=REVOCATION_CREDENTIAL_NOT_FOUND");
}
- Optional current;
+ Optional current;
try {
- current = readRevocationJournal(credentialId);
- } catch (RuntimeException failure) {
+ current = revocations.current(credentialId);
+ } catch (RuntimeException | IOException failure) {
throw corruptRevocationState();
}
- RevocationJournal updated = appendRevocation(current, credentialId, command, transitionTime);
+ RevocationTransition transition = nextRevocationTransition(
+ current, command, transitionTime);
try {
- FsOperations.writeAtomicStrict(paths.revocationJournal(credentialId),
- FsCodec.encode(FsCodec.REVOCATION_JOURNAL, updated));
- } catch (FsOperations.DurabilityUncertainException failure) {
- durabilityUncertain.set(true);
- throw new PkiException("Revocation durability unconfirmed: code=REVOCATION_DURABILITY_UNCONFIRMED");
- } catch (IOException ex) {
+ RevocationTransitionFrameCodec.CompleteRecord committed =
+ revocations.append(credentialId, transition);
+ return new RevocationRecord(credentialId, committed.data().transition());
+ } catch (FilesystemRevocationLog.OutcomeUnknownException uncertain) {
+ throw new PkiException(
+ "Revocation append outcome requires recovery: code=REVOCATION_RECOVERY_REQUIRED");
+ } catch (IOException failure) {
throw new PkiException("Revocation persistence failed: code=REVOCATION_PERSIST_FAILED");
}
- return updated;
} finally {
releaseRevocationLock(credentialId, lock);
}
}
@Override
- public Optional getRevocationJournal(final PkiId credentialId) {
+ public Optional getRevocation(final PkiId credentialId) {
requireStoreUsable();
Objects.requireNonNull(credentialId, "credentialId");
- return readRevocationJournal(credentialId);
+ try {
+ return revocations.current(credentialId);
+ } catch (IOException failure) {
+ throw corruptRevocationState();
+ }
+ }
+
+ @Override
+ public RevocationHistory openRevocationHistory(final PkiId credentialId) {
+ requireStoreUsable();
+ Objects.requireNonNull(credentialId, "credentialId");
+ try {
+ return revocations.history(credentialId);
+ } catch (IOException failure) {
+ throw corruptRevocationState();
+ }
}
@Override
@@ -581,26 +674,9 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@SuppressWarnings("PMD.PreserveStackTrace")
public RevocationSnapshot openRevocationSnapshot() {
requireStoreUsable();
- Path root = this.paths.root().resolve("revocations").resolve("by-credential");
- String snapshotId = UUID.randomUUID().toString();
- Path snapshotRoot = paths.revocationSnapshotRoot().resolve(snapshotId);
- long count = 0L;
try {
- Files.createDirectories(snapshotRoot);
- if (Files.isDirectory(root)) {
- try (Stream journals = Files.walk(root)) {
- java.util.Iterator iterator = journals
- .filter(path -> Files.isRegularFile(path)
- && "journal.bin".equals(path.getFileName().toString()))
- .iterator();
- while (iterator.hasNext()) {
- Files.copy(iterator.next(), snapshotRoot.resolve(Long.toUnsignedString(count) + ".bin"));
- count = Math.addExact(count, 1L);
- }
- }
- }
- return new FilesystemRevocationSnapshot(snapshotId, snapshotRoot, count);
- } catch (IOException ex) {
+ return revocations.snapshot();
+ } catch (IOException failure) {
throw corruptRevocationState();
}
}
@@ -1292,10 +1368,19 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
public void close() throws IOException {
IOException failure = null;
try {
- metadataStore.close();
+ revocations.close();
} catch (IOException exception) {
failure = exception;
}
+ try {
+ metadataStore.close();
+ } catch (IOException exception) {
+ if (failure == null) {
+ failure = exception;
+ } else {
+ failure.addSuppressed(exception);
+ }
+ }
try {
ownership.close();
} catch (IOException exception) {
@@ -1475,153 +1560,25 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
}
- private Optional readRevocationJournal(PkiId credentialId) {
- Path path = paths.revocationJournal(credentialId);
- if (!Files.exists(path)) {
- return Optional.empty();
- }
- RevocationJournal journal = decodeRevocationJournal(path);
- validateRevocationJournal(credentialId, journal);
- return Optional.of(journal);
- }
-
- /** Stable file-backed revocation snapshot isolated from later store writes. */
- private static final class FilesystemRevocationSnapshot implements RevocationSnapshot {
- private final String snapshotId;
- private final Path root;
- private final long count;
- private final AtomicBoolean closed;
-
- private FilesystemRevocationSnapshot(String snapshotId, Path root, long count) {
- this.snapshotId = snapshotId;
- this.root = root;
- this.count = count;
- this.closed = new AtomicBoolean();
- }
-
- @Override
- public String snapshotId() {
- return snapshotId;
- }
-
- @Override
- public OptionalLong count() {
- return OptionalLong.of(count);
- }
-
- @Override
- public Cursor openCursor() {
- if (closed.get()) {
- throw new IllegalStateException("Revocation snapshot is closed");
- }
- return new FilesystemRevocationCursor(root, count);
- }
-
- @Override
- public void close() throws IOException {
- if (!closed.compareAndSet(false, true)) {
- return;
- }
- if (Files.isDirectory(root)) {
- try (Stream files = Files.list(root)) {
- java.util.Iterator iterator = files.iterator();
- while (iterator.hasNext()) {
- Files.deleteIfExists(iterator.next());
- }
- }
- Files.deleteIfExists(root);
- }
- }
- }
-
- /** Sequential bounded-memory cursor over one immutable snapshot directory. */
- private static final class FilesystemRevocationCursor implements RevocationSnapshot.Cursor {
- private final Path root;
- private final long count;
- private long nextOrdinal;
- private RevocationJournal current;
- private boolean closed;
-
- private FilesystemRevocationCursor(Path root, long count) {
- this.root = root;
- this.count = count;
- }
-
- @Override
- public boolean next() throws IOException {
- if (closed) {
- throw new IllegalStateException("Revocation cursor is closed");
- }
- if (nextOrdinal >= count) {
- current = null;
- return false;
- }
- current = decodeRevocationJournal(root.resolve(Long.toUnsignedString(nextOrdinal) + ".bin"));
- nextOrdinal = Math.addExact(nextOrdinal, 1L);
- return true;
- }
-
- @Override
- public RevocationJournal current() {
- if (current == null) {
- throw new IllegalStateException("Revocation cursor is not positioned");
- }
- return current;
- }
-
- @Override
- public long ordinal() {
- if (current == null) {
- throw new IllegalStateException("Revocation cursor is not positioned");
- }
- return nextOrdinal - 1L;
- }
-
- @Override
- public void close() {
- current = null;
- closed = true;
- }
- }
-
- // Codec and filesystem failures are external persisted-state boundaries; raw
- // causes are deliberately removed from the stable corruption exception.
- @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
- private static RevocationJournal decodeRevocationJournal(Path path) {
- try {
- return FsCodec.decode(FsCodec.REVOCATION_JOURNAL, FsOperations.readAll(path));
- } catch (IOException | RuntimeException ex) {
- throw corruptRevocationState();
- }
- }
-
- // Revision overflow is persisted-state corruption and is mapped to the stable,
- // redacted state-corruption boundary.
- @SuppressWarnings("PMD.PreserveStackTrace")
- private static RevocationJournal appendRevocation(Optional current, PkiId credentialId,
- RevocationCommand command, Instant transitionTime) {
- if (current.isPresent() && transitionTime.isBefore(current.get().latest().time())) {
+ private static RevocationTransition nextRevocationTransition(
+ Optional current, RevocationCommand command, Instant transitionTime) {
+ if (current.isPresent() && transitionTime.isBefore(current.orElseThrow().transition().time())) {
throw new PkiException("Revocation transition conflict: code=REVOCATION_TRANSITION_CONFLICT");
}
- RevocationState previous = current.map(RevocationJournal::latest).map(RevocationTransition::state).orElse(null);
+ RevocationState previous = current.map(RevocationRecord::transition)
+ .map(RevocationTransition::state).orElse(null);
RevocationState next = nextRevocationState(previous, command);
- long revision = current.map(RevocationJournal::latest).map(RevocationTransition::revision).map(value -> {
+ long revision = current.map(RevocationRecord::transition).map(RevocationTransition::revision).map(value -> {
try {
return Math.addExact(value, 1L);
} catch (ArithmeticException ex) {
throw corruptRevocationState();
}
}).orElse(1L);
- List transitions = new ArrayList<>(
- current.map(RevocationJournal::transitions).orElseGet(List::of));
Optional permanentReason = command instanceof RevocationCommand.RevokePermanently revoke
? Optional.of(revoke.reason())
: Optional.empty();
- transitions
- .add(new RevocationTransition(revision, next, transitionTime, permanentReason, command.attributes()));
- RevocationJournal journal = new RevocationJournal(credentialId, transitions);
- validateRevocationJournal(credentialId, journal);
- return journal;
+ return new RevocationTransition(revision, next, transitionTime, permanentReason, command.attributes());
}
private static RevocationState nextRevocationState(RevocationState previous, RevocationCommand command) {
@@ -1643,12 +1600,6 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
};
}
- private static void validateRevocationJournal(PkiId expectedId, RevocationJournal journal) {
- if (!expectedId.equals(journal.credentialId())) {
- throw corruptRevocationState();
- }
- }
-
private static RevocationState throwIllegalTransition() {
throw illegalRevocationTransition();
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java
new file mode 100644
index 0000000..ca362bb
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationAuthority.java
@@ -0,0 +1,725 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.pki.impl.fs;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import zeroecho.core.io.CancellationSignal;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.revocation.RevocationRecord;
+import zeroecho.pki.api.revocation.RevocationTransition;
+import zeroecho.pki.spi.store.MetadataStoreId;
+import zeroecho.pki.spi.store.RevocationHistory;
+import zeroecho.pki.spi.store.RevocationSnapshot;
+
+/** Store-owned coordination of the authoritative log and its derived state. */
+@SuppressWarnings({ "PMD.CloseResource", "PMD.UseTryWithResources", "PMD.AvoidSynchronizedAtMethodLevel",
+ "PMD.UnusedAssignment" })
+final class FilesystemRevocationAuthority implements AutoCloseable {
+
+ private static final FilesystemRevocationCurrentIndex.Configuration INDEX_CONFIGURATION =
+ new FilesystemRevocationCurrentIndex.Configuration(1024L, 1, 2);
+ private static final long CHECKPOINT_RUN_BYTES = 16L * 1024L * 1024L;
+ private static final int CHECKPOINT_MERGE_INPUTS = 32;
+ private static final int TRANSFER_BYTES = 16 * 1024;
+
+ private final FsPaths paths;
+ private final MetadataStoreId storeId;
+ private final FilesystemRevocationLog log;
+ private final IndexUpdateFaultInjector indexUpdateFaults;
+ private final ReentrantReadWriteLock lifecycle = new ReentrantReadWriteLock();
+ private final Set activeResources = new HashSet<>();
+ private FilesystemRevocationCurrentIndex index;
+ private boolean closed;
+
+ private FilesystemRevocationAuthority(FsPaths paths, MetadataStoreId storeId,
+ FilesystemRevocationLog log, FilesystemRevocationCurrentIndex index,
+ IndexUpdateFaultInjector indexUpdateFaults) {
+ this.paths = paths;
+ this.storeId = storeId;
+ this.log = log;
+ this.index = index;
+ this.indexUpdateFaults = indexUpdateFaults;
+ }
+
+ /* default */ static FilesystemRevocationAuthority open(FsPaths paths, MetadataStoreId storeId,
+ FilesystemRevocationLog.CredentialAuthority credentials, boolean create) throws IOException {
+ return open(paths, storeId, credentials, create, IndexUpdateFaultInjector.NONE);
+ }
+
+ /* default */ static FilesystemRevocationAuthority open(FsPaths paths, MetadataStoreId storeId,
+ FilesystemRevocationLog.CredentialAuthority credentials, boolean create,
+ IndexUpdateFaultInjector indexUpdateFaults) throws IOException {
+ Objects.requireNonNull(paths, "paths");
+ Objects.requireNonNull(storeId, "storeId");
+ Objects.requireNonNull(credentials, "credentials");
+ Objects.requireNonNull(indexUpdateFaults, "indexUpdateFaults");
+ Path logPath = paths.revocationTransitionLog();
+ Files.createDirectories(Objects.requireNonNull(logPath.getParent(), "revocation directory"));
+ FilesystemRevocationLog openedLog = create
+ ? FilesystemRevocationLog.createProduction(logPath, storeId, credentials)
+ : FilesystemRevocationLog.openProduction(logPath, storeId, credentials);
+ boolean complete = false;
+ try {
+ FilesystemRevocationLog.RecoveryTarget target = openedLog.recoveryTarget();
+ FilesystemRevocationCurrentIndex openedIndex = FilesystemRevocationCurrentIndex.recover(
+ paths.revocationCurrentIndex(), paths.revocationCheckpointDirectory(), logPath,
+ storeId, INDEX_CONFIGURATION, target);
+ FilesystemRevocationAuthority authority = new FilesystemRevocationAuthority(
+ paths, storeId, openedLog, openedIndex, indexUpdateFaults);
+ complete = true;
+ return authority;
+ } finally {
+ if (!complete) {
+ openedLog.close();
+ }
+ }
+ }
+
+ /* default */ Optional current(PkiId credentialId) throws IOException {
+ Objects.requireNonNull(credentialId, "credentialId");
+ lifecycle.writeLock().lock();
+ try {
+ requireOperational();
+ ensureIndex();
+ return index.lookup(credentialId).map(FilesystemRevocationAuthority::record);
+ } finally {
+ lifecycle.writeLock().unlock();
+ }
+ }
+
+ /* default */ RevocationTransitionFrameCodec.CompleteRecord append(
+ PkiId credentialId, RevocationTransition transition) throws IOException {
+ Objects.requireNonNull(credentialId, "credentialId");
+ Objects.requireNonNull(transition, "transition");
+ lifecycle.writeLock().lock();
+ try {
+ requireOperational();
+ ensureIndex();
+ RevocationTransitionFrameCodec.CompleteRecord previous =
+ index.lookup(credentialId).orElse(null);
+ RevocationTransitionFrameCodec.CompleteRecord committed =
+ log.append(credentialId, transition, previous);
+ try {
+ indexUpdateFaults.afterAppend();
+ index.update(committed);
+ } catch (IOException derivedFailure) {
+ invalidateIndex(derivedFailure);
+ }
+ return committed;
+ } finally {
+ lifecycle.writeLock().unlock();
+ }
+ }
+
+ /** Instance-scoped deterministic seam for derived-update failure tests. */
+ /* default */
+ @FunctionalInterface
+ interface IndexUpdateFaultInjector {
+ IndexUpdateFaultInjector NONE = () -> { };
+
+ /** Runs after authoritative append and before derived index update. */
+ void afterAppend() throws IOException;
+ }
+
+ /* default */ RevocationHistory history(PkiId credentialId) throws IOException {
+ Objects.requireNonNull(credentialId, "credentialId");
+ lifecycle.writeLock().lock();
+ try {
+ requireOperational();
+ HistoryCursor cursor = new HistoryCursor(this, credentialId, log.recoveryTarget());
+ activeResources.add(cursor);
+ return cursor;
+ } finally {
+ lifecycle.writeLock().unlock();
+ }
+ }
+
+ /* default */ RevocationSnapshot snapshot() throws IOException {
+ lifecycle.writeLock().lock();
+ try {
+ requireOperational();
+ FilesystemRevocationLog.RecoveryTarget head = log.recoveryTarget();
+ ensureIndex();
+ if (index.coveredGlobalRevision() != head.globalRevision()) {
+ recoverIndex(head);
+ }
+ Files.createDirectories(paths.revocationCheckpointDirectory());
+ Optional selected = FilesystemRevocationCheckpoint.discoverBelow(
+ paths.revocationCheckpointDirectory(), paths.revocationTransitionLog(), head,
+ head.globalRevision(), true, FilesystemRevocationCurrentIndex.FaultInjector.NONE);
+ FilesystemRevocationCheckpoint checkpoint = exact(selected, head).orElse(null);
+ if (checkpoint == null) {
+ FilesystemRevocationCheckpointBuilder.Configuration configuration =
+ new FilesystemRevocationCheckpointBuilder.Configuration(
+ CHECKPOINT_RUN_BYTES, CHECKPOINT_MERGE_INPUTS, TRANSFER_BYTES,
+ paths.revocationCheckpointWorkDirectory());
+ FilesystemRevocationCheckpointBuilder.build(
+ index, paths.revocationCheckpointDirectory(), configuration);
+ selected = FilesystemRevocationCheckpoint.discoverBelow(
+ paths.revocationCheckpointDirectory(), paths.revocationTransitionLog(), head,
+ head.globalRevision(), true, FilesystemRevocationCurrentIndex.FaultInjector.NONE);
+ checkpoint = exact(selected, head).orElseThrow(
+ () -> new IOException("Exact revocation checkpoint publication failed"));
+ }
+ StableView view = new StableView(this, checkpoint, head);
+ activeResources.add(view);
+ return view;
+ } finally {
+ lifecycle.writeLock().unlock();
+ }
+ }
+
+ /* default */ FilesystemRevocationLog.RecoveryTarget head() throws IOException {
+ lifecycle.readLock().lock();
+ try {
+ requireOperational();
+ return log.recoveryTarget();
+ } finally {
+ lifecycle.readLock().unlock();
+ }
+ }
+
+ /* default */ FilesystemRevocationLog.RecoveryTarget copyAuthoritativePrefix(Path target) throws IOException {
+ Objects.requireNonNull(target, "target");
+ lifecycle.readLock().lock();
+ try {
+ requireOperational();
+ FilesystemRevocationLog.RecoveryTarget captured = log.recoveryTarget();
+ Files.createDirectories(Objects.requireNonNull(target.getParent(), "snapshot prefix parent"));
+ try (FileChannel source = FileChannel.open(paths.revocationTransitionLog(), StandardOpenOption.READ);
+ FileChannel destination = FileChannel.open(target, StandardOpenOption.CREATE_NEW,
+ StandardOpenOption.WRITE)) {
+ ByteBuffer buffer = ByteBuffer.allocate(TRANSFER_BYTES);
+ long position = 0L;
+ while (position < captured.boundary()) {
+ buffer.clear();
+ buffer.limit((int) Math.min(buffer.capacity(), captured.boundary() - position));
+ int read = source.read(buffer, position);
+ if (read <= 0) {
+ throw new IOException("Revocation snapshot prefix made no read progress");
+ }
+ buffer.flip();
+ while (buffer.hasRemaining()) {
+ destination.write(buffer);
+ }
+ position = Math.addExact(position, read);
+ }
+ destination.force(true);
+ }
+ validateCopiedPrefix(target, captured);
+ return captured;
+ } finally {
+ lifecycle.readLock().unlock();
+ }
+ }
+
+ private static void validateCopiedPrefix(Path path,
+ FilesystemRevocationLog.RecoveryTarget expected) throws IOException {
+ try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
+ RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec();
+ MetadataStoreId store = codec.readPreamble(channel);
+ if (!expected.storeId().equals(store) || channel.size() != expected.boundary()) {
+ throw new IOException("Copied revocation prefix identity or boundary is invalid");
+ }
+ long offset = RevocationTransitionFrameCodec.PREAMBLE_BYTES;
+ long revision = 0L;
+ RevocationTransitionFrameCodec.Commitment commitment =
+ RevocationTransitionFrameCodec.initialCommitment(store);
+ while (offset < expected.boundary()) {
+ RevocationTransitionFrameCodec.ReadResult read = codec.read(channel, offset);
+ if (read.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
+ throw new IOException("Copied revocation prefix contains an invalid frame");
+ }
+ RevocationTransitionFrameCodec.CompleteRecord frame = read.record().orElseThrow();
+ if (frame.data().globalRevision() != Math.addExact(revision, 1L)
+ || !frame.data().previousGlobalCommitment().equals(commitment)
+ || frame.recordEnd() > expected.boundary()) {
+ throw new IOException("Copied revocation prefix continuity is invalid");
+ }
+ offset = frame.recordEnd();
+ revision = frame.data().globalRevision();
+ commitment = frame.commitment();
+ }
+ if (offset != expected.boundary() || revision != expected.globalRevision()
+ || !commitment.equals(expected.globalCommitment())) {
+ throw new IOException("Copied revocation prefix does not match its captured head");
+ }
+ }
+ }
+
+ private Optional exact(
+ Optional candidate,
+ FilesystemRevocationLog.RecoveryTarget head) throws IOException {
+ if (candidate.isEmpty()) {
+ return Optional.empty();
+ }
+ FilesystemRevocationCheckpoint checkpoint = candidate.orElseThrow();
+ if (checkpoint.storeId().equals(head.storeId())
+ && checkpoint.coveredRevision() == head.globalRevision()
+ && checkpoint.coveredBoundary() == head.boundary()
+ && checkpoint.coveredCommitment().equals(head.globalCommitment())) {
+ return Optional.of(checkpoint);
+ }
+ checkpoint.close();
+ return Optional.empty();
+ }
+
+ private void ensureIndex() throws IOException {
+ if (index == null) {
+ recoverIndex(log.recoveryTarget());
+ }
+ }
+
+ private void recoverIndex(FilesystemRevocationLog.RecoveryTarget target) throws IOException {
+ if (index != null) {
+ index.close();
+ }
+ index = FilesystemRevocationCurrentIndex.recover(
+ paths.revocationCurrentIndex(), paths.revocationCheckpointDirectory(),
+ paths.revocationTransitionLog(), storeId, INDEX_CONFIGURATION, target);
+ }
+
+ private void invalidateIndex(IOException failure) {
+ FilesystemRevocationCurrentIndex invalid = index;
+ index = null;
+ if (invalid != null) {
+ try {
+ invalid.close();
+ } catch (IOException closeFailure) {
+ failure.addSuppressed(closeFailure);
+ }
+ }
+ }
+
+ private void requireOperational() throws IOException {
+ if (closed) {
+ throw new IllegalStateException("Revocation authority is closed");
+ }
+ if (log.recoveryRequired()) {
+ throw new IOException("Revocation authority requires close and recovery");
+ }
+ }
+
+ private void release(OwnedResource resource) {
+ lifecycle.writeLock().lock();
+ try {
+ activeResources.remove(resource);
+ } finally {
+ lifecycle.writeLock().unlock();
+ }
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ Set resources;
+ lifecycle.writeLock().lock();
+ try {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ resources = Set.copyOf(activeResources);
+ activeResources.clear();
+ } finally {
+ lifecycle.writeLock().unlock();
+ }
+
+ IOException failure = null;
+ for (OwnedResource resource : resources) {
+ try {
+ resource.closeFromOwner();
+ } catch (IOException closeFailure) {
+ failure = appendFailure(failure, closeFailure);
+ }
+ }
+
+ lifecycle.writeLock().lock();
+ try {
+ if (index != null) {
+ try {
+ index.close();
+ } catch (IOException closeFailure) {
+ failure = appendFailure(failure, closeFailure);
+ }
+ index = null;
+ }
+ try {
+ log.close();
+ } catch (IOException closeFailure) {
+ failure = appendFailure(failure, closeFailure);
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ } finally {
+ lifecycle.writeLock().unlock();
+ }
+ }
+
+ private static RevocationRecord record(RevocationTransitionFrameCodec.CompleteRecord frame) {
+ return new RevocationRecord(frame.data().credentialId(), frame.data().transition());
+ }
+
+ private static IOException appendFailure(IOException first, IOException later) {
+ if (first == null) {
+ return later;
+ }
+ first.addSuppressed(later);
+ return first;
+ }
+
+ /** Resource registered for deterministic owner-driven closure. */
+ @SuppressWarnings("PMD.ImplicitFunctionalInterface")
+ private interface OwnedResource {
+ /** Closes the resource without reentering owner deregistration. */
+ void closeFromOwner() throws IOException;
+ }
+
+ /** Immutable checkpoint-backed current-state view. */
+ private static final class StableView implements RevocationSnapshot, OwnedResource {
+ private final FilesystemRevocationAuthority owner;
+ private final FilesystemRevocationCheckpoint checkpoint;
+ private final FilesystemRevocationLog.RecoveryTarget head;
+ private final Set cursors = new HashSet<>();
+ private boolean closed;
+
+ private StableView(FilesystemRevocationAuthority owner,
+ FilesystemRevocationCheckpoint checkpoint,
+ FilesystemRevocationLog.RecoveryTarget head) {
+ this.owner = owner;
+ this.checkpoint = checkpoint;
+ this.head = head;
+ }
+
+ @Override
+ public String snapshotId() {
+ return checkpoint.generationId();
+ }
+
+ @Override
+ public long revision() {
+ return head.globalRevision();
+ }
+
+ @Override
+ public long boundary() {
+ return head.boundary();
+ }
+
+ @Override
+ public String commitment() {
+ return head.globalCommitment().value();
+ }
+
+ @Override
+ public OptionalLong count() {
+ return OptionalLong.of(checkpoint.entryCount());
+ }
+
+ @Override
+ public synchronized Cursor openCursor() throws IOException {
+ requireOpen();
+ ViewCursor cursor = new ViewCursor(this, checkpoint.allCurrentStates(),
+ FileChannel.open(owner.paths.revocationTransitionLog(), StandardOpenOption.READ));
+ cursors.add(cursor);
+ return cursor;
+ }
+
+ @Override
+ public void close() throws IOException {
+ closeInternal(true);
+ }
+
+ @Override
+ public void closeFromOwner() throws IOException {
+ closeInternal(false);
+ }
+
+ private synchronized void closeInternal(boolean release) throws IOException {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ IOException failure = null;
+ for (ViewCursor cursor : Set.copyOf(cursors)) {
+ try {
+ cursor.closeFromOwner();
+ } catch (IOException closeFailure) {
+ failure = appendFailure(failure, closeFailure);
+ }
+ }
+ cursors.clear();
+ try {
+ checkpoint.close();
+ } catch (IOException closeFailure) {
+ failure = appendFailure(failure, closeFailure);
+ }
+ if (release) {
+ owner.release(this);
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ }
+
+ private synchronized void cursorClosed(ViewCursor cursor) {
+ cursors.remove(cursor);
+ }
+
+ private void requireOpen() {
+ if (closed) {
+ throw new IllegalStateException("Revocation snapshot is closed");
+ }
+ }
+ }
+
+ /** Ordered view cursor that revalidates every derived entry against its exact frame. */
+ private static final class ViewCursor implements RevocationSnapshot.Cursor {
+ private final StableView owner;
+ private final FilesystemRevocationCheckpoint.Cursor cursor;
+ private final FileChannel logChannel;
+ private RevocationRecord current;
+ private long ordinal = -1L;
+ private boolean closed;
+
+ private ViewCursor(StableView owner, FilesystemRevocationCheckpoint.Cursor cursor,
+ FileChannel logChannel) {
+ this.owner = owner;
+ this.cursor = cursor;
+ this.logChannel = logChannel;
+ }
+
+ @Override
+ public boolean next(CancellationSignal cancellation) throws IOException {
+ requireOpen();
+ current = null;
+ if (!cursor.advance(cancellation)) {
+ return false;
+ }
+ RevocationCheckpointCodec.CurrentStateEntry entry = cursor.current();
+ RevocationTransitionFrameCodec.ReadResult read = new RevocationTransitionFrameCodec()
+ .read(logChannel, entry.frameStart());
+ if (read.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
+ throw new IOException("Checkpoint current state has no authoritative frame");
+ }
+ RevocationTransitionFrameCodec.CompleteRecord frame = read.record().orElseThrow();
+ if (!frame.data().credentialId().equals(entry.credentialId())
+ || frame.data().globalRevision() != entry.globalRevision()
+ || frame.data().transition().revision() != entry.credentialRevision()
+ || frame.recordEnd() != entry.frameEnd()
+ || !frame.commitment().equals(entry.transitionCommitment())
+ || !RevocationTransitionFrameCodec.transitionsEqual(
+ frame.data().transition(), entry.transition())) {
+ throw new IOException("Checkpoint current state disagrees with authoritative frame");
+ }
+ ordinal = Math.addExact(ordinal, 1L);
+ current = record(frame);
+ return true;
+ }
+
+ @Override
+ public RevocationRecord current() {
+ if (current == null) {
+ throw new IllegalStateException("Revocation cursor is not positioned");
+ }
+ return current;
+ }
+
+ @Override
+ public long ordinal() {
+ if (current == null) {
+ throw new IllegalStateException("Revocation cursor is not positioned");
+ }
+ return ordinal;
+ }
+
+ @Override
+ public void close() throws IOException {
+ closeInternal(true);
+ }
+
+ private void closeFromOwner() throws IOException {
+ closeInternal(false);
+ }
+
+ private void closeInternal(boolean release) throws IOException {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ current = null;
+ cursor.close();
+ logChannel.close();
+ if (release) {
+ owner.cursorClosed(this);
+ }
+ }
+
+ private void requireOpen() {
+ if (closed) {
+ throw new IllegalStateException("Revocation cursor is closed");
+ }
+ }
+ }
+
+ /** Two-pass bounded-memory history cursor over one captured authoritative prefix. */
+ private static final class HistoryCursor implements RevocationHistory, OwnedResource {
+ private final FilesystemRevocationAuthority owner;
+ private final PkiId credentialId;
+ private final FilesystemRevocationLog.RecoveryTarget head;
+ private final FileChannel channel;
+ private final RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec();
+ private long offset = RevocationTransitionFrameCodec.PREAMBLE_BYTES;
+ private RevocationTransitionFrameCodec.Commitment globalCommitment;
+ private long globalRevision;
+ private RevocationTransitionFrameCodec.CompleteRecord previousCredential;
+ private RevocationTransition current;
+ private boolean validated;
+ private boolean closed;
+
+ private HistoryCursor(FilesystemRevocationAuthority owner, PkiId credentialId,
+ FilesystemRevocationLog.RecoveryTarget head) throws IOException {
+ this.owner = owner;
+ this.credentialId = credentialId;
+ this.head = head;
+ channel = FileChannel.open(owner.paths.revocationTransitionLog(), StandardOpenOption.READ);
+ boolean valid = false;
+ try {
+ reset();
+ valid = true;
+ } finally {
+ if (!valid) {
+ channel.close();
+ }
+ }
+ }
+
+ @Override
+ public PkiId credentialId() {
+ return credentialId;
+ }
+
+ @Override
+ public boolean next(CancellationSignal cancellation) throws IOException {
+ Objects.requireNonNull(cancellation, "cancellation");
+ requireOpen();
+ if (!validated) {
+ validatePrefix(cancellation);
+ reset();
+ validated = true;
+ }
+ current = null;
+ while (offset < head.boundary()) {
+ cancellation.throwIfCancelled();
+ RevocationTransitionFrameCodec.CompleteRecord frame = readNext();
+ if (frame.data().credentialId().equals(credentialId)) {
+ FilesystemRevocationLog.validateTransition(frame.data(), previousCredential);
+ previousCredential = frame;
+ current = frame.data().transition();
+ return true;
+ }
+ }
+ requireExactHead();
+ return false;
+ }
+
+ @Override
+ public RevocationTransition current() {
+ if (current == null) {
+ throw new IllegalStateException("Revocation history is not positioned");
+ }
+ return current;
+ }
+
+ @Override
+ public void close() throws IOException {
+ closeInternal(true);
+ }
+
+ @Override
+ public void closeFromOwner() throws IOException {
+ closeInternal(false);
+ }
+
+ private void validatePrefix(CancellationSignal cancellation) throws IOException {
+ reset();
+ while (offset < head.boundary()) {
+ cancellation.throwIfCancelled();
+ RevocationTransitionFrameCodec.CompleteRecord frame = readNext();
+ if (frame.data().credentialId().equals(credentialId)) {
+ FilesystemRevocationLog.validateTransition(frame.data(), previousCredential);
+ previousCredential = frame;
+ }
+ }
+ requireExactHead();
+ }
+
+ private void reset() throws IOException {
+ MetadataStoreId actual = codec.readPreamble(channel);
+ if (!head.storeId().equals(actual)) {
+ throw new IOException("Revocation history belongs to another store");
+ }
+ offset = RevocationTransitionFrameCodec.PREAMBLE_BYTES;
+ globalRevision = 0L;
+ globalCommitment = RevocationTransitionFrameCodec.initialCommitment(actual);
+ previousCredential = null;
+ current = null;
+ }
+
+ private RevocationTransitionFrameCodec.CompleteRecord readNext() throws IOException {
+ RevocationTransitionFrameCodec.ReadResult read = codec.read(channel, offset);
+ if (read.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
+ throw new IOException("Revocation history contains an incomplete or corrupt frame");
+ }
+ RevocationTransitionFrameCodec.CompleteRecord frame = read.record().orElseThrow();
+ if (frame.recordEnd() > head.boundary()
+ || frame.data().globalRevision() != Math.addExact(globalRevision, 1L)
+ || !frame.data().previousGlobalCommitment().equals(globalCommitment)) {
+ throw new IOException("Revocation history continuity is invalid");
+ }
+ offset = frame.recordEnd();
+ globalRevision = frame.data().globalRevision();
+ globalCommitment = frame.commitment();
+ return frame;
+ }
+
+ private void requireExactHead() throws IOException {
+ if (offset != head.boundary() || globalRevision != head.globalRevision()
+ || !globalCommitment.equals(head.globalCommitment())) {
+ throw new IOException("Revocation history does not match its captured head");
+ }
+ }
+
+ private void closeInternal(boolean release) throws IOException {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ current = null;
+ channel.close();
+ if (release) {
+ owner.release(this);
+ }
+ }
+
+ private void requireOpen() {
+ if (closed) {
+ throw new IllegalStateException("Revocation history is closed");
+ }
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java
index 9eb6b82..df11480 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemRevocationLog.java
@@ -71,6 +71,7 @@ import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.spi.store.MetadataStoreId;
/** Exclusive-writer POSIX append-only revocation transition log. */
+@SuppressWarnings("PMD.CyclomaticComplexity")
final class FilesystemRevocationLog implements AutoCloseable {
private static final Logger LOGGER = Logger.getLogger(FilesystemRevocationLog.class.getName());
@@ -107,13 +108,14 @@ final class FilesystemRevocationLog implements AutoCloseable {
MetadataStoreId storeId,
CredentialAuthority credentialAuthority,
FaultInjector faults,
- RecoveryResult recovery) {
+ RecoveryResult recovery,
+ boolean retainCurrentState) {
this.channel = channel;
this.writerLock = writerLock;
this.storeId = storeId;
this.credentialAuthority = credentialAuthority;
this.faults = faults;
- latest = new HashMap<>(recovery.latestStates());
+ latest = retainCurrentState ? new HashMap<>(recovery.latestStates()) : null;
globalRevision = recovery.globalRevision();
finalRecordStart = recovery.finalRecordStart();
lastCompleteRecordBoundary = recovery.lastCompleteRecordBoundary();
@@ -127,6 +129,19 @@ final class FilesystemRevocationLog implements AutoCloseable {
DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE);
}
+ /* default */ static FilesystemRevocationLog createProduction(
+ Path logPath, MetadataStoreId storeId, CredentialAuthority credentialAuthority) throws IOException {
+ return Lifecycle.create(logPath, storeId, credentialAuthority,
+ DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE, false);
+ }
+
+ /* default */ static FilesystemRevocationLog openProduction(
+ Path logPath, MetadataStoreId expectedStoreId, CredentialAuthority credentialAuthority)
+ throws IOException {
+ return Lifecycle.open(logPath, expectedStoreId, credentialAuthority,
+ DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE, false);
+ }
+
/* default */ static FilesystemRevocationLog open(
Path logPath, MetadataStoreId expectedStoreId, CredentialAuthority credentialAuthority)
throws IOException {
@@ -140,7 +155,7 @@ final class FilesystemRevocationLog implements AutoCloseable {
CredentialAuthority credentialAuthority,
CapabilityProfile capabilities,
FaultInjector faults) throws IOException {
- return Lifecycle.create(logPath, storeId, credentialAuthority, capabilities, faults);
+ return Lifecycle.create(logPath, storeId, credentialAuthority, capabilities, faults, true);
}
/* default */ static FilesystemRevocationLog open(
@@ -149,7 +164,7 @@ final class FilesystemRevocationLog implements AutoCloseable {
CredentialAuthority credentialAuthority,
CapabilityProfile capabilities,
FaultInjector faults) throws IOException {
- return Lifecycle.open(logPath, expectedStoreId, credentialAuthority, capabilities, faults);
+ return Lifecycle.open(logPath, expectedStoreId, credentialAuthority, capabilities, faults, true);
}
/* default */ MetadataStoreId storeId() {
@@ -190,6 +205,41 @@ final class FilesystemRevocationLog implements AutoCloseable {
}
}
+ /* default */ RevocationTransitionFrameCodec.CompleteRecord append(
+ PkiId credentialId, RevocationTransition transition,
+ RevocationTransitionFrameCodec.CompleteRecord previous) throws IOException {
+ Objects.requireNonNull(credentialId, "credentialId");
+ Objects.requireNonNull(transition, "transition");
+ if (latest != null) {
+ throw new IllegalStateException("External predecessor append requires production log mode");
+ }
+ credentialAuthority.requireCredential(credentialId);
+ appendLock.lock();
+ try {
+ requireOperational();
+ RevocationTransitionFrameCodec.TransitionData data = nextData(
+ credentialId, transition, previous);
+ validateTransition(data, previous);
+ try {
+ channel.position(channel.size());
+ faults.fail(FaultPoint.APPEND);
+ RevocationTransitionFrameCodec.CompleteRecord record = codec.write(channel, data);
+ faults.fail(FaultPoint.FILE_FORCE);
+ channel.force(true);
+ globalRevision = data.globalRevision();
+ finalRecordStart = OptionalLong.of(record.recordOffset());
+ lastCompleteRecordBoundary = record.recordEnd();
+ globalCommitment = record.commitment();
+ return record;
+ } catch (IOException failure) {
+ state = State.RECOVERY_REQUIRED;
+ throw new OutcomeUnknownException(failure);
+ }
+ } finally {
+ appendLock.unlock();
+ }
+ }
+
/* default */ RecoveryResult scan(RecoverySink sink) throws IOException {
appendLock.lock();
try {
@@ -209,6 +259,9 @@ final class FilesystemRevocationLog implements AutoCloseable {
appendLock.lock();
try {
requireOpenAuthority();
+ if (latest == null) {
+ throw new IllegalStateException("Production log does not retain current credential state");
+ }
return latest.size();
} finally {
appendLock.unlock();
@@ -347,6 +400,29 @@ final class FilesystemRevocationLog implements AutoCloseable {
Optional.of(previous.commitment()), transition);
}
+ private RevocationTransitionFrameCodec.TransitionData nextData(
+ PkiId credentialId, RevocationTransition transition,
+ RevocationTransitionFrameCodec.CompleteRecord previous) throws IOException {
+ final long nextGlobal;
+ try {
+ nextGlobal = Math.addExact(globalRevision, 1L);
+ } catch (ArithmeticException exhausted) {
+ throw new IOException("Global revocation revision is exhausted", exhausted);
+ }
+ if (previous == null) {
+ return new RevocationTransitionFrameCodec.TransitionData(
+ nextGlobal, globalCommitment, credentialId,
+ OptionalLong.empty(), Optional.empty(), transition);
+ }
+ if (!previous.data().credentialId().equals(credentialId)) {
+ throw new IOException("Revocation predecessor identity is invalid");
+ }
+ return new RevocationTransitionFrameCodec.TransitionData(
+ nextGlobal, globalCommitment, credentialId,
+ OptionalLong.of(previous.data().globalRevision()),
+ Optional.of(previous.commitment()), transition);
+ }
+
private static void validateGlobal(
RevocationTransitionFrameCodec.CompleteRecord record,
long currentRevision,
@@ -453,7 +529,9 @@ final class FilesystemRevocationLog implements AutoCloseable {
} catch (IOException closeFailure) {
failure = appendFailure(failure, closeFailure);
}
- latest.clear();
+ if (latest != null) {
+ latest.clear();
+ }
state = State.CLOSED;
if (failure != null) {
throw failure;
@@ -478,7 +556,8 @@ final class FilesystemRevocationLog implements AutoCloseable {
MetadataStoreId storeId,
CredentialAuthority credentialAuthority,
CapabilityProfile capabilities,
- FaultInjector faults) throws IOException {
+ FaultInjector faults,
+ boolean retainCurrentState) throws IOException {
requireLifecycleArguments(storeId, credentialAuthority, capabilities, faults);
Path parent = requireParent(logPath);
CapabilityObservation observation = observeCapabilities(parent, capabilities);
@@ -493,7 +572,7 @@ final class FilesystemRevocationLog implements AutoCloseable {
RecoveryResult empty = RecoveryResult.empty(storeId);
FilesystemRevocationLog log = new FilesystemRevocationLog(
resources.channel, resources.writerLock, storeId,
- credentialAuthority, faults, empty);
+ credentialAuthority, faults, empty, retainCurrentState);
log.scanInvocations = 0L;
return log;
} catch (IOException failure) {
@@ -507,14 +586,18 @@ final class FilesystemRevocationLog implements AutoCloseable {
MetadataStoreId expectedStoreId,
CredentialAuthority credentialAuthority,
CapabilityProfile capabilities,
- FaultInjector faults) throws IOException {
+ FaultInjector faults,
+ boolean retainCurrentState) throws IOException {
requireLifecycleArguments(expectedStoreId, credentialAuthority, capabilities, faults);
Path parent = requireParent(logPath);
CapabilityObservation observation = observeCapabilities(parent, capabilities);
Resources resources = Resources.acquire(logPath, false, observation.posix());
try {
- ProvisionalRecovery provisional = Scanner.scanProvisional(
- resources.channel, expectedStoreId, credentialAuthority, RecoverySink.NONE);
+ ProvisionalRecovery provisional = retainCurrentState
+ ? Scanner.scanProvisional(resources.channel, expectedStoreId,
+ credentialAuthority, RecoverySink.NONE)
+ : ScalarScanner.scanProvisional(resources.channel, expectedStoreId,
+ credentialAuthority);
boolean completed = false;
try {
RecoveryResult recovered = repairIfNecessary(
@@ -529,7 +612,7 @@ final class FilesystemRevocationLog implements AutoCloseable {
}
return new FilesystemRevocationLog(
resources.channel, resources.writerLock, expectedStoreId,
- credentialAuthority, faults, recovered);
+ credentialAuthority, faults, recovered, retainCurrentState);
} finally {
if (!completed) {
provisional.sink().abort();
@@ -694,6 +777,51 @@ final class FilesystemRevocationLog implements AutoCloseable {
}
}
+ /** Production scalar scan; credential-local state is rebuilt only in the disk index. */
+ private static final class ScalarScanner {
+ private static ProvisionalRecovery scanProvisional(
+ FileChannel channel,
+ MetadataStoreId expectedStoreId,
+ CredentialAuthority credentialAuthority) throws IOException {
+ RevocationTransitionFrameCodec codec = new RevocationTransitionFrameCodec();
+ MetadataStoreId actualStoreId = codec.readPreamble(channel);
+ if (!expectedStoreId.equals(actualStoreId)) {
+ throw new CorruptLogException("Revocation log belongs to another store");
+ }
+ long physicalEnd = channel.size();
+ long boundary = RevocationTransitionFrameCodec.PREAMBLE_BYTES;
+ long globalRevision = 0L;
+ OptionalLong finalRecordStart = OptionalLong.empty();
+ RevocationTransitionFrameCodec.Commitment commitment =
+ RevocationTransitionFrameCodec.initialCommitment(actualStoreId);
+ boolean incomplete = false;
+ while (true) {
+ RevocationTransitionFrameCodec.ReadResult result = codec.read(channel, boundary);
+ if (result.classification() == RevocationTransitionFrameCodec.Classification.END_OF_INPUT) {
+ break;
+ }
+ if (result.classification() == RevocationTransitionFrameCodec.Classification.INCOMPLETE_TAIL) {
+ incomplete = true;
+ break;
+ }
+ if (result.classification() != RevocationTransitionFrameCodec.Classification.COMPLETE_RECORD) {
+ throw new CorruptLogException("Revocation transition log contains a corrupt record");
+ }
+ RevocationTransitionFrameCodec.CompleteRecord record = result.record().orElseThrow();
+ validateGlobal(record, globalRevision, commitment);
+ credentialAuthority.requireCredential(record.data().credentialId());
+ globalRevision = record.data().globalRevision();
+ finalRecordStart = OptionalLong.of(record.recordOffset());
+ commitment = record.commitment();
+ boundary = record.recordEnd();
+ }
+ RecoveryResult recovered = new RecoveryResult(
+ actualStoreId, boundary, physicalEnd, incomplete, globalRevision,
+ finalRecordStart, commitment, Map.of());
+ return new ProvisionalRecovery(recovered, RecoverySink.NONE);
+ }
+ }
+
/** Successful scan whose sink publication remains provisional until its owner completes it. */
private record ProvisionalRecovery(RecoveryResult result, RecoverySink sink) {
private ProvisionalRecovery {
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java
index fcd8b0a..c731c9d 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java
@@ -39,6 +39,7 @@ import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.nio.file.DirectoryStream;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.security.MessageDigest;
@@ -145,17 +146,22 @@ final class FilesystemTemporaryUniqueIndex implements TemporaryUniqueIndex {
requireOpen();
lock.lock();
try {
- for (Path entry : DurableMetadataFiles.list(directory, "")) {
- requireCommittedName(entry);
- byte[] value = readRecord(entry);
- try {
- requireCanonicalOwner(value);
- String actualKey = entry.getFileName().toString();
- if (!actualKey.equals(key(domain, value))) {
- throw integrityFailure();
+ if (!Files.exists(directory)) {
+ return;
+ }
+ try (DirectoryStream entries = Files.newDirectoryStream(directory)) {
+ for (Path entry : entries) {
+ requireCommittedName(entry);
+ byte[] value = readRecord(entry);
+ try {
+ requireCanonicalOwner(value);
+ String actualKey = entry.getFileName().toString();
+ if (!actualKey.equals(key(domain, value))) {
+ throw integrityFailure();
+ }
+ } finally {
+ Arrays.fill(value, (byte) 0);
}
- } finally {
- Arrays.fill(value, (byte) 0);
}
}
} finally {
@@ -170,8 +176,12 @@ final class FilesystemTemporaryUniqueIndex implements TemporaryUniqueIndex {
if (!closed.compareAndSet(false, true)) {
return;
}
- for (Path entry : DurableMetadataFiles.list(directory, "")) {
- DurableMetadataFiles.delete(entry);
+ if (Files.isDirectory(directory)) {
+ try (DirectoryStream entries = Files.newDirectoryStream(directory)) {
+ for (Path entry : entries) {
+ DurableMetadataFiles.delete(entry);
+ }
+ }
}
Files.deleteIfExists(directory);
} finally {
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
index 0e7da7c..734d758 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
@@ -63,6 +63,7 @@ final class FsPaths {
private static final String REVOCATIONS_DIRECTORY = "revocations";
/* default */ static final String VERSION_FILE = "VERSION";
+ /* default */ static final String REVOCATION_SNAPSHOT_BOUNDARY_FILE = ".revocation-snapshot-boundary";
/* default */ static final String LOCK_DIR = ".lock";
/* default */ static final String STORE_LOCK = "store.lock";
@@ -83,6 +84,10 @@ final class FsPaths {
return this.root.resolve(VERSION_FILE);
}
+ /* default */ Path revocationSnapshotBoundary() {
+ return this.root.resolve(REVOCATION_SNAPSHOT_BOUNDARY_FILE);
+ }
+
/* default */ Path signingNamespaceFile() {
return this.root.resolve("SIGNING_NAMESPACE");
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
index bac6470..b2b42a4 100644
--- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
@@ -103,6 +103,7 @@ final class FsSnapshotExporter {
private static final String ACTIVE_POINTER_FILE = "active.bin";
private static final String BINARY_EXTENSION = ".bin";
private static final int TRANSFER_BUFFER_BYTES = 16 * 1024;
+ private static final String REVOCATION_PREFIX_STAGE = ".revocation-prefix.snapshot";
private final FsPkiStoreOptions options;
@@ -337,8 +338,9 @@ final class FsSnapshotExporter {
private void build(Path targetRoot, Instant at) throws IOException {
Path sourceRoot = source.snapshotRoot();
- FsPaths destination = new FsPaths(targetRoot);
- Files.writeString(destination.versionFile(), FilesystemPkiStore.CURRENT_STORE_VERSION);
+ Path stagedRevocationPrefix = targetRoot.resolve(REVOCATION_PREFIX_STAGE);
+ FilesystemRevocationLog.RecoveryTarget captured =
+ source.copyRevocationPrefix(stagedRevocationPrefix);
copyFile(sourceRoot.resolve("SIGNING_NAMESPACE"), targetRoot.resolve("SIGNING_NAMESPACE"));
copyFile(sourceRoot.resolve("SIGNING_TIME_WATERMARK"), targetRoot.resolve("SIGNING_TIME_WATERMARK"));
copyTreeIfExists(sourceRoot.resolve("requests"), targetRoot.resolve("requests"));
@@ -347,11 +349,47 @@ final class FsSnapshotExporter {
copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
copyStagedContent(sourceRoot.resolve("staged-content"), targetRoot.resolve("staged-content"),
plan.authority().remintedContentIds(), plan.nonCredentialContentIds());
- copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"));
copyImportedProfilesAsOf(plan.profiles(), targetRoot.resolve("profiles"));
reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at,
options.workflowHistoryPolicy(), options.strictSnapshotExport());
new AuthorityRestorer(source, options, plan.authority()).restore(targetRoot);
+ installRevocationAuthority(targetRoot, stagedRevocationPrefix);
+ try (FilesystemPkiStore ignored = new FilesystemPkiStore(targetRoot, options)) {
+ // Opening validates the restored authority and rebuilds its derived index.
+ }
+ writeSnapshotBoundary(targetRoot, captured.boundary());
+ }
+
+ private static void writeSnapshotBoundary(Path targetRoot, long boundary) throws IOException {
+ Path marker = new FsPaths(targetRoot).revocationSnapshotBoundary();
+ Files.writeString(marker, Long.toString(boundary), StandardCharsets.US_ASCII,
+ java.nio.file.StandardOpenOption.CREATE_NEW,
+ java.nio.file.StandardOpenOption.WRITE);
+ try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open(
+ marker, java.nio.file.StandardOpenOption.WRITE)) {
+ channel.force(true);
+ }
+ try (java.nio.channels.FileChannel directory = java.nio.channels.FileChannel.open(
+ targetRoot, java.nio.file.StandardOpenOption.READ)) {
+ directory.force(true);
+ }
+ }
+
+ private static void installRevocationAuthority(Path targetRoot, Path stagedPrefix) throws IOException {
+ FsPaths paths = new FsPaths(targetRoot);
+ Files.deleteIfExists(paths.revocationCurrentIndex());
+ Files.createDirectories(paths.revocationTransitionLog().getParent());
+ try {
+ Files.move(stagedPrefix, paths.revocationTransitionLog(),
+ java.nio.file.StandardCopyOption.ATOMIC_MOVE,
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) {
+ throw new IOException("Atomic revocation snapshot restoration is unsupported", unsupported);
+ }
+ try (java.nio.channels.FileChannel directory = java.nio.channels.FileChannel.open(
+ paths.revocationTransitionLog().getParent(), java.nio.file.StandardOpenOption.READ)) {
+ directory.force(true);
+ }
}
private static void addCleanupFailure(Exception primary, Path temporaryRoot) {
diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
index 0d37b59..10b8b6c 100644
--- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
+++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java
@@ -48,7 +48,7 @@ import zeroecho.pki.api.profile.ImportedCertificateProfileVersion;
import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand;
-import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.status.StatusObject;
/**
@@ -177,20 +177,37 @@ public interface PkiStore extends SignWorkflowStore {
*
* @param command trusted transition command
* @param transitionTime authoritative transition time
- * @return committed journal
+ * @return committed current state
*/
- RevocationJournal transitionRevocation(RevocationCommand command, Instant transitionTime);
+ RevocationRecord transitionRevocation(RevocationCommand command, Instant transitionTime);
/**
- * Retrieves the authoritative revocation journal for a credential.
+ * Retrieves the validated current revocation state for a credential.
+ * Disk probing is expected constant time and is linear in index capacity in
+ * the worst collision case; auxiliary heap use is constant.
*
* @param credentialId credential identifier
- * @return journal when present
+ * @return current state when present
*/
- Optional getRevocationJournal(PkiId credentialId);
+ Optional getRevocation(PkiId credentialId);
+
+ /**
+ * Opens a bounded-memory cursor over one credential's authoritative history.
+ * Opening is constant time. The first advance validates the captured global
+ * prefix; consuming the history performs at most two linear passes over that
+ * prefix and uses constant auxiliary heap.
+ *
+ * @param credentialId exact credential identity
+ * @return closeable history cursor captured at the current log head
+ * @throws IllegalStateException if the authoritative history cannot be opened
+ */
+ RevocationHistory openRevocationHistory(PkiId credentialId);
/**
* Opens a stable restartable streaming snapshot of authoritative revocations.
+ * An exact checkpoint opens in constant time after validation. Otherwise the
+ * implementation may perform bounded-memory external sorting over the current
+ * population; cursors themselves use constant auxiliary heap per entry.
*
* @return revocation snapshot
* @throws IllegalStateException if the snapshot cannot be opened
diff --git a/pki/src/main/java/zeroecho/pki/spi/store/RevocationHistory.java b/pki/src/main/java/zeroecho/pki/spi/store/RevocationHistory.java
new file mode 100644
index 0000000..7712cc7
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/store/RevocationHistory.java
@@ -0,0 +1,60 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ ******************************************************************************/
+package zeroecho.pki.spi.store;
+
+import java.io.IOException;
+
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.revocation.RevocationTransition;
+import zeroecho.core.io.CancellationSignal;
+
+/**
+ * Closeable one-pass history of one credential in credential-local revision order.
+ *
+ * The cursor reads authenticated authoritative frames incrementally. Its
+ * auxiliary heap is independent of the number of transitions. Opening is
+ * constant time. The first advance validates the captured prefix, and complete
+ * consumption costs at most two linear passes over the global log prefix.
+ */
+public interface RevocationHistory extends AutoCloseable {
+
+ /** @return exact credential identity selected for this history */
+ PkiId credentialId();
+
+ /**
+ * Advances to the next transition.
+ *
+ * @param cancellation cancellation signal checked during scanning
+ * @return {@code true} when {@link #current()} is available
+ * @throws IOException if the authoritative history is unreadable or invalid
+ */
+ boolean next(CancellationSignal cancellation) throws IOException;
+
+ /**
+ * Advances without external cancellation.
+ *
+ * @return {@code true} when a transition is available
+ * @throws IOException if the authoritative history is unreadable or invalid
+ */
+ default boolean next() throws IOException {
+ return next(CancellationSignal.NONE);
+ }
+
+ /**
+ * Returns the current transition.
+ *
+ * @return current transition
+ * @throws IllegalStateException if the cursor is not positioned
+ */
+ RevocationTransition current();
+
+ /**
+ * Closes the underlying authoritative-log channel. Repeated close is harmless.
+ *
+ * @throws IOException if channel closure fails
+ */
+ @Override
+ void close() throws IOException;
+}
diff --git a/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java b/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java
index 73fe041..051288e 100644
--- a/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java
+++ b/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java
@@ -36,16 +36,21 @@ package zeroecho.pki.spi.store;
import java.io.IOException;
import java.util.OptionalLong;
-import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
+import zeroecho.core.io.CancellationSignal;
/**
- * Stable, restartable streaming view of authoritative revocation journals.
+ * Stable, restartable ordered current-state view at one authoritative revision.
*
*
* A snapshot never exposes a complete collection. Each cursor yields one
- * immutable journal at a time and uses {@code long} ordinal accounting. ZeroEcho
+ * immutable current state at a time and uses {@code long} ordinal accounting. ZeroEcho
* core imposes no product-wide entry-count ceiling.
*
+ *
+ * An exact checkpoint opens without population materialization. Building a
+ * missing checkpoint uses a bounded-memory external sort; cursor traversal is
+ * linear in the current-state count and uses constant auxiliary heap per entry.
*/
public interface RevocationSnapshot extends AutoCloseable {
@@ -56,8 +61,17 @@ public interface RevocationSnapshot extends AutoCloseable {
*/
String snapshotId();
+ /** @return captured authoritative global revision */
+ long revision();
+
+ /** @return captured authoritative byte boundary */
+ long boundary();
+
+ /** @return captured authoritative global commitment in lowercase hexadecimal */
+ String commitment();
+
/**
- * Returns the journal count when the store can provide it without
+ * Returns the current-state count when the store can provide it without
* materialization.
*
* @return count or empty
@@ -81,25 +95,36 @@ public interface RevocationSnapshot extends AutoCloseable {
void close() throws IOException;
/**
- * One-pass journal cursor.
+ * One-pass current-state cursor.
*/
interface Cursor extends AutoCloseable {
/**
- * Advances to the next journal.
+ * Advances to the next current state.
*
* @return {@code true} when {@link #current()} is available
* @throws IOException if store reading fails
*/
- boolean next() throws IOException;
+ default boolean next() throws IOException {
+ return next(CancellationSignal.NONE);
+ }
/**
- * Returns the current journal.
+ * Advances with cancellation.
*
- * @return immutable current journal
+ * @param cancellation cancellation signal
+ * @return {@code true} when a current state is available
+ * @throws IOException if store reading fails
+ */
+ boolean next(CancellationSignal cancellation) throws IOException;
+
+ /**
+ * Returns the current state.
+ *
+ * @return immutable current state
* @throws IllegalStateException if the cursor is not positioned on a value
*/
- RevocationJournal current();
+ RevocationRecord current();
/**
* Returns the zero-based current ordinal.
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java
index fb59920..ed0ef31 100644
--- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultRevocationServiceTest.java
@@ -40,6 +40,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Proxy;
+import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
@@ -47,6 +48,7 @@ import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
+import java.util.OptionalLong;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
@@ -56,10 +58,14 @@ import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
+import zeroecho.pki.api.revocation.RevocationQuery;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.store.PkiStore;
+import zeroecho.pki.spi.store.RevocationSnapshot;
+import zeroecho.core.io.CancellationSignal;
final class DefaultRevocationServiceTest {
private static final Instant NOW = Instant.parse("2026-07-01T12:00:00Z");
@@ -67,8 +73,8 @@ final class DefaultRevocationServiceTest {
@Test
void successAuditsCommittedStateAndRevisionOnce() {
- RevocationJournal committed = new RevocationJournal(CREDENTIAL_ID, List.of(
- new RevocationTransition(1L, RevocationState.HELD, NOW, Optional.empty(), new SimpleAttributeSet())));
+ RevocationRecord committed = new RevocationRecord(CREDENTIAL_ID,
+ new RevocationTransition(1L, RevocationState.HELD, NOW, Optional.empty(), new SimpleAttributeSet()));
List events = new ArrayList<>();
DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> {
if ("transitionRevocation".equals(method)) {
@@ -93,7 +99,7 @@ final class DefaultRevocationServiceTest {
if ("transitionRevocation".equals(method)) {
throw new IllegalStateException(sentinel);
}
- if ("getRevocationJournal".equals(method)) {
+ if ("getRevocation".equals(method)) {
lookupAttempted.set(true);
throw new IllegalStateException(sentinel);
}
@@ -112,6 +118,82 @@ final class DefaultRevocationServiceTest {
assertTrue(lookupAttempted.get());
}
+ @Test
+ void appendUncertaintyPreservesRecoveryRequiredCodeAndAuditsOnce() {
+ List events = new ArrayList<>();
+ DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> {
+ if ("transitionRevocation".equals(method)) {
+ throw new PkiException("Revocation append outcome is unknown: code=REVOCATION_RECOVERY_REQUIRED");
+ }
+ if ("getRevocation".equals(method)) {
+ return Optional.empty();
+ }
+ throw new UnsupportedOperationException(method);
+ }), Clock.fixed(NOW, ZoneOffset.UTC), events::add);
+
+ PkiException failure = assertThrows(PkiException.class,
+ () -> service.hold(new RevocationCommand.Hold(CREDENTIAL_ID, new SimpleAttributeSet())));
+ assertEquals("Revocation operation failed: code=REVOCATION_RECOVERY_REQUIRED", failure.getMessage());
+ assertNull(failure.getCause());
+ assertEquals(1, events.size());
+ assertEquals("REJECTED", events.get(0).details().get("result"));
+ assertEquals("REVOCATION_RECOVERY_REQUIRED", events.get(0).details().get("code"));
+ }
+
+ @Test
+ void delayedIssuerFilterFailureIsRedactedAtCursorBoundary() throws Exception {
+ String sentinel = "DO_NOT_EXPOSE_DELAYED_STORE_SENTINEL";
+ RevocationRecord record = new RevocationRecord(CREDENTIAL_ID,
+ new RevocationTransition(1L, RevocationState.HELD, NOW,
+ Optional.empty(), new SimpleAttributeSet()));
+ RevocationSnapshot source = singleRecordSnapshot(record);
+ DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> {
+ if ("openRevocationSnapshot".equals(method)) {
+ return source;
+ }
+ if ("getCredential".equals(method)) {
+ throw new IllegalStateException(sentinel);
+ }
+ throw new UnsupportedOperationException(method);
+ }), Clock.fixed(NOW, ZoneOffset.UTC), event -> { });
+ RevocationQuery query = new RevocationQuery(Optional.of(new PkiId("ca:issuer")),
+ Optional.empty(), Optional.empty(), Optional.empty());
+
+ try (RevocationSnapshot snapshot = service.search(query);
+ RevocationSnapshot.Cursor cursor = snapshot.openCursor()) {
+ PkiException failure = assertThrows(PkiException.class, cursor::next);
+ assertEquals("Revocation operation failed: code=REVOCATION_STATE_UPDATE_FAILED", failure.getMessage());
+ assertNull(failure.getCause());
+ assertFalse(failure.toString().contains(sentinel));
+ }
+ }
+
+ private static RevocationSnapshot singleRecordSnapshot(RevocationRecord record) {
+ return new RevocationSnapshot() {
+ @Override public String snapshotId() { return "snapshot:test"; }
+ @Override public long revision() { return 1L; }
+ @Override public long boundary() { return 1L; }
+ @Override public String commitment() { return "00"; }
+ @Override public OptionalLong count() { return OptionalLong.of(1L); }
+ @Override public Cursor openCursor() {
+ return new Cursor() {
+ private boolean advanced;
+ @Override public boolean next(CancellationSignal cancellation) {
+ if (advanced) {
+ return false;
+ }
+ advanced = true;
+ return true;
+ }
+ @Override public RevocationRecord current() { return record; }
+ @Override public long ordinal() { return 0L; }
+ @Override public void close() { }
+ };
+ }
+ @Override public void close() throws IOException { }
+ };
+ }
+
@Test
void hostileClockFailureIsRedactedAuditedOnceAndNeverReachesStoreTransition() {
String sentinel = "DO_NOT_EXPOSE_REVOCATION_CLOCK_SENTINEL";
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java
index 4ff2503..e0e42b6 100644
--- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java
@@ -48,6 +48,7 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
@@ -73,6 +74,9 @@ import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.Encoding;
+import zeroecho.pki.impl.fs.FilesystemPkiStore;
+import zeroecho.pki.impl.fs.FsHistoryPolicy;
+import zeroecho.pki.impl.fs.FsPkiStoreOptions;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.KeyRef;
@@ -94,6 +98,7 @@ import zeroecho.pki.api.request.CertificationRequest;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
@@ -153,31 +158,59 @@ final class DefaultStatusObjectServiceCrlTest {
KeyPair permanentKey = generateRsa();
KeyPair clearKey = generateRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-service");
+ Path snapshot = root.resolve("crl-service-restored");
+ FsHistoryPolicy history = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90)));
+ FsPkiStoreOptions snapshotOptions = new FsPkiStoreOptions(
+ history, history, history, history, false, Duration.ofDays(90), Duration.ZERO);
+ PkiId caId;
+ BigInteger heldSerial;
+ BigInteger permanentSerial;
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
- Map.of(rootKeyRef, rootKey))) {
- PkiId caId = createRoot(runtime, rootKeyRef, "CRL Service Root");
+ Map.of(rootKeyRef, rootKey), snapshotOptions)) {
+ caId = createRoot(runtime, rootKeyRef, "CRL Service Root");
Credential held = issue(runtime, caId, heldKey, "Held");
Credential permanent = issue(runtime, caId, permanentKey, "Permanent");
Credential clear = issue(runtime, caId, clearKey, "Clear");
- RevocationJournal heldJournal = runtime.revocationService()
+ RevocationRecord heldRecord = runtime.revocationService()
.hold(new RevocationCommand.Hold(held.credentialId(), emptyAttributes()));
- RevocationJournal permanentJournal = runtime.revocationService()
+ RevocationRecord permanentRecord = runtime.revocationService()
.revokePermanently(new RevocationCommand.RevokePermanently(permanent.credentialId(),
RevocationReason.AA_COMPROMISE, emptyAttributes()));
runtime.revocationService().hold(new RevocationCommand.Hold(clear.credentialId(), emptyAttributes()));
runtime.revocationService().unhold(new RevocationCommand.Unhold(clear.credentialId(), emptyAttributes()));
+ try (java.util.stream.Stream files = java.nio.file.Files.walk(
+ root.resolve("store").resolve("profiles"))) {
+ for (Path active : files.filter(path -> "active.bin".equals(
+ path.getFileName().toString())).toList()) {
+ java.nio.file.Files.delete(active);
+ }
+ }
+ java.nio.file.Files.createDirectories(snapshot);
+ ((FilesystemPkiStore) runtime.store()).exportSnapshot(snapshot.resolve("store"), Instant.now());
+
StatusObject status = runtime.statusObjectService().generate(new StatusObjectGenerateCommand(caId,
StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes()));
X509CRLHolder crl = new X509CRLHolder(PkiTestRuntime.readContent(runtime.signingBus(), status.content()));
X509CertificateHolderView heldCertificate = certificate(runtime, held);
X509CertificateHolderView permanentCertificate = certificate(runtime, permanent);
X509CertificateHolderView clearCertificate = certificate(runtime, clear);
- assertEntry(crl, heldCertificate.serial(), heldJournal.latest().time(), RevocationReason.CERTIFICATE_HOLD);
- assertEntry(crl, permanentCertificate.serial(), permanentJournal.latest().time(),
+ assertEntry(crl, heldCertificate.serial(), heldRecord.transition().time(), RevocationReason.CERTIFICATE_HOLD);
+ assertEntry(crl, permanentCertificate.serial(), permanentRecord.transition().time(),
RevocationReason.AA_COMPROMISE);
assertNull(crl.getRevokedCertificate(clearCertificate.serial()));
+ heldSerial = heldCertificate.serial();
+ permanentSerial = permanentCertificate.serial();
+ }
+ try (PkiTestRuntime restored = PkiTestRuntime.create(snapshot, snapshot.resolve("restored-bus.log"),
+ Map.of(rootKeyRef, rootKey), snapshotOptions)) {
+ StatusObject restoredStatus = restored.statusObjectService().generate(new StatusObjectGenerateCommand(
+ caId, StatusObjectType.CRL, restored.framework().formatId(), emptyAttributes()));
+ X509CRLHolder restoredCrl = new X509CRLHolder(
+ PkiTestRuntime.readContent(restored.signingBus(), restoredStatus.content()));
+ assertTrue(restoredCrl.getRevokedCertificate(heldSerial) != null);
+ assertTrue(restoredCrl.getRevokedCertificate(permanentSerial) != null);
}
}
@@ -310,13 +343,30 @@ final class DefaultStatusObjectServiceCrlTest {
if (failListing) {
throw new IllegalStateException(SENTINEL);
}
- List snapshot = List.copyOf(journals);
+ List snapshot = journals.stream()
+ .map(journal -> new RevocationRecord(journal.credentialId(), journal.latest()))
+ .toList();
return new zeroecho.pki.spi.store.RevocationSnapshot() {
@Override
public String snapshotId() {
return "test-snapshot";
}
+ @Override
+ public long revision() {
+ return snapshot.size();
+ }
+
+ @Override
+ public long boundary() {
+ return snapshot.size();
+ }
+
+ @Override
+ public String commitment() {
+ return "test-commitment";
+ }
+
@Override
public java.util.OptionalLong count() {
return java.util.OptionalLong.of(snapshot.size());
@@ -328,13 +378,13 @@ final class DefaultStatusObjectServiceCrlTest {
private int index = -1;
@Override
- public boolean next() {
+ public boolean next(zeroecho.core.io.CancellationSignal cancellation) {
index++;
return index < snapshot.size();
}
@Override
- public RevocationJournal current() {
+ public RevocationRecord current() {
return snapshot.get(index);
}
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java
index db48289..f64ba02 100644
--- a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java
@@ -70,6 +70,7 @@ import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
@@ -212,9 +213,10 @@ final class StoreBackedEffectiveCredentialStatusResolverTest {
private static PkiStore store(Function> lookup, AtomicInteger calls) {
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class>[] { PkiStore.class },
(proxy, method, arguments) -> {
- if ("getRevocationJournal".equals(method.getName())) {
+ if ("getRevocation".equals(method.getName())) {
calls.incrementAndGet();
- return lookup.apply((PkiId) arguments[0]);
+ return lookup.apply((PkiId) arguments[0])
+ .map(journal -> new RevocationRecord(journal.credentialId(), journal.latest()));
}
throw new UnsupportedOperationException(method.getName());
});
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java
index 56149b9..afa5ce0 100644
--- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java
@@ -122,8 +122,7 @@ final class FilesystemPkiStoreOwnershipTest {
Files.writeString(root.resolve(FsPaths.VERSION_FILE), "unsupported", StandardCharsets.US_ASCII);
assertThrows(IllegalStateException.class, () -> new FilesystemPkiStore(root, FsPkiStoreOptions.defaults()));
- Files.writeString(root.resolve(FsPaths.VERSION_FILE), FilesystemPkiStore.CURRENT_STORE_VERSION,
- StandardCharsets.US_ASCII);
+ Files.delete(root.resolve(FsPaths.VERSION_FILE));
try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
assertTrue(reopened.listCas().isEmpty());
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
index 6550780..7a2a244 100644
--- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
@@ -45,9 +45,11 @@ import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
@@ -56,6 +58,11 @@ import java.util.List;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
@@ -68,6 +75,7 @@ import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.content.DurableContentOwner;
import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.KeyRef;
@@ -109,6 +117,7 @@ import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationJournal;
+import zeroecho.pki.api.revocation.RevocationRecord;
import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectType;
@@ -173,7 +182,7 @@ public final class FilesystemPkiStoreTest {
store.putCa(ca);
store.putCredential(credential);
store.putRequest(request);
- RevocationJournal revocation = store.transitionRevocation(new RevocationCommand.RevokePermanently(
+ RevocationRecord revocation = store.transitionRevocation(new RevocationCommand.RevokePermanently(
credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now);
store.putStatusObject(status);
store.putPublicationRecord(publication);
@@ -187,7 +196,7 @@ public final class FilesystemPkiStoreTest {
store.getCredential(credential.credentialId()).orElseThrow().credentialId());
assertEquals(request.requestId(), store.getRequest(request.requestId()).orElseThrow().requestId());
assertEquals(revocation.credentialId(),
- store.getRevocationJournal(revocation.credentialId()).orElseThrow().credentialId());
+ store.getRevocation(revocation.credentialId()).orElseThrow().credentialId());
assertEquals(status.statusObjectId(),
store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId());
assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId());
@@ -774,10 +783,13 @@ public final class FilesystemPkiStoreTest {
System.out.println("revocationJournalPersistsLegalTransitions");
Path root = tmp.resolve("store-revocation-history");
+ Path restoredRoot = tmp.resolve("store-revocation-history-snapshot");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
+ PkiId credentialId;
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
Credential credential = TestObjects.minimalCredential(store, "SERIAL-REV", "profile-rev");
+ credentialId = credential.credentialId();
store.putCredential(credential);
store.transitionRevocation(
new RevocationCommand.Hold(credential.credentialId(), TestObjects.emptyAttributes()),
@@ -786,9 +798,33 @@ public final class FilesystemPkiStoreTest {
new RevocationCommand.Unhold(credential.credentialId(), TestObjects.emptyAttributes()),
Instant.EPOCH.plusSeconds(11L));
- Optional loaded = store.getRevocationJournal(credential.credentialId());
+ Optional loaded = store.getRevocation(credential.credentialId());
assertTrue(loaded.isPresent());
- assertEquals(2L, loaded.get().latest().revision());
+ assertEquals(2L, loaded.get().transition().revision());
+ try (zeroecho.pki.spi.store.RevocationSnapshot snapshot = store.openRevocationSnapshot()) {
+ assertEquals(2L, snapshot.revision());
+ }
+ try (zeroecho.pki.spi.store.RevocationHistory history =
+ store.openRevocationHistory(credential.credentialId())) {
+ assertTrue(history.next());
+ assertEquals(1L, history.current().revision());
+ assertTrue(history.next());
+ assertEquals(2L, history.current().revision());
+ assertFalse(history.next());
+ }
+ store.exportSnapshot(restoredRoot, Instant.now());
+ }
+ try (FilesystemPkiStore restored = new FilesystemPkiStore(restoredRoot, options)) {
+ assertEquals(RevocationState.CLEAR,
+ restored.getRevocation(credentialId).orElseThrow().transition().state());
+ try (zeroecho.pki.spi.store.RevocationHistory history =
+ restored.openRevocationHistory(credentialId)) {
+ assertTrue(history.next());
+ assertTrue(history.next());
+ assertFalse(history.next());
+ }
+ assertTrue(Files.isRegularFile(new FsPaths(restoredRoot).revocationCurrentIndex()));
+ assertFalse(Files.exists(restoredRoot.resolve("revocations").resolve("by-credential")));
}
System.out.println("...store tree:");
@@ -828,6 +864,164 @@ public final class FilesystemPkiStoreTest {
System.out.println("snapshotExportClonesNewRootNonStrict...ok");
}
+ @Test
+ void snapshotRejectsTruncatedRevocationAuthorityAndObsoleteSchema() throws Exception {
+ System.out.println("snapshotRejectsTruncatedRevocationAuthorityAndObsoleteSchema");
+ Path sourceRoot = tmp.resolve("snapshot-revocation-corruption-source");
+ Path snapshotRoot = tmp.resolve("snapshot-revocation-corruption-target");
+ Path oversizedRoot = tmp.resolve("snapshot-revocation-oversized-marker");
+ Path symlinkRoot = tmp.resolve("snapshot-revocation-symlink-marker");
+ FsPkiStoreOptions options = nonStrictSnapshotOptions();
+ try (FilesystemPkiStore source = new FilesystemPkiStore(sourceRoot, options)) {
+ Credential credential = TestObjects.minimalCredential(source, "SERIAL-SNAPSHOT-CORRUPT",
+ "profile-snapshot-corrupt");
+ source.putCredential(credential);
+ source.transitionRevocation(new RevocationCommand.Hold(
+ credential.credentialId(), TestObjects.emptyAttributes()), Instant.now());
+ source.exportSnapshot(snapshotRoot, Instant.now());
+ source.exportSnapshot(oversizedRoot, Instant.now());
+ source.exportSnapshot(symlinkRoot, Instant.now());
+ }
+ Path log = new FsPaths(snapshotRoot).revocationTransitionLog();
+ try (FileChannel channel = FileChannel.open(log, StandardOpenOption.WRITE)) {
+ channel.truncate(Files.size(log) - 1L);
+ }
+ assertThrows(IllegalStateException.class,
+ () -> new FilesystemPkiStore(snapshotRoot, options));
+
+ Files.writeString(new FsPaths(oversizedRoot).revocationSnapshotBoundary(),
+ "12345678901234567890", StandardCharsets.US_ASCII);
+ assertThrows(IllegalStateException.class,
+ () -> new FilesystemPkiStore(oversizedRoot, options));
+
+ Path symlinkMarker = new FsPaths(symlinkRoot).revocationSnapshotBoundary();
+ Files.delete(symlinkMarker);
+ Files.createSymbolicLink(symlinkMarker, new FsPaths(symlinkRoot).revocationTransitionLog());
+ assertThrows(IllegalStateException.class,
+ () -> new FilesystemPkiStore(symlinkRoot, options));
+
+ Path obsolete = tmp.resolve("snapshot-obsolete-schema");
+ Files.createDirectories(obsolete);
+ Files.writeString(obsolete.resolve(FsPaths.VERSION_FILE), "v3", StandardCharsets.US_ASCII);
+ assertThrows(IllegalStateException.class,
+ () -> new FilesystemPkiStore(obsolete, options));
+ System.out.println("snapshotRejectsTruncatedRevocationAuthorityAndObsoleteSchema...ok");
+ }
+
+ @Test
+ void stableRevocationViewIsImmutableAndExactCheckpointIsReused() throws Exception {
+ System.out.println("stableRevocationViewIsImmutableAndExactCheckpointIsReused");
+ try (FilesystemPkiStore store = new FilesystemPkiStore(
+ tmp.resolve("stable-revocation-view"), FsPkiStoreOptions.defaults())) {
+ Credential credential = TestObjects.minimalCredential(store, "SERIAL-STABLE-VIEW",
+ "profile-stable-view");
+ store.putCredential(credential);
+ store.transitionRevocation(new RevocationCommand.Hold(
+ credential.credentialId(), TestObjects.emptyAttributes()), Instant.EPOCH.plusSeconds(1L));
+ try (zeroecho.pki.spi.store.RevocationSnapshot captured = store.openRevocationSnapshot()) {
+ assertEquals(1L, captured.revision());
+ store.transitionRevocation(new RevocationCommand.Unhold(
+ credential.credentialId(), TestObjects.emptyAttributes()), Instant.EPOCH.plusSeconds(2L));
+ try (zeroecho.pki.spi.store.RevocationSnapshot.Cursor cursor = captured.openCursor()) {
+ assertTrue(cursor.next());
+ assertEquals(RevocationState.HELD, cursor.current().transition().state());
+ assertFalse(cursor.next());
+ }
+ }
+ String generation;
+ try (zeroecho.pki.spi.store.RevocationSnapshot current = store.openRevocationSnapshot()) {
+ generation = current.snapshotId();
+ assertEquals(2L, current.revision());
+ }
+ try (zeroecho.pki.spi.store.RevocationSnapshot reused = store.openRevocationSnapshot()) {
+ assertEquals(generation, reused.snapshotId());
+ assertEquals(2L, reused.revision());
+ }
+ }
+ System.out.println("stableRevocationViewIsImmutableAndExactCheckpointIsReused...ok");
+ }
+
+ @Test
+ void concurrentStableViewAndStoreCloseFinishDeterministically() throws Exception {
+ System.out.println("concurrentStableViewAndStoreCloseFinishDeterministically");
+ FilesystemPkiStore store = new FilesystemPkiStore(
+ tmp.resolve("concurrent-revocation-close"), FsPkiStoreOptions.defaults());
+ zeroecho.pki.spi.store.RevocationSnapshot view = null;
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ Credential credential = TestObjects.minimalCredential(store, "SERIAL-CONCURRENT-CLOSE",
+ "profile-concurrent-close");
+ store.putCredential(credential);
+ store.transitionRevocation(new RevocationCommand.Hold(
+ credential.credentialId(), TestObjects.emptyAttributes()), Instant.now());
+ view = store.openRevocationSnapshot();
+ zeroecho.pki.spi.store.RevocationSnapshot captured = view;
+ CountDownLatch start = new CountDownLatch(1);
+ Future> viewClose = executor.submit(() -> {
+ start.await();
+ captured.close();
+ return null;
+ });
+ Future> storeClose = executor.submit(() -> {
+ start.await();
+ store.close();
+ return null;
+ });
+ start.countDown();
+ viewClose.get(5L, TimeUnit.SECONDS);
+ storeClose.get(5L, TimeUnit.SECONDS);
+ view = null;
+ } finally {
+ if (view != null) {
+ view.close();
+ }
+ store.close();
+ executor.shutdownNow();
+ }
+ System.out.println("concurrentStableViewAndStoreCloseFinishDeterministically...ok");
+ }
+
+ @Test
+ void committedTransitionSurvivesDerivedIndexUpdateFailureAndConcurrentRecovery() throws Exception {
+ System.out.println("committedTransitionSurvivesDerivedIndexUpdateFailureAndConcurrentRecovery");
+ AtomicLong faultCalls = new AtomicLong();
+ try (FilesystemPkiStore store = new FilesystemPkiStore(
+ tmp.resolve("revocation-derived-failure"), FsPkiStoreOptions.defaults(),
+ java.time.Clock.systemUTC(), () -> {
+ if (faultCalls.getAndIncrement() == 0L) {
+ throw new IOException("Injected derived revocation-index update failure");
+ }
+ })) {
+ Credential credential = TestObjects.minimalCredential(store, "SERIAL-DERIVED-FAILURE",
+ "profile-derived-failure");
+ store.putCredential(credential);
+ RevocationRecord committed = store.transitionRevocation(new RevocationCommand.Hold(
+ credential.credentialId(), TestObjects.emptyAttributes()), Instant.now());
+ assertEquals(1L, committed.transition().revision());
+
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ CountDownLatch start = new CountDownLatch(1);
+ Future> first = executor.submit(() -> {
+ start.await();
+ return store.getRevocation(credential.credentialId());
+ });
+ Future> second = executor.submit(() -> {
+ start.await();
+ return store.getRevocation(credential.credentialId());
+ });
+ start.countDown();
+ assertEquals(RevocationState.HELD,
+ first.get(5L, TimeUnit.SECONDS).orElseThrow().transition().state());
+ assertEquals(RevocationState.HELD,
+ second.get(5L, TimeUnit.SECONDS).orElseThrow().transition().state());
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+ System.out.println("committedTransitionSurvivesDerivedIndexUpdateFailureAndConcurrentRecovery...ok");
+ }
+
@Test
void snapshotExportWithMultipleObjectsNonStrictDoesNotFail() throws Exception {
System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail");
diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java
index 2a86460..fc36026 100644
--- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java
@@ -47,6 +47,7 @@ import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.Arrays;
+import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@@ -123,7 +124,7 @@ final class FilesystemRevocationJournalTest {
() -> transition(store, revoke(credential, RevocationReason.CA_COMPROMISE)));
}
try (FilesystemPkiStore reopened = store(root)) {
- RevocationJournal journal = reopened.getRevocationJournal(credential.credentialId()).orElseThrow();
+ RevocationJournal journal = readJournal(reopened, credential.credentialId()).orElseThrow();
assertEquals(4, journal.transitions().size());
assertEquals(RevocationState.PERMANENTLY_REVOKED, journal.latest().state());
assertEquals(RevocationReason.KEY_COMPROMISE, journal.latest().permanentReason().orElseThrow());
@@ -172,32 +173,31 @@ final class FilesystemRevocationJournalTest {
store.putCredential(credential);
assertCode("REVOCATION_TRANSITION_ILLEGAL", () -> transition(store, unhold(credential)));
assertThrows(IllegalArgumentException.class, () -> revoke(credential, RevocationReason.CERTIFICATE_HOLD));
- assertTrue(store.getRevocationJournal(credential.credentialId()).isEmpty());
+ assertTrue(readJournal(store, credential.credentialId()).isEmpty());
transition(store, hold(credential));
assertCode("REVOCATION_TRANSITION_ILLEGAL", () -> transition(store, hold(credential)));
- assertEquals(1, store.getRevocationJournal(credential.credentialId()).orElseThrow().transitions().size());
+ assertEquals(1, readJournal(store, credential.credentialId()).orElseThrow().transitions().size());
transition(store, unhold(credential));
- Path journalPath = new FsPaths(temporaryDirectory.resolve("illegal"))
- .revocationJournal(credential.credentialId());
- byte[] clearBytes = FsOperations.readAll(journalPath);
+ Path logPath = new FsPaths(temporaryDirectory.resolve("illegal")).revocationTransitionLog();
+ byte[] clearBytes = FsOperations.readAll(logPath);
assertCode("REVOCATION_TRANSITION_ILLEGAL", () -> transition(store, unhold(credential)));
- assertArrayEquals(clearBytes, FsOperations.readAll(journalPath));
+ assertArrayEquals(clearBytes, FsOperations.readAll(logPath));
transition(store, revoke(credential, RevocationReason.KEY_COMPROMISE));
- byte[] permanentBytes = FsOperations.readAll(journalPath);
+ byte[] permanentBytes = FsOperations.readAll(logPath);
assertCode("REVOCATION_TERMINAL", () -> transition(store, hold(credential)));
assertCode("REVOCATION_TERMINAL",
() -> transition(store, revoke(credential, RevocationReason.KEY_COMPROMISE)));
assertCode("REVOCATION_TERMINAL",
() -> transition(store, revoke(credential, RevocationReason.CA_COMPROMISE)));
- assertArrayEquals(permanentBytes, FsOperations.readAll(journalPath));
+ assertArrayEquals(permanentBytes, FsOperations.readAll(logPath));
Credential invalidRemove = credential(store, "invalid-remove");
store.putCredential(invalidRemove);
assertThrows(IllegalArgumentException.class, () -> revoke(invalidRemove, RevocationReason.REMOVE_FROM_CRL));
- assertTrue(store.getRevocationJournal(invalidRemove.credentialId()).isEmpty());
+ assertTrue(readJournal(store, invalidRemove.credentialId()).isEmpty());
}
System.out.println("illegalAndInvalidCommandsFailBeforeJournalMutation...ok");
}
@@ -217,7 +217,7 @@ final class FilesystemRevocationJournalTest {
} finally {
executor.shutdownNow();
}
- RevocationJournal journal = store.getRevocationJournal(credential.credentialId()).orElseThrow();
+ RevocationJournal journal = readJournal(store, credential.credentialId()).orElseThrow();
assertEquals(1, journal.transitions().size());
assertEquals(1L, journal.latest().revision());
}
@@ -233,7 +233,7 @@ final class FilesystemRevocationJournalTest {
Credential fromNone = credential(store, "hold-vs-permanent");
store.putCredential(fromNone);
runConcurrent(store, hold(fromNone), revoke(fromNone, RevocationReason.KEY_COMPROMISE), executor);
- RevocationJournal fromNoneJournal = store.getRevocationJournal(fromNone.credentialId()).orElseThrow();
+ RevocationJournal fromNoneJournal = readJournal(store, fromNone.credentialId()).orElseThrow();
assertEquals(RevocationState.PERMANENTLY_REVOKED, fromNoneJournal.latest().state());
assertTrue(fromNoneJournal.transitions().size() == 1 || fromNoneJournal.transitions().size() == 2);
@@ -241,7 +241,7 @@ final class FilesystemRevocationJournalTest {
store.putCredential(fromHeld);
transition(store, hold(fromHeld));
runConcurrent(store, unhold(fromHeld), revoke(fromHeld, RevocationReason.CA_COMPROMISE), executor);
- RevocationJournal fromHeldJournal = store.getRevocationJournal(fromHeld.credentialId()).orElseThrow();
+ RevocationJournal fromHeldJournal = readJournal(store, fromHeld.credentialId()).orElseThrow();
assertEquals(RevocationState.PERMANENTLY_REVOKED, fromHeldJournal.latest().state());
assertTrue(fromHeldJournal.transitions().size() == 2 || fromHeldJournal.transitions().size() == 3);
@@ -250,7 +250,7 @@ final class FilesystemRevocationJournalTest {
List results = runConcurrent(store, revoke(reasons, RevocationReason.KEY_COMPROMISE),
revoke(reasons, RevocationReason.CA_COMPROMISE), executor);
assertEquals(1L, results.stream().filter(Boolean::booleanValue).count());
- RevocationJournal reasonsJournal = store.getRevocationJournal(reasons.credentialId()).orElseThrow();
+ RevocationJournal reasonsJournal = readJournal(store, reasons.credentialId()).orElseThrow();
assertEquals(1, reasonsJournal.transitions().size());
assertTrue(reasonsJournal.latest().permanentReason().filter(
reason -> reason == RevocationReason.KEY_COMPROMISE || reason == RevocationReason.CA_COMPROMISE)
@@ -318,7 +318,8 @@ final class FilesystemRevocationJournalTest {
new SimpleAttributeSet())));
FsOperations.writeAtomic(new FsPaths(root).revocationJournal(credential.credentialId()),
FsCodec.encode(FsCodec.REVOCATION_JOURNAL, invalid));
- assertCode("REVOCATION_STATE_CORRUPT", () -> store.getRevocationJournal(credential.credentialId()));
+ assertEquals(RevocationState.HELD,
+ store.getRevocation(credential.credentialId()).orElseThrow().transition().state());
}
System.out.println("copiedNamespaceJournalAndRegressingTransitionTimeFailClosed...ok");
}
@@ -384,14 +385,14 @@ final class FilesystemRevocationJournalTest {
oldRevokedRecordPayload(credential.credentialId()));
try (FilesystemPkiStore reopened = store(root)) {
- assertTrue(reopened.getRevocationJournal(credential.credentialId()).isEmpty());
+ assertTrue(readJournal(reopened, credential.credentialId()).isEmpty());
StoreBackedEffectiveCredentialStatusResolver resolver = new StoreBackedEffectiveCredentialStatusResolver(
reopened, Clock.fixed(TIME, java.time.ZoneOffset.UTC));
assertEquals(EffectiveCredentialStatus.USABLE, resolver.beginEvaluation()
.resolve(reopened.getCredential(credential.credentialId()).orElseThrow()));
transition(reopened, hold(credential));
assertEquals(RevocationState.HELD,
- reopened.getRevocationJournal(credential.credentialId()).orElseThrow().latest().state());
+ readJournal(reopened, credential.credentialId()).orElseThrow().latest().state());
}
System.out.println("obsoleteCurrentAndHistoryLayoutCannotInfluenceTrustedJournalOrResolver...ok");
}
@@ -472,8 +473,8 @@ final class FilesystemRevocationJournalTest {
try (FilesystemPkiStore reopened = store(root)) {
byte[] before = FsOperations.readAll(journalPath);
- assertCode("REVOCATION_STATE_CORRUPT", () -> reopened.getRevocationJournal(credential.credentialId()));
- assertCode("REVOCATION_STATE_CORRUPT", () -> transition(reopened, hold(credential)));
+ assertTrue(reopened.getRevocation(credential.credentialId()).isEmpty());
+ transition(reopened, hold(credential));
assertArrayEquals(before, FsOperations.readAll(journalPath));
}
System.out.println("strictCodecCorruptionFailsReadAndTransitionWithoutOverwrite...ok");
@@ -491,7 +492,23 @@ final class FilesystemRevocationJournalTest {
}
private static RevocationJournal transition(FilesystemPkiStore store, RevocationCommand command) {
- return store.transitionRevocation(command, TIME);
+ store.transitionRevocation(command, TIME);
+ return readJournal(store, command.credentialId()).orElseThrow();
+ }
+
+ private static Optional readJournal(FilesystemPkiStore store, PkiId credentialId) {
+ if (store.getRevocation(credentialId).isEmpty()) {
+ return Optional.empty();
+ }
+ List transitions = new ArrayList<>();
+ try (zeroecho.pki.spi.store.RevocationHistory history = store.openRevocationHistory(credentialId)) {
+ while (history.next()) {
+ transitions.add(history.current());
+ }
+ } catch (IOException failure) {
+ throw new IllegalStateException(failure);
+ }
+ return Optional.of(new RevocationJournal(credentialId, transitions));
}
private static RevocationCommand.Hold hold(Credential credential) {
diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java
index fbff6d1..f765f9b 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java
@@ -278,7 +278,17 @@ public final class PkiTestRuntime implements AutoCloseable {
for (Map.Entry entry : keyPairs.entrySet()) {
publicKeys.put(entry.getKey(), entry.getValue().getPublic());
}
- return create(rootDir, busFile, keyPairs, publicKeys, Optional.empty());
+ return create(rootDir, busFile, keyPairs, publicKeys, Optional.empty(), FsPkiStoreOptions.defaults());
+ }
+
+ /** Creates a test runtime with explicit filesystem-store options. */
+ public static PkiTestRuntime create(Path rootDir, Path busFile, Map keyPairs,
+ FsPkiStoreOptions options) {
+ Map publicKeys = new HashMap<>();
+ for (Map.Entry entry : keyPairs.entrySet()) {
+ publicKeys.put(entry.getKey(), entry.getValue().getPublic());
+ }
+ return create(rootDir, busFile, keyPairs, publicKeys, Optional.empty(), options);
}
/**
@@ -294,19 +304,19 @@ public final class PkiTestRuntime implements AutoCloseable {
*/
public static PkiTestRuntime create(Path rootDir, Path busFile, Map signingKeys,
Map resolvedKeys, ProofOfPossessionVerifier proofVerifier) {
- return create(rootDir, busFile, signingKeys, resolvedKeys, Optional.of(proofVerifier));
+ return create(rootDir, busFile, signingKeys, resolvedKeys, Optional.of(proofVerifier),
+ FsPkiStoreOptions.defaults());
}
private static PkiTestRuntime create(Path rootDir, Path busFile, Map keyPairs,
- Map resolvedKeys, Optional proofVerifier) {
+ Map resolvedKeys, Optional proofVerifier,
+ FsPkiStoreOptions opts) {
Objects.requireNonNull(rootDir, "rootDir");
Objects.requireNonNull(busFile, "busFile");
Objects.requireNonNull(keyPairs, "keyPairs");
Objects.requireNonNull(resolvedKeys, "resolvedKeys");
Objects.requireNonNull(proofVerifier, "proofVerifier");
- FsPkiStoreOptions opts = FsPkiStoreOptions.defaults();
-
Path storeRoot = rootDir.resolve("store");
FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, opts);