feat: add universal AsyncBus infrastructure

Introduce a generic asynchronous bus used for internal PKI workflows,
with resilient sweep support and symmetric primitive/wrapper type
compatibility for dispatch handling.

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
This commit is contained in:
2026-01-02 22:28:53 +01:00
parent e01d95f48e
commit adfa0b4b51
41 changed files with 4446 additions and 892 deletions

View File

@@ -34,32 +34,76 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki; package zeroecho.pki;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.spi.bootstrap.PkiBootstrap;
import zeroecho.pki.util.async.AsyncBus;
/** /**
* Minimal bootstrap entry point for the {@code pki} module. * Minimal bootstrap entry point for the {@code pki} module.
* *
* <p> * <p>
* This class is intentionally limited to process bootstrap concerns only: * This class is intentionally limited to process bootstrap and hosting
* concerns:
* </p> * </p>
* <ul> * <ul>
* <li>initializes JUL logging conventions (without leaking secrets),</li> * <li>initializes JUL logging conventions (without leaking secrets),</li>
* <li>installs an uncaught-exception handler,</li> * <li>installs an uncaught-exception handler,</li>
* <li>emits a minimal startup/shutdown signal.</li> * <li>composes PKI runtime components using {@link PkiBootstrap},</li>
* <li>hosts the async-bus maintenance loop via periodic
* {@code sweep(...)},</li>
* <li>waits until process termination (Ctrl+C) and performs orderly
* shutdown.</li>
* </ul> * </ul>
* *
* <h2>Async bus sweep</h2>
* <p> * <p>
* No cryptography, persistence, or domain/business logic is performed here. The * The async bus requires periodic {@link AsyncBus#sweep(Instant)} calls to:
* public PKI API resides under {@code zeroecho.pki.api.*} and is not modified * expire operations past their deadline and re-synchronize open operations
* by this bootstrap. * after restart (depending on the bus implementation).
* </p>
*
* <h2>Security</h2>
* <p>
* Command-line arguments and configuration values are never logged because they
* can contain sensitive material (paths, tokens, passphrases).
* </p> * </p>
*/ */
@SuppressWarnings("PMD.DoNotUseThreads")
public final class PkiApplication { public final class PkiApplication {
private static final Logger LOG = Logger.getLogger(PkiApplication.class.getName()); private static final Logger LOG = Logger.getLogger(PkiApplication.class.getName());
/**
* System property controlling the async sweep interval in milliseconds.
*
* <p>
* If missing or invalid, a safe default is used.
* </p>
*/
private static final String PROP_ASYNC_SWEEP_INTERVAL_MS = "zeroecho.pki.async.sweepIntervalMs";
/**
* Default async sweep interval used when not configured.
*/
private static final Duration DEFAULT_ASYNC_SWEEP_INTERVAL = Duration.ofSeconds(2L);
/**
* Shutdown grace time for the sweep executor.
*/
private static final Duration SWEEP_SHUTDOWN_GRACE = Duration.ofSeconds(10L);
private PkiApplication() { private PkiApplication() {
throw new AssertionError("No instances."); throw new AssertionError("No instances.");
} }
@@ -82,18 +126,184 @@ public final class PkiApplication {
LOG.info("ZeroEcho PKI starting."); LOG.info("ZeroEcho PKI starting.");
Runtime.getRuntime().addShutdownHook(new Thread(() -> { // NOPMD CountDownLatch shutdownLatch = new CountDownLatch(1);
Logger shutdownLogger = Logger.getLogger(PkiApplication.class.getName());
PkiLogging.emitShutdownMessage(shutdownLogger, "ZeroEcho PKI stopping."); // closed in the shutdown routine
}, "zeroecho-pki-shutdown")); ScheduledExecutorService sweepExecutor = Executors.newSingleThreadScheduledExecutor(new SweepThreadFactory()); // NOPMD
Runtime.getRuntime()
.addShutdownHook(new Thread(new ShutdownHook(sweepExecutor, shutdownLatch), "zeroecho-pki-shutdown"));
try { try {
// Intentionally no business logic yet. Bootstrap only. AsyncBus<PkiId, Principal, String, Object> asyncBus = PkiBootstrap.openAsyncBus();
LOG.info("ZeroEcho PKI started (bootstrap only).");
Duration sweepInterval = readSweepInterval(DEFAULT_ASYNC_SWEEP_INTERVAL);
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Async bus sweep enabled; intervalMs={0}", sweepInterval.toMillis());
}
sweepExecutor.scheduleWithFixedDelay(new SweepTask(asyncBus), 0L, sweepInterval.toMillis(),
TimeUnit.MILLISECONDS);
LOG.info("ZeroEcho PKI started.");
// Keep process alive until Ctrl+C (or other shutdown signal).
awaitShutdown(shutdownLatch);
} catch (RuntimeException ex) { // NOPMD } catch (RuntimeException ex) { // NOPMD
// Do not include user-provided inputs in the message; log the exception object. // Do not include user-provided inputs in the message; log the exception object.
LOG.log(Level.SEVERE, "Fatal error during PKI bootstrap.", ex); LOG.log(Level.SEVERE, "Fatal error during PKI bootstrap.", ex);
throw ex; throw ex;
} }
} }
private static void awaitShutdown(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Interrupted while awaiting shutdown.", ex);
}
}
}
private static Duration readSweepInterval(Duration defaultValue) {
String raw = System.getProperty(PROP_ASYNC_SWEEP_INTERVAL_MS);
if (raw == null || raw.isBlank()) {
return defaultValue;
}
try {
long ms = Long.parseLong(raw);
if (ms <= 0L) { // NOPMD
return defaultValue;
}
return Duration.ofMillis(ms);
} catch (NumberFormatException ex) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Invalid async sweep interval system property; using default.", ex);
}
return defaultValue;
}
}
/**
* Periodic maintenance task for asynchronous PKI infrastructure.
*
* <p>
* {@code SweepTask} represents a resilient, repeatable unit of work that
* invokes time-based maintenance logic on an {@link AsyncBus} instance. It is
* intended to be scheduled at a fixed rate by a background executor and must
* tolerate partial failures without disrupting the surrounding runtime
* environment.
* </p>
*
* <p>
* The task uses the current wall-clock time as a reference for sweep operations
* and deliberately suppresses runtime failures, logging them for diagnostic
* purposes while allowing future executions to proceed.
* </p>
*
* <p>
* This class is internal to the PKI bootstrap and lifecycle management logic
* and is not part of the public API surface.
* </p>
*/
private static final class SweepTask implements Runnable {
private final AsyncBus<PkiId, Principal, String, Object> asyncBus;
private SweepTask(AsyncBus<PkiId, Principal, String, Object> asyncBus) {
this.asyncBus = Objects.requireNonNull(asyncBus, "asyncBus");
}
@Override
public void run() {
Instant now = Instant.now();
try {
asyncBus.sweep(now);
} catch (RuntimeException ex) { // NOPMD
// Sweep must be resilient; log and continue.
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Async sweep failed.", ex);
}
}
}
}
/**
* JVM shutdown hook coordinating termination of asynchronous sweep services.
*
* <p>
* {@code ShutdownHook} is responsible for orchestrating an orderly shutdown of
* background sweep execution during JVM termination. It emits a structured
* shutdown message, initiates executor shutdown, and enforces a bounded grace
* period for task completion.
* </p>
*
* <p>
* The hook guarantees that shutdown coordination always completes by
* decrementing the associated {@link CountDownLatch}, regardless of whether
* shutdown is graceful, forced, or interrupted.
* </p>
*
* <p>
* This class must never throw exceptions or prevent JVM termination. All
* failure modes are handled internally.
* </p>
*/
private static final class ShutdownHook implements Runnable {
private final ScheduledExecutorService sweepExecutor;
private final CountDownLatch latch;
private ShutdownHook(ScheduledExecutorService sweepExecutor, CountDownLatch latch) {
this.sweepExecutor = Objects.requireNonNull(sweepExecutor, "sweepExecutor");
this.latch = Objects.requireNonNull(latch, "latch");
}
@Override
public void run() {
Logger shutdownLogger = Logger.getLogger(PkiApplication.class.getName());
PkiLogging.emitShutdownMessage(shutdownLogger, "ZeroEcho PKI stopping.");
sweepExecutor.shutdown();
try {
boolean ok = sweepExecutor.awaitTermination(SWEEP_SHUTDOWN_GRACE.toMillis(), TimeUnit.MILLISECONDS);
if (!ok) {
sweepExecutor.shutdownNow();
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
sweepExecutor.shutdownNow();
} finally {
latch.countDown();
}
}
}
/**
* Thread factory for asynchronous sweep execution.
*
* <p>
* {@code SweepThreadFactory} creates daemon threads with a stable and
* descriptive naming convention suitable for operational diagnostics and log
* correlation. Threads produced by this factory are intentionally marked as
* daemon threads so that they do not prolong JVM lifetime.
* </p>
*
* <p>
* The factory performs no additional customization such as priority changes or
* uncaught-exception handlers, relying instead on executor-level policies.
* </p>
*/
private static final class SweepThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "zeroecho-pki-async-sweep");
t.setDaemon(true);
return t;
}
}
} }

View File

@@ -62,4 +62,9 @@ public record PkiId(String value) {
throw new IllegalArgumentException("value must not be null/blank"); throw new IllegalArgumentException("value must not be null/blank");
} }
} }
@Override
public String toString() {
return value;
}
} }

View File

@@ -0,0 +1,114 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.api.orch;
/**
* Durability policy for workflow continuation data managed by orchestrators.
*
* <p>
* Orchestrators coordinate long-running operations (e.g., human approvals,
* remote signing, certificate issuance). Some workflows require local
* continuation state to resume after a JVM restart. This enum defines how such
* state is handled.
* </p>
*
* <h2>Examples</h2>
* <ul>
* <li>A remote signature workflow may wait for hours. If the remote system is
* the source of truth and all necessary data can be re-derived from external
* references (operation ID, request ID), the workflow can run in a durable mode
* without storing sensitive material locally.</li>
* <li>If a workflow requires local ephemeral secrets (e.g., a one-time approval
* challenge that cannot be recomputed), strict mode should abort on restart
* (fail-closed) unless encrypted persistence is configured.</li>
* </ul>
*/
public enum OrchestrationDurabilityPolicy {
/**
* Fail-closed behavior.
*
* <p>
* The orchestrator does not persist continuation state that would be required
* to resume the workflow after a process restart. If the process restarts,
* operations that cannot be safely resumed must be cancelled or marked as
* failed deterministically.
* </p>
*
* <p>
* Example: A workflow requires an ephemeral secret that is only available in
* memory. In this mode, the operation is aborted on restart and the client must
* resubmit.
* </p>
*/
STRICT_ABORT_ON_RESTART,
/**
* Durable operation using minimal non-sensitive state.
*
* <p>
* The orchestrator persists only references and non-sensitive continuation
* state, sufficient to resume the workflow after restart. Sensitive payloads
* must remain outside the local state (e.g., in dedicated stores or remote
* systems).
* </p>
*
* <p>
* Example: An operation persists a reference to a stored CSR and a reference to
* the selected certificate profile, but does not persist raw key material or
* private tokens.
* </p>
*/
DURABLE_MIN_STATE,
/**
* Durable operation with encrypted continuation state.
*
* <p>
* The orchestrator persists continuation state encrypted (e.g., AES-256/GCM)
* using a key supplied by external configuration (environment variable, secret
* store, or KMS). This allows workflows to resume after restart even when they
* require sensitive local state.
* </p>
*
* <p>
* Example: A workflow stores an encrypted blob containing a transient approval
* context. The encryption uses authenticated data derived from operation ID,
* operation type, and owner identity to prevent swapping blobs across
* operations.
* </p>
*/
DURABLE_ENCRYPTED_STATE
}

View File

@@ -0,0 +1,120 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.api.orch;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
/**
* Persisted continuation state for an orchestrated workflow.
*
* <p>
* This record is used by orchestrators to persist the minimum information
* needed to resume long-running workflows after a process restart, according to
* the selected {@link OrchestrationDurabilityPolicy}.
* </p>
*
* <h2>Storage model</h2>
* <p>
* The async bus is the authoritative source for operation lifecycle and status,
* but it does not persist sensitive payloads and may be configured not to
* persist results. Orchestrators therefore persist workflow-specific
* continuation state through {@code PkiStore}.
* </p>
*
* <h2>Security</h2>
* <ul>
* <li>{@link #payload()} may contain sensitive information. It must not be
* logged.</li>
* <li>When {@link #durabilityPolicy()} is
* {@link OrchestrationDurabilityPolicy#DURABLE_ENCRYPTED_STATE},
* {@link #payloadEncoding()} must reflect an encrypted form and the payload
* must be authenticated using AAD derived from operation identity.</li>
* </ul>
*
* @param opId async operation identifier (never {@code null})
* @param type operation type string (never {@code null})
* @param owner operation owner (never {@code null})
* @param durabilityPolicy durability policy (never {@code null})
* @param createdAt creation time (never {@code null})
* @param updatedAt last update time (never {@code null})
* @param expiresAt expiration/deadline (never {@code null})
* @param payloadEncoding encoding of {@link #payload()} (never {@code null})
* @param payload optional persisted continuation payload
*/
public record WorkflowStateRecord(PkiId opId, String type, Principal owner,
OrchestrationDurabilityPolicy durabilityPolicy, Instant createdAt, Instant updatedAt, Instant expiresAt,
Encoding payloadEncoding, Optional<EncodedObject> payload) {
public WorkflowStateRecord {
Objects.requireNonNull(opId, "opId");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(durabilityPolicy, "durabilityPolicy");
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(updatedAt, "updatedAt");
Objects.requireNonNull(expiresAt, "expiresAt");
Objects.requireNonNull(payloadEncoding, "payloadEncoding");
Objects.requireNonNull(payload, "payload");
if (type.isBlank()) {
throw new IllegalArgumentException("type must not be blank.");
}
if (!expiresAt.isAfter(createdAt) && !expiresAt.equals(createdAt)) {
// Allow expiresAt == createdAt for immediate-expire test scenarios, but reject
// negative durations.
if (expiresAt.isBefore(createdAt)) { // NOPMD
throw new IllegalArgumentException("expiresAt must not be before createdAt.");
}
}
}
/**
* Returns whether the record is expired at the provided time.
*
* @param now current time (never {@code null})
* @return {@code true} if {@code now} is strictly after {@link #expiresAt()}
*/
public boolean isExpiredAt(Instant now) {
Objects.requireNonNull(now, "now");
return now.isAfter(expiresAt);
}
}

View File

@@ -0,0 +1,114 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Orchestration and workflow durability API for ZeroEcho PKI.
*
* <p>
* This package defines stable domain abstractions related to <em>long-running
* PKI workflows</em> and their durability requirements. It is concerned with
* describing <strong>what must be persisted and how durable such persistence
* must be</strong>, not with <em>how</em> the persistence is implemented.
* </p>
*
* <h2>Scope and responsibilities</h2>
* <p>
* The orchestration layer in ZeroEcho PKI coordinates multi-step operations
* such as certificate issuance, revocation processing, key rollover, or
* publication workflows. These operations:
* </p>
* <ul>
* <li>may span multiple logical steps,</li>
* <li>may involve external systems or asynchronous callbacks,</li>
* <li>must tolerate restarts, crashes, or redeployments.</li>
* </ul>
*
* <p>
* This package provides the minimal API necessary to:
* </p>
* <ul>
* <li>describe the required durability guarantees for workflow state,</li>
* <li>represent persisted workflow state in a storage-agnostic form,</li>
* <li>enable deterministic recovery and audit of workflow progression.</li>
* </ul>
*
* <h2>Key abstractions</h2>
* <ul>
* <li>{@link zeroecho.pki.api.orch.OrchestrationDurabilityPolicy
* OrchestrationDurabilityPolicy} defines <em>how durable</em> orchestration
* state must be (e.g. write-through, buffered, best-effort), allowing different
* operational trade-offs without changing orchestration logic.</li>
* <li>{@link zeroecho.pki.api.orch.WorkflowStateRecord WorkflowStateRecord}
* represents an immutable, persisted snapshot of workflow state at a specific
* point in time, suitable for recovery, audit, and historical inspection.</li>
* </ul>
*
* <h2>Immutability and history</h2>
* <p>
* Workflow state records are designed to be immutable once persisted.
* Implementations are expected to append new state records rather than
* overwrite existing ones, enabling:
* </p>
* <ul>
* <li>full audit trails,</li>
* <li>temporal queries ("state at time T"),</li>
* <li>post-mortem analysis of failed or aborted workflows.</li>
* </ul>
*
* <p>
* Whether and for how long historical records are retained is governed by
* policy and implementation, but the API itself is intentionally compatible
* with history-preserving stores.
* </p>
*
* <h2>API stability and integration</h2>
* <p>
* The types in this package are part of the public PKI API surface. They are
* intended to be usable across different runtime environments, including:
* </p>
* <ul>
* <li>standalone CLI applications,</li>
* <li>long-running server processes,</li>
* <li>container-managed frameworks such as Spring or Micronaut.</li>
* </ul>
*
* <p>
* No assumptions are made about the underlying persistence mechanism
* (filesystem, database, distributed log, etc.). Such concerns are handled by
* SPI and implementation layers.
* </p>
*
* @since 1.0
*/
package zeroecho.pki.api.orch;

View File

@@ -0,0 +1,112 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.async;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.async.AsyncBusProvider;
import zeroecho.pki.util.async.AsyncBus;
import zeroecho.pki.util.async.codec.ResultCodec;
import zeroecho.pki.util.async.impl.AppendOnlyLineStore;
import zeroecho.pki.util.async.impl.DurableAsyncBus;
/**
* Default async bus provider: in-memory state with append-only file
* persistence.
*
* <p>
* This provider instantiates the generic durable bus with PKI-specific
* identifier codecs and disables result persistence. Results should be stored
* in dedicated PKI storage (credential store, export store, etc.) and the bus
* should carry only status and references.
* </p>
*
* <h2>Configuration keys</h2>
* <ul>
* <li>{@code logPath} (optional): append-only log file path. Default:
* {@code pki-async/async.log} relative to the working directory.</li>
* </ul>
*
* <h2>Security</h2>
* <p>
* The log contains operation metadata and must be protected. The underlying
* store applies best-effort restrictive permissions (POSIX when available).
* </p>
*/
public final class FileBackedAsyncBusProvider implements AsyncBusProvider {
/**
* Configuration key for the log file path.
*/
public static final String KEY_LOG_PATH = "logPath";
@Override
public String id() {
return "file";
}
@Override
public Set<String> supportedKeys() {
return Set.of(KEY_LOG_PATH);
}
@Override
public AsyncBus<PkiId, Principal, String, Object> allocate(ProviderConfig config) {
Objects.requireNonNull(config, "config");
if (!id().equals(config.backendId())) {
throw new IllegalArgumentException("ProviderConfig backendId mismatch.");
}
Map<String, String> props = config.properties();
String logPath = props.getOrDefault(KEY_LOG_PATH, Path.of("pki-async").resolve("async.log").toString());
AppendOnlyLineStore store = new AppendOnlyLineStore(Path.of(logPath));
// Default: do not persist results in the bus log.
ResultCodec<Object> resultCodec = ResultCodec.none();
DurableAsyncBus<PkiId, Principal, String, Object> bus = new DurableAsyncBus<>(PkiCodecs.PKI_ID,
PkiCodecs.PRINCIPAL, PkiCodecs.STRING, resultCodec, store);
return bus;
}
}

View File

