feat(pki): add durable publication lifecycle

Add durable post-commit publication registration, dispatch, retry and
reconciliation over transactional metadata.

Keep credentials and status objects authoritative independently of
publication outcomes while safely preserving unknown external results.
This commit is contained in:
2026-08-03 02:26:42 +02:00
parent b3a6e29cc0
commit d5d5bf7a96
27 changed files with 2161 additions and 232 deletions

View File

@@ -33,66 +33,85 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api; package zeroecho.pki.api;
import java.util.List; import java.util.Optional;
import zeroecho.pki.api.publication.PublicationCursor;
import zeroecho.pki.api.publication.PublicationQuery; import zeroecho.pki.api.publication.PublicationQuery;
import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.publication.PublicationRequest;
import zeroecho.pki.api.publication.PublicationResult; import zeroecho.pki.api.publication.PublicationResult;
import zeroecho.pki.api.publication.PublicationTarget;
/** /**
* Publication and distribution operations. * Publication and distribution operations.
* *
* <p> * <p>
* Publishing is an explicit operation enabling parity with established PKI * Publishing is an explicit operation enabling parity with established PKI
* systems. Implementations may publish credentials, CA materials, and status * systems. Implementations publish committed credential and status-object
* objects to configured targets such as filesystem mirrors, LDAP directories, * content to administrator-configured targets such as filesystem mirrors, LDAP
* HTTP endpoints, or object stores. * directories, HTTP endpoints, or object stores. Publication never establishes
* source-object authority.
* </p> * </p>
*/ */
public interface PublicationService { public interface PublicationService extends AutoCloseable {
/** /**
* Publishes an issued credential to the specified target. * Registers one publication operation after its source object is committed.
* *
* @param credentialId credential id * @param request immutable registration request
* @param target publication target * @return committed pending record, or the identical existing operation
* @return publication result * @throws IllegalArgumentException if the request is invalid or conflicting
* @throws IllegalArgumentException if inputs are invalid * @throws PkiException if registration cannot be established durably
* @throws PkiException if publication fails
*/ */
PublicationResult publishCredential(PkiId credentialId, PublicationTarget target); PublicationRecord register(PublicationRequest request);
/** /**
* Publishes CA materials (e.g., CA certificate sets) for the given CA entity to * Processes one pending operation synchronously.
* the specified target.
* *
* @param caId CA entity id * @param publicationId operation identity
* @param target publication target * @return durably classified result
* @return publication result * @throws IllegalArgumentException if the operation is absent or ineligible
* @throws IllegalArgumentException if inputs are invalid * @throws PkiException if persistence or content validation fails
* @throws PkiException if publication fails
*/ */
PublicationResult publishCaMaterials(PkiId caId, PublicationTarget target); PublicationResult process(PkiId publicationId);
/** /**
* Publishes a status object to the specified target. * Explicitly retries one retryable operation as a new numbered attempt.
* *
* @param statusObjectId status object id * @param publicationId operation identity
* @param target publication target * @return durably classified result
* @return publication result * @throws IllegalArgumentException if the operation is not retryable
* @throws IllegalArgumentException if inputs are invalid * @throws PkiException if persistence or content validation fails
* @throws PkiException if publication fails
*/ */
PublicationResult publishStatusObject(PkiId statusObjectId, PublicationTarget target); PublicationResult retry(PkiId publicationId);
/** /**
* Lists publication records matching query constraints. * Reconciles one unknown external outcome using the original attempt token.
* *
* @param query publication query * @param publicationId operation identity
* @return publication records * @return current durable result; unresolved operations remain unknown
* @throws IllegalArgumentException if {@code query} is invalid * @throws IllegalArgumentException if the operation is not unknown
* @throws PkiException if listing fails * @throws PkiException if reconciliation persistence fails
*/ */
List<PublicationRecord> listPublications(PublicationQuery query); PublicationResult reconcile(PkiId publicationId);
/**
* Reads one durable publication operation.
*
* @param publicationId operation identity
* @return current record, or empty when absent
*/
Optional<PublicationRecord> find(PkiId publicationId);
/**
* Opens a stable lazy query over publication operations.
*
* @param query finite query constraints
* @return closeable cursor
* @throws PkiException if the stable view cannot be opened
*/
PublicationCursor openPublications(PublicationQuery query);
/** Rejects new operations while allowing an in-flight synchronous call to classify its result. */
@Override
void close();
} }

View File

@@ -0,0 +1,30 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.api.publication;
import java.io.IOException;
import java.util.Optional;
import zeroecho.core.io.CancellationSignal;
/**
* Closeable snapshot-consistent cursor over publication records.
*
* <p>Records are returned in canonical publication-identity order. Cursor heap
* does not grow with the number of returned records.</p>
*/
public interface PublicationCursor extends AutoCloseable {
/**
* Advances by at most one matching record.
*
* @param cancellation cooperative cancellation signal
* @return next record, or empty at end of input
* @throws IOException if the stable metadata view cannot be read
*/
Optional<PublicationRecord> next(CancellationSignal cancellation) throws IOException;
/** Closes the underlying stable metadata view idempotently. */
@Override
void close();
}

View File

@@ -0,0 +1,13 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.api.publication;
/** Identifies how an exact attempt outcome was established. */
public enum PublicationEvidence {
/** The publisher returned a definitive result from direct dispatch. */
DIRECT_RESPONSE,
/** The publisher resolved the existing attempt through reconciliation. */
RECONCILIATION_RESPONSE
}

View File

@@ -0,0 +1,19 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.api.publication;
/** Safe closed classifications for publication-operation failures. */
public enum PublicationFailure {
/** The publisher authoritatively classified the attempt as retryable. */
EXTERNAL_RETRYABLE,
/** The publisher authoritatively classified the attempt as terminal. */
EXTERNAL_TERMINAL,
/** The publisher call did not establish whether an external effect occurred. */
EXTERNAL_OUTCOME_UNKNOWN,
/** The exact immutable source content could not be validated. */
CONTENT_INVALID,
/** Durable persistence of a returned result could not be established. */
RESULT_PERSISTENCE_UNKNOWN
}

View File

@@ -42,10 +42,10 @@ import java.util.Optional;
* @param targetType optional target type filter * @param targetType optional target type filter
* @param after optional lower bound for time * @param after optional lower bound for time
* @param before optional upper bound for time * @param before optional upper bound for time
* @param objectKind optional object kind filter * @param sourceType optional source-object type filter
*/ */
public record PublicationQuery(Optional<PublicationTargetType> targetType, Optional<Instant> after, public record PublicationQuery(Optional<PublicationTargetType> targetType, Optional<Instant> after,
Optional<Instant> before, Optional<String> objectKind) { Optional<Instant> before, Optional<PublicationSourceType> sourceType) {
/** /**
* Creates a publication query. * Creates a publication query.
@@ -53,8 +53,11 @@ public record PublicationQuery(Optional<PublicationTargetType> targetType, Optio
* @throws IllegalArgumentException if any optional container is null * @throws IllegalArgumentException if any optional container is null
*/ */
public PublicationQuery { public PublicationQuery {
if (targetType == null || after == null || before == null || objectKind == null) { if (targetType == null || after == null || before == null || sourceType == null) {
throw new IllegalArgumentException("optional fields must not be null"); throw new IllegalArgumentException("optional fields must not be null");
} }
if (after.isPresent() && before.isPresent() && after.orElseThrow().isAfter(before.orElseThrow())) {
throw new IllegalArgumentException("after must not be later than before");
}
} }
} }

View File

@@ -34,11 +34,14 @@
package zeroecho.pki.api.publication; package zeroecho.pki.api.publication;
import java.time.Instant; import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentReference;
/** /**
* Persisted record of a publication attempt. * Persisted current state of one publication operation.
* *
* <p> * <p>
* Publication records support operational troubleshooting, auditability, and * Publication records support operational troubleshooting, auditability, and
@@ -46,16 +49,22 @@ import zeroecho.pki.api.PkiId;
* </p> * </p>
* *
* @param publicationId publication id * @param publicationId publication id
* @param time time when publication was attempted * @param sourceType authoritative source-object type
* @param target publication target * @param sourceId authoritative source-object identity
* @param objectId published object id (credential, CA materials, status * @param content exact immutable source content
* object) * @param target configured destination identity
* @param objectKind non-empty logical kind string (e.g., "CREDENTIAL", * @param status durable lifecycle state
* "CA_MATERIALS", "STATUS_OBJECT") * @param attemptNumber non-negative monotonic attempt number
* @param status publication outcome * @param attemptToken stable token for the current attempt, when one exists
* @param createdAt registration time
* @param updatedAt last durable state-change time
* @param failure safe failure classification
* @param evidence safe outcome-evidence classification
*/ */
public record PublicationRecord(PkiId publicationId, Instant time, PublicationTarget target, PkiId objectId, public record PublicationRecord(PkiId publicationId, PublicationSourceType sourceType, PkiId sourceId,
String objectKind, PublicationStatus status) { DurableContentReference content, PublicationTarget target, PublicationStatus status,
long attemptNumber, Optional<String> attemptToken, Instant createdAt, Instant updatedAt,
Optional<PublicationFailure> failure, Optional<PublicationEvidence> evidence) {
/** /**
* Creates a publication record. * Creates a publication record.
@@ -63,23 +72,67 @@ public record PublicationRecord(PkiId publicationId, Instant time, PublicationTa
* @throws IllegalArgumentException if inputs are invalid * @throws IllegalArgumentException if inputs are invalid
*/ */
public PublicationRecord { public PublicationRecord {
if (publicationId == null) { Objects.requireNonNull(publicationId, "publicationId");
throw new IllegalArgumentException("publicationId must not be null"); Objects.requireNonNull(sourceType, "sourceType");
Objects.requireNonNull(sourceId, "sourceId");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(status, "status");
Objects.requireNonNull(attemptToken, "attemptToken");
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(updatedAt, "updatedAt");
Objects.requireNonNull(failure, "failure");
Objects.requireNonNull(evidence, "evidence");
if (attemptNumber < 0L || updatedAt.isBefore(createdAt)) {
throw new IllegalArgumentException("Publication revision or time is invalid");
} }
if (time == null) { attemptToken.ifPresent(PublicationRecord::requireAttemptToken);
throw new IllegalArgumentException("time must not be null"); validateState(status, attemptNumber, attemptToken, failure, evidence);
}
/**
* Tests whether another record describes the same immutable operation.
*
* @param other candidate record
* @return {@code true} when all immutable fields match exactly
*/
public boolean hasSameOperation(PublicationRecord other) {
return other != null && publicationId.equals(other.publicationId)
&& sourceType == other.sourceType && sourceId.equals(other.sourceId)
&& sameContent(content, other.content) && target.equals(other.target)
&& createdAt.equals(other.createdAt);
}
private static boolean sameContent(DurableContentReference first, DurableContentReference second) {
return first.storeId().equals(second.storeId()) && first.contentId().equals(second.contentId())
&& first.encoding() == second.encoding() && first.length() == second.length()
&& first.sha256().equals(second.sha256()) && first.lifecycle() == second.lifecycle();
}
private static void requireAttemptToken(String token) {
if (!token.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Publication attempt token is not canonical");
} }
if (target == null) { }
throw new IllegalArgumentException("target must not be null");
private static void validateState(PublicationStatus status, long attemptNumber, Optional<String> token,
Optional<PublicationFailure> failure, Optional<PublicationEvidence> evidence) {
boolean hasAttempt = attemptNumber > 0L && token.isPresent();
switch (status) {
case PENDING -> requireState(token.isEmpty() && failure.isEmpty() && evidence.isEmpty());
case DISPATCH_PREPARED -> requireState(hasAttempt && failure.isEmpty() && evidence.isEmpty());
case SUCCEEDED -> requireState(hasAttempt && failure.isEmpty() && evidence.isPresent());
case RETRYABLE_FAILURE -> requireState(hasAttempt
&& failure.filter(value -> value == PublicationFailure.EXTERNAL_RETRYABLE).isPresent()
&& evidence.isPresent());
case TERMINAL_FAILURE -> requireState(hasAttempt && failure.isPresent());
case OUTCOME_UNKNOWN -> requireState(hasAttempt && failure.isPresent() && evidence.isEmpty());
} }
if (objectId == null) { }
throw new IllegalArgumentException("objectId must not be null");
} private static void requireState(boolean valid) {
if (objectKind == null || objectKind.isBlank()) { if (!valid) {
throw new IllegalArgumentException("objectKind must not be null/blank"); throw new IllegalArgumentException("Publication record state is inconsistent");
}
if (status == null) {
throw new IllegalArgumentException("status must not be null");
} }
} }
} }

View File

@@ -0,0 +1,27 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.api.publication;
import java.util.Objects;
import zeroecho.pki.api.PkiId;
/**
* Immutable request to register distribution of one already committed PKI object.
*
* @param publicationId caller-selected stable operation identity
* @param sourceType authoritative source-object type
* @param sourceId exact authoritative source-object identity
* @param target configured destination identity
*/
public record PublicationRequest(PkiId publicationId, PublicationSourceType sourceType,
PkiId sourceId, PublicationTarget target) {
/** Creates a validated publication request. */
public PublicationRequest {
Objects.requireNonNull(publicationId, "publicationId");
Objects.requireNonNull(sourceType, "sourceType");
Objects.requireNonNull(sourceId, "sourceId");
Objects.requireNonNull(target, "target");
}
}

View File

