diff --git a/pki/src/main/java/zeroecho/pki/PkiApplication.java b/pki/src/main/java/zeroecho/pki/PkiApplication.java index 75b6f10..c3ad263 100644 --- a/pki/src/main/java/zeroecho/pki/PkiApplication.java +++ b/pki/src/main/java/zeroecho/pki/PkiApplication.java @@ -34,32 +34,76 @@ ******************************************************************************/ package zeroecho.pki; +import java.time.Duration; +import java.time.Instant; 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.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. * *

- * This class is intentionally limited to process bootstrap concerns only: + * This class is intentionally limited to process bootstrap and hosting + * concerns: *

* * + *

Async bus sweep

*

- * No cryptography, persistence, or domain/business logic is performed here. The - * public PKI API resides under {@code zeroecho.pki.api.*} and is not modified - * by this bootstrap. + * The async bus requires periodic {@link AsyncBus#sweep(Instant)} calls to: + * expire operations past their deadline and re-synchronize open operations + * after restart (depending on the bus implementation). + *

+ * + *

Security

+ *

+ * Command-line arguments and configuration values are never logged because they + * can contain sensitive material (paths, tokens, passphrases). *

*/ +@SuppressWarnings("PMD.DoNotUseThreads") public final class PkiApplication { private static final Logger LOG = Logger.getLogger(PkiApplication.class.getName()); + /** + * System property controlling the async sweep interval in milliseconds. + * + *

+ * If missing or invalid, a safe default is used. + *

+ */ + 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() { throw new AssertionError("No instances."); } @@ -82,18 +126,184 @@ public final class PkiApplication { LOG.info("ZeroEcho PKI starting."); - Runtime.getRuntime().addShutdownHook(new Thread(() -> { // NOPMD - Logger shutdownLogger = Logger.getLogger(PkiApplication.class.getName()); - PkiLogging.emitShutdownMessage(shutdownLogger, "ZeroEcho PKI stopping."); - }, "zeroecho-pki-shutdown")); + CountDownLatch shutdownLatch = new CountDownLatch(1); + + // closed in the shutdown routine + ScheduledExecutorService sweepExecutor = Executors.newSingleThreadScheduledExecutor(new SweepThreadFactory()); // NOPMD + + Runtime.getRuntime() + .addShutdownHook(new Thread(new ShutdownHook(sweepExecutor, shutdownLatch), "zeroecho-pki-shutdown")); try { - // Intentionally no business logic yet. Bootstrap only. - LOG.info("ZeroEcho PKI started (bootstrap only)."); + AsyncBus asyncBus = PkiBootstrap.openAsyncBus(); + + 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 // Do not include user-provided inputs in the message; log the exception object. LOG.log(Level.SEVERE, "Fatal error during PKI bootstrap.", 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. + * + *

+ * {@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. + *

+ * + *

+ * 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. + *

+ * + *

+ * This class is internal to the PKI bootstrap and lifecycle management logic + * and is not part of the public API surface. + *

+ */ + private static final class SweepTask implements Runnable { + + private final AsyncBus asyncBus; + + private SweepTask(AsyncBus 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. + * + *

+ * {@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. + *

+ * + *

+ * The hook guarantees that shutdown coordination always completes by + * decrementing the associated {@link CountDownLatch}, regardless of whether + * shutdown is graceful, forced, or interrupted. + *

+ * + *

+ * This class must never throw exceptions or prevent JVM termination. All + * failure modes are handled internally. + *

+ */ + 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. + * + *

+ * {@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. + *

+ * + *

+ * The factory performs no additional customization such as priority changes or + * uncaught-exception handlers, relying instead on executor-level policies. + *

+ */ + 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; + } + } } diff --git a/pki/src/main/java/zeroecho/pki/api/PkiId.java b/pki/src/main/java/zeroecho/pki/api/PkiId.java index eaaf42b..ccbe7c7 100644 --- a/pki/src/main/java/zeroecho/pki/api/PkiId.java +++ b/pki/src/main/java/zeroecho/pki/api/PkiId.java @@ -62,4 +62,9 @@ public record PkiId(String value) { throw new IllegalArgumentException("value must not be null/blank"); } } + + @Override + public String toString() { + return value; + } } diff --git a/pki/src/main/java/zeroecho/pki/api/orch/OrchestrationDurabilityPolicy.java b/pki/src/main/java/zeroecho/pki/api/orch/OrchestrationDurabilityPolicy.java new file mode 100644 index 0000000..03bb901 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/orch/OrchestrationDurabilityPolicy.java @@ -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. + * + *

+ * 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. + *

+ * + *

Examples

+ *
    + *
  • 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.
  • + *
  • 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.
  • + *
+ */ +public enum OrchestrationDurabilityPolicy { + + /** + * Fail-closed behavior. + * + *

+ * 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. + *

+ * + *

+ * 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. + *

+ */ + STRICT_ABORT_ON_RESTART, + + /** + * Durable operation using minimal non-sensitive state. + * + *

+ * 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). + *

+ * + *

+ * 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. + *

+ */ + DURABLE_MIN_STATE, + + /** + * Durable operation with encrypted continuation state. + * + *

+ * 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. + *

+ * + *

+ * 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. + *

+ */ + DURABLE_ENCRYPTED_STATE +} diff --git a/pki/src/main/java/zeroecho/pki/api/orch/WorkflowStateRecord.java b/pki/src/main/java/zeroecho/pki/api/orch/WorkflowStateRecord.java new file mode 100644 index 0000000..ea09712 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/orch/WorkflowStateRecord.java @@ -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. + * + *

+ * 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}. + *

+ * + *

Storage model

+ *

+ * 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}. + *

+ * + *

Security

+ *
    + *
  • {@link #payload()} may contain sensitive information. It must not be + * logged.
  • + *
  • 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.
  • + *
+ * + * @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 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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/orch/package-info.java b/pki/src/main/java/zeroecho/pki/api/orch/package-info.java new file mode 100644 index 0000000..c683992 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/orch/package-info.java @@ -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. + * + *

+ * This package defines stable domain abstractions related to long-running + * PKI workflows and their durability requirements. It is concerned with + * describing what must be persisted and how durable such persistence + * must be, not with how the persistence is implemented. + *

+ * + *

Scope and responsibilities

+ *

+ * The orchestration layer in ZeroEcho PKI coordinates multi-step operations + * such as certificate issuance, revocation processing, key rollover, or + * publication workflows. These operations: + *

+ *
    + *
  • may span multiple logical steps,
  • + *
  • may involve external systems or asynchronous callbacks,
  • + *
  • must tolerate restarts, crashes, or redeployments.
  • + *
+ * + *

+ * This package provides the minimal API necessary to: + *

+ *
    + *
  • describe the required durability guarantees for workflow state,
  • + *
  • represent persisted workflow state in a storage-agnostic form,
  • + *
  • enable deterministic recovery and audit of workflow progression.
  • + *
+ * + *

Key abstractions

+ *
    + *
  • {@link zeroecho.pki.api.orch.OrchestrationDurabilityPolicy + * OrchestrationDurabilityPolicy} defines how durable orchestration + * state must be (e.g. write-through, buffered, best-effort), allowing different + * operational trade-offs without changing orchestration logic.
  • + *
  • {@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.
  • + *
+ * + *

Immutability and history

+ *

+ * Workflow state records are designed to be immutable once persisted. + * Implementations are expected to append new state records rather than + * overwrite existing ones, enabling: + *

+ *
    + *
  • full audit trails,
  • + *
  • temporal queries ("state at time T"),
  • + *
  • post-mortem analysis of failed or aborted workflows.
  • + *
+ * + *

+ * 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. + *

+ * + *

API stability and integration

+ *

+ * The types in this package are part of the public PKI API surface. They are + * intended to be usable across different runtime environments, including: + *

+ *
    + *
  • standalone CLI applications,
  • + *
  • long-running server processes,
  • + *
  • container-managed frameworks such as Spring or Micronaut.
  • + *
+ * + *

+ * No assumptions are made about the underlying persistence mechanism + * (filesystem, database, distributed log, etc.). Such concerns are handled by + * SPI and implementation layers. + *

+ * + * @since 1.0 + */ +package zeroecho.pki.api.orch; diff --git a/pki/src/main/java/zeroecho/pki/impl/async/FileBackedAsyncBusProvider.java b/pki/src/main/java/zeroecho/pki/impl/async/FileBackedAsyncBusProvider.java new file mode 100644 index 0000000..68ef984 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/async/FileBackedAsyncBusProvider.java @@ -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. + * + *

+ * 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. + *

+ * + *

Configuration keys

+ *
    + *
  • {@code logPath} (optional): append-only log file path. Default: + * {@code pki-async/async.log} relative to the working directory.
  • + *
+ * + *

Security

+ *

+ * The log contains operation metadata and must be protected. The underlying + * store applies best-effort restrictive permissions (POSIX when available). + *

+ */ +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 supportedKeys() { + return Set.of(KEY_LOG_PATH); + } + + @Override + public AsyncBus allocate(ProviderConfig config) { + Objects.requireNonNull(config, "config"); + + if (!id().equals(config.backendId())) { + throw new IllegalArgumentException("ProviderConfig backendId mismatch."); + } + + Map 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 resultCodec = ResultCodec.none(); + + DurableAsyncBus bus = new DurableAsyncBus<>(PkiCodecs.PKI_ID, + PkiCodecs.PRINCIPAL, PkiCodecs.STRING, resultCodec, store); + + return bus; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/async/PkiAsyncTypes.java b/pki/src/main/java/zeroecho/pki/impl/async/PkiAsyncTypes.java new file mode 100644 index 0000000..db1a47c --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/async/PkiAsyncTypes.java @@ -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. + * + *

+ * This class exists solely to centralize the chosen type parameters used to + * instantiate the generic async bus core. + *

+ */ +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. + * + *

+ * 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. + *

+ */ + public static final class Result { + private Result() { + } + } + + /** + * Returns the operation id type. + * + * @return class token + */ + public static Class opIdType() { + return PkiId.class; + } + + /** + * Returns the owner type. + * + * @return class token + */ + public static Class ownerType() { + return Principal.class; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/async/PkiCodecs.java b/pki/src/main/java/zeroecho/pki/impl/async/PkiCodecs.java new file mode 100644 index 0000000..0ba41ac --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/async/PkiCodecs.java @@ -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 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}. + * + *

+ * Encoded form: {@code type:name}. Both parts are treated as non-secret + * identifiers. + *

+ */ + public static final IdCodec 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 = 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; + } + }; +} diff --git a/pki/src/main/java/zeroecho/pki/internal/x509/package-info.java b/pki/src/main/java/zeroecho/pki/impl/async/package-info.java similarity index 77% rename from pki/src/main/java/zeroecho/pki/internal/x509/package-info.java rename to pki/src/main/java/zeroecho/pki/impl/async/package-info.java index 68c0f8a..52bee02 100644 --- a/pki/src/main/java/zeroecho/pki/internal/x509/package-info.java +++ b/pki/src/main/java/zeroecho/pki/impl/async/package-info.java @@ -33,6 +33,19 @@ * POSSIBILITY OF SUCH DAMAGE. ******************************************************************************/ /** - * + * Default implementations for the PKI async bus SPI. + * + *

+ * 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}. + *

+ * + *

Security

+ *

+ * 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. + *

*/ -package zeroecho.pki.internal.x509; \ No newline at end of file +package zeroecho.pki.impl.async; diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java index cb0d400..e500165 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -55,6 +55,7 @@ import java.util.logging.Logger; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.profile.CertificateProfile; 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); } + // ------------------------------------------------------------------------- + // 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 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 listWorkflowStates() { + // List by operation directory (workflows/by-op//current.bin) + return listCurrentRecords(this.paths.workflowRoot(), WorkflowStateRecord.class); + } + @Override public void close() throws IOException { synchronized (this.lockChannel) { // NOPMD diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java index 9aea6b3..09dab7d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java @@ -58,7 +58,7 @@ public final class FilesystemPkiStoreProvider implements PkiStoreProvider { /** * Public no-arg constructor required by {@link java.util.ServiceLoader}. */ - public FilesystemPkiStoreProvider() { // NOPMD + public FilesystemPkiStoreProvider() { // no-op } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java index ce818f8..8b8158d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java @@ -40,75 +40,90 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.lang.invoke.MethodHandles; -import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.RecordComponent; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; +import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.UUID; import zeroecho.core.io.Util; /** - * Deterministic binary codec for PKI store objects. + * Compact binary codec for filesystem persistence. * + *

Encoding format

*

- * This codec is designed for: + * The codec supports two on-wire formats: *

*
    - *
  • Deterministic serialization across JVM runs.
  • - *
  • No external dependencies.
  • - *
  • Support for records (the dominant pattern in the PKI API).
  • + *
  • Compact format (current): begins with a 1-byte MAGIC marker + * {@code 0xFF}, followed by a 1-byte TAG and tag-specific payload.
  • + *
  • Legacy format (backward-compatible): 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.
  • *
* *

- * Supported types: - *

- *
    - *
  • primitive wrappers, {@link String}, {@code byte[]}
  • - *
  • {@link Instant}, {@link Duration}, {@link UUID}
  • - *
  • {@link Optional}, {@link List}
  • - *
  • {@link Enum}
  • - *
  • Java {@code record} types, recursively
  • - *
- * - *

- * 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()}. + * The compact format eliminates the pathological overhead of writing a full + * Java class name for every scalar value (e.g., {@code boolean}). It also + * substantially reduces class-loading activity during decode. *

* + *

Type compatibility

*

- * If none of the above works, an exception is thrown. This is intentional: the - * persistence layer must be explicit and auditable. + * During decode, the codec enforces that the encoded value type is compatible + * 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}. *