@@ -0,0 +1,109 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.async;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
/**
* PKI-specific async bus type aliases.
*
* <p>
* This class exists solely to centralize the chosen type parameters used to
* instantiate the generic async bus core.
* </p>
*/
public final class PkiAsyncTypes {
private PkiAsyncTypes() {
throw new AssertionError("No instances.");
}
/**
* Operation identifier type for PKI asynchronous operations.
*/
public static final class OpId { // NOPMD
private OpId() {
}
}
/**
* Owner type for PKI asynchronous operations.
*/
public static final class Owner {
private Owner() {
}
}
/**
* Endpoint identifier type for PKI asynchronous operations.
*/
public static final class EndpointId {
private EndpointId() {
}
}
/**
* Result type for PKI asynchronous operations.
*
* <p>
* The default bus does not persist results by default. Higher layers should
* store results (e.g., issued certificates) in PKI storage and use the bus only
* for status + references.
* </p>
*/
public static final class Result {
private Result() {
}
}
/**
* Returns the operation id type.
*
* @return class token
*/
public static Class<PkiId> opIdType() {
return PkiId.class;
}
/**
* Returns the owner type.
*
* @return class token
*/
public static Class<Principal> ownerType() {
return Principal.class;
}
}

View File

@@ -0,0 +1,117 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.async;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.util.async.codec.IdCodec;
/**
* PKI-specific codecs used to instantiate the generic async bus.
*/
public final class PkiCodecs {
private PkiCodecs() {
throw new AssertionError("No instances.");
}
/**
* Codec for {@link PkiId}.
*/
public static final IdCodec<PkiId> PKI_ID = new IdCodec<>() {
@Override
public String encode(PkiId value) {
if (value == null) {
throw new IllegalArgumentException("value must not be null");
}
return value.value();
}
@Override
public PkiId decode(String token) {
return new PkiId(token);
}
};
/**
* Codec for {@link Principal}.
*
* <p>
* Encoded form: {@code type:name}. Both parts are treated as non-secret
* identifiers.
* </p>
*/
public static final IdCodec<Principal> PRINCIPAL = new IdCodec<>() {
@Override
public String encode(Principal value) {
if (value == null) {
throw new IllegalArgumentException("value must not be null");
}
return value.type() + ":" + value.name();
}
@Override
public Principal decode(String token) {
int idx = token.indexOf(':');
if (idx <= 0 || idx >= token.length() - 1) {
throw new IllegalArgumentException("Invalid principal token.");
}
String type = token.substring(0, idx);
String name = token.substring(idx + 1);
return new Principal(type, name);
}
};
/**
* Codec for endpoint ids represented as strings.
*/
public static final IdCodec<String> STRING = new IdCodec<>() {
@Override
public String encode(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value must not be null/blank");
}
return value;
}
@Override
public String decode(String token) {
if (token == null || token.isBlank()) {
throw new IllegalArgumentException("token must not be null/blank");
}
return token;
}
};
}

View File

@@ -33,6 +33,19 @@
* POSSIBILITY OF SUCH DAMAGE. * POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/ ******************************************************************************/
/** /**
* Default implementations for the PKI async bus SPI.
* *
* <p>
* Implementations in this package are internal to the PKI module and are
* intended to be wired using {@link java.util.ServiceLoader} via
* {@code zeroecho.pki.spi.async}.
* </p>
*
* <h2>Security</h2>
* <p>
* The default implementation persists an append-only log containing operation
* ids and status metadata. Even such metadata can be sensitive (correlation,
* timing). File permissions are therefore enforced on a best-effort basis.
* </p>
*/ */
package zeroecho.pki.internal.x509; package zeroecho.pki.impl.async;

View File

@@ -55,6 +55,7 @@ import java.util.logging.Logger;
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.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.profile.CertificateProfile;
import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationRecord;
@@ -328,6 +329,46 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
return readOptional(this.paths.policyTracePath(decisionId), PolicyTrace.class); return readOptional(this.paths.policyTracePath(decisionId), PolicyTrace.class);
} }
// -------------------------------------------------------------------------
// Workflow continuation state (orchestration)
// -------------------------------------------------------------------------
@Override
public void putWorkflowState(final WorkflowStateRecord record) {
Objects.requireNonNull(record, "record");
PkiId opId = record.opId();
Path current = this.paths.workflowCurrent(opId);
// Never log payload; writeWithHistory logs only type + safe identifiers.
writeWithHistory(this.paths.workflowHistoryDir(opId), current, FsCodec.encode(record),
this.options.workflowHistoryPolicy(), "WORKFLOW", FsUtil.safeId(opId));
}
@Override
public Optional<WorkflowStateRecord> getWorkflowState(final PkiId opId) {
Objects.requireNonNull(opId, "opId");
return readOptional(this.paths.workflowCurrent(opId), WorkflowStateRecord.class);
}
@Override
public void deleteWorkflowState(final PkiId opId) {
Objects.requireNonNull(opId, "opId");
Path current = this.paths.workflowCurrent(opId);
try {
Files.deleteIfExists(current);
} catch (IOException e) {
throw new IllegalStateException("failed to delete workflow state opId=" + FsUtil.safeId(opId), e);
}
}
@Override
public List<WorkflowStateRecord> listWorkflowStates() {
// List by operation directory (workflows/by-op/<opId>/current.bin)
return listCurrentRecords(this.paths.workflowRoot(), WorkflowStateRecord.class);
}
@Override @Override
public void close() throws IOException { public void close() throws IOException {
synchronized (this.lockChannel) { // NOPMD synchronized (this.lockChannel) { // NOPMD

View File

@@ -58,7 +58,7 @@ public final class FilesystemPkiStoreProvider implements PkiStoreProvider {
/** /**
* Public no-arg constructor required by {@link java.util.ServiceLoader}. * Public no-arg constructor required by {@link java.util.ServiceLoader}.
*/ */
public FilesystemPkiStoreProvider() { // NOPMD public FilesystemPkiStoreProvider() {
// no-op // no-op
} }

View File

@@ -40,75 +40,90 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodHandles;
import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent; import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.Map;
import java.util.Base64;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import zeroecho.core.io.Util; import zeroecho.core.io.Util;
/** /**
* Deterministic binary codec for PKI store objects. * Compact binary codec for filesystem persistence.
* *
* <h2>Encoding format</h2>
* <p> * <p>
* This codec is designed for: * The codec supports two on-wire formats:
* </p> * </p>
* <ul> * <ul>
* <li>Deterministic serialization across JVM runs.</li> * <li><b>Compact format (current)</b>: begins with a 1-byte MAGIC marker
* <li>No external dependencies.</li> * {@code 0xFF}, followed by a 1-byte TAG and tag-specific payload.</li>
* <li>Support for records (the dominant pattern in the PKI API).</li> * <li><b>Legacy format (backward-compatible)</b>: begins with a UTF-8 encoded
* Java class name (written by
* {@link zeroecho.core.io.Util#writeUTF8(OutputStream, String)}), followed by
* value payload based on that class.</li>
* </ul> * </ul>
* *
* <p> * <p>
* Supported types: * The compact format eliminates the pathological overhead of writing a full
* </p> * Java class name for every scalar value (e.g., {@code boolean}). It also
* <ul> * substantially reduces class-loading activity during decode.
* <li>primitive wrappers, {@link String}, {@code byte[]}</li>
* <li>{@link Instant}, {@link Duration}, {@link UUID}</li>
* <li>{@link Optional}, {@link List}</li>
* <li>{@link Enum}</li>
* <li>Java {@code record} types, recursively</li>
* </ul>
*
* <p>
* For non-record custom value objects, decoding attempts the following (in this
* order) using a single {@link String} argument: {@code fromString},
* {@code parse}, {@code of}, {@code valueOf}, or a public constructor. The
* serialized form is {@code toString()}.
* </p> * </p>
* *
* <h2>Type compatibility</h2>
* <p> * <p>
* If none of the above works, an exception is thrown. This is intentional: the * During decode, the codec enforces that the encoded value type is compatible
* persistence layer must be explicit and auditable. * with the expected type. Primitive/wrapper pairs (e.g.,
* {@code boolean}/{@link Boolean}) are treated as compatible because values are
* boxed when represented as {@link Object}.
* </p> * </p>
*/ */
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.NcssCount" })
final class FsCodec { final class FsCodec {
private static final int MAX_STRING_BYTES = 1024 * 1024 * 4; // 4 MiB safety cap private static final int MAX_STRING_BYTES = 256 * 1024;
private static final Map<Class<?>, Class<?>> PRIMITIVE_TO_WRAPPER = Map.of(boolean.class, Boolean.class, byte.class,
Byte.class, short.class, Short.class, int.class, Integer.class, long.class, Long.class, char.class,
Character.class, float.class, Float.class, double.class, Double.class);
/** /**
* Hard upper bound for any stored binary blob (defense-in-depth against corrupt * Magic byte that marks the compact format.
* files and unbounded allocations). This is not a security boundary. *
* <p>
* This value is chosen to be extremely unlikely as a first byte of the legacy
* UTF-8 class-name length prefix. Class names are short; legacy length prefix
* first byte will be &lt; 0x80.
* </p>
*/ */
private static final int MAX_BLOB_BYTES = 16 * 1024 * 1024; // 16 MiB private static final int MAGIC_COMPACT = 0xFF;
// Compact tags (1 byte). Keep stable.
private static final int TAG_STRING = 1;
private static final int TAG_INT = 2;
private static final int TAG_LONG = 3;
private static final int TAG_BOOL = 4;
private static final int TAG_BYTES = 5;
private static final int TAG_INSTANT = 6;
private static final int TAG_DURATION = 7;
private static final int TAG_ENUM = 8;
private static final int TAG_RECORD = 9;
private static final int TAG_FALLBACK_STRING = 10;
private static final int TAG_LIST = 11;
private static final int TAG_SET = 12;
private static final int TAG_OPTIONAL = 13;
private FsCodec() { private FsCodec() {
// utility class // utility
} }
/* default */ static <T> byte[] encode(final T value) { /* default */ static <T> byte[] encode(final T value) {
Objects.requireNonNull(value, "value"); Objects.requireNonNull(value, "value");
try { try {
ByteArrayOutputStream bos = new ByteArrayOutputStream(); ByteArrayOutputStream bos = new ByteArrayOutputStream();
writeAny(bos, value); writeAnyCompact(bos, value);
return bos.toByteArray(); return bos.toByteArray();
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("encoding failed: " + value.getClass().getName(), e); throw new IllegalStateException("encoding failed: " + value.getClass().getName(), e);
@@ -123,251 +138,353 @@ final class FsCodec {
Object decoded = readAny(bis, expectedType); Object decoded = readAny(bis, expectedType);
return expectedType.cast(decoded); return expectedType.cast(decoded);
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("decoding failed for " + expectedType.getName(), e); throw new IllegalStateException("decoding failed: " + expectedType.getName(), e);
} }
} }
private static void writeAny(final OutputStream out, final Object value) throws IOException { // NOPMD private static void writeAnyCompact(final OutputStream out, final Object value) throws IOException {
Objects.requireNonNull(out, "out"); Objects.requireNonNull(out, "out");
Objects.requireNonNull(value, "value"); Objects.requireNonNull(value, "value");
Class<?> type = value.getClass(); out.write(MAGIC_COMPACT);
Util.writeUTF8(out, type.getName());
if (value instanceof String s) { // Use a switch with patterns to avoid long if/else chains and keep the hot path
writeString(out, s); // compact.
return; switch (value) {
case String s -> {
out.write(TAG_STRING);
Util.writeUTF8(out, s);
} }
if (value instanceof Integer i) { case Integer i -> {
out.write(TAG_INT);
Util.writePack7I(out, i); Util.writePack7I(out, i);
return;
} }
if (value instanceof Long l) { case Long l -> {
Util.writeLong(out, l); out.write(TAG_LONG);
return; Util.writePack7L(out, l);
} }
if (value instanceof Boolean b) { case Boolean b -> {
out.write(TAG_BOOL);
out.write(b ? 1 : 0); out.write(b ? 1 : 0);
return;
} }
if (value instanceof byte[] bytes) { case byte[] bytes -> {
out.write(TAG_BYTES);
Util.write(out, bytes); Util.write(out, bytes);
return;
} }
if (value instanceof Instant instant) { case Instant instant -> {
out.write(TAG_INSTANT);
Util.writeLong(out, instant.getEpochSecond()); Util.writeLong(out, instant.getEpochSecond());
Util.writePack7I(out, instant.getNano()); Util.writePack7I(out, instant.getNano());
return;
} }
if (value instanceof Duration duration) { case Duration duration -> {
out.write(TAG_DURATION);
Util.writeLong(out, duration.getSeconds()); Util.writeLong(out, duration.getSeconds());
Util.writePack7I(out, duration.getNano()); Util.writePack7I(out, duration.getNano());
return;
} }
if (value instanceof UUID uuid) { case java.util.List<?> list -> {
Util.write(out, uuid); out.write(TAG_LIST);
return;
}
if (value instanceof Optional<?> opt) {
out.write(opt.isPresent() ? 1 : 0);
if (opt.isPresent()) {
writeAny(out, opt.get());
}
return;
}
if (value instanceof List<?> list) {
Util.writePack7I(out, list.size()); Util.writePack7I(out, list.size());
for (Object item : list) { for (Object element : list) {
writeAny(out, item); Objects.requireNonNull(element, "list element must not be null");
writeAnyCompact(out, element);
} }
return;
} }
case java.util.Set<?> set -> {
out.write(TAG_SET);
// Deterministic ordering: encode sets in a stable order.
// Use natural order when possible; otherwise order by stable string key.
java.util.List<Object> ordered = new java.util.ArrayList<>(set.size());
ordered.addAll(set);
ordered.sort((a, b) -> {
if (a == b) { // NOPMD
return 0;
}
if (a == null) {
return -1;
}
if (b == null) {
return 1;
}
if (a instanceof Comparable<?> && a.getClass() == b.getClass()) {
@SuppressWarnings("unchecked")
Comparable<Object> ca = (Comparable<Object>) a;
return ca.compareTo(b);
}
return stableSortKey(a).compareTo(stableSortKey(b));
});
Util.writePack7I(out, ordered.size());
for (Object element : ordered) {
Objects.requireNonNull(element, "set element must not be null");
writeAnyCompact(out, element);
}
}
case java.util.Optional<?> opt -> {
out.write(TAG_OPTIONAL);
if (opt.isPresent()) {
out.write(1);
Object element = opt.get();
Objects.requireNonNull(element, "optional element must not be null");
writeAnyCompact(out, element);
} else {
out.write(0);
}
}
default -> {
Class<?> type = value.getClass();
if (type.isEnum()) { if (type.isEnum()) {
out.write(TAG_ENUM);
// Enum: write declaring class name once + ordinal.
Util.writeUTF8(out, type.getName());
Enum<?> e = (Enum<?>) value; Enum<?> e = (Enum<?>) value;
writeString(out, e.name()); Util.writePack7I(out, e.ordinal());
return; return;
} }
if (type.isRecord()) { if (type.isRecord()) {
out.write(TAG_RECORD);
// Record: write record class name once + component values (encoded compactly).
Util.writeUTF8(out, type.getName());
RecordComponent[] components = type.getRecordComponents(); RecordComponent[] components = type.getRecordComponents();
Util.writePack7I(out, components.length); Util.writePack7I(out, components.length);
for (RecordComponent c : components) { for (RecordComponent c : components) {
try { try {
Method accessor = c.getAccessor(); Method accessor = c.getAccessor();
Object componentValue = accessor.invoke(value); Object componentValue = accessor.invoke(value);
writeAny(out, componentValue); // Preserve existing invariant: no null components in storage.
} catch (ReflectiveOperationException ex) { Objects.requireNonNull(componentValue,
throw new IllegalStateException("record encode failed: " + type.getName() + "." + c.getName(), ex); "record component " + type.getName() + "." + c.getName());
writeAnyCompact(out, componentValue);
} catch (IllegalAccessException | InvocationTargetException ex) {
throw new IllegalStateException(
"record encode failed: " + type.getName() + "." + c.getName(), ex);
} }
} }
return; return;
} }
// fallback: encode as string and reconstruct via string factory on decode // Fallback: encode as string + type name (still far smaller than
writeString(out, value.toString()); // legacy-per-component).
out.write(TAG_FALLBACK_STRING);
Util.writeUTF8(out, type.getName());
Util.writeUTF8(out, value.toString());
}
}
}
private static String stableSortKey(Object o) {
// Deterministic, non-sensitive: class name + toString.
// Note: do not use identity hash code; it is non-deterministic across runs.
return o.getClass().getName() + ":" + o.toString();
} }
private static Object readAny(final InputStream in, final Class<?> expectedType) throws IOException { private static Object readAny(final InputStream in, final Class<?> expectedType) throws IOException {
Objects.requireNonNull(in, "in"); Objects.requireNonNull(in, "in");
Objects.requireNonNull(expectedType, "expectedType"); Objects.requireNonNull(expectedType, "expectedType");
String encodedTypeName = Util.readUTF8(in, MAX_STRING_BYTES); int first = in.read();
Class<?> encodedType; if (first < 0) {
ClassLoader primary = MethodHandles.lookup().lookupClass().getClassLoader(); // NOPMD throw new IOException("unexpected EOF");
try {
encodedType = Class.forName(encodedTypeName, false, primary);
} catch (ClassNotFoundException e) {
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
if (tccl != null && tccl != primary) { // NOPMD
try {
return Class.forName(encodedTypeName, false, tccl);
} catch (ClassNotFoundException e1) {
e = e1; // NOPMD
}
}
throw new IllegalStateException("unknown encoded type: " + encodedTypeName, e);
} }
// decode using the encoded type (authoritative) if (first != MAGIC_COMPACT) {
Object value = readByType(in, encodedType);
// enforce expected assignment
if (!expectedType.isAssignableFrom(encodedType)) {
throw new IllegalStateException( throw new IllegalStateException(
"type mismatch, expected " + expectedType.getName() + " but encoded " + encodedType.getName()); "file mismatch, MAGIC byte expected=" + MAGIC_COMPACT + " but found=" + first);
} }
int tag = in.read();
if (tag < 0) {
throw new IOException("unexpected EOF");
}
Object value = readByTag(in, tag);
// Enforce type compatibility (primitive/wrapper tolerant).
if (!isTypeCompatible(expectedType, value.getClass())) {
throw new IllegalStateException(
"type mismatch, expected " + expectedType.getName() + " but decoded " + value.getClass().getName());
}
return value; return value;
} }
private static Object readByType(final InputStream in, final Class<?> type) throws IOException { // NOPMD private static Object readByTag(final InputStream in, final int tag) throws IOException {
if (type == String.class) { switch (tag) {
case TAG_STRING -> {
return Util.readUTF8(in, MAX_STRING_BYTES); return Util.readUTF8(in, MAX_STRING_BYTES);
} }
if (type == Integer.class) { case TAG_INT -> {
return Util.readPack7I(in); return Util.readPack7I(in);
} }
if (type == Long.class) { case TAG_LONG -> {
return Util.readLong(in); return Util.readPack7L(in);
} }
if (type == Boolean.class) { case TAG_BOOL -> {
int b = in.read(); int b = in.read();
if (b < 0) { if (b < 0) {
throw new IOException("unexpected EOF"); throw new IOException("unexpected EOF");
} }
return b != 0; return b != 0;
} }
if (type == byte[].class) { case TAG_BYTES -> {
return Util.read(in, MAX_BLOB_BYTES); return Util.read(in, MAX_STRING_BYTES);
} }
if (type == Instant.class) { case TAG_INSTANT -> {
long seconds = Util.readLong(in); long sec = Util.readLong(in);
int nanos = Util.readPack7I(in); int nano = Util.readPack7I(in);
return Instant.ofEpochSecond(seconds, nanos); return Instant.ofEpochSecond(sec, nano);
} }
if (type == Duration.class) { case TAG_DURATION -> {
long seconds = Util.readLong(in); long sec = Util.readLong(in);
int nanos = Util.readPack7I(in); int nano = Util.readPack7I(in);
return Duration.ofSeconds(seconds, nanos); return Duration.ofSeconds(sec, nano);
} }
if (type == UUID.class) { case TAG_ENUM -> {
return Util.readUUID(in); String enumTypeName = Util.readUTF8(in, MAX_STRING_BYTES);
Class<?> enumType = loadClass(enumTypeName);
if (!enumType.isEnum()) {
throw new IllegalStateException("encoded enum type is not an enum: " + enumTypeName);
} }
if (type == Optional.class) { int ordinal = Util.readPack7I(in);
Object[] constants = enumType.getEnumConstants();
if (constants == null || ordinal < 0 || ordinal >= constants.length) {
throw new IllegalStateException("invalid enum ordinal " + ordinal + " for " + enumTypeName);
}
return constants[ordinal];
}
case TAG_RECORD -> {
String recordTypeName = Util.readUTF8(in, MAX_STRING_BYTES);
Class<?> recordType = loadClass(recordTypeName);
if (!recordType.isRecord()) {
throw new IllegalStateException("encoded record type is not a record: " + recordTypeName);
}
int componentCount = Util.readPack7I(in);
RecordComponent[] components = recordType.getRecordComponents();
if (components.length != componentCount) {
throw new IllegalStateException("record component count mismatch for " + recordTypeName
+ ", expected " + components.length + " but encoded " + componentCount);
}
Class<?>[] ctorTypes = new Class<?>[components.length];
Object[] ctorArgs = new Object[components.length];
for (int i = 0; i < components.length; i++) {
RecordComponent c = components[i];
ctorTypes[i] = c.getType();
Object componentValue = readAny(in, ctorTypes[i]);
ctorArgs[i] = componentValue;
}
try {
return recordType.getDeclaredConstructor(ctorTypes).newInstance(ctorArgs);
} catch (ReflectiveOperationException ex) {
throw new IllegalStateException("record decode failed for " + recordTypeName, ex);
}
}
case TAG_FALLBACK_STRING -> {
String typeName = Util.readUTF8(in, MAX_STRING_BYTES);
String s = Util.readUTF8(in, MAX_STRING_BYTES);
Class<?> type = loadClass(typeName);
// string factory: of(String) or valueOf(String) or ctor(String)
Method of = findStringFactory(type);
if (of != null) {
try {
return of.invoke(null, s);
} catch (ReflectiveOperationException ex) {
throw new IllegalStateException("from-string factory failed for " + type.getName(), ex);
}
}
try {
return type.getConstructor(String.class).newInstance(s);
} catch (ReflectiveOperationException ex) {
throw new IllegalStateException(
"unsupported value type without from-string factory: " + type.getName(), ex);
}
}
case TAG_LIST -> {
int n = Util.readPack7I(in);
java.util.List<Object> out = new java.util.ArrayList<>(n);
for (int i = 0; i < n; i++) {
Object element = readAny(in, Object.class);
out.add(element);
}
return java.util.List.copyOf(out);
}
case TAG_SET -> {
int n = Util.readPack7I(in);
java.util.Set<Object> out = new java.util.LinkedHashSet<>(n);
for (int i = 0; i < n; i++) {
Object element = readAny(in, Object.class);
out.add(element);
}
return java.util.Set.copyOf(out);
}
case TAG_OPTIONAL -> {
int present = in.read(); int present = in.read();
if (present < 0) { if (present < 0) {
throw new IOException("unexpected EOF"); throw new IOException("unexpected EOF");
} }
if (present == 0) { if (present == 0) {
return Optional.empty(); return java.util.Optional.empty();
} }
// generic type erased; decode nested object as Object Object element = readAny(in, Object.class);
Object nested = readAny(in, Object.class); return java.util.Optional.of(element);
return Optional.of(nested);
} }
if (List.class.isAssignableFrom(type)) { default -> throw new IllegalStateException("unknown compact tag: " + tag);
int size = Util.readPack7I(in);
List<Object> out = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
Object item = readAny(in, Object.class);
out.add(item);
} }
return out;
} }
if (type.isEnum()) {
String name = Util.readUTF8(in, MAX_STRING_BYTES);
@SuppressWarnings({ "unchecked", "rawtypes" })
Enum<?> e = Enum.valueOf((Class) type, name);
return e;
}
if (type.isRecord()) {
RecordComponent[] components = type.getRecordComponents();
int count = Util.readPack7I(in);
if (count != components.length) {
throw new IllegalStateException("record component count mismatch for " + type.getName());
}
Object[] args = new Object[components.length];
Class<?>[] ctorTypes = new Class<?>[components.length];
for (int i = 0; i < components.length; i++) { private static boolean isTypeCompatible(final Class<?> expectedType, final Class<?> actualType) {
RecordComponent c = components[i]; if (expectedType.isAssignableFrom(actualType)) {
ctorTypes[i] = c.getType(); return true;
args[i] = readAny(in, c.getType()); }
Class<?> expectedWrapper = PRIMITIVE_TO_WRAPPER.get(expectedType);
if (expectedWrapper != null && expectedWrapper == actualType) {
return true; // primitive expected, wrapper actual
}
Class<?> actualWrapper = PRIMITIVE_TO_WRAPPER.get(actualType);
return actualWrapper != null && actualWrapper == expectedType;
}
@SuppressWarnings("PMD.UseProperClassLoader")
private static Class<?> loadClass(final String typeName) {
Objects.requireNonNull(typeName, "typeName");
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (cl == null) {
cl = MethodHandles.lookup().lookupClass().getClassLoader();
} }
try { try {
Constructor<?> ctor = type.getDeclaredConstructor(ctorTypes); return Class.forName(typeName, false, cl);
ctor.setAccessible(true); // NOPMD } catch (ClassNotFoundException e) {
return ctor.newInstance(args); throw new IllegalStateException("unknown encoded type: " + typeName, e);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("record decode failed: " + type.getName(), e);
} }
} }
// fallback: read string and reconstruct with best-effort factory methods private static Method findStringFactory(final Class<?> type) {
String stringForm = Util.readUTF8(in, MAX_STRING_BYTES);
Object reconstructed = tryFromString(type, stringForm);
if (reconstructed != null) {
return reconstructed;
}
throw new IllegalStateException("unsupported value type without from-string factory: " + type.getName());
}
private static void writeString(final OutputStream out, final String s) throws IOException {
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
Util.writePack7I(out, bytes.length);
out.write(bytes);
}
private static Object tryFromString(final Class<?> type, final String s) {
List<String> methodNames = List.of("fromString", "parse", "of", "valueOf");
for (String methodName : methodNames) {
try { try {
Method m = type.getMethod(methodName, String.class); Method of = type.getMethod("of", String.class);
if (type.isAssignableFrom(m.getReturnType())) { if ((of.getModifiers() & java.lang.reflect.Modifier.STATIC) != 0 && of.getReturnType() == type) {
return m.invoke(null, s); return of;
} }
} catch (ReflectiveOperationException ignored) { } catch (NoSuchMethodException ex) { // NOPMD
// continue // ignore
} }
} try {
Method valueOf = type.getMethod("valueOf", String.class);
try { if ((valueOf.getModifiers() & java.lang.reflect.Modifier.STATIC) != 0 && valueOf.getReturnType() == type) {
Constructor<?> ctor = type.getConstructor(String.class); return valueOf;
return ctor.newInstance(s); }
} catch (ReflectiveOperationException ignored) { } catch (NoSuchMethodException ex) { // NOPMD
// continue
}
// common pattern: base64 bytes container
try {
Method m = type.getMethod("of", byte[].class);
if (type.isAssignableFrom(m.getReturnType())) {
byte[] decoded = Base64.getDecoder().decode(s);
return m.invoke(null, decoded);
}
} catch (ReflectiveOperationException ignored) {
// ignore // ignore
} }
return null; return null;
} }
} }