@@ -33,8 +33,6 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api.publication; package zeroecho.pki.api.publication;
import java.util.List;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
/** /**
@@ -42,9 +40,10 @@ import zeroecho.pki.api.PkiId;
* *
* @param publicationId publication record id * @param publicationId publication record id
* @param status outcome status * @param status outcome status
* @param notes non-sensitive operator-readable notes * @param attemptNumber current monotonic attempt number
*/ */
public record PublicationResult(PkiId publicationId, PublicationStatus status, List<String> notes) { public record PublicationResult(PkiId publicationId, PublicationStatus status, long attemptNumber) {
private static final long INITIAL_ATTEMPT = 0L;
/** /**
* Creates a publication result. * Creates a publication result.
@@ -58,8 +57,8 @@ public record PublicationResult(PkiId publicationId, PublicationStatus status, L
if (status == null) { if (status == null) {
throw new IllegalArgumentException("status must not be null"); throw new IllegalArgumentException("status must not be null");
} }
if (notes == null) { if (attemptNumber < INITIAL_ATTEMPT) {
throw new IllegalArgumentException("notes must not be null"); throw new IllegalArgumentException("attemptNumber must not be negative");
} }
} }
} }

View File

@@ -0,0 +1,13 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.api.publication;
/** Identifies the authoritative PKI object supplying publication content. */
public enum PublicationSourceType {
/** An immutable committed credential. */
CREDENTIAL,
/** An immutable committed status object, such as a CRL or OCSP response. */
STATUS_OBJECT
}

View File

@@ -34,23 +34,31 @@
package zeroecho.pki.api.publication; package zeroecho.pki.api.publication;
/** /**
* Publication outcome status. * Durable publication-operation state.
*/ */
public enum PublicationStatus { public enum PublicationStatus {
/** /**
* Artifact has been published successfully. * The operation is registered and eligible for explicit processing.
*/ */
PUBLISHED, PENDING,
/** /**
* Publication was skipped (e.g., already published, policy decision, target not * One exact attempt is durably prepared but has no classified outcome.
* applicable).
*/ */
SKIPPED, DISPATCH_PREPARED,
/** /**
* Publication failed. * The exact attempted operation has a confirmed successful outcome.
*/ */
FAILED SUCCEEDED,
/** The attempt definitively failed and an explicit retry is permitted. */
RETRYABLE_FAILURE,
/** The operation definitively failed and is terminal. */
TERMINAL_FAILURE,
/** The external effect is unknown and requires explicit reconciliation. */
OUTCOME_UNKNOWN
} }

View File

@@ -33,22 +33,21 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api.publication; package zeroecho.pki.api.publication;
import zeroecho.pki.api.attr.AttributeSet;
/** /**
* Describes where and how to publish an artifact. * Describes where and how to publish an artifact.
* *
* <p> * <p>
* The {@code targetId} identifies a configured target instance. Additional * The {@code targetId} identifies one administrator-configured publisher.
* configuration is carried in {@code attributes}. Secrets must not be carried * Provider configuration and secrets are resolved by that publisher and are
* in attributes intended for publication. * never carried by this value.
* </p> * </p>
* *
* @param type destination type * @param type destination type
* @param targetId target identifier (implementation-defined) * @param targetId target identifier (implementation-defined)
* @param attributes target configuration/hints (may be empty but not null)
*/ */
public record PublicationTarget(PublicationTargetType type, String targetId, AttributeSet attributes) { public record PublicationTarget(PublicationTargetType type, String targetId) {
private static final int MAXIMUM_TARGET_ID_CHARACTERS = 128;
/** /**
* Creates a publication target. * Creates a publication target.
@@ -59,11 +58,9 @@ public record PublicationTarget(PublicationTargetType type, String targetId, Att
if (type == null) { if (type == null) {
throw new IllegalArgumentException("type must not be null"); throw new IllegalArgumentException("type must not be null");
} }
if (targetId == null || targetId.isBlank()) { if (targetId == null || targetId.length() > MAXIMUM_TARGET_ID_CHARACTERS
throw new IllegalArgumentException("targetId must not be null/blank"); || !targetId.matches("[A-Za-z0-9][A-Za-z0-9._-]*")) {
} throw new IllegalArgumentException("targetId must be a canonical configured identity");
if (attributes == null) {
throw new IllegalArgumentException("attributes must not be null");
} }
} }
} }

View File

@@ -35,16 +35,18 @@
* Publication domain model. * Publication domain model.
* *
* <p> * <p>
* This package defines publication targets and records describing how PKI * This package defines the durable post-commit lifecycle for distributing exact
* artifacts are distributed to relying parties or infrastructure components * immutable credential and status-object content. Publication records are finite
* (repositories, directories, endpoints, etc.). Publication is orchestrated * operational control metadata; they never establish or invalidate PKI authority.
* through {@link zeroecho.pki.api.PublicationService}. * Processing, retry, and reconciliation are explicit through
* {@link zeroecho.pki.api.PublicationService}.
* </p> * </p>
* *
* <h2>Artifacts</h2> * <h2>Artifacts</h2>
* <p> * <p>
* Publication may include certificates, chains, status objects, and related * Payload bytes remain in the staged-content store and are streamed to one
* metadata. The concrete transport is framework- and deployment-specific. * configured destination. Unknown external outcomes require reconciliation and
* are never automatically retried.
* </p> * </p>
* *
* @since 1.0 * @since 1.0

View File

@@ -0,0 +1,445 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.impl.core;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.util.Collection;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.PublicationService;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.publication.PublicationCursor;
import zeroecho.pki.api.publication.PublicationEvidence;
import zeroecho.pki.api.publication.PublicationFailure;
import zeroecho.pki.api.publication.PublicationQuery;
import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.publication.PublicationRequest;
import zeroecho.pki.api.publication.PublicationResult;
import zeroecho.pki.api.publication.PublicationSourceType;
import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.spi.publish.PublicationAttempt;
import zeroecho.pki.spi.publish.PublicationOutcome;
import zeroecho.pki.spi.publish.Publisher;
import zeroecho.pki.spi.store.PkiStore;
/**
* Durable explicit post-commit publication lifecycle.
*
* <p>The service is thread-safe. Each external call is fenced by a durable
* compare-and-replace transition and occurs without a metadata transaction or
* store lock. Operations for unrelated identities progress independently. The
* service creates no threads and performs no automatic dispatch or retry.</p>
*/
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.AvoidCatchingGenericException",
"PMD.PreserveStackTrace", "PMD.ExceptionAsFlowControl" })
public final class DefaultPublicationService implements PublicationService {
private final PkiStore store;
private final Clock clock;
private final Map<PublicationTarget, Publisher> publishers;
private final AtomicBoolean closed = new AtomicBoolean();
/**
* Opens a publication lifecycle over one store and immutable publisher registry.
*
* <p>Opening validates a stable publication snapshot and all configured target
* bindings. The supplied store must already have performed its exclusive-open
* recovery of abandoned dispatch-prepared attempts. Construction never calls
* an external publisher.</p>
*
* @param store authoritative PKI store
* @param clock operational state-change clock
* @param publishers configured publisher instances with unique targets
* @throws IllegalArgumentException if a publisher target is duplicated
* @throws PkiException if durable publication recovery fails closed
*/
public DefaultPublicationService(PkiStore store, Clock clock, Collection<? extends Publisher> publishers) {
this.store = Objects.requireNonNull(store, "store");
this.clock = Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(publishers, "publishers");
Map<PublicationTarget, Publisher> configured = new HashMap<>();
for (Publisher publisher : publishers) {
Publisher exact = Objects.requireNonNull(publisher, "publisher");
PublicationTarget target;
try {
target = Objects.requireNonNull(exact.target(), "publisher.target");
} catch (RuntimeException failure) {
throw new IllegalArgumentException("Publication publisher configuration is invalid");
}
if (configured.putIfAbsent(target, exact) != null) {
throw new IllegalArgumentException("Publication target is configured more than once");
}
}
this.publishers = Map.copyOf(configured);
validateConfiguredOperations();
}
@Override
public PublicationRecord register(PublicationRequest request) {
requireOpen();
Objects.requireNonNull(request, "request");
requirePublisher(request.target());
DurableContentReference content = resolveSourceContent(request.sourceType(), request.sourceId());
validateContent(content);
Optional<PublicationRecord> existing = store.getPublicationRecord(request.publicationId());
if (existing.isPresent()) {
PublicationRecord current = existing.orElseThrow();
if (current.sourceType() == request.sourceType() && current.sourceId().equals(request.sourceId())
&& current.target().equals(request.target()) && sameContent(current.content(), content)) {
return current;
}
throw new IllegalArgumentException("Publication identity conflicts with an existing operation");
}
Instant now = clock.instant();
PublicationRecord pending = new PublicationRecord(request.publicationId(), request.sourceType(),
request.sourceId(), content, request.target(), PublicationStatus.PENDING, 0L, Optional.empty(),
now, now, Optional.empty(), Optional.empty());
return store.createPublicationRecord(pending);
}
@Override
public PublicationResult process(PkiId publicationId) {
requireOpen();
PublicationRecord pending = requireRecord(publicationId);
if (pending.status() != PublicationStatus.PENDING) {
throw new IllegalArgumentException("Publication operation is not pending");
}
long attemptNumber;
try {
attemptNumber = Math.addExact(pending.attemptNumber(), 1L);
} catch (ArithmeticException overflow) {
throw new PkiException("Publication attempt counter is exhausted: code=PUBLICATION_ATTEMPT_EXHAUSTED");
}
String token = attemptToken(pending, attemptNumber);
PublicationRecord prepared = copy(pending, PublicationStatus.DISPATCH_PREPARED, attemptNumber,
Optional.of(token), Optional.empty(), Optional.empty());
if (!store.replacePublicationRecord(pending, prepared)) {
throw new IllegalStateException("Publication operation changed concurrently");
}
return dispatchPrepared(prepared, PublicationEvidence.DIRECT_RESPONSE, false);
}
@Override
public PublicationResult retry(PkiId publicationId) {
requireOpen();
PublicationRecord failed = requireRecord(publicationId);
if (failed.status() != PublicationStatus.RETRYABLE_FAILURE) {
throw new IllegalArgumentException("Publication operation is not retryable");
}
PublicationRecord pending = copy(failed, PublicationStatus.PENDING, failed.attemptNumber(),
Optional.empty(), Optional.empty(), Optional.empty());
if (!store.replacePublicationRecord(failed, pending)) {
throw new IllegalStateException("Publication operation changed concurrently");
}
return process(publicationId);
}
@Override
public PublicationResult reconcile(PkiId publicationId) {
requireOpen();
PublicationRecord unknown = requireRecord(publicationId);
if (unknown.status() != PublicationStatus.OUTCOME_UNKNOWN) {
throw new IllegalArgumentException("Publication operation does not require reconciliation");
}
PublicationRecord prepared = copy(unknown, PublicationStatus.DISPATCH_PREPARED, unknown.attemptNumber(),
unknown.attemptToken(), Optional.empty(), Optional.empty());
if (!store.replacePublicationRecord(unknown, prepared)) {
throw new IllegalStateException("Publication operation changed concurrently");
}
return dispatchPrepared(prepared, PublicationEvidence.RECONCILIATION_RESPONSE, true);
}
@Override
public Optional<PublicationRecord> find(PkiId publicationId) {
requireOpen();
return store.getPublicationRecord(Objects.requireNonNull(publicationId, "publicationId"));
}
@Override
public PublicationCursor openPublications(PublicationQuery query) {
requireOpen();
return new FilteringCursor(store.openPublicationRecords(), Objects.requireNonNull(query, "query"));
}
@Override
public void close() {
closed.set(true);
}
private PublicationResult dispatchPrepared(PublicationRecord prepared, PublicationEvidence evidence,
boolean reconciliation) {
Publisher publisher = requirePublisher(prepared.target());
PublicationAttempt attempt = attempt(prepared);
if (!reconciliation) {
try {
validateContent(prepared.content());
} catch (PkiException failure) {
PublicationRecord terminal = copy(prepared, PublicationStatus.TERMINAL_FAILURE,
prepared.attemptNumber(), prepared.attemptToken(),
Optional.of(PublicationFailure.CONTENT_INVALID), Optional.empty());
return persistResult(prepared, terminal);
}
}
Optional<PublicationOutcome> outcome;
if (reconciliation) {
outcome = reconcileExternal(publisher, attempt);
if (outcome.isEmpty()) {
PublicationRecord unresolved = copy(prepared, PublicationStatus.OUTCOME_UNKNOWN,
prepared.attemptNumber(), prepared.attemptToken(),
Optional.of(PublicationFailure.EXTERNAL_OUTCOME_UNKNOWN), Optional.empty());
return persistResult(prepared, unresolved);
}
} else {
outcome = dispatchExternal(publisher, attempt, prepared);
if (outcome.isEmpty()) {
PublicationRecord unknown = copy(prepared, PublicationStatus.OUTCOME_UNKNOWN,
prepared.attemptNumber(), prepared.attemptToken(),
Optional.of(PublicationFailure.EXTERNAL_OUTCOME_UNKNOWN), Optional.empty());
return persistResult(prepared, unknown);
}
}
PublicationRecord classified = classified(prepared, outcome.orElseThrow(), evidence);
return persistResult(prepared, classified);
}
private Optional<PublicationOutcome> dispatchExternal(Publisher publisher, PublicationAttempt attempt,
PublicationRecord prepared) {
try (RepeatableContent payload = store.stagedContent().openContent(prepared.content())) {
return Optional.of(Objects.requireNonNull(publisher.publish(attempt, payload), "publisher outcome"));
} catch (IOException | RuntimeException failure) {
return Optional.empty();
}
}
private static Optional<PublicationOutcome> reconcileExternal(Publisher publisher, PublicationAttempt attempt) {
try {
return Objects.requireNonNull(publisher.reconcile(attempt), "publisher reconciliation outcome");
} catch (RuntimeException failure) {
return Optional.empty();
}
}
private PublicationResult persistResult(PublicationRecord prepared, PublicationRecord classified) {
try {
if (store.replacePublicationRecord(prepared, classified)) {
return result(classified);
}
PublicationRecord observed = requireRecord(prepared.publicationId());
if (observed.status() != PublicationStatus.DISPATCH_PREPARED) {
return result(observed);
}
} catch (RuntimeException persistenceFailure) {
Optional<PublicationRecord> observed = safeFind(prepared.publicationId());
if (observed.isPresent() && observed.orElseThrow().status() != PublicationStatus.DISPATCH_PREPARED) {
return result(observed.orElseThrow());
}
}
PublicationRecord unknown = copy(prepared, PublicationStatus.OUTCOME_UNKNOWN, prepared.attemptNumber(),
prepared.attemptToken(), Optional.of(PublicationFailure.RESULT_PERSISTENCE_UNKNOWN), Optional.empty());
try {
store.replacePublicationRecord(prepared, unknown);
} catch (RuntimeException ignored) {
// The durable PREPARED fence remains non-dispatchable and is converted
// to OUTCOME_UNKNOWN during the next service recovery.
}
throw new PkiException(
"Publication result persistence requires reconciliation: code=PUBLICATION_RESULT_UNCONFIRMED");
}
private Optional<PublicationRecord> safeFind(PkiId publicationId) {
try {
return store.getPublicationRecord(publicationId);
} catch (RuntimeException ignored) {
return Optional.empty();
}
}
private void validateConfiguredOperations() {
try (PublicationCursor cursor = store.openPublicationRecords()) {
Optional<PublicationRecord> next;
while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) {
PublicationRecord record = next.orElseThrow();
requirePublisher(record.target());
}
} catch (IOException | RuntimeException failure) {
throw publicationRecoveryFailure();
}
}
private DurableContentReference resolveSourceContent(PublicationSourceType type, PkiId sourceId) {
Objects.requireNonNull(type, "sourceType");
Objects.requireNonNull(sourceId, "sourceId");
return switch (type) {
case CREDENTIAL -> store.getCredential(sourceId).map(Credential::content)
.orElseThrow(() -> new IllegalArgumentException("Publication credential is not committed"));
case STATUS_OBJECT -> store.getStatusObject(sourceId).map(StatusObject::content)
.orElseThrow(() -> new IllegalArgumentException("Publication status object is not committed"));
};
}
private void validateContent(DurableContentReference reference) {
try {
DurableContentReference exact = store.stagedContent().restoreReference(reference.storeId(),
reference.contentId(), reference.encoding(), reference.length(), reference.sha256(),
reference.lifecycle());
if (!sameContent(reference, exact)) {
throw new IOException("Publication content commitment mismatch");
}
try (RepeatableContent content = store.stagedContent().openContent(exact);
java.io.InputStream ignored = content.openStream()) {
// Opening the store-issued stream performs the complete immutable
// integrity check before any publisher callback can run.
}
} catch (IOException | RuntimeException failure) {
throw new PkiException("Publication content is invalid: code=PUBLICATION_CONTENT_INVALID");
}
}
private Publisher requirePublisher(PublicationTarget target) {
Publisher publisher = publishers.get(Objects.requireNonNull(target, "target"));
if (publisher == null) {
throw new IllegalArgumentException("Publication target is not configured");
}
return publisher;
}
private PublicationRecord requireRecord(PkiId publicationId) {
return store.getPublicationRecord(Objects.requireNonNull(publicationId, "publicationId"))
.orElseThrow(() -> new IllegalArgumentException("Publication operation does not exist"));
}
private PublicationRecord copy(PublicationRecord current, PublicationStatus status, long attemptNumber,
Optional<String> token, Optional<PublicationFailure> failure, Optional<PublicationEvidence> evidence) {
Instant updatedAt = clock.instant();
if (updatedAt.isBefore(current.updatedAt())) {
updatedAt = current.updatedAt();
}
return new PublicationRecord(current.publicationId(), current.sourceType(), current.sourceId(),
current.content(), current.target(), status, attemptNumber, token, current.createdAt(), updatedAt,
failure, evidence);
}
private PublicationRecord classified(PublicationRecord prepared, PublicationOutcome outcome,
PublicationEvidence evidence) {
return switch (outcome) {
case SUCCESS -> copy(prepared, PublicationStatus.SUCCEEDED, prepared.attemptNumber(),
prepared.attemptToken(), Optional.empty(), Optional.of(evidence));
case RETRYABLE_FAILURE -> copy(prepared, PublicationStatus.RETRYABLE_FAILURE, prepared.attemptNumber(),
prepared.attemptToken(), Optional.of(PublicationFailure.EXTERNAL_RETRYABLE),
Optional.of(evidence));
case TERMINAL_FAILURE -> copy(prepared, PublicationStatus.TERMINAL_FAILURE, prepared.attemptNumber(),
prepared.attemptToken(), Optional.of(PublicationFailure.EXTERNAL_TERMINAL),
Optional.of(evidence));
};
}
private static PublicationAttempt attempt(PublicationRecord prepared) {
DurableContentReference content = prepared.content();
return new PublicationAttempt(prepared.publicationId(), prepared.sourceType(), prepared.sourceId(),
prepared.target(), prepared.attemptNumber(), prepared.attemptToken().orElseThrow(),
content.encoding(), content.length(), content.sha256());
}
private static PublicationResult result(PublicationRecord record) {
return new PublicationResult(record.publicationId(), record.status(), record.attemptNumber());
}
private static String attemptToken(PublicationRecord record, long attemptNumber) {
MessageDigest digest = sha256();
update(digest, record.publicationId().value());
update(digest, record.sourceType().name());
update(digest, record.sourceId().value());
update(digest, record.target().type().name());
update(digest, record.target().targetId());
update(digest, record.content().encoding().name());
update(digest, record.content().sha256());
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(record.content().length()).array());
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(attemptNumber).array());
return HexFormat.of().formatHex(digest.digest());
}
private static void update(MessageDigest digest, String value) {
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array());
digest.update(encoded);
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private static boolean sameContent(DurableContentReference first, DurableContentReference second) {
return first.storeId().equals(second.storeId()) && first.contentId().equals(second.contentId())
&& first.encoding() == second.encoding() && first.length() == second.length()
&& first.sha256().equals(second.sha256()) && first.lifecycle() == second.lifecycle();
}
private void requireOpen() {
if (closed.get()) {
throw new IllegalStateException("Publication service is closed");
}
}
private static PkiException publicationRecoveryFailure() {
return new PkiException("Publication recovery failed: code=PUBLICATION_RECOVERY_FAILED");
}
/** Lazy query wrapper retaining the stable store cursor. */
private static final class FilteringCursor implements PublicationCursor {
private final PublicationCursor delegate;
private final PublicationQuery query;
private FilteringCursor(PublicationCursor delegate, PublicationQuery query) {
this.delegate = delegate;
this.query = query;
}
@Override
public Optional<PublicationRecord> next(CancellationSignal cancellation) throws IOException {
Optional<PublicationRecord> next;
while ((next = delegate.next(cancellation)).isPresent()) {
PublicationRecord record = next.orElseThrow();
boolean targetMatch = query.targetType().isEmpty()
|| query.targetType().orElseThrow() == record.target().type();
boolean afterMatch = query.after().isEmpty()
|| !record.createdAt().isBefore(query.after().orElseThrow());
boolean beforeMatch = query.before().isEmpty()
|| !record.createdAt().isAfter(query.before().orElseThrow());
boolean sourceMatch = query.sourceType().isEmpty()
|| query.sourceType().orElseThrow() == record.sourceType();
if (targetMatch && afterMatch && beforeMatch && sourceMatch) {
return next;
}
}
return Optional.empty();
}
@Override
public void close() {
delegate.close();
}
}
}

