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:
2025-12-28 01:15:46 +01:00
parent 7673e7d82f
commit cab1eeefe7
14 changed files with 2556 additions and 38 deletions

View File

@@ -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);

View File

@@ -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}

View File

@@ -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;

View File

@@ -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,32 +92,24 @@ final class PkiLogging {
* This method is idempotent and safe to call multiple times.
* </p>
*/
static void configureIfPresent() {
if (configured) {
return;
}
synchronized (PkiLogging.class) {
if (configured) {
/* default */ static void configureIfPresent() {
// Fast-path: already configured
if (!CONFIGURED.compareAndSet(false, true)) {
return;
}
InputStream in = PkiLogging.class.getResourceAsStream(LOGGING_PROPERTIES_RESOURCE);
if (in == null) {
configured = true;
// getResourceAsStream() may return null; try-with-resources handles null safely
try (InputStream is = PkiLogging.class.getResourceAsStream(LOGGING_PROPERTIES_RESOURCE)) {
if (is == null) {
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);
}
}
}
/**
* Installs a process-wide uncaught exception handler that logs failures via
@@ -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());
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
}
}

View 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 &lt;= 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
}
}
}

View 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;
}
}

View 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;
}
}

View 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");
}
}

View 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");
}
}

View File

@@ -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);
}
}

View 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);
}
});
}
}

View File

@@ -0,0 +1,70 @@
/*******************************************************************************
* Copyright (C) 2025, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
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();
}
}

View 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;

View File

@@ -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();
}
}
}