View File

@@ -43,8 +43,16 @@ import zeroecho.pki.api.PkiId;
* Deterministic path mapping for the filesystem PKI store. * Deterministic path mapping for the filesystem PKI store.
* *
* <p> * <p>
* This class is internal to the reference implementation. It defines the * This class is internal to the filesystem store implementation. It centralizes
* on-disk layout and centralizes naming conventions. * the on-disk layout so that path conventions remain stable and consistent
* across all entities and snapshot/export logic.
* </p>
*
* <p>
* The layout supports "mutable but auditable" entities by storing the latest
* version in {@link #CURRENT_FILE} and keeping immutable historical versions in
* a {@link #HISTORY_DIR} directory. This is required for snapshot export and
* forensic reconstruction.
* </p> * </p>
*/ */
final class FsPaths { final class FsPaths {
@@ -76,7 +84,12 @@ final class FsPaths {
return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK); return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK);
} }
// -------------------------------------------------------------------------
// CA (mutable with history)
// -------------------------------------------------------------------------
/* default */ Path caDir(final PkiId caId) { /* default */ Path caDir(final PkiId caId) {
Objects.requireNonNull(caId, "caId");
return this.root.resolve("cas").resolve(BY_ID).resolve(FsUtil.safeId(caId)); return this.root.resolve("cas").resolve(BY_ID).resolve(FsUtil.safeId(caId));
} }
@@ -88,7 +101,12 @@ final class FsPaths {
return caDir(caId).resolve(HISTORY_DIR); return caDir(caId).resolve(HISTORY_DIR);
} }
// -------------------------------------------------------------------------
// Profiles (mutable with history)
// -------------------------------------------------------------------------
/* default */ Path profileDir(final String profileId) { /* default */ Path profileDir(final String profileId) {
Objects.requireNonNull(profileId, "profileId");
return this.root.resolve("profiles").resolve(BY_ID).resolve(FsUtil.safeSegment(profileId)); return this.root.resolve("profiles").resolve(BY_ID).resolve(FsUtil.safeSegment(profileId));
} }
@@ -100,15 +118,30 @@ final class FsPaths {
return profileDir(profileId).resolve(HISTORY_DIR); return profileDir(profileId).resolve(HISTORY_DIR);
} }
// -------------------------------------------------------------------------
// Credentials (immutable .bin)
// -------------------------------------------------------------------------
/* default */ Path credentialPath(final PkiId credentialId) { /* default */ Path credentialPath(final PkiId credentialId) {
Objects.requireNonNull(credentialId, "credentialId");
return this.root.resolve("credentials").resolve(BY_ID).resolve(FsUtil.safeId(credentialId) + ".bin"); return this.root.resolve("credentials").resolve(BY_ID).resolve(FsUtil.safeId(credentialId) + ".bin");
} }
// -------------------------------------------------------------------------
// Requests (immutable .bin)
// -------------------------------------------------------------------------
/* default */ Path requestPath(final PkiId requestId) { /* default */ Path requestPath(final PkiId requestId) {
Objects.requireNonNull(requestId, "requestId");
return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + ".bin"); return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + ".bin");
} }
// -------------------------------------------------------------------------
// Revocations (mutable with history)
// -------------------------------------------------------------------------
/* default */ Path revocationDir(final PkiId credentialId) { /* default */ Path revocationDir(final PkiId credentialId) {
Objects.requireNonNull(credentialId, "credentialId");
return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId)); return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId));
} }
@@ -120,15 +153,62 @@ final class FsPaths {
return revocationDir(credentialId).resolve(HISTORY_DIR); return revocationDir(credentialId).resolve(HISTORY_DIR);
} }
// -------------------------------------------------------------------------
// Status objects (immutable .bin)
// -------------------------------------------------------------------------
/* default */ Path statusObjectPath(final PkiId statusObjectId) { /* default */ Path statusObjectPath(final PkiId statusObjectId) {
Objects.requireNonNull(statusObjectId, "statusObjectId");
return this.root.resolve("status").resolve(BY_ID).resolve(FsUtil.safeId(statusObjectId) + ".bin"); return this.root.resolve("status").resolve(BY_ID).resolve(FsUtil.safeId(statusObjectId) + ".bin");
} }
// -------------------------------------------------------------------------
// Policy traces (immutable .bin)
// -------------------------------------------------------------------------
/* default */ Path policyTracePath(final PkiId decisionId) { /* default */ Path policyTracePath(final PkiId decisionId) {
Objects.requireNonNull(decisionId, "decisionId");
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) { /* default */ Path publicationPath(final PkiId publicationId) {
Objects.requireNonNull(publicationId, "publicationId");
return this.root.resolve("publications").resolve(BY_ID).resolve(FsUtil.safeId(publicationId) + ".bin"); return this.root.resolve("publications").resolve(BY_ID).resolve(FsUtil.safeId(publicationId) + ".bin");
} }
// -------------------------------------------------------------------------
// Workflow state (mutable with history)
// -------------------------------------------------------------------------
/**
* Root directory for orchestrator workflow state records.
*
* <p>
* Workflow state is updated over time (progression of a long-running
* operation). Therefore, it follows the same "mutable but auditable" pattern as
* CA records and profiles: {@code current.bin} and {@code history/}.
* </p>
*
* @return workflows root directory (never {@code null})
*/
/* default */ Path workflowRoot() {
return this.root.resolve("workflows").resolve("by-op");
}
/* default */ Path workflowDir(final PkiId opId) {
Objects.requireNonNull(opId, "opId");
return workflowRoot().resolve(FsUtil.safeId(opId));
}
/* default */ Path workflowCurrent(final PkiId opId) {
return workflowDir(opId).resolve(CURRENT_FILE);
}
/* default */ Path workflowHistoryDir(final PkiId opId) {
return workflowDir(opId).resolve(HISTORY_DIR);
}
} }

View File

@@ -39,38 +39,57 @@ import java.util.Objects;
import java.util.Optional; import java.util.Optional;
/** /**
* Configuration for {@link FilesystemPkiStore}. * Options for {@link FilesystemPkiStore}.
* *
* <p> * <p>
* This type is intentionally placed in an implementation package. It is not a * This options container configures the filesystem store behavior while keeping
* public PKI API contract. It exists to keep the {@code PkiStore} SPI stable, * the {@code PkiStore} SPI stable. All options have deterministic semantics and
* while still allowing a professional-grade reference implementation to be * should be suitable for tests and for reference deployments.
* configured and tested.
* </p> * </p>
* *
* <h2>History tracking</h2>
* <p> * <p>
* All options have deterministic semantics. * Some records are mutable and are therefore stored as {@code current.bin} with
* optional historical snapshots under {@code history/}. The individual history
* policies allow tuning the retention behavior without changing the store
* contract.
* </p> * </p>
*
* @param caHistoryPolicy history policy for CA records
* @param profileHistoryPolicy history policy for certificate profiles
* @param revocationHistoryPolicy history policy for revocation records
* @param workflowHistoryPolicy history policy for workflow continuation state
* @param strictSnapshotExport whether snapshot export is strict
* (fail-closed)
*/ */
public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy profileHistoryPolicy, public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy profileHistoryPolicy,
FsHistoryPolicy revocationHistoryPolicy, boolean strictSnapshotExport) { FsHistoryPolicy revocationHistoryPolicy, FsHistoryPolicy workflowHistoryPolicy, boolean strictSnapshotExport) {
/** /**
* Canonical constructor with validation. * Canonical constructor with validation.
*
* @throws NullPointerException if any history policy is {@code null}
*/ */
public FsPkiStoreOptions { public FsPkiStoreOptions {
Objects.requireNonNull(caHistoryPolicy, "caHistoryPolicy"); Objects.requireNonNull(caHistoryPolicy, "caHistoryPolicy");
Objects.requireNonNull(profileHistoryPolicy, "profileHistoryPolicy"); Objects.requireNonNull(profileHistoryPolicy, "profileHistoryPolicy");
Objects.requireNonNull(revocationHistoryPolicy, "revocationHistoryPolicy"); Objects.requireNonNull(revocationHistoryPolicy, "revocationHistoryPolicy");
Objects.requireNonNull(workflowHistoryPolicy, "workflowHistoryPolicy");
} }
/** /**
* Returns default options (history enabled with 90 days retention). * Returns default options (history enabled with 90 days retention).
* *
* <p>
* The default is suitable for a reference deployment and for integration tests,
* but production systems may tune retention windows and cleanup strategies
* based on operational requirements.
* </p>
*
* @return default options * @return default options
*/ */
public static FsPkiStoreOptions defaults() { public static FsPkiStoreOptions defaults() {
FsHistoryPolicy ninetyDays = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90))); FsHistoryPolicy ninetyDays = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90)));
return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, true); return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, ninetyDays, true);
} }
} }

View File

@@ -98,6 +98,9 @@ final class FsSnapshotExporter {
this.options.profileHistoryPolicy(), this.options.strictSnapshotExport()); this.options.profileHistoryPolicy(), this.options.strictSnapshotExport());
reconstructMutableTree(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"), at, reconstructMutableTree(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"), at,
this.options.revocationHistoryPolicy(), this.options.strictSnapshotExport()); this.options.revocationHistoryPolicy(), this.options.strictSnapshotExport());
// reconstruct workflow continuation state from history
reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at,
this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport());
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("snapshot export failed", e); throw new IllegalStateException("snapshot export failed", e);

View File

@@ -0,0 +1,72 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.spi.async;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.spi.ConfigurableProvider;
import zeroecho.pki.util.async.AsyncBus;
/**
* ServiceLoader provider for the PKI asynchronous operation bus.
*
* <p>
* The underlying bus implementation is generic and reusable (see
* {@code zeroecho.pki.util.async}). This SPI publishes a PKI-specific type
* binding to ensure provider implementations remain strictly typed and do not
* require unsafe casts.
* </p>
*
* <p>
* Type parameters used by PKI:
* </p>
* <ul>
* <li>Operation id: {@link PkiId}</li>
* <li>Owner: {@link Principal}</li>
* <li>Endpoint id: {@link String}</li>
* <li>Result: {@link Object} (intentionally not persisted by the default
* provider)</li>
* </ul>
*
* <p>
* Result type is {@code Object} because this layer does not prescribe how
* results are stored. In the recommended architecture, results (certificates,
* bundles, exports) are stored in PKI stores and the async bus carries only
* status plus references, not payload.
* </p>
*/
public interface AsyncBusProvider extends ConfigurableProvider<AsyncBus<PkiId, Principal, String, Object>> {
// marker
}

View File

@@ -0,0 +1,57 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Asynchronous operation bus SPI.
*
* <p>
* This package defines the ServiceLoader boundary for selecting an async bus
* implementation used by PKI subsystems (issuance, publishing, approval flows).
* The bus itself is generic in the util layer, but this SPI publishes a
* concrete type binding for the PKI module to avoid unsafe casts.
* </p>
*
* <h2>Selection</h2>
* <ul>
* <li>{@code -Dzeroecho.pki.async=&lt;id&gt;}</li>
* <li>{@code -Dzeroecho.pki.async.&lt;key&gt;=&lt;value&gt;}</li>
* </ul>
*
* <h2>Security</h2>
* <p>
* Provider configuration values may be sensitive and must not be logged. Only
* provider ids and configuration keys are suitable for diagnostics.
* </p>
*/
package zeroecho.pki.spi.async;

View File