View File

@@ -84,7 +84,11 @@ import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.ActiveCertificateProfile; import zeroecho.pki.api.profile.ActiveCertificateProfile;
import zeroecho.pki.api.profile.CertificateProfileRef; import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; import zeroecho.pki.api.profile.ImportedCertificateProfileVersion;
import zeroecho.pki.api.publication.PublicationCursor;
import zeroecho.pki.api.publication.PublicationFailure;
import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.publication.PublicationSourceType;
import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand; import zeroecho.pki.api.revocation.RevocationCommand;
import zeroecho.pki.api.revocation.RevocationRecord; import zeroecho.pki.api.revocation.RevocationRecord;
@@ -173,11 +177,13 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private static final String SIGN_OWNER_NAMESPACE = "io.zeroecho.pki.signing-owner"; 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"; private static final String STATUS_RECORD_NAMESPACE = "io.zeroecho.pki.status-object-record";
private static final String STATUS_OWNER_NAMESPACE = "io.zeroecho.pki.status-object-owner"; private static final String STATUS_OWNER_NAMESPACE = "io.zeroecho.pki.status-object-owner";
private static final String PUBLICATION_RECORD_NAMESPACE = "io.zeroecho.pki.publication-record";
private static final int CURRENT_SIGN_RECORD_VERSION = 2; private static final int CURRENT_SIGN_RECORD_VERSION = 2;
private static final int SIGN_OWNER_VALUE_VERSION = 1; private static final int SIGN_OWNER_VALUE_VERSION = 1;
private static final int STATUS_OWNER_VALUE_VERSION = 1; private static final int STATUS_OWNER_VALUE_VERSION = 1;
private static final int METADATA_TRANSFER_BUFFER_BYTES = 16 * 1024; private static final int METADATA_TRANSFER_BUFFER_BYTES = 16 * 1024;
private static final ThreadLocal<StatusCommitFaultPoint> STATUS_COMMIT_FAULT = new ThreadLocal<>(); private static final ThreadLocal<StatusCommitFaultPoint> STATUS_COMMIT_FAULT = new ThreadLocal<>();
private static final ThreadLocal<PublicationCommitFaultPoint> PUBLICATION_COMMIT_FAULT = new ThreadLocal<>();
private static final String SIGN_FINGERPRINT_PREFIX = "signfp:v1:"; private static final String SIGN_FINGERPRINT_PREFIX = "signfp:v1:";
private static final int SIGN_FINGERPRINT_HEX_LENGTH = 64; private static final int SIGN_FINGERPRINT_HEX_LENGTH = 64;
private static final long INITIAL_FENCE = 0L; private static final long INITIAL_FENCE = 0L;
@@ -268,6 +274,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
rejectNonEmptyUnversionedStore(); rejectNonEmptyUnversionedStore();
} }
ensureVersionFile(); ensureVersionFile();
rejectObsoletePublicationLayout();
this.signingNamespace = ensureSigningNamespace(); this.signingNamespace = ensureSigningNamespace();
this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(), this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(),
this.signingNamespace); this.signingNamespace);
@@ -279,6 +286,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark()); this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
this.historySeq = new AtomicLong(0L); this.historySeq = new AtomicLong(0L);
recoverStagedContent(); recoverStagedContent();
recoverPublicationRecords();
boolean snapshotRestore = requireSnapshotBoundary(); boolean snapshotRestore = requireSnapshotBoundary();
openedRevocations = FilesystemRevocationAuthority.open( openedRevocations = FilesystemRevocationAuthority.open(
this.paths, new MetadataStoreId(this.signingNamespace), credentialId -> { this.paths, new MetadataStoreId(this.signingNamespace), credentialId -> {
@@ -757,19 +765,117 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
@Override @Override
public void putPublicationRecord(final PublicationRecord record) { public PublicationRecord createPublicationRecord(final PublicationRecord record) {
requireStoreUsable(); requireStoreUsable();
Objects.requireNonNull(record, "record"); Objects.requireNonNull(record, "record");
PkiId id = record.publicationId(); validatePublicationRecord(record);
writeOnce(this.paths.publicationPath(id), FsCodec.encode(FsCodec.PUBLICATION, record), "PUBLICATION", Optional<PublicationRecord> existing = getPublicationRecord(record.publicationId());
FsUtil.safeId(id)); if (existing.isPresent()) {
if (publicationRecordsEqual(existing.orElseThrow(), record)) {
return existing.orElseThrow();
}
throw new IllegalArgumentException("Publication identity conflicts with an existing operation");
}
try (MetadataTransaction transaction = metadataStore.beginTransaction()) {
transaction.create(publicationRecordKey(record.publicationId()),
byteContent(PublicationRecordCodec.encode(record)), CancellationSignal.NONE);
PublicationCommitFaultPoint fault = takePublicationCommitFault();
if (fault == PublicationCommitFaultPoint.CREATE_BEFORE_COMMIT) {
throw new IOException("Injected publication create failure");
}
MetadataCommitResult result = transaction.commit();
if (result.outcome() == MetadataCommitResult.Outcome.COMMITTED) {
return record;
}
if (result.outcome() == MetadataCommitResult.Outcome.UNKNOWN) {
throw publicationPersistenceUnknown();
}
} catch (IOException exception) {
throw new IllegalStateException("Publication metadata persistence failed");
}
Optional<PublicationRecord> raced = getPublicationRecord(record.publicationId());
if (raced.isPresent() && publicationRecordsEqual(raced.orElseThrow(), record)) {
return raced.orElseThrow();
}
throw new IllegalArgumentException("Publication identity conflicts with an existing operation");
} }
@Override @Override
public List<PublicationRecord> listPublicationRecords() { public Optional<PublicationRecord> getPublicationRecord(final PkiId publicationId) {
requireStoreUsable(); requireStoreUsable();
Path byId = this.paths.root().resolve("publications").resolve("by-id"); Objects.requireNonNull(publicationId, "publicationId");
return listBinaryFiles(byId, FsCodec.PUBLICATION); MetadataKey key = publicationRecordKey(publicationId);
try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
Optional<MetadataSnapshot.Record> stored = snapshot.get(key);
if (stored.isEmpty()) {
return Optional.empty();
}
try (MetadataSnapshot.Record record = stored.orElseThrow()) {
return Optional.of(decodePublicationRecord(record, snapshot));
}
} catch (IOException exception) {
throw new IllegalStateException("Publication metadata is invalid");
}
}
@Override
public boolean replacePublicationRecord(final PublicationRecord expected,
final PublicationRecord replacement) {
requireStoreUsable();
Objects.requireNonNull(expected, "expected");
Objects.requireNonNull(replacement, "replacement");
validatePublicationReplacement(expected, replacement);
MetadataKey key = publicationRecordKey(expected.publicationId());
try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
Optional<MetadataSnapshot.Record> stored = snapshot.get(key);
if (stored.isEmpty()) {
return false;
}
long revision;
try (MetadataSnapshot.Record record = stored.orElseThrow()) {
if (!publicationRecordsEqual(decodePublicationRecord(record, snapshot), expected)) {
return false;
}
revision = record.recordRevision();
}
try (MetadataTransaction transaction = metadataStore.beginTransaction()) {
transaction.replace(key, revision, byteContent(PublicationRecordCodec.encode(replacement)),
CancellationSignal.NONE);
PublicationCommitFaultPoint fault = takePublicationCommitFault();
if (fault == PublicationCommitFaultPoint.REPLACE_BEFORE_COMMIT) {
throw new IOException("Injected publication replacement failure");
}
MetadataCommitResult result = transaction.commit();
if (result.outcome() == MetadataCommitResult.Outcome.COMMITTED
&& fault == PublicationCommitFaultPoint.REPLACE_AFTER_COMMIT_AS_UNKNOWN) {
throw publicationPersistenceUnknown();
}
if (result.outcome() == MetadataCommitResult.Outcome.UNKNOWN) {
throw publicationPersistenceUnknown();
}
return result.outcome() == MetadataCommitResult.Outcome.COMMITTED;
}
} catch (IOException exception) {
throw new IllegalStateException("Publication metadata persistence failed");
}
}
@Override
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
public PublicationCursor openPublicationRecords() {
requireStoreUsable();
MetadataSnapshot snapshot = null;
try {
snapshot = metadataStore.snapshot();
MetadataCursor cursor = snapshot.scan(MetadataSnapshot.KeyRange.all(PUBLICATION_RECORD_NAMESPACE),
CancellationSignal.NONE);
return new PublicationMetadataCursor(snapshot, cursor);
} catch (IOException | RuntimeException failure) {
if (snapshot != null) {
snapshot.close();
}
throw new IllegalStateException("Publication metadata cursor cannot be opened");
}
} }
@Override @Override
@@ -1827,6 +1933,162 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
return new MetadataKey(STATUS_OWNER_NAMESPACE, statusObjectId.value()); return new MetadataKey(STATUS_OWNER_NAMESPACE, statusObjectId.value());
} }
private static MetadataKey publicationRecordKey(PkiId publicationId) {
return new MetadataKey(PUBLICATION_RECORD_NAMESPACE, publicationId.value());
}
private PublicationRecord decodePublicationRecord(MetadataSnapshot.Record stored, MetadataSnapshot snapshot)
throws IOException {
PublicationRecord record = PublicationRecordCodec.decode(readMetadataValue(stored), stagedContent);
if (!publicationRecordKey(record.publicationId()).equals(stored.key())) {
throw new IOException("Publication metadata key mismatch");
}
validatePublicationRecord(record, snapshot);
return record;
}
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private void recoverPublicationRecords() {
try (PublicationCursor cursor = openPublicationRecords()) {
Optional<PublicationRecord> next;
while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) {
PublicationRecord record = next.orElseThrow();
if (record.status() == PublicationStatus.DISPATCH_PREPARED) {
Instant observed = clock.instant();
Instant updatedAt = observed.isAfter(record.updatedAt()) ? observed : record.updatedAt();
PublicationRecord unknown = new PublicationRecord(record.publicationId(), record.sourceType(),
record.sourceId(), record.content(), record.target(), PublicationStatus.OUTCOME_UNKNOWN,
record.attemptNumber(), record.attemptToken(), record.createdAt(), updatedAt,
Optional.of(PublicationFailure.EXTERNAL_OUTCOME_UNKNOWN), Optional.empty());
if (!replacePublicationRecord(record, unknown)) {
throw new IllegalStateException("Publication recovery conflict");
}
}
}
} catch (IOException exception) {
throw new IllegalStateException("Publication recovery failed");
}
}
private void validatePublicationRecord(PublicationRecord record) {
try (MetadataSnapshot snapshot = metadataStore.snapshot()) {
validatePublicationRecord(record, snapshot);
} catch (IOException exception) {
throw new IllegalStateException("Publication source authority is invalid");
}
}
private void validatePublicationRecord(PublicationRecord record, MetadataSnapshot snapshot) throws IOException {
publicationRecordKey(record.publicationId());
DurableContentReference reference = record.content();
if (!stagedContent.contentStoreId().equals(reference.storeId())
|| reference.lifecycle() != DurableContentReference.Lifecycle.PERSISTED) {
throw new IllegalArgumentException("Publication content is not persistent store authority");
}
try {
stagedContent.restoreReference(reference.storeId(), reference.contentId(), reference.encoding(),
reference.length(), reference.sha256(), reference.lifecycle());
} catch (IOException exception) {
throw new IllegalStateException("Publication content commitment is invalid");
}
DurableContentReference authoritative = record.sourceType() == PublicationSourceType.CREDENTIAL
? getCredential(record.sourceId()).map(Credential::content).orElseThrow(
() -> new IllegalStateException("Publication credential source is missing"))
: statusContent(snapshot, record.sourceId());
if (!sameContentReference(authoritative, reference)) {
throw new IllegalArgumentException("Publication content does not match its source object");
}
}
private DurableContentReference statusContent(MetadataSnapshot snapshot, PkiId statusId) throws IOException {
Optional<MetadataSnapshot.Record> stored = snapshot.get(statusRecordKey(statusId));
if (stored.isPresent()) {
return decodeStoredStatus(snapshot, stored.orElseThrow()).status().content();
}
Optional<MetadataSnapshot.Record> owner = snapshot.get(statusOwnerKey(statusId));
if (owner.isPresent()) {
try (MetadataSnapshot.Record ignored = owner.orElseThrow()) {
throw new IOException("Status owner exists without its record");
}
}
throw new IllegalStateException("Publication status source is missing");
}
private static void validatePublicationReplacement(PublicationRecord expected, PublicationRecord replacement) {
if (!expected.hasSameOperation(replacement)
|| replacement.updatedAt().isBefore(expected.updatedAt())) {
throw new IllegalArgumentException("Publication immutable fields cannot change");
}
PublicationStatus previous = expected.status();
PublicationStatus next = replacement.status();
boolean valid = switch (previous) {
case PENDING -> next == PublicationStatus.DISPATCH_PREPARED
&& replacement.attemptNumber() == Math.addExact(expected.attemptNumber(), 1L);
case DISPATCH_PREPARED -> (next == PublicationStatus.SUCCEEDED
|| next == PublicationStatus.RETRYABLE_FAILURE
|| next == PublicationStatus.TERMINAL_FAILURE
|| next == PublicationStatus.OUTCOME_UNKNOWN)
&& sameAttempt(expected, replacement);
case RETRYABLE_FAILURE -> next == PublicationStatus.PENDING
&& replacement.attemptNumber() == expected.attemptNumber();
case OUTCOME_UNKNOWN -> next == PublicationStatus.DISPATCH_PREPARED
&& sameAttempt(expected, replacement);
case SUCCEEDED, TERMINAL_FAILURE -> false;
};
if (!valid) {
throw new IllegalArgumentException("Publication state transition is illegal");
}
}
private static boolean sameAttempt(PublicationRecord first, PublicationRecord second) {
return first.attemptNumber() == second.attemptNumber()
&& first.attemptToken().equals(second.attemptToken());
}
private static boolean publicationRecordsEqual(PublicationRecord first, PublicationRecord second) {
return first.hasSameOperation(second) && second.hasSameOperation(first)
&& first.status() == second.status() && first.attemptNumber() == second.attemptNumber()
&& first.attemptToken().equals(second.attemptToken())
&& first.updatedAt().equals(second.updatedAt()) && first.failure().equals(second.failure())
&& first.evidence().equals(second.evidence());
}
private static boolean sameContentReference(DurableContentReference first, DurableContentReference second) {
return first.storeId().equals(second.storeId()) && first.contentId().equals(second.contentId())
&& first.encoding() == second.encoding() && first.length() == second.length()
&& first.sha256().equals(second.sha256()) && first.lifecycle() == second.lifecycle();
}
private static PkiException publicationPersistenceUnknown() {
return new PkiException(
"Publication metadata outcome requires recovery: code=PUBLICATION_PERSISTENCE_UNKNOWN");
}
/* package */ static void installPublicationCommitFault(PublicationCommitFaultPoint point) {
PUBLICATION_COMMIT_FAULT.set(Objects.requireNonNull(point, "point"));
}
/* package */ static void clearPublicationCommitFault() {
PUBLICATION_COMMIT_FAULT.remove();
}
private static PublicationCommitFaultPoint takePublicationCommitFault() {
PublicationCommitFaultPoint point = PUBLICATION_COMMIT_FAULT.get();
PUBLICATION_COMMIT_FAULT.remove();
return point;
}
/** Test-only publication metadata commit boundary. */
/* default */
enum PublicationCommitFaultPoint {
/** Fails registration before the metadata commit attempt. */
CREATE_BEFORE_COMMIT,
/** Fails one state replacement before the metadata commit attempt. */
REPLACE_BEFORE_COMMIT,
/** Reports uncertainty after one replacement was durably committed. */
REPLACE_AFTER_COMMIT_AS_UNKNOWN
}
private static RepeatableContent byteContent(byte[] value) { private static RepeatableContent byteContent(byte[] value) {
return new ByteValueContent(value); return new ByteValueContent(value);
} }
@@ -2349,6 +2611,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
} }
private void rejectObsoletePublicationLayout() {
if (Files.exists(this.paths.root().resolve("publications"))) {
throw new IllegalStateException("obsolete publication layout is not supported");
}
}
private <T> Optional<T> readOptional(final Path path, final FsCodec.Schema<T> schema) { private <T> Optional<T> readOptional(final Path path, final FsCodec.Schema<T> schema) {
try { try {
if (!Files.exists(path)) { if (!Files.exists(path)) {
@@ -2361,24 +2629,6 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
} }
private <T> List<T> listBinaryFiles(final Path byIdDir, final FsCodec.Schema<T> schema) {
if (!Files.isDirectory(byIdDir)) {
return List.of();
}
try {
return Files.list(byIdDir).filter(Files::isRegularFile)
.sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> {
try {
return FsCodec.decode(schema, FsOperations.readAll(p), stagedContent);
} catch (IOException e) {
throw new IllegalStateException("read failed: " + p, e);
}
}).toList();
} catch (IOException e) {
throw new IllegalStateException("list failed: " + byIdDir, e);
}
}
private <T> List<T> listCurrentRecords(final Path byIdDir, final FsCodec.Schema<T> schema) { private <T> List<T> listCurrentRecords(final Path byIdDir, final FsCodec.Schema<T> schema) {
if (!Files.isDirectory(byIdDir)) { if (!Files.isDirectory(byIdDir)) {
return List.of(); return List.of();
@@ -2479,4 +2729,39 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
// deterministic best-effort // deterministic best-effort
} }
} }
/** Stable metadata cursor that decodes and validates one record at a time. */
private final class PublicationMetadataCursor implements PublicationCursor {
private final MetadataSnapshot snapshot;
private final MetadataCursor cursor;
private final AtomicBoolean cursorClosed = new AtomicBoolean();
private PublicationMetadataCursor(MetadataSnapshot snapshot, MetadataCursor cursor) {
this.snapshot = snapshot;
this.cursor = cursor;
}
@Override
public Optional<PublicationRecord> next(CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(cancellation, "cancellation");
if (cursorClosed.get()) {
throw new IllegalStateException("Publication cursor is closed");
}
Optional<MetadataSnapshot.Record> next = cursor.next(cancellation);
if (next.isEmpty()) {
return Optional.empty();
}
try (MetadataSnapshot.Record stored = next.orElseThrow()) {
return Optional.of(decodePublicationRecord(stored, snapshot));
}
}
@Override
public void close() {
if (cursorClosed.compareAndSet(false, true)) {
cursor.close();
snapshot.close();
}
}
}
} }

View File

@@ -80,10 +80,6 @@ import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; import zeroecho.pki.api.profile.ImportedCertificateProfileVersion;
import zeroecho.pki.api.profile.SubjectAlternativeNameType; import zeroecho.pki.api.profile.SubjectAlternativeNameType;
import zeroecho.pki.api.profile.SubjectRdnType; import zeroecho.pki.api.profile.SubjectRdnType;
import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.SubjectAlternativeName; import zeroecho.pki.api.request.SubjectAlternativeName;
import zeroecho.pki.api.request.SubjectRdn; import zeroecho.pki.api.request.SubjectRdn;
@@ -125,7 +121,6 @@ final class FsCodec {
private static final int TOP_CREDENTIAL = 2; private static final int TOP_CREDENTIAL = 2;
private static final int TOP_PARSED_REQUEST = 3; private static final int TOP_PARSED_REQUEST = 3;
private static final int TOP_STATUS_OBJECT = 5; private static final int TOP_STATUS_OBJECT = 5;
private static final int TOP_PUBLICATION = 6;
private static final int TOP_POLICY_TRACE = 8; private static final int TOP_POLICY_TRACE = 8;
private static final int TOP_WORKFLOW_STATE = 9; private static final int TOP_WORKFLOW_STATE = 9;
private static final int TOP_SIGN_WORKFLOW_RECORD = 10; private static final int TOP_SIGN_WORKFLOW_RECORD = 10;
@@ -150,7 +145,6 @@ final class FsCodec {
private static final int TYPE_PRINCIPAL = 27; private static final int TYPE_PRINCIPAL = 27;
private static final int TYPE_ATTRIBUTE_ID = 28; private static final int TYPE_ATTRIBUTE_ID = 28;
private static final int TYPE_POLICY_TRACE_STEP = 29; private static final int TYPE_POLICY_TRACE_STEP = 29;
private static final int TYPE_PUBLICATION_TARGET = 30;
private static final int TYPE_ATTRIBUTE_SET = 31; private static final int TYPE_ATTRIBUTE_SET = 31;
private static final int TYPE_ATTRIBUTE_VALUE = 33; private static final int TYPE_ATTRIBUTE_VALUE = 33;
private static final int TYPE_CREDENTIAL_RECORD = 34; private static final int TYPE_CREDENTIAL_RECORD = 34;
@@ -159,8 +153,6 @@ final class FsCodec {
private static final int TYPE_CA_STATE_ENUM = 52; private static final int TYPE_CA_STATE_ENUM = 52;
private static final int TYPE_CREDENTIAL_STATUS_ENUM = 53; private static final int TYPE_CREDENTIAL_STATUS_ENUM = 53;
private static final int TYPE_STATUS_OBJECT_TYPE_ENUM = 55; private static final int TYPE_STATUS_OBJECT_TYPE_ENUM = 55;
private static final int TYPE_PUBLICATION_TARGET_TYPE_ENUM = 56;
private static final int TYPE_PUBLICATION_STATUS_ENUM = 57;
private static final int TYPE_DURABILITY_POLICY_ENUM = 58; private static final int TYPE_DURABILITY_POLICY_ENUM = 58;
private static final int TYPE_SIGN_STATE_ENUM = 59; private static final int TYPE_SIGN_STATE_ENUM = 59;
private static final int TYPE_SUBJECT_RDN_TYPE_ENUM = 62; private static final int TYPE_SUBJECT_RDN_TYPE_ENUM = 62;
@@ -250,32 +242,6 @@ final class FsCodec {
case 4 -> StatusObjectType.REVOCATION_LIST; case 4 -> StatusObjectType.REVOCATION_LIST;
default -> throw unknownEnum("StatusObjectType", code); default -> throw unknownEnum("StatusObjectType", code);
}); });
private static final ValueSchema<PublicationTargetType> PUBLICATION_TARGET_TYPE = enumSchema(
TYPE_PUBLICATION_TARGET_TYPE_ENUM, value -> switch (value) {
case FILESYSTEM -> 1;
case LDAP -> 2;
case HTTP -> 3;
case OBJECT_STORE -> 4;
case CUSTOM -> 5;
}, code -> switch (code) {
case 1 -> PublicationTargetType.FILESYSTEM;
case 2 -> PublicationTargetType.LDAP;
case 3 -> PublicationTargetType.HTTP;
case 4 -> PublicationTargetType.OBJECT_STORE;
case 5 -> PublicationTargetType.CUSTOM;
default -> throw unknownEnum("PublicationTargetType", code);
});
private static final ValueSchema<PublicationStatus> PUBLICATION_STATUS = enumSchema(TYPE_PUBLICATION_STATUS_ENUM,
value -> switch (value) {
case PUBLISHED -> 1;
case SKIPPED -> 2;
case FAILED -> 3;
}, code -> switch (code) {
case 1 -> PublicationStatus.PUBLISHED;
case 2 -> PublicationStatus.SKIPPED;
case 3 -> PublicationStatus.FAILED;
default -> throw unknownEnum("PublicationStatus", code);
});
private static final ValueSchema<OrchestrationDurabilityPolicy> DURABILITY_POLICY = enumSchema( private static final ValueSchema<OrchestrationDurabilityPolicy> DURABILITY_POLICY = enumSchema(
TYPE_DURABILITY_POLICY_ENUM, value -> switch (value) { TYPE_DURABILITY_POLICY_ENUM, value -> switch (value) {
case STRICT_ABORT_ON_RESTART -> 1; case STRICT_ABORT_ON_RESTART -> 1;
@@ -384,14 +350,6 @@ final class FsCodec {
}, reader -> new PolicyTraceStep(reader.readValue(STRING), reader.readValue(STRING), }, reader -> new PolicyTraceStep(reader.readValue(STRING), reader.readValue(STRING),
reader.readValue(STRINGS))); reader.readValue(STRINGS)));
private static final ValueSchema<List<PolicyTraceStep>> POLICY_TRACE_STEPS = listOf(POLICY_TRACE_STEP); private static final ValueSchema<List<PolicyTraceStep>> POLICY_TRACE_STEPS = listOf(POLICY_TRACE_STEP);
private static final ValueSchema<PublicationTarget> PUBLICATION_TARGET = valueSchema(TYPE_PUBLICATION_TARGET,
(writer, value) -> {
writer.writeValue(PUBLICATION_TARGET_TYPE, value.type());
writer.writeValue(STRING, value.targetId());
writer.writeValue(ATTRIBUTE_SET, value.attributes());
}, reader -> new PublicationTarget(reader.readValue(PUBLICATION_TARGET_TYPE), reader.readValue(STRING),
reader.readValue(ATTRIBUTE_SET)));
private static final ValueSchema<Optional<Validity>> OPTIONAL_VALIDITY = optionalOf(VALIDITY); private static final ValueSchema<Optional<Validity>> OPTIONAL_VALIDITY = optionalOf(VALIDITY);
private static final ValueSchema<Optional<String>> OPTIONAL_STRING = optionalOf(STRING); private static final ValueSchema<Optional<String>> OPTIONAL_STRING = optionalOf(STRING);
private static final ValueSchema<Optional<Instant>> OPTIONAL_INSTANT = optionalOf(INSTANT); private static final ValueSchema<Optional<Instant>> OPTIONAL_INSTANT = optionalOf(INSTANT);
@@ -408,8 +366,6 @@ final class FsCodec {
"PARSED_REQUEST", valueSchema(101, FsCodec::writeParsedRequest, FsCodec::readParsedRequest)); "PARSED_REQUEST", valueSchema(101, FsCodec::writeParsedRequest, FsCodec::readParsedRequest));
/* package */ static final Schema<StatusObject> STATUS_OBJECT = topLevel(TOP_STATUS_OBJECT, "STATUS_OBJECT", /* package */ static final Schema<StatusObject> STATUS_OBJECT = topLevel(TOP_STATUS_OBJECT, "STATUS_OBJECT",
valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject)); valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject));
/* package */ static final Schema<PublicationRecord> PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION",
valueSchema(104, FsCodec::writePublication, FsCodec::readPublication));
/* package */ static final Schema<PolicyTrace> POLICY_TRACE = topLevel(TOP_POLICY_TRACE, "POLICY_TRACE", /* package */ static final Schema<PolicyTrace> POLICY_TRACE = topLevel(TOP_POLICY_TRACE, "POLICY_TRACE",
valueSchema(106, FsCodec::writePolicyTrace, FsCodec::readPolicyTrace)); valueSchema(106, FsCodec::writePolicyTrace, FsCodec::readPolicyTrace));
/* package */ static final Schema<WorkflowStateRecord> WORKFLOW_STATE = topLevel(TOP_WORKFLOW_STATE, /* package */ static final Schema<WorkflowStateRecord> WORKFLOW_STATE = topLevel(TOP_WORKFLOW_STATE,
@@ -425,7 +381,7 @@ final class FsCodec {
private static final Map<Integer, Schema<?>> TOP_LEVEL_SCHEMAS = Map.ofEntries(Map.entry(TOP_CA_RECORD, CA_RECORD), private static final Map<Integer, Schema<?>> TOP_LEVEL_SCHEMAS = Map.ofEntries(Map.entry(TOP_CA_RECORD, CA_RECORD),
Map.entry(TOP_CREDENTIAL, CREDENTIAL), Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST), Map.entry(TOP_CREDENTIAL, CREDENTIAL), Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST),
Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT), Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT),
Map.entry(TOP_PUBLICATION, PUBLICATION), Map.entry(TOP_POLICY_TRACE, POLICY_TRACE), Map.entry(TOP_POLICY_TRACE, POLICY_TRACE),
Map.entry(TOP_WORKFLOW_STATE, WORKFLOW_STATE), Map.entry(TOP_SIGN_WORKFLOW_RECORD, SIGN_WORKFLOW_RECORD), Map.entry(TOP_WORKFLOW_STATE, WORKFLOW_STATE), Map.entry(TOP_SIGN_WORKFLOW_RECORD, SIGN_WORKFLOW_RECORD),
Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF)); Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF));
@@ -711,21 +667,6 @@ final class FsCodec {
reader.readValue(ATTRIBUTE_SET)); reader.readValue(ATTRIBUTE_SET));
} }
private static void writePublication(Writer writer, PublicationRecord value) throws IOException {
writer.writeValue(PKI_ID, value.publicationId());
writer.writeValue(INSTANT, value.time());
writer.writeValue(PUBLICATION_TARGET, value.target());
writer.writeValue(PKI_ID, value.objectId());
writer.writeValue(STRING, value.objectKind());
writer.writeValue(PUBLICATION_STATUS, value.status());
}
private static PublicationRecord readPublication(Reader reader) throws IOException {
return new PublicationRecord(reader.readValue(PKI_ID), reader.readValue(INSTANT),
reader.readValue(PUBLICATION_TARGET), reader.readValue(PKI_ID), reader.readValue(STRING),
reader.readValue(PUBLICATION_STATUS));
}
private static void writeProfileVersion(Writer writer, ImportedCertificateProfileVersion value) throws IOException { private static void writeProfileVersion(Writer writer, ImportedCertificateProfileVersion value) throws IOException {
writer.writeValue(PROFILE_REF, value.reference()); writer.writeValue(PROFILE_REF, value.reference());
writer.writeValue(LONG, (long) value.schemaVersion()); writer.writeValue(LONG, (long) value.schemaVersion());

View File

@@ -199,15 +199,6 @@ final class FsPaths {
return this.root.resolve("policy").resolve("by-decision").resolve(FsUtil.safeId(decisionId) + ".bin"); return this.root.resolve("policy").resolve("by-decision").resolve(FsUtil.safeId(decisionId) + ".bin");
} }
// -------------------------------------------------------------------------
// Publications (immutable .bin)
// -------------------------------------------------------------------------
/* default */ Path publicationPath(final PkiId publicationId) {
Objects.requireNonNull(publicationId, "publicationId");
return this.root.resolve("publications").resolve(BY_ID).resolve(FsUtil.safeId(publicationId) + ".bin");
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Workflow state (mutable with history) // Workflow state (mutable with history)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -51,15 +51,20 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent; import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.publication.PublicationCursor;
import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.publication.PublicationSourceType;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.impl.ProfileLifecycleFailure; import zeroecho.pki.impl.ProfileLifecycleFailure;
import zeroecho.pki.impl.ProfileLifecycleFailure.Code; import zeroecho.pki.impl.ProfileLifecycleFailure.Code;
@@ -337,27 +342,48 @@ final class FsSnapshotExporter {
} }
private void build(Path targetRoot, Instant at) throws IOException { private void build(Path targetRoot, Instant at) throws IOException {
Path sourceRoot = source.snapshotRoot(); try (PublicationCursor publications = source.openPublicationRecords()) {
Path stagedRevocationPrefix = targetRoot.resolve(REVOCATION_PREFIX_STAGE); Path sourceRoot = source.snapshotRoot();
FilesystemRevocationLog.RecoveryTarget captured = Path stagedRevocationPrefix = targetRoot.resolve(REVOCATION_PREFIX_STAGE);
source.copyRevocationPrefix(stagedRevocationPrefix); FilesystemRevocationLog.RecoveryTarget captured =
copyFile(sourceRoot.resolve("SIGNING_NAMESPACE"), targetRoot.resolve("SIGNING_NAMESPACE")); source.copyRevocationPrefix(stagedRevocationPrefix);
copyFile(sourceRoot.resolve("SIGNING_TIME_WATERMARK"), targetRoot.resolve("SIGNING_TIME_WATERMARK")); copyFile(sourceRoot.resolve("SIGNING_NAMESPACE"), targetRoot.resolve("SIGNING_NAMESPACE"));
copyTreeIfExists(sourceRoot.resolve("requests"), targetRoot.resolve("requests")); copyFile(sourceRoot.resolve("SIGNING_TIME_WATERMARK"), targetRoot.resolve("SIGNING_TIME_WATERMARK"));
copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy")); copyTreeIfExists(sourceRoot.resolve("requests"), targetRoot.resolve("requests"));
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications")); copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy"));
copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows")); copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
copyStagedContent(sourceRoot.resolve("staged-content"), targetRoot.resolve("staged-content"), copyStagedContent(sourceRoot.resolve("staged-content"), targetRoot.resolve("staged-content"),
plan.authority().remintedContentIds(), plan.nonCredentialContentIds()); plan.authority().remintedContentIds(), plan.nonCredentialContentIds());
copyImportedProfilesAsOf(plan.profiles(), targetRoot.resolve("profiles")); copyImportedProfilesAsOf(plan.profiles(), targetRoot.resolve("profiles"));
reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at, reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at,
options.workflowHistoryPolicy(), options.strictSnapshotExport()); options.workflowHistoryPolicy(), options.strictSnapshotExport());
new AuthorityRestorer(source, options, plan.authority()).restore(targetRoot); new AuthorityRestorer(source, options, plan.authority()).restore(targetRoot);
installRevocationAuthority(targetRoot, stagedRevocationPrefix); installRevocationAuthority(targetRoot, stagedRevocationPrefix);
try (FilesystemPkiStore ignored = new FilesystemPkiStore(targetRoot, options)) { try (FilesystemPkiStore target = new FilesystemPkiStore(targetRoot, options)) {
// Opening validates the restored authority and rebuilds its derived index. // Opening validates the restored authority and rebuilds its derived index.
restorePublicationMetadata(publications, target);
}
writeSnapshotBoundary(targetRoot, captured.boundary());
}
}
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private static void restorePublicationMetadata(PublicationCursor cursor, FilesystemPkiStore target)
throws IOException {
Optional<PublicationRecord> next;
while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) {
PublicationRecord record = next.orElseThrow();
DurableContentReference targetContent = record.sourceType() == PublicationSourceType.CREDENTIAL
? target.getCredential(record.sourceId()).map(Credential::content).orElseThrow(
() -> new IOException("Snapshot publication credential is missing"))
: target.getStatusObject(record.sourceId()).map(StatusObject::content).orElseThrow(
() -> new IOException("Snapshot publication status object is missing"));
PublicationRecord restored = new PublicationRecord(record.publicationId(), record.sourceType(),
record.sourceId(), targetContent, record.target(), record.status(), record.attemptNumber(),
record.attemptToken(), record.createdAt(), record.updatedAt(), record.failure(),
record.evidence());
target.createPublicationRecord(restored);
} }
writeSnapshotBoundary(targetRoot, captured.boundary());
} }
private static void writeSnapshotBoundary(Path targetRoot, long boundary) throws IOException { private static void writeSnapshotBoundary(Path targetRoot, long boundary) throws IOException {

View File

@@ -0,0 +1,305 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Optional;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.publication.PublicationEvidence;
import zeroecho.pki.api.publication.PublicationFailure;
import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.publication.PublicationSourceType;
import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.spi.store.StagedContentStore;
/** Strict current-schema codec for finite publication control metadata. */
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.AvoidCatchingGenericException",
"PMD.PreserveStackTrace" })
final class PublicationRecordCodec {
private static final int MAGIC = 0x5a505542;
private static final int VERSION = 1;
private static final int MAXIMUM_RECORD_BYTES = 32 * 1024;
private static final int MAXIMUM_STRING_BYTES = 4096;
private static final int OPTIONAL_ABSENT = 0;
private static final int OPTIONAL_PRESENT = 1;
private PublicationRecordCodec() {
}
/* package */ static byte[] encode(PublicationRecord record) {
try {
ByteArrayOutputStream buffer = new ByteArrayOutputStream(512);
try (DataOutputStream output = new DataOutputStream(buffer)) {
output.writeInt(MAGIC);
output.writeInt(VERSION);
writeString(output, record.publicationId().value());
output.writeInt(sourceTypeCode(record.sourceType()));
writeString(output, record.sourceId().value());
writeContent(output, record.content());
output.writeInt(targetTypeCode(record.target().type()));
writeString(output, record.target().targetId());
output.writeInt(statusCode(record.status()));
output.writeLong(record.attemptNumber());
writeOptionalString(output, record.attemptToken());
writeInstant(output, record.createdAt());
writeInstant(output, record.updatedAt());
output.writeInt(record.failure().map(PublicationRecordCodec::failureCode).orElse(0));
output.writeInt(record.evidence().map(PublicationRecordCodec::evidenceCode).orElse(0));
}
byte[] encoded = buffer.toByteArray();
if (encoded.length > MAXIMUM_RECORD_BYTES) {
throw new IllegalArgumentException("Publication record exceeds its finite metadata limit");
}
return encoded;
} catch (IOException impossible) {
throw new IllegalStateException("Publication record encoding failed", impossible);
}
}
/* package */ static PublicationRecord decode(byte[] encoded, StagedContentStore stagedContent)
throws IOException {
if (encoded.length > MAXIMUM_RECORD_BYTES) {
throw malformed();
}
try (ByteArrayInputStream bytes = new ByteArrayInputStream(encoded);
DataInputStream input = new DataInputStream(bytes)) {
if (input.readInt() != MAGIC || input.readInt() != VERSION) {
throw malformed();
}
PkiId publicationId = new PkiId(readString(input));
PublicationSourceType sourceType = sourceType(input.readInt());
PkiId sourceId = new PkiId(readString(input));
DurableContentReference content = readContent(input, stagedContent);
PublicationTarget target = new PublicationTarget(targetType(input.readInt()), readString(input));
PublicationStatus status = status(input.readInt());
long attemptNumber = input.readLong();
Optional<String> attemptToken = readOptionalString(input);
Instant createdAt = readInstant(input);
Instant updatedAt = readInstant(input);
Optional<PublicationFailure> failure = optionalFailure(input.readInt());
Optional<PublicationEvidence> evidence = optionalEvidence(input.readInt());
if (bytes.available() != 0) {
throw malformed();
}
return new PublicationRecord(publicationId, sourceType, sourceId, content, target, status,
attemptNumber, attemptToken, createdAt, updatedAt, failure, evidence);
} catch (EOFException | RuntimeException failure) {
throw malformed();
}
}
private static void writeContent(DataOutputStream output, DurableContentReference content) throws IOException {
writeString(output, content.storeId());
writeString(output, content.contentId());
output.writeInt(encodingCode(content.encoding()));
output.writeLong(content.length());
writeString(output, content.sha256());
output.writeInt(lifecycleCode(content.lifecycle()));
}
private static DurableContentReference readContent(DataInputStream input, StagedContentStore stagedContent)
throws IOException {
return stagedContent.restoreReference(readString(input), readString(input), encoding(input.readInt()),
input.readLong(), readString(input), lifecycle(input.readInt()));
}
private static void writeString(DataOutputStream output, String value) throws IOException {
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
if (encoded.length == 0 || encoded.length > MAXIMUM_STRING_BYTES) {
throw new IllegalArgumentException("Publication metadata string is not finite");
}
output.writeInt(encoded.length);
output.write(encoded);
}
private static String readString(DataInputStream input) throws IOException {
int length = input.readInt();
if (length <= 0 || length > MAXIMUM_STRING_BYTES) {
throw malformed();
}
byte[] value = input.readNBytes(length);
if (value.length != length) {
throw malformed();
}
String decoded = new String(value, StandardCharsets.UTF_8);
if (!java.util.Arrays.equals(value, decoded.getBytes(StandardCharsets.UTF_8))) {
throw malformed();
}
return decoded;
}
private static void writeOptionalString(DataOutputStream output, Optional<String> value) throws IOException {
output.writeByte(value.isPresent() ? OPTIONAL_PRESENT : OPTIONAL_ABSENT);
if (value.isPresent()) {
writeString(output, value.orElseThrow());
}
}
private static Optional<String> readOptionalString(DataInputStream input) throws IOException {
int marker = input.readUnsignedByte();
if (marker == OPTIONAL_ABSENT) {
return Optional.empty();
}
if (marker == OPTIONAL_PRESENT) {
return Optional.of(readString(input));
}
throw malformed();
}
private static void writeInstant(DataOutputStream output, Instant value) throws IOException {
output.writeLong(value.getEpochSecond());
output.writeInt(value.getNano());
}
private static Instant readInstant(DataInputStream input) throws IOException {
return Instant.ofEpochSecond(input.readLong(), input.readInt());
}
private static int sourceTypeCode(PublicationSourceType value) {
return switch (value) {
case CREDENTIAL -> 1;
case STATUS_OBJECT -> 2;
};
}
private static PublicationSourceType sourceType(int code) throws IOException {
return switch (code) {
case 1 -> PublicationSourceType.CREDENTIAL;
case 2 -> PublicationSourceType.STATUS_OBJECT;
default -> throw malformed();
};
}
private static int targetTypeCode(PublicationTargetType value) {
return switch (value) {
case FILESYSTEM -> 1;
case LDAP -> 2;
case HTTP -> 3;
case OBJECT_STORE -> 4;
case CUSTOM -> 5;
};
}
private static PublicationTargetType targetType(int code) throws IOException {
return switch (code) {
case 1 -> PublicationTargetType.FILESYSTEM;
case 2 -> PublicationTargetType.LDAP;
case 3 -> PublicationTargetType.HTTP;
case 4 -> PublicationTargetType.OBJECT_STORE;
case 5 -> PublicationTargetType.CUSTOM;
default -> throw malformed();
};
}
private static int statusCode(PublicationStatus value) {
return switch (value) {
case PENDING -> 1;
case DISPATCH_PREPARED -> 2;
case SUCCEEDED -> 3;
case RETRYABLE_FAILURE -> 4;
case TERMINAL_FAILURE -> 5;
case OUTCOME_UNKNOWN -> 6;
};
}
private static PublicationStatus status(int code) throws IOException {
return switch (code) {
case 1 -> PublicationStatus.PENDING;
case 2 -> PublicationStatus.DISPATCH_PREPARED;
case 3 -> PublicationStatus.SUCCEEDED;
case 4 -> PublicationStatus.RETRYABLE_FAILURE;
case 5 -> PublicationStatus.TERMINAL_FAILURE;
case 6 -> PublicationStatus.OUTCOME_UNKNOWN;
default -> throw malformed();
};
}
private static int failureCode(PublicationFailure value) {
return switch (value) {
case EXTERNAL_RETRYABLE -> 1;
case EXTERNAL_TERMINAL -> 2;
case EXTERNAL_OUTCOME_UNKNOWN -> 3;
case CONTENT_INVALID -> 4;
case RESULT_PERSISTENCE_UNKNOWN -> 5;
};
}
private static Optional<PublicationFailure> optionalFailure(int code) throws IOException {
return switch (code) {
case 0 -> Optional.empty();
case 1 -> Optional.of(PublicationFailure.EXTERNAL_RETRYABLE);
case 2 -> Optional.of(PublicationFailure.EXTERNAL_TERMINAL);
case 3 -> Optional.of(PublicationFailure.EXTERNAL_OUTCOME_UNKNOWN);
case 4 -> Optional.of(PublicationFailure.CONTENT_INVALID);
case 5 -> Optional.of(PublicationFailure.RESULT_PERSISTENCE_UNKNOWN);
default -> throw malformed();
};
}
private static int evidenceCode(PublicationEvidence value) {
return switch (value) {
case DIRECT_RESPONSE -> 1;
case RECONCILIATION_RESPONSE -> 2;
};
}
private static Optional<PublicationEvidence> optionalEvidence(int code) throws IOException {
return switch (code) {
case 0 -> Optional.empty();
case 1 -> Optional.of(PublicationEvidence.DIRECT_RESPONSE);
case 2 -> Optional.of(PublicationEvidence.RECONCILIATION_RESPONSE);
default -> throw malformed();
};
}
private static int encodingCode(Encoding value) {
return switch (value) {
case DER -> 1;
case PEM -> 2;
case BINARY -> 3;
};
}
private static Encoding encoding(int code) throws IOException {
return switch (code) {
case 1 -> Encoding.DER;
case 2 -> Encoding.PEM;
case 3 -> Encoding.BINARY;
default -> throw malformed();
};
}
private static int lifecycleCode(DurableContentReference.Lifecycle value) {
return switch (value) {
case OPERATION -> 1;
case PERSISTED -> 2;
case TEMPORARY -> 3;
};
}
private static DurableContentReference.Lifecycle lifecycle(int code) throws IOException {
return switch (code) {
case 1 -> DurableContentReference.Lifecycle.OPERATION;
case 2 -> DurableContentReference.Lifecycle.PERSISTED;
case 3 -> DurableContentReference.Lifecycle.TEMPORARY;
default -> throw malformed();
};
}
private static IOException malformed() {
return new IOException("Publication record metadata is malformed");
}
}