*/ +@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.NcssCount" }) 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> 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 - * files and unbounded allocations). This is not a security boundary. + * Magic byte that marks the compact format. + * + *

+ * 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 < 0x80. + *

*/ - 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() { - // utility class + // utility } /* default */ static byte[] encode(final T value) { Objects.requireNonNull(value, "value"); try { ByteArrayOutputStream bos = new ByteArrayOutputStream(); - writeAny(bos, value); + writeAnyCompact(bos, value); return bos.toByteArray(); } catch (IOException e) { throw new IllegalStateException("encoding failed: " + value.getClass().getName(), e); @@ -123,251 +138,353 @@ final class FsCodec { Object decoded = readAny(bis, expectedType); return expectedType.cast(decoded); } 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(value, "value"); - Class type = value.getClass(); - Util.writeUTF8(out, type.getName()); + out.write(MAGIC_COMPACT); - if (value instanceof String s) { - writeString(out, s); - return; - } - if (value instanceof Integer i) { - Util.writePack7I(out, i); - return; - } - if (value instanceof Long l) { - Util.writeLong(out, l); - return; - } - if (value instanceof Boolean b) { - out.write(b ? 1 : 0); - return; - } - if (value instanceof byte[] bytes) { - Util.write(out, bytes); - return; - } - if (value instanceof Instant instant) { - Util.writeLong(out, instant.getEpochSecond()); - Util.writePack7I(out, instant.getNano()); - return; - } - if (value instanceof Duration duration) { - Util.writeLong(out, duration.getSeconds()); - Util.writePack7I(out, duration.getNano()); - return; - } - if (value instanceof UUID uuid) { - Util.write(out, uuid); - return; - } - if (value instanceof Optional opt) { - out.write(opt.isPresent() ? 1 : 0); - if (opt.isPresent()) { - writeAny(out, opt.get()); + // Use a switch with patterns to avoid long if/else chains and keep the hot path + // compact. + switch (value) { + case String s -> { + out.write(TAG_STRING); + Util.writeUTF8(out, s); } - return; - } - if (value instanceof List list) { - Util.writePack7I(out, list.size()); - for (Object item : list) { - writeAny(out, item); + case Integer i -> { + out.write(TAG_INT); + Util.writePack7I(out, i); } - return; - } - if (type.isEnum()) { - Enum e = (Enum) value; - writeString(out, e.name()); - return; - } - if (type.isRecord()) { - RecordComponent[] components = type.getRecordComponents(); - Util.writePack7I(out, components.length); - for (RecordComponent c : components) { - try { - Method accessor = c.getAccessor(); - Object componentValue = accessor.invoke(value); - writeAny(out, componentValue); - } catch (ReflectiveOperationException ex) { - throw new IllegalStateException("record encode failed: " + type.getName() + "." + c.getName(), ex); + case Long l -> { + out.write(TAG_LONG); + Util.writePack7L(out, l); + } + case Boolean b -> { + out.write(TAG_BOOL); + out.write(b ? 1 : 0); + } + case byte[] bytes -> { + out.write(TAG_BYTES); + Util.write(out, bytes); + } + case Instant instant -> { + out.write(TAG_INSTANT); + Util.writeLong(out, instant.getEpochSecond()); + Util.writePack7I(out, instant.getNano()); + } + case Duration duration -> { + out.write(TAG_DURATION); + Util.writeLong(out, duration.getSeconds()); + Util.writePack7I(out, duration.getNano()); + } + case java.util.List list -> { + out.write(TAG_LIST); + Util.writePack7I(out, list.size()); + for (Object element : list) { + Objects.requireNonNull(element, "list element must not be null"); + writeAnyCompact(out, element); } } - return; - } + case java.util.Set set -> { + out.write(TAG_SET); - // fallback: encode as string and reconstruct via string factory on decode - writeString(out, value.toString()); + // Deterministic ordering: encode sets in a stable order. + // Use natural order when possible; otherwise order by stable string key. + java.util.List 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 ca = (Comparable) 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()) { + out.write(TAG_ENUM); + // Enum: write declaring class name once + ordinal. + Util.writeUTF8(out, type.getName()); + Enum e = (Enum) value; + Util.writePack7I(out, e.ordinal()); + return; + } + + 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(); + Util.writePack7I(out, components.length); + for (RecordComponent c : components) { + try { + Method accessor = c.getAccessor(); + Object componentValue = accessor.invoke(value); + // Preserve existing invariant: no null components in storage. + Objects.requireNonNull(componentValue, + "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; + } + + // Fallback: encode as string + type name (still far smaller than + // 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 { Objects.requireNonNull(in, "in"); Objects.requireNonNull(expectedType, "expectedType"); - String encodedTypeName = Util.readUTF8(in, MAX_STRING_BYTES); - Class encodedType; - ClassLoader primary = MethodHandles.lookup().lookupClass().getClassLoader(); // NOPMD - 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); + int first = in.read(); + if (first < 0) { + throw new IOException("unexpected EOF"); } - // decode using the encoded type (authoritative) - Object value = readByType(in, encodedType); - - // enforce expected assignment - if (!expectedType.isAssignableFrom(encodedType)) { + if (first != MAGIC_COMPACT) { 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; } - private static Object readByType(final InputStream in, final Class type) throws IOException { // NOPMD - if (type == String.class) { - return Util.readUTF8(in, MAX_STRING_BYTES); - } - if (type == Integer.class) { - return Util.readPack7I(in); - } - if (type == Long.class) { - return Util.readLong(in); - } - if (type == Boolean.class) { - int b = in.read(); - if (b < 0) { - throw new IOException("unexpected EOF"); + private static Object readByTag(final InputStream in, final int tag) throws IOException { + switch (tag) { + case TAG_STRING -> { + return Util.readUTF8(in, MAX_STRING_BYTES); } - return b != 0; - } - if (type == byte[].class) { - return Util.read(in, MAX_BLOB_BYTES); - } - if (type == Instant.class) { - long seconds = Util.readLong(in); - int nanos = Util.readPack7I(in); - return Instant.ofEpochSecond(seconds, nanos); - } - if (type == Duration.class) { - long seconds = Util.readLong(in); - int nanos = Util.readPack7I(in); - return Duration.ofSeconds(seconds, nanos); - } - if (type == UUID.class) { - return Util.readUUID(in); - } - if (type == Optional.class) { - int present = in.read(); - if (present < 0) { - throw new IOException("unexpected EOF"); + case TAG_INT -> { + return Util.readPack7I(in); } - if (present == 0) { - return Optional.empty(); + case TAG_LONG -> { + return Util.readPack7L(in); } - // generic type erased; decode nested object as Object - Object nested = readAny(in, Object.class); - return Optional.of(nested); - } - if (List.class.isAssignableFrom(type)) { - int size = Util.readPack7I(in); - List 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++) { - RecordComponent c = components[i]; - ctorTypes[i] = c.getType(); - args[i] = readAny(in, c.getType()); - } - - try { - Constructor ctor = type.getDeclaredConstructor(ctorTypes); - ctor.setAccessible(true); // NOPMD - return ctor.newInstance(args); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("record decode failed: " + type.getName(), e); - } - } - - // fallback: read string and reconstruct with best-effort factory methods - 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 methodNames = List.of("fromString", "parse", "of", "valueOf"); - for (String methodName : methodNames) { - try { - Method m = type.getMethod(methodName, String.class); - if (type.isAssignableFrom(m.getReturnType())) { - return m.invoke(null, s); + case TAG_BOOL -> { + int b = in.read(); + if (b < 0) { + throw new IOException("unexpected EOF"); } - } catch (ReflectiveOperationException ignored) { - // continue + return b != 0; } + case TAG_BYTES -> { + return Util.read(in, MAX_STRING_BYTES); + } + case TAG_INSTANT -> { + long sec = Util.readLong(in); + int nano = Util.readPack7I(in); + return Instant.ofEpochSecond(sec, nano); + } + case TAG_DURATION -> { + long sec = Util.readLong(in); + int nano = Util.readPack7I(in); + return Duration.ofSeconds(sec, nano); + } + case TAG_ENUM -> { + 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); + } + 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 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 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(); + if (present < 0) { + throw new IOException("unexpected EOF"); + } + if (present == 0) { + return java.util.Optional.empty(); + } + Object element = readAny(in, Object.class); + return java.util.Optional.of(element); + } + default -> throw new IllegalStateException("unknown compact tag: " + tag); + } + } + + private static boolean isTypeCompatible(final Class expectedType, final Class actualType) { + if (expectedType.isAssignableFrom(actualType)) { + return true; + } + + 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 { - Constructor ctor = type.getConstructor(String.class); - return ctor.newInstance(s); - } catch (ReflectiveOperationException ignored) { - // continue + return Class.forName(typeName, false, cl); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("unknown encoded type: " + typeName, e); } + } - // common pattern: base64 bytes container + private static Method findStringFactory(final Class type) { try { - Method m = type.getMethod("of", byte[].class); - if (type.isAssignableFrom(m.getReturnType())) { - byte[] decoded = Base64.getDecoder().decode(s); - return m.invoke(null, decoded); + Method of = type.getMethod("of", String.class); + if ((of.getModifiers() & java.lang.reflect.Modifier.STATIC) != 0 && of.getReturnType() == type) { + return of; } - } catch (ReflectiveOperationException ignored) { + } catch (NoSuchMethodException ex) { // NOPMD + // ignore + } + try { + Method valueOf = type.getMethod("valueOf", String.class); + if ((valueOf.getModifiers() & java.lang.reflect.Modifier.STATIC) != 0 && valueOf.getReturnType() == type) { + return valueOf; + } + } catch (NoSuchMethodException ex) { // NOPMD // ignore } - return null; } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java index 3676395..b61de97 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -43,8 +43,16 @@ import zeroecho.pki.api.PkiId; * Deterministic path mapping for the filesystem PKI store. * *

- * This class is internal to the reference implementation. It defines the - * on-disk layout and centralizes naming conventions. + * This class is internal to the filesystem store implementation. It centralizes + * the on-disk layout so that path conventions remain stable and consistent + * across all entities and snapshot/export logic. + *

+ * + *

+ * 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. *

*/ final class FsPaths { @@ -76,7 +84,12 @@ final class FsPaths { return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK); } + // ------------------------------------------------------------------------- + // CA (mutable with history) + // ------------------------------------------------------------------------- + /* default */ Path caDir(final PkiId caId) { + Objects.requireNonNull(caId, "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); } + // ------------------------------------------------------------------------- + // Profiles (mutable with history) + // ------------------------------------------------------------------------- + /* default */ Path profileDir(final String profileId) { + Objects.requireNonNull(profileId, "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); } + // ------------------------------------------------------------------------- + // Credentials (immutable .bin) + // ------------------------------------------------------------------------- + /* default */ Path credentialPath(final PkiId credentialId) { + Objects.requireNonNull(credentialId, "credentialId"); return this.root.resolve("credentials").resolve(BY_ID).resolve(FsUtil.safeId(credentialId) + ".bin"); } + // ------------------------------------------------------------------------- + // Requests (immutable .bin) + // ------------------------------------------------------------------------- + /* default */ Path requestPath(final PkiId requestId) { + Objects.requireNonNull(requestId, "requestId"); return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + ".bin"); } + // ------------------------------------------------------------------------- + // Revocations (mutable with history) + // ------------------------------------------------------------------------- + /* default */ Path revocationDir(final PkiId credentialId) { + Objects.requireNonNull(credentialId, "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); } + // ------------------------------------------------------------------------- + // Status objects (immutable .bin) + // ------------------------------------------------------------------------- + /* default */ Path statusObjectPath(final PkiId statusObjectId) { + Objects.requireNonNull(statusObjectId, "statusObjectId"); return this.root.resolve("status").resolve(BY_ID).resolve(FsUtil.safeId(statusObjectId) + ".bin"); } + // ------------------------------------------------------------------------- + // Policy traces (immutable .bin) + // ------------------------------------------------------------------------- + /* default */ Path policyTracePath(final PkiId decisionId) { + Objects.requireNonNull(decisionId, "decisionId"); return this.root.resolve("policy").resolve("by-decision").resolve(FsUtil.safeId(decisionId) + ".bin"); } + // ------------------------------------------------------------------------- + // Publications (immutable .bin) + // ------------------------------------------------------------------------- + /* default */ Path publicationPath(final PkiId publicationId) { + Objects.requireNonNull(publicationId, "publicationId"); return this.root.resolve("publications").resolve(BY_ID).resolve(FsUtil.safeId(publicationId) + ".bin"); } + + // ------------------------------------------------------------------------- + // Workflow state (mutable with history) + // ------------------------------------------------------------------------- + + /** + * Root directory for orchestrator workflow state records. + * + *

+ * 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/}. + *