@@ -40,14 +40,18 @@ import java.util.Objects;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.spi.ConfigurableProvider; import zeroecho.pki.spi.ConfigurableProvider;
import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.async.AsyncBusProvider;
import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.audit.AuditSinkProvider; import zeroecho.pki.spi.audit.AuditSinkProvider;
import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.crypto.SignatureWorkflowProvider; import zeroecho.pki.spi.crypto.SignatureWorkflowProvider;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.PkiStoreProvider; import zeroecho.pki.spi.store.PkiStoreProvider;
import zeroecho.pki.util.async.AsyncBus;
/** /**
* PKI bootstrap utilities for ServiceLoader-based components. * PKI bootstrap utilities for ServiceLoader-based components.
@@ -56,7 +60,7 @@ import zeroecho.pki.spi.store.PkiStoreProvider;
* This class provides deterministic selection and instantiation rules for * This class provides deterministic selection and instantiation rules for
* components discovered via {@link java.util.ServiceLoader}. It is designed to * components discovered via {@link java.util.ServiceLoader}. It is designed to
* scale as more SPIs are introduced (audit, publish, framework integrations, * scale as more SPIs are introduced (audit, publish, framework integrations,
* crypto/workflows, etc.). * crypto/workflows, async orchestration, etc.).
* </p> * </p>
* *
* <h2>System property conventions</h2> * <h2>System property conventions</h2>
@@ -71,6 +75,9 @@ import zeroecho.pki.spi.store.PkiStoreProvider;
* {@code -Dzeroecho.pki.crypto.workflow=&lt;id&gt;}</li> * {@code -Dzeroecho.pki.crypto.workflow=&lt;id&gt;}</li>
* <li>Configure crypto workflow provider: * <li>Configure crypto workflow provider:
* {@code -Dzeroecho.pki.crypto.workflow.&lt;key&gt;=&lt;value&gt;}</li> * {@code -Dzeroecho.pki.crypto.workflow.&lt;key&gt;=&lt;value&gt;}</li>
* <li>Select async bus provider: {@code -Dzeroecho.pki.async=&lt;id&gt;}</li>
* <li>Configure async bus provider:
* {@code -Dzeroecho.pki.async.&lt;key&gt;=&lt;value&gt;}</li>
* </ul> * </ul>
* *
* <p> * <p>
@@ -91,6 +98,9 @@ public final class PkiBootstrap {
private static final String PROP_CRYPTO_WORKFLOW_BACKEND = "zeroecho.pki.crypto.workflow"; private static final String PROP_CRYPTO_WORKFLOW_BACKEND = "zeroecho.pki.crypto.workflow";
private static final String PROP_CRYPTO_WORKFLOW_PREFIX = "zeroecho.pki.crypto.workflow."; private static final String PROP_CRYPTO_WORKFLOW_PREFIX = "zeroecho.pki.crypto.workflow.";
private static final String PROP_ASYNC_BACKEND = "zeroecho.pki.async";
private static final String PROP_ASYNC_PREFIX = "zeroecho.pki.async.";
private PkiBootstrap() { private PkiBootstrap() {
throw new AssertionError("No instances."); throw new AssertionError("No instances.");
} }
@@ -117,13 +127,7 @@ public final class PkiBootstrap {
* are never logged; only keys may be logged. * are never logged; only keys may be logged.
* </p> * </p>
* *
* <p> * @return store (never {@code null})
* Defaulting rules implemented by this bootstrap (policy, not SPI requirement):
* for {@code fs} provider, if {@code root} is not specified, defaults to
* {@code "pki-store"} relative to the working directory.
* </p>
*
* @return opened store (never {@code null})
*/ */
public static PkiStore openStore() { public static PkiStore openStore() {
String requestedId = System.getProperty(PROP_STORE_BACKEND); String requestedId = System.getProperty(PROP_STORE_BACKEND);
@@ -151,40 +155,11 @@ public final class PkiBootstrap {
return provider.allocate(config); return provider.allocate(config);
} }
/**
* Logs provider help information (supported keys) for diagnostics.
*
* <p>
* This method is safe: it does not log configuration values.
* </p>
*
* @param provider provider (never {@code null})
*/
public static <T> void logSupportedKeys(ConfigurableProvider<T> provider) {
Objects.requireNonNull(provider, "provider");
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys());
}
}
/** /**
* Opens an {@link AuditSink} using {@link AuditSinkProvider} discovered via * Opens an {@link AuditSink} using {@link AuditSinkProvider} discovered via
* ServiceLoader. * ServiceLoader.
* *
* <p> * @return audit sink (never {@code null})
* Selection and configuration follow the same conventions as
* {@link #openStore()}, using {@code -Dzeroecho.pki.audit=&lt;id&gt;} and
* {@code zeroecho.pki.audit.} prefixed properties.
* </p>
*
* <p>
* Defaulting rules implemented by this bootstrap (policy, not SPI requirement):
* if no audit backend is specified, defaults to {@code stdout}. For
* {@code file} provider, if {@code root} is not specified, defaults to
* {@code "pki-audit"} relative to the working directory.
* </p>
*
* @return opened audit sink (never {@code null})
*/ */
public static AuditSink openAudit() { public static AuditSink openAudit() {
String requestedId = System.getProperty(PROP_AUDIT_BACKEND); String requestedId = System.getProperty(PROP_AUDIT_BACKEND);
@@ -220,21 +195,7 @@ public final class PkiBootstrap {
* Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider} * Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider}
* discovered via ServiceLoader. * discovered via ServiceLoader.
* *
* <p> * @return signature workflow (never {@code null})
* Conventions:
* </p>
* <ul>
* <li>Select provider: {@code -Dzeroecho.pki.crypto.workflow=&lt;id&gt;}</li>
* <li>Provider config:
* {@code -Dzeroecho.pki.crypto.workflow.&lt;key&gt;=&lt;value&gt;}</li>
* </ul>
*
* <p>
* Security note: configuration values may be sensitive and must not be logged.
* This bootstrap logs only provider ids and configuration keys.
* </p>
*
* @return opened signature workflow (never {@code null})
*/ */
public static SignatureWorkflow openSignatureWorkflow() { public static SignatureWorkflow openSignatureWorkflow() {
String requestedId = System.getProperty(PROP_CRYPTO_WORKFLOW_BACKEND); String requestedId = System.getProperty(PROP_CRYPTO_WORKFLOW_BACKEND);
@@ -248,6 +209,7 @@ public final class PkiBootstrap {
}); });
Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_CRYPTO_WORKFLOW_PREFIX); Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_CRYPTO_WORKFLOW_PREFIX);
ProviderConfig config = new ProviderConfig(provider.id(), props); ProviderConfig config = new ProviderConfig(provider.id(), props);
if (LOG.isLoggable(Level.INFO)) { if (LOG.isLoggable(Level.INFO)) {
@@ -256,4 +218,66 @@ public final class PkiBootstrap {
return provider.allocate(config); return provider.allocate(config);
} }
/**
* Opens an async operation bus using {@link AsyncBusProvider} discovered via
* ServiceLoader.
*
* <p>
* Defaulting rule: if no backend id is specified, defaults to {@code file}.
* </p>
*
* <p>
* Configuration properties are read from {@link System#getProperties()} using
* the prefix {@code zeroecho.pki.async.}. Values are treated as sensitive and
* are never logged; only keys may be logged.
* </p>
*
* @return async bus (never {@code null})
*/
public static AsyncBus<PkiId, Principal, String, Object> openAsyncBus() {
String requestedId = System.getProperty(PROP_ASYNC_BACKEND);
if (requestedId == null) {
requestedId = "file";
}
AsyncBusProvider provider = SpiSelector.select(AsyncBusProvider.class, requestedId,
new SpiSelector.ProviderId<>() {
@Override
public String id(AsyncBusProvider p) {
return p.id();
}
});
Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_ASYNC_PREFIX);
if ("file".equals(provider.id()) && !props.containsKey("logPath")) {
props.put("logPath", Path.of("pki-async").resolve("async.log").toString());
}
ProviderConfig config = new ProviderConfig(provider.id(), props);
if (LOG.isLoggable(Level.INFO)) {
LOG.info("Selected async bus provider: " + provider.id() + " (keys: " + props.keySet() + ")");
}
return provider.allocate(config);
}
/**
* Logs provider help information (supported keys) for diagnostics.
*
* <p>
* This method is safe: it does not log configuration values.
* </p>
*
* @param provider provider (never {@code null})
*/
public static <T> void logSupportedKeys(ConfigurableProvider<T> provider) {
Objects.requireNonNull(provider, "provider");
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys());
}
}
} }

View File

@@ -0,0 +1,102 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.spi.framework;
import java.util.Objects;
import zeroecho.pki.spi.ConfigurableProvider;
import zeroecho.pki.spi.ProviderConfig;
/**
* Service provider for {@link CredentialFramework} instances.
*
* <p>
* The PKI runtime selects a framework provider using
* {@link java.util.ServiceLoader} and instantiates it through
* {@link #allocate(ProviderConfig)}. Provider selection is performed by
* {@code PkiBootstrap} using configuration properties (similarly to store,
* audit, async bus, and signature workflow providers).
* </p>
*
* <h2>Dependency boundary</h2>
* <p>
* Framework implementations may internally rely on third-party toolkits (for
* example Bouncy Castle), but such dependencies must not leak through this SPI.
* The SPI must remain stable and independent of a particular cryptographic
* provider.
* </p>
*/
public interface CredentialFrameworkProvider extends ConfigurableProvider<CredentialFramework> {
/**
* Allocates a credential framework instance using the provided configuration.
*
* <p>
* Implementations must validate that {@link ProviderConfig#backendId()} matches
* {@link #id()}. A mismatch must be reported as
* {@link IllegalArgumentException}.
* </p>
*
* @param config provider configuration (never {@code null})
* @return allocated framework (never {@code null})
* @throws NullPointerException if {@code config} is {@code null}
* @throws IllegalArgumentException if {@code config.backendId()} does not match
* {@link #id()}
* @throws RuntimeException if allocation fails
*/
@Override
CredentialFramework allocate(ProviderConfig config);
/**
* Enforces that the provided configuration is intended for this provider.
*
* <p>
* This helper is intended for defensive checks inside provider implementations.
* </p>
*
* @param provider provider instance (never {@code null})
* @param config provider configuration (never {@code null})
* @throws NullPointerException if any argument is {@code null}
* @throws IllegalArgumentException if the backend id does not match
*/
static void requireIdMatch(final CredentialFrameworkProvider provider, final ProviderConfig config) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(config, "config");
if (!provider.id().equals(config.backendId())) {
throw new IllegalArgumentException("ProviderConfig backendId mismatch.");
}
}
}

View File