View File

@@ -0,0 +1,43 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.spi.publish;
import java.util.Objects;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.publication.PublicationSourceType;
import zeroecho.pki.api.publication.PublicationTarget;
/**
* Immutable non-secret identity and content commitment for one external attempt.
*
* @param publicationId operation identity
* @param sourceType authoritative source type
* @param sourceId authoritative source identity
* @param target configured target identity
* @param attemptNumber positive attempt number
* @param attemptToken stable canonical idempotency token
* @param encoding payload encoding
* @param length exact payload length
* @param sha256 exact lowercase SHA-256 commitment
*/
public record PublicationAttempt(PkiId publicationId, PublicationSourceType sourceType, PkiId sourceId,
PublicationTarget target, long attemptNumber, String attemptToken, Encoding encoding,
long length, String sha256) {
/** Creates a validated immutable attempt descriptor. */
public PublicationAttempt {
Objects.requireNonNull(publicationId, "publicationId");
Objects.requireNonNull(sourceType, "sourceType");
Objects.requireNonNull(sourceId, "sourceId");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(attemptToken, "attemptToken");
Objects.requireNonNull(encoding, "encoding");
Objects.requireNonNull(sha256, "sha256");
if (attemptNumber <= 0L || length < 0L || !attemptToken.matches("[0-9a-f]{64}")
|| !sha256.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Publication attempt commitment is invalid");
}
}
}

