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

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

+ * + *

+ * 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 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); + } + } catch (ReflectiveOperationException ignored) { + // continue + } + } + + try { + Constructor ctor = type.getConstructor(String.class); + return ctor.newInstance(s); + } catch (ReflectiveOperationException ignored) { + // continue + } + + // common pattern: base64 bytes container + try { + Method m = type.getMethod("of", byte[].class); + if (type.isAssignableFrom(m.getReturnType())) { + byte[] decoded = Base64.getDecoder().decode(s); + return m.invoke(null, decoded); + } + } catch (ReflectiveOperationException ignored) { + // ignore + } + + return null; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsHistoryPolicy.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsHistoryPolicy.java new file mode 100644 index 0000000..54f6acf --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsHistoryPolicy.java @@ -0,0 +1,133 @@ +/******************************************************************************* + * 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.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * History configuration for mutable entities stored by + * {@link FilesystemPkiStore}. + * + *

+ * "Mutable" here means: the entity may be replaced/updated over time, but every + * replacement must remain audit-able. This implementation achieves that by + * writing an immutable history record (append-only) and then updating + * {@code current.bin} atomically. + *

+ */ +public final class FsHistoryPolicy { + + /** + * Supported cleanup strategy. + * + *

+ * {@code ON_WRITE} means retention cleanup is executed as part of a write + * operation, which is deterministic and does not require background tasks. + *

+ */ + public enum CleanupStrategy { + /** + * Cleanup happens during write operations only. + */ + ON_WRITE + } + + private final boolean enabled; + private final CleanupStrategy cleanupStrategy; + private final Optional retentionWindow; + + private FsHistoryPolicy(final boolean enabled, final CleanupStrategy cleanupStrategy, + final Optional retentionWindow) { + + this.enabled = enabled; + this.cleanupStrategy = Objects.requireNonNull(cleanupStrategy, "cleanupStrategy"); + this.retentionWindow = Objects.requireNonNull(retentionWindow, "retentionWindow"); + } + + /** + * Creates a history policy with {@link CleanupStrategy#ON_WRITE}. + * + * @param retentionWindow optional retention window; when present, entries older + * than {@code now - retentionWindow} are deleted during + * writes (best-effort, deterministic order) + * @return policy + */ + public static FsHistoryPolicy onWrite(final Optional retentionWindow) { + return new FsHistoryPolicy(true, CleanupStrategy.ON_WRITE, retentionWindow); + } + + /** + * Disables history tracking. + * + *

+ * Disabling history implies that updates to the entity are still atomic, but + * audit reconstruction and snapshot export may be limited or impossible. + *

+ * + * @return policy + */ + public static FsHistoryPolicy disabled() { + return new FsHistoryPolicy(false, CleanupStrategy.ON_WRITE, Optional.empty()); + } + + /** + * Whether history is enabled. + * + * @return true if enabled + */ + public boolean enabled() { + return this.enabled; + } + + /** + * Cleanup strategy. + * + * @return strategy + */ + public CleanupStrategy cleanupStrategy() { + return this.cleanupStrategy; + } + + /** + * Optional retention window. + * + * @return retention window + */ + public Optional retentionWindow() { + return this.retentionWindow; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java new file mode 100644 index 0000000..833b4d6 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java @@ -0,0 +1,319 @@ +/******************************************************************************* + * 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.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Filesystem I/O helpers for the PKI store implementation. + * + *

Goals

+ *
    + *
  • Deterministic on-disk behavior.
  • + *
  • Atomic updates via write-to-temp + move-into-place.
  • + *
  • Best-effort durability (fsync) where the platform supports it.
  • + *
  • Conservative file permissions (0700 for directories, 0600 for files) when + * POSIX is available.
  • + *
  • Targeted JUL diagnostics for unusual situations without logging any + * sensitive material.
  • + *
+ * + *

Security

+ *

+ * This class never logs file contents. Logged data is limited to paths and + * operation outcomes. + *

+ */ +final class FsOperations { + + private static final Logger LOG = Logger.getLogger(FsOperations.class.getName()); + + private static final Set DIR_0700 = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE); + + private static final Set FILE_0600 = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE); + + private FsOperations() { + // utility class + } + + /** + * Ensures the directory exists. If the filesystem supports POSIX attributes, + * new directories are created with 0700. + * + * @param dir directory path + * @throws IOException if directory cannot be created + */ + /* default */ static void ensureDir(final Path dir) throws IOException { + Objects.requireNonNull(dir, "dir"); + if (Files.isDirectory(dir, LinkOption.NOFOLLOW_LINKS)) { + return; + } + Files.createDirectories(dir, dirAttributesIfSupported()); + } + + /** + * Writes bytes to {@code target} atomically (best-effort). + * + *

+ * Implementation: + *

+ *
    + *
  1. Create/overwrite a temp file next to {@code target} with safe permissions + * (0600 when supported).
  2. + *
  3. Write the content to temp.
  4. + *
  5. fsync the temp file (best-effort).
  6. + *
  7. Move temp into place. Prefer {@code ATOMIC_MOVE}, fallback to + * replace.
  8. + *
  9. fsync the parent directory (best-effort).
  10. + *
+ * + * @param target destination file + * @param data bytes to write + * @throws IOException if writing fails + */ + /* default */ static void writeAtomic(final Path target, final byte[] data) throws IOException { + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(data, "data"); + + final Path parent = requireParent(target); + ensureDir(parent); + + final Path tmp = tempSibling(target); + + // If the tmp file exists, it indicates a previous crash or external + // interference. + // This is not fatal; we will overwrite it deterministically. + if (Files.exists(tmp, LinkOption.NOFOLLOW_LINKS) && LOG.isLoggable(Level.FINE)) { + LOG.log(Level.FINE, "Temporary file already exists; overwriting: {0}", tmp); + } + + createOrTruncateTemp(tmp); + + // Write payload into tmp (do not log payload size/content here; size may be OK + // but keep minimal). + final OpenOption[] options = { StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE }; + + try (OutputStream out = Files.newOutputStream(tmp, options)) { + out.write(data); + out.flush(); + } + + forceFileBestEffort(tmp); + moveIntoPlace(tmp, target); + forceDirectoryBestEffort(parent); + } + + /** + * Writes bytes to {@code target} as a write-once operation. + * + *

+ * If {@code target} exists, this method throws + * {@link FileAlreadyExistsException}. New files are created with 0600 + * permissions when POSIX is available. + *

+ * + * @param target destination file + * @param data bytes to write + * @throws IOException if writing fails + */ + /* default */ static void writeNew(final Path target, final byte[] data) throws IOException { + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(data, "data"); + + final Path parent = requireParent(target); + ensureDir(parent); + + Files.createFile(target, fileAttributesIfSupported()); + + try (OutputStream out = Files.newOutputStream(target, StandardOpenOption.WRITE)) { + out.write(data); + out.flush(); + } + + forceFileBestEffort(target); + forceDirectoryBestEffort(parent); + } + + /** + * Reads all bytes from {@code path}. + * + * @param path file path + * @return file bytes + * @throws IOException on read failure + */ + /* default */ static byte[] readAll(final Path path) throws IOException { + Objects.requireNonNull(path, "path"); + return Files.readAllBytes(path); + } + + /** + * Opens an input stream for {@code path}. + * + * @param path file path + * @return input stream + * @throws IOException on failure + */ + /* default */ static InputStream newInputStream(final Path path) throws IOException { + Objects.requireNonNull(path, "path"); + return Files.newInputStream(path, StandardOpenOption.READ); + } + + /** + * Opens an output stream for {@code path} (create/truncate). + * + *

+ * The caller is responsible for any atomicity requirements; use + * {@link #writeAtomic(Path, byte[])} for atomic updates. + *

+ * + * @param path file path + * @return output stream + * @throws IOException on failure + */ + /* default */ static OutputStream newOutputStream(final Path path) throws IOException { + Objects.requireNonNull(path, "path"); + final Path parent = requireParent(path); + ensureDir(parent); + return Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + } + + private static void createOrTruncateTemp(final Path tmp) throws IOException { + // We want deterministic permissions on tmp. If it doesn't exist, create with + // attrs. + // If it exists, truncate; permissions are best-effort (caller may have changed + // them). + if (!Files.exists(tmp, LinkOption.NOFOLLOW_LINKS)) { + Files.createFile(tmp, fileAttributesIfSupported()); + return; + } + + // Truncate by opening with TRUNCATE_EXISTING + WRITE and closing immediately. + try (OutputStream ignored = Files.newOutputStream(tmp, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE)) { + // no-op + } + } + + private static void moveIntoPlace(final Path tmp, final Path target) throws IOException { + try { + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + if (LOG.isLoggable(Level.FINE)) { + LOG.log(Level.FINE, "ATOMIC_MOVE not supported; falling back to REPLACE_EXISTING: {0} -> {1}", + new Object[] { tmp, target }); + } + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void forceFileBestEffort(final Path file) { + try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) { + ch.force(true); + } catch (IOException e) { + // Best-effort durability: never fail writes due to fsync limitations. + if (LOG.isLoggable(Level.FINE)) { + LOG.log(Level.FINE, "fsync file failed (best-effort): {0}", file); + } + } + } + + private static void forceDirectoryBestEffort(final Path dir) { + // Not all platforms allow opening directories for fsync; best-effort only. + try (FileChannel ch = FileChannel.open(dir, StandardOpenOption.READ)) { + ch.force(true); + } catch (IOException e) { + if (LOG.isLoggable(Level.FINE)) { + LOG.log(Level.FINE, "fsync directory failed (best-effort): {0}", dir); + } + } + } + + private static Path requireParent(final Path path) throws IOException { + final Path parent = path.getParent(); + if (parent == null) { + throw new IOException("Path has no parent directory: " + path); + } + return parent; + } + + private static Path tempSibling(final Path target) { + final String name = target.getFileName().toString(); + // Deterministic temp naming; safe because store is single-writer locked. + return target.resolveSibling(name + ".tmp"); + } + + private static FileAttribute[] dirAttributesIfSupported() { + if (supportsPosix()) { + return new FileAttribute[] { PosixFilePermissions.asFileAttribute(DIR_0700) }; + } + return new FileAttribute[0]; + } + + private static FileAttribute[] fileAttributesIfSupported() { + if (supportsPosix()) { + return new FileAttribute[] { PosixFilePermissions.asFileAttribute(FILE_0600) }; + } + return new FileAttribute[0]; + } + + private static boolean supportsPosix() { + final FileSystem fs = FileSystems.getDefault(); + return fs.supportedFileAttributeViews().contains("posix"); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java new file mode 100644 index 0000000..3676395 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -0,0 +1,134 @@ +/******************************************************************************* + * 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.nio.file.Path; +import java.util.Objects; + +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. + *

+ */ +final class FsPaths { + + private static final String BY_ID = "by-id"; + + /* default */ static final String VERSION_FILE = "VERSION"; + /* default */ static final String LOCK_DIR = ".lock"; + /* default */ static final String STORE_LOCK = "store.lock"; + + /* default */ static final String CURRENT_FILE = "current.bin"; + /* default */ static final String HISTORY_DIR = "history"; + + /* default */ final Path root; + + /* default */ FsPaths(final Path root) { + this.root = Objects.requireNonNull(root, "root"); + } + + /* default */ Path root() { + return this.root; + } + + /* default */ Path versionFile() { + return this.root.resolve(VERSION_FILE); + } + + /* default */ Path lockFile() { + return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK); + } + + /* default */ Path caDir(final PkiId caId) { + return this.root.resolve("cas").resolve(BY_ID).resolve(FsUtil.safeId(caId)); + } + + /* default */ Path caCurrent(final PkiId caId) { + return caDir(caId).resolve(CURRENT_FILE); + } + + /* default */ Path caHistoryDir(final PkiId caId) { + return caDir(caId).resolve(HISTORY_DIR); + } + + /* default */ Path profileDir(final String profileId) { + return this.root.resolve("profiles").resolve(BY_ID).resolve(FsUtil.safeSegment(profileId)); + } + + /* default */ Path profileCurrent(final String profileId) { + return profileDir(profileId).resolve(CURRENT_FILE); + } + + /* default */ Path profileHistoryDir(final String profileId) { + return profileDir(profileId).resolve(HISTORY_DIR); + } + + /* default */ Path credentialPath(final PkiId credentialId) { + return this.root.resolve("credentials").resolve(BY_ID).resolve(FsUtil.safeId(credentialId) + ".bin"); + } + + /* default */ Path requestPath(final PkiId requestId) { + return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + ".bin"); + } + + /* default */ Path revocationDir(final PkiId credentialId) { + return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId)); + } + + /* default */ Path revocationCurrent(final PkiId credentialId) { + return revocationDir(credentialId).resolve(CURRENT_FILE); + } + + /* default */ Path revocationHistoryDir(final PkiId credentialId) { + return revocationDir(credentialId).resolve(HISTORY_DIR); + } + + /* default */ Path statusObjectPath(final PkiId statusObjectId) { + return this.root.resolve("status").resolve(BY_ID).resolve(FsUtil.safeId(statusObjectId) + ".bin"); + } + + /* default */ Path policyTracePath(final PkiId decisionId) { + return this.root.resolve("policy").resolve("by-decision").resolve(FsUtil.safeId(decisionId) + ".bin"); + } + + /* default */ Path publicationPath(final PkiId publicationId) { + return this.root.resolve("publications").resolve(BY_ID).resolve(FsUtil.safeId(publicationId) + ".bin"); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java new file mode 100644 index 0000000..cf53274 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPkiStoreOptions.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * 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.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * Configuration 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. + *

+ * + *

+ * All options have deterministic semantics. + *

+ */ +public record FsPkiStoreOptions(FsHistoryPolicy caHistoryPolicy, FsHistoryPolicy profileHistoryPolicy, + FsHistoryPolicy revocationHistoryPolicy, boolean strictSnapshotExport) { + + /** + * Canonical constructor with validation. + */ + public FsPkiStoreOptions { + Objects.requireNonNull(caHistoryPolicy, "caHistoryPolicy"); + Objects.requireNonNull(profileHistoryPolicy, "profileHistoryPolicy"); + Objects.requireNonNull(revocationHistoryPolicy, "revocationHistoryPolicy"); + } + + /** + * Returns default options (history enabled with 90 days retention). + * + * @return default options + */ + public static FsPkiStoreOptions defaults() { + FsHistoryPolicy ninetyDays = FsHistoryPolicy.onWrite(Optional.of(Duration.ofDays(90))); + return new FsPkiStoreOptions(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 new file mode 100644 index 0000000..d130778 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -0,0 +1,193 @@ +/******************************************************************************* + * 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.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Comparator; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Snapshot exporter ("time travel") for {@link FilesystemPkiStore}. + * + *

+ * The exporter never mutates the source store. It creates a brand-new store + * root and populates it with a reconstructed view at time {@code at}. This is + * an internal implementation facility and is not part of any public PKI API. + *

+ * + *

+ * Semantics: + *

+ *
    + *
  • For entities with history enabled, the exporter selects the latest + * history entry with timestamp {@code <= at}. If none exists, it may fall back + * to {@code current.bin} only when strict mode is disabled.
  • + *
  • Write-once objects are copied as-is (they are immutable). This exporter + * does not attempt to prune them by time unless an upstream index exists.
  • + *
+ */ +final class FsSnapshotExporter { + + private static final Logger LOG = Logger.getLogger(FsSnapshotExporter.class.getName()); + + private final FsPkiStoreOptions options; + + /* default */ FsSnapshotExporter(final FsPkiStoreOptions options) { + this.options = Objects.requireNonNull(options, "options"); + } + + /* default */ void exportSnapshot(final Path sourceRoot, final Path targetRoot, final Instant at) { + Objects.requireNonNull(sourceRoot, "sourceRoot"); + Objects.requireNonNull(targetRoot, "targetRoot"); + Objects.requireNonNull(at, "at"); + + try { + FsOperations.ensureDir(targetRoot); + FsPaths dst = new FsPaths(targetRoot); + + Files.writeString(dst.versionFile(), "v1"); + + // copy write-once trees as-is (best-effort, deterministic order) + copyTreeIfExists(sourceRoot.resolve("credentials"), targetRoot.resolve("credentials")); + copyTreeIfExists(sourceRoot.resolve("requests"), targetRoot.resolve("requests")); + copyTreeIfExists(sourceRoot.resolve("status"), targetRoot.resolve("status")); + copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy")); + copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications")); + + // reconstruct mutable entities from history (CAS, PROFILES, REVOCATIONS) + reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at, + this.options.caHistoryPolicy(), this.options.strictSnapshotExport()); + reconstructMutableTree(sourceRoot.resolve("profiles"), targetRoot.resolve("profiles"), at, + this.options.profileHistoryPolicy(), this.options.strictSnapshotExport()); + reconstructMutableTree(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"), at, + this.options.revocationHistoryPolicy(), this.options.strictSnapshotExport()); + + } catch (IOException e) { + throw new IllegalStateException("snapshot export failed", e); + } + } + + private static void reconstructMutableTree(final Path srcTree, final Path dstTree, final Instant at, + final FsHistoryPolicy policy, final boolean strict) throws IOException { + + if (!Files.exists(srcTree)) { + return; + } + Files.walk(srcTree).filter(Files::isRegularFile).sorted(Comparator.comparing(Path::toString)).forEach(p -> { + // only process current.bin; replace it based on history + if (!FsPaths.CURRENT_FILE.equals(p.getFileName().toString())) { + return; + } + + Path entityDir = p.getParent(); + Path historyDir = entityDir.resolve(FsPaths.HISTORY_DIR); + + Path selected = null; + if (policy.enabled() && Files.isDirectory(historyDir)) { + selected = selectHistoryEntry(historyDir, at); + } + + if (selected == null) { + if (strict && policy.enabled()) { + throw new IllegalStateException( + "no history entry available for snapshot at " + at + " for " + entityDir); + } + selected = p; + } + + try { + Path relative = srcTree.relativize(entityDir); + Path dstEntityDir = dstTree.resolve(relative); + FsOperations.ensureDir(dstEntityDir); + byte[] data = Files.readAllBytes(selected); + FsOperations.writeAtomic(dstEntityDir.resolve(FsPaths.CURRENT_FILE), data); + } catch (IOException e) { + throw new IllegalStateException("snapshot reconstruction failed for " + entityDir, e); + } + }); + } + + private static Path selectHistoryEntry(final Path historyDir, final Instant at) { + try { + return Files.list(historyDir).filter(Files::isRegularFile) + .sorted(Comparator.comparing(Path::getFileName).reversed()).filter(p -> { + String name = p.getFileName().toString(); + // format: -.bin + int dash = name.indexOf('-'); + int dot = name.lastIndexOf('.'); + if (dash <= 0 || dot <= dash) { + return false; + } + 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 + return !entryTime.isAfter(at); + } catch (NumberFormatException e) { + return false; + } + }).findFirst().orElse(null); + } catch (IOException e) { + LOG.log(Level.FINE, "history scan failed: {0}", historyDir); + return null; + } + } + + private static void copyTreeIfExists(final Path src, final Path dst) throws IOException { + if (!Files.exists(src)) { + return; + } + Files.walk(src).sorted(Comparator.comparing(Path::toString)).forEach(p -> { + try { + Path rel = src.relativize(p); + Path out = dst.resolve(rel); + if (Files.isDirectory(p)) { + FsOperations.ensureDir(out); + } else if (Files.isRegularFile(p)) { + FsOperations.ensureDir(out.getParent()); + FsOperations.writeAtomic(out, Files.readAllBytes(p)); + } + } catch (IOException e) { + throw new IllegalStateException("copy failed: " + p, e); + } + }); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsUtil.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsUtil.java new file mode 100644 index 0000000..3bcb146 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsUtil.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. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import java.util.Objects; + +/** + * Small, deterministic utilities for the filesystem store. + */ +final class FsUtil { + + private FsUtil() { + // utility class + } + + /* default */ static String safeId(final Object id) { + Objects.requireNonNull(id, "id"); + return safeSegment(id.toString()); + } + + /* default */ static String safeSegment(final String segment) { + Objects.requireNonNull(segment, "segment"); + String trimmed = segment.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("segment must not be blank"); + } + + // conservative allowlist for filesystem safety and determinism + StringBuilder sb = new StringBuilder(trimmed.length()); + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' + || c == '_' || c == '.'; + sb.append(ok ? c : '_'); + } + return sb.toString(); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/package-info.java b/pki/src/main/java/zeroecho/pki/impl/fs/package-info.java new file mode 100644 index 0000000..4f685cd --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/package-info.java @@ -0,0 +1,96 @@ +/******************************************************************************* + * 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. + ******************************************************************************/ +/** + * Filesystem-based reference implementation of the PKI persistence layer. + * + *

+ * This package provides a deterministic, auditable, and strictly defined + * filesystem-backed implementation of the {@code PkiStore} SPI. It is intended + * as a reference implementation and a correctness baseline rather than + * a high-performance backend. + *

+ * + *

Design principles

+ * + *
    + *
  • Deterministic layout – all persisted objects are stored + * in a stable, predictable directory structure.
  • + *
  • Write-once semantics – immutable domain objects are + * never overwritten; attempts to do so result in explicit failures.
  • + *
  • Auditable mutation – mutable objects are versioned using + * append-only history with timestamped entries.
  • + *
  • Atomic updates – all writes use a write-to-temp + + * move-into-place strategy based on NIO.
  • + *
  • Strict snapshot semantics – snapshot export reconstructs + * a complete store state for a given point in time and fails explicitly if no + * valid history entry exists.
  • + *
  • No hidden magic – behavior is explicit, observable, and + * suitable for forensic analysis.
  • + *
+ * + *

Time travel and snapshots

+ * + *

+ * History directories retain timestamped versions of mutable records. Snapshot + * export reconstructs a new store root by selecting, for each mutable entity, + * the latest history entry whose timestamp is less than or equal to the + * requested snapshot instant. + *

+ * + *

+ * If no such entry exists for any required object, snapshot export fails with + * an exception. This strict behavior ensures that snapshots never represent + * states that did not exist in reality. + *

+ * + *

Security considerations

+ * + *
    + *
  • No private keys or secret material are persisted.
  • + *
  • No encryption at rest is performed (by design, for this reference + * implementation).
  • + *
  • Logging uses {@code java.util.logging} exclusively and never includes + * sensitive domain data.
  • + *
+ * + *

Scope

+ * + *

+ * This package is not part of the public PKI API. It may + * evolve independently and may be replaced or complemented by other persistence + * backends (e.g. database-backed implementations). + *

+ */ +package zeroecho.pki.impl.fs; diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java new file mode 100644 index 0000000..5bf7d55 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -0,0 +1,650 @@ +/******************************************************************************* + * 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.assertNotNull; +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.ca.CaKind; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.CaState; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialStatus; +import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.revocation.RevokedRecord; + +/** + * Black-box 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. + *

+ * + *

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

+ */ +public final class FilesystemPkiStoreTest { + + @TempDir + Path tmp; + + @Test + void writeOnceCredentialRejected() throws Exception { + System.out.println("writeOnceCredentialRejected"); + + Path root = tmp.resolve("store-write-once"); + FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); + + try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { + Credential credential = TestObjects.minimalCredential(); + + store.putCredential(credential); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> store.putCredential(credential)); + assertNotNull(ex); + System.out.println("...expected failure: " + safeMsg(ex.getMessage())); + } + + printTree("store layout", root); + System.out.println("writeOnceCredentialRejected...ok"); + } + + @Test + void caHistoryCreatesCurrentAndHistory() throws Exception { + System.out.println("caHistoryCreatesCurrentAndHistory"); + + 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(); + + store.putCa(ca1); + + Path caHist = caHistoryDir(root, caId); + waitForHistoryCount(caHist, 1, Duration.ofMillis(500)); + + CaRecord ca2 = TestObjects.caVariant(ca1, CaState.DISABLED); + 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); + assertTrue(loaded.isPresent()); + } + + printTree("store layout", root); + System.out.println("caHistoryCreatesCurrentAndHistory...ok"); + } + + @Test + void revocationHistorySupportsOverwriteWithTrail() throws Exception { + System.out.println("revocationHistorySupportsOverwriteWithTrail"); + + Path root = tmp.resolve("store-revocations"); + FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); + + PkiId credentialId; + + try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { + RevokedRecord r1 = TestObjects.minimalRevocation(); + credentialId = r1.credentialId(); + + 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); + } + + printTree("store layout", root); + System.out.println("revocationHistorySupportsOverwriteWithTrail...ok"); + } + + @Test + void snapshotExportSelectsCorrectHistoryVersions() throws Exception { + System.out.println("snapshotExportSelectsCorrectHistoryVersions"); + + Path root = tmp.resolve("store-snapshot"); + FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); + + PkiId caId; + + // Arrange: create 4 CA versions (ACTIVE -> DISABLED -> RETIRED -> COMPROMISED). + try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { + CaRecord caV1 = TestObjects.minimalCaRecord(); + caId = caV1.caId(); + + store.putCa(caV1); + Path caHist = caHistoryDir(root, caId); + waitForHistoryCount(caHist, 1, Duration.ofMillis(500)); + + 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)); + } + + printTree("store layout", 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); + + assertTrue(tsMicros.size() >= 4); + + // 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); + + // 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())); + + // 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()); + + // Case 3: at third -> v3 (RETIRED). + Path snap3 = tmp.resolve("snapshot-v3"); + 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()); + + // Case 4: after fourth -> v4 (COMPROMISED). + Path snap4 = tmp.resolve("snapshot-v4"); + try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { + store.exportSnapshot(snap4, afterFourth); + } + printTree("snapshot v4 layout", snap4); + CaRecord snapCa4 = readSnapshotCa(snap4, caId); + System.out.println("...snapshot v4 state=" + snapCa4.state()); + assertEquals(CaState.COMPROMISED, snapCa4.state()); + + System.out.println("snapshotExportSelectsCorrectHistoryVersions...ok"); + } + + // ----------------------- + // Helper methods + // ----------------------- + + private static Path caHistoryDir(final Path storeRoot, final PkiId caId) { + return storeRoot.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId)).resolve("history"); + } + + private static Path revocationHistoryDir(final Path storeRoot, final PkiId credentialId) { + return storeRoot.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId)) + .resolve("history"); + } + + private static void waitForHistoryCount(final Path historyDir, final int expected, final Duration timeout) + 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); + } + throw new IllegalStateException( + "history did not reach expected count " + expected + " in " + timeout + " at " + historyDir); + } + + 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 Instant microsToInstant(final long tsMicros) { + long sec = tsMicros / 1_000_000L; + long micros = tsMicros % 1_000_000L; + return Instant.ofEpochSecond(sec, micros * 1_000L); + } + + private static CaRecord readSnapshotCa(final Path snapshotRoot, final PkiId caId) throws IOException { + Path current = snapshotRoot.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId)).resolve("current.bin"); + byte[] data = FsOperations.readAll(current); + return FsCodec.decode(data, CaRecord.class); + } + + private static void printTree(final String label, final Path root) throws IOException { + System.out.println("..." + label + ": " + root); + if (!Files.exists(root)) { + System.out.println("... "); + 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)"); + } + }); + } + + private static String safeMsg(final String s) { + if (s == null) { + return ""; + } + if (s.length() <= 80) { + 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; + } + } + + /** + * Minimal deterministic empty {@link AttributeSet} for tests. + * + *

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

+ */ + static final class EmptyAttributeSet implements AttributeSet { + + @Override + public Set ids() { + return Set.of(); + } + + @Override + public Optional get(final AttributeId id) { + Objects.requireNonNull(id, "id"); + return Optional.empty(); + } + + @Override + public List getAll(final AttributeId id) { + Objects.requireNonNull(id, "id"); + return List.of(); + } + } +}