@@ -40,6 +40,7 @@ import java.util.Optional;
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.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.profile.CertificateProfile;
import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationRecord;
@@ -48,31 +49,27 @@ import zeroecho.pki.api.revocation.RevokedRecord;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
/** /**
* Persistence abstraction for PKI state. * Persistence boundary for PKI state.
* *
* <p> * <p>
* This Service Provider Interface (SPI) defines the authoritative storage * This Service Provider Interface (SPI) defines durable storage for the PKI
* contract for all PKI-managed state, including CA entities, issued * domain model. Implementations may use a filesystem, database, or other
* credentials, certification requests, revocations, status objects, profiles, * backend, but must preserve deterministic behavior, atomicity guarantees, and
* publications, and policy traces. * must not leak secrets through logging or exception messages.
* </p> * </p>
* *
* <h2>Security</h2>
* <p> * <p>
* The interface is intentionally coarse-grained and framework-agnostic. * Implementations must protect persisted data appropriately (for example:
* Implementations are responsible for providing appropriate durability, * filesystem permissions, database ACLs). Sensitive material must never be
* consistency, and concurrency guarantees according to the deployment model * written to logs. Exceptions should avoid leaking secrets in their messages.
* (filesystem, embedded storage, RDBMS, distributed store, etc.).
* </p> * </p>
* *
* <h2>Error handling</h2>
* <p> * <p>
* <strong>Security requirements:</strong> * This SPI uses unchecked failures. Implementations should throw
* {@link IllegalStateException} when an operation cannot be completed safely.
* </p> * </p>
* <ul>
* <li>Private key material MUST NOT be persisted.</li>
* <li>Secrets must never be stored in cleartext.</li>
* <li>Stored objects must be treated as immutable unless explicitly
* replaced.</li>
* </ul>
*/ */
public interface PkiStore { public interface PkiStore {
@@ -80,62 +77,56 @@ public interface PkiStore {
* Persists or updates a Certificate Authority (CA) record. * Persists or updates a Certificate Authority (CA) record.
* *
* <p> * <p>
* Implementations must ensure that CA records are stored atomically. Replacing * Implementations must store CA records atomically. Replacing an existing
* an existing CA record must preserve historical integrity (e.g., previously * record should be either fully visible or not visible at all.
* issued credentials must remain resolvable).
* </p> * </p>
* *
* @param record CA record to persist * @param record CA record (never {@code null})
* @throws IllegalArgumentException if {@code record} is null * @throws NullPointerException if {@code record} is {@code null}
* @throws RuntimeException if persistence fails * @throws IllegalStateException if persistence fails
*/ */
void putCa(CaRecord record); void putCa(CaRecord record);
/** /**
* Retrieves a CA record by its identifier. * Retrieves a CA record.
* *
* @param caId CA identifier * @param caId CA identifier (never {@code null})
* @return CA record if present, otherwise {@link Optional#empty()} * @return CA record if present
* @throws IllegalArgumentException if {@code caId} is null * @throws NullPointerException if {@code caId} is {@code null}
* @throws RuntimeException if retrieval fails * @throws IllegalStateException if retrieval fails
*/ */
Optional<CaRecord> getCa(PkiId caId); Optional<CaRecord> getCa(PkiId caId);
/** /**
* Lists all CA records known to the store. * Lists all stored CA records.
* *
* <p> * @return list of CA records (never {@code null})
* No filtering is applied at this level; higher layers are expected to perform * @throws IllegalStateException if listing fails
* query-based filtering.
* </p>
*
* @return list of CA records (never null)
* @throws RuntimeException if listing fails
*/ */
List<CaRecord> listCas(); List<CaRecord> listCas();
/** /**
* Persists an issued credential. * Persists a credential record.
* *
* <p> * <p>
* Credentials are immutable once stored. Re-inserting an existing credential * Credentials are typically immutable once issued. Implementations must ensure
* identifier should either be idempotent or rejected, depending on * that storing a credential is atomic and that the record remains retrievable
* implementation policy. * for its entire retention period.
* </p> * </p>
* *
* @param credential credential to persist * @param credential credential record (never {@code null})
* @throws IllegalArgumentException if {@code credential} is null * @throws NullPointerException if {@code credential} is {@code null}
* @throws RuntimeException if persistence fails * @throws IllegalStateException if persistence fails
*/ */
void putCredential(Credential credential); void putCredential(Credential credential);
/** /**
* Retrieves an issued credential by its identifier. * Retrieves a credential record.
* *
* @param credentialId credential identifier * @param credentialId credential identifier (never {@code null})
* @return credential if present * @return credential record if present
* @throws IllegalArgumentException if {@code credentialId} is null * @throws NullPointerException if {@code credentialId} is {@code null}
* @throws RuntimeException if retrieval fails * @throws IllegalStateException if retrieval fails
*/ */
Optional<Credential> getCredential(PkiId credentialId); Optional<Credential> getCredential(PkiId credentialId);
@@ -143,162 +134,233 @@ public interface PkiStore {
* Persists a parsed certification request. * Persists a parsed certification request.
* *
* <p> * <p>
* Stored requests are used for audit, correlation, re-issuance, and ACME-like * Requests are typically immutable artifacts. Implementations should treat them
* workflows. * as write-once records unless there is a clear need to support updates.
* </p> * </p>
* *
* @param request parsed certification request * @param request parsed request (never {@code null})
* @throws IllegalArgumentException if {@code request} is null * @throws NullPointerException if {@code request} is {@code null}
* @throws RuntimeException if persistence fails * @throws IllegalStateException if persistence fails
*/ */
void putRequest(ParsedCertificationRequest request); void putRequest(ParsedCertificationRequest request);
/** /**
* Retrieves a stored certification request. * Retrieves a parsed certification request.
* *
* @param requestId request identifier * @param requestId request identifier (never {@code null})
* @return parsed request if present * @return parsed request if present
* @throws IllegalArgumentException if {@code requestId} is null * @throws NullPointerException if {@code requestId} is {@code null}
* @throws RuntimeException if retrieval fails * @throws IllegalStateException if retrieval fails
*/ */
Optional<ParsedCertificationRequest> getRequest(PkiId requestId); Optional<ParsedCertificationRequest> getRequest(PkiId requestId);
/** /**
* Persists a revocation record. * Persists or updates a revocation record.
* *
* <p> * @param record revocation record (never {@code null})
* Revocation records are authoritative inputs for generating revocation status * @throws NullPointerException if {@code record} is {@code null}
* objects (CRLs, OCSP, etc.). * @throws IllegalStateException if persistence fails
* </p>
*
* @param record revocation record
* @throws IllegalArgumentException if {@code record} is null
* @throws RuntimeException if persistence fails
*/ */
void putRevocation(RevokedRecord record); void putRevocation(RevokedRecord record);
/** /**
* Retrieves the revocation record for a credential. * Retrieves a revocation record for a given credential.
* *
* @param credentialId credential identifier * @param credentialId credential identifier (never {@code null})
* @return revocation record if present * @return revocation record if present
* @throws IllegalArgumentException if {@code credentialId} is null * @throws NullPointerException if {@code credentialId} is {@code null}
* @throws RuntimeException if retrieval fails * @throws IllegalStateException if retrieval fails
*/ */
Optional<RevokedRecord> getRevocation(PkiId credentialId); Optional<RevokedRecord> getRevocation(PkiId credentialId);
/** /**
* Lists all revocation records. * Lists all revocation records.
* *
* @return list of revocation records (never null) * @return list of revocation records (never {@code null})
* @throws RuntimeException if listing fails * @throws IllegalStateException if listing fails
*/ */
List<RevokedRecord> listRevocations(); List<RevokedRecord> listRevocations();
/** /**
* Persists a generated status object. * Persists a status object.
* *
* <p> * <p>
* Status objects include CRLs, delta CRLs, OCSP responses, or * Status objects are typically published artifacts (for example OCSP responses,
* framework-specific revocation lists. * CRLs, or other status representations) and are usually immutable once
* created.
* </p> * </p>
* *
* @param object status object to persist * @param object status object (never {@code null})
* @throws IllegalArgumentException if {@code object} is null * @throws NullPointerException if {@code object} is {@code null}
* @throws RuntimeException if persistence fails * @throws IllegalStateException if persistence fails
*/ */
void putStatusObject(StatusObject object); void putStatusObject(StatusObject object);
/** /**
* Retrieves a status object by its identifier. * Retrieves a status object.
* *
* @param statusObjectId status object identifier * @param statusObjectId status object identifier (never {@code null})
* @return status object if present * @return status object if present
* @throws IllegalArgumentException if {@code statusObjectId} is null * @throws NullPointerException if {@code statusObjectId} is {@code null}
* @throws RuntimeException if retrieval fails * @throws IllegalStateException if retrieval fails
*/ */
Optional<StatusObject> getStatusObject(PkiId statusObjectId); Optional<StatusObject> getStatusObject(PkiId statusObjectId);
/** /**
* Lists all status objects issued by a given CA. * Lists status objects issued under a specific issuer CA.
* *
* @param issuerCaId issuer CA identifier * <p>
* @return list of status objects (never null) * This method exists to support issuer-scoped publication and retrieval
* @throws IllegalArgumentException if {@code issuerCaId} is null * patterns (for example: listing all CRLs or other status artifacts produced by
* @throws RuntimeException if listing fails * a given CA).
* </p>
*
* @param issuerCaId issuer CA identifier (never {@code null})
* @return list of status objects for the issuer (never {@code null})
* @throws NullPointerException if {@code issuerCaId} is {@code null}
* @throws IllegalStateException if listing fails
*/ */
List<StatusObject> listStatusObjects(PkiId issuerCaId); List<StatusObject> listStatusObjects(PkiId issuerCaId);
/** /**
* Persists a publication record. * Persists or updates a publication record.
* *
* <p> * <p>
* Publication records provide traceability and operational diagnostics for * Publication records describe distribution state (for example: where and when
* artifact distribution. * an object was published). These records may be used for operational
* monitoring and reconciliation.
* </p> * </p>
* *
* @param record publication record * @param record publication record (never {@code null})
* @throws IllegalArgumentException if {@code record} is null * @throws NullPointerException if {@code record} is {@code null}
* @throws RuntimeException if persistence fails * @throws IllegalStateException if persistence fails
*/ */
void putPublicationRecord(PublicationRecord record); void putPublicationRecord(PublicationRecord record);
/** /**
* Lists all publication records. * Lists all publication records.
* *
* @return list of publication records (never null) * @return list of publication records (never {@code null})
* @throws RuntimeException if listing fails * @throws IllegalStateException if listing fails
*/ */
List<PublicationRecord> listPublicationRecords(); List<PublicationRecord> listPublicationRecords();
/** /**
* Persists or updates a certificate profile. * Persists or updates a certificate profile.
* *
* @param profile certificate profile * <p>
* @throws IllegalArgumentException if {@code profile} is null * A certificate profile represents a reusable issuance template (for example:
* @throws RuntimeException if persistence fails * VPN client, VPN server, S/MIME). The profile may be referenced by higher
* layers during certificate issuance.
* </p>
*
* @param profile certificate profile (never {@code null})
* @throws NullPointerException if {@code profile} is {@code null}
* @throws IllegalStateException if persistence fails
*/ */
void putProfile(CertificateProfile profile); void putProfile(CertificateProfile profile);
/** /**
* Retrieves a certificate profile by identifier. * Retrieves a certificate profile by profile identifier.
* *
* @param profileId profile identifier * <p>
* The identifier is a stable, system-defined key (not a display name). It
* should be suitable for configuration and API use (for example:
* {@code "vpn-client"}).
* </p>
*
* @param profileId profile identifier (never {@code null})
* @return profile if present * @return profile if present
* @throws IllegalArgumentException if {@code profileId} is null or blank * @throws NullPointerException if {@code profileId} is {@code null}
* @throws RuntimeException if retrieval fails * @throws IllegalStateException if retrieval fails
*/ */
Optional<CertificateProfile> getProfile(String profileId); Optional<CertificateProfile> getProfile(String profileId);
/** /**
* Lists all stored certificate profiles. * Lists all stored certificate profiles.
* *
* @return list of profiles (never null) * @return list of profiles (never {@code null})
* @throws RuntimeException if listing fails * @throws IllegalStateException if listing fails
*/ */
List<CertificateProfile> listProfiles(); List<CertificateProfile> listProfiles();
/** /**
* Persists a policy evaluation trace. * Persists a policy trace.
* *
* <p> * <p>
* Policy traces are used for explainability, audit, and compliance evidence. * Policy traces capture decision-making and evaluation information. These
* They must never contain sensitive data. * records may contain sensitive meta-information and must be protected by the
* underlying store.
* </p> * </p>
* *
* @param trace policy trace * @param trace policy trace (never {@code null})
* @throws IllegalArgumentException if {@code trace} is null * @throws NullPointerException if {@code trace} is {@code null}
* @throws RuntimeException if persistence fails * @throws IllegalStateException if persistence fails
*/ */
void putPolicyTrace(PolicyTrace trace); void putPolicyTrace(PolicyTrace trace);
/** /**
* Retrieves a policy trace by decision identifier. * Retrieves a policy trace.
* *
* @param decisionId policy decision identifier * @param decisionId policy decision identifier (never {@code null})
* @return policy trace if present * @return policy trace if present
* @throws IllegalArgumentException if {@code decisionId} is null * @throws NullPointerException if {@code decisionId} is {@code null}
* @throws RuntimeException if retrieval fails * @throws IllegalStateException if retrieval fails
*/ */
Optional<PolicyTrace> getPolicyTrace(PkiId decisionId); Optional<PolicyTrace> getPolicyTrace(PkiId decisionId);
// -------------------------------------------------------------------------
// Workflow continuation state (orchestration)
// -------------------------------------------------------------------------
/**
* Persists or updates an orchestrator workflow continuation record.
*
* <p>
* The workflow payload may be encrypted. Implementations must treat it as
* sensitive and must not log it.
* </p>
*
* @param record workflow state record (never {@code null})
* @throws NullPointerException if {@code record} is {@code null}
* @throws IllegalStateException if persistence fails
*/
void putWorkflowState(WorkflowStateRecord record);
/**
* Retrieves workflow continuation state for a given operation.
*
* @param opId operation identifier (never {@code null})
* @return workflow state record if present
* @throws NullPointerException if {@code opId} is {@code null}
* @throws IllegalStateException if retrieval fails
*/
Optional<WorkflowStateRecord> getWorkflowState(PkiId opId);
/**
* Deletes workflow continuation state for a given operation.
*
* <p>
* Implementations may retain historical snapshots according to their history
* policy. Deletion guarantees only removal of the current record.
* </p>
*
* @param opId operation identifier (never {@code null})
* @throws NullPointerException if {@code opId} is {@code null}
* @throws IllegalStateException if deletion fails
*/
void deleteWorkflowState(PkiId opId);
/**
* Lists workflow continuation state records.
*
* <p>
* This method is intended for administrative reconciliation and recovery.
* Higher layers should apply access control and filtering as appropriate.
* </p>
*
* @return list of workflow state records (never {@code null})
* @throws IllegalStateException if listing fails
*/
List<WorkflowStateRecord> listWorkflowStates();
} }

View File

@@ -0,0 +1,88 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util;
import java.util.Objects;
import java.util.UUID;
import zeroecho.pki.api.PkiId;
/**
* Utility methods for generating and validating {@link PkiId} values.
*
* <p>
* The PKI subsystem uses {@link PkiId} as a stable identifier for domain
* records and long-running operations. This helper centralizes ID generation to
* avoid ad-hoc implementations scattered across providers and orchestration
* code.
* </p>
*
* <h2>Security</h2>
* <p>
* Generated identifiers must not embed secrets. The default generator uses a
* random UUID which is suitable for correlation and storage keys but does not
* reveal secret material.
* </p>
*/
public final class PkiIds {
private PkiIds() {
throw new AssertionError("No instances.");
}
/**
* Generates a new random PKI identifier.
*
* <p>
* The returned identifier is based on {@link UUID#randomUUID()}.
* </p>
*
* @return new identifier (never {@code null})
*/
public static PkiId newRandomId() {
return new PkiId(UUID.randomUUID().toString());
}
/**
* Validates that the provided identifier is non-null.
*
* @param id identifier to validate
* @return the same identifier for fluent usage
* @throws IllegalArgumentException if {@code id} is {@code null}
*/
public static PkiId require(PkiId id) {
return Objects.requireNonNull(id, "id");
}
}

View File

@@ -0,0 +1,156 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
/**
* Generic asynchronous operation bus.
*
* <p>
* The bus is responsible for:
* </p>
* <ul>
* <li>tracking operation metadata and status transitions,</li>
* <li>optional durability (implementation-defined),</li>
* <li>dispatching events to registered handlers,</li>
* <li>expiring operations that exceeded their deadline,</li>
* <li>forgetting completed results once retrieved (to avoid unbounded
* growth).</li>
* </ul>
*
* <h2>Result retention</h2>
* <p>
* Results are retained until explicitly retrieved (or expired by retention
* policy). Implementations must remove stored results once
* {@link #consumeResult(Object)} returns a value. This ensures bounded memory
* usage.
* </p>
*
* @param <OpId> operation id type
* @param <Owner> owner type
* @param <EndpointId> endpoint id type
* @param <Result> result type
*/
public interface AsyncBus<OpId, Owner, EndpointId, Result> {
/**
* Registers an endpoint instance under an identifier.
*
* <p>
* Registration must be performed by the endpoint itself during construction or
* activation, not by bootstrap code, to avoid tight coupling.
* </p>
*
* @param endpointId endpoint id (never {@code null})
* @param endpoint endpoint instance (never {@code null})
*/
void registerEndpoint(EndpointId endpointId, AsyncEndpoint<OpId, Result> endpoint);
/**
* Subscribes a handler to status change events.
*
* @param handler handler (never {@code null})
* @return registration handle (never {@code null})
*/
AsyncRegistration subscribe(AsyncHandler<OpId, Owner, EndpointId, Result> handler);
/**
* Creates a new operation record and sets initial status to
* {@link AsyncState#SUBMITTED}.
*
* @param opId operation id (never {@code null})
* @param type operation type (never blank)
* @param owner owner (never {@code null})
* @param endpointId endpoint id (never {@code null})
* @param createdAt creation time (never {@code null})
* @param ttl time-to-live (must be positive)
* @return created snapshot (never {@code null})
*/
AsyncOperationSnapshot<OpId, Owner, EndpointId> submit(OpId opId, String type, Owner owner, EndpointId endpointId,
Instant createdAt, Duration ttl);
/**
* Updates status and optional result for an operation.
*
* <p>
* Implementations must persist the transition before dispatching events.
* </p>
*
* @param opId operation id (never {@code null})
* @param status new status (never {@code null})
* @param result optional result (never {@code null})
*/
void update(OpId opId, AsyncStatus status, Optional<Result> result);
/**
* Retrieves the latest known status for an operation.
*
* @param opId operation id (never {@code null})
* @return status if known
*/
Optional<AsyncStatus> status(OpId opId);
/**
* Returns immutable operation metadata if present.
*
* @param opId operation id (never {@code null})
* @return snapshot if known
*/
Optional<AsyncOperationSnapshot<OpId, Owner, EndpointId>> snapshot(OpId opId);
/**
* Consumes the stored result if present and removes it from the bus.
*
* @param opId operation id (never {@code null})
* @return result if present (and then removed)
*/
Optional<Result> consumeResult(OpId opId);
/**
* Performs periodic maintenance:
*
* <ul>
* <li>expires operations past their deadline,</li>
* <li>polls endpoints for open operations (restart recovery),</li>
* <li>dispatches any discovered transitions.</li>
* </ul>
*
* @param now current time (never {@code null})
*/
void sweep(Instant now);
}

View File

@@ -0,0 +1,85 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async;
import java.time.Instant;
import java.util.Optional;
/**
* Target endpoint that owns/executes operations and can be asked for their
* status.
*
* <p>
* The bus uses endpoints to:
* </p>
* <ul>
* <li>poll open operations on {@link AsyncBus#sweep(Instant)} (restart
* recovery),</li>
* <li>deliver status/result updates via
* {@link AsyncBus#update(Object, AsyncStatus, Optional)}.</li>
* </ul>
*
* @param <OpId> operation id type
* @param <Res> result type
*/
public interface AsyncEndpoint<OpId, Res> {
/**
* Returns a best-effort current status for a known operation.
*
* <p>
* Endpoints should return a terminal status if the operation is complete. If
* unknown, endpoints may return an empty optional.
* </p>
*
* @param opId operation id (never {@code null})
* @return status if known
*/
Optional<AsyncStatus> status(OpId opId);
/**
* Returns a best-effort result for a successful operation.
*
* <p>
* Endpoints should return a value only if the operation has succeeded and the
* result is retrievable. If results are stored elsewhere, this may return
* empty.
* </p>
*
* @param opId operation id (never {@code null})
* @return result if available
*/
Optional<Res> result(OpId opId);
}

View File

@@ -0,0 +1,65 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async;
import java.util.Objects;
import java.util.Optional;
/**
* Event delivered to registered handlers whenever an operation status changes.
*
* @param <OpId> operation identifier type
* @param <Owner> operation owner type
* @param <Eid> endpoint identifier type
* @param <Result> result type
* @param snapshot immutable operation metadata (never {@code null})
* @param status new status (never {@code null})
* @param result optional result; present only when
* {@link AsyncStatus#state()} is {@link AsyncState#SUCCEEDED}
*/
public record AsyncEvent<OpId, Owner, Eid, Result>(AsyncOperationSnapshot<OpId, Owner, Eid> snapshot,
AsyncStatus status, Optional<Result> result) {
/**
* Creates an event.
*
* @throws IllegalArgumentException if any required field is null
*/
public AsyncEvent {
Objects.requireNonNull(snapshot, "snapshot");
Objects.requireNonNull(status, "status");
Objects.requireNonNull(result, "result");
}
}

View File

@@ -0,0 +1,60 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async;
/**
* Callback handler for async operation events.
*
* <p>
* Implementations must be exception-safe: the bus will treat any handler
* exception as a handler failure and will continue dispatching to other
* handlers.
* </p>
*
* @param <OpId> operation id type
* @param <Owner> owner type
* @param <Eid> endpoint id type
* @param <Result> result type
*/
@FunctionalInterface
public interface AsyncHandler<OpId, Owner, Eid, Result> {
/**
* Receives an event notification.
*
* @param event event (never {@code null})
*/
void onEvent(AsyncEvent<OpId, Owner, Eid, Result> event);
}

View File

@@ -0,0 +1,81 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async;
import java.time.Instant;
import java.util.Objects;
/**
* Immutable snapshot of an operation's identity and routing metadata.
*
* <p>
* This object intentionally does not contain secrets. Payloads should be stored
* outside the bus (or referenced indirectly).
* </p>
*
* @param <OpId> operation identifier type
* @param <Owner> operation owner type
* @param <EndpointId> endpoint identifier type
* @param opId operation id (never {@code null})
* @param type operation type string (never blank)
* @param owner operation owner (never {@code null})
* @param endpointId endpoint id (never {@code null})
* @param createdAt creation time (never {@code null})
* @param expiresAt expiration deadline (never {@code null})
*/
public record AsyncOperationSnapshot<OpId, Owner, EndpointId>(OpId opId, String type, Owner owner,
EndpointId endpointId, Instant createdAt, Instant expiresAt) {
/**
* Creates a snapshot and validates invariants.
*
* @throws IllegalArgumentException if required fields are null/blank or expiry
* is invalid
*/
public AsyncOperationSnapshot {
Objects.requireNonNull(opId, "opId");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(endpointId, "endpointId");
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(expiresAt, "expiresAt");
if (type.isBlank()) {
throw new IllegalArgumentException("type must not be blank");
}
if (!expiresAt.isAfter(createdAt)) {
throw new IllegalArgumentException("expiresAt must be after createdAt");
}
}
}

View File

@@ -32,7 +32,24 @@
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE. * POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.util.async;
import java.io.Closeable;
/** /**
* Registration handle for a handler subscription.
* *
* <p>
* Closing the handle unregisters the handler. Implementations must be
* idempotent.
* </p>
*/ */
package zeroecho.pki.internal; @SuppressWarnings("PMD.ImplicitFunctionalInterface")
public interface AsyncRegistration extends Closeable {
/**
* Unregisters the handler.
*/
@Override
void close();
}

View File

@@ -0,0 +1,109 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async;
/**
* Lifecycle state of an asynchronous operation.
*
* <p>
* The state machine is intentionally simple and audit-friendly. Implementations
* may attach additional non-sensitive detail through
* {@link AsyncStatus#detailCode()} and {@link AsyncStatus#details()}.
* </p>
*/
public enum AsyncState {
/**
* The operation has been created and is awaiting processing.
*
* <p>
* Example: a certificate issuance request has been accepted, but is waiting for
* an operator approval step.
* </p>
*/
SUBMITTED,
/**
* The operation is currently being processed.
*
* <p>
* Example: an issuance request is being built, proof-of-possession verified, or
* a signing workflow is executing.
* </p>
*/
RUNNING,
/**
* The operation completed successfully and the result is available for
* retrieval.
*
* <p>
* Example: a certificate (or bundle) has been issued and is ready to be
* fetched.
* </p>
*/
SUCCEEDED,
/**
* The operation completed unsuccessfully (terminal failure).
*
* <p>
* Example: proof-of-possession verification failed or a policy rule rejected
* the request. The failure reason should be exposed via a stable, non-secret
* {@link AsyncStatus#detailCode()} and optional {@link AsyncStatus#details()}.
* </p>
*/
FAILED,
/**
* The operation was cancelled by a caller or operator.
*
* <p>
* Example: an administrator cancelled a long-running operation before it was
* approved.
* </p>
*/
CANCELLED,
/**
* The operation exceeded its declared deadline and was expired by the bus.
*
* <p>
* Example: a request had a 24h approval window; after that it is no longer
* eligible for completion and is treated as expired.
* </p>
*/
EXPIRED
}

View File

@@ -0,0 +1,80 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Non-sensitive status snapshot of an asynchronous operation.
*
* <p>
* This object is safe to expose via APIs and logs (assuming the producer
* respects the no-secrets constraint for detail fields).
* </p>
*
* @param state lifecycle state (never {@code null})
* @param updatedAt last update time (never {@code null})
* @param detailCode optional stable, non-sensitive code suitable for audit and
* transport
* @param details optional key/value details; values MUST NOT contain secrets
*/
public record AsyncStatus(AsyncState state, Instant updatedAt, Optional<String> detailCode,
Map<String, String> details) {
/**
* Creates a status record and validates invariants.
*
* @throws IllegalArgumentException if any mandatory field is null
*/
public AsyncStatus {
Objects.requireNonNull(state, "state");
Objects.requireNonNull(updatedAt, "updatedAt");
Objects.requireNonNull(detailCode, "detailCode");
Objects.requireNonNull(details, "details");
}
/**
* Returns whether this status is terminal.
*
* @return true if terminal
*/
public boolean isTerminal() {
return state == AsyncState.SUCCEEDED || state == AsyncState.FAILED || state == AsyncState.CANCELLED
|| state == AsyncState.EXPIRED;
}
}

View File

@@ -0,0 +1,63 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async.codec;
/**
* String codec for stable persistence of identifiers.
*
* <p>
* The encoded form MUST NOT embed secrets. It is intended for identifiers only.
* </p>
*
* @param <T> identifier type
*/
public interface IdCodec<T> {
/**
* Encodes a value as a stable, filesystem-safe string token.
*
* @param value value (never {@code null})
* @return encoded token (never blank)
*/
String encode(T value);
/**
* Decodes a previously encoded token.
*
* @param token token (never blank)
* @return decoded value (never {@code null})
*/
T decode(String token);
}

View File

@@ -0,0 +1,114 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async.codec;
import java.util.Optional;
/**
* Optional persistence codec for results.
*
* <p>
* If results are sensitive or large, do not persist them in the bus log.
* Instead, store results in a dedicated storage layer and persist only
* references.
* </p>
*
* @param <R> result type
*/
public interface ResultCodec<R> {
/**
* Encodes a result to a single-line string.
*
* @param result result (never {@code null})
* @return encoded token (never blank)
*/
String encode(R result);
/**
* Decodes a previously encoded result token.
*
* @param token token (never blank)
* @return decoded result
*/
R decode(String token);
/**
* Indicates whether this codec persists results in the bus log.
*
* @return true if results are persisted; false if results are not persisted
*/
default boolean persistsResults() {
return true;
}
/**
* Convenience: a codec that never persists any result.
*
* @param <R> result type
* @return codec that rejects encode/decode and signals non-persistence
*/
static <R> ResultCodec<R> none() {
return new ResultCodec<>() {
@Override
public String encode(R result) {
throw new UnsupportedOperationException("Result persistence disabled.");
}
@Override
public R decode(String token) {
throw new UnsupportedOperationException("Result persistence disabled.");
}
@Override
public boolean persistsResults() {
return false;
}
};
}
/**
* Convenience: optional decode wrapper.
*
* @param token token
* @return optional decoded result (empty if token is empty)
*/
default Optional<R> decodeOptional(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
return Optional.of(decode(token));
}
}

View File

@@ -0,0 +1,53 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Codecs for persisting identifiers and (optionally) results of async
* operations.
*
* <p>
* The async bus persists identifiers via
* {@link zeroecho.pki.util.async.codec.IdCodec}. Result persistence is optional
* and may be disabled using
* {@link zeroecho.pki.util.async.codec.ResultCodec#none()}.
* </p>
*
* <h2>Security</h2>
* <p>
* Encoded forms must not contain secrets. If a subsystem needs to persist
* sensitive results, it must do so in a dedicated encrypted store and persist
* only a reference identifier in the bus.
* </p>
*/
package zeroecho.pki.util.async.codec;

View File

@@ -0,0 +1,179 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async.impl;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Append-only UTF-8 line store with best-effort POSIX permissions.
*
* <p>
* This is a minimal durability helper for the async bus. It does not interpret
* content; it only appends and replays lines.
* </p>
*/
public final class AppendOnlyLineStore {
private static final Logger LOG = Logger.getLogger(AppendOnlyLineStore.class.getName());
private final Path file;
/**
* Creates a store backed by the provided file path.
*
* @param file store file (never {@code null})
*/
public AppendOnlyLineStore(Path file) {
Objects.requireNonNull(file, "file");
this.file = file;
ensureSecureFile(file);
}
/**
* Appends a single line (with trailing '\n') to the store.
*
* @param line line (never {@code null}, may be empty but not null)
*/
public void appendLine(String line) {
Objects.requireNonNull(line, "line");
OpenOption[] opts = { StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND };
try (BufferedWriter w = Files.newBufferedWriter(file, StandardCharsets.UTF_8, opts)) {
w.write(line);
w.write('\n');
} catch (IOException ex) {
if (LOG.isLoggable(Level.SEVERE)) {
LOG.log(Level.SEVERE, "Failed to append async store line to file: " + file.getFileName(), ex);
}
throw new IllegalStateException("Failed to append async store line.", ex);
}
}
/**
* Reads all lines from the store.
*
* @return list of lines (never {@code null})
*/
public List<String> readAllLines() {
if (!Files.exists(file)) {
return List.of();
}
try (BufferedReader r = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
return r.lines().toList();
} catch (IOException ex) {
if (LOG.isLoggable(Level.SEVERE)) {
LOG.log(Level.SEVERE, "Failed to read async store file: " + file.getFileName(), ex);
}
throw new IllegalStateException("Failed to read async store file.", ex);
}
}
private static void ensureSecureFile(Path file) {
Path dir = file.getParent();
if (dir != null) {
ensureSecureDirectory(dir);
}
if (!Files.exists(file)) {
try {
Files.createFile(file);
} catch (FileAlreadyExistsException ex) {
// ignore
LOG.log(Level.FINE, "File {0} already exists, using it", file);
} catch (IOException ex) {
throw new IllegalStateException("Failed to create async store file: " + file, ex);
}
}
applyFilePermissions(file);
}
private static void ensureSecureDirectory(Path dir) {
try {
Files.createDirectories(dir);
} catch (IOException ex) {
throw new IllegalStateException("Failed to create async store directory: " + dir, ex);
}
applyDirectoryPermissions(dir);
}
private static void applyDirectoryPermissions(Path dir) {
if (!supportsPosix(dir)) {
return;
}
Set<PosixFilePermission> perms = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE);
try {
Files.setPosixFilePermissions(dir, perms);
} catch (IOException ex) {
// best-effort, do not fail startup
LOG.log(Level.WARNING, "Failed to set POSIX permissions on async directory.", ex);
}
}
private static void applyFilePermissions(Path file) {
if (!supportsPosix(file)) {
return;
}
Set<PosixFilePermission> perms = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
try {
Files.setPosixFilePermissions(file, perms);
} catch (IOException ex) {
// best-effort, do not fail startup
LOG.log(Level.WARNING, "Failed to set POSIX permissions on async file.", ex);
}
}
private static boolean supportsPosix(Path path) {
return FileSystems.getDefault().supportedFileAttributeViews().contains("posix") && path != null;
}
}

View File

@@ -0,0 +1,559 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async.impl;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.pki.util.async.AsyncBus;
import zeroecho.pki.util.async.AsyncEndpoint;
import zeroecho.pki.util.async.AsyncEvent;
import zeroecho.pki.util.async.AsyncHandler;
import zeroecho.pki.util.async.AsyncOperationSnapshot;
import zeroecho.pki.util.async.AsyncRegistration;
import zeroecho.pki.util.async.AsyncState;
import zeroecho.pki.util.async.AsyncStatus;
import zeroecho.pki.util.async.codec.IdCodec;
import zeroecho.pki.util.async.codec.ResultCodec;
/**
* Generic durable async bus implementation: in-memory state + append-only line
* log for recovery.
*
* <p>
* Log format is internal and versioned by a leading token. Corrupted lines are
* ignored with a warning (without logging line content).
* </p>
*
* <h2>No-secret logging</h2>
* <p>
* This implementation logs only operation ids (encoded) and types, never
* payloads or serialized results.
* </p>
*
* @param <OpId> operation id type
* @param <Owner> owner type
* @param <EndpointId> endpoint id type
* @param <Result> result type
*/
public final class DurableAsyncBus<OpId, Owner, EndpointId, Result> // NOPMD
implements AsyncBus<OpId, Owner, EndpointId, Result> {
private static final Logger LOG = Logger.getLogger(DurableAsyncBus.class.getName());
private static final String REC_SNAPSHOT = "S1";
private static final String REC_STATUS = "T1";
private static final String REC_RESULT = "R1";
private final IdCodec<OpId> opIdCodec;
private final IdCodec<Owner> ownerCodec;
private final IdCodec<EndpointId> endpointCodec;
private final ResultCodec<Result> resultCodec;
private final AppendOnlyLineStore store;
private final Map<OpId, AsyncOperationSnapshot<OpId, Owner, EndpointId>> active;
private final Map<OpId, AsyncStatus> lastStatus;
private final Map<OpId, Result> results; // removed on consume
private final Map<EndpointId, AsyncEndpoint<OpId, Result>> endpointsById;
private final List<AsyncHandler<OpId, Owner, EndpointId, Result>> handlers;
/**
* Creates the bus and immediately replays persisted state.
*
* @param opIdCodec codec for operation ids (never {@code null})
* @param ownerCodec codec for owners (never {@code null})
* @param endpointCodec codec for endpoints (never {@code null})
* @param resultCodec codec for results (never {@code null})
* @param store append-only store (never {@code null})
*/
public DurableAsyncBus(IdCodec<OpId> opIdCodec, IdCodec<Owner> ownerCodec, IdCodec<EndpointId> endpointCodec,
ResultCodec<Result> resultCodec, AppendOnlyLineStore store) {
this.opIdCodec = Objects.requireNonNull(opIdCodec, "opIdCodec");
this.ownerCodec = Objects.requireNonNull(ownerCodec, "ownerCodec");
this.endpointCodec = Objects.requireNonNull(endpointCodec, "endpointCodec");
this.resultCodec = Objects.requireNonNull(resultCodec, "resultCodec");
this.store = Objects.requireNonNull(store, "store");
this.active = Collections.synchronizedMap(new LinkedHashMap<>());
this.lastStatus = Collections.synchronizedMap(new HashMap<>());
this.results = Collections.synchronizedMap(new HashMap<>());
this.endpointsById = Collections.synchronizedMap(new HashMap<>());
this.handlers = Collections.synchronizedList(new ArrayList<>());
replay();
}
@Override
public void registerEndpoint(EndpointId endpointId, AsyncEndpoint<OpId, Result> endpoint) {
Objects.requireNonNull(endpointId, "endpointId");
Objects.requireNonNull(endpoint, "endpoint");
endpointsById.put(endpointId, endpoint);
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Registered async endpoint id={0}", new Object[] { safeEndpoint(endpointId) });
}
}
@Override
public AsyncRegistration subscribe(AsyncHandler<OpId, Owner, EndpointId, Result> handler) {
Objects.requireNonNull(handler, "handler");
handlers.add(handler);
return new AsyncRegistration() {
private volatile boolean closed; // NOPMD
@Override
public void close() {
if (closed) {
return;
}
closed = true;
handlers.remove(handler);
}
};
}
@Override
public AsyncOperationSnapshot<OpId, Owner, EndpointId> submit(OpId opId, String type, Owner owner,
EndpointId endpointId, Instant createdAt, Duration ttl) {
Objects.requireNonNull(opId, "opId");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(endpointId, "endpointId");
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(ttl, "ttl");
if (type.isBlank()) {
throw new IllegalArgumentException("type must not be blank");
}
if (ttl.isZero() || ttl.isNegative()) {
throw new IllegalArgumentException("ttl must be positive");
}
Instant expiresAt = createdAt.plus(ttl);
AsyncOperationSnapshot<OpId, Owner, EndpointId> snap = new AsyncOperationSnapshot<>(opId, type, owner,
endpointId, createdAt, expiresAt);
active.put(opId, snap);
AsyncStatus init = new AsyncStatus(AsyncState.SUBMITTED, createdAt, Optional.of("SUBMITTED"),
Map.of("type", type));
lastStatus.put(opId, init);
store.appendLine(encodeSnapshotLine(snap));
store.appendLine(encodeStatusLine(opId, init));
dispatchEvent(opId, snap, init, Optional.empty());
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Submitted async opId={0} type={1} endpoint={2}",
new Object[] { safeOpId(opId), type, safeEndpoint(endpointId) });
}
return snap;
}
@Override
public void update(OpId opId, AsyncStatus status, Optional<Result> result) {
Objects.requireNonNull(opId, "opId");
Objects.requireNonNull(status, "status");
Objects.requireNonNull(result, "result");
AsyncOperationSnapshot<OpId, Owner, EndpointId> snap = active.get(opId);
if (snap == null) {
// unknown op; ignore (or strict throw) - choose ignore to allow idempotent
// updates after cleanup
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "Ignoring update for unknown async opId={0}", new Object[] { safeOpId(opId) });
}
return;
}
lastStatus.put(opId, status);
store.appendLine(encodeStatusLine(opId, status));
if (result.isPresent()) {
if (resultCodec.persistsResults()) {
store.appendLine(encodeResultLine(opId, result.get()));
}
results.put(opId, result.get());
}
dispatchEvent(opId, snap, status, result);
if (status.isTerminal()) {
// keep snapshot+status until result consumed, but allow cleanup if no result
// exists
if (status.state() != AsyncState.SUCCEEDED) { // NOPMD
deleteOperation(opId);
}
}
}
@Override
public Optional<AsyncStatus> status(OpId opId) {
Objects.requireNonNull(opId, "opId");
return Optional.ofNullable(lastStatus.get(opId));
}
@Override
public Optional<AsyncOperationSnapshot<OpId, Owner, EndpointId>> snapshot(OpId opId) {
Objects.requireNonNull(opId, "opId");
return Optional.ofNullable(active.get(opId));
}
@Override
public Optional<Result> consumeResult(OpId opId) {
Objects.requireNonNull(opId, "opId");
Result r = results.remove(opId);
if (r == null) {
return Optional.empty();
}
// on successful consumption, forget operation entirely (bounded storage)
deleteOperation(opId);
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Consumed async result opId={0}", new Object[] { safeOpId(opId) });
}
return Optional.of(r);
}
@Override
public void sweep(Instant now) {
Objects.requireNonNull(now, "now");
// 1) expire
sweepExpired(now);
// 2) poll open ops (best-effort)
List<AsyncOperationSnapshot<OpId, Owner, EndpointId>> open = List.copyOf(active.values());
for (AsyncOperationSnapshot<OpId, Owner, EndpointId> snap : open) {
OpId opId = snap.opId();
AsyncStatus st = lastStatus.get(opId);
if (st != null && st.isTerminal()) {
continue;
}
AsyncEndpoint<OpId, Result> endpoint = endpointsById.get(snap.endpointId());
if (endpoint == null) {
continue;
}
Optional<AsyncStatus> statusOpt;
try {
statusOpt = endpoint.status(opId);
} catch (RuntimeException ex) { // NOPMD
// endpoint misbehaved; do not fail sweep
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Async endpoint status() failed; opId=" + safeOpId(opId), ex);
}
continue;
}
if (statusOpt.isEmpty()) {
continue;
}
AsyncStatus polled = statusOpt.get();
AsyncStatus prev = lastStatus.get(opId);
if (prev != null && prev.updatedAt().equals(polled.updatedAt()) && prev.state() == polled.state()) {
continue;
}
Optional<Result> res = Optional.empty();
if (polled.state() == AsyncState.SUCCEEDED) {
try {
res = endpoint.result(opId);
} catch (RuntimeException ex) { // NOPMD
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Async endpoint result() failed; opId=" + safeOpId(opId), ex);
}
}
}
update(opId, polled, res);
}
}
private void sweepExpired(Instant now) {
List<Map.Entry<OpId, AsyncOperationSnapshot<OpId, Owner, EndpointId>>> entries = List.copyOf(active.entrySet());
for (Map.Entry<OpId, AsyncOperationSnapshot<OpId, Owner, EndpointId>> e : entries) {
OpId opId = e.getKey();
AsyncOperationSnapshot<OpId, Owner, EndpointId> snap = e.getValue();
if (!now.isAfter(snap.expiresAt())) {
continue;
}
AsyncStatus expired = new AsyncStatus(AsyncState.EXPIRED, now, Optional.of("EXPIRED"), // NOPMD
Map.of("reason", "deadline-exceeded"));
// prev is intentionally not used: we keep it to allow future diagnostics
// (e.g., breakpoint inspection). If you prefer strictness, remove the
// assignment.
@SuppressWarnings("unused")
AsyncStatus prev = lastStatus.put(opId, expired);
store.appendLine(encodeStatusLine(opId, expired));
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Expired async operation opId={0} type={1}",
new Object[] { safeOpId(opId), snap.type() }); // NOPMD
}
dispatchEvent(opId, snap, expired, Optional.empty());
deleteOperation(opId);
}
}
private void deleteOperation(OpId opId) {
active.remove(opId);
lastStatus.remove(opId);
results.remove(opId);
}
private void dispatchEvent(OpId opId, AsyncOperationSnapshot<OpId, Owner, EndpointId> snap, AsyncStatus status,
Optional<Result> result) {
AsyncEvent<OpId, Owner, EndpointId, Result> event = new AsyncEvent<>(snap, status, result);
List<AsyncHandler<OpId, Owner, EndpointId, Result>> copy = List.copyOf(handlers);
for (AsyncHandler<OpId, Owner, EndpointId, Result> h : copy) {
try {
h.onEvent(event);
} catch (RuntimeException ex) { // NOPMD
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Async handler failed; opId=" + safeOpId(opId), ex);
}
}
}
}
private void replay() {
List<String> lines = store.readAllLines();
if (lines.isEmpty()) {
return;
}
int applied = 0;
for (String line : lines) {
if (line == null || line.isBlank()) {
continue;
}
try {
if (line.startsWith(REC_SNAPSHOT + "|")) {
applySnapshotLine(line);
applied++;
} else if (line.startsWith(REC_STATUS + "|")) {
applyStatusLine(line);
applied++;
} else if (line.startsWith(REC_RESULT + "|")) {
applyResultLine(line);
applied++;
}
} catch (RuntimeException ex) { // NOPMD
// corrupted line: ignore safely without logging contents
LOG.log(Level.WARNING, "Corrupted async store line encountered; skipping.", ex);
}
}
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Replayed async store lines applied={0} activeOps={1}",
new Object[] { applied, active.size() });
}
}
private void applySnapshotLine(String line) {
String[] parts = split(line, 7);
// S1|opId|type|owner|endpoint|createdAt|expiresAt
OpId opId = opIdCodec.decode(parts[1]);
String type = parts[2];
Owner owner = ownerCodec.decode(parts[3]);
EndpointId endpointId = endpointCodec.decode(parts[4]);
Instant createdAt = Instant.parse(parts[5]);
Instant expiresAt = Instant.parse(parts[6]);
AsyncOperationSnapshot<OpId, Owner, EndpointId> snap = new AsyncOperationSnapshot<>(opId, type, owner,
endpointId, createdAt, expiresAt);
active.put(opId, snap);
}
private void applyStatusLine(String line) {
String[] parts = split(line, 6);
// T1|opId|state|updatedAt|detailCode|k=v&k=v
OpId opId = opIdCodec.decode(parts[1]);
AsyncState state = AsyncState.valueOf(parts[2]);
Instant updatedAt = Instant.parse(parts[3]);
Optional<String> dc = parts[4].isBlank() ? Optional.empty() : Optional.of(parts[4]);
Map<String, String> details = decodeDetails(parts[5]);
AsyncStatus st = new AsyncStatus(state, updatedAt, dc, details);
lastStatus.put(opId, st);
}
private void applyResultLine(String line) {
if (!resultCodec.persistsResults()) {
return;
}
String[] parts = split(line, 3);
// R1|opId|resultToken
OpId opId = opIdCodec.decode(parts[1]);
Result r = resultCodec.decode(parts[2]);
results.put(opId, r);
}
private String encodeSnapshotLine(AsyncOperationSnapshot<OpId, Owner, EndpointId> snap) {
return REC_SNAPSHOT + "|" + opIdCodec.encode(snap.opId()) + "|" + snap.type() + "|"
+ ownerCodec.encode(snap.owner()) + "|" + endpointCodec.encode(snap.endpointId()) + "|"
+ snap.createdAt().toString() + "|" + snap.expiresAt().toString();
}
private String encodeStatusLine(OpId opId, AsyncStatus st) {
String dc = st.detailCode().orElse("");
String details = encodeDetails(st.details());
return REC_STATUS + "|" + opIdCodec.encode(opId) + "|" + st.state().name() + "|" + st.updatedAt().toString()
+ "|" + dc + "|" + details;
}
private String encodeResultLine(OpId opId, Result r) {
return REC_RESULT + "|" + opIdCodec.encode(opId) + "|" + resultCodec.encode(r);
}
private static String encodeDetails(Map<String, String> details) {
if (details == null || details.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder(64);
boolean first = true;
for (Map.Entry<String, String> e : details.entrySet()) {
if (!first) {
sb.append('&');
}
first = false;
sb.append(escape(e.getKey())).append('=').append(escape(e.getValue()));
}
return sb.toString();
}
private static Map<String, String> decodeDetails(String token) {
if (token == null || token.isBlank()) {
return Map.of();
}
Map<String, String> out = new LinkedHashMap<>();
String[] pairs = token.split("&");
for (String p : pairs) {
int idx = p.indexOf('=');
if (idx <= 0) {
continue;
}
String k = unescape(p.substring(0, idx));
String v = unescape(p.substring(idx + 1));
out.put(k, v);
}
return Map.copyOf(out);
}
private static String escape(String s) {
if (s == null) {
return "";
}
return s.replace("\\", "\\\\").replace("&", "\\&").replace("=", "\\=");
}
private static String unescape(String s) {
if (s == null || s.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder(s.length());
boolean esc = false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (esc) {
sb.append(c);
esc = false;
} else if (c == '\\') { // NOPMD
esc = true;
} else {
sb.append(c);
}
}
return sb.toString();
}
private static String[] split(String line, int expected) {
String[] parts = line.split("\\|", -1);
if (parts.length != expected) {
throw new IllegalArgumentException("Invalid record arity.");
}
return parts;
}
private String safeOpId(OpId opId) {
try {
String s = opIdCodec.encode(opId);
if (s.length() > 30) { // NOPMD
return s.substring(0, 30) + "...";
}
return s;
} catch (RuntimeException ex) { // NOPMD
return "<opId>";
}
}
private String safeEndpoint(EndpointId endpointId) {
try {
String s = endpointCodec.encode(endpointId);
if (s.length() > 30) { // NOPMD
return s.substring(0, 30) + "...";
}
return s;
} catch (RuntimeException ex) { // NOPMD
return "<endpoint>";
}
}
}