View File

@@ -0,0 +1,15 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.spi.publish;
/** Definitive outcomes a publisher may safely return for one exact attempt. */
public enum PublicationOutcome {
/** The exact attempt completed successfully. */
SUCCESS,
/** No unsafe duplicate effect exists and an explicit retry is permitted. */
RETRYABLE_FAILURE,
/** The exact attempt definitively failed and must not be retried. */
TERMINAL_FAILURE
}

View File

@@ -33,22 +33,42 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.spi.publish; package zeroecho.pki.spi.publish;
import java.util.Optional;
import zeroecho.core.io.RepeatableContent; import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.publication.PublicationTarget; import zeroecho.pki.api.publication.PublicationTarget;
/** /**
* Publishes an encoded artifact to a configured publication target. * Publishes an encoded artifact to a configured publication target.
*/ */
@SuppressWarnings("PMD.ImplicitFunctionalInterface")
public interface Publisher { public interface Publisher {
/** /**
* Publishes the given payload to the specified target. * Returns the exact administrator-configured destination served by this instance.
* *
* @param target publication target * @return immutable destination identity
* @param payload payload to publish
* @throws IllegalArgumentException if inputs are null
* @throws RuntimeException if publishing fails
*/ */
void publish(PublicationTarget target, RepeatableContent payload); PublicationTarget target();
/**
* Publishes the payload for one durably prepared attempt.
*
* @param attempt exact attempt and idempotency identity
* @param payload repeatable immutable payload; implementations must stream it
* @return definitive classified outcome
* @throws IllegalArgumentException if inputs are null
* @throws RuntimeException if the outcome cannot be established; callers treat
* every such exception as externally unknown and discard its details
*/
PublicationOutcome publish(PublicationAttempt attempt, RepeatableContent payload);
/**
* Resolves an existing unknown attempt without creating a new external effect.
*
* @param attempt original exact attempt identity
* @return definitive outcome, or empty when reliable reconciliation is unsupported
* @throws RuntimeException if the lookup outcome itself is unknown
*/
default Optional<PublicationOutcome> reconcile(PublicationAttempt attempt) {
return Optional.empty();
}
} }

