diff --git a/app/build.gradle b/app/build.gradle index 7ccb9d8..aff01eb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,8 +8,11 @@ group='org.egothor' dependencies { implementation 'org.apache.commons:commons-text' implementation 'commons-cli:commons-cli' + implementation platform('tools.jackson:jackson-bom:3.1.5') + implementation 'tools.jackson.core:jackson-core' implementation project(':lib') implementation project(':ext') + implementation project(':pki') // might be removed if I move BC ops to the lib testImplementation 'org.bouncycastle:bcpkix-jdk18on' } @@ -57,4 +60,3 @@ jar { javadoc { options.links("https://www.egothor.org/javadoc/zeroecho/lib") } - diff --git a/app/src/main/java/zeroecho/ZeroEcho.java b/app/src/main/java/zeroecho/ZeroEcho.java index 75113ab..7256195 100644 --- a/app/src/main/java/zeroecho/ZeroEcho.java +++ b/app/src/main/java/zeroecho/ZeroEcho.java @@ -35,6 +35,7 @@ package zeroecho; import java.io.IOException; import java.security.GeneralSecurityException; +import java.util.Arrays; import java.util.logging.Level; import java.util.logging.Logger; @@ -48,6 +49,7 @@ import org.apache.commons.cli.ParseException; import org.apache.commons.cli.help.HelpFormatter; import zeroecho.sdk.util.BouncyCastleActivator; +import zeroecho.pki.cli.PkiCli; /** * ZeroEcho is a command-line utility for managing asymmetric keys and @@ -102,6 +104,12 @@ public final class ZeroEcho { public static void main(final String[] args) throws IOException { final int errorCode = mainProcess(args); + if (args.length > 0 && "pki".equals(args[0])) { + if (errorCode != 0) { + System.exit(errorCode); + } + return; + } if (errorCode == 0) { System.out.println("OK"); } else { @@ -119,6 +127,9 @@ public final class ZeroEcho { * @throws IOException If the output could not be written */ public static int mainProcess(final String... args) throws IOException { + if (args.length > 0 && "pki".equals(args[0])) { + return PkiCli.execute(Arrays.copyOfRange(args, 1, args.length), System.out); + } final Option KEM_OPTION = Option.builder("E").longOpt("kem").desc("KEM encryption/decryption").get(); final Option GUARD_OPTION = Option.builder("G").longOpt("guard") .desc("multi-recipient encryption/decryption (keys+passwords), AES/ChaCha").get(); diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCli.java b/app/src/main/java/zeroecho/pki/cli/PkiCli.java new file mode 100644 index 0000000..f4c2c4e --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiCli.java @@ -0,0 +1,300 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.application.PkiOperationFailure; +import zeroecho.pki.application.PkiOperationValue; +import zeroecho.pki.application.PkiSession; +import zeroecho.pki.application.PkiSessionConfiguration; + +/** Composition root for the synchronous {@code zeroecho pki} command family. */ +@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.AvoidInstantiatingObjectsInLoops", + "PMD.AvoidReassigningLoopVariables", "PMD.CloseResource", "PMD.DoNotUseThreads", "PMD.UseVarargs" }) +public final class PkiCli { + + private static final String CONFIG = "--config"; + private static final String OUTPUT = "--output"; + private static final String RUN = "run"; + private static final int RUN_TOKEN_COUNT = 2; + + private PkiCli() { + } + + /** + * Executes one direct PKI operation or one sequential plan. + * + * @param arguments arguments following the {@code pki} command + * @param output exclusive result output stream + * @return stable PKI CLI process exit code + * @throws NullPointerException if an argument container is {@code null} + */ + public static int execute(String[] arguments, OutputStream output) { + return execute(arguments, output, PkiSession::open, () -> Thread.currentThread().isInterrupted()); + } + + /* default */ static int execute(String[] arguments, OutputStream output, SessionOpener opener, + CancellationSignal cancellation) { + Objects.requireNonNull(arguments, "arguments"); + Objects.requireNonNull(output, "output"); + Objects.requireNonNull(opener, "opener"); + Objects.requireNonNull(cancellation, "cancellation"); + PkiOutputRenderer renderer = new PkiOutputRenderer(); + Invocation invocation; + try { + invocation = parseInvocation(arguments); + } catch (IllegalArgumentException failure) { + return renderFailure(renderer, PkiOutputMode.HUMAN, "INVALID_INVOCATION", "INVOCATION_INVALID", + PkiExitCodes.INVALID_INVOCATION, output); + } + if (invocation.help()) { + renderHelp(output); + return PkiExitCodes.SUCCESS; + } + + PkiSessionConfiguration configuration; + try { + configuration = PkiCliConfiguration.read(invocation.configuration()); + } catch (IOException | RuntimeException failure) { + return renderFailure(renderer, invocation.outputMode(), "CONFIGURATION_FAILURE", "CONFIGURATION_INVALID", + PkiExitCodes.CONFIGURATION_FAILURE, output); + } + + PkiOperationRegistry registry = new PkiOperationRegistry(); + PkiPlan plan; + try { + plan = invocation.batch() ? PkiPlanParser.read(invocation.plan(), registry.names()) + : singlePlan(invocation, registry); + } catch (IOException | RuntimeException failure) { + return renderFailure(renderer, invocation.outputMode(), "INVALID_PLAN", "PLAN_INVALID", + PkiExitCodes.INVALID_INVOCATION, output); + } + + PkiSession session; + try { + session = opener.open(configuration); + } catch (IllegalArgumentException failure) { + return renderFailure(renderer, invocation.outputMode(), "CONFIGURATION_FAILURE", "CONFIGURATION_INVALID", + PkiExitCodes.CONFIGURATION_FAILURE, output); + } catch (RuntimeException failure) { + return renderFailure(renderer, invocation.outputMode(), "RESOURCE_FAILURE", "SESSION_OPEN_FAILED", + PkiExitCodes.RESOURCE_FAILURE, output); + } + + PkiPlanExecution execution; + try { + execution = new PkiPlanExecutor(registry).execute(plan, session, cancellation); + } catch (RuntimeException failure) { + execution = internalFailure(plan); + } + try { + session.close(); + } catch (Exception failure) { + execution = execution.withPlanFailure(PkiOperationFailure.RESOURCE_FAILURE, "SESSION_CLOSE_FAILED"); + } + try { + renderer.render(execution, invocation.outputMode(), invocation.batch(), output); + } catch (IOException failure) { + return PkiExitCodes.RESOURCE_FAILURE; + } + return PkiExitCodes.from(execution); + } + + @SuppressWarnings("PMD.CyclomaticComplexity") + private static Invocation parseInvocation(String[] arguments) { + if (arguments.length == 0) { + throw new IllegalArgumentException("PKI command is missing"); + } + Path configuration = null; + PkiOutputMode outputMode = PkiOutputMode.HUMAN; + List commandTokens = new ArrayList<>(); + boolean help = false; + boolean outputSelected = false; + for (int index = 0; index < arguments.length; index++) { + String argument = Objects.requireNonNull(arguments[index], "argument"); + if (CONFIG.equals(argument)) { + if (configuration != null) { + throw new IllegalArgumentException("PKI configuration option is duplicated"); + } + configuration = Path.of(requireValue(arguments, ++index)); + } else if (OUTPUT.equals(argument)) { + if (outputSelected) { + throw new IllegalArgumentException("PKI output option is duplicated"); + } + outputMode = parseOutput(requireValue(arguments, ++index)); + outputSelected = true; + } else if ("--help".equals(argument) || "-h".equals(argument)) { + help = true; + } else { + commandTokens.add(argument); + } + } + if (help) { + return new Invocation(null, outputMode, false, null, null, null, true); + } + if (configuration == null || commandTokens.isEmpty()) { + throw new IllegalArgumentException("PKI configuration or operation is missing"); + } + String command = commandTokens.get(0); + if (RUN.equals(command)) { + if (commandTokens.size() != RUN_TOKEN_COUNT) { + throw new IllegalArgumentException("Plan path is invalid"); + } + return new Invocation(configuration, outputMode, true, Path.of(commandTokens.get(1)), null, null, false); + } + PkiOperationValue.ObjectValue operationArguments = directArguments(commandTokens.subList(1, + commandTokens.size())); + return new Invocation(configuration, outputMode, false, null, command, operationArguments, false); + } + + private static PkiOperationValue.ObjectValue directArguments(List tokens) { + if ((tokens.size() & 1) != 0) { + throw new IllegalArgumentException("Operation arguments must be option-value pairs"); + } + Map arguments = new LinkedHashMap<>(); + for (int index = 0; index < tokens.size(); index += 2) { + String name = argumentName(tokens.get(index)); + String raw = tokens.get(index + 1); + PkiOperationValue value = "limit".equals(name) ? new PkiOperationValue.IntegerValue(parseLong(raw)) + : new PkiOperationValue.Text(raw); + if (arguments.putIfAbsent(name, value) != null) { + throw new IllegalArgumentException("Operation argument is duplicated"); + } + } + return new PkiOperationValue.ObjectValue(arguments); + } + + private static String argumentName(String option) { + return switch (option) { + case "--profile-file" -> "profileFile"; + case "--credential-id" -> "credentialId"; + case "--publication-id" -> "publicationId"; + case "--reason" -> "reason"; + case "--limit" -> "limit"; + default -> throw new IllegalArgumentException("Unknown PKI operation argument"); + }; + } + + private static PkiPlan singlePlan(Invocation invocation, PkiOperationRegistry registry) throws IOException { + if (!registry.names().contains(invocation.operation())) { + throw new IllegalArgumentException("Unknown PKI operation"); + } + Path base = Path.of(".").toAbsolutePath().normalize(); + PkiPlan.Step step = new PkiPlan.Step("command", invocation.operation(), invocation.arguments()); + return new PkiPlan(PkiPlan.CURRENT_VERSION, PkiPlan.FailurePolicy.FAIL_FAST, List.of(step), base); + } + + private static PkiPlanExecution internalFailure(PkiPlan plan) { + List results = new ArrayList<>(plan.steps().size()); + boolean first = true; + for (PkiPlan.Step step : plan.steps()) { + if (first) { + results.add(PkiPlanExecution.StepResult.failure(step.id(), step.operation(), + PkiOperationFailure.INTERNAL_FAILURE, "INTERNAL_FAILURE")); + first = false; + } else { + results.add(PkiPlanExecution.StepResult.skipped(step.id(), step.operation(), "FAIL_FAST")); + } + } + return new PkiPlanExecution(results); + } + + private static int renderFailure(PkiOutputRenderer renderer, PkiOutputMode mode, String category, String code, + int exitCode, OutputStream output) { + try { + renderer.renderFailure(mode, category, code, output); + } catch (IOException ignored) { + return PkiExitCodes.RESOURCE_FAILURE; + } + return exitCode; + } + + private static void renderHelp(OutputStream output) { + java.io.PrintWriter writer = new java.io.PrintWriter(output, false, java.nio.charset.StandardCharsets.UTF_8); + writer.println("Usage: zeroecho pki --config [--output human|json] [arguments]"); + writer.println(" zeroecho pki run --config [--output human|json]"); + writer.println("Operations:"); + writer.println(" configuration.validate"); + writer.println(" profile.validate --profile-file "); + writer.println(" credential.inspect --credential-id "); + writer.println(" credential.revoke --credential-id --reason "); + writer.println(" revocation.history --credential-id --limit <1..1000>"); + writer.println(" publication.inspect --publication-id "); + writer.flush(); + } + + private static PkiOutputMode parseOutput(String raw) { + return switch (raw) { + case "human" -> PkiOutputMode.HUMAN; + case "json" -> PkiOutputMode.JSON; + default -> throw new IllegalArgumentException("Output mode is invalid"); + }; + } + + private static String requireValue(String[] arguments, int index) { + if (index >= arguments.length) { + throw new IllegalArgumentException("Option value is missing"); + } + return arguments[index]; + } + + private static long parseLong(String raw) { + try { + return Long.parseLong(raw); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("Numeric operation argument is invalid", failure); + } + } + + /** Lifecycle construction seam for CLI orchestration tests. */ + @FunctionalInterface + @SuppressWarnings("PMD.CommentDefaultAccessModifier") + interface SessionOpener { + /** Opens one lifecycle-owned backend session. */ + PkiSession open(PkiSessionConfiguration configuration); + } + + private record Invocation(Path configuration, PkiOutputMode outputMode, boolean batch, Path plan, + String operation, PkiOperationValue.ObjectValue arguments, boolean help) { + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java new file mode 100644 index 0000000..a263752 --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java @@ -0,0 +1,114 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import zeroecho.pki.application.PkiOperationValue; +import zeroecho.pki.application.PkiSessionConfiguration; +import zeroecho.pki.spi.ProviderConfig; + +/** Strict versioned CLI configuration loader. */ +final class PkiCliConfiguration { + + private static final Set ROOT_FIELDS = Set.of("version", "store", "audit"); + private static final Set PROVIDER_FIELDS = Set.of("provider", "properties"); + private static final String STDOUT_PROVIDER = "stdout"; + + private PkiCliConfiguration() { + } + + /* default */ static PkiSessionConfiguration read(Path path) throws java.io.IOException { + byte[] document = PkiCliFiles.readRegularFile(path, PkiCliJson.MAXIMUM_DOCUMENT_BYTES); + PkiOperationValue.ObjectValue root = object(PkiCliJson.parse(document)); + requireExactFields(root.fields(), ROOT_FIELDS); + int version = Math.toIntExact(integer(required(root, "version"))); + ProviderConfig store = provider(required(root, "store")); + ProviderConfig audit = provider(required(root, "audit")); + if (STDOUT_PROVIDER.equals(audit.backendId())) { + throw new IllegalArgumentException("CLI audit provider must not write to standard output"); + } + return new PkiSessionConfiguration(version, store, audit); + } + + private static ProviderConfig provider(PkiOperationValue value) { + PkiOperationValue.ObjectValue object = object(value); + requireExactFields(object.fields(), PROVIDER_FIELDS); + String provider = text(required(object, "provider")); + PkiOperationValue.ObjectValue properties = object(required(object, "properties")); + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : properties.fields().entrySet()) { + result.put(entry.getKey(), text(entry.getValue())); + } + return new ProviderConfig(provider, result); + } + + /* default */ static PkiOperationValue required(PkiOperationValue.ObjectValue object, String name) { + PkiOperationValue value = object.fields().get(name); + if (value == null) { + throw new IllegalArgumentException("Required CLI field is missing"); + } + return value; + } + + /* default */ static PkiOperationValue.ObjectValue object(PkiOperationValue value) { + if (value instanceof PkiOperationValue.ObjectValue object) { + return object; + } + throw new IllegalArgumentException("CLI field has the wrong type"); + } + + /* default */ static String text(PkiOperationValue value) { + if (value instanceof PkiOperationValue.Text text) { + return text.value(); + } + throw new IllegalArgumentException("CLI field has the wrong type"); + } + + /* default */ static long integer(PkiOperationValue value) { + if (value instanceof PkiOperationValue.IntegerValue integer) { + return integer.value(); + } + throw new IllegalArgumentException("CLI field has the wrong type"); + } + + /* default */ static void requireExactFields(Map actual, Set expected) { + if (!actual.keySet().equals(expected)) { + throw new IllegalArgumentException("CLI document fields are invalid"); + } + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliFiles.java b/app/src/main/java/zeroecho/pki/cli/PkiCliFiles.java new file mode 100644 index 0000000..33297c3 --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiCliFiles.java @@ -0,0 +1,92 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.channels.Channels; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Objects; + +/** Bounded regular-file input used by the CLI trust boundary. */ +final class PkiCliFiles { + + private static final int BUFFER_BYTES = 8_192; + + private PkiCliFiles() { + } + + /* default */ static byte[] readRegularFile(Path path, int maximumBytes) throws IOException { + Path exact = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + if (maximumBytes <= 0) { + throw new IllegalArgumentException("maximumBytes must be positive"); + } + int readLimit = Math.addExact(maximumBytes, 1); + BasicFileAttributes attributes = Files.readAttributes(exact, BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS); + if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() < 0L + || attributes.size() > maximumBytes) { + throw new IOException("CLI input file is invalid"); + } + ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(attributes.size(), BUFFER_BYTES)); + byte[] buffer = new byte[BUFFER_BYTES]; + int total = 0; + try (SeekableByteChannel channel = Files.newByteChannel(exact, StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS); + InputStream input = Channels.newInputStream(channel)) { + while (true) { + int remaining = readLimit - total; + int count = input.read(buffer, 0, Math.min(buffer.length, remaining)); + if (count < 0) { + break; + } + if (count == 0) { + continue; + } + output.write(buffer, 0, count); + total = Math.addExact(total, count); + if (total > maximumBytes) { + throw new IOException("CLI input file is too large"); + } + } + } + return output.toByteArray(); + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliJson.java b/app/src/main/java/zeroecho/pki/cli/PkiCliJson.java new file mode 100644 index 0000000..bb09bd8 --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiCliJson.java @@ -0,0 +1,147 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.ObjectReadContext; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonFactoryBuilder; +import tools.jackson.core.json.JsonReadFeature; +import zeroecho.pki.application.PkiOperationValue; + +/** Strict bounded JSON reader for CLI configuration and operation plans. */ +final class PkiCliJson { + + /* default */ static final int MAXIMUM_DOCUMENT_BYTES = 1_048_576; + /* default */ static final int MAXIMUM_CONTAINER_ENTRIES = 2_048; + + private static final int MAXIMUM_DEPTH = 16; + private static final int MAXIMUM_STRING_LENGTH = 4_096; + private static final JsonFactory JSON_FACTORY = createFactory(); + + private PkiCliJson() { + } + + /* default */ static PkiOperationValue parse(byte[] document) { + if (document == null || document.length == 0 || document.length > MAXIMUM_DOCUMENT_BYTES || hasBom(document)) { + throw invalid(); + } + try (JsonParser parser = JSON_FACTORY.createParser(ObjectReadContext.empty(), document, 0, document.length)) { + JsonToken first = parser.nextToken(); + if (first == null) { + throw invalid(); + } + PkiOperationValue value = readValue(parser, first); + if (parser.nextToken() != null) { + throw invalid(); + } + return value; + } catch (JacksonException failure) { + throw invalid(failure); + } + } + + private static PkiOperationValue readValue(JsonParser parser, JsonToken token) { + return switch (token) { + case START_OBJECT -> readObject(parser); + case START_ARRAY -> readArray(parser); + case VALUE_STRING -> new PkiOperationValue.Text(parser.getString()); + case VALUE_NUMBER_INT -> new PkiOperationValue.IntegerValue(parser.getLongValue()); + case VALUE_TRUE -> new PkiOperationValue.BooleanValue(true); + case VALUE_FALSE -> new PkiOperationValue.BooleanValue(false); + default -> throw invalid(); + }; + } + + private static PkiOperationValue.ObjectValue readObject(JsonParser parser) { + Map fields = new LinkedHashMap<>(); + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.currentToken() != JsonToken.PROPERTY_NAME || fields.size() >= MAXIMUM_CONTAINER_ENTRIES) { + throw invalid(); + } + String name = parser.currentName(); + JsonToken valueToken = parser.nextToken(); + if (valueToken == null || fields.putIfAbsent(name, readValue(parser, valueToken)) != null) { + throw invalid(); + } + } + return new PkiOperationValue.ObjectValue(fields); + } + + private static PkiOperationValue.ListValue readArray(JsonParser parser) { + List values = new ArrayList<>(); + JsonToken token; + while ((token = parser.nextToken()) != JsonToken.END_ARRAY) { + if (token == null || values.size() >= MAXIMUM_CONTAINER_ENTRIES) { + throw invalid(); + } + values.add(readValue(parser, token)); + } + return new PkiOperationValue.ListValue(values); + } + + private static JsonFactory createFactory() { + StreamReadConstraints constraints = StreamReadConstraints.builder().maxNestingDepth(MAXIMUM_DEPTH) + .maxDocumentLength(MAXIMUM_DOCUMENT_BYTES).maxTokenCount(32_768).maxNumberLength(20) + .maxStringLength(MAXIMUM_STRING_LENGTH).maxNameLength(64).build(); + JsonFactoryBuilder builder = JsonFactory.builder().streamReadConstraints(constraints) + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).disable(StreamReadFeature.AUTO_CLOSE_SOURCE); + for (JsonReadFeature feature : JsonReadFeature.values()) { + builder.disable(feature); + } + return builder.build(); + } + + private static boolean hasBom(byte[] document) { + return document.length >= 3 && document[0] == (byte) 0xef && document[1] == (byte) 0xbb + && document[2] == (byte) 0xbf; + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("CLI JSON document is invalid"); + } + + private static IllegalArgumentException invalid(JacksonException failure) { + return new IllegalArgumentException("CLI JSON document is invalid", failure); + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiExitCodes.java b/app/src/main/java/zeroecho/pki/cli/PkiExitCodes.java new file mode 100644 index 0000000..bea4461 --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiExitCodes.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import zeroecho.pki.application.PkiOperationFailure; + +/** Stable process exit-code mapping for the PKI command family. */ +final class PkiExitCodes { + + /* default */ static final int SUCCESS = 0; + /* default */ static final int INVALID_INVOCATION = 2; + /* default */ static final int CONFIGURATION_FAILURE = 3; + /* default */ static final int VALIDATION_OR_POLICY = 4; + /* default */ static final int NOT_FOUND_OR_CONFLICT = 5; + /* default */ static final int RECOVERY_REQUIRED = 6; + /* default */ static final int EXTERNAL_OUTCOME_UNKNOWN = 7; + /* default */ static final int RESOURCE_FAILURE = 8; + /* default */ static final int INTERNAL_FAILURE = 9; + /* default */ static final int CANCELLED = 130; + + private PkiExitCodes() { + } + + /* default */ static int from(PkiPlanExecution execution) { + int result = execution.planFailure().map(PkiExitCodes::from).orElse(SUCCESS); + for (PkiPlanExecution.StepResult step : execution.steps()) { + if (step.failure().isPresent()) { + result = Math.max(result, from(step.failure().orElseThrow())); + } + } + return result == SUCCESS && !execution.succeeded() ? VALIDATION_OR_POLICY : result; + } + + private static int from(PkiOperationFailure failure) { + return switch (failure) { + case VALIDATION_FAILURE, POLICY_REJECTION -> VALIDATION_OR_POLICY; + case NOT_FOUND, CONFLICT -> NOT_FOUND_OR_CONFLICT; + case RECOVERY_REQUIRED -> RECOVERY_REQUIRED; + case EXTERNAL_OUTCOME_UNKNOWN -> EXTERNAL_OUTCOME_UNKNOWN; + case RESOURCE_FAILURE -> RESOURCE_FAILURE; + case INTERNAL_FAILURE -> INTERNAL_FAILURE; + case CANCELLED -> CANCELLED; + }; + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java new file mode 100644 index 0000000..39ea0b9 --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java @@ -0,0 +1,145 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.application.PkiOperation; +import zeroecho.pki.application.PkiOperationValue; + +/** Explicit stable operation-name to typed-request binding registry. */ +@SuppressWarnings("PMD.UnusedFormalParameter") +final class PkiOperationRegistry { + + private static final String CREDENTIAL_ID = "credentialId"; + + private final Map bindings; + + /* default */ PkiOperationRegistry() { + Map configured = new LinkedHashMap<>(); + add(configured, PkiOperation.ValidateConfiguration.NAME, this::configuration); + add(configured, PkiOperation.ValidateProfile.NAME, this::profile); + add(configured, PkiOperation.InspectCredential.NAME, this::credential); + add(configured, PkiOperation.RevokeCredential.NAME, this::revoke); + add(configured, PkiOperation.ReadRevocationHistory.NAME, this::history); + add(configured, PkiOperation.InspectPublication.NAME, this::publication); + bindings = Map.copyOf(configured); + } + + /* default */ Set names() { + return bindings.keySet(); + } + + /* default */ PkiOperation bind(String name, PkiOperationValue.ObjectValue arguments, Path baseDirectory) + throws IOException { + Binding binding = bindings.get(name); + if (binding == null) { + throw new IllegalArgumentException("Unknown PKI operation"); + } + return binding.bind(arguments, baseDirectory); + } + + private PkiOperation configuration(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of()); + return new PkiOperation.ValidateConfiguration(); + } + + private PkiOperation profile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) throws IOException { + requireFields(arguments, Set.of("profileFile")); + Path path = resolve(baseDirectory, text(arguments, "profileFile")); + byte[] document = PkiCliFiles.readRegularFile(path, CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES); + return new PkiOperation.ValidateProfile(document); + } + + private PkiOperation credential(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(CREDENTIAL_ID)); + return new PkiOperation.InspectCredential(new PkiId(text(arguments, CREDENTIAL_ID))); + } + + private PkiOperation revoke(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(CREDENTIAL_ID, "reason")); + RevocationReason reason = RevocationReason.valueOf(text(arguments, "reason")); + return new PkiOperation.RevokeCredential(new PkiId(text(arguments, CREDENTIAL_ID)), reason); + } + + private PkiOperation history(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(CREDENTIAL_ID, "limit")); + int limit = Math.toIntExact(integer(arguments, "limit")); + return new PkiOperation.ReadRevocationHistory(new PkiId(text(arguments, CREDENTIAL_ID)), limit); + } + + private PkiOperation publication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of("publicationId")); + return new PkiOperation.InspectPublication(new PkiId(text(arguments, "publicationId"))); + } + + private static void add(Map target, String name, Binding binding) { + if (target.putIfAbsent(name, binding) != null) { + throw new IllegalStateException("Duplicate PKI operation identity"); + } + } + + private static void requireFields(PkiOperationValue.ObjectValue arguments, Set expected) { + if (!arguments.fields().keySet().equals(expected)) { + throw new IllegalArgumentException("PKI operation arguments are invalid"); + } + } + + private static String text(PkiOperationValue.ObjectValue arguments, String name) { + return PkiCliConfiguration.text(PkiCliConfiguration.required(arguments, name)); + } + + private static long integer(PkiOperationValue.ObjectValue arguments, String name) { + return PkiCliConfiguration.integer(PkiCliConfiguration.required(arguments, name)); + } + + private static Path resolve(Path baseDirectory, String value) { + Path supplied = Path.of(value); + return (supplied.isAbsolute() ? supplied : baseDirectory.resolve(supplied)).toAbsolutePath().normalize(); + } + + /** One explicit typed binding. */ + @FunctionalInterface + private interface Binding { + /** Binds validated structured arguments to one typed operation. */ + PkiOperation bind(PkiOperationValue.ObjectValue arguments, Path baseDirectory) throws IOException; + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOutputMode.java b/app/src/main/java/zeroecho/pki/cli/PkiOutputMode.java new file mode 100644 index 0000000..28a0554 --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiOutputMode.java @@ -0,0 +1,40 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +/** Supported administrator output representations. */ +enum PkiOutputMode { + HUMAN, + JSON +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOutputRenderer.java b/app/src/main/java/zeroecho/pki/cli/PkiOutputRenderer.java new file mode 100644 index 0000000..13e6676 --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiOutputRenderer.java @@ -0,0 +1,188 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.ObjectWriteContext; +import tools.jackson.core.json.JsonFactory; +import zeroecho.pki.application.PkiOperationResult; +import zeroecho.pki.application.PkiOperationValue; + +/** Deterministic safe human and versioned machine result renderer. */ +final class PkiOutputRenderer { + + private static final JsonFactory JSON_FACTORY = JsonFactory.builder().build(); + + /* default */ void render(PkiPlanExecution execution, PkiOutputMode mode, boolean batch, OutputStream output) + throws IOException { + if (mode == PkiOutputMode.JSON) { + renderJson(execution, batch, output); + } else { + renderHuman(execution, output); + } + } + + /* default */ void renderFailure(PkiOutputMode mode, String category, String code, OutputStream output) + throws IOException { + if (mode == PkiOutputMode.HUMAN) { + PrintWriter writer = new PrintWriter(output, false, StandardCharsets.UTF_8); + writer.println("PKI command: FAILED"); + writer.println("category: " + category); + writer.println("code: " + code); + writer.flush(); + return; + } + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + try (JsonGenerator generator = JSON_FACTORY.createGenerator(ObjectWriteContext.empty(), encoded)) { + generator.writeStartObject(); + generator.writeNumberProperty("version", 1); + generator.writeStringProperty("status", "FAILED"); + generator.writeStringProperty("category", category); + generator.writeStringProperty("code", code); + generator.writeEndObject(); + } catch (JacksonException failure) { + throw new IOException("Machine output generation failed", failure); + } + output.write(encoded.toByteArray()); + output.write('\n'); + output.flush(); + } + + private static void renderHuman(PkiPlanExecution execution, OutputStream output) { + PrintWriter writer = new PrintWriter(output, false, StandardCharsets.UTF_8); + writer.println("PKI plan: " + (execution.succeeded() ? "SUCCEEDED" : "FAILED")); + if (execution.planFailure().isPresent()) { + writer.println("code: " + execution.planCode().orElseThrow()); + } + for (PkiPlanExecution.StepResult step : execution.steps()) { + writer.println(step.id() + " " + step.operation() + " " + step.status()); + if (step.output().isPresent()) { + for (Map.Entry field : step.output().orElseThrow().fields().entrySet()) { + writer.println(" " + field.getKey() + ": " + human(field.getValue())); + } + } else { + step.code().ifPresent(code -> writer.println(" code: " + code)); + } + } + writer.flush(); + } + + private static void renderJson(PkiPlanExecution execution, boolean batch, OutputStream output) throws IOException { + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + try (JsonGenerator generator = JSON_FACTORY.createGenerator(ObjectWriteContext.empty(), encoded)) { + generator.writeStartObject(); + generator.writeNumberProperty("version", 1); + generator.writeStringProperty("mode", batch ? "batch" : "single"); + generator.writeStringProperty("status", execution.succeeded() ? "SUCCEEDED" : "FAILED"); + if (execution.planFailure().isPresent()) { + generator.writeStringProperty("failure", execution.planFailure().orElseThrow().name()); + generator.writeStringProperty("code", execution.planCode().orElseThrow()); + } + generator.writeArrayPropertyStart("steps"); + for (PkiPlanExecution.StepResult step : execution.steps()) { + writeStep(generator, step); + } + generator.writeEndArray(); + generator.writeEndObject(); + } catch (JacksonException failure) { + throw new IOException("Machine output generation failed", failure); + } + output.write(encoded.toByteArray()); + output.write('\n'); + output.flush(); + } + + private static void writeStep(JsonGenerator generator, PkiPlanExecution.StepResult step) { + generator.writeStartObject(); + generator.writeStringProperty("id", step.id()); + generator.writeStringProperty("operation", step.operation()); + generator.writeStringProperty("status", step.status().name()); + if (step.output().isPresent()) { + generator.writeObjectPropertyStart("output"); + PkiOperationResult result = step.output().orElseThrow(); + for (Map.Entry field : result.fields().entrySet()) { + generator.writeName(field.getKey()); + writeValue(generator, field.getValue()); + } + generator.writeEndObject(); + } else { + if (step.failure().isPresent()) { + generator.writeStringProperty("failure", step.failure().orElseThrow().name()); + } + generator.writeStringProperty("code", step.code().orElseThrow()); + } + generator.writeEndObject(); + } + + private static void writeValue(JsonGenerator generator, PkiOperationValue value) { + switch (value) { + case PkiOperationValue.Text text -> generator.writeString(text.value()); + case PkiOperationValue.IntegerValue integer -> generator.writeNumber(integer.value()); + case PkiOperationValue.BooleanValue bool -> generator.writeBoolean(bool.value()); + case PkiOperationValue.ObjectValue object -> { + generator.writeStartObject(); + for (Map.Entry entry : object.fields().entrySet()) { + generator.writeName(entry.getKey()); + writeValue(generator, entry.getValue()); + } + generator.writeEndObject(); + } + case PkiOperationValue.ListValue list -> { + generator.writeStartArray(); + for (PkiOperationValue entry : list.values()) { + writeValue(generator, entry); + } + generator.writeEndArray(); + } + } + } + + private static String human(PkiOperationValue value) { + return switch (value) { + case PkiOperationValue.Text text -> text.value(); + case PkiOperationValue.IntegerValue integer -> Long.toString(integer.value()); + case PkiOperationValue.BooleanValue bool -> Boolean.toString(bool.value()); + case PkiOperationValue.ObjectValue object -> "object(" + object.fields().size() + ")"; + case PkiOperationValue.ListValue list -> "list(" + list.values().size() + ")"; + }; + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiPlan.java b/app/src/main/java/zeroecho/pki/cli/PkiPlan.java new file mode 100644 index 0000000..03725d6 --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiPlan.java @@ -0,0 +1,70 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +import zeroecho.pki.application.PkiOperationValue; + +/** Immutable validated sequential operation plan. */ +record PkiPlan(int version, FailurePolicy failurePolicy, List steps, Path baseDirectory) { + + /* default */ static final int CURRENT_VERSION = 1; + + PkiPlan { + if (version != CURRENT_VERSION || steps == null || steps.isEmpty()) { + throw new IllegalArgumentException("Plan version or steps are invalid"); + } + Objects.requireNonNull(failurePolicy, "failurePolicy"); + steps = List.copyOf(steps); + baseDirectory = Objects.requireNonNull(baseDirectory, "baseDirectory").toAbsolutePath().normalize(); + } + + /** Supported sequential failure policies. */ + /* default */ enum FailurePolicy { + FAIL_FAST, + CONTINUE_INDEPENDENT + } + + /** One validated ordered plan step. */ + /* default */ record Step(String id, String operation, PkiOperationValue.ObjectValue arguments) { + Step { + if (id == null || operation == null || arguments == null) { + throw new IllegalArgumentException("Plan step is incomplete"); + } + } + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiPlanExecution.java b/app/src/main/java/zeroecho/pki/cli/PkiPlanExecution.java new file mode 100644 index 0000000..24cb99e --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiPlanExecution.java @@ -0,0 +1,115 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.pki.application.PkiOperationFailure; +import zeroecho.pki.application.PkiOperationResult; + +/** Ordered complete result of one single-command or batch plan execution. */ +record PkiPlanExecution(List steps, Optional planFailure, Optional planCode) { + + /* default */ PkiPlanExecution { + steps = List.copyOf(steps); + Objects.requireNonNull(planFailure, "planFailure"); + Objects.requireNonNull(planCode, "planCode"); + if (steps.isEmpty()) { + throw new IllegalArgumentException("execution requires at least one step"); + } + if (planFailure.isPresent() != planCode.isPresent()) { + throw new IllegalArgumentException("plan failure state is inconsistent"); + } + } + + /* default */ PkiPlanExecution(List steps) { + this(steps, Optional.empty(), Optional.empty()); + } + + /* default */ boolean succeeded() { + return planFailure.isEmpty() && steps.stream().allMatch(step -> step.status() == StepStatus.SUCCEEDED); + } + + /* default */ PkiPlanExecution withPlanFailure(PkiOperationFailure failure, String code) { + return new PkiPlanExecution(steps, Optional.of(failure), Optional.of(code)); + } + + /** Step completion state. */ + /* default */ enum StepStatus { + SUCCEEDED, + FAILED, + SKIPPED + } + + /** One safe ordered step result. */ + /* default */ record StepResult(String id, String operation, StepStatus status, + Optional output, + Optional failure, Optional code) { + StepResult { + if (id == null || id.isBlank() || operation == null || operation.isBlank()) { + throw new IllegalArgumentException("step result identity is invalid"); + } + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(output, "output"); + Objects.requireNonNull(failure, "failure"); + Objects.requireNonNull(code, "code"); + boolean valid = switch (status) { + case SUCCEEDED -> output.isPresent() && failure.isEmpty() && code.isEmpty(); + case FAILED -> output.isEmpty() && failure.isPresent() && code.isPresent(); + case SKIPPED -> output.isEmpty() && failure.isEmpty() && code.isPresent(); + }; + if (!valid) { + throw new IllegalArgumentException("step result state is inconsistent"); + } + } + + /* default */ static StepResult success(String id, String operation, PkiOperationResult result) { + return new StepResult(id, operation, StepStatus.SUCCEEDED, Optional.of(result), Optional.empty(), + Optional.empty()); + } + + /* default */ static StepResult failure(String id, String operation, PkiOperationFailure failure, + String code) { + return new StepResult(id, operation, StepStatus.FAILED, Optional.empty(), Optional.of(failure), + Optional.of(code)); + } + + /* default */ static StepResult skipped(String id, String operation, String code) { + return new StepResult(id, operation, StepStatus.SKIPPED, Optional.empty(), Optional.empty(), + Optional.of(code)); + } + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiPlanExecutor.java b/app/src/main/java/zeroecho/pki/cli/PkiPlanExecutor.java new file mode 100644 index 0000000..6f429fd --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiPlanExecutor.java @@ -0,0 +1,198 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Objects; +import java.util.regex.Matcher; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.application.PkiOperation; +import zeroecho.pki.application.PkiOperationFailure; +import zeroecho.pki.application.PkiOperationOutcome; +import zeroecho.pki.application.PkiOperationResult; +import zeroecho.pki.application.PkiOperationValue; +import zeroecho.pki.application.PkiSession; + +/** Sequential shared executor used by direct commands and batch plans. */ +final class PkiPlanExecutor { + + private final PkiOperationRegistry registry; + + /* default */ PkiPlanExecutor(PkiOperationRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry"); + } + + /* default */ PkiPlanExecution execute(PkiPlan plan, PkiSession session, CancellationSignal cancellation) { + List results = new ArrayList<>(plan.steps().size()); + Map successful = new LinkedHashMap<>(); + Map states = new LinkedHashMap<>(); + boolean failFast = false; + boolean cancelled = false; + for (PkiPlan.Step step : plan.steps()) { + if (failFast) { + addSkipped(results, states, step, "FAIL_FAST"); + continue; + } + if (cancelled) { + addSkipped(results, states, step, "CANCELLED"); + continue; + } + if (cancellation.isCancelled()) { + PkiPlanExecution.StepResult result = PkiPlanExecution.StepResult.failure(step.id(), step.operation(), + PkiOperationFailure.CANCELLED, "OPERATION_CANCELLED"); + results.add(result); + states.put(step.id(), result.status()); + cancelled = true; + continue; + } + Resolution resolution; + try { + resolution = resolve(step.arguments(), successful, states); + } catch (IllegalArgumentException failure) { + PkiPlanExecution.StepResult result = PkiPlanExecution.StepResult.failure(step.id(), step.operation(), + PkiOperationFailure.VALIDATION_FAILURE, "REFERENCE_FIELD_INVALID"); + results.add(result); + states.put(step.id(), result.status()); + if (plan.failurePolicy() == PkiPlan.FailurePolicy.FAIL_FAST) { + failFast = true; + } + continue; + } + if (resolution.dependencyUnavailable()) { + addSkipped(results, states, step, "DEPENDENCY_UNAVAILABLE"); + continue; + } + PkiPlanExecution.StepResult result = executeStep(step, resolution.value(), plan, session, cancellation); + results.add(result); + states.put(step.id(), result.status()); + result.output().ifPresent(value -> successful.put(step.id(), value)); + if (result.failure().filter(value -> value == PkiOperationFailure.CANCELLED).isPresent()) { + cancelled = true; + } + if (result.status() == PkiPlanExecution.StepStatus.FAILED + && plan.failurePolicy() == PkiPlan.FailurePolicy.FAIL_FAST) { + failFast = true; + } + } + return new PkiPlanExecution(results); + } + + private PkiPlanExecution.StepResult executeStep(PkiPlan.Step step, PkiOperationValue value, PkiPlan plan, + PkiSession session, CancellationSignal cancellation) { + try { + PkiOperation operation = registry.bind(step.operation(), PkiCliConfiguration.object(value), + plan.baseDirectory()); + PkiOperationOutcome outcome = session.operations().execute(operation, cancellation); + if (outcome instanceof PkiOperationOutcome.Success success) { + return PkiPlanExecution.StepResult.success(step.id(), step.operation(), success.result()); + } + PkiOperationOutcome.Failure failure = (PkiOperationOutcome.Failure) outcome; + return PkiPlanExecution.StepResult.failure(step.id(), step.operation(), failure.classification(), + failure.code()); + } catch (IllegalArgumentException failure) { + return PkiPlanExecution.StepResult.failure(step.id(), step.operation(), + PkiOperationFailure.VALIDATION_FAILURE, "OPERATION_ARGUMENTS_INVALID"); + } catch (IOException failure) { + return PkiPlanExecution.StepResult.failure(step.id(), step.operation(), + PkiOperationFailure.RESOURCE_FAILURE, "OPERATION_INPUT_FAILED"); + } catch (RuntimeException failure) { // NOPMD - application boundary redacts unexpected provider details. + return PkiPlanExecution.StepResult.failure(step.id(), step.operation(), + PkiOperationFailure.INTERNAL_FAILURE, "INTERNAL_FAILURE"); + } + } + + private static Resolution resolve(PkiOperationValue value, Map successful, + Map states) { + if (value instanceof PkiOperationValue.Text text) { + Matcher matcher = PkiPlanParser.REFERENCE.matcher(text.value()); + if (!matcher.matches()) { + return Resolution.available(value); + } + String stepId = matcher.group(1); + if (states.get(stepId) != PkiPlanExecution.StepStatus.SUCCEEDED) { + return Resolution.unavailable(); + } + Optional field = successful.get(stepId).field(matcher.group(2)); + if (field.isEmpty()) { + throw new IllegalArgumentException("Referenced output field does not exist"); + } + return Resolution.available(field.orElseThrow()); + } + if (value instanceof PkiOperationValue.ObjectValue object) { + Map fields = new LinkedHashMap<>(); + for (Map.Entry entry : object.fields().entrySet()) { + Resolution child = resolve(entry.getValue(), successful, states); + if (child.dependencyUnavailable()) { + return Resolution.unavailable(); + } + fields.put(entry.getKey(), child.value()); + } + return Resolution.available(new PkiOperationValue.ObjectValue(fields)); + } + if (value instanceof PkiOperationValue.ListValue list) { + List values = new ArrayList<>(list.values().size()); + for (PkiOperationValue item : list.values()) { + Resolution child = resolve(item, successful, states); + if (child.dependencyUnavailable()) { + return Resolution.unavailable(); + } + values.add(child.value()); + } + return Resolution.available(new PkiOperationValue.ListValue(values)); + } + return Resolution.available(value); + } + + private static void addSkipped(List results, + Map states, PkiPlan.Step step, String code) { + results.add(PkiPlanExecution.StepResult.skipped(step.id(), step.operation(), code)); + states.put(step.id(), PkiPlanExecution.StepStatus.SKIPPED); + } + + private record Resolution(PkiOperationValue value, boolean dependencyUnavailable) { + /* default */ static Resolution available(PkiOperationValue value) { + return new Resolution(value, false); + } + + /* default */ static Resolution unavailable() { + return new Resolution(new PkiOperationValue.BooleanValue(false), true); + } + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/PkiPlanParser.java b/app/src/main/java/zeroecho/pki/cli/PkiPlanParser.java new file mode 100644 index 0000000..57efafa --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/PkiPlanParser.java @@ -0,0 +1,150 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import zeroecho.pki.application.PkiOperationValue; + +/** Strict parser and reference-order validator for plan schema version 1. */ +final class PkiPlanParser { + + /* default */ static final int MAXIMUM_STEPS = 1_000; + /* default */ static final Pattern REFERENCE = Pattern + .compile("\\$\\{([a-z][a-z0-9-]{0,63})\\.([a-z][a-zA-Z0-9]{0,63})}"); + + private static final Pattern STEP_ID = Pattern.compile("[a-z][a-z0-9-]{0,63}"); + private static final Pattern OPERATION_NAME = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + private static final Set ROOT_FIELDS = Set.of("version", "failurePolicy", "operations"); + private static final Set STEP_FIELDS = Set.of("id", "operation", "arguments"); + + private PkiPlanParser() { + } + + /* default */ static PkiPlan read(Path path, Set supportedOperations) throws IOException { + Path exact = path.toAbsolutePath().normalize(); + PkiOperationValue.ObjectValue root = PkiCliConfiguration + .object(PkiCliJson.parse(PkiCliFiles.readRegularFile(exact, PkiCliJson.MAXIMUM_DOCUMENT_BYTES))); + PkiCliConfiguration.requireExactFields(root.fields(), ROOT_FIELDS); + int version = Math.toIntExact(PkiCliConfiguration.integer(PkiCliConfiguration.required(root, "version"))); + if (version != PkiPlan.CURRENT_VERSION) { + throw new IllegalArgumentException("Plan version is unsupported"); + } + PkiPlan.FailurePolicy policy = parsePolicy(PkiCliConfiguration.text( + PkiCliConfiguration.required(root, "failurePolicy"))); + List encodedSteps = list(PkiCliConfiguration.required(root, "operations")); + if (encodedSteps.isEmpty() || encodedSteps.size() > MAXIMUM_STEPS) { + throw new IllegalArgumentException("Plan step count is invalid"); + } + List steps = new ArrayList<>(encodedSteps.size()); + Set ids = new HashSet<>(); + Map positions = new HashMap<>(); + for (int index = 0; index < encodedSteps.size(); index++) { + PkiPlan.Step step = parseStep(encodedSteps.get(index), supportedOperations); + if (!ids.add(step.id())) { + throw new IllegalArgumentException("Plan step identifiers must be unique"); + } + positions.put(step.id(), index); + steps.add(step); + } + validateReferences(steps, positions); + Path parent = exact.getParent(); + return new PkiPlan(version, policy, steps, parent == null ? Path.of(".") : parent); + } + + private static PkiPlan.Step parseStep(PkiOperationValue value, Set supportedOperations) { + PkiOperationValue.ObjectValue object = PkiCliConfiguration.object(value); + PkiCliConfiguration.requireExactFields(object.fields(), STEP_FIELDS); + String id = PkiCliConfiguration.text(PkiCliConfiguration.required(object, "id")); + String operation = PkiCliConfiguration.text(PkiCliConfiguration.required(object, "operation")); + if (!STEP_ID.matcher(id).matches() || !OPERATION_NAME.matcher(operation).matches() + || !supportedOperations.contains(operation)) { + throw new IllegalArgumentException("Plan step identity or operation is invalid"); + } + PkiOperationValue.ObjectValue arguments = PkiCliConfiguration + .object(PkiCliConfiguration.required(object, "arguments")); + return new PkiPlan.Step(id, operation, arguments); + } + + private static void validateReferences(List steps, Map positions) { + for (int index = 0; index < steps.size(); index++) { + validateValueReferences(steps.get(index).arguments(), index, positions); + } + } + + @SuppressWarnings("PMD.AvoidDeeplyNestedIfStmts") + private static void validateValueReferences(PkiOperationValue value, int current, + Map positions) { + if (value instanceof PkiOperationValue.Text text) { + String raw = text.value(); + Matcher matcher = REFERENCE.matcher(raw); + if (matcher.matches()) { + Integer referenced = positions.get(matcher.group(1)); + if (referenced == null || referenced >= current) { + throw new IllegalArgumentException("Plan reference must target an earlier step"); + } + } else if (raw.contains("${")) { + throw new IllegalArgumentException("Plan interpolation is not supported"); + } + } else if (value instanceof PkiOperationValue.ObjectValue object) { + for (PkiOperationValue child : object.fields().values()) { + validateValueReferences(child, current, positions); + } + } else if (value instanceof PkiOperationValue.ListValue list) { + for (PkiOperationValue child : list.values()) { + validateValueReferences(child, current, positions); + } + } + } + + private static PkiPlan.FailurePolicy parsePolicy(String value) { + return PkiPlan.FailurePolicy.valueOf(value); + } + + private static List list(PkiOperationValue value) { + if (value instanceof PkiOperationValue.ListValue list) { + return list.values(); + } + throw new IllegalArgumentException("CLI field has the wrong type"); + } +} diff --git a/app/src/main/java/zeroecho/pki/cli/package-info.java b/app/src/main/java/zeroecho/pki/cli/package-info.java new file mode 100644 index 0000000..0eda304 --- /dev/null +++ b/app/src/main/java/zeroecho/pki/cli/package-info.java @@ -0,0 +1,36 @@ +/******************************************************************************* + * Copyright (C) 2026, 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. + ******************************************************************************/ + +/** Strict PKI command, plan, binding and output adapters owned by the application module. */ +package zeroecho.pki.cli; diff --git a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java new file mode 100644 index 0000000..f71d2ae --- /dev/null +++ b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java @@ -0,0 +1,311 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.ProfileService; +import zeroecho.pki.api.RevocationService; +import zeroecho.pki.application.PkiOperation; +import zeroecho.pki.application.PkiOperationFailure; +import zeroecho.pki.application.PkiOperationOutcome; +import zeroecho.pki.application.PkiOperationResult; +import zeroecho.pki.application.PkiOperationValue; +import zeroecho.pki.application.PkiSession; +import zeroecho.pki.application.PkiSessionConfiguration; + +class PkiCliTest { + + @TempDir + Path temporaryDirectory; + + @Test + void productionSessionExecutesMachineAndHumanSingleCommands() throws IOException { + System.out.println("productionSessionExecutesMachineAndHumanSingleCommands"); + Path configuration = configuration("production"); + ByteArrayOutputStream machine = new ByteArrayOutputStream(); + int first = PkiCli.execute(new String[] { "configuration.validate", "--config", configuration.toString(), + "--output", "json" }, machine); + ByteArrayOutputStream human = new ByteArrayOutputStream(); + int second = PkiCli.execute(new String[] { "configuration.validate", "--config", configuration.toString() }, + human); + String json = machine.toString(StandardCharsets.UTF_8); + System.out.println("...machineBytes=" + json.length()); + assertEquals(0, first); + assertEquals(0, second); + assertTrue(json.contains("\"mode\":\"single\"")); + assertTrue(json.contains("\"storeProvider\":\"fs\"")); + assertTrue(human.toString(StandardCharsets.UTF_8).contains("configuration.validate SUCCEEDED")); + System.out.println("...ok"); + } + + @Test + void singleAndBatchUseTheSameTypedExecutorAndSessionLifecycle() throws IOException { + System.out.println("singleAndBatchUseTheSameTypedExecutorAndSessionLifecycle"); + Path configuration = configuration("shared"); + Path plan = plan("shared.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"validate","operation":"configuration.validate","arguments":{}} + ]} + """); + RecordingOpener opener = new RecordingOpener(); + ByteArrayOutputStream single = new ByteArrayOutputStream(); + int singleCode = PkiCli.execute(new String[] { "configuration.validate", "--config", + configuration.toString(), "--output", "json" }, single, opener, () -> false); + ByteArrayOutputStream batch = new ByteArrayOutputStream(); + int batchCode = PkiCli.execute(new String[] { "run", plan.toString(), "--config", configuration.toString(), + "--output", "json" }, batch, opener, () -> false); + System.out.println("...sessions=" + opener.sessions.get()); + assertEquals(0, singleCode); + assertEquals(0, batchCode); + assertEquals(List.of(PkiOperation.ValidateConfiguration.class, PkiOperation.ValidateConfiguration.class), + opener.operationTypes); + assertEquals(2, opener.sessions.get()); + assertEquals(2, opener.closes.get()); + System.out.println("...ok"); + } + + @Test + void directMutationUsesTheSharedTypedExecutor() throws IOException { + System.out.println("directMutationUsesTheSharedTypedExecutor"); + Path configuration = configuration("mutation"); + RecordingOpener opener = new RecordingOpener(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int code = PkiCli.execute(new String[] { "credential.revoke", "--credential-id", "credential-1", + "--reason", "KEY_COMPROMISE", "--config", configuration.toString(), "--output", "json" }, + output, opener, () -> false); + String rendered = output.toString(StandardCharsets.UTF_8); + System.out.println("...exit=" + code); + assertEquals(PkiExitCodes.SUCCESS, code); + assertEquals(List.of(PkiOperation.RevokeCredential.class), opener.operationTypes); + assertTrue(rendered.contains("\"state\":\"PERMANENTLY_REVOKED\"")); + assertEquals(1, opener.closes.get()); + System.out.println("...ok"); + } + + @Test + void resolvesEarlierFieldsAndContinuesOnlyIndependentSteps() throws IOException { + System.out.println("resolvesEarlierFieldsAndContinuesOnlyIndependentSteps"); + Path configuration = configuration("references"); + Path plan = plan("references.json", """ + {"version":1,"failurePolicy":"CONTINUE_INDEPENDENT","operations":[ + {"id":"config","operation":"configuration.validate","arguments":{}}, + {"id":"inspect","operation":"credential.inspect","arguments":{"credentialId":"${config.storeProvider}"}}, + {"id":"unknown-field","operation":"credential.inspect","arguments":{"credentialId":"${config.absentField}"}}, + {"id":"missing","operation":"publication.inspect","arguments":{"publicationId":"missing"}}, + {"id":"independent","operation":"configuration.validate","arguments":{}}, + {"id":"dependent","operation":"credential.inspect","arguments":{"credentialId":"${missing.sourceId}"}} + ]} + """); + RecordingOpener opener = new RecordingOpener(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int code = PkiCli.execute(new String[] { "run", plan.toString(), "--config", configuration.toString(), + "--output", "json" }, output, opener, () -> false); + String json = output.toString(StandardCharsets.UTF_8); + System.out.println("...exit=" + code); + assertEquals(PkiExitCodes.NOT_FOUND_OR_CONFLICT, code); + assertTrue(json.contains("\"id\":\"independent\",\"operation\":\"configuration.validate\",\"status\":\"SUCCEEDED\"")); + assertTrue(json.contains("\"id\":\"unknown-field\",\"operation\":\"credential.inspect\",\"status\":\"FAILED\"")); + assertTrue(json.contains("\"id\":\"dependent\",\"operation\":\"credential.inspect\",\"status\":\"SKIPPED\"")); + assertEquals(1, opener.sessions.get()); + assertEquals(1, opener.closes.get()); + System.out.println("...ok"); + } + + @Test + void rejectsFutureReferencesScriptsInterpolationAndSecretArgumentsBeforeOpening() throws IOException { + System.out.println("rejectsFutureReferencesScriptsInterpolationAndSecretArgumentsBeforeOpening"); + Path configuration = configuration("hostile"); + Path plan = plan("hostile.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"first","operation":"credential.inspect","arguments":{"credentialId":"${later.credentialId}"}}, + {"id":"later","operation":"configuration.validate","arguments":{}} + ]} + """); + RecordingOpener opener = new RecordingOpener(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int future = PkiCli.execute(new String[] { "run", plan.toString(), "--config", configuration.toString(), + "--output", "json" }, output, opener, () -> false); + int script = PkiCli.execute(new String[] { "java.lang.Runtime", "--config", configuration.toString() }, + new ByteArrayOutputStream(), opener, () -> false); + int secret = PkiCli.execute(new String[] { "configuration.validate", "--config", configuration.toString(), + "--password", "do-not-print" }, new ByteArrayOutputStream(), opener, () -> false); + System.out.println("...rejections=3"); + assertEquals(PkiExitCodes.INVALID_INVOCATION, future); + assertEquals(PkiExitCodes.INVALID_INVOCATION, script); + assertEquals(PkiExitCodes.INVALID_INVOCATION, secret); + assertEquals(0, opener.sessions.get()); + assertFalse(output.toString(StandardCharsets.UTF_8).contains("later.credentialId")); + System.out.println("...ok"); + } + + @Test + void failFastCancellationAndCloseFailuresRemainExplicit() throws IOException { + System.out.println("failFastCancellationAndCloseFailuresRemainExplicit"); + Path configuration = configuration("failures"); + Path plan = plan("fail-fast.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"missing","operation":"publication.inspect","arguments":{"publicationId":"missing"}}, + {"id":"later","operation":"configuration.validate","arguments":{}} + ]} + """); + RecordingOpener opener = new RecordingOpener(); + ByteArrayOutputStream failed = new ByteArrayOutputStream(); + int failFast = PkiCli.execute(new String[] { "run", plan.toString(), "--config", configuration.toString(), + "--output", "json" }, failed, opener, () -> false); + ByteArrayOutputStream cancelled = new ByteArrayOutputStream(); + int cancel = PkiCli.execute(new String[] { "configuration.validate", "--config", configuration.toString(), + "--output", "json" }, cancelled, opener, () -> true); + opener.failClose = true; + ByteArrayOutputStream close = new ByteArrayOutputStream(); + int closeCode = PkiCli.execute(new String[] { "configuration.validate", "--config", + configuration.toString(), "--output", "json" }, close, opener, () -> false); + System.out.println("...codes=" + failFast + "," + cancel + "," + closeCode); + assertEquals(PkiExitCodes.NOT_FOUND_OR_CONFLICT, failFast); + assertTrue(failed.toString(StandardCharsets.UTF_8).contains("\"code\":\"FAIL_FAST\"")); + assertEquals(PkiExitCodes.CANCELLED, cancel); + assertEquals(PkiExitCodes.RESOURCE_FAILURE, closeCode); + assertTrue(close.toString(StandardCharsets.UTF_8).contains("SESSION_CLOSE_FAILED")); + System.out.println("...ok"); + } + + @Test + void rejectsMalformedConfigurationAndNeverPrintsProviderExceptions() throws IOException { + System.out.println("rejectsMalformedConfigurationAndNeverPrintsProviderExceptions"); + Path malformed = plan("bad-config.json", + "{\"version\":1,\"store\":{},\"audit\":{},\"secret\":\"hidden\"}"); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int invalid = PkiCli.execute(new String[] { "configuration.validate", "--config", malformed.toString(), + "--output", "json" }, output, configuration -> { + throw new IllegalStateException("/sensitive/path provider-secret"); + }, () -> false); + Path valid = configuration("provider-failure"); + ByteArrayOutputStream providerOutput = new ByteArrayOutputStream(); + int providerFailure = PkiCli.execute(new String[] { "configuration.validate", "--config", valid.toString(), + "--output", "json" }, providerOutput, configuration -> { + throw new IllegalStateException("/sensitive/path provider-secret"); + }, () -> false); + String rendered = output.toString(StandardCharsets.UTF_8) + + providerOutput.toString(StandardCharsets.UTF_8); + assertEquals(PkiExitCodes.CONFIGURATION_FAILURE, invalid); + assertEquals(PkiExitCodes.RESOURCE_FAILURE, providerFailure); + assertFalse(rendered.contains("hidden")); + assertFalse(rendered.contains("sensitive")); + assertFalse(rendered.contains("provider-secret")); + System.out.println("...ok"); + } + + private Path configuration(String name) throws IOException { + Path store = temporaryDirectory.resolve(name + "-store"); + return plan(name + "-config.json", "{\"version\":1,\"store\":{\"provider\":\"fs\",\"properties\":{\"root\":\"" + + store + "\"}},\"audit\":{\"provider\":\"memory\",\"properties\":{\"size\":\"32\"}}}"); + } + + private Path plan(String name, String content) throws IOException { + Path path = temporaryDirectory.resolve(name); + Files.writeString(path, content, StandardCharsets.UTF_8); + return path; + } + + private static final class RecordingOpener implements PkiCli.SessionOpener { + private final AtomicInteger sessions = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + private final List> operationTypes = new java.util.ArrayList<>(); + private boolean failClose; + + @Override + public PkiSession open(PkiSessionConfiguration configuration) { + sessions.incrementAndGet(); + return new PkiSession() { + @Override + public ProfileService profiles() { + throw new UnsupportedOperationException(); + } + + @Override + public RevocationService revocations() { + throw new UnsupportedOperationException(); + } + + @Override + public zeroecho.pki.application.PkiOperationExecutor operations() { + return (operation, cancellation) -> outcome(operation); + } + + @Override + public void close() throws Exception { + closes.incrementAndGet(); + if (failClose) { + throw new IOException("sensitive-close-detail"); + } + } + }; + } + + private PkiOperationOutcome outcome(PkiOperation operation) { + operationTypes.add(operation.getClass()); + if (operation instanceof PkiOperation.InspectPublication) { + return new PkiOperationOutcome.Failure(operation.name(), PkiOperationFailure.NOT_FOUND, + "OBJECT_NOT_FOUND"); + } + LinkedHashMap fields = new LinkedHashMap<>(); + if (operation instanceof PkiOperation.ValidateConfiguration) { + fields.put("version", new PkiOperationValue.IntegerValue(1)); + fields.put("storeProvider", new PkiOperationValue.Text("fs")); + } else if (operation instanceof PkiOperation.InspectCredential inspect) { + fields.put("credentialId", new PkiOperationValue.Text(inspect.credentialId().value())); + } else if (operation instanceof PkiOperation.RevokeCredential revoke) { + fields.put("credentialId", new PkiOperationValue.Text(revoke.credentialId().value())); + fields.put("state", new PkiOperationValue.Text("PERMANENTLY_REVOKED")); + } else { + fields.put("status", new PkiOperationValue.Text("OK")); + } + return new PkiOperationOutcome.Success(new PkiOperationResult(operation.name(), fields)); + } + } +} diff --git a/app/src/test/java/zeroecho/pki/cli/PkiPlanParserTest.java b/app/src/test/java/zeroecho/pki/cli/PkiPlanParserTest.java new file mode 100644 index 0000000..11955f4 --- /dev/null +++ b/app/src/test/java/zeroecho/pki/cli/PkiPlanParserTest.java @@ -0,0 +1,119 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.cli; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PkiPlanParserTest { + + @TempDir + Path temporaryDirectory; + + @Test + void rejectsEmptyDuplicateAndUnknownOperationPlans() throws IOException { + System.out.println("rejectsEmptyDuplicateAndUnknownOperationPlans"); + PkiOperationRegistry registry = new PkiOperationRegistry(); + assertInvalid("empty.json", "{\"version\":1,\"failurePolicy\":\"FAIL_FAST\",\"operations\":[]}", registry); + assertInvalid("duplicate.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"same","operation":"configuration.validate","arguments":{}}, + {"id":"same","operation":"configuration.validate","arguments":{}} + ]} + """, registry); + assertInvalid("unknown.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"unknown","operation":"system.exec","arguments":{}} + ]} + """, registry); + System.out.println("...rejections=3"); + System.out.println("...ok"); + } + + @Test + void rejectsUnknownFutureAndEmbeddedReferences() throws IOException { + System.out.println("rejectsUnknownFutureAndEmbeddedReferences"); + PkiOperationRegistry registry = new PkiOperationRegistry(); + assertInvalid("unknown-reference.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"inspect","operation":"credential.inspect","arguments":{"credentialId":"${absent.id}"}} + ]} + """, registry); + assertInvalid("future-reference.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"inspect","operation":"credential.inspect","arguments":{"credentialId":"${later.id}"}}, + {"id":"later","operation":"configuration.validate","arguments":{}} + ]} + """, registry); + assertInvalid("embedded-reference.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"config","operation":"configuration.validate","arguments":{}}, + {"id":"inspect","operation":"credential.inspect","arguments":{"credentialId":"prefix-${config.storeProvider}"}} + ]} + """, registry); + System.out.println("...rejections=3"); + System.out.println("...ok"); + } + + @Test + void rejectsDuplicateJsonKeysAndTrailingDocuments() throws IOException { + System.out.println("rejectsDuplicateJsonKeysAndTrailingDocuments"); + PkiOperationRegistry registry = new PkiOperationRegistry(); + assertInvalid("duplicate-key.json", """ + {"version":1,"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"config","operation":"configuration.validate","arguments":{}} + ]} + """, registry); + assertInvalid("trailing.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"config","operation":"configuration.validate","arguments":{}} + ]} {} + """, registry); + System.out.println("...rejections=2"); + System.out.println("...ok"); + } + + private void assertInvalid(String name, String content, PkiOperationRegistry registry) throws IOException { + Path path = temporaryDirectory.resolve(name); + Files.writeString(path, content, StandardCharsets.UTF_8); + assertThrows(IllegalArgumentException.class, () -> PkiPlanParser.read(path, registry.names())); + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java new file mode 100644 index 0000000..5b6182e --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java @@ -0,0 +1,269 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.RevocationService; +import zeroecho.pki.api.credential.CaProfileBinding; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.EndEntityProfileBinding; +import zeroecho.pki.api.profile.CertificateProfileDefinition; +import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.publication.PublicationRecord; +import zeroecho.pki.api.revocation.RevocationCommand; +import zeroecho.pki.api.revocation.RevocationRecord; +import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.RevocationHistory; + +/** Explicit non-reflective executor used by one backend session. */ +final class DefaultPkiOperationExecutor implements PkiOperationExecutor { + + private final PkiSessionConfiguration configuration; + private final PkiStore store; + private final RevocationService revocations; + private final Runnable openCheck; + + /* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store, + RevocationService revocations, Runnable openCheck) { + this.configuration = Objects.requireNonNull(configuration, "configuration"); + this.store = Objects.requireNonNull(store, "store"); + this.revocations = Objects.requireNonNull(revocations, "revocations"); + this.openCheck = Objects.requireNonNull(openCheck, "openCheck"); + } + + @Override + public PkiOperationOutcome execute(PkiOperation operation, CancellationSignal cancellation) { + openCheck.run(); + PkiOperation exact = Objects.requireNonNull(operation, "operation"); + CancellationSignal signal = Objects.requireNonNull(cancellation, "cancellation"); + try { + signal.throwIfCancelled(); + PkiOperationResult result = switch (exact) { + case PkiOperation.ValidateConfiguration ignored -> validateConfiguration(); + case PkiOperation.ValidateProfile request -> validateProfile(request); + case PkiOperation.InspectCredential request -> inspectCredential(request); + case PkiOperation.RevokeCredential request -> revokeCredential(request); + case PkiOperation.ReadRevocationHistory request -> readHistory(request, signal); + case PkiOperation.InspectPublication request -> inspectPublication(request); + }; + return new PkiOperationOutcome.Success(result); + } catch (InterruptedIOException failure) { + return failure(exact.name(), PkiOperationFailure.CANCELLED, "OPERATION_CANCELLED"); + } catch (IOException failure) { + return failure(exact.name(), PkiOperationFailure.RESOURCE_FAILURE, "OPERATION_RESOURCE_FAILED"); + } catch (RuntimeException failure) { // NOPMD - normalize provider failures at the application boundary. + return classify(exact.name(), failure); + } + } + + private PkiOperationResult validateConfiguration() { + Map fields = fields(); + fields.put("version", integer(configuration.version())); + fields.put("storeProvider", text(configuration.store().backendId())); + fields.put("auditProvider", text(configuration.audit().backendId())); + return result(PkiOperation.ValidateConfiguration.NAME, fields); + } + + private static PkiOperationResult validateProfile(PkiOperation.ValidateProfile request) { + CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(request.document()); + Map fields = fields(); + fields.put("profileId", text(definition.profileId())); + fields.put("profileVersion", integer(definition.profileVersion())); + fields.put("certificateType", text(definition.certificateType().name())); + fields.put("formatId", text(definition.formatId().value())); + return result(request.name(), fields); + } + + private PkiOperationResult inspectCredential(PkiOperation.InspectCredential request) { + Credential credential = store.getCredential(request.credentialId()) + .orElseThrow(() -> new MissingObjectException()); + CertificateProfileRef profile = profileReference(credential); + Map fields = fields(); + fields.put("credentialId", text(credential.credentialId().value())); + fields.put("formatId", text(credential.formatId().value())); + fields.put("issuerId", text(credential.issuerRef().caId().value())); + fields.put("publicKeyId", text(credential.publicKeyId().value())); + fields.put("profileId", text(profile.profileId())); + fields.put("profileVersion", integer(profile.profileVersion())); + fields.put("status", text(credential.status().name())); + fields.put("notBefore", text(credential.validity().notBefore().toString())); + fields.put("notAfter", text(credential.validity().notAfter().toString())); + return result(request.name(), fields); + } + + private PkiOperationResult revokeCredential(PkiOperation.RevokeCredential request) { + RevocationCommand.RevokePermanently command = new RevocationCommand.RevokePermanently(request.credentialId(), + request.reason(), new SimpleAttributeSet()); + RevocationRecord record = revocations.revokePermanently(command); + return revocationResult(request.name(), record); + } + + private PkiOperationResult readHistory(PkiOperation.ReadRevocationHistory request, CancellationSignal signal) + throws IOException { + List transitions = new ArrayList<>(); + boolean truncated; + try (RevocationHistory history = revocations.history(request.credentialId())) { + while (transitions.size() < request.limit() && history.next(signal)) { + transitions.add(transitionValue(history.current())); + } + truncated = transitions.size() == request.limit() && history.next(signal); + } + Map fields = fields(); + fields.put("credentialId", text(request.credentialId().value())); + fields.put("count", integer(transitions.size())); + fields.put("truncated", bool(truncated)); + fields.put("transitions", new PkiOperationValue.ListValue(transitions)); + return result(request.name(), fields); + } + + private PkiOperationResult inspectPublication(PkiOperation.InspectPublication request) { + PublicationRecord record = store.getPublicationRecord(request.publicationId()) + .orElseThrow(() -> new MissingObjectException()); + Map fields = fields(); + fields.put("publicationId", text(record.publicationId().value())); + fields.put("sourceType", text(record.sourceType().name())); + fields.put("sourceId", text(record.sourceId().value())); + fields.put("status", text(record.status().name())); + fields.put("attemptNumber", integer(record.attemptNumber())); + fields.put("createdAt", text(record.createdAt().toString())); + fields.put("updatedAt", text(record.updatedAt().toString())); + record.failure().ifPresent(value -> fields.put("failure", text(value.name()))); + record.evidence().ifPresent(value -> fields.put("evidence", text(value.name()))); + return result(request.name(), fields); + } + + private static PkiOperationResult revocationResult(String operation, RevocationRecord record) { + Map fields = fields(); + fields.put("credentialId", text(record.credentialId().value())); + appendTransition(fields, record.transition()); + return result(operation, fields); + } + + private static PkiOperationValue transitionValue(RevocationTransition transition) { + Map fields = fields(); + appendTransition(fields, transition); + return new PkiOperationValue.ObjectValue(fields); + } + + private static void appendTransition(Map fields, RevocationTransition transition) { + fields.put("revision", integer(transition.revision())); + fields.put("state", text(transition.state().name())); + fields.put("time", text(transition.time().toString())); + transition.permanentReason().ifPresent(reason -> fields.put("reason", text(reason.name()))); + } + + private static CertificateProfileRef profileReference(Credential credential) { + if (credential.profileBinding() instanceof EndEntityProfileBinding binding) { + return binding.reference(); + } + if (credential.profileBinding() instanceof CaProfileBinding binding) { + return binding.reference(); + } + throw new IllegalStateException("Credential profile binding is unsupported"); + } + + private static PkiOperationOutcome classify(String operation, RuntimeException failure) { + if (failure instanceof MissingObjectException) { + return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND"); + } + String message = Optional.ofNullable(failure.getMessage()).orElse(""); + if (message.contains("RECOVERY_REQUIRED") || message.contains("DURABILITY_UNCONFIRMED")) { + return failure(operation, PkiOperationFailure.RECOVERY_REQUIRED, "RECOVERY_REQUIRED"); + } + if (message.contains("OUTCOME_UNKNOWN") || message.contains("RESULT_UNCONFIRMED")) { + return failure(operation, PkiOperationFailure.EXTERNAL_OUTCOME_UNKNOWN, "EXTERNAL_OUTCOME_UNKNOWN"); + } + if (message.contains("CREDENTIAL_NOT_FOUND")) { + return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND"); + } + if (message.contains("CONFLICT") || message.contains("changed concurrently")) { + return failure(operation, PkiOperationFailure.CONFLICT, "OPERATION_CONFLICT"); + } + if (message.contains("TRANSITION_ILLEGAL") || message.contains("REVOCATION_TERMINAL")) { + return failure(operation, PkiOperationFailure.POLICY_REJECTION, "PKI_POLICY_REJECTED"); + } + if (failure instanceof IllegalArgumentException) { + return failure(operation, PkiOperationFailure.VALIDATION_FAILURE, "OPERATION_INPUT_INVALID"); + } + if (failure instanceof PkiException) { + return failure(operation, PkiOperationFailure.POLICY_REJECTION, "PKI_POLICY_REJECTED"); + } + if (failure instanceof IllegalStateException) { + return failure(operation, PkiOperationFailure.RESOURCE_FAILURE, "PKI_RESOURCE_FAILED"); + } + return failure(operation, PkiOperationFailure.INTERNAL_FAILURE, "INTERNAL_FAILURE"); + } + + private static PkiOperationOutcome failure(String operation, PkiOperationFailure classification, String code) { + return new PkiOperationOutcome.Failure(operation, classification, code); + } + + private static PkiOperationResult result(String operation, Map fields) { + return new PkiOperationResult(operation, fields); + } + + private static Map fields() { + return new LinkedHashMap<>(); + } + + private static PkiOperationValue text(String value) { + return new PkiOperationValue.Text(value); + } + + private static PkiOperationValue integer(long value) { + return new PkiOperationValue.IntegerValue(value); + } + + private static PkiOperationValue bool(boolean value) { + return new PkiOperationValue.BooleanValue(value); + } + + /** Internal marker normalized to the public NOT_FOUND classification. */ + private static final class MissingObjectException extends RuntimeException { + private static final long serialVersionUID = 1L; + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java new file mode 100644 index 0000000..71eb528 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java @@ -0,0 +1,209 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import java.time.Clock; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +import zeroecho.pki.api.ProfileService; +import zeroecho.pki.api.RevocationService; +import zeroecho.pki.impl.core.DefaultProfileService; +import zeroecho.pki.impl.core.DefaultRevocationService; +import zeroecho.pki.spi.ProviderConfig; +import zeroecho.pki.spi.audit.AuditSink; +import zeroecho.pki.spi.bootstrap.PkiBootstrap; +import zeroecho.pki.spi.store.PkiStore; + +/** Default synchronous session composition. */ +final class DefaultPkiSession implements PkiSession { + + private final PkiStore store; + private final AuditSink audit; + private final ProfileService profiles; + private final RevocationService revocations; + private final PkiOperationExecutor operations; + private final AtomicBoolean closed = new AtomicBoolean(); + + private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit, Clock clock) { + this.store = store; + this.audit = audit; + this.profiles = new DefaultProfileService(store, clock, audit); + this.revocations = new DefaultRevocationService(store, clock, audit); + this.operations = new DefaultPkiOperationExecutor(configuration, store, revocations, + this::requireOpen); + } + + /* default */ static PkiSession open(PkiSessionConfiguration configuration) { + return open(configuration, Clock.systemUTC(), ProductionBootstrap.INSTANCE); + } + + @SuppressWarnings("PMD.AvoidCatchingGenericException") + /* default */ static PkiSession open(PkiSessionConfiguration configuration, Clock clock, Bootstrap bootstrap) { + PkiSessionConfiguration exact = Objects.requireNonNull(configuration, "configuration"); + Objects.requireNonNull(clock, "clock"); + Objects.requireNonNull(bootstrap, "bootstrap"); + bootstrap.validateStore(exact.store()); + bootstrap.validateAudit(exact.audit()); + + PkiStore store = null; + AuditSink audit = null; + try { + store = Objects.requireNonNull(bootstrap.openStore(exact.store()), "opened store"); + audit = Objects.requireNonNull(bootstrap.openAudit(exact.audit()), "opened audit sink"); + return new DefaultPkiSession(exact, store, audit, clock); + } catch (RuntimeException | Error primary) { + closeAfterConstructionFailure(audit, store, primary); + throw primary; + } + } + + @Override + public ProfileService profiles() { + requireOpen(); + return profiles; + } + + @Override + public RevocationService revocations() { + requireOpen(); + return revocations; + } + + @Override + public PkiOperationExecutor operations() { + requireOpen(); + return operations; + } + + @Override + public void close() throws Exception { + if (!closed.compareAndSet(false, true)) { + return; + } + Throwable primary = null; + try { + audit.close(); + } catch (Throwable failure) { // NOPMD - preserve Error and checked close failures. + primary = failure; + } + try { + store.close(); + } catch (Throwable failure) { // NOPMD - preserve Error and checked close failures. + if (primary == null) { + primary = failure; + } else { + primary.addSuppressed(failure); + } + } + rethrow(primary); + } + + private void requireOpen() { + if (closed.get()) { + throw new IllegalStateException("PKI session is closed"); + } + } + + private static void closeAfterConstructionFailure(AuditSink audit, PkiStore store, Throwable primary) { + if (audit != null) { + try { + audit.close(); + } catch (Throwable failure) { // NOPMD - construction failure remains primary. + primary.addSuppressed(failure); + } + } + if (store != null) { + try { + store.close(); + } catch (Throwable failure) { // NOPMD - construction failure remains primary. + primary.addSuppressed(failure); + } + } + } + + @SuppressWarnings("PMD.SignatureDeclareThrowsException") + private static void rethrow(Throwable failure) throws Exception { + if (failure == null) { + return; + } + if (failure instanceof Exception exception) { + throw exception; + } + if (failure instanceof Error error) { + throw error; + } + throw new IllegalStateException("PKI session close failed", failure); + } + + /** Resource bootstrap seam used for deterministic lifecycle verification. */ + /* default */ interface Bootstrap { + /** Validates store configuration without allocating a store. */ + void validateStore(ProviderConfig configuration); + + /** Validates audit configuration without allocating a sink. */ + void validateAudit(ProviderConfig configuration); + + /** Opens the configured store. */ + PkiStore openStore(ProviderConfig configuration); + + /** Opens the configured audit sink. */ + AuditSink openAudit(ProviderConfig configuration); + } + + /** Production provider bootstrap implementation. */ + private enum ProductionBootstrap implements Bootstrap { + INSTANCE; + + @Override + public void validateStore(ProviderConfig configuration) { + PkiBootstrap.validateStoreConfiguration(configuration); + } + + @Override + public void validateAudit(ProviderConfig configuration) { + PkiBootstrap.validateAuditConfiguration(configuration); + } + + @Override + public PkiStore openStore(ProviderConfig configuration) { + return PkiBootstrap.openStore(configuration); + } + + @Override + public AuditSink openAudit(ProviderConfig configuration) { + return PkiBootstrap.openAudit(configuration); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java new file mode 100644 index 0000000..f35f9cd --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java @@ -0,0 +1,158 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import java.util.Objects; + +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.revocation.RevocationReason; + +/** + * Closed set of typed synchronous PKI operations available to transports. + * + *

Names are stable external semantic identities. Implementations dispatch by + * explicit type and never by reflection or Java class name.

+ */ +public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, PkiOperation.ValidateProfile, + PkiOperation.InspectCredential, PkiOperation.RevokeCredential, PkiOperation.ReadRevocationHistory, + PkiOperation.InspectPublication { + + /** @return stable semantic operation name */ + String name(); + + /** Validates the already-open session configuration. */ + record ValidateConfiguration() implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "configuration.validate"; + + @Override + public String name() { + return NAME; + } + } + + /** Validates one bounded certificate-profile JSON document without persisting it. */ + record ValidateProfile(byte[] document) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "profile.validate"; + + /** Defensively snapshots the document. */ + public ValidateProfile { + document = Objects.requireNonNull(document, "document").clone(); + } + + @Override + public byte[] document() { + return document.clone(); + } + + @Override + public String name() { + return NAME; + } + } + + /** Reads safe metadata for one committed credential. */ + record InspectCredential(PkiId credentialId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "credential.inspect"; + + /** Validates the identifier. */ + public InspectCredential { + Objects.requireNonNull(credentialId, "credentialId"); + } + + @Override + public String name() { + return NAME; + } + } + + /** Permanently revokes one committed credential. */ + record RevokeCredential(PkiId credentialId, RevocationReason reason) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "credential.revoke"; + + /** Validates permanent-revocation inputs. */ + public RevokeCredential { + Objects.requireNonNull(credentialId, "credentialId"); + Objects.requireNonNull(reason, "reason"); + if (reason == RevocationReason.CERTIFICATE_HOLD || reason == RevocationReason.REMOVE_FROM_CRL) { + throw new IllegalArgumentException("reason must be permanent"); + } + } + + @Override + public String name() { + return NAME; + } + } + + /** Reads a bounded prefix from one closeable authoritative history cursor. */ + record ReadRevocationHistory(PkiId credentialId, int limit) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "revocation.history"; + /** Maximum finite result entries for one invocation. */ + public static final int MAXIMUM_RESULT_ENTRIES = 1_000; + + /** Validates the identifier and finite result limit. */ + public ReadRevocationHistory { + Objects.requireNonNull(credentialId, "credentialId"); + if (limit < 1 || limit > MAXIMUM_RESULT_ENTRIES) { + throw new IllegalArgumentException("limit must be between 1 and 1000"); + } + } + + @Override + public String name() { + return NAME; + } + } + + /** Reads safe durable state for one publication operation. */ + record InspectPublication(PkiId publicationId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "publication.inspect"; + + /** Validates the identifier. */ + public InspectPublication { + Objects.requireNonNull(publicationId, "publicationId"); + } + + @Override + public String name() { + return NAME; + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationExecutor.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationExecutor.java new file mode 100644 index 0000000..d62482e --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationExecutor.java @@ -0,0 +1,57 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import zeroecho.core.io.CancellationSignal; + +/** + * Synchronous transport-neutral executor for the closed PKI operation model. + * + *

Implementations execute on the caller thread, perform no hidden retry and + * create no thread or executor. A returned cancellation or failure never implies + * rollback of an already committed backend transition.

+ */ +@FunctionalInterface +public interface PkiOperationExecutor { + + /** + * Executes one typed operation synchronously. + * + * @param operation validated typed operation + * @param cancellation cooperative cancellation signal + * @return successful result or safely classified failure + * @throws IllegalStateException if the owning session is closed + */ + PkiOperationOutcome execute(PkiOperation operation, CancellationSignal cancellation); +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationFailure.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationFailure.java new file mode 100644 index 0000000..27fedf4 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationFailure.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +/** Stable safe classifications for synchronous PKI operation failures. */ +public enum PkiOperationFailure { + /** Typed input or operation precondition is malformed. */ + VALIDATION_FAILURE, + /** A valid request is rejected by PKI policy. */ + POLICY_REJECTION, + /** The selected authoritative object does not exist. */ + NOT_FOUND, + /** The requested state conflicts with committed state. */ + CONFLICT, + /** Durable recovery is required before the operation can continue. */ + RECOVERY_REQUIRED, + /** An external operation may have completed and must be reconciled. */ + EXTERNAL_OUTCOME_UNKNOWN, + /** Filesystem, channel or another required resource failed. */ + RESOURCE_FAILURE, + /** Cooperative cancellation was observed. */ + CANCELLED, + /** An unexpected implementation defect prevented a safe result. */ + INTERNAL_FAILURE +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationOutcome.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationOutcome.java new file mode 100644 index 0000000..0dda093 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationOutcome.java @@ -0,0 +1,60 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import java.util.Objects; + +/** Closed success-or-safe-failure outcome returned by the operation executor. */ +public sealed interface PkiOperationOutcome permits PkiOperationOutcome.Success, PkiOperationOutcome.Failure { + + /** Successful operation outcome. */ + record Success(PkiOperationResult result) implements PkiOperationOutcome { + /** Validates the result. */ + public Success { + Objects.requireNonNull(result, "result"); + } + } + + /** Safely classified operation failure without a throwable graph. */ + record Failure(String operationName, PkiOperationFailure classification, String code) + implements PkiOperationOutcome { + /** Validates stable, non-sensitive failure data. */ + public Failure { + if (operationName == null || operationName.isBlank() || code == null || !code.matches("[A-Z0-9_]{3,64}")) { + throw new IllegalArgumentException("Operation failure identity or code is invalid"); + } + Objects.requireNonNull(classification, "classification"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationResult.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationResult.java new file mode 100644 index 0000000..1806d56 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationResult.java @@ -0,0 +1,71 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Finite structured result of one successful typed PKI operation. + * + * @param operationName stable operation identity + * @param fields ordered allowlisted result fields + */ +public record PkiOperationResult(String operationName, Map fields) { + + /** Validates and snapshots the result. */ + public PkiOperationResult { + if (operationName == null || operationName.isBlank()) { + throw new IllegalArgumentException("operationName must not be blank"); + } + Objects.requireNonNull(fields, "fields"); + fields = Collections.unmodifiableMap(new LinkedHashMap<>(fields)); + if (fields.containsKey(null) || fields.containsValue(null)) { + throw new IllegalArgumentException("result fields must not contain null"); + } + } + + /** + * Returns one declared safe field. + * + * @param name field name + * @return field value when declared + */ + public Optional field(String name) { + return Optional.ofNullable(fields.get(Objects.requireNonNull(name, "name"))); + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperationValue.java b/pki/src/main/java/zeroecho/pki/application/PkiOperationValue.java new file mode 100644 index 0000000..4d06261 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiOperationValue.java @@ -0,0 +1,94 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Closed transport-neutral value model used by safe operation results and plan + * arguments. + * + *

The model deliberately excludes byte arrays and arbitrary Java objects, so + * private keys, unrestricted DER and provider exception graphs cannot become + * operation output accidentally.

+ */ +public sealed interface PkiOperationValue permits PkiOperationValue.Text, PkiOperationValue.IntegerValue, + PkiOperationValue.BooleanValue, PkiOperationValue.ObjectValue, PkiOperationValue.ListValue { + + /** Text scalar. */ + record Text(String value) implements PkiOperationValue { + /** Validates the value. */ + public Text { + Objects.requireNonNull(value, "value"); + } + } + + /** Signed 64-bit integer scalar. */ + record IntegerValue(long value) implements PkiOperationValue { + } + + /** Boolean scalar. */ + record BooleanValue(boolean value) implements PkiOperationValue { + } + + /** Deterministically ordered object value. */ + record ObjectValue(Map fields) implements PkiOperationValue { + /** Validates names and snapshots insertion order. */ + public ObjectValue { + Objects.requireNonNull(fields, "fields"); + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : fields.entrySet()) { + String name = Objects.requireNonNull(entry.getKey(), "field name"); + if (name.isBlank() || copy.putIfAbsent(name, Objects.requireNonNull(entry.getValue(), "field value")) + != null) { + throw new IllegalArgumentException("Operation object field names must be unique and nonblank"); + } + } + fields = Collections.unmodifiableMap(copy); + } + } + + /** Immutable ordered list value. */ + record ListValue(List values) implements PkiOperationValue { + /** Snapshots the list and rejects null entries. */ + public ListValue { + Objects.requireNonNull(values, "values"); + values = List.copyOf(values); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSession.java b/pki/src/main/java/zeroecho/pki/application/PkiSession.java new file mode 100644 index 0000000..a376c0f --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiSession.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import zeroecho.pki.api.ProfileService; +import zeroecho.pki.api.RevocationService; + +/** + * Lifecycle-owned synchronous PKI backend session. + * + *

A session is constructed from one validated immutable configuration and is + * suitable for one CLI plan or for the complete lifetime of a future server. + * It creates no scheduler, thread or executor. Implementations reject service + * access after close.

+ */ +public interface PkiSession extends AutoCloseable { + + /** + * Opens a production session. + * + * @param configuration validated immutable configuration + * @return opened session + * @throws IllegalArgumentException if configuration is invalid + * @throws RuntimeException if resource construction fails + */ + static PkiSession open(PkiSessionConfiguration configuration) { + return DefaultPkiSession.open(configuration); + } + + /** @return profile lifecycle service owned by this session */ + ProfileService profiles(); + + /** @return revocation service owned by this session */ + RevocationService revocations(); + + /** @return shared typed operation executor owned by this session */ + PkiOperationExecutor operations(); + + /** + * Closes services and backend resources in reverse construction order. + * Repeated calls are harmless; primary and suppressed failures are preserved. + * + * @throws Exception if resource closure fails + */ + @Override + void close() throws Exception; +} diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java new file mode 100644 index 0000000..f130d45 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java @@ -0,0 +1,68 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import java.util.Objects; + +import zeroecho.pki.spi.ProviderConfig; + +/** + * Immutable, versioned configuration for one synchronous PKI backend session. + * + *

Provider values may be sensitive. Callers must not render or log this + * object. Validation of provider identities and closed property sets occurs + * before backend allocation.

+ * + * @param version configuration schema version + * @param store store-provider configuration + * @param audit audit-provider configuration + */ +public record PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit) { + + /** Current configuration schema version. */ + public static final int CURRENT_VERSION = 1; + + /** Validates and snapshots the configuration. */ + public PkiSessionConfiguration { + if (version != CURRENT_VERSION) { + throw new IllegalArgumentException("Unsupported PKI session configuration version"); + } + store = snapshot(Objects.requireNonNull(store, "store")); + audit = snapshot(Objects.requireNonNull(audit, "audit")); + } + + private static ProviderConfig snapshot(ProviderConfig config) { + return new ProviderConfig(config.backendId(), config.properties()); + } +} diff --git a/pki/src/main/java/zeroecho/pki/application/package-info.java b/pki/src/main/java/zeroecho/pki/application/package-info.java new file mode 100644 index 0000000..00845c0 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/package-info.java @@ -0,0 +1,36 @@ +/******************************************************************************* + * Copyright (C) 2026, 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. + ******************************************************************************/ + +/** Lifecycle-owned synchronous PKI sessions and transport-neutral typed operations. */ +package zeroecho.pki.application; diff --git a/pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java b/pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java index fef2cec..6ba6eb0 100644 --- a/pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java +++ b/pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java @@ -33,7 +33,6 @@ ******************************************************************************/ package zeroecho.pki.spi; -import java.util.Collections; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -76,7 +75,7 @@ public record ProviderConfig(String backendId, Map properties) { if (backendId.isBlank()) { throw new IllegalArgumentException("backendId must not be blank"); } - properties = Collections.unmodifiableMap(properties); + properties = Map.copyOf(properties); } /** diff --git a/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java b/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java index c9d679a..e3de091 100644 --- a/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java +++ b/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java @@ -44,7 +44,7 @@ import zeroecho.pki.api.audit.AuditEvent; *

*/ @FunctionalInterface -public interface AuditSink { +public interface AuditSink extends AutoCloseable { /** * Persists an audit event. @@ -54,4 +54,16 @@ public interface AuditSink { * @throws RuntimeException if persistence fails */ void record(AuditEvent event); + + /** + * Releases lifecycle-owned sink resources. + * + *

The default implementation owns no closeable resource. Stateful + * providers may override this method; repeated close calls should be + * harmless.

+ */ + @Override + default void close() { + // Most current sinks open resources only for the duration of record(...). + } } diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java index d7a2114..aab4d61 100644 --- a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java +++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java @@ -140,27 +140,47 @@ public final class PkiBootstrap { */ public static PkiStore openStore() { String requestedId = System.getProperty(PROP_STORE_BACKEND); - - PkiStoreProvider provider = SpiSelector.select(PkiStoreProvider.class, requestedId, - new SpiSelector.ProviderId<>() { - @Override - public String id(PkiStoreProvider p) { - return p.id(); - } - }); - Map props = SpiSystemProperties.readPrefixed(PROP_STORE_PREFIX); - + PkiStoreProvider provider = selectStoreProvider(requestedId); if ("fs".equals(provider.id()) && !props.containsKey("root")) { props.put("root", Path.of("pki-store").toString()); } - ProviderConfig config = new ProviderConfig(provider.id(), props); - if (LOG.isLoggable(Level.INFO)) { LOG.info("Selected store provider: " + provider.id() + " (keys: " + props.keySet() + ")"); } + return provider.allocate(config); + } + /** + * Validates an explicit store configuration without allocating a store. + * + *

This entry point is intended for lifecycle composition that must reject + * every invalid configuration before exposing or allocating backend + * resources. Unknown provider properties are rejected because an explicit + * application configuration is versioned and closed.

+ * + * @param config immutable store-provider configuration + * @throws NullPointerException if {@code config} is {@code null} + * @throws IllegalArgumentException if the provider or configuration is invalid + */ + public static void validateStoreConfiguration(ProviderConfig config) { + PkiStoreProvider provider = selectStoreProvider(Objects.requireNonNull(config, "config").backendId()); + requireKnownKeys(provider, config); + provider.validateConfig(config); + } + + /** + * Opens a store from an explicit, strictly validated configuration. + * + * @param config immutable store-provider configuration + * @return opened store owned by the caller + * @throws IllegalArgumentException if the configuration is invalid + * @throws RuntimeException if store allocation fails + */ + public static PkiStore openStore(ProviderConfig config) { + validateStoreConfiguration(config); + PkiStoreProvider provider = selectStoreProvider(config.backendId()); return provider.allocate(config); } @@ -200,6 +220,32 @@ public final class PkiBootstrap { return provider.allocate(config); } + /** + * Validates an explicit audit configuration without allocating a sink. + * + * @param config immutable audit-provider configuration + * @throws NullPointerException if {@code config} is {@code null} + * @throws IllegalArgumentException if the provider or configuration is invalid + */ + public static void validateAuditConfiguration(ProviderConfig config) { + AuditSinkProvider provider = selectAuditProvider(Objects.requireNonNull(config, "config").backendId()); + requireKnownKeys(provider, config); + provider.validateConfig(config); + } + + /** + * Opens an audit sink from an explicit, strictly validated configuration. + * + * @param config immutable audit-provider configuration + * @return configured audit sink + * @throws IllegalArgumentException if the configuration is invalid + * @throws RuntimeException if sink allocation fails + */ + public static AuditSink openAudit(ProviderConfig config) { + validateAuditConfiguration(config); + return selectAuditProvider(config.backendId()).allocate(config); + } + /** * Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider} * discovered via ServiceLoader. @@ -349,4 +395,30 @@ public final class PkiBootstrap { LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys()); } } + + private static PkiStoreProvider selectStoreProvider(String requestedId) { + return SpiSelector.select(PkiStoreProvider.class, requestedId, new SpiSelector.ProviderId<>() { + @Override + public String id(PkiStoreProvider provider) { + return provider.id(); + } + }); + } + + private static AuditSinkProvider selectAuditProvider(String requestedId) { + return SpiSelector.select(AuditSinkProvider.class, requestedId, new SpiSelector.ProviderId<>() { + @Override + public String id(AuditSinkProvider provider) { + return provider.id(); + } + }); + } + + private static void requireKnownKeys(ConfigurableProvider provider, ProviderConfig config) { + for (String key : config.properties().keySet()) { + if (!provider.supportedKeys().contains(key)) { + throw new IllegalArgumentException("Unknown provider configuration key: " + key); + } + } + } } diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java index db64f3c..f38e5c2 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -90,7 +90,7 @@ import zeroecho.pki.api.status.StatusObject; * {@link IllegalStateException} when an operation cannot be completed safely. *

*/ -public interface PkiStore extends SignWorkflowStore { +public interface PkiStore extends SignWorkflowStore, AutoCloseable { /** * Returns the runtime-owned durable streaming-content store. @@ -430,4 +430,17 @@ public interface PkiStore extends SignWorkflowStore { * @throws IllegalStateException if listing fails */ List listWorkflowStates(); + + /** + * Closes this store and releases all lifecycle-owned resources. + * + *

After this method returns, implementations must reject new operations. + * Repeated close calls must follow the implementation's documented idempotence + * contract. A caller that coordinates multiple resources must preserve the + * primary close failure and attach later failures as suppressed exceptions.

+ * + * @throws Exception if a lifecycle-owned resource cannot be closed + */ + @Override + void close() throws Exception; } diff --git a/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java b/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java new file mode 100644 index 0000000..0fefa9e --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java @@ -0,0 +1,206 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.RevocationService; +import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; +import zeroecho.pki.api.revocation.RevocationCommand; +import zeroecho.pki.api.revocation.RevocationQuery; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.revocation.RevocationRecord; +import zeroecho.pki.api.revocation.RevocationState; +import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.spi.ProviderConfig; +import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.RevocationHistory; +import zeroecho.pki.spi.store.RevocationSnapshot; + +class PkiOperationExecutorTest { + + private static final PkiId CREDENTIAL_ID = new PkiId("credential:test"); + + @Test + void validatesConfigurationAndProfilesWithSafeTypedResults() { + System.out.println("validatesConfigurationAndProfilesWithSafeTypedResults"); + DefaultPkiOperationExecutor executor = executor(new AtomicInteger()); + PkiOperationOutcome configuration = executor.execute(new PkiOperation.ValidateConfiguration(), + CancellationSignal.NONE); + byte[] profile = BuiltInCertificateProfileCatalog.load(getClass().getClassLoader()).get(0).canonicalJson(); + PkiOperationOutcome validated = executor.execute(new PkiOperation.ValidateProfile(profile), + CancellationSignal.NONE); + PkiOperationOutcome malformed = executor.execute(new PkiOperation.ValidateProfile(new byte[] { 1 }), + CancellationSignal.NONE); + System.out.println("...profileBytes=" + profile.length); + assertInstanceOf(PkiOperationOutcome.Success.class, configuration); + PkiOperationResult profileResult = ((PkiOperationOutcome.Success) validated).result(); + assertTrue(profileResult.field("profileId").isPresent()); + assertEquals(PkiOperationFailure.POLICY_REJECTION, + ((PkiOperationOutcome.Failure) malformed).classification()); + System.out.println("...ok"); + } + + @Test + void delegatesMutationAndStreamsBoundedHistory() { + System.out.println("delegatesMutationAndStreamsBoundedHistory"); + AtomicInteger revocations = new AtomicInteger(); + DefaultPkiOperationExecutor executor = executor(revocations); + PkiOperationOutcome mutation = executor.execute( + new PkiOperation.RevokeCredential(CREDENTIAL_ID, RevocationReason.KEY_COMPROMISE), + CancellationSignal.NONE); + PkiOperationOutcome history = executor.execute(new PkiOperation.ReadRevocationHistory(CREDENTIAL_ID, 1), + CancellationSignal.NONE); + System.out.println("...mutations=" + revocations.get()); + assertInstanceOf(PkiOperationOutcome.Success.class, mutation); + PkiOperationResult historyResult = ((PkiOperationOutcome.Success) history).result(); + assertEquals(new PkiOperationValue.BooleanValue(true), historyResult.field("truncated").orElseThrow()); + assertEquals(1, revocations.get()); + System.out.println("...ok"); + } + + @Test + void classifiesMissingObjectsAndCancellationWithoutThrowableDetails() { + System.out.println("classifiesMissingObjectsAndCancellationWithoutThrowableDetails"); + DefaultPkiOperationExecutor executor = executor(new AtomicInteger()); + PkiOperationOutcome missing = executor.execute(new PkiOperation.InspectPublication(new PkiId("missing")), + CancellationSignal.NONE); + PkiOperationOutcome cancelled = executor.execute(new PkiOperation.InspectCredential(CREDENTIAL_ID), + () -> true); + assertEquals(PkiOperationFailure.NOT_FOUND, ((PkiOperationOutcome.Failure) missing).classification()); + PkiOperationOutcome.Failure cancelledFailure = (PkiOperationOutcome.Failure) cancelled; + assertEquals(PkiOperationFailure.CANCELLED, cancelledFailure.classification()); + assertEquals("OPERATION_CANCELLED", cancelledFailure.code()); + System.out.println("...ok"); + } + + private static DefaultPkiOperationExecutor executor(AtomicInteger mutations) { + PkiSessionConfiguration configuration = new PkiSessionConfiguration(1, + new ProviderConfig("fs", Map.of("root", "unused")), new ProviderConfig("memory", Map.of())); + PkiStore store = (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), + new Class[] { PkiStore.class }, (proxy, method, arguments) -> switch (method.getName()) { + case "getCredential", "getPublicationRecord" -> Optional.empty(); + case "close" -> null; + default -> throw new UnsupportedOperationException(method.getName()); + }); + return new DefaultPkiOperationExecutor(configuration, store, revocations(mutations), () -> { + }); + } + + private static RevocationService revocations(AtomicInteger mutations) { + return new RevocationService() { + @Override + public RevocationRecord hold(RevocationCommand.Hold command) { + throw new UnsupportedOperationException(); + } + + @Override + public RevocationRecord unhold(RevocationCommand.Unhold command) { + throw new UnsupportedOperationException(); + } + + @Override + public RevocationRecord revokePermanently(RevocationCommand.RevokePermanently command) { + mutations.incrementAndGet(); + return new RevocationRecord(command.credentialId(), transitions().get(1)); + } + + @Override + public Optional get(PkiId credentialId) { + return Optional.empty(); + } + + @Override + public RevocationHistory history(PkiId credentialId) { + return historyCursor(credentialId); + } + + @Override + public RevocationSnapshot search(RevocationQuery query) { + throw new UnsupportedOperationException(); + } + }; + } + + private static RevocationHistory historyCursor(PkiId credentialId) { + return new RevocationHistory() { + private int index = -1; + + @Override + public PkiId credentialId() { + return credentialId; + } + + @Override + public boolean next(CancellationSignal cancellation) throws IOException { + cancellation.throwIfCancelled(); + index++; + return index < transitions().size(); + } + + @Override + public RevocationTransition current() { + return transitions().get(index); + } + + @Override + public void close() { + // no-op + } + }; + } + + private static List transitions() { + return List.of(new RevocationTransition(1L, RevocationState.HELD, Instant.parse("2026-08-04T00:00:00Z"), + Optional.empty(), new SimpleAttributeSet()), + new RevocationTransition(2L, RevocationState.PERMANENTLY_REVOKED, + Instant.parse("2026-08-04T00:01:00Z"), Optional.of(RevocationReason.KEY_COMPROMISE), + new SimpleAttributeSet())); + } +} diff --git a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java new file mode 100644 index 0000000..11327a4 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java @@ -0,0 +1,190 @@ +/******************************************************************************* + * Copyright (C) 2026, 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.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.spi.ProviderConfig; +import zeroecho.pki.spi.audit.AuditSink; +import zeroecho.pki.spi.store.PkiStore; + +class PkiSessionLifecycleTest { + + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-08-04T00:00:00Z"), ZoneOffset.UTC); + + @TempDir + Path temporaryDirectory; + + @Test + void opensReusesAndDeterministicallyClosesOneSession() throws Exception { + System.out.println("opensReusesAndDeterministicallyClosesOneSession"); + PkiSessionConfiguration configuration = configuration(temporaryDirectory.resolve("store")); + PkiSession session = PkiSession.open(configuration); + PkiOperationOutcome first = session.operations().execute(new PkiOperation.ValidateConfiguration(), + CancellationSignal.NONE); + PkiOperationOutcome second = session.operations().execute(new PkiOperation.ValidateConfiguration(), + CancellationSignal.NONE); + System.out.println("...executions=2"); + assertInstanceOf(PkiOperationOutcome.Success.class, first); + assertInstanceOf(PkiOperationOutcome.Success.class, second); + session.close(); + session.close(); + assertThrows(IllegalStateException.class, session::operations); + assertThrows(UnsupportedOperationException.class, + () -> ((PkiOperationOutcome.Success) first).result().fields().put("x", new PkiOperationValue.Text("y"))); + System.out.println("...ok"); + } + + @Test + void validatesBeforeAllocationAndCleansPartialConstruction() { + System.out.println("validatesBeforeAllocationAndCleansPartialConstruction"); + PkiSessionConfiguration configuration = configuration(temporaryDirectory.resolve("unused")); + AtomicInteger storesOpened = new AtomicInteger(); + AtomicInteger storesClosed = new AtomicInteger(); + DefaultPkiSession.Bootstrap invalid = bootstrap(storesOpened, storesClosed, true, false, false, false); + assertThrows(IllegalArgumentException.class, + () -> DefaultPkiSession.open(configuration, CLOCK, invalid)); + assertEquals(0, storesOpened.get()); + + DefaultPkiSession.Bootstrap partial = bootstrap(storesOpened, storesClosed, false, true, false, false); + assertThrows(IllegalStateException.class, + () -> DefaultPkiSession.open(configuration, CLOCK, partial)); + System.out.println("...partialStoreCloses=" + storesClosed.get()); + assertEquals(1, storesClosed.get()); + System.out.println("...ok"); + } + + @Test + void preservesPrimaryAndSuppressedCloseFailures() throws Exception { + System.out.println("preservesPrimaryAndSuppressedCloseFailures"); + PkiSessionConfiguration configuration = configuration(temporaryDirectory.resolve("unused-close")); + DefaultPkiSession.Bootstrap bootstrap = bootstrap(new AtomicInteger(), new AtomicInteger(), false, false, + true, true); + PkiSession session = DefaultPkiSession.open(configuration, CLOCK, bootstrap); + Exception failure = assertThrows(Exception.class, session::close); + System.out.println("...suppressed=" + failure.getSuppressed().length); + assertEquals("audit-close", failure.getMessage()); + assertEquals(1, failure.getSuppressed().length); + assertInstanceOf(IOException.class, failure.getSuppressed()[0]); + session.close(); + System.out.println("...ok"); + } + + private static PkiSessionConfiguration configuration(Path storeRoot) { + return new PkiSessionConfiguration(1, new ProviderConfig("fs", Map.of("root", storeRoot.toString())), + new ProviderConfig("memory", Map.of("size", "16"))); + } + + private static DefaultPkiSession.Bootstrap bootstrap(AtomicInteger storesOpened, AtomicInteger storesClosed, + boolean invalidAudit, boolean failAuditOpen, boolean failAuditClose, boolean failStoreClose) { + return new DefaultPkiSession.Bootstrap() { + @Override + public void validateStore(ProviderConfig configuration) { + // valid + } + + @Override + public void validateAudit(ProviderConfig configuration) { + if (invalidAudit) { + throw new IllegalArgumentException("invalid"); + } + } + + @Override + public PkiStore openStore(ProviderConfig configuration) { + storesOpened.incrementAndGet(); + return proxyStore(() -> { + storesClosed.incrementAndGet(); + if (failStoreClose) { + throw new IOException("store-close"); + } + }); + } + + @Override + public AuditSink openAudit(ProviderConfig configuration) { + if (failAuditOpen) { + throw new IllegalStateException("audit-open"); + } + return new AuditSink() { + @Override + public void record(zeroecho.pki.api.audit.AuditEvent event) { + // no-op + } + + @Override + public void close() { + if (failAuditClose) { + throw new IllegalStateException("audit-close"); + } + } + }; + } + }; + } + + private static PkiStore proxyStore(CloseAction close) { + return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class[] { PkiStore.class }, + (proxy, method, arguments) -> { + if ("close".equals(method.getName())) { + close.close(); + return null; + } + if ("toString".equals(method.getName())) { + return "test-store"; + } + throw new UnsupportedOperationException(method.getName()); + }); + } + + @FunctionalInterface + private interface CloseAction { + void close() throws Exception; + } +}