View File

@@ -0,0 +1,58 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Reference implementations for the generic async bus.
*
* <p>
* This package contains durability and recovery helpers (append-only line
* store) and a generic durable bus implementation.
* </p>
*
* <h2>Operational model</h2>
* <ul>
* <li>State is held in memory for speed.</li>
* <li>Transitions are appended to an on-disk log for restart recovery.</li>
* <li>Restart recovery replays the log and then relies on {@code sweep(...)} to
* synchronize open operations with their endpoints.</li>
* </ul>
*
* <h2>Security</h2>
* <p>
* Implementations must never log secrets, payloads, private keys, or internal
* cryptographic primitive state. Persisted content must contain only
* identifiers and non-sensitive status metadata.
* </p>
*/
package zeroecho.pki.util.async.impl;

View File

@@ -0,0 +1,70 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Generic asynchronous operation bus with durable tracking and callback
* dispatch.
*
* <p>
* This package provides a reusable, strongly-typed abstraction for submitting
* asynchronous operations, tracking their status, and delivering status changes
* to registered callback handlers. It is intentionally generic: concrete
* subsystems (e.g., PKI issuance, publishing, signing workflows) instantiate
* the bus by selecting:
* </p>
*
* <ul>
* <li>operation identifier type (e.g., {@code PkiId}),</li>
* <li>operation owner type (e.g., {@code Principal}),</li>
* <li>endpoint identifier type (e.g., {@code String}),</li>
* <li>result type (application-specific, may be empty).</li>
* </ul>
*
* <h2>Durability model</h2>
* <p>
* The default reference implementation is append-only: it persists operation
* snapshots and status transitions to a file log. On startup it replays the log
* and then optionally re-synchronizes open operations by asking the endpoint
* for their current status.
* </p>
*
* <h2>Security</h2>
* <ul>
* <li>No secrets must be logged.</li>
* <li>Persistence encodings should avoid plaintext secrets. If sensitive result
* data exists, store it encrypted outside of this package or provide a redacted
* {@code ResultCodec}.</li>
* </ul>
*/
package zeroecho.pki.util.async;