View File

@@ -36,7 +36,9 @@
* *
* <p> * <p>
* Publishers implement target-specific distribution (filesystem, LDAP, HTTP, * Publishers implement target-specific distribution (filesystem, LDAP, HTTP,
* object stores). * object stores) for one core-issued attempt token. They return only closed safe
* outcomes and may optionally reconcile that exact attempt. Provider secrets and
* exception details never cross this boundary into publication metadata.
* </p> * </p>
*/ */
package zeroecho.pki.spi.publish; package zeroecho.pki.spi.publish;

View File

@@ -45,6 +45,7 @@ import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.ActiveCertificateProfile; import zeroecho.pki.api.profile.ActiveCertificateProfile;
import zeroecho.pki.api.profile.CertificateProfileRef; import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; import zeroecho.pki.api.profile.ImportedCertificateProfileVersion;
import zeroecho.pki.api.publication.PublicationCursor;
import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand; import zeroecho.pki.api.revocation.RevocationCommand;
@@ -61,6 +62,21 @@ import zeroecho.pki.api.status.StatusObject;
* must not leak secrets through logging or exception messages. * must not leak secrets through logging or exception messages.
* </p> * </p>
* *
* <h2>Publication recovery ownership</h2>
* <p>
* Before an instance is exposed to callers, its implementation must hold
* exclusive publication-runtime ownership and validate the complete publication
* namespace in one stable view. Any abandoned
* {@link zeroecho.pki.api.publication.PublicationStatus#DISPATCH_PREPARED}
* operation must be durably changed to
* {@link zeroecho.pki.api.publication.PublicationStatus#OUTCOME_UNKNOWN} before
* exposure, without invoking a publisher. Once exposed, the store must not
* reclassify a prepared operation because it may represent an active dispatch in
* the owning runtime. A backend that supports simultaneous runtimes must provide
* durable session or lease fencing with equivalent safety instead of unconditional
* open-time recovery.
* </p>
*
* <h2>Security</h2> * <h2>Security</h2>
* <p> * <p>
* Implementations must protect persisted data appropriately (for example: * Implementations must protect persisted data appropriately (for example:
@@ -256,27 +272,49 @@ public interface PkiStore extends SignWorkflowStore {
List<StatusObject> listStatusObjects(PkiId issuerCaId); List<StatusObject> listStatusObjects(PkiId issuerCaId);
/** /**
* Persists or updates a publication record. * Creates one publication operation if absent.
* *
* <p> * <p>
* Publication records describe distribution state (for example: where and when * An identical replay is idempotent. A different record under the same identity
* an object was published). These records may be used for operational * fails closed. The implementation must use its finite transactional metadata
* monitoring and reconciliation. * authority and must not create a sidecar record.
* </p> * </p>
* *
* @param record publication record (never {@code null}) * @param record publication record (never {@code null})
* @throws NullPointerException if {@code record} is {@code null} * @throws NullPointerException if {@code record} is {@code null}
* @throws IllegalStateException if persistence fails * @return committed record, including an identical existing record
* @throws IllegalArgumentException if the identity conflicts
* @throws IllegalStateException if persistence fails or is uncertain
*/ */
void putPublicationRecord(PublicationRecord record); PublicationRecord createPublicationRecord(PublicationRecord record);
/** /**
* Lists all publication records. * Reads one publication operation by exact semantic identity.
* *
* @return list of publication records (never {@code null}) * @param publicationId operation identity
* @throws IllegalStateException if listing fails * @return current durable record, or empty when absent
*/ */
List<PublicationRecord> listPublicationRecords(); Optional<PublicationRecord> getPublicationRecord(PkiId publicationId);
/**
* Atomically replaces one exact observed publication state.
*
* @param expected exact current record
* @param replacement valid next state with identical immutable fields
* @return {@code true} when committed; {@code false} on a concurrent conflict
* @throws IllegalArgumentException if identities, immutable fields or the state
* transition are invalid
* @throws IllegalStateException if persistence fails or is uncertain
*/
boolean replacePublicationRecord(PublicationRecord expected, PublicationRecord replacement);
/**
* Opens a stable ordered cursor over the complete publication namespace.
*
* @return closeable cursor whose heap does not grow with returned records
* @throws IllegalStateException if the stable view cannot be opened
*/
PublicationCursor openPublicationRecords();
/** /**
* Atomically imports one immutable validated profile version. * Atomically imports one immutable validated profile version.

View File

@@ -64,6 +64,9 @@ public interface PkiStoreProvider extends ConfigurableProvider<PkiStore> {
* <p> * <p>
* Implementations must validate required keys and throw * Implementations must validate required keys and throw
* {@link IllegalArgumentException} if configuration is incomplete or invalid. * {@link IllegalArgumentException} if configuration is incomplete or invalid.
* Before returning, the provider must also satisfy the exclusive publication
* recovery contract documented by {@link PkiStore}; no external publication
* call may occur during allocation.
* </p> * </p>
* *
* @param config configuration (never {@code null}) * @param config configuration (never {@code null})

View File

@@ -611,7 +611,10 @@ final class PkiProofGateE2eTest {
assertTrue(signingFailure.store().listCas().isEmpty()); assertTrue(signingFailure.store().listCas().isEmpty());
assertTrue(signingFailure.store().listWorkflowStates().isEmpty()); assertTrue(signingFailure.store().listWorkflowStates().isEmpty());
assertTrue(signingFailure.store().listSignRecords().isEmpty()); assertTrue(signingFailure.store().listSignRecords().isEmpty());
assertTrue(signingFailure.store().listPublicationRecords().isEmpty()); try (zeroecho.pki.api.publication.PublicationCursor cursor =
signingFailure.store().openPublicationRecords()) {
assertTrue(cursor.next(zeroecho.core.io.CancellationSignal.NONE).isEmpty());
}
assertFalse(signingFailure.hasRunningSignatureOperations()); assertFalse(signingFailure.hasRunningSignatureOperations());
assertEquals(0, signingFailure.submittedSignCount()); assertEquals(0, signingFailure.submittedSignCount());
assertEquals("MANAGED_KEY_UNAVAILABLE", signingFailure.auditSink().snapshot() assertEquals("MANAGED_KEY_UNAVAILABLE", signingFailure.auditSink().snapshot()

View File

@@ -0,0 +1,624 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*******************************************************************************/
package zeroecho.pki.impl.fs;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.publication.PublicationCursor;
import zeroecho.pki.api.publication.PublicationFailure;
import zeroecho.pki.api.publication.PublicationQuery;
import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.publication.PublicationRequest;
import zeroecho.pki.api.publication.PublicationResult;
import zeroecho.pki.api.publication.PublicationSourceType;
import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.impl.core.DefaultPublicationService;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.publish.PublicationAttempt;
import zeroecho.pki.spi.publish.PublicationOutcome;
import zeroecho.pki.spi.publish.Publisher;
final class DefaultPublicationServiceTest {
private static final Instant NOW = Instant.parse("2026-08-03T10:15:30Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private static final PublicationTarget TARGET =
new PublicationTarget(PublicationTargetType.FILESYSTEM, "primary-mirror-v1");
@TempDir
private Path temporaryDirectory;
@AfterEach
void clearFaults() {
FilesystemPkiStore.clearPublicationCommitFault();
}
@Test
void registrationRequiresCommittedExactSourceAndIsIdempotent() throws Exception {
System.out.println("registrationRequiresCommittedExactSourceAndIsIdempotent");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
try (FilesystemPkiStore store = store("registration");
DefaultPublicationService service = service(store, publisher)) {
Credential credential = credential(store, "registration-one", new byte[] { 1, 2, 3 });
PublicationRequest request = credentialRequest("publication-registration", credential);
assertThrows(IllegalArgumentException.class, () -> service.register(request));
assertEquals(0, publisher.dispatchCalls.get());
store.putCredential(credential);
Set<zeroecho.pki.api.content.DurableContentOwner> ownersBefore =
store.stagedContent().contentOwners(credential.content());
PublicationRecord first = service.register(request);
PublicationRecord replay = service.register(request);
assertEquals(PublicationStatus.PENDING, first.status());
assertEquals(first, replay);
assertEquals(ownersBefore, store.stagedContent().contentOwners(credential.content()));
assertEquals(0, publisher.dispatchCalls.get());
Credential other = credential(store, "registration-two", new byte[] { 4, 5, 6 });
store.putCredential(other);
PublicationRequest conflict = new PublicationRequest(first.publicationId(),
PublicationSourceType.CREDENTIAL, other.credentialId(), TARGET);
assertThrows(IllegalArgumentException.class, () -> service.register(conflict));
PublicationRecord changedTarget = new PublicationRecord(first.publicationId(), first.sourceType(),
first.sourceId(), first.content(),
new PublicationTarget(PublicationTargetType.FILESYSTEM, "other-mirror-v1"), first.status(),
first.attemptNumber(), first.attemptToken(), first.createdAt(), first.updatedAt(),
first.failure(), first.evidence());
assertThrows(IllegalArgumentException.class,
() -> store.replacePublicationRecord(first, changedTarget));
StatusObject status = status(store, "registration-status", new byte[] { 7, 8 });
store.putStatusObject(status);
PublicationRecord statusRecord = service.register(statusRequest("publication-status", status));
assertEquals(status.content(), statusRecord.content());
assertEquals(PublicationSourceType.STATUS_OBJECT, statusRecord.sourceType());
PublicationTarget absent = new PublicationTarget(PublicationTargetType.HTTP, "unconfigured-v1");
assertThrows(IllegalArgumentException.class, () -> service.register(new PublicationRequest(
new PkiId("publication-absent"), PublicationSourceType.CREDENTIAL,
credential.credentialId(), absent)));
System.out.println("...registered states=" + first.status() + "," + statusRecord.status());
}
System.out.println("registrationRequiresCommittedExactSourceAndIsIdempotent...ok");
}
@Test
void storeRejectsForeignContentAndObsoleteSidecarAuthority() throws Exception {
System.out.println("storeRejectsForeignContentAndObsoleteSidecarAuthority");
Path firstRoot = temporaryDirectory.resolve("foreign-first");
Path secondRoot = temporaryDirectory.resolve("foreign-second");
DurableContentReference foreign;
try (FilesystemPkiStore second = new FilesystemPkiStore(secondRoot, FsPkiStoreOptions.defaults())) {
foreign = zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(second.stagedContent(), Encoding.DER,
new byte[] { 9, 9, 9 });
}
try (FilesystemPkiStore first = new FilesystemPkiStore(firstRoot, FsPkiStoreOptions.defaults())) {
Credential credential = credential(first, "foreign", new byte[] { 1 });
first.putCredential(credential);
PublicationRecord invalid = pending("publication-foreign", credential, foreign);
assertThrows(IllegalArgumentException.class, () -> first.createPublicationRecord(invalid));
}
Path obsolete = firstRoot.resolve("publications").resolve("by-id").resolve("legacy.bin");
Files.createDirectories(obsolete.getParent());
Files.write(obsolete, new byte[] { 1, 2 });
assertThrows(IllegalStateException.class,
() -> new FilesystemPkiStore(firstRoot, FsPkiStoreOptions.defaults()));
assertArrayEquals(new byte[] { 1, 2 }, Files.readAllBytes(obsolete));
System.out.println("storeRejectsForeignContentAndObsoleteSidecarAuthority...ok");
}
@Test
void dispatchRetryAndTerminalStatesAreDurable() throws Exception {
System.out.println("dispatchRetryAndTerminalStatesAreDurable");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
try (FilesystemPkiStore store = store("dispatch-states");
DefaultPublicationService service = service(store, publisher)) {
Credential credential = committedCredential(store, "retry", new byte[] { 1, 2, 3, 4 });
PkiId operation = service.register(credentialRequest("publication-retry", credential)).publicationId();
publisher.dispatchOutcome.set(PublicationOutcome.RETRYABLE_FAILURE);
PublicationResult retryable = service.process(operation);
PublicationRecord firstAttempt = service.find(operation).orElseThrow();
String firstToken = firstAttempt.attemptToken().orElseThrow();
assertEquals(PublicationStatus.RETRYABLE_FAILURE, retryable.status());
assertEquals(1L, retryable.attemptNumber());
publisher.dispatchOutcome.set(PublicationOutcome.SUCCESS);
PublicationResult success = service.retry(operation);
PublicationRecord secondAttempt = service.find(operation).orElseThrow();
assertEquals(PublicationStatus.SUCCEEDED, success.status());
assertEquals(2L, success.attemptNumber());
assertNotEquals(firstToken, secondAttempt.attemptToken().orElseThrow());
assertThrows(IllegalArgumentException.class, () -> service.retry(operation));
assertThrows(IllegalArgumentException.class, () -> service.process(operation));
Credential terminalCredential = committedCredential(store, "terminal", new byte[] { 5, 6 });
PkiId terminalId = service.register(
credentialRequest("publication-terminal", terminalCredential)).publicationId();
publisher.dispatchOutcome.set(PublicationOutcome.TERMINAL_FAILURE);
assertEquals(PublicationStatus.TERMINAL_FAILURE, service.process(terminalId).status());
assertThrows(IllegalArgumentException.class, () -> service.retry(terminalId));
assertEquals(3, publisher.dispatchCalls.get());
assertTrue(publisher.maximumReadSize.get() <= 11);
System.out.println("...attempts=" + secondAttempt.attemptNumber());
}
System.out.println("dispatchRetryAndTerminalStatesAreDurable...ok");
}
@Test
void publisherExceptionIsRedactedUnknownAndReconcilesWithSameToken() throws Exception {
System.out.println("publisherExceptionIsRedactedUnknownAndReconcilesWithSameToken");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
try (FilesystemPkiStore store = store("unknown");
DefaultPublicationService service = service(store, publisher)) {
Credential credential = committedCredential(store, "unknown", new byte[] { 1, 3, 5 });
PkiId operation = service.register(credentialRequest("publication-unknown", credential)).publicationId();
publisher.throwOnDispatch.set(true);
PublicationResult unknown = service.process(operation);
PublicationRecord unknownRecord = service.find(operation).orElseThrow();
String token = unknownRecord.attemptToken().orElseThrow();
assertEquals(PublicationStatus.OUTCOME_UNKNOWN, unknown.status());
assertEquals(Optional.of(PublicationFailure.EXTERNAL_OUTCOME_UNKNOWN), unknownRecord.failure());
assertThrows(IllegalArgumentException.class, () -> service.process(operation));
publisher.throwOnDispatch.set(false);
PublicationResult unresolved = service.reconcile(operation);
assertEquals(PublicationStatus.OUTCOME_UNKNOWN, unresolved.status());
assertEquals(token, service.find(operation).orElseThrow().attemptToken().orElseThrow());
publisher.reconciliationOutcome.set(Optional.of(PublicationOutcome.SUCCESS));
PublicationResult resolved = service.reconcile(operation);
assertEquals(PublicationStatus.SUCCEEDED, resolved.status());
assertEquals(token, publisher.reconciliationAttempts.get(1).attemptToken());
assertEquals(1, publisher.dispatchCalls.get());
System.out.println("...token=" + token.substring(0, 12) + "...");
}
System.out.println("publisherExceptionIsRedactedUnknownAndReconcilesWithSameToken...ok");
}
@Test
void resultPersistenceFailureFencesUnknownAndCommittedUncertaintyResolves() throws Exception {
System.out.println("resultPersistenceFailureFencesUnknownAndCommittedUncertaintyResolves");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
try (FilesystemPkiStore store = store("persistence-failure");
DefaultPublicationService service = service(store, publisher)) {
Credential first = committedCredential(store, "persist-first", new byte[] { 1 });
PkiId firstId = service.register(credentialRequest("publication-persist-first", first)).publicationId();
publisher.afterDispatch = () -> FilesystemPkiStore.installPublicationCommitFault(
FilesystemPkiStore.PublicationCommitFaultPoint.REPLACE_BEFORE_COMMIT);
PkiException failure = assertThrows(PkiException.class, () -> service.process(firstId));
assertTrue(failure.getMessage().contains("code=PUBLICATION_RESULT_UNCONFIRMED"));
PublicationRecord unknown = service.find(firstId).orElseThrow();
assertEquals(PublicationStatus.OUTCOME_UNKNOWN, unknown.status());
assertEquals(Optional.of(PublicationFailure.RESULT_PERSISTENCE_UNKNOWN), unknown.failure());
Credential second = committedCredential(store, "persist-second", new byte[] { 2 });
PkiId secondId = service.register(
credentialRequest("publication-persist-second", second)).publicationId();
publisher.afterDispatch = () -> FilesystemPkiStore.installPublicationCommitFault(
FilesystemPkiStore.PublicationCommitFaultPoint.REPLACE_AFTER_COMMIT_AS_UNKNOWN);
PublicationResult resolved = service.process(secondId);
assertEquals(PublicationStatus.SUCCEEDED, resolved.status());
assertEquals(PublicationStatus.SUCCEEDED, service.find(secondId).orElseThrow().status());
Credential third = committedCredential(store, "persist-third", new byte[] { 3 });
FilesystemPkiStore.installPublicationCommitFault(
FilesystemPkiStore.PublicationCommitFaultPoint.CREATE_BEFORE_COMMIT);
assertThrows(IllegalStateException.class,
() -> service.register(credentialRequest("publication-create-failure", third)));
assertTrue(service.find(new PkiId("publication-create-failure")).isEmpty());
System.out.println("...unknown failure=" + unknown.failure().orElseThrow());
}
System.out.println("resultPersistenceFailureFencesUnknownAndCommittedUncertaintyResolves...ok");
}
@Test
void restartConvertsPreparedAttemptToUnknownWithoutDispatch() throws Exception {
System.out.println("restartConvertsPreparedAttemptToUnknownWithoutDispatch");
Path root = temporaryDirectory.resolve("restart");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
PkiId operation = new PkiId("publication-restart");
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults());
DefaultPublicationService service = service(store, publisher)) {
Credential credential = committedCredential(store, "restart", new byte[] { 4, 4 });
PublicationRecord pending = service.register(new PublicationRequest(operation,
PublicationSourceType.CREDENTIAL, credential.credentialId(), TARGET));
PublicationRecord prepared = new PublicationRecord(pending.publicationId(), pending.sourceType(),
pending.sourceId(), pending.content(), pending.target(), PublicationStatus.DISPATCH_PREPARED,
1L, Optional.of("a".repeat(64)), pending.createdAt(), pending.updatedAt(), Optional.empty(),
Optional.empty());
assertTrue(store.replacePublicationRecord(pending, prepared));
}
try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
PublicationRecord record = reopened.getPublicationRecord(operation).orElseThrow();
assertEquals(PublicationStatus.OUTCOME_UNKNOWN, record.status());
assertEquals("a".repeat(64), record.attemptToken().orElseThrow());
assertEquals(0, publisher.dispatchCalls.get());
try (DefaultPublicationService recovered = service(reopened, publisher)) {
assertEquals(PublicationStatus.OUTCOME_UNKNOWN,
recovered.find(operation).orElseThrow().status());
}
}
System.out.println("restartConvertsPreparedAttemptToUnknownWithoutDispatch...ok");
}
@Test
void restartPreservesPendingRetryableSuccessfulAndUnknownStates() throws Exception {
System.out.println("restartPreservesPendingRetryableSuccessfulAndUnknownStates");
Path root = temporaryDirectory.resolve("restart-states");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults());
DefaultPublicationService service = service(store, publisher)) {
Credential pending = committedCredential(store, "restart-pending", new byte[] { 1 });
Credential retryable = committedCredential(store, "restart-retryable", new byte[] { 2 });
Credential succeeded = committedCredential(store, "restart-succeeded", new byte[] { 3 });
Credential unknown = committedCredential(store, "restart-unknown", new byte[] { 4 });
service.register(credentialRequest("publication-restart-pending", pending));
service.register(credentialRequest("publication-restart-retryable", retryable));
service.register(credentialRequest("publication-restart-succeeded", succeeded));
service.register(credentialRequest("publication-restart-unknown", unknown));
publisher.dispatchOutcome.set(PublicationOutcome.RETRYABLE_FAILURE);
service.process(new PkiId("publication-restart-retryable"));
publisher.dispatchOutcome.set(PublicationOutcome.SUCCESS);
service.process(new PkiId("publication-restart-succeeded"));
publisher.throwOnDispatch.set(true);
service.process(new PkiId("publication-restart-unknown"));
}
RecordingPublisher reopenedPublisher = new RecordingPublisher(TARGET);
try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults());
DefaultPublicationService recovered = service(reopened, reopenedPublisher)) {
assertEquals(PublicationStatus.PENDING,
recovered.find(new PkiId("publication-restart-pending")).orElseThrow().status());
assertEquals(PublicationStatus.RETRYABLE_FAILURE,
recovered.find(new PkiId("publication-restart-retryable")).orElseThrow().status());
assertEquals(PublicationStatus.SUCCEEDED,
recovered.find(new PkiId("publication-restart-succeeded")).orElseThrow().status());
assertEquals(PublicationStatus.OUTCOME_UNKNOWN,
recovered.find(new PkiId("publication-restart-unknown")).orElseThrow().status());
assertEquals(0, reopenedPublisher.dispatchCalls.get());
assertTrue(reopened.getCredential(new PkiId("credential:restart-unknown")).isPresent());
System.out.println("...recovered states=4");
}
System.out.println("restartPreservesPendingRetryableSuccessfulAndUnknownStates...ok");
}
@Test
void concurrentSameOperationDoesNotDuplicateAndUnrelatedProgresses() throws Exception {
System.out.println("concurrentSameOperationDoesNotDuplicateAndUnrelatedProgresses");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
publisher.blockedPublication.set(new PkiId("publication-blocked"));
try (FilesystemPkiStore store = store("concurrency");
DefaultPublicationService service = service(store, publisher);
ExecutorService executor = Executors.newFixedThreadPool(2)) {
Credential blocked = committedCredential(store, "blocked", new byte[] { 1, 2 });
Credential unrelated = committedCredential(store, "unrelated", new byte[] { 3, 4 });
service.register(credentialRequest("publication-blocked", blocked));
service.register(credentialRequest("publication-unrelated", unrelated));
Future<PublicationResult> first = executor.submit(
() -> service.process(new PkiId("publication-blocked")));
assertTrue(publisher.entered.await(5, TimeUnit.SECONDS));
assertThrows(IllegalArgumentException.class,
() -> service.process(new PkiId("publication-blocked")));
Future<PublicationResult> second = executor.submit(
() -> service.process(new PkiId("publication-unrelated")));
assertEquals(PublicationStatus.SUCCEEDED, second.get(5, TimeUnit.SECONDS).status());
service.close();
assertThrows(IllegalStateException.class,
() -> service.find(new PkiId("publication-unrelated")));
publisher.release.countDown();
assertEquals(PublicationStatus.SUCCEEDED, first.get(5, TimeUnit.SECONDS).status());
assertEquals(2, publisher.dispatchCalls.get());
System.out.println("...dispatch calls=" + publisher.dispatchCalls.get());
}
System.out.println("concurrentSameOperationDoesNotDuplicateAndUnrelatedProgresses...ok");
}
@Test
void secondServiceDoesNotReclassifyAnActiveDispatch() throws Exception {
System.out.println("secondServiceDoesNotReclassifyAnActiveDispatch");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
PkiId operation = new PkiId("publication-two-services");
publisher.blockedPublication.set(operation);
try (FilesystemPkiStore store = store("two-services");
DefaultPublicationService first = service(store, publisher);
ExecutorService executor = Executors.newSingleThreadExecutor()) {
Credential credential = committedCredential(store, "two-services", new byte[] { 7, 7 });
first.register(new PublicationRequest(operation, PublicationSourceType.CREDENTIAL,
credential.credentialId(), TARGET));
Future<PublicationResult> result = executor.submit(() -> first.process(operation));
assertTrue(publisher.entered.await(5L, TimeUnit.SECONDS));
try (DefaultPublicationService second = service(store, publisher)) {
assertEquals(PublicationStatus.DISPATCH_PREPARED,
second.find(operation).orElseThrow().status());
assertThrows(IllegalArgumentException.class, () -> second.reconcile(operation));
}
publisher.release.countDown();
assertEquals(PublicationStatus.SUCCEEDED, result.get(5L, TimeUnit.SECONDS).status());
assertEquals(1, publisher.dispatchCalls.get());
System.out.println("...active state preserved");
}
System.out.println("secondServiceDoesNotReclassifyAnActiveDispatch...ok");
}
@Test
void cursorIsStableFilteredAndCloseable() throws Exception {
System.out.println("cursorIsStableFilteredAndCloseable");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
try (FilesystemPkiStore store = store("cursor");
DefaultPublicationService service = service(store, publisher)) {
Credential credential = committedCredential(store, "cursor-credential", new byte[] { 1 });
StatusObject status = status(store, "cursor-status", new byte[] { 2 });
store.putStatusObject(status);
service.register(credentialRequest("publication-cursor-credential", credential));
PublicationCursor cursor = service.openPublications(new PublicationQuery(Optional.empty(),
Optional.empty(), Optional.empty(), Optional.of(PublicationSourceType.STATUS_OBJECT)));
service.register(statusRequest("publication-cursor-status", status));
assertTrue(cursor.next(CancellationSignal.NONE).isEmpty());
cursor.close();
assertThrows(IllegalStateException.class, () -> cursor.next(CancellationSignal.NONE));
try (PublicationCursor current = service.openPublications(new PublicationQuery(Optional.empty(),
Optional.empty(), Optional.empty(), Optional.of(PublicationSourceType.STATUS_OBJECT)))) {
assertEquals(new PkiId("publication-cursor-status"),
current.next(CancellationSignal.NONE).orElseThrow().publicationId());
assertTrue(current.next(CancellationSignal.NONE).isEmpty());
}
}
System.out.println("cursorIsStableFilteredAndCloseable...ok");
}
@Test
void snapshotReconstructsPublicationMetadataWithoutSidecarAuthority() throws Exception {
System.out.println("snapshotReconstructsPublicationMetadataWithoutSidecarAuthority");
Path sourceRoot = temporaryDirectory.resolve("snapshot-source");
Path snapshotRoot = temporaryDirectory.resolve("snapshot-target");
RecordingPublisher publisher = new RecordingPublisher(TARGET);
PkiId operation = new PkiId("publication-snapshot");
String sourceContentId;
try (FilesystemPkiStore source = new FilesystemPkiStore(sourceRoot, FsPkiStoreOptions.defaults());
DefaultPublicationService service = service(source, publisher)) {
Credential credential = committedCredential(source, "snapshot", new byte[] { 8, 6, 7, 5 });
sourceContentId = credential.content().contentId();
service.register(new PublicationRequest(operation, PublicationSourceType.CREDENTIAL,
credential.credentialId(), TARGET));
assertEquals(PublicationStatus.SUCCEEDED, service.process(operation).status());
source.exportSnapshot(snapshotRoot, NOW.plusSeconds(1L));
}
assertFalse(Files.exists(snapshotRoot.resolve("publications")));
try (FilesystemPkiStore restored = new FilesystemPkiStore(snapshotRoot, FsPkiStoreOptions.defaults())) {
PublicationRecord record = restored.getPublicationRecord(operation).orElseThrow();
assertEquals(PublicationStatus.SUCCEEDED, record.status());
assertNotEquals(sourceContentId, record.content().contentId());
assertEquals(restored.getCredential(record.sourceId()).orElseThrow().content(), record.content());
}
System.out.println("snapshotReconstructsPublicationMetadataWithoutSidecarAuthority...ok");
}
@Test
void snapshotRemintingPreservesPendingAndRetryAttemptTokens() throws Exception {
System.out.println("snapshotRemintingPreservesPendingAndRetryAttemptTokens");
Path sourceRoot = temporaryDirectory.resolve("snapshot-token-source");
Path snapshotRoot = temporaryDirectory.resolve("snapshot-token-target");
PkiId pendingId = new PkiId("publication-snapshot-pending-token");
PkiId retryId = new PkiId("publication-snapshot-retry-token");
RecordingPublisher sourcePublisher = new RecordingPublisher(TARGET);
try (FilesystemPkiStore source = new FilesystemPkiStore(sourceRoot, FsPkiStoreOptions.defaults());
DefaultPublicationService service = service(source, sourcePublisher)) {
Credential pending = committedCredential(source, "snapshot-pending-token", new byte[] { 1, 4 });
Credential retryable = committedCredential(source, "snapshot-retry-token", new byte[] { 2, 8 });
service.register(new PublicationRequest(pendingId, PublicationSourceType.CREDENTIAL,
pending.credentialId(), TARGET));
service.register(new PublicationRequest(retryId, PublicationSourceType.CREDENTIAL,
retryable.credentialId(), TARGET));
sourcePublisher.dispatchOutcome.set(PublicationOutcome.RETRYABLE_FAILURE);
service.process(retryId);
source.exportSnapshot(snapshotRoot, NOW.plusSeconds(1L));
sourcePublisher.dispatchOutcome.set(PublicationOutcome.SUCCESS);
service.process(pendingId);
service.retry(retryId);
}
RecordingPublisher restoredPublisher = new RecordingPublisher(TARGET);
try (FilesystemPkiStore restored = new FilesystemPkiStore(snapshotRoot, FsPkiStoreOptions.defaults());
DefaultPublicationService service = service(restored, restoredPublisher)) {
service.process(pendingId);
service.retry(retryId);
}
assertEquals(attemptToken(sourcePublisher, pendingId, 1L),
attemptToken(restoredPublisher, pendingId, 1L));
assertEquals(attemptToken(sourcePublisher, retryId, 2L),
attemptToken(restoredPublisher, retryId, 2L));
System.out.println("...snapshot-stable tokens=2");
System.out.println("snapshotRemintingPreservesPendingAndRetryAttemptTokens...ok");
}
@Test
void strictCodecRejectsTrailingAndImpossiblePublicationMetadata() throws Exception {
System.out.println("strictCodecRejectsTrailingAndImpossiblePublicationMetadata");
try (FilesystemPkiStore store = store("codec")) {
Credential credential = committedCredential(store, "codec", new byte[] { 1, 2 });
PublicationRecord record = pending("publication-codec", credential, credential.content());
byte[] encoded = PublicationRecordCodec.encode(record);
byte[] trailing = java.util.Arrays.copyOf(encoded, encoded.length + 1);
assertThrows(IOException.class, () -> PublicationRecordCodec.decode(trailing, store.stagedContent()));
encoded[0] ^= 0x01;
assertThrows(IOException.class, () -> PublicationRecordCodec.decode(encoded, store.stagedContent()));
}
System.out.println("strictCodecRejectsTrailingAndImpossiblePublicationMetadata...ok");
}
private FilesystemPkiStore store(String suffix) {
return new FilesystemPkiStore(temporaryDirectory.resolve(suffix), FsPkiStoreOptions.defaults());
}
private static DefaultPublicationService service(FilesystemPkiStore store, Publisher publisher) {
return new DefaultPublicationService(store, CLOCK, List.of(publisher));
}
private static Credential committedCredential(FilesystemPkiStore store, String suffix, byte[] bytes)
throws IOException {
Credential credential = credential(store, suffix, bytes);
store.putCredential(credential);
return credential;
}
private static Credential credential(FilesystemPkiStore store, String suffix, byte[] bytes) throws IOException {
DurableContentReference content = zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(
store.stagedContent(), Encoding.DER, bytes);
return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"),
new IssuerRef(new PkiId("ca:publication")), new SubjectRef("CN=" + suffix),
new Validity(NOW.minusSeconds(60L), NOW.plusSeconds(3600L)), suffix,
new PkiId("key:" + suffix),
new CaProfileBinding(new zeroecho.pki.api.profile.CertificateProfileRef(
"default", 1L, new byte[32])), CredentialStatus.ISSUED, content, new SimpleAttributeSet());
}
private static StatusObject status(FilesystemPkiStore store, String suffix, byte[] bytes) throws IOException {
DurableContentReference content = zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(
store.stagedContent(), Encoding.DER, bytes);
return new StatusObject(new PkiId("status:" + suffix), new FormatId("x509"),
new PkiId("ca:publication"), StatusObjectType.CRL, NOW, Optional.of(NOW.plusSeconds(60L)),
content, new SimpleAttributeSet());
}
private static PublicationRequest credentialRequest(String publicationId, Credential credential) {
return new PublicationRequest(new PkiId(publicationId), PublicationSourceType.CREDENTIAL,
credential.credentialId(), TARGET);
}
private static PublicationRequest statusRequest(String publicationId, StatusObject status) {
return new PublicationRequest(new PkiId(publicationId), PublicationSourceType.STATUS_OBJECT,
status.statusObjectId(), TARGET);
}
private static PublicationRecord pending(String publicationId, Credential credential,
DurableContentReference content) {
return new PublicationRecord(new PkiId(publicationId), PublicationSourceType.CREDENTIAL,
credential.credentialId(), content, TARGET, PublicationStatus.PENDING, 0L, Optional.empty(),
NOW, NOW, Optional.empty(), Optional.empty());
}
private static String attemptToken(RecordingPublisher publisher, PkiId publicationId, long attemptNumber) {
return publisher.dispatchAttempts.stream()
.filter(attempt -> attempt.publicationId().equals(publicationId)
&& attempt.attemptNumber() == attemptNumber)
.findFirst().orElseThrow().attemptToken();
}
/** Deterministic streaming publisher with controllable outcomes and barriers. */
private static final class RecordingPublisher implements Publisher {
private final PublicationTarget target;
private final AtomicInteger dispatchCalls = new AtomicInteger();
private final AtomicInteger maximumReadSize = new AtomicInteger();
private final AtomicReference<PublicationOutcome> dispatchOutcome =
new AtomicReference<>(PublicationOutcome.SUCCESS);
private final AtomicReference<Optional<PublicationOutcome>> reconciliationOutcome =
new AtomicReference<>(Optional.empty());
private final AtomicBoolean throwOnDispatch = new AtomicBoolean();
private final List<PublicationAttempt> reconciliationAttempts = java.util.Collections.synchronizedList(
new ArrayList<>());
private final List<PublicationAttempt> dispatchAttempts = java.util.Collections.synchronizedList(
new ArrayList<>());
private final AtomicReference<PkiId> blockedPublication = new AtomicReference<>();
private final CountDownLatch entered = new CountDownLatch(1);
private final CountDownLatch release = new CountDownLatch(1);
private volatile Runnable afterDispatch = () -> { };
private RecordingPublisher(PublicationTarget target) {
this.target = target;
}
@Override
public PublicationTarget target() {
return target;
}
@Override
public PublicationOutcome publish(PublicationAttempt attempt, RepeatableContent payload) {
dispatchCalls.incrementAndGet();
dispatchAttempts.add(attempt);
try (InputStream input = payload.openStream()) {
byte[] buffer = new byte[11];
while (true) {
int count = input.read(buffer);
if (count < 0) {
break;
}
maximumReadSize.accumulateAndGet(count, Math::max);
}
if (attempt.publicationId().equals(blockedPublication.get())) {
entered.countDown();
if (!release.await(5L, TimeUnit.SECONDS)) {
throw new IllegalStateException("publisher barrier timeout");
}
}
} catch (IOException exception) {
throw new IllegalStateException("provider-secret-path", exception);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("provider-secret-interrupted", exception);
}
if (throwOnDispatch.get()) {
throw new IllegalStateException("provider-secret-token-and-path");
}
afterDispatch.run();
afterDispatch = () -> { };
return dispatchOutcome.get();
}
@Override
public Optional<PublicationOutcome> reconcile(PublicationAttempt attempt) {
reconciliationAttempts.add(attempt);
return reconciliationOutcome.get();
}
}
}

