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 <lg@hq.egothor.org>
This commit is contained in:
@@ -48,10 +48,9 @@ import zeroecho.core.spi.AsymmetricKeyBuilder;
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @since 1.0
|
||||
@@ -103,7 +102,6 @@ public final class SlhDsaKeyGenBuilder implements AsymmetricKeyBuilder<SlhDsaKey
|
||||
|
||||
// Optional pre-hash suffix used by BC:
|
||||
// _with_sha256/_with_sha512/_with_shake128/_with_shake256
|
||||
// :contentReference[oaicite:4]{index=4}
|
||||
String suffix = "";
|
||||
if (spec.preHash() != SlhDsaKeyGenSpec.PreHash.NONE) {
|
||||
suffix = "_with_" + spec.preHash().name().toLowerCase(Locale.ROOT);
|
||||
|
||||
@@ -106,7 +106,6 @@ public final class SlhDsaKeyGenSpec implements AlgorithmKeySpec {
|
||||
* <p>
|
||||
* Bouncy Castle exposes "with hash" variants as distinct parameter specs, for
|
||||
* example {@code slh_dsa_sha2_128s_with_sha256}.
|
||||
* :contentReference[oaicite:1]{index=1}
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
@@ -183,7 +182,6 @@ public final class SlhDsaKeyGenSpec implements AlgorithmKeySpec {
|
||||
* <p>
|
||||
* This bypasses automatic mapping. The name must match a static field in
|
||||
* {@code org.bouncycastle.jcajce.spec.SLHDSAParameterSpec}.
|
||||
* :contentReference[oaicite:2]{index=2}
|
||||
* </p>
|
||||
*
|
||||
* @param name field name in {@code SLHDSAParameterSpec}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
* </p>
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
480
pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
Normal file
480
pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java
Normal file
@@ -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}.
|
||||
*
|
||||
* <h2>Consistency and Atomicity Guarantees</h2>
|
||||
*
|
||||
* <p>
|
||||
* This store provides the following guarantees:
|
||||
* </p>
|
||||
*
|
||||
* <ul>
|
||||
* <li><strong>Atomic object replacement:</strong> 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).</li>
|
||||
*
|
||||
* <li><strong>Write-once enforcement:</strong> 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.
|
||||
*
|
||||
* <li><strong>Audit history for mutable entities:</strong> 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.</li>
|
||||
*
|
||||
* <li><strong>Deterministic behavior:</strong> filenames, ordering, and cleanup
|
||||
* semantics are deterministic. Cleanup occurs only during writes
|
||||
* ({@link FsHistoryPolicy.CleanupStrategy#ON_WRITE}).</li>
|
||||
*
|
||||
* <li><strong>Single-writer process lock:</strong> an exclusive file lock is
|
||||
* held for the lifetime of the instance. Multiple processes can be prevented
|
||||
* from concurrently modifying the same root.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Security Notes</h2>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Logging (JUL) never includes raw serialized bytes or sensitive payloads. Logs
|
||||
* are limited to object type, safe IDs, and file operation outcomes.
|
||||
* </p>
|
||||
*/
|
||||
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}.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<CaRecord> getCa(final PkiId caId) {
|
||||
Objects.requireNonNull(caId, "caId");
|
||||
Path p = this.paths.caCurrent(caId);
|
||||
return readOptional(p, CaRecord.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CaRecord> 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<Credential> 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<ParsedCertificationRequest> 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<RevokedRecord> getRevocation(final PkiId credentialId) {
|
||||
Objects.requireNonNull(credentialId, "credentialId");
|
||||
return readOptional(this.paths.revocationCurrent(credentialId), RevokedRecord.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RevokedRecord> 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<StatusObject> getStatusObject(final PkiId statusObjectId) {
|
||||
Objects.requireNonNull(statusObjectId, "statusObjectId");
|
||||
return readOptional(this.paths.statusObjectPath(statusObjectId), StatusObject.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StatusObject> 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<StatusObject> all = listBinaryFiles(byId, StatusObject.class);
|
||||
List<StatusObject> 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<PublicationRecord> 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<CertificateProfile> 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<CertificateProfile> 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<PolicyTrace> 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 <T> Optional<T> readOptional(final Path path, final Class<T> 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 <T> List<T> listBinaryFiles(final Path byIdDir, final Class<T> 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 <T> List<T> listCurrentRecords(final Path byIdDir, final Class<T> type) {
|
||||
if (!Files.isDirectory(byIdDir)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
List<Path> entityDirs = Files.list(byIdDir).filter(Files::isDirectory)
|
||||
.sorted(Comparator.comparing(p -> p.getFileName().toString())).toList();
|
||||
|
||||
List<T> 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<Duration> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
373
pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
Normal file
373
pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This codec is designed for:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>Deterministic serialization across JVM runs.</li>
|
||||
* <li>No external dependencies.</li>
|
||||
* <li>Support for records (the dominant pattern in the PKI API).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Supported types:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>primitive wrappers, {@link String}, {@code byte[]}</li>
|
||||
* <li>{@link Instant}, {@link Duration}, {@link UUID}</li>
|
||||
* <li>{@link Optional}, {@link List}</li>
|
||||
* <li>{@link Enum}</li>
|
||||
* <li>Java {@code record} types, recursively</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* For non-record custom value objects, decoding attempts the following (in this
|
||||
* order) using a single {@link String} argument: {@code fromString},
|
||||
* {@code parse}, {@code of}, {@code valueOf}, or a public constructor. The
|
||||
* serialized form is {@code toString()}.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* If none of the above works, an exception is thrown. This is intentional: the
|
||||
* persistence layer must be explicit and auditable.
|
||||
* </p>
|
||||
*/
|
||||
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 <T> 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> T decode(final byte[] data, final Class<T> 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<Object> out = new ArrayList<>(size);
|
||||
for (int i = 0; i < size; i++) {
|
||||
Object item = readAny(in, Object.class);
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (type.isEnum()) {
|
||||
String name = Util.readUTF8(in, MAX_STRING_BYTES);
|
||||
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||
Enum<?> e = Enum.valueOf((Class) type, name);
|
||||
return e;
|
||||
}
|
||||
if (type.isRecord()) {
|
||||
RecordComponent[] components = type.getRecordComponents();
|
||||
int count = Util.readPack7I(in);
|
||||
if (count != components.length) {
|
||||
throw new IllegalStateException("record component count mismatch for " + type.getName());
|
||||
}
|
||||
Object[] args = new Object[components.length];
|
||||
Class<?>[] ctorTypes = new Class<?>[components.length];
|
||||
|
||||
for (int i = 0; i < components.length; i++) {
|
||||
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<String> 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;
|
||||
}
|
||||
}
|
||||
133
pki/src/main/java/zeroecho/pki/impl/fs/FsHistoryPolicy.java
Normal file
133
pki/src/main/java/zeroecho/pki/impl/fs/FsHistoryPolicy.java
Normal file
@@ -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}.
|
||||
*
|
||||
* <p>
|
||||
* "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.
|
||||
* </p>
|
||||
*/
|
||||
public final class FsHistoryPolicy {
|
||||
|
||||
/**
|
||||
* Supported cleanup strategy.
|
||||
*
|
||||
* <p>
|
||||
* {@code ON_WRITE} means retention cleanup is executed as part of a write
|
||||
* operation, which is deterministic and does not require background tasks.
|
||||
* </p>
|
||||
*/
|
||||
public enum CleanupStrategy {
|
||||
/**
|
||||
* Cleanup happens during write operations only.
|
||||
*/
|
||||
ON_WRITE
|
||||
}
|
||||
|
||||
private final boolean enabled;
|
||||
private final CleanupStrategy cleanupStrategy;
|
||||
private final Optional<Duration> retentionWindow;
|
||||
|
||||
private FsHistoryPolicy(final boolean enabled, final CleanupStrategy cleanupStrategy,
|
||||
final Optional<Duration> 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<Duration> retentionWindow) {
|
||||
return new FsHistoryPolicy(true, CleanupStrategy.ON_WRITE, retentionWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables history tracking.
|
||||
*
|
||||
* <p>
|
||||
* Disabling history implies that updates to the entity are still atomic, but
|
||||
* audit reconstruction and snapshot export may be limited or impossible.
|
||||
* </p>
|
||||
*
|
||||
* @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<Duration> retentionWindow() {
|
||||
return this.retentionWindow;
|
||||
}
|
||||
}
|
||||
319
pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java
Normal file
319
pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java
Normal file
@@ -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.
|
||||
*
|
||||
* <h2>Goals</h2>
|
||||
* <ul>
|
||||
* <li>Deterministic on-disk behavior.</li>
|
||||
* <li>Atomic updates via write-to-temp + move-into-place.</li>
|
||||
* <li>Best-effort durability (fsync) where the platform supports it.</li>
|
||||
* <li>Conservative file permissions (0700 for directories, 0600 for files) when
|
||||
* POSIX is available.</li>
|
||||
* <li>Targeted JUL diagnostics for unusual situations without logging any
|
||||
* sensitive material.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Security</h2>
|
||||
* <p>
|
||||
* This class never logs file contents. Logged data is limited to paths and
|
||||
* operation outcomes.
|
||||
* </p>
|
||||
*/
|
||||
final class FsOperations {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(FsOperations.class.getName());
|
||||
|
||||
private static final Set<PosixFilePermission> DIR_0700 = EnumSet.of(PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
|
||||
|
||||
private static final Set<PosixFilePermission> 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).
|
||||
*
|
||||
* <p>
|
||||
* Implementation:
|
||||
* </p>
|
||||
* <ol>
|
||||
* <li>Create/overwrite a temp file next to {@code target} with safe permissions
|
||||
* (0600 when supported).</li>
|
||||
* <li>Write the content to temp.</li>
|
||||
* <li>fsync the temp file (best-effort).</li>
|
||||
* <li>Move temp into place. Prefer {@code ATOMIC_MOVE}, fallback to
|
||||
* replace.</li>
|
||||
* <li>fsync the parent directory (best-effort).</li>
|
||||
* </ol>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* If {@code target} exists, this method throws
|
||||
* {@link FileAlreadyExistsException}. New files are created with 0600
|
||||
* permissions when POSIX is available.
|
||||
* </p>
|
||||
*
|
||||
* @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).
|
||||
*
|
||||
* <p>
|
||||
* The caller is responsible for any atomicity requirements; use
|
||||
* {@link #writeAtomic(Path, byte[])} for atomic updates.
|
||||
* </p>
|
||||
*
|
||||
* @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");
|
||||
}
|
||||
}
|
||||
134
pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
Normal file
134
pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This class is internal to the reference implementation. It defines the
|
||||
* on-disk layout and centralizes naming conventions.
|
||||
* </p>
|
||||
*/
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* All options have deterministic semantics.
|
||||
* </p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
193
pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
Normal file
193
pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java
Normal file
@@ -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}.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Semantics:
|
||||
* </p>
|
||||
* <ul>
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
*/
|
||||
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: <tsMicros>-<seq>.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
70
pki/src/main/java/zeroecho/pki/impl/fs/FsUtil.java
Normal file
70
pki/src/main/java/zeroecho/pki/impl/fs/FsUtil.java
Normal file
@@ -0,0 +1,70 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2025, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
******************************************************************************/
|
||||
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();
|
||||
}
|
||||
}
|
||||
96
pki/src/main/java/zeroecho/pki/impl/fs/package-info.java
Normal file
96
pki/src/main/java/zeroecho/pki/impl/fs/package-info.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* This package provides a deterministic, auditable, and strictly defined
|
||||
* filesystem-backed implementation of the {@code PkiStore} SPI. It is intended
|
||||
* as a <em>reference implementation</em> and a correctness baseline rather than
|
||||
* a high-performance backend.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Design principles</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li><strong>Deterministic layout</strong> – all persisted objects are stored
|
||||
* in a stable, predictable directory structure.</li>
|
||||
* <li><strong>Write-once semantics</strong> – immutable domain objects are
|
||||
* never overwritten; attempts to do so result in explicit failures.</li>
|
||||
* <li><strong>Auditable mutation</strong> – mutable objects are versioned using
|
||||
* append-only history with timestamped entries.</li>
|
||||
* <li><strong>Atomic updates</strong> – all writes use a write-to-temp +
|
||||
* move-into-place strategy based on NIO.</li>
|
||||
* <li><strong>Strict snapshot semantics</strong> – snapshot export reconstructs
|
||||
* a complete store state for a given point in time and fails explicitly if no
|
||||
* valid history entry exists.</li>
|
||||
* <li><strong>No hidden magic</strong> – behavior is explicit, observable, and
|
||||
* suitable for forensic analysis.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Time travel and snapshots</h2>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <h2>Security considerations</h2>
|
||||
*
|
||||
* <ul>
|
||||
* <li>No private keys or secret material are persisted.</li>
|
||||
* <li>No encryption at rest is performed (by design, for this reference
|
||||
* implementation).</li>
|
||||
* <li>Logging uses {@code java.util.logging} exclusively and never includes
|
||||
* sensitive domain data.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Scope</h2>
|
||||
*
|
||||
* <p>
|
||||
* This package is <strong>not</strong> 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).
|
||||
* </p>
|
||||
*/
|
||||
package zeroecho.pki.impl.fs;
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Every test routine prints its own name and prints {@code ...ok} on success.
|
||||
* Important intermediate values are printed with {@code "..."} prefix.
|
||||
* </p>
|
||||
*/
|
||||
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<CaRecord> 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<Long> 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<Long> listHistoryMicrosSorted(final Path historyDir) throws IOException {
|
||||
List<Long> out = new ArrayList<>();
|
||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(historyDir, "*.bin")) {
|
||||
for (Path p : ds) {
|
||||
String name = p.getFileName().toString();
|
||||
int dash = name.indexOf('-');
|
||||
if (dash <= 0) {
|
||||
continue;
|
||||
}
|
||||
String ts = name.substring(0, dash);
|
||||
try {
|
||||
out.add(Long.valueOf(ts));
|
||||
} catch (NumberFormatException ignore) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort(Long::compareTo);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Instant microsToInstant(final long tsMicros) {
|
||||
long sec = tsMicros / 1_000_000L;
|
||||
long micros = tsMicros % 1_000_000L;
|
||||
return Instant.ofEpochSecond(sec, micros * 1_000L);
|
||||
}
|
||||
|
||||
private static CaRecord readSnapshotCa(final Path snapshotRoot, final PkiId caId) throws IOException {
|
||||
Path current = snapshotRoot.resolve("cas").resolve("by-id").resolve(FsUtil.safeId(caId)).resolve("current.bin");
|
||||
byte[] data = FsOperations.readAll(current);
|
||||
return FsCodec.decode(data, CaRecord.class);
|
||||
}
|
||||
|
||||
private static void printTree(final String label, final Path root) throws IOException {
|
||||
System.out.println("..." + label + ": " + root);
|
||||
if (!Files.exists(root)) {
|
||||
System.out.println("... <missing>");
|
||||
return;
|
||||
}
|
||||
Files.walk(root).sorted(Comparator.comparing(p -> root.relativize(p).toString())).forEach(p -> {
|
||||
try {
|
||||
String rel = root.relativize(p).toString();
|
||||
if (rel.isEmpty()) {
|
||||
rel = ".";
|
||||
}
|
||||
if (Files.isDirectory(p)) {
|
||||
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 "<null>";
|
||||
}
|
||||
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<CertificateProfile> ctor = CertificateProfile.class.getConstructor(String.class, formatId,
|
||||
String.class, List.class, List.class, Optional.class, boolean.class);
|
||||
|
||||
return ctor.newInstance("profile-1", fmt, "Profile 1", List.of(), List.of(), Optional.empty(), true);
|
||||
}
|
||||
|
||||
static Credential minimalCredential() throws Exception {
|
||||
Class<?> formatId = Class.forName("zeroecho.pki.api.FormatId");
|
||||
Class<?> issuerRef = Class.forName("zeroecho.pki.api.IssuerRef");
|
||||
Class<?> subjectRef = Class.forName("zeroecho.pki.api.SubjectRef");
|
||||
Class<?> validity = Class.forName("zeroecho.pki.api.Validity");
|
||||
Class<?> encodedObject = Class.forName("zeroecho.pki.api.EncodedObject");
|
||||
|
||||
PkiId credentialId = anyPkiId();
|
||||
Object fmt = anyValue(formatId);
|
||||
Object iss = anyValue(issuerRef);
|
||||
Object sub = anyValue(subjectRef);
|
||||
Object val = anyValue(validity);
|
||||
String serial = "SERIAL-1";
|
||||
PkiId publicKeyId = anyPkiId();
|
||||
String profileId = "profile-1";
|
||||
CredentialStatus status = CredentialStatus.ISSUED;
|
||||
Object enc = anyValue(encodedObject);
|
||||
AttributeSet attrs = new EmptyAttributeSet();
|
||||
|
||||
Constructor<Credential> ctor = Credential.class.getConstructor(PkiId.class, formatId, issuerRef, subjectRef,
|
||||
validity, String.class, PkiId.class, String.class, CredentialStatus.class, encodedObject,
|
||||
AttributeSet.class);
|
||||
|
||||
return ctor.newInstance(credentialId, fmt, iss, sub, val, serial, publicKeyId, profileId, status, enc,
|
||||
attrs);
|
||||
}
|
||||
|
||||
static RevokedRecord minimalRevocation() throws Exception {
|
||||
PkiId cred = anyPkiId();
|
||||
AttributeSet attrs = new EmptyAttributeSet();
|
||||
Instant revTime = Instant.ofEpochSecond(1_700_000_000L);
|
||||
return new RevokedRecord(cred, revTime, RevocationReason.CESSATION_OF_OPERATION, attrs);
|
||||
}
|
||||
|
||||
static PkiId anyPkiId() throws Exception {
|
||||
long n = nextIdCounter();
|
||||
UUID uuid = new UUID(0L, n);
|
||||
|
||||
Class<PkiId> cls = PkiId.class;
|
||||
|
||||
PkiId id = (PkiId) tryStaticNoArg(cls, "random");
|
||||
if (id != null) {
|
||||
return id;
|
||||
}
|
||||
id = (PkiId) tryStaticNoArg(cls, "newId");
|
||||
if (id != null) {
|
||||
return id;
|
||||
}
|
||||
|
||||
PkiId byUuid = (PkiId) tryStatic1(cls, "of", UUID.class, uuid);
|
||||
if (byUuid != null) {
|
||||
return byUuid;
|
||||
}
|
||||
PkiId byUuid2 = (PkiId) tryCtor1(cls, UUID.class, uuid);
|
||||
if (byUuid2 != null) {
|
||||
return byUuid2;
|
||||
}
|
||||
|
||||
String s = uuid.toString();
|
||||
PkiId byStr = (PkiId) tryStatic1(cls, "fromString", String.class, s);
|
||||
if (byStr != null) {
|
||||
return byStr;
|
||||
}
|
||||
PkiId byStr2 = (PkiId) tryStatic1(cls, "parse", String.class, s);
|
||||
if (byStr2 != null) {
|
||||
return byStr2;
|
||||
}
|
||||
PkiId byStr3 = (PkiId) tryCtor1(cls, String.class, s);
|
||||
if (byStr3 != null) {
|
||||
return byStr3;
|
||||
}
|
||||
|
||||
throw new IllegalStateException("cannot construct PkiId reflectively");
|
||||
}
|
||||
|
||||
static Object anyValue(final Class<?> type) throws Exception {
|
||||
Objects.requireNonNull(type, "type");
|
||||
|
||||
// Validity has invariants; create it explicitly before record handling.
|
||||
if ("zeroecho.pki.api.Validity".equals(type.getName())) {
|
||||
Instant notBefore = Instant.ofEpochSecond(1_700_000_000L);
|
||||
Instant notAfter = notBefore.plusSeconds(86400L);
|
||||
Constructor<?> ctor = type.getConstructor(Instant.class, Instant.class);
|
||||
return ctor.newInstance(notBefore, notAfter);
|
||||
}
|
||||
|
||||
if (AttributeSet.class.getName().equals(type.getName())) {
|
||||
return new EmptyAttributeSet();
|
||||
}
|
||||
|
||||
if (type.isRecord()) {
|
||||
java.lang.reflect.RecordComponent[] components = type.getRecordComponents();
|
||||
Class<?>[] ctorTypes = new Class<?>[components.length];
|
||||
Object[] args = new Object[components.length];
|
||||
for (int i = 0; i < components.length; i++) {
|
||||
ctorTypes[i] = components[i].getType();
|
||||
args[i] = defaultValue(ctorTypes[i]);
|
||||
}
|
||||
Constructor<?> ctor = type.getDeclaredConstructor(ctorTypes);
|
||||
ctor.setAccessible(true);
|
||||
return ctor.newInstance(args);
|
||||
}
|
||||
|
||||
Object v = tryStaticNoArg(type, "empty");
|
||||
if (v != null) {
|
||||
return v;
|
||||
}
|
||||
v = tryStaticNoArg(type, "defaultValue");
|
||||
if (v != null) {
|
||||
return v;
|
||||
}
|
||||
|
||||
Constructor<?>[] ctors = type.getConstructors();
|
||||
for (Constructor<?> ctor : ctors) {
|
||||
if (ctor.getParameterCount() == 0) {
|
||||
return ctor.newInstance();
|
||||
}
|
||||
}
|
||||
|
||||
Object vs = tryCtor1(type, String.class, "x");
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* The production API defines {@link AttributeSet} as a passive interface
|
||||
* without factories. Tests only require an immutable empty set.
|
||||
* </p>
|
||||
*/
|
||||
static final class EmptyAttributeSet implements AttributeSet {
|
||||
|
||||
@Override
|
||||
public Set<AttributeId> ids() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AttributeValue> get(final AttributeId id) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AttributeValue> getAll(final AttributeId id) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user