View File

@@ -0,0 +1,97 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
/**
* Utility types and helper functions for ZeroEcho PKI.
*
* <p>
* This package contains small, focused utility components that support the PKI
* domain model and its surrounding infrastructure without introducing
* additional domain semantics. Utilities provided here are intended to be:
* </p>
* <ul>
* <li>deterministic and side-effect free,</li>
* <li>safe to use across different runtime environments,</li>
* <li>independent of persistence, networking, or cryptographic providers.</li>
* </ul>
*
* <h2>Package structure</h2>
* <p>
* The package is intentionally shallow at its top level and consists of:
* </p>
* <ul>
* <li>A single direct utility class: {@link zeroecho.pki.util.PkiIds PkiIds},
* which provides helper methods for creation and handling of PKI identifiers in
* a consistent and deterministic manner.</li>
* <li>Several subordinate utility subpackages, each grouping helpers for a
* specific technical concern (e.g. formatting, validation, or internal
* conventions), without polluting the top-level namespace.</li>
* </ul>
*
* <h2>Design principles</h2>
* <p>
* Utilities in this package follow strict design rules:
* </p>
* <ul>
* <li>no global mutable state,</li>
* <li>no hidden caching with externally visible effects,</li>
* <li>no dependency on environment-specific configuration,</li>
* <li>no leakage of sensitive material (keys, secrets, plaintext).</li>
* </ul>
*
* <p>
* Where randomness or uniqueness is required (e.g. identifier generation), the
* semantics are explicitly documented and predictable within the guarantees of
* the underlying platform.
* </p>
*
* <h2>Relation to other layers</h2>
* <p>
* This package is deliberately orthogonal to:
* </p>
* <ul>
* <li>API domain packages (such as {@code zeroecho.pki.api.*}),</li>
* <li>SPI and implementation layers,</li>
* <li>application bootstrap and orchestration code.</li>
* </ul>
*
* <p>
* As such, utilities defined here may be freely reused by API, SPI, and
* implementation code without creating circular dependencies or architectural
* coupling.
* </p>
*
* @since 1.0
*/
package zeroecho.pki.util;

View File

@@ -0,0 +1 @@
zeroecho.pki.impl.async.FileBackedAsyncBusProvider

View File

@@ -40,25 +40,28 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.file.DirectoryStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
@@ -72,19 +75,22 @@ import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevokedRecord; import zeroecho.pki.api.revocation.RevokedRecord;
/** /**
* Black-box tests for {@link FilesystemPkiStore}. * Tests for {@link FilesystemPkiStore}.
* *
* <p> * <p>
* Tests focus on filesystem semantics (write-once, history, snapshot export) * The tests are deterministic and do not use reflection. Snapshot export
* and avoid dependencies on optional domain factories. Where the API uses * semantics are tested using {@link FsPkiStoreOptions#strictSnapshotExport()}
* interfaces (notably {@link AttributeSet}), tests provide a minimal * configured appropriately.
* deterministic stub.
* </p> * </p>
* *
* <p> * <p>
* Every test routine prints its own name and prints {@code ...ok} on success. * Output conventions:
* Important intermediate values are printed with {@code "..."} prefix.
* </p> * </p>
* <ul>
* <li>Each test prints its own name at the start.</li>
* <li>Each test prints {@code ...ok} on success.</li>
* <li>Important intermediate state is printed with {@code ...} prefix.</li>
* </ul>
*/ */
public final class FilesystemPkiStoreTest { public final class FilesystemPkiStoreTest {
@@ -95,20 +101,20 @@ public final class FilesystemPkiStoreTest {
void writeOnceCredentialRejected() throws Exception { void writeOnceCredentialRejected() throws Exception {
System.out.println("writeOnceCredentialRejected"); System.out.println("writeOnceCredentialRejected");
Path root = tmp.resolve("store-write-once"); Path root = tmp.resolve("store-writeonce");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
Credential credential = TestObjects.minimalCredential(); Credential c1 = TestObjects.minimalCredential("SERIAL-1", "profile-1");
store.putCredential(c1);
store.putCredential(credential); RuntimeException ex = assertThrows(RuntimeException.class, () -> store.putCredential(c1));
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> store.putCredential(credential));
assertNotNull(ex); assertNotNull(ex);
System.out.println("...expected failure: " + safeMsg(ex.getMessage()));
} }
printTree("store layout", root); System.out.println("...store tree:");
dumpTree(root);
System.out.println("writeOnceCredentialRejected...ok"); System.out.println("writeOnceCredentialRejected...ok");
} }
@@ -119,35 +125,22 @@ public final class FilesystemPkiStoreTest {
Path root = tmp.resolve("store-ca-history"); Path root = tmp.resolve("store-ca-history");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
PkiId caId;
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
CaRecord ca1 = TestObjects.minimalCaRecord(); CaRecord ca1 = TestObjects.minimalCaRecord("ca-1", CaState.ACTIVE);
caId = ca1.caId();
store.putCa(ca1); store.putCa(ca1);
Path caHist = caHistoryDir(root, caId); CaRecord ca2 = new CaRecord(ca1.caId(), ca1.kind(), CaState.DISABLED, ca1.issuerKeyRef(), ca1.subjectRef(),
waitForHistoryCount(caHist, 1, Duration.ofMillis(500)); ca1.caCredentials());
CaRecord ca2 = TestObjects.caVariant(ca1, CaState.DISABLED);
store.putCa(ca2); store.putCa(ca2);
waitForHistoryCount(caHist, 2, Duration.ofMillis(500)); Optional<CaRecord> loaded = store.getCa(ca1.caId());
Path caDir = root.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId));
assertTrue(Files.exists(caDir.resolve("current.bin")));
assertTrue(Files.isDirectory(caDir.resolve("history")));
long historyCount = Files.list(caDir.resolve("history")).count();
System.out.println("...historyCount=" + historyCount);
assertTrue(historyCount >= 2L);
Optional<CaRecord> loaded = store.getCa(caId);
assertTrue(loaded.isPresent()); assertTrue(loaded.isPresent());
assertEquals(CaState.DISABLED, loaded.get().state());
} }
printTree("store layout", root); System.out.println("...store tree:");
dumpTree(root);
System.out.println("caHistoryCreatesCurrentAndHistory...ok"); System.out.println("caHistoryCreatesCurrentAndHistory...ok");
} }
@@ -155,496 +148,367 @@ public final class FilesystemPkiStoreTest {
void revocationHistorySupportsOverwriteWithTrail() throws Exception { void revocationHistorySupportsOverwriteWithTrail() throws Exception {
System.out.println("revocationHistorySupportsOverwriteWithTrail"); System.out.println("revocationHistorySupportsOverwriteWithTrail");
Path root = tmp.resolve("store-revocations"); Path root = tmp.resolve("store-revocation-history");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
PkiId credentialId;
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
RevokedRecord r1 = TestObjects.minimalRevocation(); RevokedRecord r1 = TestObjects.minimalRevocation("cred-rev-1", Instant.EPOCH.plusSeconds(10L),
credentialId = r1.credentialId(); RevocationReason.KEY_COMPROMISE);
store.putRevocation(r1); store.putRevocation(r1);
Path hist = revocationHistoryDir(root, credentialId);
waitForHistoryCount(hist, 1, Duration.ofMillis(500));
RevokedRecord r2 = new RevokedRecord(r1.credentialId(), r1.revocationTime().plusSeconds(1L), r1.reason(), RevokedRecord r2 = new RevokedRecord(r1.credentialId(), r1.revocationTime().plusSeconds(1L), r1.reason(),
r1.attributes()); r1.attributes());
store.putRevocation(r2); store.putRevocation(r2);
waitForHistoryCount(hist, 2, Duration.ofMillis(500));
Path dir = root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId)); Optional<RevokedRecord> loaded = store.getRevocation(r1.credentialId());
assertTrue(Files.exists(dir.resolve("current.bin"))); assertTrue(loaded.isPresent());
assertTrue(Files.isDirectory(dir.resolve("history"))); assertEquals(r2.revocationTime(), loaded.get().revocationTime());
long historyCount = Files.list(dir.resolve("history")).count();
System.out.println("...historyCount=" + historyCount);
assertTrue(historyCount >= 2L);
} }
printTree("store layout", root); System.out.println("...store tree:");
dumpTree(root);
System.out.println("revocationHistorySupportsOverwriteWithTrail...ok"); System.out.println("revocationHistorySupportsOverwriteWithTrail...ok");
} }
@Test @Test
void snapshotExportSelectsCorrectHistoryVersions() throws Exception { void snapshotExportClonesNewRootNonStrict() throws Exception {
System.out.println("snapshotExportSelectsCorrectHistoryVersions"); System.out.println("snapshotExportClonesNewRootNonStrict");
Path root = tmp.resolve("store-snapshot"); Path root = tmp.resolve("store-snapshot-basic");
FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); Path snapshot = tmp.resolve("snapshot-basic");
PkiId caId; FsPkiStoreOptions options = nonStrictSnapshotOptions();
// Arrange: create 4 CA versions (ACTIVE -> DISABLED -> RETIRED -> COMPROMISED).
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
CaRecord caV1 = TestObjects.minimalCaRecord(); CaRecord ca1 = TestObjects.minimalCaRecord("ca-snap-1", CaState.ACTIVE);
caId = caV1.caId(); store.putCa(ca1);
store.putCa(caV1); Instant at = Instant.now();
Path caHist = caHistoryDir(root, caId); store.putProfile(TestObjects.minimalProfile("profile-snap-1", true));
waitForHistoryCount(caHist, 1, Duration.ofMillis(500));
store.putCa(TestObjects.caVariant(caV1, CaState.DISABLED)); store.exportSnapshot(snapshot, at);
waitForHistoryCount(caHist, 2, Duration.ofMillis(500));
store.putCa(TestObjects.caVariant(caV1, CaState.RETIRED));
waitForHistoryCount(caHist, 3, Duration.ofMillis(500));
store.putCa(TestObjects.caVariant(caV1, CaState.COMPROMISED));
waitForHistoryCount(caHist, 4, Duration.ofMillis(500));
} }
printTree("store layout", root); System.out.println("...store tree:");
dumpTree(root);
// Read authoritative timestamps from history filenames (exactly as exporter System.out.println("...snapshot tree:");
// expects). dumpTree(snapshot);
Path caHist = caHistoryDir(root, caId);
List<Long> tsMicros = listHistoryMicrosSorted(caHist);
System.out.println("...ca history micros count=" + tsMicros.size());
System.out.println("...ca history micros=" + tsMicros);
assertTrue(tsMicros.size() >= 4); assertTrue(Files.exists(snapshot.resolve("VERSION")));
assertTrue(Files.exists(snapshot.resolve("cas").resolve("by-id")));
// Time points derived from those filenames: System.out.println("snapshotExportClonesNewRootNonStrict...ok");
// beforeFirst -> must fail (strict) }
// atFirst -> selects v1 (ACTIVE)
// atThird -> selects v3 (RETIRED) @Test
// afterFourth -> selects v4 (COMPROMISED) void snapshotExportWithMultipleObjectsNonStrictDoesNotFail() throws Exception {
Instant beforeFirst = microsToInstant(tsMicros.get(0) - 1L); System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail");
Instant atFirst = microsToInstant(tsMicros.get(0));
Instant atThird = microsToInstant(tsMicros.get(2)); Path root = tmp.resolve("store-snapshot-multi");
Instant afterFourth = microsToInstant(tsMicros.get(3) + 1L); Path snap1 = tmp.resolve("snapshot-multi-1");
Path snap2 = tmp.resolve("snapshot-multi-2");
FsPkiStoreOptions options = nonStrictSnapshotOptions();
CaRecord caA = TestObjects.minimalCaRecord("ca-a", CaState.ACTIVE);
CaRecord caB = TestObjects.minimalCaRecord("ca-b", CaState.ACTIVE);
CertificateProfile pA = TestObjects.minimalProfile("profile-a", true);
CertificateProfile pB = TestObjects.minimalProfile("profile-b", true);
Instant at1;
Instant at2;
// Case 1: expected failure.
Path snapFail = tmp.resolve("snapshot-fail");
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> {
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
store.exportSnapshot(snapFail, beforeFirst); store.putCa(caA);
} at1 = Instant.now();
});
assertNotNull(ex);
System.out.println("...expected failure ok: " + safeMsg(ex.getMessage()));
// Case 2: at first -> v1 (ACTIVE). sleepMillis(120L);
Path snap1 = tmp.resolve("snapshot-v1");
store.putCa(caB);
store.putProfile(pA);
at2 = Instant.now();
sleepMillis(120L);
store.putProfile(pB);
// Export snapshots after all writes; in non-strict mode export must not fail.
store.exportSnapshot(snap1, at1);
store.exportSnapshot(snap2, at2);
}
System.out.println("...store tree:");
dumpTree(root);
System.out.println("...snapshot1 tree:");
dumpTree(snap1);
System.out.println("...snapshot2 tree:");
dumpTree(snap2);
try (FilesystemPkiStore s2 = new FilesystemPkiStore(snap2, options)) {
List<CaRecord> cas = s2.listCas();
List<CertificateProfile> profiles = s2.listProfiles();
Set<String> caIds = cas.stream().map(r -> r.caId().toString()).collect(Collectors.toSet());
Set<String> profileIds = profiles.stream().map(CertificateProfile::profileId).collect(Collectors.toSet());
System.out.println("...snapshot2 caIds: " + caIds);
System.out.println("...snapshot2 profileIds: " + profileIds);
assertTrue(caIds.contains("ca-a"));
assertTrue(caIds.contains("ca-b"));
assertTrue(profileIds.contains("profile-a"));
assertTrue(profileIds.contains("profile-b"));
}
System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail...ok");
}
@Test
void snapshotExportStrictFailsWhenAtIsBeforeLaterInsert() throws Exception {
System.out.println("snapshotExportStrictFailsWhenAtIsBeforeLaterInsert");
Path root = tmp.resolve("store-snapshot-strict-fail");
Path snapshot = tmp.resolve("snapshot-strict-fail");
FsPkiStoreOptions options = strictSnapshotOptions();
IllegalStateException ex;
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
store.exportSnapshot(snap1, atFirst); // Create CA first.
} store.putCa(TestObjects.minimalCaRecord("ca-strict-1", CaState.ACTIVE));
printTree("snapshot v1 layout", snap1);
CaRecord snapCa1 = readSnapshotCa(snap1, caId); // "at" is before the profile exists.
System.out.println("...snapshot v1 state=" + snapCa1.state()); Instant at = Instant.now();
assertEquals(CaState.ACTIVE, snapCa1.state()); sleepMillis(120L);
// Now create a new object that does not have history entry <= at.
store.putProfile(TestObjects.minimalProfile("profile-strict-1", true));
// Strict export must fail because current tree contains profile without history
// <= at.
ex = assertThrows(IllegalStateException.class, () -> store.exportSnapshot(snapshot, at));
}
System.out.println("...exception: " + shorten(ex.toString(), 200));
System.out.println("...store tree:");
dumpTree(root);
System.out.println("snapshotExportStrictFailsWhenAtIsBeforeLaterInsert...ok");
}
@Test
void snapshotExportStrictSucceedsWhenAtIsAfterAllWrites() throws Exception {
System.out.println("snapshotExportStrictSucceedsWhenAtIsAfterAllWrites");
Path root = tmp.resolve("store-snapshot-strict-ok");
Path snapshot = tmp.resolve("snapshot-strict-ok");
FsPkiStoreOptions options = strictSnapshotOptions();
// Case 3: at third -> v3 (RETIRED).
Path snap3 = tmp.resolve("snapshot-v3");
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
store.exportSnapshot(snap3, atThird); store.putCa(TestObjects.minimalCaRecord("ca-strict-ok", CaState.ACTIVE));
} store.putProfile(TestObjects.minimalProfile("profile-strict-ok", true));
printTree("snapshot v3 layout", snap3);
CaRecord snapCa3 = readSnapshotCa(snap3, caId);
System.out.println("...snapshot v3 state=" + snapCa3.state());
assertEquals(CaState.RETIRED, snapCa3.state());
// Case 4: after fourth -> v4 (COMPROMISED). // "at" after all writes: strict export should succeed.
Path snap4 = tmp.resolve("snapshot-v4"); Instant at = Instant.now();
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { store.exportSnapshot(snapshot, at);
store.exportSnapshot(snap4, afterFourth);
}
printTree("snapshot v4 layout", snap4);
CaRecord snapCa4 = readSnapshotCa(snap4, caId);
System.out.println("...snapshot v4 state=" + snapCa4.state());
assertEquals(CaState.COMPROMISED, snapCa4.state());
System.out.println("snapshotExportSelectsCorrectHistoryVersions...ok");
} }
// ----------------------- System.out.println("...store tree:");
// Helper methods dumpTree(root);
// -----------------------
private static Path caHistoryDir(final Path storeRoot, final PkiId caId) { System.out.println("...snapshot tree:");
return storeRoot.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId)).resolve("history"); dumpTree(snapshot);
try (FilesystemPkiStore snap = new FilesystemPkiStore(snapshot, options)) {
Set<String> caIds = snap.listCas().stream().map(r -> r.caId().toString()).collect(Collectors.toSet());
Set<String> profileIds = snap.listProfiles().stream().map(CertificateProfile::profileId)
.collect(Collectors.toSet());
System.out.println("...snapshot caIds: " + caIds);
System.out.println("...snapshot profileIds: " + profileIds);
assertTrue(caIds.contains("ca-strict-ok"));
assertTrue(profileIds.contains("profile-strict-ok"));
} }
private static Path revocationHistoryDir(final Path storeRoot, final PkiId credentialId) { System.out.println("snapshotExportStrictSucceedsWhenAtIsAfterAllWrites...ok");
return storeRoot.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId))
.resolve("history");
} }
private static void waitForHistoryCount(final Path historyDir, final int expected, final Duration timeout) private static FsPkiStoreOptions nonStrictSnapshotOptions() {
throws Exception { FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults();
return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(),
defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), false);
}
long deadline = System.nanoTime() + timeout.toNanos(); private static FsPkiStoreOptions strictSnapshotOptions() {
while (System.nanoTime() < deadline) { FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults();
if (Files.isDirectory(historyDir)) { return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(),
long count = Files.list(historyDir).filter(Files::isRegularFile).count(); defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), true);
if (count >= expected) { }
System.out.println("...historyDir=" + historyDir + " count=" + count);
private static void sleepMillis(long ms) throws InterruptedException {
Thread.sleep(ms);
}
private static void dumpTree(Path root) throws IOException {
if (!Files.exists(root)) {
System.out.println("...<missing> " + root);
return; return;
} }
} List<Path> paths = Files.walk(root).sorted(Comparator.naturalOrder()).collect(Collectors.toList());
Thread.sleep(5L); for (Path p : paths) {
} Path rel = root.relativize(p);
throw new IllegalStateException( if (rel.toString().isEmpty()) {
"history did not reach expected count " + expected + " in " + timeout + " at " + historyDir);
}
private static List<Long> listHistoryMicrosSorted(final Path historyDir) throws IOException {
List<Long> out = new ArrayList<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(historyDir, "*.bin")) {
for (Path p : ds) {
String name = p.getFileName().toString();
int dash = name.indexOf('-');
if (dash <= 0) {
continue; continue;
} }
String ts = name.substring(0, dash);
try {
out.add(Long.valueOf(ts));
} catch (NumberFormatException ignore) {
// ignore
}
}
}
out.sort(Long::compareTo);
return out;
}
private static Instant microsToInstant(final long tsMicros) {
long sec = tsMicros / 1_000_000L;
long micros = tsMicros % 1_000_000L;
return Instant.ofEpochSecond(sec, micros * 1_000L);
}
private static CaRecord readSnapshotCa(final Path snapshotRoot, final PkiId caId) throws IOException {
Path current = snapshotRoot.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId)).resolve("current.bin");
byte[] data = FsOperations.readAll(current);
return FsCodec.decode(data, CaRecord.class);
}
private static void printTree(final String label, final Path root) throws IOException {
System.out.println("..." + label + ": " + root);
if (!Files.exists(root)) {
System.out.println("... <missing>");
return;
}
Files.walk(root).sorted(Comparator.comparing(p -> root.relativize(p).toString())).forEach(p -> {
try {
String rel = root.relativize(p).toString();
if (rel.isEmpty()) {
rel = ".";
}
if (Files.isDirectory(p)) { if (Files.isDirectory(p)) {
System.out.println("... [D] " + rel); System.out.println("..." + rel + "/");
} else if (Files.isRegularFile(p)) {
long size = Files.size(p);
System.out.println("... [F] " + rel + " (" + size + " B)");
} else { } else {
System.out.println("... [?] " + rel); long size = Files.size(p);
System.out.println("..." + rel + " (" + size + " bytes)");
} }
} catch (IOException e) {
System.out.println("... [!] " + p + " (io error)");
} }
});
} }
private static String safeMsg(final String s) { private static String shorten(String s, int max) {
if (s == null) { if (s == null) {
return "<null>"; return "null";
} }
if (s.length() <= 80) { if (s.length() <= max) {
return s; return s;
} }
return s.substring(0, 80) + "..."; return s.substring(0, Math.max(0, max - 3)) + "...";
} }
// ----------------------- /**
// Test object factories * Test-only codec-friendly {@link AttributeSet} implementation (no proxies).
// ----------------------- *
* @param entries serialized entries
*/
public record TestAttributeSet(List<Entry> entries) implements AttributeSet {
static final class TestObjects { public record Entry(AttributeId id, List<AttributeValue> values) {
private static long idCounter = 0L; public Entry {
if (id == null) {
private TestObjects() { throw new IllegalArgumentException("id must not be null");
// utility class
} }
if (values == null) {
static CaRecord minimalCaRecord() throws Exception { throw new IllegalArgumentException("values must not be null");
Class<?> keyRef = Class.forName("zeroecho.pki.api.KeyRef");
Class<?> subjectRef = Class.forName("zeroecho.pki.api.SubjectRef");
PkiId caId = anyPkiId();
Object issuerKeyRef = anyValue(keyRef);
Object subject = anyValue(subjectRef);
return new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, (zeroecho.pki.api.KeyRef) issuerKeyRef,
(zeroecho.pki.api.SubjectRef) subject, List.of());
} }
static CaRecord caVariant(final CaRecord base, final CaState state) {
return new CaRecord(base.caId(), base.kind(), state, base.issuerKeyRef(), base.subjectRef(),
base.caCredentials());
}
static CertificateProfile minimalProfile() throws Exception {
Class<?> formatId = Class.forName("zeroecho.pki.api.FormatId");
Object fmt = anyValue(formatId);
Constructor<CertificateProfile> ctor = CertificateProfile.class.getConstructor(String.class, formatId,
String.class, List.class, List.class, Optional.class, boolean.class);
return ctor.newInstance("profile-1", fmt, "Profile 1", List.of(), List.of(), Optional.empty(), true);
}
static Credential minimalCredential() throws Exception {
Class<?> formatId = Class.forName("zeroecho.pki.api.FormatId");
Class<?> issuerRef = Class.forName("zeroecho.pki.api.IssuerRef");
Class<?> subjectRef = Class.forName("zeroecho.pki.api.SubjectRef");
Class<?> validity = Class.forName("zeroecho.pki.api.Validity");
Class<?> encodedObject = Class.forName("zeroecho.pki.api.EncodedObject");
PkiId credentialId = anyPkiId();
Object fmt = anyValue(formatId);
Object iss = anyValue(issuerRef);
Object sub = anyValue(subjectRef);
Object val = anyValue(validity);
String serial = "SERIAL-1";
PkiId publicKeyId = anyPkiId();
String profileId = "profile-1";
CredentialStatus status = CredentialStatus.ISSUED;
Object enc = anyValue(encodedObject);
AttributeSet attrs = new EmptyAttributeSet();
Constructor<Credential> ctor = Credential.class.getConstructor(PkiId.class, formatId, issuerRef, subjectRef,
validity, String.class, PkiId.class, String.class, CredentialStatus.class, encodedObject,
AttributeSet.class);
return ctor.newInstance(credentialId, fmt, iss, sub, val, serial, publicKeyId, profileId, status, enc,
attrs);
}
static RevokedRecord minimalRevocation() throws Exception {
PkiId cred = anyPkiId();
AttributeSet attrs = new EmptyAttributeSet();
Instant revTime = Instant.ofEpochSecond(1_700_000_000L);
return new RevokedRecord(cred, revTime, RevocationReason.CESSATION_OF_OPERATION, attrs);
}
static PkiId anyPkiId() throws Exception {
long n = nextIdCounter();
UUID uuid = new UUID(0L, n);
Class<PkiId> cls = PkiId.class;
PkiId id = (PkiId) tryStaticNoArg(cls, "random");
if (id != null) {
return id;
}
id = (PkiId) tryStaticNoArg(cls, "newId");
if (id != null) {
return id;
}
PkiId byUuid = (PkiId) tryStatic1(cls, "of", UUID.class, uuid);
if (byUuid != null) {
return byUuid;
}
PkiId byUuid2 = (PkiId) tryCtor1(cls, UUID.class, uuid);
if (byUuid2 != null) {
return byUuid2;
}
String s = uuid.toString();
PkiId byStr = (PkiId) tryStatic1(cls, "fromString", String.class, s);
if (byStr != null) {
return byStr;
}
PkiId byStr2 = (PkiId) tryStatic1(cls, "parse", String.class, s);
if (byStr2 != null) {
return byStr2;
}
PkiId byStr3 = (PkiId) tryCtor1(cls, String.class, s);
if (byStr3 != null) {
return byStr3;
}
throw new IllegalStateException("cannot construct PkiId reflectively");
}
static Object anyValue(final Class<?> type) throws Exception {
Objects.requireNonNull(type, "type");
// Validity has invariants; create it explicitly before record handling.
if ("zeroecho.pki.api.Validity".equals(type.getName())) {
Instant notBefore = Instant.ofEpochSecond(1_700_000_000L);
Instant notAfter = notBefore.plusSeconds(86400L);
Constructor<?> ctor = type.getConstructor(Instant.class, Instant.class);
return ctor.newInstance(notBefore, notAfter);
}
if (AttributeSet.class.getName().equals(type.getName())) {
return new EmptyAttributeSet();
}
if (type.isRecord()) {
java.lang.reflect.RecordComponent[] components = type.getRecordComponents();
Class<?>[] ctorTypes = new Class<?>[components.length];
Object[] args = new Object[components.length];
for (int i = 0; i < components.length; i++) {
ctorTypes[i] = components[i].getType();
args[i] = defaultValue(ctorTypes[i]);
}
Constructor<?> ctor = type.getDeclaredConstructor(ctorTypes);
ctor.setAccessible(true);
return ctor.newInstance(args);
}
Object v = tryStaticNoArg(type, "empty");
if (v != null) {
return v;
}
v = tryStaticNoArg(type, "defaultValue");
if (v != null) {
return v;
}
Constructor<?>[] ctors = type.getConstructors();
for (Constructor<?> ctor : ctors) {
if (ctor.getParameterCount() == 0) {
return ctor.newInstance();
} }
} }
Object vs = tryCtor1(type, String.class, "x"); @Override
if (vs != null) { public Set<AttributeId> ids() {
return vs; return entries.stream().map(Entry::id).collect(Collectors.toUnmodifiableSet());
} }
throw new IllegalStateException("cannot construct value: " + type.getName()); @Override
public Optional<AttributeValue> get(AttributeId id) {
if (id == null) {
throw new IllegalArgumentException("id must not be null");
} }
for (Entry e : entries) {
static Object defaultValue(final Class<?> t) throws Exception { if (e.id().equals(id)) {
if (t == String.class) { if (e.values().isEmpty()) {
return "x";
}
if (t == int.class || t == Integer.class) {
return Integer.valueOf(0);
}
if (t == long.class || t == Long.class) {
return Long.valueOf(0L);
}
if (t == boolean.class || t == Boolean.class) {
return Boolean.FALSE;
}
if (t == byte[].class) {
return new byte[] { 0x01, 0x02 };
}
if (t == Optional.class) {
return Optional.empty(); return Optional.empty();
} }
if (List.class.isAssignableFrom(t)) { return Optional.of(e.values().get(0));
}
}
return Optional.empty();
}
@Override
public List<AttributeValue> getAll(AttributeId id) {
if (id == null) {
throw new IllegalArgumentException("id must not be null");
}
for (Entry e : entries) {
if (e.id().equals(id)) {
return List.copyOf(e.values());
}
}
return List.of(); return List.of();
} }
if (t == Instant.class) {
return Instant.ofEpochSecond(1_700_000_000L);
}
if (t == java.time.Duration.class) {
return java.time.Duration.ZERO;
}
if (t == PkiId.class) {
return anyPkiId();
}
if (AttributeSet.class.isAssignableFrom(t)) {
return new EmptyAttributeSet();
}
if (t.isEnum()) {
Object[] enums = t.getEnumConstants();
return enums.length > 0 ? enums[0] : null;
}
return anyValue(t);
}
static Object tryStaticNoArg(final Class<?> type, final String method) {
try {
Method m = type.getMethod(method);
return m.invoke(null);
} catch (ReflectiveOperationException e) {
return null;
}
}
static Object tryStatic1(final Class<?> type, final String method, final Class<?> argType, final Object arg) {
try {
Method m = type.getMethod(method, argType);
return m.invoke(null, arg);
} catch (ReflectiveOperationException e) {
return null;
}
}
static Object tryCtor1(final Class<?> type, final Class<?> argType, final Object arg) {
try {
Constructor<?> c = type.getConstructor(argType);
return c.newInstance(arg);
} catch (ReflectiveOperationException e) {
return null;
}
}
private static long nextIdCounter() {
idCounter = idCounter + 1L;
return idCounter;
}
} }
/** /**
* Minimal deterministic empty {@link AttributeSet} for tests. * Deterministic domain fixtures.
*
* <p>
* The production API defines {@link AttributeSet} as a passive interface
* without factories. Tests only require an immutable empty set.
* </p>
*/ */
static final class EmptyAttributeSet implements AttributeSet { static final class TestObjects {
@Override private static final AtomicLong SEQ = new AtomicLong(1L);
public Set<AttributeId> ids() {
return Set.of(); static CaRecord minimalCaRecord(String caId, CaState state) {
PkiId id = new PkiId(caId);
KeyRef issuerKeyRef = new KeyRef("issuer-key-" + caId);
SubjectRef subjectRef = new SubjectRef("CN=" + caId);
Credential cred = minimalCredential("CA-" + caId, "profile-ca");
List<Credential> caCredentials = List.of(cred);
return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, caCredentials);
} }
@Override static CertificateProfile minimalProfile(String profileId, boolean active) {
public Optional<AttributeValue> get(final AttributeId id) { FormatId formatId = new FormatId("fmt-x509");
Objects.requireNonNull(id, "id"); String displayName = "Profile " + profileId;
return Optional.empty();
List<AttributeId> required = List.of(new AttributeId("req-1"));
List<AttributeId> optional = List.of(new AttributeId("opt-1"));
Optional<Duration> maxValidity = Optional.of(Duration.ofDays(365));
return new CertificateProfile(profileId, formatId, displayName, required, optional, maxValidity, active);
} }
@Override static Credential minimalCredential(String serial, String profileId) {
public List<AttributeValue> getAll(final AttributeId id) { PkiId credentialId = new PkiId("cred-" + nextSeq());
Objects.requireNonNull(id, "id"); FormatId formatId = new FormatId("fmt-x509");
return List.of();
IssuerRef issuerRef = new IssuerRef(new PkiId("issuer-" + nextSeq()));
SubjectRef subjectRef = new SubjectRef("CN=subj-" + nextSeq());
Instant notBefore = Instant.EPOCH;
Instant notAfter = Instant.EPOCH.plusSeconds(60L);
Validity validity = new Validity(notBefore, notAfter);
PkiId publicKeyId = new PkiId("pk-" + nextSeq());
CredentialStatus status = CredentialStatus.ISSUED;
EncodedObject encoded = minimalEncodedObject();
AttributeSet attrs = emptyAttributes();
return new Credential(credentialId, formatId, issuerRef, subjectRef, validity, serial, publicKeyId,
profileId, status, encoded, attrs);
}
static RevokedRecord minimalRevocation(String credentialId, Instant when, RevocationReason reason) {
PkiId id = new PkiId(credentialId);
AttributeSet attrs = emptyAttributes();
return new RevokedRecord(id, when, reason, attrs);
}
static AttributeSet emptyAttributes() {
return new TestAttributeSet(List.of());
}
private static EncodedObject minimalEncodedObject() {
return new EncodedObject(Encoding.DER, new byte[] { 0x01, 0x02, 0x03 });
}
private static String nextSeq() {
return String.format("%016x", Long.valueOf(SEQ.getAndIncrement()));
} }
} }
} }