+ * + * @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); + } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java index cf53274..2f1a999 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java @@ -39,38 +39,57 @@ import java.util.Objects; import java.util.Optional; /** - * Configuration for {@link FilesystemPkiStore}. + * Options for {@link FilesystemPkiStore}. * *

- * This type is intentionally placed in an implementation package. It is not a - * public PKI API contract. It exists to keep the {@code PkiStore} SPI stable, - * while still allowing a professional-grade reference implementation to be - * configured and tested. + * This options container configures the filesystem store behavior while keeping + * the {@code PkiStore} SPI stable. All options have deterministic semantics and + * should be suitable for tests and for reference deployments. *

* + *

History tracking

*

- * 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. *

+ * + * @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, - FsHistoryPolicy revocationHistoryPolicy, boolean strictSnapshotExport) { + FsHistoryPolicy revocationHistoryPolicy, FsHistoryPolicy workflowHistoryPolicy, boolean strictSnapshotExport) { /** * Canonical constructor with validation. + * + * @throws NullPointerException if any history policy is {@code null} */ public FsPkiStoreOptions { Objects.requireNonNull(caHistoryPolicy, "caHistoryPolicy"); Objects.requireNonNull(profileHistoryPolicy, "profileHistoryPolicy"); Objects.requireNonNull(revocationHistoryPolicy, "revocationHistoryPolicy"); + Objects.requireNonNull(workflowHistoryPolicy, "workflowHistoryPolicy"); } /** * Returns default options (history enabled with 90 days retention). * + *

+ * 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. + *

+ * * @return default options */ public static FsPkiStoreOptions defaults() { FsHistoryPolicy ninetyDays = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90))); - return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, true); + return new FsPkiStoreOptions(ninetyDays, ninetyDays, ninetyDays, ninetyDays, true); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java index d130778..2944e69 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -98,6 +98,9 @@ final class FsSnapshotExporter { this.options.profileHistoryPolicy(), this.options.strictSnapshotExport()); reconstructMutableTree(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"), at, 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) { throw new IllegalStateException("snapshot export failed", e); diff --git a/pki/src/main/java/zeroecho/pki/spi/async/AsyncBusProvider.java b/pki/src/main/java/zeroecho/pki/spi/async/AsyncBusProvider.java new file mode 100644 index 0000000..a73e3b8 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/async/AsyncBusProvider.java @@ -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. + * + *

+ * 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. + *

+ * + *

+ * Type parameters used by PKI: + *

+ *
    + *
  • Operation id: {@link PkiId}
  • + *
  • Owner: {@link Principal}
  • + *
  • Endpoint id: {@link String}
  • + *
  • Result: {@link Object} (intentionally not persisted by the default + * provider)
  • + *
+ * + *

+ * 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. + *

+ */ +public interface AsyncBusProvider extends ConfigurableProvider> { + // marker +} diff --git a/pki/src/main/java/zeroecho/pki/spi/async/package-info.java b/pki/src/main/java/zeroecho/pki/spi/async/package-info.java new file mode 100644 index 0000000..17bd562 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/async/package-info.java @@ -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. + * + *

+ * 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. + *

+ * + *

Selection

+ *
    + *
  • {@code -Dzeroecho.pki.async=<id>}
  • + *
  • {@code -Dzeroecho.pki.async.<key>=<value>}
  • + *
+ * + *

Security

+ *

+ * Provider configuration values may be sensitive and must not be logged. Only + * provider ids and configuration keys are suitable for diagnostics. + *

+ */ +package zeroecho.pki.spi.async; diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java index f4cac7c..331141e 100644 --- a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java +++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java @@ -40,14 +40,18 @@ import java.util.Objects; import java.util.logging.Level; 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.ProviderConfig; +import zeroecho.pki.spi.async.AsyncBusProvider; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.audit.AuditSinkProvider; import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.crypto.SignatureWorkflowProvider; import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStoreProvider; +import zeroecho.pki.util.async.AsyncBus; /** * 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 * components discovered via {@link java.util.ServiceLoader}. It is designed to * scale as more SPIs are introduced (audit, publish, framework integrations, - * crypto/workflows, etc.). + * crypto/workflows, async orchestration, etc.). *

* *

System property conventions

@@ -71,6 +75,9 @@ import zeroecho.pki.spi.store.PkiStoreProvider; * {@code -Dzeroecho.pki.crypto.workflow=<id>} *
  • Configure crypto workflow provider: * {@code -Dzeroecho.pki.crypto.workflow.<key>=<value>}
  • + *
  • Select async bus provider: {@code -Dzeroecho.pki.async=<id>}
  • + *
  • Configure async bus provider: + * {@code -Dzeroecho.pki.async.<key>=<value>}
  • * * *

    @@ -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_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() { throw new AssertionError("No instances."); } @@ -117,13 +127,7 @@ public final class PkiBootstrap { * are never logged; only keys may be logged. *

    * - *

    - * 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. - *

    - * - * @return opened store (never {@code null}) + * @return store (never {@code null}) */ public static PkiStore openStore() { String requestedId = System.getProperty(PROP_STORE_BACKEND); @@ -151,40 +155,11 @@ public final class PkiBootstrap { return provider.allocate(config); } - /** - * Logs provider help information (supported keys) for diagnostics. - * - *

    - * This method is safe: it does not log configuration values. - *

    - * - * @param provider provider (never {@code null}) - */ - public static void logSupportedKeys(ConfigurableProvider 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 * ServiceLoader. * - *

    - * Selection and configuration follow the same conventions as - * {@link #openStore()}, using {@code -Dzeroecho.pki.audit=<id>} and - * {@code zeroecho.pki.audit.} prefixed properties. - *

    - * - *

    - * 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. - *

    - * - * @return opened audit sink (never {@code null}) + * @return audit sink (never {@code null}) */ public static AuditSink openAudit() { String requestedId = System.getProperty(PROP_AUDIT_BACKEND); @@ -220,21 +195,7 @@ public final class PkiBootstrap { * Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider} * discovered via ServiceLoader. * - *

    - * Conventions: - *

    - *
      - *
    • Select provider: {@code -Dzeroecho.pki.crypto.workflow=<id>}
    • - *
    • Provider config: - * {@code -Dzeroecho.pki.crypto.workflow.<key>=<value>}
    • - *
    - * - *

    - * Security note: configuration values may be sensitive and must not be logged. - * This bootstrap logs only provider ids and configuration keys. - *

    - * - * @return opened signature workflow (never {@code null}) + * @return signature workflow (never {@code null}) */ public static SignatureWorkflow openSignatureWorkflow() { String requestedId = System.getProperty(PROP_CRYPTO_WORKFLOW_BACKEND); @@ -248,6 +209,7 @@ public final class PkiBootstrap { }); Map props = SpiSystemProperties.readPrefixed(PROP_CRYPTO_WORKFLOW_PREFIX); + ProviderConfig config = new ProviderConfig(provider.id(), props); if (LOG.isLoggable(Level.INFO)) { @@ -256,4 +218,66 @@ public final class PkiBootstrap { return provider.allocate(config); } + + /** + * Opens an async operation bus using {@link AsyncBusProvider} discovered via + * ServiceLoader. + * + *

    + * Defaulting rule: if no backend id is specified, defaults to {@code file}. + *

    + * + *

    + * 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. + *

    + * + * @return async bus (never {@code null}) + */ + public static AsyncBus 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 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. + * + *

    + * This method is safe: it does not log configuration values. + *

    + * + * @param provider provider (never {@code null}) + */ + public static void logSupportedKeys(ConfigurableProvider provider) { + Objects.requireNonNull(provider, "provider"); + if (LOG.isLoggable(Level.FINE)) { + LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys()); + } + } } diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFrameworkProvider.java b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFrameworkProvider.java new file mode 100644 index 0000000..0f0a6fa --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialFrameworkProvider.java @@ -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. + * + *

    + * 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). + *

    + * + *

    Dependency boundary

    + *

    + * 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. + *

    + */ +public interface CredentialFrameworkProvider extends ConfigurableProvider { + + /** + * Allocates a credential framework instance using the provided configuration. + * + *

    + * Implementations must validate that {@link ProviderConfig#backendId()} matches + * {@link #id()}. A mismatch must be reported as + * {@link IllegalArgumentException}. + *

    + * + * @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. + * + *

    + * This helper is intended for defensive checks inside provider implementations. + *

    + * + * @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."); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java index a02f6d5..01dd288 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -40,6 +40,7 @@ import java.util.Optional; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.publication.PublicationRecord; @@ -48,31 +49,27 @@ import zeroecho.pki.api.revocation.RevokedRecord; import zeroecho.pki.api.status.StatusObject; /** - * Persistence abstraction for PKI state. + * Persistence boundary for PKI state. * *

    - * This Service Provider Interface (SPI) defines the authoritative storage - * contract for all PKI-managed state, including CA entities, issued - * credentials, certification requests, revocations, status objects, profiles, - * publications, and policy traces. + * This Service Provider Interface (SPI) defines durable storage for the PKI + * domain model. Implementations may use a filesystem, database, or other + * backend, but must preserve deterministic behavior, atomicity guarantees, and + * must not leak secrets through logging or exception messages. *

    * + *

    Security

    *

    - * The interface is intentionally coarse-grained and framework-agnostic. - * Implementations are responsible for providing appropriate durability, - * consistency, and concurrency guarantees according to the deployment model - * (filesystem, embedded storage, RDBMS, distributed store, etc.). + * Implementations must protect persisted data appropriately (for example: + * filesystem permissions, database ACLs). Sensitive material must never be + * written to logs. Exceptions should avoid leaking secrets in their messages. *

    * + *

    Error handling

    *

    - * Security requirements: + * This SPI uses unchecked failures. Implementations should throw + * {@link IllegalStateException} when an operation cannot be completed safely. *

    - *
      - *
    • Private key material MUST NOT be persisted.
    • - *
    • Secrets must never be stored in cleartext.
    • - *
    • Stored objects must be treated as immutable unless explicitly - * replaced.
    • - *
    */ public interface PkiStore { @@ -80,62 +77,56 @@ public interface PkiStore { * Persists or updates a Certificate Authority (CA) record. * *

    - * Implementations must ensure that CA records are stored atomically. Replacing - * an existing CA record must preserve historical integrity (e.g., previously - * issued credentials must remain resolvable). + * Implementations must store CA records atomically. Replacing an existing + * record should be either fully visible or not visible at all. *

    * - * @param record CA record to persist - * @throws IllegalArgumentException if {@code record} is null - * @throws RuntimeException if persistence fails + * @param record CA record (never {@code null}) + * @throws NullPointerException if {@code record} is {@code null} + * @throws IllegalStateException if persistence fails */ void putCa(CaRecord record); /** - * Retrieves a CA record by its identifier. + * Retrieves a CA record. * - * @param caId CA identifier - * @return CA record if present, otherwise {@link Optional#empty()} - * @throws IllegalArgumentException if {@code caId} is null - * @throws RuntimeException if retrieval fails + * @param caId CA identifier (never {@code null}) + * @return CA record if present + * @throws NullPointerException if {@code caId} is {@code null} + * @throws IllegalStateException if retrieval fails */ Optional getCa(PkiId caId); /** - * Lists all CA records known to the store. + * Lists all stored CA records. * - *

    - * No filtering is applied at this level; higher layers are expected to perform - * query-based filtering. - *

    - * - * @return list of CA records (never null) - * @throws RuntimeException if listing fails + * @return list of CA records (never {@code null}) + * @throws IllegalStateException if listing fails */ List listCas(); /** - * Persists an issued credential. + * Persists a credential record. * *

    - * Credentials are immutable once stored. Re-inserting an existing credential - * identifier should either be idempotent or rejected, depending on - * implementation policy. + * Credentials are typically immutable once issued. Implementations must ensure + * that storing a credential is atomic and that the record remains retrievable + * for its entire retention period. *

    * - * @param credential credential to persist - * @throws IllegalArgumentException if {@code credential} is null - * @throws RuntimeException if persistence fails + * @param credential credential record (never {@code null}) + * @throws NullPointerException if {@code credential} is {@code null} + * @throws IllegalStateException if persistence fails */ void putCredential(Credential credential); /** - * Retrieves an issued credential by its identifier. + * Retrieves a credential record. * - * @param credentialId credential identifier - * @return credential if present - * @throws IllegalArgumentException if {@code credentialId} is null - * @throws RuntimeException if retrieval fails + * @param credentialId credential identifier (never {@code null}) + * @return credential record if present + * @throws NullPointerException if {@code credentialId} is {@code null} + * @throws IllegalStateException if retrieval fails */ Optional getCredential(PkiId credentialId); @@ -143,162 +134,233 @@ public interface PkiStore { * Persists a parsed certification request. * *

    - * Stored requests are used for audit, correlation, re-issuance, and ACME-like - * workflows. + * Requests are typically immutable artifacts. Implementations should treat them + * as write-once records unless there is a clear need to support updates. *

    * - * @param request parsed certification request - * @throws IllegalArgumentException if {@code request} is null - * @throws RuntimeException if persistence fails + * @param request parsed request (never {@code null}) + * @throws NullPointerException if {@code request} is {@code null} + * @throws IllegalStateException if persistence fails */ 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 - * @throws IllegalArgumentException if {@code requestId} is null - * @throws RuntimeException if retrieval fails + * @throws NullPointerException if {@code requestId} is {@code null} + * @throws IllegalStateException if retrieval fails */ Optional getRequest(PkiId requestId); /** - * Persists a revocation record. + * Persists or updates a revocation record. * - *

    - * Revocation records are authoritative inputs for generating revocation status - * objects (CRLs, OCSP, etc.). - *

    - * - * @param record revocation record - * @throws IllegalArgumentException if {@code record} is null - * @throws RuntimeException if persistence fails + * @param record revocation record (never {@code null}) + * @throws NullPointerException if {@code record} is {@code null} + * @throws IllegalStateException if persistence fails */ 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 - * @throws IllegalArgumentException if {@code credentialId} is null - * @throws RuntimeException if retrieval fails + * @throws NullPointerException if {@code credentialId} is {@code null} + * @throws IllegalStateException if retrieval fails */ Optional getRevocation(PkiId credentialId); /** * Lists all revocation records. * - * @return list of revocation records (never null) - * @throws RuntimeException if listing fails + * @return list of revocation records (never {@code null}) + * @throws IllegalStateException if listing fails */ List listRevocations(); /** - * Persists a generated status object. + * Persists a status object. * *

    - * Status objects include CRLs, delta CRLs, OCSP responses, or - * framework-specific revocation lists. + * Status objects are typically published artifacts (for example OCSP responses, + * CRLs, or other status representations) and are usually immutable once + * created. *

    * - * @param object status object to persist - * @throws IllegalArgumentException if {@code object} is null - * @throws RuntimeException if persistence fails + * @param object status object (never {@code null}) + * @throws NullPointerException if {@code object} is {@code null} + * @throws IllegalStateException if persistence fails */ 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 - * @throws IllegalArgumentException if {@code statusObjectId} is null - * @throws RuntimeException if retrieval fails + * @throws NullPointerException if {@code statusObjectId} is {@code null} + * @throws IllegalStateException if retrieval fails */ Optional 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 - * @return list of status objects (never null) - * @throws IllegalArgumentException if {@code issuerCaId} is null - * @throws RuntimeException if listing fails + *

    + * This method exists to support issuer-scoped publication and retrieval + * patterns (for example: listing all CRLs or other status artifacts produced by + * a given CA). + *

    + * + * @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 listStatusObjects(PkiId issuerCaId); /** - * Persists a publication record. + * Persists or updates a publication record. * *

    - * Publication records provide traceability and operational diagnostics for - * artifact distribution. + * Publication records describe distribution state (for example: where and when + * an object was published). These records may be used for operational + * monitoring and reconciliation. *

    * - * @param record publication record - * @throws IllegalArgumentException if {@code record} is null - * @throws RuntimeException if persistence fails + * @param record publication record (never {@code null}) + * @throws NullPointerException if {@code record} is {@code null} + * @throws IllegalStateException if persistence fails */ void putPublicationRecord(PublicationRecord record); /** * Lists all publication records. * - * @return list of publication records (never null) - * @throws RuntimeException if listing fails + * @return list of publication records (never {@code null}) + * @throws IllegalStateException if listing fails */ List listPublicationRecords(); /** * Persists or updates a certificate profile. * - * @param profile certificate profile - * @throws IllegalArgumentException if {@code profile} is null - * @throws RuntimeException if persistence fails + *

    + * A certificate profile represents a reusable issuance template (for example: + * VPN client, VPN server, S/MIME). The profile may be referenced by higher + * layers during certificate issuance. + *

    + * + * @param profile certificate profile (never {@code null}) + * @throws NullPointerException if {@code profile} is {@code null} + * @throws IllegalStateException if persistence fails */ void putProfile(CertificateProfile profile); /** - * Retrieves a certificate profile by identifier. + * Retrieves a certificate profile by profile identifier. * - * @param profileId profile identifier + *

    + * 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"}). + *

    + * + * @param profileId profile identifier (never {@code null}) * @return profile if present - * @throws IllegalArgumentException if {@code profileId} is null or blank - * @throws RuntimeException if retrieval fails + * @throws NullPointerException if {@code profileId} is {@code null} + * @throws IllegalStateException if retrieval fails */ Optional getProfile(String profileId); /** * Lists all stored certificate profiles. * - * @return list of profiles (never null) - * @throws RuntimeException if listing fails + * @return list of profiles (never {@code null}) + * @throws IllegalStateException if listing fails */ List listProfiles(); /** - * Persists a policy evaluation trace. + * Persists a policy trace. * *

    - * Policy traces are used for explainability, audit, and compliance evidence. - * They must never contain sensitive data. + * Policy traces capture decision-making and evaluation information. These + * records may contain sensitive meta-information and must be protected by the + * underlying store. *

    * - * @param trace policy trace - * @throws IllegalArgumentException if {@code trace} is null - * @throws RuntimeException if persistence fails + * @param trace policy trace (never {@code null}) + * @throws NullPointerException if {@code trace} is {@code null} + * @throws IllegalStateException if persistence fails */ 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 - * @throws IllegalArgumentException if {@code decisionId} is null - * @throws RuntimeException if retrieval fails + * @throws NullPointerException if {@code decisionId} is {@code null} + * @throws IllegalStateException if retrieval fails */ Optional getPolicyTrace(PkiId decisionId); + + // ------------------------------------------------------------------------- + // Workflow continuation state (orchestration) + // ------------------------------------------------------------------------- + + /** + * Persists or updates an orchestrator workflow continuation record. + * + *

    + * The workflow payload may be encrypted. Implementations must treat it as + * sensitive and must not log it. + *

    + * + * @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 getWorkflowState(PkiId opId); + + /** + * Deletes workflow continuation state for a given operation. + * + *

    + * Implementations may retain historical snapshots according to their history + * policy. Deletion guarantees only removal of the current record. + *

    + * + * @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. + * + *

    + * This method is intended for administrative reconciliation and recovery. + * Higher layers should apply access control and filtering as appropriate. + *

    + * + * @return list of workflow state records (never {@code null}) + * @throws IllegalStateException if listing fails + */ + List listWorkflowStates(); } diff --git a/pki/src/main/java/zeroecho/pki/util/PkiIds.java b/pki/src/main/java/zeroecho/pki/util/PkiIds.java new file mode 100644 index 0000000..f0fd4e6 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/PkiIds.java @@ -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. + * + *

    + * 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. + *

    + * + *

    Security

    + *

    + * 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. + *

    + */ +public final class PkiIds { + + private PkiIds() { + throw new AssertionError("No instances."); + } + + /** + * Generates a new random PKI identifier. + * + *

    + * The returned identifier is based on {@link UUID#randomUUID()}. + *

    + * + * @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"); + } +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncBus.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncBus.java new file mode 100644 index 0000000..7461f51 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncBus.java @@ -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. + * + *

    + * The bus is responsible for: + *

    + *
      + *
    • tracking operation metadata and status transitions,
    • + *
    • optional durability (implementation-defined),
    • + *
    • dispatching events to registered handlers,
    • + *
    • expiring operations that exceeded their deadline,
    • + *
    • forgetting completed results once retrieved (to avoid unbounded + * growth).
    • + *
    + * + *

    Result retention

    + *

    + * 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. + *

    + * + * @param operation id type + * @param owner type + * @param endpoint id type + * @param result type + */ +public interface AsyncBus { + + /** + * Registers an endpoint instance under an identifier. + * + *

    + * Registration must be performed by the endpoint itself during construction or + * activation, not by bootstrap code, to avoid tight coupling. + *

    + * + * @param endpointId endpoint id (never {@code null}) + * @param endpoint endpoint instance (never {@code null}) + */ + void registerEndpoint(EndpointId endpointId, AsyncEndpoint endpoint); + + /** + * Subscribes a handler to status change events. + * + * @param handler handler (never {@code null}) + * @return registration handle (never {@code null}) + */ + AsyncRegistration subscribe(AsyncHandler 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 submit(OpId opId, String type, Owner owner, EndpointId endpointId, + Instant createdAt, Duration ttl); + + /** + * Updates status and optional result for an operation. + * + *

    + * Implementations must persist the transition before dispatching events. + *

    + * + * @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); + + /** + * Retrieves the latest known status for an operation. + * + * @param opId operation id (never {@code null}) + * @return status if known + */ + Optional status(OpId opId); + + /** + * Returns immutable operation metadata if present. + * + * @param opId operation id (never {@code null}) + * @return snapshot if known + */ + Optional> 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 consumeResult(OpId opId); + + /** + * Performs periodic maintenance: + * + *
      + *
    • expires operations past their deadline,
    • + *
    • polls endpoints for open operations (restart recovery),
    • + *
    • dispatches any discovered transitions.
    • + *
    + * + * @param now current time (never {@code null}) + */ + void sweep(Instant now); +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncEndpoint.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncEndpoint.java new file mode 100644 index 0000000..c62ddc8 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncEndpoint.java @@ -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. + * + *

    + * The bus uses endpoints to: + *

    + *
      + *
    • poll open operations on {@link AsyncBus#sweep(Instant)} (restart + * recovery),
    • + *
    • deliver status/result updates via + * {@link AsyncBus#update(Object, AsyncStatus, Optional)}.
    • + *
    + * + * @param operation id type + * @param result type + */ +public interface AsyncEndpoint { + + /** + * Returns a best-effort current status for a known operation. + * + *

    + * Endpoints should return a terminal status if the operation is complete. If + * unknown, endpoints may return an empty optional. + *

    + * + * @param opId operation id (never {@code null}) + * @return status if known + */ + Optional status(OpId opId); + + /** + * Returns a best-effort result for a successful operation. + * + *

    + * 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. + *

    + * + * @param opId operation id (never {@code null}) + * @return result if available + */ + Optional result(OpId opId); +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncEvent.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncEvent.java new file mode 100644 index 0000000..09b40ff --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncEvent.java @@ -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 operation identifier type + * @param operation owner type + * @param endpoint identifier type + * @param 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(AsyncOperationSnapshot snapshot, + AsyncStatus status, Optional 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"); + } +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncHandler.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncHandler.java new file mode 100644 index 0000000..9e718b7 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncHandler.java @@ -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. + * + *

    + * Implementations must be exception-safe: the bus will treat any handler + * exception as a handler failure and will continue dispatching to other + * handlers. + *

    + * + * @param operation id type + * @param owner type + * @param endpoint id type + * @param result type + */ +@FunctionalInterface +public interface AsyncHandler { + + /** + * Receives an event notification. + * + * @param event event (never {@code null}) + */ + void onEvent(AsyncEvent event); +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncOperationSnapshot.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncOperationSnapshot.java new file mode 100644 index 0000000..246e8eb --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncOperationSnapshot.java @@ -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. + * + *

    + * This object intentionally does not contain secrets. Payloads should be stored + * outside the bus (or referenced indirectly). + *

    + * + * @param operation identifier type + * @param operation owner type + * @param 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 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"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/internal/package-info.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncRegistration.java similarity index 82% rename from pki/src/main/java/zeroecho/pki/internal/package-info.java rename to pki/src/main/java/zeroecho/pki/util/async/AsyncRegistration.java index 04d2fe1..fe04867 100644 --- a/pki/src/main/java/zeroecho/pki/internal/package-info.java +++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncRegistration.java @@ -32,7 +32,24 @@ * 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.io.Closeable; + /** - * + * Registration handle for a handler subscription. + * + *

    + * Closing the handle unregisters the handler. Implementations must be + * idempotent. + *

    */ -package zeroecho.pki.internal; \ No newline at end of file +@SuppressWarnings("PMD.ImplicitFunctionalInterface") +public interface AsyncRegistration extends Closeable { + + /** + * Unregisters the handler. + */ + @Override + void close(); +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncState.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncState.java new file mode 100644 index 0000000..5589b43 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncState.java @@ -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. + * + *

    + * The state machine is intentionally simple and audit-friendly. Implementations + * may attach additional non-sensitive detail through + * {@link AsyncStatus#detailCode()} and {@link AsyncStatus#details()}. + *

    + */ +public enum AsyncState { + + /** + * The operation has been created and is awaiting processing. + * + *

    + * Example: a certificate issuance request has been accepted, but is waiting for + * an operator approval step. + *

    + */ + SUBMITTED, + + /** + * The operation is currently being processed. + * + *

    + * Example: an issuance request is being built, proof-of-possession verified, or + * a signing workflow is executing. + *

    + */ + RUNNING, + + /** + * The operation completed successfully and the result is available for + * retrieval. + * + *

    + * Example: a certificate (or bundle) has been issued and is ready to be + * fetched. + *

    + */ + SUCCEEDED, + + /** + * The operation completed unsuccessfully (terminal failure). + * + *

    + * 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()}. + *

    + */ + FAILED, + + /** + * The operation was cancelled by a caller or operator. + * + *

    + * Example: an administrator cancelled a long-running operation before it was + * approved. + *

    + */ + CANCELLED, + + /** + * The operation exceeded its declared deadline and was expired by the bus. + * + *

    + * Example: a request had a 24h approval window; after that it is no longer + * eligible for completion and is treated as expired. + *

    + */ + EXPIRED +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/AsyncStatus.java b/pki/src/main/java/zeroecho/pki/util/async/AsyncStatus.java new file mode 100644 index 0000000..a981129 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/AsyncStatus.java @@ -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. + * + *

    + * This object is safe to expose via APIs and logs (assuming the producer + * respects the no-secrets constraint for detail fields). + *

    + * + * @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 detailCode, + Map 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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/codec/IdCodec.java b/pki/src/main/java/zeroecho/pki/util/async/codec/IdCodec.java new file mode 100644 index 0000000..6d2674b --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/codec/IdCodec.java @@ -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. + * + *

    + * The encoded form MUST NOT embed secrets. It is intended for identifiers only. + *

    + * + * @param identifier type + */ +public interface IdCodec { + + /** + * 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); +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/codec/ResultCodec.java b/pki/src/main/java/zeroecho/pki/util/async/codec/ResultCodec.java new file mode 100644 index 0000000..196f2c8 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/codec/ResultCodec.java @@ -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. + * + *

    + * 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. + *

    + * + * @param result type + */ +public interface ResultCodec { + + /** + * 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 result type + * @return codec that rejects encode/decode and signals non-persistence + */ + static ResultCodec 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 decodeOptional(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + return Optional.of(decode(token)); + } +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/codec/package-info.java b/pki/src/main/java/zeroecho/pki/util/async/codec/package-info.java new file mode 100644 index 0000000..a388df9 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/codec/package-info.java @@ -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. + * + *

    + * 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()}. + *

    + * + *

    Security

    + *

    + * 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. + *

    + */ +package zeroecho.pki.util.async.codec; diff --git a/pki/src/main/java/zeroecho/pki/util/async/impl/AppendOnlyLineStore.java b/pki/src/main/java/zeroecho/pki/util/async/impl/AppendOnlyLineStore.java new file mode 100644 index 0000000..513bfd1 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/impl/AppendOnlyLineStore.java @@ -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. + * + *

    + * This is a minimal durability helper for the async bus. It does not interpret + * content; it only appends and replays lines. + *

    + */ +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 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 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 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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java b/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java new file mode 100644 index 0000000..5d28c04 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/impl/DurableAsyncBus.java @@ -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. + * + *

    + * Log format is internal and versioned by a leading token. Corrupted lines are + * ignored with a warning (without logging line content). + *

    + * + *

    No-secret logging

    + *

    + * This implementation logs only operation ids (encoded) and types, never + * payloads or serialized results. + *

    + * + * @param operation id type + * @param owner type + * @param endpoint id type + * @param result type + */ +public final class DurableAsyncBus // NOPMD + implements AsyncBus { + + 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 opIdCodec; + private final IdCodec ownerCodec; + private final IdCodec endpointCodec; + private final ResultCodec resultCodec; + private final AppendOnlyLineStore store; + + private final Map> active; + private final Map lastStatus; + private final Map results; // removed on consume + private final Map> endpointsById; + private final List> 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 opIdCodec, IdCodec ownerCodec, IdCodec endpointCodec, + ResultCodec 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 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 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 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 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) { + Objects.requireNonNull(opId, "opId"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(result, "result"); + + AsyncOperationSnapshot 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 status(OpId opId) { + Objects.requireNonNull(opId, "opId"); + return Optional.ofNullable(lastStatus.get(opId)); + } + + @Override + public Optional> snapshot(OpId opId) { + Objects.requireNonNull(opId, "opId"); + return Optional.ofNullable(active.get(opId)); + } + + @Override + public Optional 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> open = List.copyOf(active.values()); + for (AsyncOperationSnapshot snap : open) { + OpId opId = snap.opId(); + AsyncStatus st = lastStatus.get(opId); + if (st != null && st.isTerminal()) { + continue; + } + + AsyncEndpoint endpoint = endpointsById.get(snap.endpointId()); + if (endpoint == null) { + continue; + } + + Optional 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 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>> entries = List.copyOf(active.entrySet()); + for (Map.Entry> e : entries) { + OpId opId = e.getKey(); + AsyncOperationSnapshot 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 snap, AsyncStatus status, + Optional result) { + + AsyncEvent event = new AsyncEvent<>(snap, status, result); + + List> copy = List.copyOf(handlers); + for (AsyncHandler 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 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 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 dc = parts[4].isBlank() ? Optional.empty() : Optional.of(parts[4]); + Map 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 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 details) { + if (details == null || details.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(64); + boolean first = true; + for (Map.Entry 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 decodeDetails(String token) { + if (token == null || token.isBlank()) { + return Map.of(); + } + Map 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 ""; + } + } + + 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 ""; + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/util/async/impl/package-info.java b/pki/src/main/java/zeroecho/pki/util/async/impl/package-info.java new file mode 100644 index 0000000..0984886 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/impl/package-info.java @@ -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. + * + *

    + * This package contains durability and recovery helpers (append-only line + * store) and a generic durable bus implementation. + *

    + * + *

    Operational model

    + *
      + *
    • State is held in memory for speed.
    • + *
    • Transitions are appended to an on-disk log for restart recovery.
    • + *
    • Restart recovery replays the log and then relies on {@code sweep(...)} to + * synchronize open operations with their endpoints.
    • + *
    + * + *

    Security

    + *

    + * Implementations must never log secrets, payloads, private keys, or internal + * cryptographic primitive state. Persisted content must contain only + * identifiers and non-sensitive status metadata. + *

    + */ +package zeroecho.pki.util.async.impl; diff --git a/pki/src/main/java/zeroecho/pki/util/async/package-info.java b/pki/src/main/java/zeroecho/pki/util/async/package-info.java new file mode 100644 index 0000000..e4f7740 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/async/package-info.java @@ -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. + * + *

    + * 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: + *

    + * + *
      + *
    • operation identifier type (e.g., {@code PkiId}),
    • + *
    • operation owner type (e.g., {@code Principal}),
    • + *
    • endpoint identifier type (e.g., {@code String}),
    • + *
    • result type (application-specific, may be empty).
    • + *
    + * + *

    Durability model

    + *

    + * 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. + *

    + * + *

    Security

    + *
      + *
    • No secrets must be logged.
    • + *
    • Persistence encodings should avoid plaintext secrets. If sensitive result + * data exists, store it encrypted outside of this package or provide a redacted + * {@code ResultCodec}.
    • + *
    + */ +package zeroecho.pki.util.async; diff --git a/pki/src/main/java/zeroecho/pki/util/package-info.java b/pki/src/main/java/zeroecho/pki/util/package-info.java new file mode 100644 index 0000000..22d4dd3 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/util/package-info.java @@ -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. + * + *

    + * 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: + *

    + *
      + *
    • deterministic and side-effect free,
    • + *
    • safe to use across different runtime environments,
    • + *
    • independent of persistence, networking, or cryptographic providers.
    • + *
    + * + *

    Package structure

    + *

    + * The package is intentionally shallow at its top level and consists of: + *

    + *
      + *
    • 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.
    • + *
    • 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.
    • + *
    + * + *

    Design principles

    + *

    + * Utilities in this package follow strict design rules: + *

    + *
      + *
    • no global mutable state,
    • + *
    • no hidden caching with externally visible effects,
    • + *
    • no dependency on environment-specific configuration,
    • + *
    • no leakage of sensitive material (keys, secrets, plaintext).
    • + *
    + * + *

    + * Where randomness or uniqueness is required (e.g. identifier generation), the + * semantics are explicitly documented and predictable within the guarantees of + * the underlying platform. + *

    + * + *

    Relation to other layers

    + *

    + * This package is deliberately orthogonal to: + *

    + *
      + *
    • API domain packages (such as {@code zeroecho.pki.api.*}),
    • + *
    • SPI and implementation layers,
    • + *
    • application bootstrap and orchestration code.
    • + *
    + * + *

    + * As such, utilities defined here may be freely reused by API, SPI, and + * implementation code without creating circular dependencies or architectural + * coupling. + *

    + * + * @since 1.0 + */ +package zeroecho.pki.util; diff --git a/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.async.AsyncBusProvider b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.async.AsyncBusProvider new file mode 100644 index 0000000..8a1caa8 --- /dev/null +++ b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.async.AsyncBusProvider @@ -0,0 +1 @@ +zeroecho.pki.impl.async.FileBackedAsyncBusProvider diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java index 5bf7d55..98a7309 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -40,25 +40,28 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; 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.Path; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; import java.util.Comparator; import java.util.List; -import java.util.Objects; import java.util.Optional; 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.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.SubjectRef; +import zeroecho.pki.api.Validity; import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeValue; @@ -72,19 +75,22 @@ import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevokedRecord; /** - * Black-box tests for {@link FilesystemPkiStore}. + * Tests for {@link FilesystemPkiStore}. * *

    - * Tests focus on filesystem semantics (write-once, history, snapshot export) - * and avoid dependencies on optional domain factories. Where the API uses - * interfaces (notably {@link AttributeSet}), tests provide a minimal - * deterministic stub. + * The tests are deterministic and do not use reflection. Snapshot export + * semantics are tested using {@link FsPkiStoreOptions#strictSnapshotExport()} + * configured appropriately. *

    * *

    - * Every test routine prints its own name and prints {@code ...ok} on success. - * Important intermediate values are printed with {@code "..."} prefix. + * Output conventions: *

    + *
      + *
    • Each test prints its own name at the start.
    • + *
    • Each test prints {@code ...ok} on success.
    • + *
    • Important intermediate state is printed with {@code ...} prefix.
    • + *
    */ public final class FilesystemPkiStoreTest { @@ -95,20 +101,20 @@ public final class FilesystemPkiStoreTest { void writeOnceCredentialRejected() throws Exception { System.out.println("writeOnceCredentialRejected"); - Path root = tmp.resolve("store-write-once"); + Path root = tmp.resolve("store-writeonce"); FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); 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); - - IllegalStateException ex = assertThrows(IllegalStateException.class, () -> store.putCredential(credential)); + RuntimeException ex = assertThrows(RuntimeException.class, () -> store.putCredential(c1)); 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"); } @@ -119,35 +125,22 @@ public final class FilesystemPkiStoreTest { Path root = tmp.resolve("store-ca-history"); FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); - PkiId caId; - try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - CaRecord ca1 = TestObjects.minimalCaRecord(); - caId = ca1.caId(); - + CaRecord ca1 = TestObjects.minimalCaRecord("ca-1", CaState.ACTIVE); store.putCa(ca1); - Path caHist = caHistoryDir(root, caId); - waitForHistoryCount(caHist, 1, Duration.ofMillis(500)); - - CaRecord ca2 = TestObjects.caVariant(ca1, CaState.DISABLED); + CaRecord ca2 = new CaRecord(ca1.caId(), ca1.kind(), CaState.DISABLED, ca1.issuerKeyRef(), ca1.subjectRef(), + ca1.caCredentials()); store.putCa(ca2); - waitForHistoryCount(caHist, 2, Duration.ofMillis(500)); - - 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 loaded = store.getCa(caId); + Optional loaded = store.getCa(ca1.caId()); 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"); } @@ -155,496 +148,367 @@ public final class FilesystemPkiStoreTest { void revocationHistorySupportsOverwriteWithTrail() throws Exception { System.out.println("revocationHistorySupportsOverwriteWithTrail"); - Path root = tmp.resolve("store-revocations"); + Path root = tmp.resolve("store-revocation-history"); FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); - PkiId credentialId; - try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - RevokedRecord r1 = TestObjects.minimalRevocation(); - credentialId = r1.credentialId(); - + RevokedRecord r1 = TestObjects.minimalRevocation("cred-rev-1", Instant.EPOCH.plusSeconds(10L), + RevocationReason.KEY_COMPROMISE); 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(), r1.attributes()); - store.putRevocation(r2); - waitForHistoryCount(hist, 2, Duration.ofMillis(500)); - Path dir = root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId)); - assertTrue(Files.exists(dir.resolve("current.bin"))); - assertTrue(Files.isDirectory(dir.resolve("history"))); - - long historyCount = Files.list(dir.resolve("history")).count(); - System.out.println("...historyCount=" + historyCount); - assertTrue(historyCount >= 2L); + Optional loaded = store.getRevocation(r1.credentialId()); + assertTrue(loaded.isPresent()); + assertEquals(r2.revocationTime(), loaded.get().revocationTime()); } - printTree("store layout", root); + System.out.println("...store tree:"); + dumpTree(root); + System.out.println("revocationHistorySupportsOverwriteWithTrail...ok"); } @Test - void snapshotExportSelectsCorrectHistoryVersions() throws Exception { - System.out.println("snapshotExportSelectsCorrectHistoryVersions"); + void snapshotExportClonesNewRootNonStrict() throws Exception { + System.out.println("snapshotExportClonesNewRootNonStrict"); - Path root = tmp.resolve("store-snapshot"); - FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); + Path root = tmp.resolve("store-snapshot-basic"); + 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)) { - CaRecord caV1 = TestObjects.minimalCaRecord(); - caId = caV1.caId(); + CaRecord ca1 = TestObjects.minimalCaRecord("ca-snap-1", CaState.ACTIVE); + store.putCa(ca1); - store.putCa(caV1); - Path caHist = caHistoryDir(root, caId); - waitForHistoryCount(caHist, 1, Duration.ofMillis(500)); + Instant at = Instant.now(); + store.putProfile(TestObjects.minimalProfile("profile-snap-1", true)); - store.putCa(TestObjects.caVariant(caV1, CaState.DISABLED)); - 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)); + store.exportSnapshot(snapshot, at); } - printTree("store layout", root); + System.out.println("...store tree:"); + dumpTree(root); - // Read authoritative timestamps from history filenames (exactly as exporter - // expects). - Path caHist = caHistoryDir(root, caId); - List tsMicros = listHistoryMicrosSorted(caHist); - System.out.println("...ca history micros count=" + tsMicros.size()); - System.out.println("...ca history micros=" + tsMicros); + System.out.println("...snapshot tree:"); + dumpTree(snapshot); - 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: - // beforeFirst -> must fail (strict) - // atFirst -> selects v1 (ACTIVE) - // atThird -> selects v3 (RETIRED) - // afterFourth -> selects v4 (COMPROMISED) - Instant beforeFirst = microsToInstant(tsMicros.get(0) - 1L); - Instant atFirst = microsToInstant(tsMicros.get(0)); - Instant atThird = microsToInstant(tsMicros.get(2)); - Instant afterFourth = microsToInstant(tsMicros.get(3) + 1L); + System.out.println("snapshotExportClonesNewRootNonStrict...ok"); + } - // Case 1: expected failure. - Path snapFail = tmp.resolve("snapshot-fail"); - IllegalStateException ex = assertThrows(IllegalStateException.class, () -> { - try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - store.exportSnapshot(snapFail, beforeFirst); - } - }); - assertNotNull(ex); - System.out.println("...expected failure ok: " + safeMsg(ex.getMessage())); + @Test + void snapshotExportWithMultipleObjectsNonStrictDoesNotFail() throws Exception { + System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail"); + + Path root = tmp.resolve("store-snapshot-multi"); + 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 2: at first -> v1 (ACTIVE). - Path snap1 = tmp.resolve("snapshot-v1"); try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - store.exportSnapshot(snap1, atFirst); - } - printTree("snapshot v1 layout", snap1); - CaRecord snapCa1 = readSnapshotCa(snap1, caId); - System.out.println("...snapshot v1 state=" + snapCa1.state()); - assertEquals(CaState.ACTIVE, snapCa1.state()); + store.putCa(caA); + at1 = Instant.now(); - // Case 3: at third -> v3 (RETIRED). - Path snap3 = tmp.resolve("snapshot-v3"); + sleepMillis(120L); + + 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 cas = s2.listCas(); + List profiles = s2.listProfiles(); + + Set caIds = cas.stream().map(r -> r.caId().toString()).collect(Collectors.toSet()); + Set 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)) { - store.exportSnapshot(snap3, atThird); - } - printTree("snapshot v3 layout", snap3); - CaRecord snapCa3 = readSnapshotCa(snap3, caId); - System.out.println("...snapshot v3 state=" + snapCa3.state()); - assertEquals(CaState.RETIRED, snapCa3.state()); + // Create CA first. + store.putCa(TestObjects.minimalCaRecord("ca-strict-1", CaState.ACTIVE)); + + // "at" is before the profile exists. + Instant at = Instant.now(); + 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 4: after fourth -> v4 (COMPROMISED). - Path snap4 = tmp.resolve("snapshot-v4"); try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - store.exportSnapshot(snap4, afterFourth); + store.putCa(TestObjects.minimalCaRecord("ca-strict-ok", CaState.ACTIVE)); + store.putProfile(TestObjects.minimalProfile("profile-strict-ok", true)); + + // "at" after all writes: strict export should succeed. + Instant at = Instant.now(); + store.exportSnapshot(snapshot, at); } - 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:"); + dumpTree(root); - // ----------------------- - // Helper methods - // ----------------------- + System.out.println("...snapshot tree:"); + dumpTree(snapshot); - private static Path caHistoryDir(final Path storeRoot, final PkiId caId) { - return storeRoot.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId)).resolve("history"); - } + try (FilesystemPkiStore snap = new FilesystemPkiStore(snapshot, options)) { + Set caIds = snap.listCas().stream().map(r -> r.caId().toString()).collect(Collectors.toSet()); + Set profileIds = snap.listProfiles().stream().map(CertificateProfile::profileId) + .collect(Collectors.toSet()); - private static Path revocationHistoryDir(final Path storeRoot, final PkiId credentialId) { - return storeRoot.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId)) - .resolve("history"); - } + System.out.println("...snapshot caIds: " + caIds); + System.out.println("...snapshot profileIds: " + profileIds); - private static void waitForHistoryCount(final Path historyDir, final int expected, final Duration timeout) - throws Exception { - - long deadline = System.nanoTime() + timeout.toNanos(); - while (System.nanoTime() < deadline) { - if (Files.isDirectory(historyDir)) { - long count = Files.list(historyDir).filter(Files::isRegularFile).count(); - if (count >= expected) { - System.out.println("...historyDir=" + historyDir + " count=" + count); - return; - } - } - Thread.sleep(5L); + assertTrue(caIds.contains("ca-strict-ok")); + assertTrue(profileIds.contains("profile-strict-ok")); } - throw new IllegalStateException( - "history did not reach expected count " + expected + " in " + timeout + " at " + historyDir); + + System.out.println("snapshotExportStrictSucceedsWhenAtIsAfterAllWrites...ok"); } - private static List listHistoryMicrosSorted(final Path historyDir) throws IOException { - List out = new ArrayList<>(); - try (DirectoryStream ds = Files.newDirectoryStream(historyDir, "*.bin")) { - for (Path p : ds) { - String name = p.getFileName().toString(); - int dash = name.indexOf('-'); - if (dash <= 0) { - 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 FsPkiStoreOptions nonStrictSnapshotOptions() { + FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults(); + return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(), + defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), false); } - 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 FsPkiStoreOptions strictSnapshotOptions() { + FsPkiStoreOptions defaults = FsPkiStoreOptions.defaults(); + return new FsPkiStoreOptions(defaults.caHistoryPolicy(), defaults.profileHistoryPolicy(), + defaults.revocationHistoryPolicy(), defaults.workflowHistoryPolicy(), true); } - 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 sleepMillis(long ms) throws InterruptedException { + Thread.sleep(ms); } - private static void printTree(final String label, final Path root) throws IOException { - System.out.println("..." + label + ": " + root); + private static void dumpTree(Path root) throws IOException { if (!Files.exists(root)) { - System.out.println("... "); + System.out.println("... " + root); 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)) { - System.out.println("... [D] " + rel); - } else if (Files.isRegularFile(p)) { - long size = Files.size(p); - System.out.println("... [F] " + rel + " (" + size + " B)"); - } else { - System.out.println("... [?] " + rel); - } - } catch (IOException e) { - System.out.println("... [!] " + p + " (io error)"); + List paths = Files.walk(root).sorted(Comparator.naturalOrder()).collect(Collectors.toList()); + for (Path p : paths) { + Path rel = root.relativize(p); + if (rel.toString().isEmpty()) { + continue; } - }); + if (Files.isDirectory(p)) { + System.out.println("..." + rel + "/"); + } else { + long size = Files.size(p); + System.out.println("..." + rel + " (" + size + " bytes)"); + } + } } - private static String safeMsg(final String s) { + private static String shorten(String s, int max) { if (s == null) { - return ""; + return "null"; } - if (s.length() <= 80) { + if (s.length() <= max) { return s; } - return s.substring(0, 80) + "..."; - } - - // ----------------------- - // Test object factories - // ----------------------- - - static final class TestObjects { - - private static long idCounter = 0L; - - private TestObjects() { - // utility class - } - - static CaRecord minimalCaRecord() throws Exception { - 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 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 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 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"); - if (vs != null) { - return vs; - } - - throw new IllegalStateException("cannot construct value: " + type.getName()); - } - - static Object defaultValue(final Class t) throws Exception { - if (t == String.class) { - 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(); - } - if (List.class.isAssignableFrom(t)) { - 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; - } + return s.substring(0, Math.max(0, max - 3)) + "..."; } /** - * Minimal deterministic empty {@link AttributeSet} for tests. + * Test-only codec-friendly {@link AttributeSet} implementation (no proxies). * - *

    - * The production API defines {@link AttributeSet} as a passive interface - * without factories. Tests only require an immutable empty set. - *

    + * @param entries serialized entries */ - static final class EmptyAttributeSet implements AttributeSet { + public record TestAttributeSet(List entries) implements AttributeSet { - @Override - public Set ids() { - return Set.of(); + public record Entry(AttributeId id, List values) { + + public Entry { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + if (values == null) { + throw new IllegalArgumentException("values must not be null"); + } + } } @Override - public Optional get(final AttributeId id) { - Objects.requireNonNull(id, "id"); + public Set ids() { + return entries.stream().map(Entry::id).collect(Collectors.toUnmodifiableSet()); + } + + @Override + public Optional get(AttributeId id) { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + for (Entry e : entries) { + if (e.id().equals(id)) { + if (e.values().isEmpty()) { + return Optional.empty(); + } + return Optional.of(e.values().get(0)); + } + } return Optional.empty(); } @Override - public List getAll(final AttributeId id) { - Objects.requireNonNull(id, "id"); + public List 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(); } } + + /** + * Deterministic domain fixtures. + */ + static final class TestObjects { + + private static final AtomicLong SEQ = new AtomicLong(1L); + + 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 caCredentials = List.of(cred); + + return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, caCredentials); + } + + static CertificateProfile minimalProfile(String profileId, boolean active) { + FormatId formatId = new FormatId("fmt-x509"); + String displayName = "Profile " + profileId; + + List required = List.of(new AttributeId("req-1")); + List optional = List.of(new AttributeId("opt-1")); + + Optional maxValidity = Optional.of(Duration.ofDays(365)); + + return new CertificateProfile(profileId, formatId, displayName, required, optional, maxValidity, active); + } + + static Credential minimalCredential(String serial, String profileId) { + PkiId credentialId = new PkiId("cred-" + nextSeq()); + FormatId formatId = new FormatId("fmt-x509"); + + 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())); + } + } } diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java new file mode 100644 index 0000000..c8cc5b9 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java @@ -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 list = List.of("a", "b"); + byte[] listData = FsCodec.encode(list); + Object decodedList = FsCodec.decode(listData, Object.class); + assertEquals(list, decodedList); + + Optional 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"); + } + +} diff --git a/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java b/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java new file mode 100644 index 0000000..257dda8 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/util/async/DurableAsyncBusTest.java @@ -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 bus = new DurableAsyncBus( + PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, new ResultCodec() { + @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() { + @Override + public Optional status(PkiId id) { + return Optional.empty(); + } + + @Override + public Optional 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 consumed = bus.consumeResult(opId); + System.out.println("...consumed=" + (consumed.isPresent() ? consumed.get() : "")); + 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 bus1 = new DurableAsyncBus( + 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 bus2 = new DurableAsyncBus( + PkiCodecs.PKI_ID, PkiCodecs.PRINCIPAL, PkiCodecs.STRING, ResultCodec.none(), store2); + + Optional> snap = bus2.snapshot(opId); + Optional 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"); + } +}