From cab1eeefe7b9d9cfb20c22c75a74fad7ebc6bf42 Mon Sep 17 00:00:00 2001
From: Leo Galambos
Date: Sun, 28 Dec 2025 01:15:46 +0100
Subject: [PATCH] feat: add filesystem-based PkiStore reference implementation
Introduce a deterministic filesystem-backed PkiStore implementation
under zeroecho.pki.impl.fs.
Key characteristics:
- write-once semantics for immutable objects with explicit failure on
overwrite
- history tracking for mutable records with full audit trail
- atomic writes using NIO (temp + move) with best-effort durability
- strict snapshot export supporting time-travel reconstruction
- configurable history retention (ON_WRITE policy)
- no secrets logged; JUL-only diagnostics for anomalies
Includes comprehensive JUnit 5 tests validating:
- write-once enforcement
- history creation and overwrite semantics
- strict snapshot export (failure and positive selection cases)
- deterministic on-disk layout and structure
This implementation is intentionally non-public and serves as a
reference and validation baseline for future persistence backends.
Signed-off-by: Leo Galambos
---
.../core/alg/slhdsa/SlhDsaKeyGenBuilder.java | 8 +-
.../core/alg/slhdsa/SlhDsaKeyGenSpec.java | 2 -
.../java/zeroecho/pki/PkiApplication.java | 4 +-
.../main/java/zeroecho/pki/PkiLogging.java | 56 +-
.../pki/impl/fs/FilesystemPkiStore.java | 480 +++++++++++++
.../java/zeroecho/pki/impl/fs/FsCodec.java | 373 ++++++++++
.../zeroecho/pki/impl/fs/FsHistoryPolicy.java | 133 ++++
.../zeroecho/pki/impl/fs/FsOperations.java | 319 +++++++++
.../java/zeroecho/pki/impl/fs/FsPaths.java | 134 ++++
.../pki/impl/fs/FsPkiStoreOptions.java | 76 ++
.../pki/impl/fs/FsSnapshotExporter.java | 193 ++++++
.../java/zeroecho/pki/impl/fs/FsUtil.java | 70 ++
.../zeroecho/pki/impl/fs/package-info.java | 96 +++
.../pki/impl/fs/FilesystemPkiStoreTest.java | 650 ++++++++++++++++++
14 files changed, 2556 insertions(+), 38 deletions(-)
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FsHistoryPolicy.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FsUtil.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/package-info.java
create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java
diff --git a/lib/src/main/java/zeroecho/core/alg/slhdsa/SlhDsaKeyGenBuilder.java b/lib/src/main/java/zeroecho/core/alg/slhdsa/SlhDsaKeyGenBuilder.java
index 8708642..12b2718 100644
--- a/lib/src/main/java/zeroecho/core/alg/slhdsa/SlhDsaKeyGenBuilder.java
+++ b/lib/src/main/java/zeroecho/core/alg/slhdsa/SlhDsaKeyGenBuilder.java
@@ -48,10 +48,9 @@ import zeroecho.core.spi.AsymmetricKeyBuilder;
*
*
* This builder maps {@link SlhDsaKeyGenSpec} to the appropriate
- * {@code org.bouncycastle.jcajce.spec.SLHDSAParameterSpec} constant.
- * :contentReference[oaicite:3]{index=3} Reflection is used to avoid a hard
- * dependency on any particular set of parameter constants across provider
- * versions.
+ * {@code org.bouncycastle.jcajce.spec.SLHDSAParameterSpec} constant. Reflection
+ * is used to avoid a hard dependency on any particular set of parameter
+ * constants across provider versions.
*
*
* @since 1.0
@@ -103,7 +102,6 @@ public final class SlhDsaKeyGenBuilder implements AsymmetricKeyBuilder
* Bouncy Castle exposes "with hash" variants as distinct parameter specs, for
* example {@code slh_dsa_sha2_128s_with_sha256}.
- * :contentReference[oaicite:1]{index=1}
*
*
*
@@ -183,7 +182,6 @@ public final class SlhDsaKeyGenSpec implements AlgorithmKeySpec {
*
* This bypasses automatic mapping. The name must match a static field in
* {@code org.bouncycastle.jcajce.spec.SLHDSAParameterSpec}.
- * :contentReference[oaicite:2]{index=2}
*
*
* @param name field name in {@code SLHDSAParameterSpec}
diff --git a/pki/src/main/java/zeroecho/pki/PkiApplication.java b/pki/src/main/java/zeroecho/pki/PkiApplication.java
index 46690d9..75b6f10 100644
--- a/pki/src/main/java/zeroecho/pki/PkiApplication.java
+++ b/pki/src/main/java/zeroecho/pki/PkiApplication.java
@@ -82,7 +82,7 @@ public final class PkiApplication {
LOG.info("ZeroEcho PKI starting.");
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> { // NOPMD
Logger shutdownLogger = Logger.getLogger(PkiApplication.class.getName());
PkiLogging.emitShutdownMessage(shutdownLogger, "ZeroEcho PKI stopping.");
}, "zeroecho-pki-shutdown"));
@@ -90,7 +90,7 @@ public final class PkiApplication {
try {
// Intentionally no business logic yet. Bootstrap only.
LOG.info("ZeroEcho PKI started (bootstrap only).");
- } catch (RuntimeException ex) {
+ } 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;
diff --git a/pki/src/main/java/zeroecho/pki/PkiLogging.java b/pki/src/main/java/zeroecho/pki/PkiLogging.java
index 0e8b58a..4362861 100644
--- a/pki/src/main/java/zeroecho/pki/PkiLogging.java
+++ b/pki/src/main/java/zeroecho/pki/PkiLogging.java
@@ -38,6 +38,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.lang.Thread.UncaughtExceptionHandler;
import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.LogManager;
import java.util.logging.Logger;
@@ -71,11 +72,14 @@ final class PkiLogging {
/**
* Optional classpath resource for JUL configuration.
*/
- static final String LOGGING_PROPERTIES_RESOURCE = "/zeroecho-pki-logging.properties";
+ private static final String LOGGING_PROPERTIES_RESOURCE = "/zeroecho-pki-logging.properties";
private static final Logger LOG = Logger.getLogger(PkiLogging.class.getName());
- private static volatile boolean configured;
+ /**
+ * One-shot guard ensuring JUL configuration is attempted at most once.
+ */
+ private static final AtomicBoolean CONFIGURED = new AtomicBoolean(false);
private PkiLogging() {
throw new AssertionError("No instances.");
@@ -88,30 +92,22 @@ final class PkiLogging {
* This method is idempotent and safe to call multiple times.
*
*/
- static void configureIfPresent() {
- if (configured) {
+ /* default */ static void configureIfPresent() {
+ // Fast-path: already configured
+ if (!CONFIGURED.compareAndSet(false, true)) {
return;
}
- synchronized (PkiLogging.class) {
- if (configured) {
+
+ // getResourceAsStream() may return null; try-with-resources handles null safely
+ try (InputStream is = PkiLogging.class.getResourceAsStream(LOGGING_PROPERTIES_RESOURCE)) {
+ if (is == null) {
return;
}
-
- InputStream in = PkiLogging.class.getResourceAsStream(LOGGING_PROPERTIES_RESOURCE);
- if (in == null) {
- configured = true;
- return;
- }
-
- try (InputStream is = in) {
- LogManager.getLogManager().readConfiguration(is);
- configured = true;
- LOG.info("JUL configured from classpath resource.");
- } catch (IOException ex) {
- configured = true;
- // Keep message generic; do not leak environment specifics.
- LOG.log(Level.WARNING, "Failed to load JUL configuration; continuing with defaults.", ex);
- }
+ LogManager.getLogManager().readConfiguration(is);
+ LOG.info("JUL configured from classpath resource.");
+ } catch (IOException ex) {
+ // Keep message generic; do not leak environment specifics.
+ LOG.log(Level.WARNING, "Failed to load JUL configuration; continuing with defaults.", ex);
}
}
@@ -125,15 +121,17 @@ final class PkiLogging {
* secrets.
*
*/
- static void installUncaughtExceptionHandler() {
- UncaughtExceptionHandler handler = (Thread thread, Throwable throwable) -> {
+ /* default */ static void installUncaughtExceptionHandler() {
+ UncaughtExceptionHandler handler = (Thread thread, Throwable throwable) -> { // NOPMD
Objects.requireNonNull(thread, "thread");
Objects.requireNonNull(throwable, "throwable");
Logger logger = Logger.getLogger(PkiApplication.class.getName());
- logger.log(Level.SEVERE, "Uncaught exception in thread: " + thread.getName(), throwable);
+ if (logger.isLoggable(Level.SEVERE)) {
+ logger.log(Level.SEVERE, "Uncaught exception in thread: " + thread.getName(), throwable);
+ }
};
- Thread.setDefaultUncaughtExceptionHandler(handler);
+ Thread.setDefaultUncaughtExceptionHandler(handler); // NOPMD
}
/**
@@ -151,7 +149,7 @@ final class PkiLogging {
* @throws NullPointerException if {@code logger} or {@code message} is
* {@code null}
*/
- static void emitShutdownMessage(Logger logger, String message) {
+ /* default */ static void emitShutdownMessage(Logger logger, String message) {
Objects.requireNonNull(logger, "logger");
Objects.requireNonNull(message, "message");
@@ -163,7 +161,7 @@ final class PkiLogging {
for (java.util.logging.Handler handler : root.getHandlers()) {
try {
handler.flush();
- } catch (RuntimeException ignored) {
+ } catch (RuntimeException ignored) { // NOPMD
// Never throw during shutdown
}
}
@@ -172,7 +170,7 @@ final class PkiLogging {
try {
System.err.println(message);
System.err.flush();
- } catch (RuntimeException ignored) {
+ } catch (RuntimeException ignored) { // NOPMD
// Never throw during shutdown
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
new file mode 100644
index 0000000..7873ecc
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
@@ -0,0 +1,480 @@
+/*******************************************************************************
+ * 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 java.io.Closeable;
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+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.concurrent.atomic.AtomicLong;
+import java.util.logging.Level;
+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.policy.PolicyTrace;
+import zeroecho.pki.api.profile.CertificateProfile;
+import zeroecho.pki.api.publication.PublicationRecord;
+import zeroecho.pki.api.request.ParsedCertificationRequest;
+import zeroecho.pki.api.revocation.RevokedRecord;
+import zeroecho.pki.api.status.StatusObject;
+import zeroecho.pki.spi.store.PkiStore;
+
+/**
+ * Filesystem-based reference implementation of {@link PkiStore}.
+ *
+ *
Consistency and Atomicity Guarantees
+ *
+ *
+ * This store provides the following guarantees:
+ *
+ *
+ *
+ *
Atomic object replacement: writes of {@code current.bin}
+ * are done via a write-to-temp + atomic move (best-effort {@code ATOMIC_MOVE},
+ * with a documented fallback when not supported).
+ *
+ *
Write-once enforcement: objects stored under write-once
+ * namespaces (credentials, requests, status objects, policy traces,
+ * publications) are never overwritten. A second write to the same identifier
+ * fails with {@link IllegalStateException}. This is intentional to surface
+ * anomalous behavior for audit and incident analysis.
+ *
+ *
Audit history for mutable entities: CA records,
+ * profiles, and revocations are treated as "mutable but auditable": each update
+ * appends an immutable history entry and then updates {@code current.bin}
+ * atomically. This supports forensic reconstruction, and snapshot export ("time
+ * travel") without mutating the store.
+ *
+ *
Deterministic behavior: filenames, ordering, and cleanup
+ * semantics are deterministic. Cleanup occurs only during writes
+ * ({@link FsHistoryPolicy.CleanupStrategy#ON_WRITE}).
+ *
+ *
Single-writer process lock: an exclusive file lock is
+ * held for the lifetime of the instance. Multiple processes can be prevented
+ * from concurrently modifying the same root.
+ *
+ *
+ *
Security Notes
+ *
+ *
+ * This reference implementation stores objects as-is. It does not implement
+ * encryption at rest. It also must not persist private key material; higher
+ * layers must respect the SPI security requirements.
+ *
+ *
+ *
+ * Logging (JUL) never includes raw serialized bytes or sensitive payloads. Logs
+ * are limited to object type, safe IDs, and file operation outcomes.
+ *
+ */
+public final class FilesystemPkiStore implements PkiStore, Closeable {
+
+ private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
+
+ private static final String VERSION_V1 = "v1";
+
+ private final FsPkiStoreOptions options;
+ private final FsPaths paths;
+ private final AtomicLong historySeq;
+
+ private final FileChannel lockChannel;
+
+ /**
+ * Opens or creates a filesystem PKI store rooted at {@code root}.
+ *
+ * @param root store root directory
+ * @param options implementation options
+ * @throws IllegalArgumentException if inputs are null
+ * @throws IllegalStateException if the store cannot be opened or locked
+ */
+ public FilesystemPkiStore(final Path root, final FsPkiStoreOptions options) {
+ this.options = Objects.requireNonNull(options, "options");
+ Objects.requireNonNull(root, "root");
+
+ try {
+ FsOperations.ensureDir(root);
+ this.paths = new FsPaths(root);
+ FsOperations.ensureDir(this.paths.lockFile().getParent());
+
+ this.lockChannel = FileChannel.open(this.paths.lockFile(), StandardOpenOption.CREATE,
+ StandardOpenOption.WRITE);
+ lockChannel.lock(); // exclusive lock
+
+ ensureVersionFile();
+ this.historySeq = new AtomicLong(0L);
+
+ } catch (IOException e) {
+ throw new IllegalStateException("failed to open filesystem store at " + root, e);
+ }
+ }
+
+ /**
+ * Exports a snapshot of this store as of time {@code at} into
+ * {@code targetRoot}.
+ *
+ *
+ * This method is an implementation-only feature. It does not modify the current
+ * store; it clones a new store layout and reconstructs {@code current.bin} for
+ * history-tracked entities.
+ *
+ *
+ * @param targetRoot new store root to create/populate
+ * @param at snapshot time (inclusive, "latest <= at")
+ * @throws IllegalArgumentException if inputs are null
+ * @throws IllegalStateException if export fails
+ */
+ public void exportSnapshot(final Path targetRoot, final Instant at) {
+ Objects.requireNonNull(targetRoot, "targetRoot");
+ Objects.requireNonNull(at, "at");
+ new FsSnapshotExporter(this.options).exportSnapshot(this.paths.root(), targetRoot, at);
+ }
+
+ @Override
+ public void putCa(final CaRecord record) {
+ Objects.requireNonNull(record, "record");
+ PkiId caId = record.caId();
+ Path current = this.paths.caCurrent(caId);
+
+ writeWithHistory(this.paths.caHistoryDir(caId), current, FsCodec.encode(record), this.options.caHistoryPolicy(),
+ "CA", FsUtil.safeId(caId));
+ }
+
+ @Override
+ public Optional getCa(final PkiId caId) {
+ Objects.requireNonNull(caId, "caId");
+ Path p = this.paths.caCurrent(caId);
+ return readOptional(p, CaRecord.class);
+ }
+
+ @Override
+ public List listCas() {
+ Path casRoot = this.paths.root().resolve("cas").resolve("by-id");
+ return listCurrentRecords(casRoot, CaRecord.class);
+ }
+
+ @Override
+ public void putCredential(final Credential credential) {
+ Objects.requireNonNull(credential, "credential");
+ PkiId id = credential.credentialId();
+ Path p = this.paths.credentialPath(id);
+ writeOnce(p, FsCodec.encode(credential), "CREDENTIAL", FsUtil.safeId(id));
+ }
+
+ @Override
+ public Optional getCredential(final PkiId credentialId) {
+ Objects.requireNonNull(credentialId, "credentialId");
+ return readOptional(this.paths.credentialPath(credentialId), Credential.class);
+ }
+
+ @Override
+ public void putRequest(final ParsedCertificationRequest request) {
+ Objects.requireNonNull(request, "request");
+ PkiId id = request.requestId();
+ writeOnce(this.paths.requestPath(id), FsCodec.encode(request), "REQUEST", FsUtil.safeId(id));
+ }
+
+ @Override
+ public Optional getRequest(final PkiId requestId) {
+ Objects.requireNonNull(requestId, "requestId");
+ return readOptional(this.paths.requestPath(requestId), ParsedCertificationRequest.class);
+ }
+
+ @Override
+ public void putRevocation(final RevokedRecord record) {
+ Objects.requireNonNull(record, "record");
+ PkiId credId = record.credentialId();
+ Path current = this.paths.revocationCurrent(credId);
+
+ writeWithHistory(this.paths.revocationHistoryDir(credId), current, FsCodec.encode(record),
+ this.options.revocationHistoryPolicy(), "REVOCATION", FsUtil.safeId(credId));
+ }
+
+ @Override
+ public Optional getRevocation(final PkiId credentialId) {
+ Objects.requireNonNull(credentialId, "credentialId");
+ return readOptional(this.paths.revocationCurrent(credentialId), RevokedRecord.class);
+ }
+
+ @Override
+ public List listRevocations() {
+ Path root = this.paths.root().resolve("revocations").resolve("by-credential");
+ return listCurrentRecords(root, RevokedRecord.class);
+ }
+
+ @Override
+ public void putStatusObject(final StatusObject object) {
+ Objects.requireNonNull(object, "object");
+ PkiId id = object.statusObjectId();
+ writeOnce(this.paths.statusObjectPath(id), FsCodec.encode(object), "STATUS_OBJECT", FsUtil.safeId(id));
+ }
+
+ @Override
+ public Optional getStatusObject(final PkiId statusObjectId) {
+ Objects.requireNonNull(statusObjectId, "statusObjectId");
+ return readOptional(this.paths.statusObjectPath(statusObjectId), StatusObject.class);
+ }
+
+ @Override
+ public List listStatusObjects(final PkiId issuerCaId) {
+ Objects.requireNonNull(issuerCaId, "issuerCaId");
+
+ // Deterministic but coarse: scan all and filter by issuer id.
+ // This is acceptable for a reference implementation; indexes can be added
+ // later.
+ Path byId = this.paths.root().resolve("status").resolve("by-id");
+ List all = listBinaryFiles(byId, StatusObject.class);
+ List out = new ArrayList<>();
+ for (StatusObject o : all) {
+ if (issuerCaId.equals(o.issuerCaId())) {
+ out.add(o);
+ }
+ }
+ return out;
+ }
+
+ @Override
+ public void putPublicationRecord(final PublicationRecord record) {
+ Objects.requireNonNull(record, "record");
+ PkiId id = record.publicationId();
+ writeOnce(this.paths.publicationPath(id), FsCodec.encode(record), "PUBLICATION", FsUtil.safeId(id));
+ }
+
+ @Override
+ public List listPublicationRecords() {
+ Path byId = this.paths.root().resolve("publications").resolve("by-id");
+ return listBinaryFiles(byId, PublicationRecord.class);
+ }
+
+ @Override
+ public void putProfile(final CertificateProfile profile) {
+ Objects.requireNonNull(profile, "profile");
+ String profileId = profile.profileId();
+ Path current = this.paths.profileCurrent(profileId);
+
+ writeWithHistory(this.paths.profileHistoryDir(profileId), current, FsCodec.encode(profile),
+ this.options.profileHistoryPolicy(), "PROFILE", FsUtil.safeSegment(profileId));
+ }
+
+ @Override
+ public Optional getProfile(final String profileId) {
+ if (profileId == null || profileId.isBlank()) {
+ throw new IllegalArgumentException("profileId must not be null/blank");
+ }
+ return readOptional(this.paths.profileCurrent(profileId), CertificateProfile.class);
+ }
+
+ @Override
+ public List listProfiles() {
+ Path root = this.paths.root().resolve("profiles").resolve("by-id");
+ return listCurrentRecords(root, CertificateProfile.class);
+ }
+
+ @Override
+ public void putPolicyTrace(final PolicyTrace trace) {
+ Objects.requireNonNull(trace, "trace");
+ PkiId id = trace.decisionId();
+ writeOnce(this.paths.policyTracePath(id), FsCodec.encode(trace), "POLICY_TRACE", FsUtil.safeId(id));
+ }
+
+ @Override
+ public Optional getPolicyTrace(final PkiId decisionId) {
+ Objects.requireNonNull(decisionId, "decisionId");
+ return readOptional(this.paths.policyTracePath(decisionId), PolicyTrace.class);
+ }
+
+ @Override
+ public void close() throws IOException {
+ synchronized (this.lockChannel) { // NOPMD
+ if (this.lockChannel.isOpen()) {
+ this.lockChannel.close();
+ }
+ }
+ }
+
+ private void ensureVersionFile() throws IOException {
+ Path vf = this.paths.versionFile();
+ if (!Files.exists(vf)) {
+ FsOperations.writeAtomic(vf, VERSION_V1.getBytes());
+ return;
+ }
+ String ver = Files.readString(vf).trim();
+ if (!VERSION_V1.equals(ver)) {
+ throw new IllegalStateException("unsupported store version: " + ver);
+ }
+ }
+
+ private static Optional readOptional(final Path path, final Class type) {
+ try {
+ if (!Files.exists(path)) {
+ return Optional.empty();
+ }
+ byte[] data = FsOperations.readAll(path);
+ return Optional.of(FsCodec.decode(data, type));
+ } catch (IOException e) {
+ throw new IllegalStateException("read failed: " + path, e);
+ }
+ }
+
+ private static List listBinaryFiles(final Path byIdDir, final Class type) {
+ if (!Files.isDirectory(byIdDir)) {
+ return List.of();
+ }
+ try {
+ return Files.list(byIdDir).filter(Files::isRegularFile)
+ .sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> {
+ try {
+ return FsCodec.decode(FsOperations.readAll(p), type);
+ } catch (IOException e) {
+ throw new IllegalStateException("read failed: " + p, e);
+ }
+ }).toList();
+ } catch (IOException e) {
+ throw new IllegalStateException("list failed: " + byIdDir, e);
+ }
+ }
+
+ private static List listCurrentRecords(final Path byIdDir, final Class type) {
+ if (!Files.isDirectory(byIdDir)) {
+ return List.of();
+ }
+ try {
+ List entityDirs = Files.list(byIdDir).filter(Files::isDirectory)
+ .sorted(Comparator.comparing(p -> p.getFileName().toString())).toList();
+
+ List out = new ArrayList<>();
+ for (Path entityDir : entityDirs) {
+ Path current = entityDir.resolve(FsPaths.CURRENT_FILE);
+ if (Files.exists(current)) {
+ out.add(FsCodec.decode(FsOperations.readAll(current), type));
+ }
+ }
+ return out;
+ } catch (IOException e) {
+ throw new IllegalStateException("list current records failed: " + byIdDir, e);
+ }
+ }
+
+ private static void writeOnce(final Path target, final byte[] data, final String kind, final String safeId) {
+ try {
+ FsOperations.ensureDir(target.getParent());
+ if (Files.exists(target)) {
+ throw new IllegalStateException(kind + " is write-once; already exists: " + safeId);
+ }
+ FsOperations.writeNew(target, data);
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.log(Level.FINE, "{0} stored (write-once): {1}", new Object[] { kind, safeId });
+ }
+ } catch (FileAlreadyExistsException e) {
+ throw new IllegalStateException(kind + " is write-once; already exists: " + safeId, e);
+ } catch (IOException e) {
+ throw new IllegalStateException("write-once store failed: " + kind + " " + safeId, e);
+ }
+ }
+
+ private void writeWithHistory(final Path historyDir, final Path currentFile, final byte[] data,
+ final FsHistoryPolicy policy, final String kind, final String safeId) {
+
+ try {
+ FsOperations.ensureDir(currentFile.getParent());
+
+ if (policy.enabled()) {
+ FsOperations.ensureDir(historyDir);
+ Path entry = historyDir.resolve(historyFileName(Instant.now(), this.historySeq.incrementAndGet()));
+ FsOperations.writeAtomic(entry, data);
+
+ if (policy.cleanupStrategy() == FsHistoryPolicy.CleanupStrategy.ON_WRITE) {
+ cleanupHistory(historyDir, policy.retentionWindow());
+ }
+ }
+
+ FsOperations.writeAtomic(currentFile, data);
+ if (LOG.isLoggable(Level.FINE)) {
+ LOG.log(Level.FINE, "{0} stored/updated: {1}", new Object[] { kind, safeId });
+ }
+
+ } catch (IOException e) {
+ throw new IllegalStateException("store failed: " + kind + " " + safeId, e);
+ }
+ }
+
+ private static String historyFileName(final Instant now, final long seq) {
+ long tsMicros = now.getEpochSecond() * 1_000_000L + (now.getNano() / 1_000);
+ return tsMicros + "-" + seq + ".bin";
+ }
+
+ private static void cleanupHistory(final Path historyDir, final Optional retentionWindow) {
+ if (retentionWindow.isEmpty()) {
+ return;
+ }
+ Duration window = retentionWindow.get();
+ Instant cutoff = Instant.now().minus(window);
+
+ try {
+ Files.list(historyDir).filter(Files::isRegularFile)
+ .sorted(Comparator.comparing(p -> p.getFileName().toString())).forEach(p -> {
+ String name = p.getFileName().toString();
+ int dash = name.indexOf('-');
+ if (dash <= 0) {
+ return;
+ }
+ String tsPart = name.substring(0, dash);
+ try {
+ long tsMicros = Long.parseLong(tsPart);
+ Instant entryTime = Instant.ofEpochSecond(tsMicros / 1_000_000L,
+ (tsMicros % 1_000_000L) * 1_000L); // NOPMD
+ if (entryTime.isBefore(cutoff)) {
+ Files.deleteIfExists(p);
+ }
+ } catch (NumberFormatException | IOException ignored) {
+ // deterministic best-effort; never fail a write due to cleanup
+ }
+ });
+ } catch (IOException ignored) {
+ // deterministic best-effort
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
new file mode 100644
index 0000000..ce818f8
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
@@ -0,0 +1,373 @@
+/*******************************************************************************
+ * 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 java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+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.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.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+import zeroecho.core.io.Util;
+
+/**
+ * Deterministic binary codec for PKI store objects.
+ *
+ *
+ * This codec is designed for:
+ *
+ *
+ *
Deterministic serialization across JVM runs.
+ *
No external dependencies.
+ *
Support for records (the dominant pattern in the PKI API).
+ * 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()}.
+ *
+ *
+ *
+ * If none of the above works, an exception is thrown. This is intentional: the
+ * persistence layer must be explicit and auditable.
+ *
+ */
+final class FsCodec {
+
+ private static final int MAX_STRING_BYTES = 1024 * 1024 * 4; // 4 MiB safety cap
+
+ /**
+ * Hard upper bound for any stored binary blob (defense-in-depth against corrupt
+ * files and unbounded allocations). This is not a security boundary.
+ */
+ private static final int MAX_BLOB_BYTES = 16 * 1024 * 1024; // 16 MiB
+
+ private FsCodec() {
+ // utility class
+ }
+
+ /* default */ static byte[] encode(final T value) {
+ Objects.requireNonNull(value, "value");
+ try {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ writeAny(bos, value);
+ return bos.toByteArray();
+ } catch (IOException e) {
+ throw new IllegalStateException("encoding failed: " + value.getClass().getName(), e);
+ }
+ }
+
+ /* default */ static T decode(final byte[] data, final Class expectedType) {
+ Objects.requireNonNull(data, "data");
+ Objects.requireNonNull(expectedType, "expectedType");
+ try {
+ ByteArrayInputStream bis = new ByteArrayInputStream(data);
+ Object decoded = readAny(bis, expectedType);
+ return expectedType.cast(decoded);
+ } catch (IOException e) {
+ throw new IllegalStateException("decoding failed for " + expectedType.getName(), e);
+ }
+ }
+
+ private static void writeAny(final OutputStream out, final Object value) throws IOException { // NOPMD
+ Objects.requireNonNull(out, "out");
+ Objects.requireNonNull(value, "value");
+
+ Class> type = value.getClass();
+ Util.writeUTF8(out, type.getName());
+
+ 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());
+ }
+ return;
+ }
+ if (value instanceof List> list) {
+ Util.writePack7I(out, list.size());
+ for (Object item : list) {
+ writeAny(out, item);
+ }
+ 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);
+ }
+ }
+ return;
+ }
+
+ // fallback: encode as string and reconstruct via string factory on decode
+ writeString(out, value.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);
+ }
+
+ // decode using the encoded type (authoritative)
+ Object value = readByType(in, encodedType);
+
+ // enforce expected assignment
+ if (!expectedType.isAssignableFrom(encodedType)) {
+ throw new IllegalStateException(
+ "type mismatch, expected " + expectedType.getName() + " but encoded " + encodedType.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");
+ }
+ 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");
+ }
+ if (present == 0) {
+ return Optional.empty();
+ }
+ // 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