feat(pki): add typed CLI operation foundation
Add a reusable PKI session and typed synchronous operation executor shared by direct CLI commands and versioned sequential batch plans. Provide deterministic references, structured output, failure policies and safe lifecycle handling without introducing a scripting language or runtime.
This commit is contained in:
@@ -8,8 +8,11 @@ group='org.egothor'
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.apache.commons:commons-text'
|
implementation 'org.apache.commons:commons-text'
|
||||||
implementation 'commons-cli:commons-cli'
|
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(':lib')
|
||||||
implementation project(':ext')
|
implementation project(':ext')
|
||||||
|
implementation project(':pki')
|
||||||
// might be removed if I move BC ops to the lib
|
// might be removed if I move BC ops to the lib
|
||||||
testImplementation 'org.bouncycastle:bcpkix-jdk18on'
|
testImplementation 'org.bouncycastle:bcpkix-jdk18on'
|
||||||
}
|
}
|
||||||
@@ -57,4 +60,3 @@ jar {
|
|||||||
javadoc {
|
javadoc {
|
||||||
options.links("https://www.egothor.org/javadoc/zeroecho/lib")
|
options.links("https://www.egothor.org/javadoc/zeroecho/lib")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ package zeroecho;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.security.GeneralSecurityException;
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ import org.apache.commons.cli.ParseException;
|
|||||||
import org.apache.commons.cli.help.HelpFormatter;
|
import org.apache.commons.cli.help.HelpFormatter;
|
||||||
|
|
||||||
import zeroecho.sdk.util.BouncyCastleActivator;
|
import zeroecho.sdk.util.BouncyCastleActivator;
|
||||||
|
import zeroecho.pki.cli.PkiCli;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ZeroEcho is a command-line utility for managing asymmetric keys and
|
* 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 {
|
public static void main(final String[] args) throws IOException {
|
||||||
final int errorCode = mainProcess(args);
|
final int errorCode = mainProcess(args);
|
||||||
|
|
||||||
|
if (args.length > 0 && "pki".equals(args[0])) {
|
||||||
|
if (errorCode != 0) {
|
||||||
|
System.exit(errorCode);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (errorCode == 0) {
|
if (errorCode == 0) {
|
||||||
System.out.println("OK");
|
System.out.println("OK");
|
||||||
} else {
|
} else {
|
||||||
@@ -119,6 +127,9 @@ public final class ZeroEcho {
|
|||||||
* @throws IOException If the output could not be written
|
* @throws IOException If the output could not be written
|
||||||
*/
|
*/
|
||||||
public static int mainProcess(final String... args) throws IOException {
|
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 KEM_OPTION = Option.builder("E").longOpt("kem").desc("KEM encryption/decryption").get();
|
||||||
final Option GUARD_OPTION = Option.builder("G").longOpt("guard")
|
final Option GUARD_OPTION = Option.builder("G").longOpt("guard")
|
||||||
.desc("multi-recipient encryption/decryption (keys+passwords), AES/ChaCha").get();
|
.desc("multi-recipient encryption/decryption (keys+passwords), AES/ChaCha").get();
|
||||||
|
|||||||
300
app/src/main/java/zeroecho/pki/cli/PkiCli.java
Normal file
300
app/src/main/java/zeroecho/pki/cli/PkiCli.java
Normal file
@@ -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<String> 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<String> tokens) {
|
||||||
|
if ((tokens.size() & 1) != 0) {
|
||||||
|
throw new IllegalArgumentException("Operation arguments must be option-value pairs");
|
||||||
|
}
|
||||||
|
Map<String, PkiOperationValue> 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<PkiPlanExecution.StepResult> 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 <operation> --config <file> [--output human|json] [arguments]");
|
||||||
|
writer.println(" zeroecho pki run <plan-file> --config <file> [--output human|json]");
|
||||||
|
writer.println("Operations:");
|
||||||
|
writer.println(" configuration.validate");
|
||||||
|
writer.println(" profile.validate --profile-file <file>");
|
||||||
|
writer.println(" credential.inspect --credential-id <id>");
|
||||||
|
writer.println(" credential.revoke --credential-id <id> --reason <permanent-reason>");
|
||||||
|
writer.println(" revocation.history --credential-id <id> --limit <1..1000>");
|
||||||
|
writer.println(" publication.inspect --publication-id <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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
Normal file
114
app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
Normal file
@@ -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<String> ROOT_FIELDS = Set.of("version", "store", "audit");
|
||||||
|
private static final Set<String> 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<String, String> result = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, PkiOperationValue> 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<String, PkiOperationValue> actual, Set<String> expected) {
|
||||||
|
if (!actual.keySet().equals(expected)) {
|
||||||
|
throw new IllegalArgumentException("CLI document fields are invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/src/main/java/zeroecho/pki/cli/PkiCliFiles.java
Normal file
92
app/src/main/java/zeroecho/pki/cli/PkiCliFiles.java
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/src/main/java/zeroecho/pki/cli/PkiCliJson.java
Normal file
147
app/src/main/java/zeroecho/pki/cli/PkiCliJson.java
Normal file
@@ -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<String, PkiOperationValue> 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<PkiOperationValue> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/src/main/java/zeroecho/pki/cli/PkiExitCodes.java
Normal file
76
app/src/main/java/zeroecho/pki/cli/PkiExitCodes.java
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
Normal file
145
app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
Normal file
@@ -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<String, Binding> bindings;
|
||||||
|
|
||||||
|
/* default */ PkiOperationRegistry() {
|
||||||
|
Map<String, Binding> 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<String> 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<String, Binding> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/src/main/java/zeroecho/pki/cli/PkiOutputMode.java
Normal file
40
app/src/main/java/zeroecho/pki/cli/PkiOutputMode.java
Normal file
@@ -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
|
||||||
|
}
|
||||||
188
app/src/main/java/zeroecho/pki/cli/PkiOutputRenderer.java
Normal file
188
app/src/main/java/zeroecho/pki/cli/PkiOutputRenderer.java
Normal file
@@ -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<String, PkiOperationValue> 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<String, PkiOperationValue> 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<String, PkiOperationValue> 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() + ")";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/src/main/java/zeroecho/pki/cli/PkiPlan.java
Normal file
70
app/src/main/java/zeroecho/pki/cli/PkiPlan.java
Normal file
@@ -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<Step> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/src/main/java/zeroecho/pki/cli/PkiPlanExecution.java
Normal file
115
app/src/main/java/zeroecho/pki/cli/PkiPlanExecution.java
Normal file
@@ -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<StepResult> steps, Optional<PkiOperationFailure> planFailure, Optional<String> 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<StepResult> 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<PkiOperationResult> output,
|
||||||
|
Optional<PkiOperationFailure> failure, Optional<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
app/src/main/java/zeroecho/pki/cli/PkiPlanExecutor.java
Normal file
198
app/src/main/java/zeroecho/pki/cli/PkiPlanExecutor.java
Normal file
@@ -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<PkiPlanExecution.StepResult> results = new ArrayList<>(plan.steps().size());
|
||||||
|
Map<String, PkiOperationResult> successful = new LinkedHashMap<>();
|
||||||
|
Map<String, PkiPlanExecution.StepStatus> 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<String, PkiOperationResult> successful,
|
||||||
|
Map<String, PkiPlanExecution.StepStatus> 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<PkiOperationValue> 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<String, PkiOperationValue> fields = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, PkiOperationValue> 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<PkiOperationValue> 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<PkiPlanExecution.StepResult> results,
|
||||||
|
Map<String, PkiPlanExecution.StepStatus> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
150
app/src/main/java/zeroecho/pki/cli/PkiPlanParser.java
Normal file
150
app/src/main/java/zeroecho/pki/cli/PkiPlanParser.java
Normal file
@@ -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<String> ROOT_FIELDS = Set.of("version", "failurePolicy", "operations");
|
||||||
|
private static final Set<String> STEP_FIELDS = Set.of("id", "operation", "arguments");
|
||||||
|
|
||||||
|
private PkiPlanParser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/* default */ static PkiPlan read(Path path, Set<String> 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<PkiOperationValue> encodedSteps = list(PkiCliConfiguration.required(root, "operations"));
|
||||||
|
if (encodedSteps.isEmpty() || encodedSteps.size() > MAXIMUM_STEPS) {
|
||||||
|
throw new IllegalArgumentException("Plan step count is invalid");
|
||||||
|
}
|
||||||
|
List<PkiPlan.Step> steps = new ArrayList<>(encodedSteps.size());
|
||||||
|
Set<String> ids = new HashSet<>();
|
||||||
|
Map<String, Integer> 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<String> 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<PkiPlan.Step> steps, Map<String, Integer> 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<String, Integer> 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<PkiOperationValue> list(PkiOperationValue value) {
|
||||||
|
if (value instanceof PkiOperationValue.ListValue list) {
|
||||||
|
return list.values();
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("CLI field has the wrong type");
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/src/main/java/zeroecho/pki/cli/package-info.java
Normal file
36
app/src/main/java/zeroecho/pki/cli/package-info.java
Normal file
@@ -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;
|
||||||
311
app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
Normal file
311
app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
Normal file
@@ -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<Class<?>> 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<String, PkiOperationValue> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/src/test/java/zeroecho/pki/cli/PkiPlanParserTest.java
Normal file
119
app/src/test/java/zeroecho/pki/cli/PkiPlanParserTest.java
Normal file
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, PkiOperationValue> 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<String, PkiOperationValue> 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<String, PkiOperationValue> 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<PkiOperationValue> 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<String, PkiOperationValue> 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<String, PkiOperationValue> 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<String, PkiOperationValue> fields = fields();
|
||||||
|
fields.put("credentialId", text(record.credentialId().value()));
|
||||||
|
appendTransition(fields, record.transition());
|
||||||
|
return result(operation, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiOperationValue transitionValue(RevocationTransition transition) {
|
||||||
|
Map<String, PkiOperationValue> fields = fields();
|
||||||
|
appendTransition(fields, transition);
|
||||||
|
return new PkiOperationValue.ObjectValue(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendTransition(Map<String, PkiOperationValue> 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<String, PkiOperationValue> fields) {
|
||||||
|
return new PkiOperationResult(operation, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, PkiOperationValue> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
pki/src/main/java/zeroecho/pki/application/PkiOperation.java
Normal file
158
pki/src/main/java/zeroecho/pki/application/PkiOperation.java
Normal file
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Names are stable external semantic identities. Implementations dispatch by
|
||||||
|
* explicit type and never by reflection or Java class name.</p>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, PkiOperationValue> 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<PkiOperationValue> field(String name) {
|
||||||
|
return Optional.ofNullable(fields.get(Objects.requireNonNull(name, "name")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>The model deliberately excludes byte arrays and arbitrary Java objects, so
|
||||||
|
* private keys, unrestricted DER and provider exception graphs cannot become
|
||||||
|
* operation output accidentally.</p>
|
||||||
|
*/
|
||||||
|
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<String, PkiOperationValue> fields) implements PkiOperationValue {
|
||||||
|
/** Validates names and snapshots insertion order. */
|
||||||
|
public ObjectValue {
|
||||||
|
Objects.requireNonNull(fields, "fields");
|
||||||
|
Map<String, PkiOperationValue> copy = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, PkiOperationValue> 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<PkiOperationValue> values) implements PkiOperationValue {
|
||||||
|
/** Snapshots the list and rejects null entries. */
|
||||||
|
public ListValue {
|
||||||
|
Objects.requireNonNull(values, "values");
|
||||||
|
values = List.copyOf(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
pki/src/main/java/zeroecho/pki/application/PkiSession.java
Normal file
78
pki/src/main/java/zeroecho/pki/application/PkiSession.java
Normal file
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @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());
|
||||||
|
}
|
||||||
|
}
|
||||||
36
pki/src/main/java/zeroecho/pki/application/package-info.java
Normal file
36
pki/src/main/java/zeroecho/pki/application/package-info.java
Normal file
@@ -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;
|
||||||
@@ -33,7 +33,6 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.spi;
|
package zeroecho.pki.spi;
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -76,7 +75,7 @@ public record ProviderConfig(String backendId, Map<String, String> properties) {
|
|||||||
if (backendId.isBlank()) {
|
if (backendId.isBlank()) {
|
||||||
throw new IllegalArgumentException("backendId must not be blank");
|
throw new IllegalArgumentException("backendId must not be blank");
|
||||||
}
|
}
|
||||||
properties = Collections.unmodifiableMap(properties);
|
properties = Map.copyOf(properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import zeroecho.pki.api.audit.AuditEvent;
|
|||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface AuditSink {
|
public interface AuditSink extends AutoCloseable {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists an audit event.
|
* Persists an audit event.
|
||||||
@@ -54,4 +54,16 @@ public interface AuditSink {
|
|||||||
* @throws RuntimeException if persistence fails
|
* @throws RuntimeException if persistence fails
|
||||||
*/
|
*/
|
||||||
void record(AuditEvent event);
|
void record(AuditEvent event);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases lifecycle-owned sink resources.
|
||||||
|
*
|
||||||
|
* <p>The default implementation owns no closeable resource. Stateful
|
||||||
|
* providers may override this method; repeated close calls should be
|
||||||
|
* harmless.</p>
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
default void close() {
|
||||||
|
// Most current sinks open resources only for the duration of record(...).
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,27 +140,47 @@ public final class PkiBootstrap {
|
|||||||
*/
|
*/
|
||||||
public static PkiStore openStore() {
|
public static PkiStore openStore() {
|
||||||
String requestedId = System.getProperty(PROP_STORE_BACKEND);
|
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<String, String> props = SpiSystemProperties.readPrefixed(PROP_STORE_PREFIX);
|
Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_STORE_PREFIX);
|
||||||
|
PkiStoreProvider provider = selectStoreProvider(requestedId);
|
||||||
if ("fs".equals(provider.id()) && !props.containsKey("root")) {
|
if ("fs".equals(provider.id()) && !props.containsKey("root")) {
|
||||||
props.put("root", Path.of("pki-store").toString());
|
props.put("root", Path.of("pki-store").toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
ProviderConfig config = new ProviderConfig(provider.id(), props);
|
ProviderConfig config = new ProviderConfig(provider.id(), props);
|
||||||
|
|
||||||
if (LOG.isLoggable(Level.INFO)) {
|
if (LOG.isLoggable(Level.INFO)) {
|
||||||
LOG.info("Selected store provider: " + provider.id() + " (keys: " + props.keySet() + ")");
|
LOG.info("Selected store provider: " + provider.id() + " (keys: " + props.keySet() + ")");
|
||||||
}
|
}
|
||||||
|
return provider.allocate(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an explicit store configuration without allocating a store.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @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);
|
return provider.allocate(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +220,32 @@ public final class PkiBootstrap {
|
|||||||
return provider.allocate(config);
|
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}
|
* Opens a {@link SignatureWorkflow} using {@link SignatureWorkflowProvider}
|
||||||
* discovered via ServiceLoader.
|
* discovered via ServiceLoader.
|
||||||
@@ -349,4 +395,30 @@ public final class PkiBootstrap {
|
|||||||
LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys());
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ import zeroecho.pki.api.status.StatusObject;
|
|||||||
* {@link IllegalStateException} when an operation cannot be completed safely.
|
* {@link IllegalStateException} when an operation cannot be completed safely.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public interface PkiStore extends SignWorkflowStore {
|
public interface PkiStore extends SignWorkflowStore, AutoCloseable {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the runtime-owned durable streaming-content store.
|
* Returns the runtime-owned durable streaming-content store.
|
||||||
@@ -430,4 +430,17 @@ public interface PkiStore extends SignWorkflowStore {
|
|||||||
* @throws IllegalStateException if listing fails
|
* @throws IllegalStateException if listing fails
|
||||||
*/
|
*/
|
||||||
List<WorkflowStateRecord> listWorkflowStates();
|
List<WorkflowStateRecord> listWorkflowStates();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes this store and releases all lifecycle-owned resources.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @throws Exception if a lifecycle-owned resource cannot be closed
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
void close() throws Exception;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<RevocationRecord> 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<RevocationTransition> 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user