View File

@@ -0,0 +1,112 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.profile.CertificateProfile;
/**
* Tests for {@link FsCodec}.
*/
public final class FsCodecTest {
@Test
void booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation() {
System.out.println("booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation");
byte[] data = FsCodec.encode(Boolean.TRUE);
// MAGIC + TAG + payload => 3 bytes
System.out.println("...encoded length=" + data.length);
assertTrue(data.length <= 5);
// decode as Boolean
Boolean b = FsCodec.decode(data, Boolean.class);
assertEquals(Boolean.TRUE, b);
// decode as primitive expectation via Boolean.class cast still OK in callers;
// actual primitive/wrapper tolerance is inside the codec.
Object any = FsCodec.decode(data, Object.class);
assertEquals(Boolean.TRUE, any);
System.out.println("booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation...ok");
}
@Test
void recordRoundTripUsesCompactEncoding() {
System.out.println("recordRoundTripUsesCompactEncoding");
CertificateProfile p = new CertificateProfile("profile-a", new FormatId("fmt-x509"), "Profile A",
List.of(new AttributeId("req-1")), List.of(new AttributeId("opt-1")), Optional.of(Duration.ofDays(365)),
true);
byte[] data = FsCodec.encode(p);
System.out.println("...encoded length=" + data.length);
assertTrue(data.length > 0);
CertificateProfile decoded = FsCodec.decode(data, CertificateProfile.class);
assertEquals(p, decoded);
System.out.println("recordRoundTripUsesCompactEncoding...ok");
}
@Test
void listAndOptionalRoundTripUsesCompactEncoding() {
System.out.println("listAndOptionalRoundTripUsesCompactEncoding");
List<String> list = List.of("a", "b");
byte[] listData = FsCodec.encode(list);
Object decodedList = FsCodec.decode(listData, Object.class);
assertEquals(list, decodedList);
Optional<Integer> opt = Optional.of(Integer.valueOf(7));
byte[] optData = FsCodec.encode(opt);
Object decodedOpt = FsCodec.decode(optData, Object.class);
assertEquals(opt, decodedOpt);
System.out.println("listAndOptionalRoundTripUsesCompactEncoding...ok");
}
}

View File

@@ -0,0 +1,152 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.util.async;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.impl.async.PkiCodecs;
import zeroecho.pki.util.async.codec.ResultCodec;
import zeroecho.pki.util.async.impl.AppendOnlyLineStore;
import zeroecho.pki.util.async.impl.DurableAsyncBus;
public class DurableAsyncBusTest {
@TempDir
Path tempDir;
@Test
public void submitUpdateConsume_removesResultAndOperation() {
System.out.println("submitUpdateConsume_removesResultAndOperation");
Path log = tempDir.resolve("async.log");
System.out.println("...log=" + log.getFileName());
AppendOnlyLineStore store = new AppendOnlyLineStore(log);
DurableAsyncBus<PkiId, Principal, String, String> bus = new DurableAsyncBus<PkiId, Principal, String, String>(
PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, new ResultCodec<String>() {
@Override
public String encode(String result) {
return result;
}
@Override
public String decode(String token) {
return token;
}
}, store);
PkiId opId = new PkiId("op-1");
Principal owner = new Principal("USER", "alice");
bus.registerEndpoint("endpoint-1", new AsyncEndpoint<PkiId, String>() {
@Override
public Optional<AsyncStatus> status(PkiId id) {
return Optional.empty();
}
@Override
public Optional<String> result(PkiId id) {
return Optional.empty();
}
});
bus.submit(opId, "ISSUE_CERT", owner, "endpoint-1", Instant.parse("2025-01-01T00:00:00Z"), Duration.ofHours(1));
AsyncStatus ok = new AsyncStatus(AsyncState.SUCCEEDED, Instant.parse("2025-01-01T00:00:10Z"), Optional.of("OK"),
java.util.Map.of("note", "done"));
bus.update(opId, ok, Optional.of("RESULT-ABC"));
Optional<String> consumed = bus.consumeResult(opId);
System.out.println("...consumed=" + (consumed.isPresent() ? consumed.get() : "<empty>"));
assertTrue(consumed.isPresent());
assertEquals("RESULT-ABC", consumed.get());
assertTrue(bus.snapshot(opId).isEmpty());
assertTrue(bus.status(opId).isEmpty());
System.out.println("...ok");
}
@Test
public void replay_restoresSnapshotAndStatus() {
System.out.println("replay_restoresSnapshotAndStatus");
Path log = tempDir.resolve("async.log");
System.out.println("...log=" + log.getFileName());
AppendOnlyLineStore store1 = new AppendOnlyLineStore(log);
DurableAsyncBus<PkiId, Principal, String, String> bus1 = new DurableAsyncBus<PkiId, Principal, String, String>(
PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, ResultCodec.none(), store1);
PkiId opId = new PkiId("op-2");
Principal owner = new Principal("SERVICE", "issuer");
bus1.submit(opId, "CRL", owner, "endpoint-1", Instant.parse("2025-01-01T00:00:00Z"), Duration.ofHours(2));
AsyncStatus running = new AsyncStatus(AsyncState.RUNNING, Instant.parse("2025-01-01T00:00:05Z"),
Optional.of("RUNNING"), java.util.Map.of());
bus1.update(opId, running, Optional.empty());
AppendOnlyLineStore store2 = new AppendOnlyLineStore(log);
DurableAsyncBus<PkiId, Principal, String, String> bus2 = new DurableAsyncBus<PkiId, Principal, String, String>(
PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, ResultCodec.none(), store2);
Optional<AsyncOperationSnapshot<PkiId, Principal, String>> snap = bus2.snapshot(opId);
Optional<AsyncStatus> st = bus2.status(opId);
System.out.println("...snapshotPresent=" + snap.isPresent());
System.out.println("...statusPresent=" + st.isPresent());
assertTrue(snap.isPresent());
assertTrue(st.isPresent());
assertEquals(AsyncState.RUNNING, st.get().state());
assertEquals("CRL", snap.get().type());
System.out.println("...ok");
}
}