View File

@@ -111,6 +111,7 @@ import zeroecho.pki.api.profile.SubjectPolicy;
import zeroecho.pki.api.profile.SubjectRdnRule; import zeroecho.pki.api.profile.SubjectRdnRule;
import zeroecho.pki.api.profile.SubjectRdnType; import zeroecho.pki.api.profile.SubjectRdnType;
import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationRecord;
import zeroecho.pki.api.publication.PublicationSourceType;
import zeroecho.pki.api.publication.PublicationStatus; import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget; import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType; import zeroecho.pki.api.publication.PublicationTargetType;
@@ -175,16 +176,17 @@ public final class FilesystemPkiStoreTest {
zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), Encoding.DER, zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), Encoding.DER,
new byte[] { 1, 2 }), new byte[] { 1, 2 }),
attributes); attributes);
PublicationRecord publication = new PublicationRecord(new PkiId("publication-all"), now, PublicationRecord publication = new PublicationRecord(new PkiId("publication-all"),
new PublicationTarget(PublicationTargetType.FILESYSTEM, "target-all", attributes), PublicationSourceType.CREDENTIAL, credential.credentialId(), credential.content(),
credential.credentialId(), "CREDENTIAL", PublicationStatus.PUBLISHED); new PublicationTarget(PublicationTargetType.FILESYSTEM, "target-all"),
PublicationStatus.PENDING, 0L, Optional.empty(), now, now, Optional.empty(), Optional.empty());
store.putCa(ca); store.putCa(ca);
store.putCredential(credential); store.putCredential(credential);
store.putRequest(request); store.putRequest(request);
RevocationRecord revocation = store.transitionRevocation(new RevocationCommand.RevokePermanently( RevocationRecord revocation = store.transitionRevocation(new RevocationCommand.RevokePermanently(
credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now); credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now);
store.putStatusObject(status); store.putStatusObject(status);
store.putPublicationRecord(publication); store.createPublicationRecord(publication);
importProfile(store, profile, now); importProfile(store, profile, now);
store.activateProfile(profile.profileId(), 1); store.activateProfile(profile.profileId(), 1);
store.putPolicyTrace(trace); store.putPolicyTrace(trace);
@@ -198,7 +200,8 @@ public final class FilesystemPkiStoreTest {
store.getRevocation(revocation.credentialId()).orElseThrow().credentialId()); store.getRevocation(revocation.credentialId()).orElseThrow().credentialId());
assertEquals(status.statusObjectId(), assertEquals(status.statusObjectId(),
store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId()); store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId());
assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId()); assertEquals(publication.publicationId(),
store.getPublicationRecord(publication.publicationId()).orElseThrow().publicationId());
assertEquals(profile, store.requireActiveProfile(profile.profileId()).profile()); assertEquals(profile, store.requireActiveProfile(profile.profileId()).profile());
assertEquals(trace, store.getPolicyTrace(trace.decisionId()).orElseThrow()); assertEquals(trace, store.getPolicyTrace(trace.decisionId()).orElseThrow());
assertEquals(workflow.opId(), store.getWorkflowState(workflow.opId()).orElseThrow().opId()); assertEquals(workflow.opId(), store.getWorkflowState(workflow.opId()).orElseThrow().opId());
@@ -241,7 +244,9 @@ public final class FilesystemPkiStoreTest {
StatusObjectType.CRL, Instant.EPOCH.plusSeconds(1L), Optional.empty(), secondReference, StatusObjectType.CRL, Instant.EPOCH.plusSeconds(1L), Optional.empty(), secondReference,
TestObjects.emptyAttributes())); TestObjects.emptyAttributes()));
assertEquals(2, store.listStatusObjects(new PkiId("ca-status")).size()); assertEquals(2, store.listStatusObjects(new PkiId("ca-status")).size());
assertTrue(store.listPublicationRecords().isEmpty()); try (zeroecho.pki.api.publication.PublicationCursor cursor = store.openPublicationRecords()) {
assertTrue(cursor.next(CancellationSignal.NONE).isEmpty());
}
assertFalse(Files.exists(root.resolve("staged-content").resolve(firstReference.contentId() + ".owners"))); assertFalse(Files.exists(root.resolve("staged-content").resolve(firstReference.contentId() + ".owners")));
} }