From 7c2a240a667aa9c17085b18fc99c36ca15acf7ec Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 11 Sep 2026 23:45:43 +0000 Subject: [PATCH] feat: add the ESDK TestServer Java Language_Server Host the ESDK TestServer Java Language_Server in this repository under test-server/ (the smithy-java server project plus its server-config.json, feature-config.json, and bug-config.json). The server delegates to this repository's ESDK Java build via the Maven artifact (live-source mode installs it locally), so it lives with the library it exercises. --- test-server/.gitignore | 12 + test-server/Makefile | 313 ++++++++++++ test-server/README.md | 47 ++ test-server/bug-config.json | 8 + test-server/feature-config.json | 23 + test-server/server-config.json | 8 + test-server/server/.gitignore | 6 + test-server/server/build.gradle.kts | 207 ++++++++ test-server/server/gradle.properties | 41 ++ .../server/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + test-server/server/gradlew | 251 ++++++++++ test-server/server/gradlew.bat | 94 ++++ test-server/server/settings.gradle.kts | 14 + test-server/server/smithy-build.json | 13 + .../server/config/ConfigMarshaller.java | 60 +++ .../server/config/ConfigValidator.java | 150 ++++++ .../server/config/EsdkClientFactory.java | 474 ++++++++++++++++++ .../server/error/ErrorClassifier.java | 103 ++++ .../server/error/EsdkClientException.java | 33 ++ .../server/error/OperationWrapper.java | 57 +++ .../esdk/testserver/server/handler/Blobs.java | 21 + .../server/handler/ClientIdGuard.java | 38 ++ .../server/handler/CreateClientHandler.java | 74 +++ .../server/handler/DecryptHandler.java | 39 ++ .../server/handler/DecryptStreamHandler.java | 53 ++ .../server/handler/EncryptHandler.java | 43 ++ .../server/handler/EncryptStreamHandler.java | 60 +++ .../handler/EsdkTestServerHandlers.java | 82 +++ .../server/launcher/ServerBootstrap.java | 110 ++++ .../protocol/DiscriminatingCborCodec.java | 43 ++ .../DiscriminatingCborSerializer.java | 165 ++++++ .../ErrorTypeRpcV2CborServerProtocol.java | 52 ++ ...orTypeRpcV2CborServerProtocolProvider.java | 39 ++ .../server/registry/ClientRegistry.java | 72 +++ .../server/registry/EsdkClient.java | 75 +++ .../server/registry/RealEsdkClient.java | 140 ++++++ ...hy.java.server.core.ServerProtocolProvider | 5 + .../config/ConfigMarshallerPropertyTest.java | 127 +++++ .../server/config/ConfigTestFactory.java | 133 +++++ .../config/ConfigValidatorPropertyTest.java | 89 ++++ .../error/OperationWrapperPropertyTest.java | 127 +++++ .../handler/ClientIdGuardPropertyTest.java | 99 ++++ .../handler/ControllableEsdkClient.java | 88 ++++ .../CreateClientFailurePropertyTest.java | 148 ++++++ .../handler/ErrorMappingPropertyTest.java | 100 ++++ .../RegistryConcurrencyIntegrationTest.java | 144 ++++++ .../registry/ClientRegistryPropertyTest.java | 76 +++ .../server/registry/StubEsdkClient.java | 47 ++ test-server/tests/clone_setup_failure_it.sh | 248 +++++++++ test-server/tests/structural_smoke_check.sh | 175 +++++++ 51 files changed, 4633 insertions(+) create mode 100644 test-server/.gitignore create mode 100644 test-server/Makefile create mode 100644 test-server/README.md create mode 100644 test-server/bug-config.json create mode 100644 test-server/feature-config.json create mode 100644 test-server/server-config.json create mode 100644 test-server/server/.gitignore create mode 100644 test-server/server/build.gradle.kts create mode 100644 test-server/server/gradle.properties create mode 100644 test-server/server/gradle/wrapper/gradle-wrapper.jar create mode 100644 test-server/server/gradle/wrapper/gradle-wrapper.properties create mode 100755 test-server/server/gradlew create mode 100644 test-server/server/gradlew.bat create mode 100644 test-server/server/settings.gradle.kts create mode 100644 test-server/server/smithy-build.json create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/ConfigMarshaller.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/ConfigValidator.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/EsdkClientFactory.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/ErrorClassifier.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/EsdkClientException.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/OperationWrapper.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/Blobs.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/ClientIdGuard.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/CreateClientHandler.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/DecryptHandler.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/DecryptStreamHandler.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EncryptHandler.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EncryptStreamHandler.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EsdkTestServerHandlers.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/launcher/ServerBootstrap.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/DiscriminatingCborCodec.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/DiscriminatingCborSerializer.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/ErrorTypeRpcV2CborServerProtocol.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/ErrorTypeRpcV2CborServerProtocolProvider.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/ClientRegistry.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/EsdkClient.java create mode 100644 test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/RealEsdkClient.java create mode 100644 test-server/server/src/main/resources/META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigMarshallerPropertyTest.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigTestFactory.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigValidatorPropertyTest.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/error/OperationWrapperPropertyTest.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ClientIdGuardPropertyTest.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ControllableEsdkClient.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/CreateClientFailurePropertyTest.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ErrorMappingPropertyTest.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/RegistryConcurrencyIntegrationTest.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/registry/ClientRegistryPropertyTest.java create mode 100644 test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/registry/StubEsdkClient.java create mode 100755 test-server/tests/clone_setup_failure_it.sh create mode 100755 test-server/tests/structural_smoke_check.sh diff --git a/test-server/.gitignore b/test-server/.gitignore new file mode 100644 index 00000000..bcd8bb24 --- /dev/null +++ b/test-server/.gitignore @@ -0,0 +1,12 @@ +# The Commons_Repository is cloned fresh at head on every run (Req 8.2/8.4); +# never commit the clone back into this Language_Repository. +.commons-clone/ + +# Maven versions plugin backup poms produced by build-live-esdk (safety net; +# the recipe reverts the version, but leave these ignored just in case). +*.versionsBackup + +# Integration test scratch output. +.it-tmp/ +# jqwik's local failure-sample database (developer-local, not shared). +**/.jqwik-database diff --git a/test-server/Makefile b/test-server/Makefile new file mode 100644 index 00000000..7ece64b1 --- /dev/null +++ b/test-server/Makefile @@ -0,0 +1,313 @@ +# ============================================================================ +# ESDK TestServer bootstrap for the aws-crypto-tools-java Language_Repository +# ---------------------------------------------------------------------------- +# This repository (aws-crypto-tools-java) is a Language_Repository: it holds +# the live ESDK Java source (../, i.e. esdk/) and, after the relocation, the +# Java Language_Server (esdk/test-server/server/). It does NOT embed the +# cross-language TestServer. The Orchestrator core is hosted in the +# Commons_Repository, so this Makefile is the THIN BOOTSTRAP of the +# bootstrap-then-delegate design (design section "4. Language_Repository +# bootstrap"): it does the minimum needed to reach the core, and everything +# else — validation, source resolution, building + launching every +# Language_Server (including stamping/installing THIS working tree as the live +# ESDK Java library), running the Tests matrix, and reporting — lives in the +# core. The bootstrap plus core together are the single orchestrated entry +# point of Requirement 2.7. +# +# The whole flow is ONE target: +# +# make test-server [COMMONS_BRANCH=] +# +# 1. Parse commons-configuration.json (the Commons_Configuration_Entry). +# Missing/unparseable -> halt BEFORE any clone, naming the file +# (Requirement 4.9). +# 2. git clone --depth 1 --branch .commons-clone +# Clone failure / nonexistent branch -> halt naming the URL and branch +# (Requirement 4.10). +# 3. Run the orchestrator core inside the clone with +# context=language:java, languageRepoRoot=, and the +# commonsOrigin.* coordinates. The core uses THIS working tree as the +# live Java library AND Java server source (Requirement 4.2) and +# resolves every Other language from the clone's Configuration_Set. +# The core's exit code is the run result and propagates out of make. +# +# COMMONS_BRANCH= on the command line is the invocation-time commons +# branch override of Requirement 4.8: the clone uses the supplied branch in +# place of the configured one, and the delegation reports +# commonsOrigin.reason=invocation-override (else configuration-entry). +# +# The old clone-commons-at-head / build-live-esdk / orchestrate-live / run +# targets are gone: the orchestrator's JavaLaunchPlan now stamps and installs +# the live library itself (mvn versions:set -> install -> revert under a +# distinct version), so local and CI runs cannot diverge from the orchestrated +# path. +# +# ---------------------------------------------------------------------------- +# COMMONS COORDINATES — SINGLE SOURCE OF TRUTH +# ---------------------------------------------------------------------------- +# The Commons_Repository coordinates (name, URL, branch) are the +# Commons_Configuration_Entry and live in ONE place: the `commonsRepository` +# object of `commons-configuration.json` next to this Makefile (Requirement +# 4.4). That file also carries the required `product` field, the Java +# Feature_Declaration (supportedFeatures / unsupportedFeatures), and optional +# configurationOverrides — all consumed by the orchestrator core, not by this +# bootstrap. The file is parsed with python3 (no jq dependency), lazily inside +# the test-server recipe so `make help` / `make clean` work even when it is +# missing — its absence is a hard error for `make test-server` only, reported +# BEFORE any clone (Requirement 4.9). +# +# Until the ESDK TestServer merges to commons `main`, the configured branch +# pins the feature branch `kessplas/esdk-test-server`; flip it back to `main` +# in commons-configuration.json after the merge. +# +# CI note: the workflow authenticates the private commons clone by mapping the +# SSH URL to an HTTPS+PAT URL via `git config url.<...>.insteadOf` before +# invoking this Makefile; the plain `git clone` below picks that up +# automatically, so nothing credential-specific lives here. +# +# NOTE: written for the GNU Make 3.81 that ships with macOS (no .ONESHELL), so +# multi-step recipes run on continued lines as one bash script, mirroring the +# commons esdk/test-server/Makefile. +# ============================================================================ + +SHELL := bash + +# Absolute directory containing this Makefile (works with `make -C`). +MAKEFILE_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) + +# The root of this Language_Repository (aws-crypto-tools-java). This is the +# languageRepoRoot handed to the orchestrator core: the core resolves the Java +# library (esdk/) and the Java server (esdk/test-server/server/) from paths +# relative to it (Requirement 4.2), and reads configurationOverrides from +# /esdk/test-server/commons-configuration.json. +REPO_ROOT := $(abspath $(MAKEFILE_DIR)/../..) + +# The Commons_Configuration_Entry carrier (single source of truth, Req 4.4). +COMMONS_CONFIGURATION := $(MAKEFILE_DIR)/commons-configuration.json + +# Invocation-time commons branch override (Requirement 4.8). Empty means "use +# the branch configured in commons-configuration.json". Supplying it flips the +# delegation's commonsOrigin.reason to invocation-override. +COMMONS_BRANCH ?= + +# Testing convenience ONLY (used by the failure-path integration tests to +# point at a local file:// fixture without network): overrides the clone URL. +# The authoritative URL is the one in commons-configuration.json. +COMMONS_REPO ?= + +# Where the fresh clone lands. Kept under a scratch dir ignored by .gitignore +# so the clone is never committed back into this Language_Repository. +CLONE_DIR ?= $(MAKEFILE_DIR)/.commons-clone + +# Path to the orchestrator core inside the clone. +CLONE_ORCH_DIR := $(CLONE_DIR)/esdk/test-server/orchestrator + +# The relocated Java Language_Server module (task 12.1): handlers, registry, +# config marshalling, protocol, ServerBootstrap, and its jqwik/unit tests. +SERVER_DIR := $(MAKEFILE_DIR)/server + +# The commons-hosted Smithy model consumed by the server build (the server +# carries NO model copy; Requirement 1.7). Defaults to the model inside the +# bootstrap clone; `server-test` clones commons first when it is absent. +# Override for local work against a commons working copy, e.g.: +# make server-test MODEL_DIR=/path/to/aws-crypto-tools-commons/esdk/test-server/model +MODEL_DIR ?= $(CLONE_DIR)/esdk/test-server/model + +# --- JDK resolution ---------------------------------------------------------- +# The commons harness (smithy-java client/server + orchestrator) requires +# JDK 21+ -> HARNESS_JAVA_HOME, resolved here for the delegation step. The +# live ESDK Java build's own JDK (17-class) is resolved INSIDE the +# orchestrator's JavaLaunchPlan — no ESDK-side JDK is needed here anymore. +# +# HARNESS_JAVA_HOME: honor caller, else try macOS java_home for 23/22/21 and +# require major >= 21, else the known Homebrew JDK 23. +# NOTE: the version is parsed from the line CONTAINING "version" (grep), not +# `head -1`, because a JAVA_TOOL_OPTIONS notice is often printed to stderr +# before the version line and would otherwise be mis-parsed as an empty major +# version. +HARNESS_JAVA_HOME ?= $(shell for c in "$$(/usr/libexec/java_home -v 23 2>/dev/null)" "$$(/usr/libexec/java_home -v 22 2>/dev/null)" "$$(/usr/libexec/java_home -v 21 2>/dev/null)" "/opt/homebrew/Cellar/openjdk/23.0.2/libexec/openjdk.jdk/Contents/Home"; do if [ -n "$$c" ] && [ -x "$$c/bin/java" ]; then v=$$("$$c/bin/java" -version 2>&1 | grep -i version | head -1 | sed -E 's/.*version .?([0-9]+).*/\1/'); if [ -n "$$v" ] && [ "$$v" -ge 21 ] 2>/dev/null; then echo "$$c"; break; fi; fi; done) + +.DEFAULT_GOAL := help + +.PHONY: help check-harness-java test-server server-test smoke-check it clean + +# ---------------------------------------------------------------------------- +help: ## Show this help + @echo "ESDK TestServer — aws-crypto-tools-java Language_Repository bootstrap" + @echo "" + @echo "Targets:" + @grep -E '^[a-zA-Z0-9_-]+:.*## ' "$(lastword $(MAKEFILE_LIST))" \ + | sort \ + | awk 'BEGIN{FS=":.*## "}{printf " %-24s %s\n", $$1, $$2}' + @echo "" + @echo "The single entry point is 'make test-server': it parses the" + @echo "Commons_Configuration_Entry, clones commons at the branch head, and" + @echo "delegates the complete Language_Repository_Run to the orchestrator core" + @echo "in the clone (this working tree is the live Java library + server)." + @echo "The KMS scenarios are REQUIRED, so valid AWS credentials must be present." + @echo "" + @echo "Commons coordinates (single source of truth: commons-configuration.json):" + @echo " COMMONS_CONFIGURATION=$(COMMONS_CONFIGURATION)" + @if coords=$$(python3 -c 'import json,sys; c=json.load(open(sys.argv[1]))["commonsRepository"]; print(c["name"]); print(c["url"]); print(c["branch"])' "$(COMMONS_CONFIGURATION)" 2>/dev/null); then \ + echo "$$coords" | { read -r n; read -r u; read -r b; \ + echo " name: $$n"; \ + echo " url: $$u"; \ + echo " branch: $$b (design default is 'main'; pinned to the feature branch until merge)"; }; \ + else \ + echo " "; \ + fi + @echo "" + @echo "Variables:" + @echo " COMMONS_BRANCH=$(if $(strip $(COMMONS_BRANCH)),$(COMMONS_BRANCH),) (invocation override, Req 4.8)" + @echo " CLONE_DIR=$(CLONE_DIR)" + @echo " MODEL_DIR=$(MODEL_DIR) (commons Smithy model for 'server-test')" + @echo " REPO_ROOT=$(REPO_ROOT)" + @echo " HARNESS_JAVA_HOME=$(if $(strip $(HARNESS_JAVA_HOME)),$(HARNESS_JAVA_HOME),)" + +# ---------------------------------------------------------------------------- +check-harness-java: ## Verify a JDK 21+ is resolved for the commons harness + @if [ -z "$(strip $(HARNESS_JAVA_HOME))" ]; then \ + echo "ERROR: could not resolve a JDK 21+ for the commons harness (smithy-java needs 21+)." >&2; \ + echo " Set one explicitly, e.g.: make HARNESS_JAVA_HOME=/path/to/jdk21 " >&2; \ + exit 1; \ + fi + @echo "==> Harness JAVA_HOME=$(HARNESS_JAVA_HOME)" + +# ---------------------------------------------------------------------------- +# The single orchestrated entry point for this Language_Repository +# (Requirement 2.7): bootstrap (parse + clone) then delegate to the +# orchestrator core in the clone. All three steps run in ONE recipe under +# `set -eo pipefail`, so any failure halts the run and make exits with the +# failing step's code — in particular the core's non-zero exit on a failed run +# propagates out (CI failure semantics, Requirements 6.4/6.8). +test-server: check-harness-java ## Bootstrap + delegate the complete Language_Repository_Run (needs AWS creds); COMMONS_BRANCH= overrides the commons branch + @set -eo pipefail; \ + \ + : "--- 1. parse the Commons_Configuration_Entry (halt BEFORE any clone: Req 4.9) ---"; \ + if ! coords=$$(python3 -c 'import json,sys; c=json.load(open(sys.argv[1]))["commonsRepository"]; print(c["url"]); print(c["branch"]); print(c["name"])' "$(COMMONS_CONFIGURATION)" 2>/dev/null); then \ + echo "ERROR: the Commons_Configuration_Entry is missing or unparseable." >&2; \ + echo " expected location: $(COMMONS_CONFIGURATION)" >&2; \ + echo " It must be valid JSON with a .commonsRepository object containing" >&2; \ + echo " \"url\", \"branch\", and \"name\" (python3 is required to parse it)." >&2; \ + echo " Halting before cloning the Commons_Repository: no Tests will run (Requirement 4.9)." >&2; \ + exit 1; \ + fi; \ + { read -r cfg_url; read -r cfg_branch; read -r cfg_name; } <<< "$$coords"; \ + \ + : "--- select the branch: invocation override (Req 4.8) or the configured entry ---"; \ + if [ -n "$(strip $(COMMONS_BRANCH))" ]; then \ + branch="$(strip $(COMMONS_BRANCH))"; reason="invocation-override"; \ + else \ + branch="$$cfg_branch"; reason="configuration-entry"; \ + fi; \ + url="$(strip $(COMMONS_REPO))"; if [ -z "$$url" ]; then url="$$cfg_url"; fi; \ + \ + : "--- 2. clone commons at the branch head (halt naming URL + branch: Req 4.10) ---"; \ + echo "==> Cloning the Commons_Repository ($$cfg_name) at head of '$$branch' ($$reason)"; \ + echo " url: $$url"; \ + echo " branch: $$branch"; \ + echo " into: $(CLONE_DIR)"; \ + rm -rf "$(CLONE_DIR)"; \ + if ! git clone --depth 1 --single-branch --branch "$$branch" "$$url" "$(CLONE_DIR)"; then \ + echo "ERROR: failed to clone the Commons_Repository." >&2; \ + echo " url: $$url" >&2; \ + echo " branch: $$branch" >&2; \ + echo " The repository may be unreachable or the branch may not exist." >&2; \ + echo " Halting: no Tests will run (Requirement 4.10)." >&2; \ + exit 1; \ + fi; \ + if [ ! -d "$(CLONE_ORCH_DIR)" ]; then \ + echo "ERROR: the cloned Commons_Repository has no orchestrator core at esdk/test-server/orchestrator." >&2; \ + echo " url: $$url" >&2; \ + echo " branch: $$branch" >&2; \ + echo " The branch may not contain the ESDK TestServer." >&2; \ + echo " Halting: no Tests will run (Requirement 4.10)." >&2; \ + exit 1; \ + fi; \ + \ + : "--- 3. delegate to the orchestrator core in the clone (exit code propagates) ---"; \ + echo "==> Delegating to the orchestrator core in the clone"; \ + echo " context=language:java languageRepoRoot=$(REPO_ROOT)"; \ + echo " commonsOrigin.url=$$url commonsOrigin.branch=$$branch commonsOrigin.reason=$$reason"; \ + export JAVA_HOME="$(HARNESS_JAVA_HOME)"; \ + cd "$(CLONE_ORCH_DIR)"; \ + ./gradlew --console=plain run \ + --args="context=language:java languageRepoRoot=$(REPO_ROOT) commonsOrigin.url=$$url commonsOrigin.branch=$$branch commonsOrigin.reason=$$reason" + +# ---------------------------------------------------------------------------- +# 12.1: the relocated Java Language_Server's OWN unit/property tests (jqwik + +# JUnit; no AWS credentials, no other Language_Server). The server build +# REQUIRES -PmodelDir pointing at the commons-hosted Smithy model (this repo +# carries no model copy, Requirement 1.7), so when MODEL_DIR is left at its +# default (inside $(CLONE_DIR)) and the model is not already there, this +# target first obtains commons exactly like `test-server` does: parse the +# Commons_Configuration_Entry (halt naming the file), then a depth-1 clone at +# the selected branch head (halt naming URL + branch). CI (the +# Language_Repository_Run workflow) calls this target before `test-server`; +# local runs may skip the clone entirely with +# MODEL_DIR=/esdk/test-server/model. +server-test: check-harness-java ## Run the Java Language_Server's own unit/property tests (MODEL_DIR= to use a local commons) + @set -eo pipefail; \ + model_dir="$(MODEL_DIR)"; \ + if [ ! -d "$$model_dir" ]; then \ + if [ "$$model_dir" != "$(CLONE_DIR)/esdk/test-server/model" ]; then \ + echo "ERROR: MODEL_DIR does not exist: $$model_dir" >&2; \ + echo " Point MODEL_DIR at the commons esdk/test-server/model directory." >&2; \ + exit 1; \ + fi; \ + : "--- obtain commons for its model (same bootstrap as test-server) ---"; \ + if ! coords=$$(python3 -c 'import json,sys; c=json.load(open(sys.argv[1]))["commonsRepository"]; print(c["url"]); print(c["branch"]); print(c["name"])' "$(COMMONS_CONFIGURATION)" 2>/dev/null); then \ + echo "ERROR: the Commons_Configuration_Entry is missing or unparseable." >&2; \ + echo " expected location: $(COMMONS_CONFIGURATION)" >&2; \ + echo " Cannot locate the commons Smithy model for the server build." >&2; \ + exit 1; \ + fi; \ + { read -r cfg_url; read -r cfg_branch; read -r cfg_name; } <<< "$$coords"; \ + if [ -n "$(strip $(COMMONS_BRANCH))" ]; then branch="$(strip $(COMMONS_BRANCH))"; else branch="$$cfg_branch"; fi; \ + url="$(strip $(COMMONS_REPO))"; if [ -z "$$url" ]; then url="$$cfg_url"; fi; \ + echo "==> Cloning the Commons_Repository ($$cfg_name) at head of '$$branch' for its Smithy model"; \ + echo " url: $$url"; \ + echo " branch: $$branch"; \ + echo " into: $(CLONE_DIR)"; \ + rm -rf "$(CLONE_DIR)"; \ + if ! git clone --depth 1 --single-branch --branch "$$branch" "$$url" "$(CLONE_DIR)"; then \ + echo "ERROR: failed to clone the Commons_Repository." >&2; \ + echo " url: $$url" >&2; \ + echo " branch: $$branch" >&2; \ + exit 1; \ + fi; \ + if [ ! -d "$$model_dir" ]; then \ + echo "ERROR: the cloned Commons_Repository has no model at esdk/test-server/model." >&2; \ + echo " url: $$url" >&2; \ + echo " branch: $$branch" >&2; \ + exit 1; \ + fi; \ + fi; \ + echo "==> Running the Java Language_Server tests (modelDir=$$model_dir)"; \ + export JAVA_HOME="$(HARNESS_JAVA_HOME)"; \ + cd "$(SERVER_DIR)"; \ + ./gradlew --console=plain test -PmodelDir="$$model_dir" + +# ---------------------------------------------------------------------------- +# 15.2: structural smoke check for the shipped factoring — complete +# Commons_Configuration_Entry (Req 4.4), product "esdk" (Req 8.2), the Java +# Feature_Declaration supporting streaming + MPL (Req 8.12), zero copies of +# the Tests definition (Req 10.6), and no standalone feature file (Req 8.2). +# Hermetic: filesystem + python3 only. +smoke-check: ## Run the structural smoke check for this Language_Repository + @bash "$(MAKEFILE_DIR)/tests/structural_smoke_check.sh" + +# ---------------------------------------------------------------------------- +# 11.3 + 15.2: the repository's hermetic checks — the bootstrap failure-path +# integration tests (missing/corrupt commons-configuration.json, bogus +# URL/branch — no Tests run in any case) followed by the structural smoke +# check. Both run under `set -e` so a failing suite fails the target. +it: ## Run the bootstrap failure-path integration tests and the structural smoke check + @set -e; \ + bash "$(MAKEFILE_DIR)/tests/clone_setup_failure_it.sh"; \ + bash "$(MAKEFILE_DIR)/tests/structural_smoke_check.sh" + +# ---------------------------------------------------------------------------- +clean: ## Remove the commons clone + @echo "==> Removing commons clone ($(CLONE_DIR))" + @rm -rf "$(CLONE_DIR)" diff --git a/test-server/README.md b/test-server/README.md new file mode 100644 index 00000000..bd640b26 --- /dev/null +++ b/test-server/README.md @@ -0,0 +1,47 @@ +# ESDK TestServer integration (aws-crypto-tools-java Language_Repository) + +This directory wires the live ESDK Java source into the cross-language +TestServer. It does **not** embed the TestServer; instead it clones the +`Commons_Repository` fresh at a branch head on every run and orchestrates the +TestServer against this repo's working tree as the live Java source. + +## `commons-source.json` — the single source of truth for commons coordinates + +`commons-source.json` is the **Commons_Source_Config** (Requirement 8.2): the +one place in this Language_Repository that names the `Commons_Repository` +coordinates — its `name`, repository `url`, and the `branch` to clone at head: + +```json +{ + "commonsRepository": { + "name": "aws-crypto-tools-commons", + "url": "git@github.com:aws/aws-crypto-tools-commons.git", + "branch": "kessplas/esdk-test-server" + } +} +``` + +Both the [`Makefile`](./Makefile) and the CI workflow +[`.github/workflows/esdk-test-server.yml`](../../.github/workflows/esdk-test-server.yml) +**read** these coordinates from this file (parsed with `python3` — no `jq` +dependency) rather than carrying their own hardcoded defaults, so the two never +drift apart. + +- The Makefile exposes `COMMONS_REPO` / `COMMONS_BRANCH` whose **defaults** come + from this file; both stay overridable on the command line + (`make run COMMONS_BRANCH=some-branch`). +- The CI workflow resolves the branch from this file, unless a non-empty + `commons_branch` `workflow_dispatch`/`workflow_call` input overrides it. +- If this file is missing or unparseable, both fail fast with an error naming + it — it is the single source of truth, so its absence is a hard error. + +### Branch default note + +The design default for `branch` is `main`. Until the ESDK TestServer merges to +commons `main`, this file pins the `kessplas/esdk-test-server` feature branch so +the flow works out of the box. **Once the TestServer merges to commons `main`, +flip `branch` back to `main` here** — no other file needs to change. + +## Common targets + +Run `make help` for the full list and the resolved commons coordinates. diff --git a/test-server/bug-config.json b/test-server/bug-config.json new file mode 100644 index 00000000..d9d09d34 --- /dev/null +++ b/test-server/bug-config.json @@ -0,0 +1,8 @@ +[ + "decrypt-accepts-out-of-order-frame-sequence", + "create-client-accepts-zero-max-encrypted-data-keys", + "encrypt-stream-ignores-plaintext-length-bound", + "encrypt-rejects-empty-encryption-context-value", + "encrypt-accepts-reserved-prefix-encryption-context-key", + "encryption-context-value-length-capped-at-32767" +] diff --git a/test-server/feature-config.json b/test-server/feature-config.json new file mode 100644 index 00000000..1020db2b --- /dev/null +++ b/test-server/feature-config.json @@ -0,0 +1,23 @@ +{ + "supportedFeatures": [ + "streaming", + "MPL", + "hierarchical", + "raw-aes", + "raw-rsa", + "multi", + "aws-kms", + "aws-kms-multi", + "aws-kms-discovery", + "aws-kms-mrk", + "aws-kms-mrk-multi", + "aws-kms-mrk-discovery", + "aws-kms-rsa", + "required-encryption-context" + ], + "unsupportedFeatures": [ + "raw-ecdh", + "aws-kms-ecdh", + "caching" + ] +} diff --git a/test-server/server-config.json b/test-server/server-config.json new file mode 100644 index 00000000..18012872 --- /dev/null +++ b/test-server/server-config.json @@ -0,0 +1,8 @@ +{ + "commonsRepository": { + "name": "aws-crypto-tools-commons", + "url": "git@github.com:aws/aws-crypto-tools-commons.git", + "branch": "lucmcdon/esdk-test-server-all-languages" + }, + "product": "esdk" +} diff --git a/test-server/server/.gitignore b/test-server/server/.gitignore new file mode 100644 index 00000000..fc86d8d7 --- /dev/null +++ b/test-server/server/.gitignore @@ -0,0 +1,6 @@ +# Gradle +.gradle/ +build/ + +# Smithy build output +smithyprojections/ diff --git a/test-server/server/build.gradle.kts b/test-server/server/build.gradle.kts new file mode 100644 index 00000000..ed600fb2 --- /dev/null +++ b/test-server/server/build.gradle.kts @@ -0,0 +1,207 @@ +// Builds the Java Language_Server for the ESDK TestServer service over the +// rpcv2Cbor protocol. The server scaffolding (request decoding, response +// encoding, routing, error serialization) is generated from the single +// source-of-truth Smithy model hosted in the aws-crypto-tools-commons +// repository (esdk/test-server/model), supplied via the REQUIRED `modelDir` +// Gradle property, by the smithy-java `java-codegen` build plugin in SERVER +// mode (Requirement 1.7); only the operation handler bodies are hand-written. +// +// This repository carries NO copy of the model: the orchestrator always passes +// -PmodelDir=/esdk/test-server/model, and a developer +// running this module standalone passes it manually. +// +// The wire contract is identical to the one the single generated Java +// Test_Client (commons esdk/test-server/client-java) speaks, because both are +// generated from the same model with the same protocol declared once at the +// service level. + +plugins { + `java-library` + // Runs the Smithy build (and thus the java-codegen plugin) during the + // Gradle build. Version comes from gradle.properties via settings. + id("software.amazon.smithy.gradle.smithy-base") +} + +repositories { + // mavenLocal() is listed FIRST so that when the resolved ESDK Java library + // source has been installed to the local Maven repository (by the + // orchestrator's JavaLaunchPlan: mvn versions:set -> install -> revert + // under a distinct version), a matching + // `com.amazonaws:aws-encryption-sdk-java:` there is consumed + // as the LIVE source in place of the published GA artifact. The live flow + // installs a DISTINCT version (e.g. 3.0.2-LIVE-SNAPSHOT) and passes + // `-PesdkVersion=`, so head/default runs still resolve the + // published artifact from Maven Central below and only an explicit live + // run picks up the local build. + mavenLocal() + mavenCentral() +} + +// smithy-java 1.x baselines on Java 21. Build with a JDK 21+ (set JAVA_HOME to a +// JDK 21 or newer when invoking Gradle). We intentionally do not pin a Java +// toolchain version here so the build uses whatever compatible JDK 21+ is +// configured for Gradle in the environment / CI, mirroring the client-java +// module. + +val smithyJavaVersion: String by project +val smithyProtocolTraitsVersion: String by project +val esdkVersion: String by project +val materialProvidersVersion: String by project +val awsSdkKmsVersion: String by project +val jqwikVersion: String by project +val junitVersion: String by project + +dependencies { + // --- Code generation (smithy build classpath only) --- + // The smithy-java code generation plugins, discovered by the smithyBuild + // task via SPI. + smithyBuild("software.amazon.smithy.java:codegen-plugin:$smithyJavaVersion") + // The rpcv2Cbor protocol trait definition must be resolvable while the + // model is built so `smithy.protocols#rpcv2Cbor` is understood by codegen. + smithyBuild("software.amazon.smithy:smithy-protocol-traits:$smithyProtocolTraitsVersion") + + // --- Runtime dependencies of the generated server --- + // server-core is required by all generated smithy-java servers (routing, + // request/response plumbing, the operation/service abstractions). + api("software.amazon.smithy.java:server-core:$smithyJavaVersion") + // rpcv2Cbor server protocol implementation (request decoding / response and + // error encoding), discovered at runtime via SPI; this is the protocol + // declared once at the service level in the model. + api("software.amazon.smithy.java:server-rpcv2-cbor:$smithyJavaVersion") + // The rpcv2Cbor codec is used directly by ConfigMarshaller to round-trip the + // config shapes through the exact wire form the protocol uses. + implementation("software.amazon.smithy.java:cbor-codec:$smithyJavaVersion") + // The runnable ServerBootstrap main() needs the Netty HTTP server provider + // (the ServerProvider SPI implementation) on its runtime classpath so + // Server.builder() can bind a real HTTP endpoint. This is required only for + // the standalone launcher / manual two-step run, not for the generated + // server sources themselves. + runtimeOnly("software.amazon.smithy.java:server-netty:$smithyJavaVersion") + + // --- Real ESDK Java delegation (Requirement 3.1, 4.2, 4.3) --- + // The CreateClient/Encrypt/Decrypt handlers delegate to the REAL AWS + // Encryption SDK for Java. For this pass we consume the published GA + // artifact from Maven Central (com.amazonaws:aws-encryption-sdk-java), which + // transitively pulls in the AWS Cryptographic Material Providers library + // (software.amazon.cryptography:aws-cryptographic-material-providers) used to + // construct keyrings and CMMs. This is aligned with the version the live + // product source declares (aws-crypto-tools-java/esdk/pom.xml -> 3.0.2). + // + // LIVE-SOURCE MODE (task 11): `esdkVersion` is overridable via + // `-PesdkVersion=`. Default runs resolve the published GA artifact from + // Maven Central. A live run installs THIS repo's working tree to the local + // Maven repository under a distinct version (e.g. 3.0.2-LIVE-SNAPSHOT) and + // passes `-PesdkVersion=3.0.2-LIVE-SNAPSHOT`; combined with mavenLocal() + // above, the server then delegates to the LIVE ESDK Java build rather than + // the published artifact. The ESDK Java `mvn install` consumes the AWS + // Cryptographic Material Providers library as a published artifact (the + // esdk/pom.xml declares aws-cryptographic-material-providers: from Maven + // Central), so no heavy Dafny/Smithy-Dafny transpile is required to build + // the live Java source. + implementation("com.amazonaws:aws-encryption-sdk-java:$esdkVersion") + // The handlers/config factory import the Material Providers keyring & CMM + // types directly, so declare the library explicitly (rather than leaning on + // the ESDK's transitive compile scope). Version aligned with the ESDK. + implementation("software.amazon.cryptography:aws-cryptographic-material-providers:$materialProvidersVersion") + // The AWS SDK KMS client. The Material Providers library above declares this + // only at `runtime` scope, but the EsdkClientFactory references KmsClient, + // EncryptionAlgorithmSpec, and GetPublicKeyRequest directly to fully wire the + // five KMS keyring variants (AwsKms/AwsKmsMrk/AwsKmsMultiKeyring/AwsKmsRsa/ + // AwsKmsDiscovery, task 15.3), so it must be on the compile classpath. Pinned + // to the version the Material Providers BOM (2.26.3) resolves. Construction of + // a KMS keyring performs no network call; only Encrypt/Decrypt reach AWS KMS. + implementation("software.amazon.awssdk:kms:$awsSdkKmsVersion") + // The hierarchical keyring's branch-key store reads from DynamoDB. + implementation("software.amazon.awssdk:dynamodb:$awsSdkKmsVersion") + + // --- Test dependencies --- + // jqwik: the established Java property-based testing library used for the + // harness-logic property tests (do not hand-roll PBT). + testImplementation("net.jqwik:jqwik:$jqwikVersion") + testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion") +} + +// The shared model is owned by the model/ package; this server only consumes +// it. Disable the formatter so building the server never rewrites the single +// source-of-truth model file (Requirement 1.1). +smithy { + format.set(false) +} + +// Use the single source-of-truth model hosted in the Commons_Repository +// (Requirement 1.7) rather than a copy. The location is supplied via the +// REQUIRED `modelDir` Gradle property; fail fast with a clear message when it +// is absent so a bare `./gradlew build` cannot silently pick up a stale or +// wrong model. +val modelDir: String = providers.gradleProperty("modelDir").orNull + ?: throw GradleException( + "The Java Language_Server consumes the Smithy model from the commons repository: " + + "pass -PmodelDir=" + ) + +sourceSets { + main { + smithy { + srcDir(modelDir) + } + } +} + +// Add the generated server sources/resources to the main sourceSet so they are +// compiled alongside the hand-written handlers. +afterEvaluate { + val serverPath = smithy.getPluginProjectionPath(smithy.sourceProjection.get(), "java-codegen").get() + sourceSets { + main { + java { + srcDir("$serverPath/java") + } + resources { + srcDir("$serverPath/resources") + } + } + } +} + +// Ensure code generation runs before compilation / resource processing. +tasks.named("compileJava") { + dependsOn("smithyBuild") +} + +tasks.named("processResources") { + dependsOn("smithyBuild") +} + +tasks.withType().configureEach { + useJUnitPlatform { + // jqwik registers its own JUnit Platform engine; include it explicitly. + includeEngines("jqwik", "junit-jupiter") + } +} + +// A minimal runnable launcher for the Java Language_Server (task 5 support, NOT +// the full orchestrator of task 7). Starts the smithy-java rpcv2Cbor HTTP server +// on a configurable port so a user can run a real over-HTTP round trip manually: +// +// Terminal 1 (start the server on port 8080): +// JAVA_HOME= ./gradlew runServer +// # or choose a port: +// JAVA_HOME= ./gradlew runServer --args="9090" +// # or: JAVA_HOME= ./gradlew runServer -Pport=9090 +// +// Terminal 2 (point the Tests at it — from ../../tests): +// JAVA_HOME= ./gradlew test -Desdk.testserver.endpoints=http://127.0.0.1:8080 +// +// The port may also be supplied via -Pport=, the system property +// esdk.testserver.port, or the ESDK_TESTSERVER_PORT env var (see ServerBootstrap). +tasks.register("runServer") { + group = "application" + description = "Start the Java Language_Server (rpcv2Cbor HTTP) on a configurable port." + mainClass.set("aws.cryptography.esdk.testserver.server.launcher.ServerBootstrap") + classpath = sourceSets["main"].runtimeClasspath + // Allow `-Pport=` as a convenience in addition to CLI args / sys prop / env. + (project.findProperty("port") as String?)?.let { + systemProperty("esdk.testserver.port", it) + } +} diff --git a/test-server/server/gradle.properties b/test-server/server/gradle.properties new file mode 100644 index 00000000..52224325 --- /dev/null +++ b/test-server/server/gradle.properties @@ -0,0 +1,41 @@ +# Versions for the Java Language_Server, kept in lockstep with the single +# generated Java Test_Client (../client-java) so the wire contract is identical +# on both ends. smithy-java 1.4.0 is the current stable release on Maven Central; +# the Smithy Gradle plugin (smithy-base) is versioned independently and is also +# at 1.4.0. smithyProtocolTraitsVersion matches the pin used by the model's own +# smithy-build.json so the rpcv2Cbor protocol trait resolves identically during +# code generation. +# +# The smithy-java codegen version and the smithy-java server runtime are kept in +# lockstep (both 1.4.0). +smithyGradleVersion=1.4.0 +smithyJavaVersion=1.4.0 +smithyProtocolTraitsVersion=1.58.0 + +# The published AWS Encryption SDK for Java GA artifact, consumed from Maven +# Central for this pass to prove the harness end-to-end. Aligned with the +# version the live product source declares (aws-crypto-tools-java/esdk 3.0.2); +# it transitively brings in the AWS Cryptographic Material Providers library. +esdkVersion=3.0.2 + +# The AWS Cryptographic Material Providers library (keyrings & CMMs), aligned +# with the version the ESDK 3.0.2 product source declares. +materialProvidersVersion=1.7.0 + +# The AWS SDK for Java v2 KMS client version. The Material Providers library +# 1.7.0 declares the AWS SDK KMS dependency only at `runtime` scope (via the +# `software.amazon.awssdk:bom` 2.26.3), so the EsdkClientFactory — which now +# references `KmsClient`, `EncryptionAlgorithmSpec`, and `GetPublicKeyRequest` +# directly to fully wire the five KMS keyring variants (task 15.3) — declares +# the KMS SDK explicitly on the compile classpath, pinned to the same version +# the Material Providers BOM resolves so the two stay in lockstep. +awsSdkKmsVersion=2.26.3 + +# jqwik is the established Java property-based testing library used for the +# harness-logic property tests (design Testing Strategy). JUnit 5 platform hosts +# both jqwik and the example-based unit tests. +jqwikVersion=1.9.2 +junitVersion=5.11.3 + +# Run the Smithy CLI in a forked process to isolate its classloader from Gradle. +org.gradle.jvmargs=-Xmx2g diff --git a/test-server/server/gradle/wrapper/gradle-wrapper.jar b/test-server/server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 zcma&OWmKeVvL#I6?i3D%6z=Zs?ofE*?rw#G$eqJB ziT4y8-Y@s9rkH0Tz>ll(^xkcTl)CY?rS&9VNd66Yc)g^6)JcWaY(5$5gt z8gr3SBXUTN;~cBgz&})qX%#!Fxom2Yau_`&8)+6aSN7YY+pS410rRUU*>J}qL0TnJ zRxt*7QeUqTh8j)Q&iavh<}L+$Jqz))<`IfKussVk%%Ah-Ti?Eo0hQH!rK%K=#EAw0 zwq@@~XNUXRnv8$;zv<6rCRJ6fPD^hfrh;0K?n z=p!u^3xOgWZ%f3+?+>H)9+w^$Tn1e;?UpVMJb!!;f)`6f&4|8mr+g)^@x>_rvnL0< zvD0Hu_N>$(Li7|Jgu0mRh&MV+<}`~Wi*+avM01E)Jtg=)-vViQKax!GeDc!xv$^mL z{#OVBA$U{(Zr8~Xm|cP@odkHC*1R8z6hcLY#N@3E-A8XEvpt066+3t9L_6Zg6j@9Q zj$$%~yO-OS6PUVrM2s)(T4#6=JpI_@Uz+!6=GdyVU?`!F=d;8#ZB@(5g7$A0(`eqY z8_i@3w$0*es5mrSjhW*qzrl!_LQWs4?VfLmo1Sd@Ztt53+etwzAT^8ow_*7Jp`Y|l z*UgSEwvxq+FYO!O*aLf-PinZYne7Ib6ny3u>MjQz=((r3NTEeU4=-i0LBq3H-VJH< z^>1RE3_JwrclUn9vb7HcGUaFRA0QHcnE;6)hnkp%lY1UII#WPAv?-;c?YH}LWB8Nl z{sx-@Z;QxWh9fX8SxLZk8;kMFlGD3Jc^QZVL4nO)1I$zQwvwM&_!kW+LMf&lApv#< zur|EyC|U@5OQuph$TC_ZU`{!vJp`13e9alaR0Dbn5ikLFH7>eIz4QbV|C=%7)F=qo z_>M&5N)d)7G(A%c>}UCrW!Ql_6_A{?R7&CL`;!KOb3 z8Z=$YkV-IF;c7zs{3-WDEFJzuakFbd*4LWd<_kBE8~BFcv}js_2OowRNzWCtCQ6&k z{&~Me92$m*@e0ANcWKuz)?YjB*VoSTx??-3Cc0l2U!X^;Bv@m87eKHukAljrD54R+ zE;@_w4NPe1>3`i5Qy*3^E9x#VB6?}v=~qIprrrd5|DFkg;v5ixo0IsBmik8=Y;zv2 z%Bcf%NE$a44bk^`i4VwDLTbX=q@j9;JWT9JncQ!+Y%2&HHk@1~*L8-{ZpY?(-a9J-1~<1ltr9i~D9`P{XTIFWA6IG8c4;6bFw*lzU-{+?b&%OcIoCiw00n>A1ra zFPE$y@>ebbZlf(sN_iWBzQKDV zmmaLX#zK!@ZdvCANfwV}9@2O&w)!5gSgQzHdk2Q`jG6KD7S+1R5&F)j6QTD^=hq&7 zHUW+r^da^%V(h(wonR(j?BOiC!;y=%nJvz?*aW&5E87qq;2z`EI(f zBJNNSMFF9U{sR-af5{IY&AtoGcoG)Iq-S^v{7+t0>7N(KRoPj;+2N5;9o_nxIGjJ@ z7bYQK)bX)vEhy~VL%N6g^NE@D5VtV+Q8U2%{ji_=6+i^G%xeskEhH>Sqr194PJ$fB zu1y^){?9Vkg(FY2h)3ZHrw0Z<@;(gd_dtF#6y_;Iwi{yX$?asr?0N0_B*CifEi7<6 zq`?OdQjCYbhVcg+7MSgIM|pJRu~`g?g3x?Tl+V}#$It`iD1j+!x+!;wS0+2e>#g?Z z*EA^k7W{jO1r^K~cD#5pamp+o@8&yw6;%b|uiT?{Wa=4+9<}aXWUuL#ZwN1a;lQod zW{pxWCYGXdEq9qAmvAB904}?97=re$>!I%wxPV#|f#@A*Y=qa%zHlDv^yWbR03%V0 zprLP+b(#fBqxI%FiF*-n8HtH6$8f(P6!H3V^ysgd8de-N(@|K!A< z^qP}jp(RaM9kQ(^K(U8O84?D)aU(g?1S8iWwe)gqpHCaFlJxb*ilr{KTnu4_@5{K- z)n=CCeCrPHO0WHz)dDtkbZfUfVBd?53}K>C5*-wC4hpDN8cGk3lu-ypq+EYpb_2H; z%vP4@&+c2p;thaTs$dc^1CDGlPG@A;yGR5@$UEqk6p58qpw#7lc<+W(WR;(vr(D>W z#(K$vE#uBkT=*q&uaZwzz=P5mjiee6>!lV?c}QIX%ZdkO1dHg>Fa#xcGT6~}1*2m9 zkc7l3ItD6Ie~o_aFjI$Ri=C!8uF4!Ky7iG9QTrxVbsQroi|r)SAon#*B*{}TB-?=@ z8~jJs;_R2iDd!$+n$%X6FO&PYS{YhDAS+U2o4su9x~1+U3z7YN5o0qUK&|g^klZ6X zj_vrM5SUTnz5`*}Hyts9ADwLu#x_L=nv$Z0`HqN`Zo=V>OQI)fh01n~*a%01%cx%0 z4LTFVjmW+ipVQv5rYcn3;d2o4qunWUY!p+?s~X~(ost@WR@r@EuDOSs8*MT4fiP>! zkfo^!PWJJ1MHgKS2D_hc?Bs?isSDO61>ebl$U*9*QY(b=i&rp3@3GV@z>KzcZOxip z^dzA~44;R~cnhWz7s$$v?_8y-k!DZys}Q?4IkSyR!)C0j$(Gm|t#e3|QAOFaV2}36 z?dPNY;@I=FaCwylc_;~kXlZsk$_eLkNb~TIl8QQ`mmH&$*zwwR8zHU*sId)rxHu*K z;yZWa8UmCwju%aSNLwD5fBl^b0Ux1%q8YR*uG`53Mi<`5uA^Dc6Ync)J3N7;zQ*75)hf%a@{$H+%S?SGT)ks60)?6j$ zspl|4Ad6@%-r1t*$tT(en!gIXTUDcsj?28ZEzz)dH)SV3bZ+pjMaW0oc~rOPZP@g! zb9E+ndeVO_Ib9c_>{)`01^`ZS198 z)(t=+{Azi11$eu%aU7jbwuQrO`vLOixuh~%4z@mKr_Oc;F%Uq01fA)^W&y+g16e?rkLhTxV!EqC%2}sx_1u7IBq|}Be&7WI z4I<;1-9tJsI&pQIhj>FPkQV9{(m!wYYV@i5h?A0#BN2wqlEwNDIq06|^2oYVa7<~h zI_OLan0Do*4R5P=a3H9`s5*>xU}_PSztg`+2mv)|3nIy=5#Z$%+@tZnr> zLcTI!Mxa`PY7%{;KW~!=;*t)R_sl<^b>eNO@w#fEt(tPMg_jpJpW$q_DoUlkY|uo> z0-1{ouA#;t%spf*7VjkK&$QrvwUERKt^Sdo)5@?qAP)>}Y!h4(JQ!7{wIdkA+|)bv z&8hBwoX4v|+fie}iTslaBX^i*TjwO}f{V)8*!dMmRPi%XAWc8<_IqK1jUsApk)+~R zNFTCD-h>M5Y{qTQ&0#j@I@tmXGj%rzhTW5%Bkh&sSc=$Fv;M@1y!zvYG5P2(2|(&W zlcbR1{--rJ&s!rB{G-sX5^PaM@3EqWVz_y9cwLR9xMig&9gq(voeI)W&{d6j1jh&< zARXi&APWE1FQWh7eoZjuP z;vdgX>zep^{{2%hem;e*gDJhK1Hj12nBLIJoL<=0+8SVEBx7!4Ea+hBY;A1gBwvY<)tj~T=H`^?3>zeWWm|LAwo*S4Z%bDVUe z6r)CH1H!(>OH#MXFJ2V(U(qxD{4Px2`8qfFLG+=a;B^~Te_Z!r3RO%Oc#ZAHKQxV5 zRYXxZ9T2A%NVJIu5Pu7!Mj>t%YDO$T@M=RR(~mi%sv(YXVl`yMLD;+WZ{vG9(@P#e zMo}ZiK^7^h6TV%cG+;jhJ0s>h&VERs=tuZz^Tlu~%d{ZHtq6hX$V9h)Bw|jVCMudd zwZ5l7In8NT)qEPGF$VSKg&fb0%R2RnUnqa){)V(X(s0U zkCdVZe6wy{+_WhZh3qLp245Y2RR$@g-!9PjJ&4~0cFSHMUn=>dapv)hy}|y91ZWTV zCh=z*!S3_?`$&-eZ6xIXUq8RGl9oK0BJw*TdU6A`LJqX9eS3X@F)g$jLkBWFscPhR zpCv8#KeAc^y>>Y$k^=r|K(DTC}T$0#jQBOwB#@`P6~*IuW_8JxCG}J4va{ zsZzt}tt+cv7=l&CEuVtjD6G2~_Meh%p4RGuY?hSt?(sreO_F}8r7Kp$qQdvCdZnDQ zxzc*qchE*E2=WK)^oRNa>Ttj`fpvF-JZ5tu5>X1xw)J@1!IqWjq)ESBG?J|ez`-Tc zi5a}GZx|w-h%5lNDE_3ho0hEXMoaofo#Z;$8|2;EDF&*L+e$u}K=u?pb;dv$SXeQM zD-~7P0i_`Wk$#YP$=hw3UVU+=^@Kuy$>6?~gIXx636jh{PHly_a2xNYe1l60`|y!7 z(u%;ILuW0DDJ)2%y`Zc~hOALnj1~txJtcdD#o4BCT68+8gZe`=^te6H_egxY#nZH&P*)hgYaoJ^qtmpeea`35Fw)cy!w@c#v6E29co8&D9CTCl%^GV|X;SpneSXzV~LXyRn-@K0Df z{tK-nDWA!q38M1~`xUIt_(MO^R(yNY#9@es9RQbY@Ia*xHhD&=k^T+ zJi@j2I|WcgW=PuAc>hs`(&CvgjL2a9Rx zCbZyUpi8NWUOi@S%t+Su4|r&UoU|ze9SVe7p@f1GBkrjkkq)T}X%Qo1g!SQ{O{P?m z-OfGyyWta+UCXH+-+(D^%kw#A1-U;?9129at7MeCCzC{DNgO zeSqsV>W^NIfTO~4({c}KUiuoH8A*J!Cb0*sp*w-Bg@YfBIPZFH!M}C=S=S7PLLcIG zs7K77g~W)~^|+mx9onzMm0qh(f~OsDTzVmRtz=aZTllgR zGUn~_5hw_k&rll<4G=G+`^Xlnw;jNYDJz@bE?|r866F2hA9v0-8=JO3g}IHB#b`hy zA42a0>{0L7CcabSD+F7?pGbS1KMvT{@1_@k!_+Ki|5~EMGt7T%u=79F)8xEiL5!EJ zzuxQ`NBliCoJMJdwu|);zRCD<5Sf?Y>U$trQ-;xj6!s5&w=9E7)%pZ+1Nh&8nCCwM zv5>Ket%I?cxr3vVva`YeR?dGxbG@pi{H#8@kFEf0Jq6~K4>kt26*bxv=P&jyE#e$| zDJB_~imk^-z|o!2njF2hL*|7sHCnzluhJjwLQGDmC)Y9 zr9ZN`s)uCd^XDvn)VirMgW~qfn1~SaN^7vcX#K1G`==UGaDVVx$0BQnubhX|{e z^i0}>k-;BP#Szk{cFjO{2x~LjK{^Upqd&<+03_iMLp0$!6_$@TbX>8U-f*-w-ew1?`CtD_0y_Lo|PfKi52p?`5$Jzx0E8`M0 zNIb?#!K$mM4X%`Ry_yhG5k@*+n4||2!~*+&pYLh~{`~o(W|o64^NrjP?-1Lgu?iK^ zTX6u3?#$?R?N!{599vg>G8RGHw)Hx&=|g4599y}mXNpM{EPKKXB&+m?==R3GsIq?G zL5fH={=zawB(sMlDBJ+{dgb)Vx3pu>L=mDV0{r1Qs{0Pn%TpopH{m(By4;{FBvi{I z$}x!Iw~MJOL~&)p93SDIfP3x%ROjg}X{Sme#hiJ&Yk&a;iR}V|n%PriZBY8SX2*;6 z4hdb^&h;Xz%)BDACY5AUsV!($lib4>11UmcgXKWpzRL8r2Srl*9Y(1uBQsY&hO&uv znDNff0tpHlLISam?o(lOp#CmFdH<6HmA0{UwfU#Y{8M+7od8b8|B|7ZYR9f<#+V|ZSaCQvI$~es~g(Pv{2&m_rKSB2QQ zMvT}$?Ll>V+!9Xh5^iy3?UG;dF-zh~RL#++roOCsW^cZ&({6q|?Jt6`?S8=16Y{oH zp50I7r1AC1(#{b`Aq5cw>ypNggHKM9vBx!W$eYIzD!4KbLsZGr2o8>g<@inmS3*>J zx8oG((8f!ei|M@JZB`p7+n<Q}?>h249<`7xJ?u}_n;Gq(&km#1ULN87CeTO~FY zS_Ty}0TgQhV zOh3T7{{x&LSYGQfKR1PDIkP!WnfC1$l+fs@Di+d4O=eVKeF~2fq#1<8hEvpwuqcaH z4A8u~r^gnY3u6}zj*RHjk{AHhrrDqaj?|6GaVJbV%o-nATw}ASFr!f`Oz|u_QPkR# z0mDudY1dZRlk@TyQ?%Eti=$_WNFtLpSx9=S^be{wXINp%MU?a`F66LNU<c;0&ngifmP9i;bj6&hdGMW^Kf8e6ZDXbQD&$QAAMo;OQ)G zW(qlHh;}!ZP)JKEjm$VZjTs@hk&4{?@+NADuYrr!R^cJzU{kGc1yB?;7mIyAWwhbeA_l_lw-iDVi7wcFurf5 z#Uw)A@a9fOf{D}AWE%<`s1L_AwpZ?F!Vac$LYkp<#A!!`XKaDC{A%)~K#5z6>Hv@V zBEqF(D5?@6r3Pwj$^krpPDCjB+UOszqUS;b2n>&iAFcw<*im2(b3|5u6SK!n9Sg4I z0KLcwA6{Mq?p%t>aW0W!PQ>iUeYvNjdKYqII!CE7SsS&Rj)eIw-K4jtI?II+0IdGq z2WT|L3RL?;GtGgt1LWfI4Ka`9dbZXc$TMJ~8#Juv@K^1RJN@yzdLS8$AJ(>g!U9`# zx}qr7JWlU+&m)VG*Se;rGisutS%!6yybi%B`bv|9rjS(xOUIvbNz5qtvC$_JYY+c& za*3*2$RUH8p%pSq>48xR)4qsp!Q7BEiJ*`^>^6INRbC@>+2q9?x(h0bpc>GaNFi$K zPH$6!#(~{8@0QZk=)QnM#I=bDx5vTvjm$f4K}%*s+((H2>tUTf==$wqyoI`oxI7>C z&>5fe)Yg)SmT)eA(|j@JYR1M%KixxC-Eceknf-;N=jJTwKvk#@|J^&5H0c+%KxHUI z6dQbwwVx3p?X<_VRVb2fStH?HH zFR@Mp=qX%#L3XL)+$PXKV|o|#DpHAoqvj6uQKe@M-mnhCSou7Dj4YuO6^*V`m)1lf z;)@e%1!Qg$10w8uEmz{ENb$^%u}B;J7sDd zump}onoD#!l=agcBR)iG!3AF0-63%@`K9G(CzKrm$VJ{v7^O9Ps7Zej|3m= zVXlR&yW6=Y%mD30G@|tf=yC7-#L!16Q=dq&@beWgaIL40k0n% z)QHrp2Jck#evLMM1RGt3WvQ936ZC9vEje0nFMfvmOHVI+&okB_K|l-;|4vW;qk>n~ z+|kk8#`K?x`q>`(f6A${wfw9Cx(^)~tX7<#TpxR#zYG2P+FY~mG{tnEkv~d6oUQA+ z&hNTL=~Y@rF`v-RZlts$nb$3(OL1&@Y11hhL9+zUb6)SP!;CD)^GUtUpCHBE`j1te zAGud@miCVFLk$fjsrcpjsadP__yj9iEZUW{Ll7PPi<$R;m1o!&Xdl~R_v0;oDX2z^!&8}zNGA}iYG|k zmehMd1%?R)u6R#<)B)1oe9TgYH5-CqUT8N7K-A-dm3hbm_W21p%8)H{O)xUlBVb+iUR}-v5dFaCyfSd zC6Bd7=N4A@+Bna=!-l|*_(nWGDpoyU>nH=}IOrLfS+-d40&(Wo*dDB9nQiA2Tse$R z;uq{`X7LLzP)%Y9aHa4YQ%H?htkWd3Owv&UYbr5NUDAH^<l@Z0Cx%`N+B*i!!1u>D8%;Qt1$ zE5O0{-`9gdDxZ!`0m}ywH!;c{oBfL-(BH<&SQ~smbcobU!j49O^f4&IIYh~f+hK*M zZwTp%{ZSAhMFj1qFaOA+3)p^gnXH^=)`NTYgTu!CLpEV2NF=~-`(}7p^Eof=@VUbd z_9U|8qF7Rueg&$qpSSkN%%%DpbV?8E8ivu@ensI0toJ7Eas^jyFReQ1JeY9plb^{m z&eQO)qPLZQ6O;FTr*aJq=$cMN)QlQO@G&%z?BKUs1&I^`lq>=QLODwa`(mFGC`0H< zOlc*|N?B5&!U6BuJvkL?s1&nsi$*5cCv7^j_*l&$-sBmRS85UIrE--7eD8Gr3^+o? zqG-Yl4S&E;>H>k^a0GdUI(|n1`ws@)1%sq2XBdK`mqrNq_b4N{#VpouCXLzNvjoFv zo9wMQ6l0+FT+?%N(ka*;%m~(?338bu32v26!{r)|w8J`EL|t$}TA4q_FJRX5 zCPa{hc_I(7TGE#@rO-(!$1H3N-C0{R$J=yPCXCtGk{4>=*B56JdXU9cQVwB`6~cQZ zf^qK21x_d>X%dT!!)CJQ3mlHA@ z{Prkgfs6=Tz%63$6Zr8CO0Ak3A)Cv#@BVKr&aiKG7RYxY$Yx>Bj#3gJk*~Ps-jc1l z;4nltQwwT4@Z)}Pb!3xM?+EW0qEKA)sqzw~!C6wd^{03-9aGf3Jmt=}w-*!yXupLf z;)>-7uvWN4Unn8b4kfIza-X=x*e4n5pU`HtgpFFd))s$C@#d>aUl3helLom+RYb&g zI7A9GXLRZPl}iQS*d$Azxg-VgcUr*lpLnbPKUV{QI|bsG{8bLG<%CF( zMoS4pRDtLVYOWG^@ox^h8xL~afW_9DcE#^1eEC1SVSb1BfDi^@g?#f6e%v~Aw>@w- zIY0k+2lGWNV|aA*e#`U3=+oBDmGeInfcL)>*!w|*;mWiKNG6wP6AW4-4imN!W)!hE zA02~S1*@Q`fD*+qX@f3!2yJX&6FsEfPditB%TWo3=HA;T3o2IrjS@9SSxv%{{7&4_ zdS#r4OU41~GYMiib#z#O;zohNbhJknrPPZS6sN$%HB=jUnlCO_w5Gw5EeE@KV>soy z2EZ?Y|4RQDDjt5y!WBlZ(8M)|HP<0YyG|D%RqD+K#e7-##o3IZxS^wQ5{Kbzb6h(i z#(wZ|^ei>8`%ta*!2tJzwMv+IFHLF`zTU8E^Mu!R*45_=ccqI};Zbyxw@U%a#2}%f zF>q?SrUa_a4H9l+uW8JHh2Oob>NyUwG=QH~-^ZebU*R@67DcXdz2{HVB4#@edz?B< z5!rQH3O0>A&ylROO%G^fimV*LX7>!%re{_Sm6N>S{+GW1LCnGImHRoF@csnFzn@P0 zM=jld0z%oz;j=>c7mMwzq$B^2mae7NiG}%>(wtmsDXkWk{?BeMpTrIt3Mizq?vRsf zi_WjNp+61uV(%gEU-Vf0;>~vcDhe(dzWdaf#4mH3o^v{0EWhj?E?$5v02sV@xL0l4 zX0_IMFtQ44PfWBbPYN#}qxa%=J%dlR{O!KyZvk^g5s?sTNycWYPJ^FK(nl3k?z-5t z39#hKrdO7V(@!TU)LAPY&ngnZ1MzLEeEiZznn7e-jLCy8LO zu^7_#z*%I-BjS#Pg-;zKWWqX-+Ly$T!4`vTe5ZOV0j?TJVA*2?*=82^GVlZIuH%9s zXiV&(T(QGHHah=s&7e|6y?g+XxZGmK55`wGV>@1U)Th&=JTgJq>4mI&Av2C z)w+kRoj_dA!;SfTfkgMPO>7Dw6&1*Hi1q?54Yng`JO&q->^CX21^PrU^JU#CJ_qhV zSG>afB%>2fx<~g8p=P8Yzxqc}s@>>{g7}F!;lCXvF#RV)^fyYb_)iKVCz1xEq=fJ| z0a7DMCK*FuP=NM*5h;*D`R4y$6cpW-E&-i{v`x=Jbk_xSn@2T3q!3HoAOB`@5Vg6) z{PW|@9o!e;v1jZ2{=Uw6S6o{g82x6g=k!)cFSC*oemHaVjg?VpEmtUuD2_J^A~$4* z3O7HsbA6wxw{TP5Kk)(Vm?gKo+_}11vbo{Tp_5x79P~#F)ahQXT)tSH5;;14?s)On zel1J>1x>+7;g1Iz2FRpnYz;sD0wG9Q!vuzE9yKi3@4a9Nh1!GGN?hA)!mZEnnHh&i zf?#ZEN2sFbf~kV;>K3UNj1&vFhc^sxgj8FCL4v>EOYL?2uuT`0eDH}R zmtUJMxVrV5H{L53hu3#qaWLUa#5zY?f5ozIn|PkMWNP%n zWB5!B0LZB0kLw$k39=!akkE9Q>F4j+q434jB4VmslQ;$ zKiO#FZ`p|dKS716jpcvR{QJkSNfDVhr2%~eHrW;fU45>>snr*S8Vik-5eN5k*c2Mp zyxvX&_cFbB6lODXznHHT|rsURe2!swomtrqc~w5 zymTM8!w`1{04CBprR!_F{5LB+2_SOuZN{b*!J~1ZiPpP-M;);!ce!rOPDLtgR@Ie1 zPreuqm4!H)hYePcW1WZ0Fyaqe%l}F~Orr)~+;mkS&pOhP5Ebb`cnUt!X_QhP4_4p( z8YKQCDKGIy>?WIFm3-}Br2-N`T&FOi?t)$hjphB9wOhBXU#Hb+zm&We_-O)s(wc`2 z8?VsvU;J>Ju7n}uUb3s1yPx_F*|FlAi=Ge=-kN?1;`~6szP%$3B0|8Sqp%ebM)F8v zADFrbeT0cgE>M0DMV@_Ze*GHM>q}wWMzt|GYC%}r{OXRG3Ij&<+nx9;4jE${Fj_r* z`{z1AW_6Myd)i6e0E-h&m{{CvzH=Xg!&(bLYgRMO_YVd8JU7W+7MuGWNE=4@OvP9+ zxi^vqS@5%+#gf*Z@RVyU9N1sO-(rY$24LGsg1>w>s6ST^@)|D9>cT50maXLUD{Fzf zt~tp{OSTEKg3ZSQyQQ5r51){%=?xlZ54*t1;Ow)zLe3i?8tD8YyY^k%M)e`V*r+vL zPqUf&m)U+zxps+NprxMHF{QSxv}>lE{JZETNk1&F+R~bp{_T$dbXL2UGnB|hgh*p4h$clt#6;NO~>zuyY@C-MD@)JCc5XrYOt`wW7! z_ti2hhZBMJNbn0O-uTxl_b6Hm313^fG@e;RrhIUK9@# z+DHGv_Ow$%S8D%RB}`doJjJy*aOa5mGHVHz0e0>>O_%+^56?IkA5eN+L1BVCp4~m=1eeL zb;#G!#^5G%6Mw}r1KnaKsLvJB%HZL)!3OxT{k$Yo-XrJ?|7{s4!H+S2o?N|^Z z)+?IE9H7h~Vxn5hTis^3wHYuOU84+bWd)cUKuHapq=&}WV#OxHpLab`NpwHm8LmOo zjri+!k;7j_?FP##CpM+pOVx*0wExEex z@`#)K<-ZrGyArK;a%Km`^+We|eT+#MygHOT6lXBmz`8|lyZOwL1+b+?Z$0OhMEp3R z&J=iRERpv~TC=p2-BYLC*?4 zxvPs9V@g=JT0>zky5Poj=fW_M!c)Xxz1<=&_ZcL=LMZJqlnO1P^xwGGW*Z+yTBvbV z-IFe6;(k1@$1;tS>{%pXZ_7w+i?N4A2=TXnGf=YhePg8bH8M|Lk-->+w8Y+FjZ;L=wSGwxfA`gqSn)f(XNuSm>6Y z@|#e-)I(PQ^G@N`%|_DZSb4_pkaEF0!-nqY+t#pyA>{9^*I-zw4SYA1_z2Bs$XGUZbGA;VeMo%CezHK0lO={L%G)dI-+8w?r9iexdoB{?l zbJ}C?huIhWXBVs7oo{!$lOTlvCLZ_KN1N+XJGuG$rh<^eUQIqcI7^pmqhBSaOKNRq zrx~w^?9C?*&rNwP_SPYmo;J-#!G|{`$JZK7DxsM3N^8iR4vvn>E4MU&Oe1DKJvLc~ zCT>KLZ1;t@My zRj_2hI^61T&LIz)S!+AQIV23n1>ng+LUvzv;xu!4;wpqb#EZz;F)BLUzT;8UA1x*6vJ zicB!3Mj03s*kGV{g`fpC?V^s(=JG-k1EMHbkdP4P*1^8p_TqO|;!Zr%GuP$8KLxuf z=pv*H;kzd;P|2`JmBt~h6|GxdU~@weK5O=X&5~w$HpfO}@l-T7@vTCxVOwCkoPQv8 z@aV_)I5HQtfs7^X=C03zYmH4m0S!V@JINm6#(JmZRHBD?T!m^DdiZJrhKpBcur2u1 zf9e4%k$$vcFopK5!CC`;ww(CKL~}mlxK_Pv!cOsFgVkNIghA2Au@)t6;Y3*2gK=5d z?|@1a)-(sQ%uFOmJ7v2iG&l&m^u&^6DJM#XzCrF%r>{2XKyxLD2rgWBD;i(!e4InDQBDg==^z;AzT2z~OmV0!?Z z0S9pX$+E;w3WN;v&NYT=+G8hf=6w0E1$0AOr61}eOvE8W1jX%>&Mjo7&!ulawgzLH zbcb+IF(s^3aj12WSi#pzIpijJJzkP?JzRawnxmNDSUR#7!29vHULCE<3Aa#be}ie~d|!V+ z%l~s9Odo$G&fH!t!+`rUT0T9DulF!Yq&BfQWFZV1L9D($r4H(}Gnf6k3^wa7g5|Ws zj7%d`!3(0bb55yhC6@Q{?H|2os{_F%o=;-h{@Yyyn*V7?{s%Grvpe!H^kl6tF4Zf5 z{Jv1~yZ*iIWL_9C*8pBMQArfJJ0d9Df6Kl#wa}7Xa#Ef_5B7=X}DzbQXVPfCwTO@9+@;A^Ti6il_C>g?A-GFwA0#U;t4;wOm-4oS})h z5&on>NAu67O?YCQr%7XIzY%LS4bha9*e*4bU4{lGCUmO2UQ2U)QOqClLo61Kx~3dI zmV3*(P6F_Tr-oP%x!0kTnnT?Ep5j;_IQ^pTRp=e8dmJtI4YgWd0}+b2=ATkOhgpXe z;jmw+FBLE}UIs4!&HflFr4)vMFOJ19W4f2^W(=2)F%TAL)+=F>IE$=e=@j-*bFLSg z)wf|uFQu+!=N-UzSef62u0-C8Zc7 zo6@F)c+nZA{H|+~7i$DCU0pL{0Ye|fKLuV^w!0Y^tT$isu%i1Iw&N|tX3kwFKJN(M zXS`k9js66o$r)x?TWL}Kxl`wUDUpwFx(w4Yk%49;$sgVvT~n8AgfG~HUcDt1TRo^s zdla@6heJB@JV z!vK;BUMznhzGK6PVtj0)GB=zTv6)Q9Yt@l#fv7>wKovLobMV-+(8)NJmyF8R zcB|_K7=FJGGn^X@JdFaat0uhKjp3>k#^&xE_}6NYNG?kgTp>2Iu?ElUjt4~E-?`Du z?mDCS9wbuS%fU?5BU@Ijx>1HG*N?gIP+<~xE4u=>H`8o((cS5M6@_OK%jSjFHirQK zN9@~NXFx*jS{<|bgSpC|SAnA@I)+GB=2W|JJChLI_mx+-J(mSJ!b)uUom6nH0#2^(L@JBlV#t zLl?j54s`Y3vE^c_3^Hl0TGu*tw_n?@HyO@ZrENxA+^!)OvUX28gDSF*xFtQzM$A+O zCG=n#6~r|3zt=8%GuG} z<#VCZ%2?3Q(Ad#Y7GMJ~{U3>E{5e@z6+rgZLX{Cxk^p-7dip^d29;2N1_mm4QkASo z-L`GWWPCq$uCo;X_BmGIpJFBlhl<8~EG{vOD1o|X$aB9KPhWO_cKiU*$HWEgtf=fn zsO%9bp~D2c@?*K9jVN@_vhR03>M_8h!_~%aN!Cnr?s-!;U3SVfmhRwk11A^8Ns`@KeE}+ zN$H}a1U6E;*j5&~Og!xHdfK5M<~xka)x-0N)K_&e7AjMz`toDzasH+^1bZlC!n()crk9kg@$(Y{wdKvbuUd04N^8}t1iOgsKF zGa%%XWx@WoVaNC1!|&{5ZbkopFre-Lu(LCE5HWZBoE#W@er9W<>R=^oYxBvypN#x3 zq#LC8&q)GFP=5^-bpHj?LW=)-g+3_)Ylps!3^YQ{9~O9&K)xgy zMkCWaApU-MI~e^cV{Je75Qr7eF%&_H)BvfyKL=gIA>;OSq(y z052BFz3E(Prg~09>|_Z@!qj}@;8yxnw+#Ej0?Rk<y}4ghbD569B{9hSFr*^ygZ zr6j7P#gtZh6tMk6?4V$*Jgz+#&ug;yOr>=qdI#9U&^am2qoh4Jy}H2%a|#Fs{E(5r z%!ijh;VuGA6)W)cJZx+;9Bp1LMUzN~x_8lQ#D3+sL{be-Jyeo@@dv7XguJ&S5vrH` z>QxOMWn7N-T!D@1(@4>ZlL^y5>m#0!HKovs12GRav4z!>p(1~xok8+_{| z#Ae4{9#NLh#Vj2&JuIn5$d6t@__`o}umFo(n0QxUtd2GKCyE+erwXY?`cm*h&^9*8 zJ+8x6fRZI-e$CRygofIQN^dWysCxgkyr{(_oBwwSRxZora1(%(aC!5BTtj^+YuevI zx?)H#(xlALUp6QJ!=l9N__$cxBZ5p&7;qD3PsXRFVd<({Kh+mShFWJNpy`N@ab7?9 zv5=klvCJ4bx|-pvOO2-+G)6O?$&)ncA#Urze2rlBfp#htudhx-NeRnJ@u%^_bfw4o z4|{b8SkPV3b>Wera1W(+N@p9H>dc6{cnkh-sgr?e%(YkWvK+0YXVwk0=d`)}*47*B z5JGkEdVix!w7-<%r0JF~`ZMMPe;f0EQHuYHxya`puazyph*ZSb1mJAt^k4549BfS; zK7~T&lRb=W{s&t`DJ$B}s-eH1&&-wEOH1KWsKn0a(ZI+G!v&W4A*cl>qAvUv6pbUR z#(f#EKV8~hk&8oayBz4vaswc(?qw1vn`yC zZQDl2PCB-&Uu@g9ZQHhO+v(W0bNig{-k0;;`+wM@#@J)8r?qOYs#&vUna8ILxN7S{ zp1s41KnR8miQJtJtOr|+qk}wrLt+N*z#5o`TmD1)E&QD(Vh&pjZJ_J*0!8dy_ z>^=@v=J)C`x&gjqAYu`}t^S=DFCtc0MkBU2zf|69?xW`Ck~(6zLD)gSE{7n~6w8j_ zoH&~$ED2k5-yRa0!r8fMRy z;QjBYUaUnpd}mf%iVFPR%Dg9!d>g`01m~>2s))`W|5!kc+_&Y>wD@@C9%>-lE`WB0 zOIf%FVD^cj#2hCkFgi-fgzIfOi+ya)MZK@IZhHT5FVEaSbv-oDDs0W)pA0&^nM0TW zmgJmd7b1R7b0a`UwWJYZXp4AJPteYLH>@M|xZFKwm!t3D3&q~av?i)WvAKHE{RqpD{{%OhYkK?47}+}` zrR2(Iv9bhVa;cDzJ%6ntcSbx7v7J@Y4x&+eWSKZ*eR7_=CVIUSB$^lfYe@g+p|LD{ zPSpQmxx@b$%d!05|H}WzBT4_cq?@~dvy<7s&QWtieJ9)hd4)$SZz}#H2UTi$CkFWW|I)v_-NjuH!VypONC=1`A=rm_jfzQ8Fu~1r8i{q-+S_j$ z#u^t&Xnfi5tZtl@^!fUJhx@~Cg0*vXMK}D{>|$#T*+mj(J_@c{jXBF|rm4-8%Z2o! z2z0o(4%8KljCm^>6HDK!{jI7p+RAPcty_~GZ~R_+=+UzZ0qzOwD=;YeZt*?3%UGdr z`c|BPE;yUbnyARUl&XWSNJ<+uRt%!xPF&K;(l$^JcA_CMH6)FZt{>6ah$|(9$2fc~ z=CD00uHM{qv;{Zk9FR0~u|3|Eiqv9?z2#^GqylT5>6JNZwKqKBzzQpKU2_pmtD;CT zi%Ktau!Y2Tldfu&b0UgmF(SSBID)15*r08eoUe#bT_K-G4VecJL2Pa=6D1K6({zj6 za(2Z{r!FY5W^y{qZ}08+h9f>EKd&PN90f}Sc0ejf%kB4+f#T8Q1=Pj=~#pi$U zp#5rMR%W25>k?<$;$x72pkLibu1N|jX4cWjD3q^Pk3js!uK6h7!dlvw24crL|MZs_ zb%Y%?Fyp0bY0HkG^XyS76Ts*|Giw{31LR~+WU5NejqfPr73Rp!xQ1mLgq@mdWncLy z%8}|nzS4P&`^;zAR-&nm5f;D-%yNQPwq4N7&yULM8bkttkD)hVU>h>t47`{8?n2&4 zjEfL}UEagLUYwdx0sB2QXGeRmL?sZ%J!XM`$@ODc2!y|2#7hys=b$LrGbvvjx`Iqi z&RDDm3YBrlKhl`O@%%&rhLWZ*ABFz2nHu7k~3@e4)kO3%$=?GEFUcCF=6-1n!x^vmu+Ai*amgXH+Rknl6U>#9w;A} zn2xanZSDu`4%%x}+~FG{Wbi1jo@wqBc5(5Xl~d0KW(^Iu(U3>WB@-(&vn_PJt9{1`e9Iic@+{VPc`vP776L*viP{wYB2Iff8hB%E3|o zGMOu)tJX!`qJ}ZPzq7>=`*9TmETN7xwU;^AmFZ-ckZjV5B2T09pYliaqGFY|X#E-8 z20b>y?(r-Fn5*WZ-GsK}4WM>@TTqsxvSYWL6>18q8Q`~JO1{vLND2wg@58OaU!EvT z1|o+f1mVXz2EKAbL!Q=QWQKDZpV|jznuJ}@-)1&cdo z^&~b4Mx{*1gurlH;Vhk5g_cM&6LOHS2 zRkLfO#HabR1JD4Vc2t828dCUG#DL}f5QDSBg?o)IYYi@_xVwR2w_ntlpAW0NWk$F1 z$If?*lP&Ka1oWfl!)1c3fl`g*lMW3JOn#)R1+tfwrs`aiFUgz3;XIJ>{QFxLCkK30 zNS-)#DON3yb!7LBHQJ$)4y%TN82DC2-9tOIqzhZ27@WY^<6}vXCWcR5iN{LN8{0u9 zNXayqD=G|e?O^*ms*4P?G%o@J1tN9_76e}E#66mr89%W_&w4n66~R;X_vWD(oArwj z4CpY`)_mH2FvDuxgT+akffhX0b_slJJ*?Jn3O3~moqu2Fs1oL*>7m=oVek2bnprnW zixkaIFU%+3XhNA@@9hyhFwqsH2bM|`P?G>i<-gy>NflhrN{$9?LZ1ynSE_Mj0rADF zhOz4FnK}wpLmQuV zgO4_Oz9GBu_NN>cPLA=`SP^$gxAnj;WjJnBi%Q1zg`*^cG;Q)#3Gv@c^j6L{arv>- zAW%8WrSAVY1sj$=umcAf#ZgC8UGZGoamK}hR7j6}i8#np8ruUlvgQ$j+AQglFsQQq zOjyHf22pxh9+h#n$21&$h?2uq0>C9P?P=Juw0|;oE~c$H{#RGfa>| zj)Iv&uOnaf@foiBJ}_;zyPHcZt1U~nOcNB{)og8Btv+;f@PIT*xz$x!G?u0Di$lo7 zOugtQ$Wx|C($fyJTZE1JvR~i7LP{ zbdIwqYghQAJi9p}V&$=*2Azev$6K@pyblphgpv8^9bN!?V}{BkC!o#bl&AP!3DAjM zmWFsvn2fKWCfjcAQmE+=c3Y7j@#7|{;;0f~PIodmq*;W9Fiak|gil6$w3%b_Pr6K_ zJEG@&!J%DgBZJDCMn^7mk`JV0&l07Bt`1ymM|;a)MOWz*bh2#d{i?SDe9IcHs7 zjCrnyQ*Y5GzIt}>`bD91o#~5H?4_nckAgotN{2%!?wsSl|LVmJht$uhGa+HiH>;av z8c?mcMYM7;mvWr6noUR{)gE!=i7cZUY7e;HXa221KkRoc2UB>s$Y(k%NzTSEr>W(u z<(4mcc)4rB_&bPzX*1?*ra%VF}P1nwiP5cykJ&W{!OTlz&Td0pOkVp+wc z@k=-Hg=()hNg=Q!Ub%`BONH{ z_=ZFgetj@)NvppAK2>8r!KAgi>#%*7;O-o9MOOfQjV-n@BX6;Xw;I`%HBkk20v`qoVd0)}L6_49y1IhR z_OS}+eto}OPVRn*?UHC{eGyFU7JkPz!+gX4P>?h3QOwGS63fv4D1*no^6PveUeE5% zlehjv_3_^j^C({a2&RSoVlOn71D8WwMu9@Nb@=E_>1R*ve3`#TF(NA0?d9IR_tm=P zOP-x;gS*vtyE1Cm zG0L?2nRUFj#aLr-R1fX*$sXhad)~xdA*=hF3zPZhha<2O$Ps+F07w*3#MTe?)T8|A!P!v+a|ot{|^$q(TX`35O{WI0RbU zCj?hgOv=Z)xV?F`@HKI11IKtT^ocP78cqHU!YS@cHI@{fPD?YXL)?sD~9thOAv4JM|K8OlQhPXgnevF=F7GKD2#sZW*d za}ma31wLm81IZxX(W#A9mBvLZr|PoLnP>S4BhpK8{YV_}C|p<)4#yO{#ISbco92^3 zv&kCE(q9Wi;9%7>>PQ!zSkM%qqqLZW7O`VXvcj;WcJ`2~v?ZTYB@$Q&^CTfvy?1r^ z;Cdi+PTtmQwHX_7Kz?r#1>D zS5lWU(Mw_$B&`ZPmqxpIvK<~fbXq?x20k1~9az-Q!uR78mCgRj*eQ>zh3c$W}>^+w^dIr-u{@s30J=)1zF8?Wn|H`GS<=>Om|DjzC{}Jt?{!fSJe*@$H zg>wFnlT)k#T?LslW zu$^7Uy~$SQ21cE?3Ijl+bLfuH^U5P^$@~*UY#|_`uvAIe(+wD2eF}z_y!pvomuVO; zS^9fbdv)pcm-B@CW|Upm<7s|0+$@@<&*>$a{aW+oJ%f+VMO<#wa)7n|JL5egEgoBv zl$BY(NQjE0#*nv=!kMnp&{2Le#30b)Ql2e!VkPLK*+{jv77H7)xG7&=aPHL7LK9ER z5lfHxBI5O{-3S?GU4X6$yVk>lFn;ApnwZybdC-GAvaznGW-lScIls-P?Km2mF>%B2 zkcrXTk+__hj-3f48U%|jX9*|Ps41U_cd>2QW81Lz9}%`mTDIhE)jYI$q$ma7Y-`>% z8=u+Oftgcj%~TU}3nP8&h7k+}$D-CCgS~wtWvM|UU77r^pUw3YCV80Ou*+bH0!mf0 zxzUq4ed6y>oYFz7+l18PGGzhB^pqSt)si=9M>~0(Bx9*5r~W7sa#w+_1TSj3Jn9mW zMuG9BxN=}4645Cpa#SVKjFst;9UUY@O<|wpnZk$kE+to^4!?0@?Cwr3(>!NjYbu?x z1!U-?0_O?k!NdM^-rIQ8p)%?M+2xkhltt*|l=%z2WFJhme7*2xD~@zk#`dQR$6Lmd zb3LOD4fdt$Cq>?1<%&Y^wTWX=eHQ49Xl_lFUA(YQYHGHhd}@!VpYHHm=(1-O=yfK#kKe|2Xc*9}?BDFN zD7FJM-AjVi)T~OG)hpSWqH>vlb41V#^G2B_EvYlWhDB{Z;Q9-0)ja(O+By`31=biA zG&Fs#5!%_mHi|E4Nm$;vVQ!*>=_F;ZC=1DTPB#CICS5fL2T3XmzyHu?bI;m7D4@#; ztr~;dGYwb?m^VebuULtS4lkC_7>KCS)F@)0OdxZIFZp@FM_pHnJes8YOvwB|++#G( z&dm*OP^cz95Wi15vh`Q+yB>R{8zqEhz5of>Po$9LNE{xS<)lg2*roP*sQ}3r3t<}; zPbDl{lk{pox~2(XY5=qg0z!W-x^PJ`VVtz$git7?)!h>`91&&hESZy1KCJ2nS^yMH z!=Q$eTyRi68rKxdDsdt+%J_&lapa{ds^HV9Ngp^YDvtq&-Xp}60B_w@Ma>_1TTC;^ zpbe!#gH}#fFLkNo#|`jcn?5LeUYto%==XBk6Ik0kc4$6Z+L3x^4=M6OI1=z5u#M%0 z0E`kevJEpJjvvN>+g`?gtnbo$@p4VumliZV3Z%CfXXB&wPS^5C+7of2tyVkMwNWBiTE2 z8CdPu3i{*vR-I(NY5syRR}I1TJOV@DJy-Xmvxn^IInF>Tx2e)eE9jVSz69$6T`M9-&om!T+I znia!ZWJRB28o_srWlAxtz4VVft8)cYloIoVF=pL zugnk@vFLXQ_^7;%hn9x;Vq?lzg7%CQR^c#S)Oc-8d=q_!2ZVH764V z!wDKSgP}BrVV6SfCLZnYe-7f;igDs9t+K*rbMAKsp9L$Kh<6Z;e7;xxced zn=FGY<}CUz31a2G}$Q(`_r~75PzM4l_({Hg&b@d8&jC}B?2<+ed`f#qMEWi z`gm!STV9E4sLaQX+sp5Nu9*;9g12naf5?=P9p@H@f}dxYprH+3ju)uDFt^V{G0APn zS;16Dk{*fm6&BCg#2vo?7cbkkI4R`S9SSEJ=#KBk3rl69SxnCnS#{*$!^T9UUmO#&XXKjHKBqLdt^3yVvu8yn|{ zZ#%1CP)8t-PAz(+_g?xyq;C2<9<5Yy<~C74Iw(y>uUL$+$mp(DRcCWbCKiGCZw@?_ zdomfp+C5xt;j5L@VfhF*xvZdXwA5pcdsG>G<8II-|1dhAgzS&KArcb0BD4ZZ#WfiEY{hkCq5%z9@f|!EwTm;UEjKJsUo696V>h zy##eXYX}GUu%t{Gql8vVZKkNhQeQ4C%n|RmxL4ee5$cgwlU+?V7a?(jI#&3wid+Kz5+x^G!bb#$q>QpR#BZ}Xo5UW^ zD&I`;?(a}Oys7-`I^|AkN?{XLZNa{@27Dv^s4pGowuyhHuXc zuctKG2x0{WCvg_sGN^n9myJ}&FXyGmUQnW7fR$=bj$AHR88-q$D!*8MNB{YvTTEyS zn22f@WMdvg5~o_2wkjItJN@?mDZ9UUlat2zCh(zVE=dGi$rjXF7&}*sxac^%HFD`Y zTM5D3u5x**{bW!68DL1A!s&$2XG@ytB~dX-?BF9U@XZABO`a|LM1X3HWCllgl0+uL z04S*PX$%|^WAq%jkzp~%9HyYIF{Ym?k)j3nMwPZ=hlCg9!G+t>tf0o|J2%t1 ztC+`((dUplgm3`+0JN~}&FRRJ3?l*>Y&TfjS>!ShS`*MwO{WIbAZR#<%M|4c4^dY8 z{Rh;-!qhY=dz5JthbWoovLY~jNaw>%tS4gHVlt5epV8ekXm#==Po$)}mh^u*cE>q7*kvX&gq)(AHoItMYH6^s6f(deNw%}1=7O~bTHSj1rm2|Cq+3M z93djjdomWCTCYu!3Slx2bZVy#CWDozNedIHbqa|otsUl+ut?>a;}OqPfQA05Yim_2 zs@^BjPoFHOYNc6VbNaR5QZfSMh2S*`BGwcHMM(1@w{-4jVqE8Eu0Bi%d!E*^Rj?cR z7qgxkINXZR)K^=fh{pc0DCKtrydVbVILI>@Y0!Jm>x-xM!gu%dehm?cC6ok_msDVA*J#{75%4IZt}X|tIVPReZS#aCvuHkZxc zHVMtUhT(wp09+w9j9eRqz~LtuSNi2rQx_QgQ(}jBt7NqyT&ma61ldD(s9x%@q~PQl zp6N*?=N$BtvjQ_xIT{+vhb1>{pM0Arde0!X-y))A4znDrVx8yrP3B1(7bKPE5jR@5 zwpzwT4cu~_qUG#zYMZ_!2Tkl9zP>M%cy>9Y(@&VoB84#%>amTAH{(hL4cDYt!^{8L z645F>BWO6QaFJ-{C-i|-d%j7#&7)$X7pv#%9J6da#9FB5KyDhkA+~)G0^87!^}AP>XaCSScr;kL;Z%RSPD2CgoJ;gpYT5&6NUK$86$T?jRH=w8nI9Z534O?5fk{kd z`(-t$8W|#$3>xoMfXvV^-A(Q~$8SKDE^!T;J+rQXP71XZ(kCCbP%bAQ1|%$%Ov9_a zyC`QP3uPvFoBqr_+$HenHklqyIr>PU_Fk5$2C+0eYy^~7U&(!B&&P2%7#mBUhM!z> z_B$Ko?{Pf6?)gpYs~N*y%-3!1>o-4;@1Zz9VQHh)j5U1aL-Hyu@1d?X;jtDBNk*vMXPn@ z+u@wxHN*{uHR!*g*4Xo&w;5A+=Pf9w#PeZ^x@UD?iQ&${K2c}UQgLRik-rKM#Y5rdDphdcNTF~cCX&9ViRP}`>L)QA4zNXeG)KXFzSDa6 zd^St;inY6J_i=5mcGTx4_^Ys`M3l%Q==f>{8S1LEHn{y(kbxn5g1ezt4CELqy)~TV6{;VW>O9?5^ ztcoxHRa0jQY7>wwHWcxA-BCwzsP>63Kt&3fy*n#Cha687CQurXaRQnf5wc9o8v7Rw zNwGr2fac;Wr-Ldehn7tF^(-gPJwPt@VR1f;AmKgxN&YPL;j=0^xKM{!wuU|^mh3NE zy35quf}MeL!PU;|{OW_x$TBothLylT-J>_x6p}B_jW1L>k)ps6n%7Rh z96mPkJIM0QFNYUM2H}YF5bs%@Chs6#pEnloQhEl?J-)es!(SoJpEPoMTdgA14-#mC zghayD-DJWtUu`TD8?4mR)w5E`^EHbsz2EjH5aQLYRcF{l7_Q5?CEEvzDo(zjh|BKg z3aJl_n#j&eFHsUw4~lxqnr!6NL*se)6H=A+T1e3xUJGQrd}oSPwSy5+$tt{2t5J5@(lFxl43amsARG74iyNC}uuS zd2$=(r6RdamdGx^eatX@F2D8?U23tDpR+Os?0Gq2&^dF+$9wiWf?=mDWfjo4LfRwL zI#SRV9iSz>XCSgEj!cW&9H-njJopYiYuq|2w<5R2!nZ27DyvU4UDrHpoNQZiGPkp@ z1$h4H46Zn~eqdj$pWrv;*t!rTYTfZ1_bdkZmVVIRC21YeU$iS-*XMNK`#p8Z_DJx| zk3Jssf^XP7v0X?MWFO{rACltn$^~q(M9rMYoVxG$15N;nP)A98k^m3CJx8>6}NrUd@wp-E#$Q0uUDQT5GoiK_R{ z<{`g;8s>UFLpbga#DAf%qbfi`WN1J@6IA~R!YBT}qp%V-j!ybkR{uY0X|x)gmzE0J z&)=eHPjBxJvrZSOmt|)hC+kIMI;qgOnuL3mbNR0g^<%|>9x7>{}>a2qYSZAGPt4it?8 zNcLc!Gy0>$jaU?}ZWxK78hbhzE+etM`67*-*x4DN>1_&{@5t7_c*n(qz>&K{Y?10s zXsw2&nQev#SUSd|D8w7ZD2>E<%g^; zV{yE_O}gq?Q|zL|jdqB^zcx7vo(^})QW?QKacx$yR zhG|XH|8$vDZNIfuxr-sYFR{^csEI*IM#_gd;9*C+SysUFejP0{{z7@P?1+&_o6=7V|EJLQun^XEMS)w(=@eMi5&bbH*a0f;iC~2J74V2DZIlLUHD&>mlug5+v z6xBN~8-ovZylyH&gG#ptYsNlT?-tzOh%V#Y33zlsJ{AIju`CjIgf$@gr8}JugRq^c zAVQ3;&uGaVlVw}SUSWnTkH_6DISN&k2QLMBe9YU=sA+WiX@z)FoSYX`^k@B!j;ZeC zf&**P?HQG6Rk98hZ*ozn6iS-dG}V>jQhb3?4NJB*2F?6N7Nd;EOOo;xR7acylLaLy z9)^lykX39d@8@I~iEVar4jmjjLWhR0d=EB@%I;FZM$rykBNN~jf>#WbH4U{MqhhF6 zU??@fSO~4EbU4MaeQ_UXQcFyO*Rae|VAPLYMJEU`Q_Q_%s2*>$#S^)&7er+&`9L=1 z4q4ao07Z2Vsa%(nP!kJ590YmvrWg+YrgXYs_lv&B5EcoD`%uL79WyYA$0>>qi6ov7 z%`ia~J^_l{p39EY zv>>b}Qs8vxsu&WcXEt8B#FD%L%ZpcVtY!rqVTHe;$p9rbb5O{^rFMB>auLn-^;s+-&P1#h~mf~YLg$8M9 zZ4#87;e-Y6x6QO<{McUzhy(%*6| z)`D~A(TJ$>+0H+mct(jfgL4x%^oC^T#u(bL)`E2tBI#V1kSikAWmOOYrO~#-cc_8! zCe|@1&mN2{*ceeiBldHCdrURk4>V}79_*TVP3aCyV*5n@jiNbOm+~EQ_}1#->_tI@ zqXv+jj2#8xJtW508rzFrYcJxoek@iW6SR@1%a%Bux&;>25%`j3UI`0DaUr7l79`B1 zqqUARhW1^h6=)6?;@v>xrZNM;t}{yY3P@|L}ey@gG( z9r{}WoYN(9TW&dE2dEJIXkyHA4&pU6ki=rx&l2{DLGbVmg4%3Dlfvn!GB>EVaY_%3+Df{fBiqJV>~Xf8A0aqUjgpa} zoF8YXO&^_x*Ej}nw-$-F@(ddB>%RWoPUj?p8U{t0=n>gAI83y<9Ce@Q#3&(soJ{64 z37@Vij1}5fmzAuIUnXX`EYe;!H-yTVTmhAy;y8VZeB#vD{vw9~P#DiFiKQ|kWwGFZ z=jK;JX*A;Jr{#x?n8XUOLS;C%f|zj-7vXtlf_DtP7bpurBeX%Hjwr z4lI-2TdFpzkjgiv!8Vfv`=SP+s=^i3+N~1ELNWUbH|ytVu>EyPN_3(4TM^QE1swRo zoV7Y_g)a>28+hZG0e7g%@2^s>pzR4^fzR-El}ARTmtu!zjZLuX%>#OoU3}|rFjJg} zQ2TmaygxJ#sbHVyiA5KE+yH0LREWr%^C*yR|@gM$nK2P zo}M}PV0v))uJh&33N>#aU376@ZH79u(Yw`EQ2hM3SJs9f99+cO6_pNW$j$L-CtAfe zYfM)ccwD!P%LiBk!eCD?fHCGvgMQ%Q2oT_gmf?OY=A>&PaZQOq4eT=lwbaf}33LCH zFD|)lu{K7$8n9gX#w4~URjZxWm@wlH%oL#G|I~Fb-v^0L0TWu+`B+ZG!yII)w05DU z>GO?n(TN+B=>HdxVDSlIH76pta$_LhbBg;eZ`M7OGcqt||qi zogS72W1IN%=)5JCyOHWoFP7pOFK0L*OAh=i%&VW&4^LF@R;+K)t^S!96?}^+5QBIs zjJNTCh)?)4k^H^g1&jc>gysM`y^8Rm3qsvkr$9AeWwYpa$b22=yAd1t<*{ zaowSEFP+{y?Ob}8&cwfqoy4Pb9IA~VnM3u!trIK$&&0Op#Ql4j>(EW?UNUv#*iH1$ z^j>+W{afcd`{e&`-A{g}{JnIzYib)!T56IT@YEs{4|`sMpW3c8@UCoIJv`XsAw!XC z34|Il$LpW}CIHFC5e*)}00I5{%OL*WZRGzC0?_}-9{#ue?-ug^ zLE|uv-~6xnSs_2_&CN9{9vyc!Xgtn36_g^wI0C4s0s^;8+p?|mm;Odt3`2ZjwtK;l zfd6j)*Fr#53>C6Y8(N5?$H0ma;BCF3HCjUs7rpb2Kf*x3Xcj#O8mvs#&33i+McX zQpBxD8!O{5Y8D&0*QjD=Yhl9%M0)&_vk}bmN_Ud^BPN;H=U^bn&(csl-pkA+GyY0Z zKV7sU_4n;}uR78ouo8O%g*V;79KY?3d>k6%gpcmQsKk&@Vkw9yna_3asGt`0Hmj59 z%0yiF*`jXhByBI9QsD=+>big5{)BGe&+U2gAARGe3ID)xrid~QN_{I>k}@tzL!Md_ z&=7>TWciblF@EMC3t4-WX{?!m!G6$M$1S?NzF*2KHMP3Go4=#ZHkeIv{eEd;s-yD# z_jU^Ba06TZqvV|Yd;Z_sN%$X=!T+&?#p+OQIHS%!LO`Hx0q_Y0MyGYFNoM{W;&@0@ zLM^!X4KhdtsET5G<0+|q0oqVXMW~-7LW9Bg}=E$YtNh1#1D^6Mz(V9?2g~I1( zoz9Cz=8Hw98zVLwC2AQvp@pBeKyidn6Xu0-1SY1((^Hu*-!HxFUPs)yJ+i`^BC>PC zjwd0mygOVK#d2pRC9LxqGc6;Ui>f{YW9Bvb>33bp^NcnZoH~w9(lM5@JiIlfa-6|k ziy31UoMN%fvQfhi8^T+=yrP{QEyb-jK~>$A4SZT-N56NYEbpvO&yUme&pWKs3^94D zH{oXnUTb3T@H+RgzML*lejx`WAyw*?K7B-I(VJx($2!NXYm%3`=F~TbLv3H<{>D?A zJo-FDYdSA-(Y%;4KUP2SpHKAIcv9-ld(UEJE7=TKp|Gryn;72?0LHqAN^fk6%8PCW z{g_-t)G5uCIf0I`*F0ZNl)Z>))MaLMpXgqWgj-y;R+@A+AzDjsTqw2Mo9ULKA3c70 z!7SOkMtZb+MStH>9MnvNV0G;pwSW9HgP+`tg}e{ij0H6Zt5zJ7iw`hEnvye!XbA@!~#%vIkzowCOvq5I5@$3wtc*w2R$7!$*?}vg4;eDyJ_1=ixJuEp3pUS27W?qq(P^8$_lU!mRChT}ctvZz4p!X^ zOSp|JOAi~f?UkwH#9k{0smZ7-#=lK6X3OFEMl7%)WIcHb=#ZN$L=aD`#DZKOG4p4r zwlQ~XDZ`R-RbF&hZZhu3(67kggsM-F4Y_tI^PH8PMJRcs7NS9ogF+?bZB*fcpJ z=LTM4W=N9yepVvTj&Hu~0?*vR1HgtEvf8w%Q;U0^`2@e8{SwgX5d(cQ|1(!|i$km! zvY03MK}j`sff;*-%mN~ST>xU$6Bu?*Hm%l@0dk;j@%>}jsgDcQ)Hn*UfuThz9(ww_ zasV`rSrp_^bp-0sx>i35FzJwA!d6cZ5#5#nr@GcPEjNnFHIrtUYm1^Z$;{d&{hQV9 z6EfFHaIS}46p^5I-D_EcwwzUUuO}mqRh&T7r9sfw`)G^Q%oHxEs~+XoM?8e*{-&!7 z7$m$lg9t9KP9282eke608^Q2E%H-xm|oJ8=*SyEo} z@&;TQ3K)jgspgKHyGiKVMCz>xmC=H5Fy3!=TP)-R3|&1S-B)!6q50wfLHKM@7Bq6E z44CY%G;GY>tC`~yh!qv~YdXw! zSkquvYNs6k1r7>Eza?Vkkxo6XRS$W7EzL&A`o>=$HXgBp{L(i^$}t`NcnAxzbH8Ht z2!;`bhKIh`f1hIFcI5bHI=ueKdzmB9)!z$s-BT4ItyY|NaA_+o=jO%MU5as9 zc2)aLP>N%u>wlaXTK!p)r?+~)L+0eCGb5{8WIk7K52$nufnQ+m8YF+GQc&{^(zh-$ z#wyWV*Zh@d!b(WwXqvfhQX)^aoHTBkc;4ossV3&Ut*k>AI|m+{#kh4B!`3*<)EJVj zwrxK>99v^k4&Y&`Awm>|exo}NvewV%E+@vOc>5>%H#BK9uaE2$vje zWYM5fKuOTtn96B_2~~!xJPIcXF>E_;yO8AwpJ4)V`Hht#wbO3Ung~@c%%=FX4)q+9 z99#>VC2!4l`~0WHs9FI$Nz+abUq# zz`Of97})Su=^rGp2S$)7N3rQCj#0%2YO<R&p>$<#lgXcUj=4H_{oAYiT3 z44*xDn-$wEzRw7#@6aD)EGO$0{!C5Z^7#yl1o;k0PhN=aVUQu~eTQ^Xy{z8Ow6tk83 z4{5xe%(hx)%nD&|e*6sTWH`4W&U!Jae#U4TnICheJmsw{l|CH?UA{a6?2GNgpZLyzU2UlFu1ZVwlALmh_DOs03J^Cjh1im`E3?9&zvNmg(MuMw&0^Lu$(#CJ*q6DjlKsY-RMJ^8yIY|{SQZ*9~CH|u9L z`R78^r=EbbR*_>5?-)I+$6i}G)%mN(`!X72KaV(MNUP7Nv3MS9S|Pe!%N2AeOt5zG zVJ;jI4HZ$W->Ai_4X+`9c(~m=@ek*m`ZQbv3ryI-AD#AH=`x$~WeW~M{Js57(K7(v ze5`};LG|%C_tmd>bkufMWmAo&B+DT9ZV~h(4jg0>^aeAqL`PEUzJJtI8W1M!bQWpv zvN(d}E1@nlYa!L!!A*RN!(Q3F%J?5PvQ0udu?q-T)j3JKV~NL>KRb~w-lWc685uS6 z=S#aR&B8Sc8>cGJ!!--?kwsJTUUm`Jk?7`H z7PrO~xgBrSW2_tTlCq1LH8*!o?pj?qxy8}(=r_;G18POrFh#;buWR0qU24+XUaVZ0 z?(sXcr@-YqvkCmHr{U2oPogHL{r#3r49TeR<{SJX1pcUqyWPrkYz^X8#QW~?F)R5i z>p^!i<;qM8Nf{-fd6!_&V*e_9qP6q(s<--&1Ttj01j0w>bXY7y1W*%Auu&p|XSOH=)V7Bd4fUKh&T1)@cvqhuD-d=?w}O zjI%i(f|thk0Go*!d7D%0^ztBfE*V=(ZIN84f5HU}T9?ulmEYzT5usi=DeuI*d|;M~ zp_=Cx^!4k#=m_qSPBr5EK~E?3J{dWWPH&oCcNepYVqL?nh4D5ynfWip$m*YlZ8r^Z zuFEUL-nW!3qjRCLIWPT0x)FDL7>Yt7@8dA?R2kF@WE>ysMY+)lTsgNM#3VbXVGL}F z1O(>q>2a+_`6r5Xv$NZAnp=Kgnr3)cL(^=8ypEeOf3q8(HGe@7Tt59;yFl||w|mnO zHDxg2G3z8=(6wjj9kbcEY@Z0iOd7Gq5GiPS5% z*sF1J<#daxDV2Z8H>wxOF<;yKzMeTaSOp_|XkS9Sfn6Mpe9UBi1cSTieGG5$O;ZLIIJ60Y>SN4vC?=yE_CWlo(EEE$e4j?z&^FM%kNmRtlbEL^dPPgvs9sbK5fGw*r@ z+!EU@u$T8!nZh?Fdf_qk$VuHk^yVw`h`_#KoS*N%epIIOfQUy_&V}VWDGp3tplMbf z5Se1sJUC$7N0F1-9jdV2mmGK{-}fu|Nv;12jDy0<-kf^AmkDnu6j~TPWOgy1MT68|D z=4=50jVbUKdKaQgD`eWGr3I&^<6uhkjz$YwItY8%Yp9{z4-{6g{73<_b*@XJ4Nm3-3z z?BW3{aY_ccRjb@W1)i5nLg|7BnWS!B`_Uo9CWaE`Ij327QH?i)9A}4Ug4wmxVVa^b z-4+m%-wwOl7cKH7+=x&nrCrbEC)Q$fpg&V83#uEH;C=GNMz`ps@^RxK%T*8%OPnC` z{WO~J%nxYJ`x|N%?&i7?;{_8t^jM&=50HlaOQj8fS}_`moH$c;vI<|cruPFnpT8yU zS%rPOCUSd5Zdb(zwk`hqwTQn)*&n)uYsP*F_(~xEWq}C= zv30kFmZFwJZ@ELVX3?$dXQh|icO7UrL*_5G=I^xXjImz`ZPp>?g#tf(ej~KaIU0algsG!IS09;>?MvqGg#c{i+}qY|{P8W~O%#>|gFd z<1dr$-oxyRGN17yZo1OwLnzwYs0|;IS_nymNB0IlSzPQ%-r`?T=;_XQ^~&#}b|AB} zkNbN5uB?-sUB-T5QLlg%Uk3)uHB;>VIzGe9_J9 zaeISkQm!v(9d(0ML^b9fR^sfHFlH?7Mvddt37OuR{|O0{uv)(&-6<87W4 zyO>s!=cPgP3O&7xxU5DlIPw_o3O>6o6Qb?JWs3qw#p3sBc3g$?Dx zi(6D+DYgV;GrUis-CL%Qe{nvZnwaVXmbhH(|GFh|Q)k=1uvA$I@1DXI7bKlQ@8D6P zS?(*?><>)G49q0wr;NajpxP4W2G)kHl6^=Z>hrNEI4Mwd_$O6$1dXF;Q#hE(-eeW6 zz03GJF%Wl?HO=_ztv5*zRlcU~{+{k%#N59mgm~eK>P!QZ6E?#Cu^2)+K8m@ySvZ*5 z|HDT}BkF@3!l(0%75G=1u2hETXEj!^1Z$!)!lyGXlWD!_vqGE$Z)#cUVBqlORW>0^ zDjyVTxwKHKG|0}j-`;!R-p>}qQfBl(?($7pP<+Y8QE#M8SCDq~k<+>Q^Zf@cT_WdX3~BSe z+|KK|7OL5Hm5(NFP~j>Ct3*$wi0n0!xl=(C61`q&cec@mFlH(sy%+RH<=s)8aAPN`SfJdkAQjdv82G5iRdv8 zh{9wHUZaniSEpslXl^_ODh}mypC?b*9FzLjb~H@3DFSe;D(A-K3t3eOTB(m~I6C;(-lKAvit(70k`%@+O*Ztdz;}|_TS~B?Tpmi=QKC^m_ z2YpEaT3iiz*;T~ap1yiA)a`dKMwu`^UhIUeltNQ1Yjo=q@bI@&3zH?rVUg=IxLy-ni zyxDu%-Fr{H6owTjZU2O5>nDb=q&Jz_TjeSq%!2m40x&U6w~GQ({quPL73IsJS;f`$ zsuhioqCBj(gJ>2hoo)Gou7(WP*pX)f=Y=!=k!&1K?EYY%jJ~X&DnK{^saPQK<1BJ z_A`_{%ZozcB(3w$z^To^6d|XuT@=X~wtW!+{4ID@N{AB~J6AL5vuY>JwvWCNFKsKh zd}@>q@_WV#QZ&UJ0#?X(pXR!oyXOEG3rqzHbCzGLONDb042i$})fM@XF)uSP(DHUc z^&{|$*xe{cs?Gp8=B%RY3L7#$ve$?TWh>MZdxF1zH1v}1z+$Ov#G7?%D)bBCyDe*% zSeKSpETC2V1){II>@UwJi>4uBN+iAx+82E~gb|Cr&8E^i&)A!uv-g?jzH99wU}8+# z$nh>yvb;TwZmS@7LrvuCu_d0-WxFNI&C7%sWuTL%YU!l|I1{|->=dlOeHOCtUO#zkS3ESO8LHV4hTdQL5EdV zuWD33fFPH}HPrW^s$Qn1Xgp&AT6<-He{{4%eIu3rN=iK|9mURdKXfB&Q?qGok%!cs ze53UP{Z!TO-Y@q2;;k2avA3`lm4OoN4@S*k=UA)7H;qZ`d8`XaYFCv?Ba+uGW@r5v z&&{nf(24WSBOhc7!qF^@0cz;XcUynNaj6w2349;s!K{KVqs5yS{ z7VubS`2OzT^5#1~6Tt^RTvt9-J|D2F>y~>2;jeF>g`hx5l%B3H=aLExQihuYngzlnBTYOTHJQMzl>kwqN5JYs)Ej zblA@ntkUS~xi+}y6|(81helS}Q~&VB37qyV|S3Y=><^1wh%msQM?fz z<58MX(=|PSUKCF#)dbhR%D&xgCD?$aR0qen+wpp6 zst}vX18!Be96TD??j1HsHTUx(a&@F?=gT`Q$oJFFyrh^;zgz!(NlAHGn0cJy@us=w zNhC#l5G;H}+>49Nsh12=ZPO2r*2OBQe5kpb&1?*PIBFitK8}FUfb~S-#hKfF0o#&d z#3aPkB$9scYku&kA6{0xHnBV#&Wei5J>5T-XX-gUXEPo+9b7WL=*XESc(3BshL`aj zXp}QIp*40}oWJt*l043e8_5;H5PI5c)U&IEw5dF(4zjX0y_lk9 zAp@!mK>WUqHo)-jop=DoK>&no>kAD=^qIE7qis&_*4~ z6q^EF$D@R~3_xseCG>Ikb6Gfofb$g|75PPyyZN&tiRxqovo_k zO|HA|sgy#B<32gyU9x^&)H$1jvw@qp+1b(eGAb)O%O!&pyX@^nQd^9BQ4{(F8<}|A zhF&)xusQhtoXOOhic=8#Xtt5&slLia3c*a?dIeczyTbC#>FTfiLST57nc3@Y#v_Eg#VUv zT8cKH#f3=1PNj!Oroz_MAR*pow%Y0*6YCYmUy^7`^r|j23Q~^*TW#cU7CHf0eAD_0 zEWEVddxFgQ7=!nEBQ|ibaScslvhuUk^*%b#QUNrEB{3PG@uTxNwW}Bs4$nS9wc(~O zG7Iq>aMsYkcr!9#A;HNsJrwTDYkK8ikdj{M;N$sN6BqJ<8~z>T20{J8Z2rRUuH7~3 z=tgS`AgxbBOMg87UT4Lwge`*Y=01Dvk>)^{Iu+n6fuVX4%}>?3czOGR$0 zpp*wp>bsFFSV`V;r_m+TZns$ZprIi`OUMhe^cLE$2O+pP3nP!YB$ry}2THx2QJs3< za1;>d-AggCarrQ>&Z!d@;mW+!q6eXhb&`GbzUDSxpl8AJ#Cm#tuc)_xh(2NV=5XMs zrf_ozRYO$NkC=pKFX5OH8v1>0i9Z$ec`~Mf+_jQ68spn(CJwclDhEEkH2Qw;${J$clv__nUjn5jA0wCLEnu1j;v!0vB>Ri6m9`;R{JMS%^)4FC zU0Z44+u$I$w=Bj|iu4DT5h~sS`C*zbmX?@-crY}E+hy>}2~C0Nn(EKk@5^qO4@l@! z6O0lr%tzGC`D^)8xU3FnMZVm0kX1sBWhaQyzVoXFWwr%Ny?=2M{5s#5i7fTu3gEkG zc{(Pr$v=;`Y#&`y*J}#M9ux>0?xu!`$9cUKm#Bdd_&S#LPTS?ZPV6zN6>W6JTS~-LfjL{mB=b(KMk3 z2HjBSlJeyUVqDd=Mt!=hpYsvby2GL&3~zm;0{^nZJq+4vb?5HH4wufvr}IX42sHeK zm@x?HN$8TsTavXs)tLDFJtY9b)y~Tl@7z4^I8oUQq4JckH@~CVQ;FoK(+e0XAM>1O z(ei}h?)JQp>)d=6ng-BZF1Z5hsAKW@mXq+hU?r8I(*%`tnIIOXw7V6ZK(T9RFJJe@ zZS!aC+p)Gf2Ujc=a6hx4!A1Th%YH!Lb^xpI!Eu` zmJO{9rw){B1Ql18d%F%da+Tbu1()?o(zT7StYqK6_w`e+fjXq5L^y(0 z09QA6H4oFj59c2wR~{~>jUoDzDdKz}5#onYPJRwa`SUO)Pd4)?(ENBaFVLJr6Kvz= zhTtXqbx09C1z~~iZt;g^9_2nCZ{};-b4dQJbv8HsWHXPVg^@(*!@xycp#R?a|L!+` zY5w))JWV`Gls(=}shH0#r*;~>_+-P5Qc978+QUd>J%`fyn{*TsiG-dWMiJXNgwBaT zJ=wgYFt+1ACW)XwtNx)Q9tA2LPoB&DkL16P)ERWQlY4%Y`-5aM9mZ{eKPUgI!~J3Z zkMd5A_p&v?V-o-6TUa8BndiX?ooviev(DKw=*bBVOW|=zps9=Yl|-R5@yJe*BPzN}a0mUsLn{4LfjB_oxpv(mwq# zSY*%E{iB)sNvWfzg-B!R!|+x(Q|b@>{-~cFvdDHA{F2sFGA5QGiIWy#3?P2JIpPKg6ncI^)dvqe`_|N=8 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/test-server/server/gradlew.bat b/test-server/server/gradlew.bat new file mode 100644 index 00000000..5eed7ee8 --- /dev/null +++ b/test-server/server/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/test-server/server/settings.gradle.kts b/test-server/server/settings.gradle.kts new file mode 100644 index 00000000..a0e2d3b6 --- /dev/null +++ b/test-server/server/settings.gradle.kts @@ -0,0 +1,14 @@ +// The Java Language_Server for the ESDK TestServer. Generated from the single +// source-of-truth Smithy model via smithy-java SERVER codegen (Requirement 1.7). +pluginManagement { + val smithyGradleVersion: String by settings + plugins { + id("software.amazon.smithy.gradle.smithy-base").version(smithyGradleVersion) + } + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "esdk-test-server-java" diff --git a/test-server/server/smithy-build.json b/test-server/server/smithy-build.json new file mode 100644 index 00000000..00486fa9 --- /dev/null +++ b/test-server/server/smithy-build.json @@ -0,0 +1,13 @@ +{ + "version": "1.0", + "plugins": { + "java-codegen": { + "service": "aws.cryptography.esdk.testserver#ESDKTestServer", + "namespace": "aws.cryptography.esdk.testserver.server", + "protocol": "smithy.protocols#rpcv2Cbor", + "modes": [ + "server" + ] + } + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/ConfigMarshaller.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/ConfigMarshaller.java new file mode 100644 index 00000000..4f6eb086 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/ConfigMarshaller.java @@ -0,0 +1,60 @@ +package aws.cryptography.esdk.testserver.server.config; + +import aws.cryptography.esdk.testserver.server.model.CryptographicMaterialsManager; +import aws.cryptography.esdk.testserver.server.model.ESDKClientConfig; +import aws.cryptography.esdk.testserver.server.model.Keyring; +import java.nio.ByteBuffer; +import software.amazon.smithy.java.cbor.Rpcv2CborCodec; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.Codec; + +/** + * Marshals and unmarshals the tagged-union config shapes through the exact wire + * form used by the service protocol, rpcv2Cbor (Requirement 2.2). Because the + * config shapes reference themselves recursively — a Caching or + * RequiredEncryptionContext CMM wraps another CMM, and a Multi keyring contains + * child keyrings — marshalling round-trips at any nesting depth + * (Requirement 2.5). + * + *

The generated shapes already know how to (de)serialize themselves via + * smithy-java's serde; this class pins that to the rpcv2Cbor codec so what is + * marshalled here is byte-identical to what crosses the wire, and exposes small + * typed round-trip helpers used by the handlers and property tests. + */ +public final class ConfigMarshaller { + + private final Codec codec; + + public ConfigMarshaller() { + this.codec = Rpcv2CborCodec.builder().build(); + } + + /** Marshal any config struct to its rpcv2Cbor wire bytes. */ + public byte[] marshal(SerializableStruct config) { + ByteBuffer buffer = codec.serialize(config); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + return bytes; + } + + /** Unmarshal wire bytes back into a config struct using the given builder. */ + public T unmarshal(byte[] wire, ShapeBuilder builder) { + return codec.deserializeShape(wire, builder); + } + + /** Round-trip a whole client config through the wire form. */ + public ESDKClientConfig roundTrip(ESDKClientConfig config) { + return unmarshal(marshal(config), ESDKClientConfig.builder()); + } + + /** Round-trip a CMM (possibly deeply nested) through the wire form. */ + public CryptographicMaterialsManager roundTrip(CryptographicMaterialsManager cmm) { + return unmarshal(marshal(cmm), CryptographicMaterialsManager.builder()); + } + + /** Round-trip a keyring (possibly deeply nested) through the wire form. */ + public Keyring roundTrip(Keyring keyring) { + return unmarshal(marshal(keyring), Keyring.builder()); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/ConfigValidator.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/ConfigValidator.java new file mode 100644 index 00000000..5c9f3bce --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/ConfigValidator.java @@ -0,0 +1,150 @@ +package aws.cryptography.esdk.testserver.server.config; + +import aws.cryptography.esdk.testserver.server.model.CachingCmmConfig; +import aws.cryptography.esdk.testserver.server.model.CryptographicMaterialsManager; +import aws.cryptography.esdk.testserver.server.model.DefaultCmmConfig; +import aws.cryptography.esdk.testserver.server.model.ESDKClientConfig; +import aws.cryptography.esdk.testserver.server.model.ESDKClientError; +import aws.cryptography.esdk.testserver.server.model.Keyring; +import aws.cryptography.esdk.testserver.server.model.MultiKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.RequiredEncryptionContextCmmConfig; + +/** + * Enforces the "exactly one variant member set" invariant on the polymorphic + * config shapes, which are modeled as a tagged union via optional members + * (Requirement 2.2). Smithy structures permit any subset of optional members, + * so this invariant is a runtime check, not a type-system guarantee. + * + *

Every polymorphic shape reachable from an {@link ESDKClientConfig} is + * validated, recursing through the recursive variants to any nesting depth + * (Requirement 2.5): {@link CryptographicMaterialsManager} (Default / + * RequiredEncryptionContext / Caching, where Caching and + * RequiredEncryptionContext wrap another CMM) and {@link Keyring} (whose Multi + * variant contains child keyrings and an optional generator keyring). + * + *

When a polymorphic shape has zero, or two or more, variant members set, the + * request is rejected with an {@link ESDKClientError} before the + * operation runs (Requirements 2.3, 2.4). Validation is read-only: it performs + * no operation and mutates no state. + */ +public final class ConfigValidator { + + /** + * Validate a whole client config: its CMM and everything nested beneath it. + * + * @throws ESDKClientError if any polymorphic shape does not have exactly one + * variant member set (Requirements 2.3, 2.4). + */ + public void validate(ESDKClientConfig config) { + if (config == null) { + throw error("ESDKClientConfig", 0, ""); + } + validateCmm(config.getCmm()); + } + + /** + * Validate a {@link CryptographicMaterialsManager} and recurse into any + * wrapped CMM or keyring. + */ + public void validateCmm(CryptographicMaterialsManager cmm) { + if (cmm == null) { + throw error("CryptographicMaterialsManager", 0, + "Default, RequiredEncryptionContext, Caching"); + } + DefaultCmmConfig def = cmm.getDefault(); + RequiredEncryptionContextCmmConfig req = cmm.getRequiredEncryptionContext(); + CachingCmmConfig caching = cmm.getCaching(); + + int set = 0; + if (def != null) { + set++; + } + if (req != null) { + set++; + } + if (caching != null) { + set++; + } + if (set != 1) { + throw error("CryptographicMaterialsManager", set, + "Default, RequiredEncryptionContext, Caching"); + } + + if (def != null) { + validateKeyring(def.getKeyring()); + } else if (req != null) { + validateCmm(req.getUnderlyingCMM()); + } else { + validateCmm(caching.getUnderlyingCMM()); + } + } + + /** + * Validate a {@link Keyring} and recurse into a Multi keyring's generator and + * child keyrings. + */ + public void validateKeyring(Keyring keyring) { + if (keyring == null) { + throw error("Keyring", 0, keyringVariants()); + } + int set = 0; + if (keyring.getAwsKms() != null) { + set++; + } + if (keyring.getAwsKmsMrk() != null) { + set++; + } + if (keyring.getAwsKmsMultiKeyring() != null) { + set++; + } + if (keyring.getAwsKmsMrkMultiKeyring() != null) { + set++; + } + if (keyring.getAwsKmsDiscovery() != null) { + set++; + } + if (keyring.getAwsKmsMrkDiscovery() != null) { + set++; + } + if (keyring.getAwsKmsHierarchical() != null) { + set++; + } + if (keyring.getAwsKmsRsa() != null) { + set++; + } + if (keyring.getRawAes() != null) { + set++; + } + if (keyring.getRawRsa() != null) { + set++; + } + if (keyring.getMulti() != null) { + set++; + } + if (set != 1) { + throw error("Keyring", set, keyringVariants()); + } + + MultiKeyringConfig multi = keyring.getMulti(); + if (multi != null) { + if (multi.getGenerator() != null) { + validateKeyring(multi.getGenerator()); + } + for (Keyring child : multi.getChildKeyrings()) { + validateKeyring(child); + } + } + } + + private static String keyringVariants() { + return "AwsKms, AwsKmsMrk, AwsKmsMultiKeyring, AwsKmsMrkMultiKeyring, " + + "AwsKmsDiscovery, AwsKmsMrkDiscovery, AwsKmsRsa, AwsKmsHierarchical, RawAes, RawRsa, Multi"; + } + + private static ESDKClientError error(String shape, int set, String variants) { + return ESDKClientError.builder() + .message("Polymorphic configuration '" + shape + "' must set exactly one variant " + + "member (" + variants + "), but " + set + " were set.") + .build(); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/EsdkClientFactory.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/EsdkClientFactory.java new file mode 100644 index 00000000..94b2f797 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/config/EsdkClientFactory.java @@ -0,0 +1,474 @@ +package aws.cryptography.esdk.testserver.server.config; + +import aws.cryptography.esdk.testserver.server.model.AwsKmsDiscoveryKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsHierarchicalKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsMrkKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsMrkDiscoveryKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsMrkMultiKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsMultiKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsRsaKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.CachingCmmConfig; +import aws.cryptography.esdk.testserver.server.model.CryptographicMaterialsManager; +import aws.cryptography.esdk.testserver.server.model.DefaultCmmConfig; +import aws.cryptography.esdk.testserver.server.model.ESDKClientConfig; +import aws.cryptography.esdk.testserver.server.model.Keyring; +import aws.cryptography.esdk.testserver.server.model.KmsRsaEncryptionAlgorithm; +import aws.cryptography.esdk.testserver.server.model.MultiKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.RawAesKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.RawRsaKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.RequiredEncryptionContextCmmConfig; +import aws.cryptography.esdk.testserver.server.registry.EsdkClient; +import aws.cryptography.esdk.testserver.server.registry.RealEsdkClient; +import com.amazonaws.encryptionsdk.CommitmentPolicy; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.kms.KmsClient; +import software.amazon.awssdk.services.kms.model.EncryptionAlgorithmSpec; +import software.amazon.awssdk.services.kms.model.GetPublicKeyRequest; +import software.amazon.cryptography.keystore.KeyStore; +import software.amazon.cryptography.keystore.model.KMSConfiguration; +import software.amazon.cryptography.keystore.model.KeyStoreConfig; +import software.amazon.cryptography.materialproviders.ICryptographicMaterialsManager; +import software.amazon.cryptography.materialproviders.IKeyring; +import software.amazon.cryptography.materialproviders.MaterialProviders; +import software.amazon.cryptography.materialproviders.model.AesWrappingAlg; +import software.amazon.cryptography.materialproviders.model.CreateAwsKmsDiscoveryKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateAwsKmsHierarchicalKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateAwsKmsKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateAwsKmsMrkKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateAwsKmsMrkDiscoveryKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateAwsKmsMrkMultiKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateAwsKmsMultiKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateAwsKmsRsaKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateDefaultCryptographicMaterialsManagerInput; +import software.amazon.cryptography.materialproviders.model.CreateMultiKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateRawAesKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateRawRsaKeyringInput; +import software.amazon.cryptography.materialproviders.model.CreateRequiredEncryptionContextCMMInput; +import software.amazon.cryptography.materialproviders.model.MaterialProvidersConfig; +import software.amazon.cryptography.materialproviders.model.PaddingScheme; + +/** + * Translates a validated {@link ESDKClientConfig} into a {@link RealEsdkClient} + * backed by the REAL AWS Encryption SDK for Java plus the AWS Cryptographic + * Material Providers library (Requirement 3.1). The tagged-union config shapes + * are walked recursively — a Default/RequiredEncryptionContext CMM, and a Multi + * keyring whose children are themselves keyrings — mirroring the model's + * recursive variants (Requirement 2.5). + * + *

This factory assumes the config has already passed {@link ConfigValidator} + * (exactly one variant member set at each polymorphic node). Any failure to + * build a real client — an unsupported variant in this pass, or an input the + * material providers / ESDK reject — is surfaced as a thrown exception; the + * {@code CreateClient} handler maps that to a {@code GenericServerError} and + * leaves the registry unchanged (Requirement 3.6). + * + *

Scope note: the offline-capable variants used by the offline round-trip + * tests — Raw AES, Raw RSA, Multi, and the Default / RequiredEncryptionContext + * CMMs — are fully wired. All six AWS KMS keyring variants are also fully wired: + * {@code AwsKms} (single symmetric key), {@code AwsKmsMrk} (single + * multi-region key), {@code AwsKmsMultiKeyring} (generator + child keys), + * {@code AwsKmsRsa} (asymmetric RSA key), {@code AwsKmsDiscovery} (discovery + * keyring), and {@code AwsKmsHierarchical} (branch keys in a DynamoDB key store + * wrapped by a KMS key). Every KMS keyring is constructed without a + * network call — + * the KMS client is built eagerly but is not invoked — so {@code CreateClient} + * stays offline; the real AWS KMS calls happen only on {@code Encrypt}/{@code + * Decrypt} (Requirements 14.1, 14.3, 14.4, 14.14). The single exception is the + * {@code AwsKmsRsa} keyring when the modeled config omits the RSA public key: in + * that case the factory fetches it once via {@code kms:GetPublicKey} at + * construction (a network call the design explicitly permits at + * {@code CreateClient} time); supplying {@code publicKey} in the config keeps + * construction fully offline. The Caching CMM remains unwired and causes a + * construction failure (GenericServerError) if requested (Requirement 3.6). + */ +public final class EsdkClientFactory { + + private final MaterialProviders materialProviders; + + public EsdkClientFactory() { + this.materialProviders = MaterialProviders.builder() + .MaterialProvidersConfig(MaterialProvidersConfig.builder().build()) + .build(); + } + + /** + * Build a configured real ESDK client from the modeled config. + * + * @throws RuntimeException if a real client cannot be constructed; the caller + * maps this to a {@code GenericServerError} (Requirement 3.6). + */ + public EsdkClient create(ESDKClientConfig config) { + CommitmentPolicy commitmentPolicy = toCommitmentPolicy(config.getCommitmentPolicy().getValue()); + Integer maxEdk = config.getMaxEncryptedDataKeys() == null + ? null + : Math.toIntExact(config.getMaxEncryptedDataKeys()); + ICryptographicMaterialsManager cmm = buildCmm(config.getCmm()); + return new RealEsdkClient(commitmentPolicy, maxEdk, cmm); + } + + private ICryptographicMaterialsManager buildCmm(CryptographicMaterialsManager cmm) { + DefaultCmmConfig defaultCmm = cmm.getDefault(); + RequiredEncryptionContextCmmConfig requiredEc = cmm.getRequiredEncryptionContext(); + CachingCmmConfig caching = cmm.getCaching(); + + if (defaultCmm != null) { + IKeyring keyring = buildKeyring(defaultCmm.getKeyring()); + return materialProviders.CreateDefaultCryptographicMaterialsManager( + CreateDefaultCryptographicMaterialsManagerInput.builder() + .keyring(keyring) + .build()); + } + if (requiredEc != null) { + ICryptographicMaterialsManager underlying = buildCmm(requiredEc.getUnderlyingCMM()); + return materialProviders.CreateRequiredEncryptionContextCMM( + CreateRequiredEncryptionContextCMMInput.builder() + .underlyingCMM(underlying) + .requiredEncryptionContextKeys( + new ArrayList<>(requiredEc.getRequiredEncryptionContextKeys())) + .build()); + } + if (caching != null) { + throw new UnsupportedOperationException( + "Caching CMM is not wired in this pass of the ESDK TestServer"); + } + throw new IllegalArgumentException( + "CryptographicMaterialsManager had no variant member set"); + } + + private IKeyring buildKeyring(Keyring keyring) { + RawAesKeyringConfig rawAes = keyring.getRawAes(); + RawRsaKeyringConfig rawRsa = keyring.getRawRsa(); + MultiKeyringConfig multi = keyring.getMulti(); + AwsKmsKeyringConfig awsKms = keyring.getAwsKms(); + AwsKmsMrkKeyringConfig awsKmsMrk = keyring.getAwsKmsMrk(); + AwsKmsMultiKeyringConfig awsKmsMulti = keyring.getAwsKmsMultiKeyring(); + AwsKmsMrkMultiKeyringConfig awsKmsMrkMulti = keyring.getAwsKmsMrkMultiKeyring(); + AwsKmsRsaKeyringConfig awsKmsRsa = keyring.getAwsKmsRsa(); + AwsKmsDiscoveryKeyringConfig awsKmsDiscovery = keyring.getAwsKmsDiscovery(); + AwsKmsMrkDiscoveryKeyringConfig awsKmsMrkDiscovery = keyring.getAwsKmsMrkDiscovery(); + AwsKmsHierarchicalKeyringConfig awsKmsHierarchical = keyring.getAwsKmsHierarchical(); + + if (rawAes != null) { + return materialProviders.CreateRawAesKeyring( + CreateRawAesKeyringInput.builder() + .keyNamespace(rawAes.getKeyNamespace()) + .keyName(rawAes.getKeyName()) + .wrappingKey(rawAes.getWrappingKey()) + .wrappingAlg(AesWrappingAlg.valueOf(rawAes.getWrappingAlg().getValue())) + .build()); + } + if (rawRsa != null) { + CreateRawRsaKeyringInput.Builder builder = CreateRawRsaKeyringInput.builder() + .keyNamespace(rawRsa.getKeyNamespace()) + .keyName(rawRsa.getKeyName()) + .paddingScheme(PaddingScheme.valueOf(rawRsa.getPaddingScheme().getValue())); + if (rawRsa.getPublicKey() != null) { + builder.publicKey(rawRsa.getPublicKey()); + } + if (rawRsa.getPrivateKey() != null) { + builder.privateKey(rawRsa.getPrivateKey()); + } + return materialProviders.CreateRawRsaKeyring(builder.build()); + } + if (multi != null) { + CreateMultiKeyringInput.Builder builder = CreateMultiKeyringInput.builder(); + if (multi.getGenerator() != null) { + builder.generator(buildKeyring(multi.getGenerator())); + } + List children = new ArrayList<>(); + for (Keyring child : multi.getChildKeyrings()) { + children.add(buildKeyring(child)); + } + builder.childKeyrings(children); + return materialProviders.CreateMultiKeyring(builder.build()); + } + if (awsKms != null) { + // Single symmetric KMS key -> the single-key KMS keyring (the faithful + // mapping for one symmetric key). The KMS client is built eagerly; no + // network call happens until Encrypt/Decrypt (Requirement 14.1, 14.14). + CreateAwsKmsKeyringInput.Builder builder = CreateAwsKmsKeyringInput.builder() + .kmsKeyId(awsKms.getKmsKeyId()) + .kmsClient(kmsClientForKey(awsKms.getKmsKeyId())); + if (awsKms.hasGrantTokens() && !awsKms.getGrantTokens().isEmpty()) { + builder.grantTokens(new ArrayList<>(awsKms.getGrantTokens())); + } + return materialProviders.CreateAwsKmsKeyring(builder.build()); + } + if (awsKmsMrk != null) { + // Single multi-region key -> the single-key MRK-aware KMS keyring. + CreateAwsKmsMrkKeyringInput.Builder builder = CreateAwsKmsMrkKeyringInput.builder() + .kmsKeyId(awsKmsMrk.getKmsKeyId()) + .kmsClient(kmsClientForKey(awsKmsMrk.getKmsKeyId())); + if (awsKmsMrk.hasGrantTokens() && !awsKmsMrk.getGrantTokens().isEmpty()) { + builder.grantTokens(new ArrayList<>(awsKmsMrk.getGrantTokens())); + } + return materialProviders.CreateAwsKmsMrkKeyring(builder.build()); + } + if (awsKmsMulti != null) { + CreateAwsKmsMultiKeyringInput.Builder builder = CreateAwsKmsMultiKeyringInput.builder(); + if (awsKmsMulti.getGenerator() != null) { + builder.generator(awsKmsMulti.getGenerator()); + } + if (awsKmsMulti.hasKmsKeyIds()) { + builder.kmsKeyIds(new ArrayList<>(awsKmsMulti.getKmsKeyIds())); + } + if (awsKmsMulti.hasGrantTokens() && !awsKmsMulti.getGrantTokens().isEmpty()) { + builder.grantTokens(new ArrayList<>(awsKmsMulti.getGrantTokens())); + } + return materialProviders.CreateAwsKmsMultiKeyring(builder.build()); + } + if (awsKmsMrkMulti != null) { + // MRK-aware multi-keyring: an optional MRK generator + child MRK key + // ids. Mirrors the non-MRK multi-keyring wiring; the MRK-aware form + // matches multi-region keys across regions on decrypt. + CreateAwsKmsMrkMultiKeyringInput.Builder builder = + CreateAwsKmsMrkMultiKeyringInput.builder(); + if (awsKmsMrkMulti.getGenerator() != null) { + builder.generator(awsKmsMrkMulti.getGenerator()); + } + if (awsKmsMrkMulti.hasKmsKeyIds()) { + builder.kmsKeyIds(new ArrayList<>(awsKmsMrkMulti.getKmsKeyIds())); + } + if (awsKmsMrkMulti.hasGrantTokens() && !awsKmsMrkMulti.getGrantTokens().isEmpty()) { + builder.grantTokens(new ArrayList<>(awsKmsMrkMulti.getGrantTokens())); + } + return materialProviders.CreateAwsKmsMrkMultiKeyring(builder.build()); + } + if (awsKmsRsa != null) { + return buildAwsKmsRsaKeyring(awsKmsRsa); + } + if (awsKmsDiscovery != null) { + return buildAwsKmsDiscoveryKeyring(awsKmsDiscovery); + } + if (awsKmsMrkDiscovery != null) { + return buildAwsKmsMrkDiscoveryKeyring(awsKmsMrkDiscovery); + } + if (awsKmsHierarchical != null) { + return buildAwsKmsHierarchicalKeyring(awsKmsHierarchical); + } + throw new IllegalArgumentException("Keyring had no variant member set"); + } + + /** + * Build the AWS KMS RSA keyring (Requirement 14.3, 14.4). The keyring needs + * the RSA public key bytes (for encrypt), the KMS key id/ARN and a KMS + * client (for decrypt, which calls {@code kms:Decrypt}), and an RSAES-OAEP + * encryption algorithm mapped from the modeled {@link KmsRsaEncryptionAlgorithm}. + * + *

Public-key sourcing: if the modeled config carries {@code publicKey}, it + * is used and construction stays fully offline. Otherwise the factory fetches + * it once via {@code kms:GetPublicKey} — a network call the design permits at + * {@code CreateClient} time (KMS scenarios only run when credentials are + * present). Either way, no encrypt/decrypt happens at construction. + */ + private IKeyring buildAwsKmsRsaKeyring(AwsKmsRsaKeyringConfig config) { + KmsClient kmsClient = kmsClientForKey(config.getKmsKeyId()); + ByteBuffer publicKey = config.getPublicKey(); + if (publicKey == null) { + // Fetch the RSA public key once from KMS (network call at construction). + publicKey = kmsClient.getPublicKey( + GetPublicKeyRequest.builder().keyId(config.getKmsKeyId()).build()) + .publicKey() + .asByteBuffer(); + } + // The ESDK's CreateAwsKmsRsaKeyring expects the public key as PEM, but KMS + // GetPublicKey returns it as DER (X.509 SubjectPublicKeyInfo). Wrap DER as + // a PEM "PUBLIC KEY" block (pass through if it is already PEM). + CreateAwsKmsRsaKeyringInput.Builder builder = CreateAwsKmsRsaKeyringInput.builder() + .kmsKeyId(config.getKmsKeyId()) + .publicKey(toPublicKeyPem(publicKey)) + .encryptionAlgorithm(toEncryptionAlgorithmSpec(config.getEncryptionAlgorithm())) + .kmsClient(kmsClient); + if (config.hasGrantTokens() && !config.getGrantTokens().isEmpty()) { + builder.grantTokens(new ArrayList<>(config.getGrantTokens())); + } + return materialProviders.CreateAwsKmsRsaKeyring(builder.build()); + } + + /** + * Normalize an RSA public key to PEM, as required by {@code + * CreateAwsKmsRsaKeyring}. KMS {@code GetPublicKey} returns the key as DER + * (X.509 {@code SubjectPublicKeyInfo}); this base64-wraps those bytes in a + * {@code -----BEGIN PUBLIC KEY-----} block. If the input already looks like + * PEM it is returned unchanged, so a caller-supplied PEM public key works too. + */ + private static ByteBuffer toPublicKeyPem(ByteBuffer publicKey) { + byte[] bytes = new byte[publicKey.remaining()]; + publicKey.duplicate().get(bytes); + String head = new String(bytes, 0, Math.min(bytes.length, 11), StandardCharsets.US_ASCII); + if (head.startsWith("-----BEGIN")) { + return ByteBuffer.wrap(bytes); + } + String base64 = Base64.getEncoder().encodeToString(bytes); + StringBuilder pem = new StringBuilder("-----BEGIN PUBLIC KEY-----\n"); + for (int i = 0; i < base64.length(); i += 64) { + pem.append(base64, i, Math.min(i + 64, base64.length())).append('\n'); + } + pem.append("-----END PUBLIC KEY-----\n"); + return ByteBuffer.wrap(pem.toString().getBytes(StandardCharsets.US_ASCII)); + } + + /** + * Build the AWS KMS discovery keyring (Requirement 14.3, 14.4). A discovery + * keyring is decrypt-only: it needs a KMS client (its region comes from the + * ambient AWS region / credentials the online Tests supply) and, optionally, a + * discovery filter scoping decrypt to a partition + account ids. On the + * round-trip it pairs with an encrypting KMS keyring on the encrypt leg. + */ + private IKeyring buildAwsKmsDiscoveryKeyring(AwsKmsDiscoveryKeyringConfig config) { + CreateAwsKmsDiscoveryKeyringInput.Builder builder = CreateAwsKmsDiscoveryKeyringInput.builder() + .kmsClient(kmsClient()); + aws.cryptography.esdk.testserver.server.model.DiscoveryFilter modeledFilter = + config.getDiscoveryFilter(); + if (modeledFilter != null) { + builder.discoveryFilter( + software.amazon.cryptography.materialproviders.model.DiscoveryFilter.builder() + .partition(modeledFilter.getPartition()) + .accountIds(new ArrayList<>(modeledFilter.getAccountIds())) + .build()); + } + if (config.hasGrantTokens() && !config.getGrantTokens().isEmpty()) { + builder.grantTokens(new ArrayList<>(config.getGrantTokens())); + } + return materialProviders.CreateAwsKmsDiscoveryKeyring(builder.build()); + } + + /** + * Build the AWS KMS MRK-aware discovery keyring: a decrypt-only discovery + * keyring normalized to {@code region} (built with a KMS client in that + * region), so it can decrypt a multi-region key written in another region. + */ + private IKeyring buildAwsKmsMrkDiscoveryKeyring(AwsKmsMrkDiscoveryKeyringConfig config) { + CreateAwsKmsMrkDiscoveryKeyringInput.Builder builder = + CreateAwsKmsMrkDiscoveryKeyringInput.builder() + .kmsClient(KmsClient.builder().region(Region.of(config.getRegion())).build()) + .region(config.getRegion()); + aws.cryptography.esdk.testserver.server.model.DiscoveryFilter modeledFilter = + config.getDiscoveryFilter(); + if (modeledFilter != null) { + builder.discoveryFilter( + software.amazon.cryptography.materialproviders.model.DiscoveryFilter.builder() + .partition(modeledFilter.getPartition()) + .accountIds(new ArrayList<>(modeledFilter.getAccountIds())) + .build()); + } + if (config.hasGrantTokens() && !config.getGrantTokens().isEmpty()) { + builder.grantTokens(new ArrayList<>(config.getGrantTokens())); + } + return materialProviders.CreateAwsKmsMrkDiscoveryKeyring(builder.build()); + } + + /** + * Build the AWS KMS hierarchical keyring: branch keys live in a DynamoDB key + * store and are wrapped by a KMS key. The key store's DynamoDB and KMS clients + * are built eagerly without a network call; branch-key retrieval reaches + * DynamoDB and KMS only on {@code Encrypt}/{@code Decrypt}. + */ + private IKeyring buildAwsKmsHierarchicalKeyring(AwsKmsHierarchicalKeyringConfig config) { + KeyStore keyStore = KeyStore.builder() + .KeyStoreConfig(KeyStoreConfig.builder() + .ddbTableName(config.getKeyStoreTableName()) + .logicalKeyStoreName(config.getLogicalKeyStoreName()) + .kmsConfiguration(KMSConfiguration.builder() + .kmsKeyArn(config.getKmsKeyArn()) + .build()) + .ddbClient(DynamoDbClient.builder().region(Region.of(resolveRegion())).build()) + .kmsClient(kmsClient()) + .build()) + .build(); + return materialProviders.CreateAwsKmsHierarchicalKeyring( + CreateAwsKmsHierarchicalKeyringInput.builder() + .keyStore(keyStore) + .branchKeyId(config.getBranchKeyId()) + .ttlSeconds(config.getTtlSeconds()) + .build()); + } + + /** + * Construct an AWS KMS client. Building the client performs no network call; + * the region is resolved from the ambient AWS region provider chain (the + * {@code AWS_REGION} / configured region the online KMS Tests supply). Key-ARN + * based keyrings encode their own region, but the client is supplied so + * discovery (which has no key ARN) and RSA (GetPublicKey) can reach KMS. + */ + private static KmsClient kmsClient() { + // Resolve the region explicitly with a safe default so the KMS client + // never fails region resolution when the ambient provider chain is empty + // (e.g. a nested server JVM that did not inherit AWS_REGION). The ambient + // AWS_REGION / aws.region still takes precedence; us-west-2 (where the + // KMS_Test_Resources live) is the fallback. + return KmsClient.builder().region(Region.of(resolveRegion())).build(); + } + + /** + * A KMS client in the key's own region. KMS rejects an ARN whose region + * differs from the client's region ("Invalid arn <region>"), so a + * us-east-1 key needs a us-east-1 client even when the ambient region is + * us-west-2. Falls back to {@link #resolveRegion()} for a bare key id or + * alias that carries no region. + */ + private static KmsClient kmsClientForKey(String kmsKeyId) { + return KmsClient.builder().region(Region.of(regionForKey(kmsKeyId))).build(); + } + + private static String regionForKey(String kmsKeyId) { + if (kmsKeyId != null && kmsKeyId.startsWith("arn:")) { + String[] parts = kmsKeyId.split(":"); + if (parts.length > 3 && !isBlank(parts[3])) { + return parts[3]; + } + } + return resolveRegion(); + } + + /** + * @return the AWS region for the KMS client: {@code aws.region} system + * property, then {@code AWS_REGION} / {@code AWS_DEFAULT_REGION} + * environment, then the {@code us-west-2} default. + */ + private static String resolveRegion() { + String region = System.getProperty("aws.region"); + if (isBlank(region)) { + region = System.getenv("AWS_REGION"); + } + if (isBlank(region)) { + region = System.getenv("AWS_DEFAULT_REGION"); + } + return isBlank(region) ? "us-west-2" : region.trim(); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static EncryptionAlgorithmSpec toEncryptionAlgorithmSpec( + KmsRsaEncryptionAlgorithm algorithm) { + if (algorithm == null) { + throw new IllegalArgumentException( + "AwsKmsRsa keyring requires an encryptionAlgorithm (RSAES_OAEP_SHA_1 " + + "or RSAES_OAEP_SHA_256)"); + } + return switch (algorithm.getValue()) { + case "RSAES_OAEP_SHA_1" -> EncryptionAlgorithmSpec.RSAES_OAEP_SHA_1; + case "RSAES_OAEP_SHA_256" -> EncryptionAlgorithmSpec.RSAES_OAEP_SHA_256; + default -> throw new IllegalArgumentException( + "Unknown KMS RSA encryption algorithm: " + algorithm.getValue()); + }; + } + + private static CommitmentPolicy toCommitmentPolicy(String value) { + return switch (value) { + case "FORBID_ENCRYPT_ALLOW_DECRYPT" -> CommitmentPolicy.ForbidEncryptAllowDecrypt; + case "REQUIRE_ENCRYPT_ALLOW_DECRYPT" -> CommitmentPolicy.RequireEncryptAllowDecrypt; + case "REQUIRE_ENCRYPT_REQUIRE_DECRYPT" -> CommitmentPolicy.RequireEncryptRequireDecrypt; + default -> throw new IllegalArgumentException("Unknown commitment policy: " + value); + }; + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/ErrorClassifier.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/ErrorClassifier.java new file mode 100644 index 00000000..aab8394b --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/ErrorClassifier.java @@ -0,0 +1,103 @@ +package aws.cryptography.esdk.testserver.server.error; + +import aws.cryptography.esdk.testserver.server.model.ESDKClientError; +import aws.cryptography.esdk.testserver.server.model.ESDKTestServerException; +import aws.cryptography.esdk.testserver.server.model.GenericServerError; +import software.amazon.cryptography.materialproviders.model.CollectionOfErrors; + +/** + * Maps any {@link Throwable} raised while handling an operation onto exactly one + * of the two modeled error shapes, by the origin of the failure (Property 8, + * Requirements 5.5, 5.6, 6.1, 6.2). + * + *

The mapping is: + *

    + *
  • An already-modeled {@link GenericServerError} or {@link ESDKClientError} + * is returned unchanged, preserving its type and message (Requirement 6.1).
  • + *
  • An {@link EsdkClientException} — a failure that originated inside the real + * ESDK client — becomes an {@link ESDKClientError} whose message is the ESDK + * exception's message, unmodified, and never a {@link GenericServerError} + * (Requirements 5.6, Property 8).
  • + *
  • Any other (non-modeled) exception — a TestServer-framework failure — + * becomes a {@link GenericServerError} with a non-empty message that includes + * the originating exception's description, and never an {@link ESDKClientError} + * (Requirements 5.5, 6.2, Property 8).
  • + *
+ * + *

This is a pure function of the throwable and the operation name; it performs + * no I/O and mutates no state, so it is exercised directly by the error-mapping + * property tests (P8) and, through {@link OperationWrapper}, by the catch-all + * wrapping property test (P9). + */ +public final class ErrorClassifier { + + /** + * Classify a failure into a modeled error by its origin. + * + * @param operationName the operation being handled, used to build a helpful + * framework-error message. + * @param failure the throwable raised by the handler. + * @return a {@link GenericServerError} or an {@link ESDKClientError}; never + * {@code null}. + */ + public ESDKTestServerException classify(String operationName, Throwable failure) { + // (6.1) Modeled errors pass through with type and message preserved. + if (failure instanceof GenericServerError modeled) { + return modeled; + } + if (failure instanceof ESDKClientError modeled) { + return modeled; + } + + // (5.6, P8) ESDK-origin failures forward the ESDK message unmodified, + // plus any nested CollectionOfErrors causes so per-key failures are visible. + if (failure instanceof EsdkClientException esdk) { + return ESDKClientError.builder() + .message(esdkMessageWithCauses(esdk)) + .build(); + } + + // (5.5, 6.2, P8) Every other failure is a framework failure -> a + // GenericServerError with a non-empty message including the description. + return GenericServerError.builder() + .message("Operation '" + operationName + "' failed: " + describe(failure)) + .build(); + } + + /** Build a non-empty description of a non-modeled exception (Requirement 6.2). */ + private static String describe(Throwable failure) { + if (failure == null) { + return "unknown error"; + } + String type = failure.getClass().getName(); + String message = failure.getMessage(); + return (message == null || message.isEmpty()) ? type : type + ": " + message; + } + + /** + * The ESDK message, plus — when the failure carries an MPL + * {@link CollectionOfErrors} in its cause chain — the nested list of + * encountered exceptions, so the underlying per-key causes (e.g. why each + * configured key could not decrypt) are visible instead of only the + * top-level "... available via `list`". + */ + private static String esdkMessageWithCauses(EsdkClientException esdk) { + String message = esdk.esdkMessage(); + String base = message == null ? "" : message; + for (Throwable cause = esdk; cause != null; cause = cause.getCause()) { + if (cause instanceof CollectionOfErrors collection && !collection.list().isEmpty()) { + StringBuilder encountered = new StringBuilder(); + for (RuntimeException nested : collection.list()) { + if (encountered.length() > 0) { + encountered.append("; "); + } + encountered.append(nested.getMessage() == null + ? nested.getClass().getSimpleName() + : nested.getMessage()); + } + return base + " [encountered: " + encountered + "]"; + } + } + return base; + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/EsdkClientException.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/EsdkClientException.java new file mode 100644 index 00000000..7f266a9b --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/EsdkClientException.java @@ -0,0 +1,33 @@ +package aws.cryptography.esdk.testserver.server.error; + +/** + * Marks a failure that originated as an exception thrown by the underlying real + * ESDK client (encrypt/decrypt, or config-driven keyring/CMM construction that + * the ESDK itself rejects). The {@link aws.cryptography.esdk.testserver.server.registry.EsdkClient} + * implementation catches the ESDK exception and rethrows it wrapped in this type + * so the {@link ErrorClassifier} can distinguish ESDK-origin failures from + * TestServer-framework failures (Requirements 5.5, 5.6, Property 8). + * + *

The ESDK exception's message is captured verbatim so the classifier can + * forward it unmodified in an {@code ESDKClientError} (Requirement 5.6). + */ +public final class EsdkClientException extends Exception { + + /** + * Wrap an ESDK-thrown exception. + * + * @param cause the exception thrown by the real ESDK client; its message is + * forwarded unmodified. + */ + public EsdkClientException(Throwable cause) { + super(cause == null ? null : cause.getMessage(), cause); + } + + /** + * @return the ESDK exception's message, unmodified (Requirement 5.6). May be + * {@code null} if the underlying ESDK exception carried no message. + */ + public String esdkMessage() { + return getMessage(); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/OperationWrapper.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/OperationWrapper.java new file mode 100644 index 00000000..5d832b7a --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/error/OperationWrapper.java @@ -0,0 +1,57 @@ +package aws.cryptography.esdk.testserver.server.error; + +/** + * Catch-all wrapper applied to every operation handler so that each operation's + * outcome is exactly one of: a successful modeled response, a + * {@link aws.cryptography.esdk.testserver.server.model.GenericServerError}, or an + * {@link aws.cryptography.esdk.testserver.server.model.ESDKClientError} — never a + * bare HTTP error (Requirements 6.1, 6.2, 6.3, 6.4, Property 9). + * + *

The wrapper runs the handler body; if it returns normally that value is the + * successful modeled response. If the body throws, the throwable is routed + * through {@link ErrorClassifier} and the resulting modeled error is thrown so + * the smithy-java runtime serializes it as one of the two declared error shapes. + * Because the classifier always yields a modeled error, no non-modeled exception + * can escape a wrapped handler. + */ +public final class OperationWrapper { + + private final ErrorClassifier classifier; + + public OperationWrapper() { + this(new ErrorClassifier()); + } + + public OperationWrapper(ErrorClassifier classifier) { + this.classifier = classifier; + } + + /** + * A handler body that produces the operation's successful modeled response, + * or throws. Unlike {@link java.util.function.Supplier}, it may throw checked + * exceptions (notably {@link EsdkClientException} from the real ESDK client). + */ + @FunctionalInterface + public interface HandlerBody { + T run() throws Exception; + } + + /** + * Run a handler body under the catch-all contract. + * + * @param operationName the operation name, used for framework-error messages. + * @param body the handler body. + * @param the operation's output type. + * @return the successful modeled response produced by {@code body}. + * @throws aws.cryptography.esdk.testserver.server.model.GenericServerError or + * {@link aws.cryptography.esdk.testserver.server.model.ESDKClientError} if + * the body throws; the origin determines which (Property 8, 9). + */ + public T invoke(String operationName, HandlerBody body) { + try { + return body.run(); + } catch (Throwable failure) { + throw classifier.classify(operationName, failure); + } + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/Blobs.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/Blobs.java new file mode 100644 index 00000000..56267b1a --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/Blobs.java @@ -0,0 +1,21 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import java.nio.ByteBuffer; + +/** Small helpers for reading modeled blob members without disturbing them. */ +final class Blobs { + + private Blobs() { + } + + /** + * Copy the remaining bytes of a {@link ByteBuffer} into a fresh array, + * duplicating first so the source buffer's position is not consumed. + */ + static byte[] toArray(ByteBuffer buffer) { + ByteBuffer duplicate = buffer.duplicate(); + byte[] bytes = new byte[duplicate.remaining()]; + duplicate.get(bytes); + return bytes; + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/ClientIdGuard.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/ClientIdGuard.java new file mode 100644 index 00000000..18a7b462 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/ClientIdGuard.java @@ -0,0 +1,38 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import aws.cryptography.esdk.testserver.server.model.GenericServerError; +import aws.cryptography.esdk.testserver.server.registry.ClientRegistry; +import aws.cryptography.esdk.testserver.server.registry.EsdkClient; + +/** + * Resolves the {@code ClientId} required by every non-{@code CreateClient} + * operation (Requirement 3.8). An absent, empty, or unknown id yields a + * {@link GenericServerError}; because the guard throws before any ESDK call is + * made, no ESDK operation runs and the {@link ClientRegistry} is left unchanged + * (Requirement 3.9, Property 5). + */ +public final class ClientIdGuard { + + private final ClientRegistry registry; + + public ClientIdGuard(ClientRegistry registry) { + this.registry = registry; + } + + /** + * Resolve a client by id, or throw a {@link GenericServerError} if the id is + * absent, empty, or not present in the registry. + * + * @param clientId the id from the request (the generated shapes substitute an + * empty string for an absent required id). + * @return the resolved client, never {@code null}. + */ + public EsdkClient resolve(String clientId) { + return registry.resolve(clientId).orElseThrow(() -> + GenericServerError.builder() + .message("No ESDK client is registered under ClientId '" + + (clientId == null ? "" : clientId) + + "'. Call CreateClient first and pass the returned ClientId.") + .build()); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/CreateClientHandler.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/CreateClientHandler.java new file mode 100644 index 00000000..1ad96120 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/CreateClientHandler.java @@ -0,0 +1,74 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import aws.cryptography.esdk.testserver.server.config.ConfigValidator; +import aws.cryptography.esdk.testserver.server.config.EsdkClientFactory; +import aws.cryptography.esdk.testserver.server.error.OperationWrapper; +import aws.cryptography.esdk.testserver.server.model.CreateClientInput; +import aws.cryptography.esdk.testserver.server.model.CreateClientOutput; +import aws.cryptography.esdk.testserver.server.model.ESDKClientError; +import aws.cryptography.esdk.testserver.server.model.GenericServerError; +import aws.cryptography.esdk.testserver.server.registry.ClientRegistry; +import aws.cryptography.esdk.testserver.server.registry.EsdkClient; +import aws.cryptography.esdk.testserver.server.service.CreateClientOperation; +import software.amazon.smithy.java.server.RequestContext; + +/** + * Constructs a real ESDK Java client from the modeled config and registers it, + * returning its {@code ClientId} (Requirements 3.1, 3.5). + * + *

The config is first validated for the exactly-one-variant invariant, which + * fails with a modeled {@link ESDKClientError} (Requirements 2.3, 2.4). Then the + * client is constructed; if construction fails the registry is left unchanged, no + * {@code ClientId} is returned, and a {@link GenericServerError} is raised + * (Requirement 3.6, Property 4). Registration happens only after a successful + * construction, so a failed {@code CreateClient} never adds an entry. + */ +public final class CreateClientHandler implements CreateClientOperation { + + private final ClientRegistry registry; + private final ConfigValidator validator; + private final EsdkClientFactory factory; + private final OperationWrapper wrapper; + + public CreateClientHandler(ClientRegistry registry, ConfigValidator validator, + EsdkClientFactory factory, OperationWrapper wrapper) { + this.registry = registry; + this.validator = validator; + this.factory = factory; + this.wrapper = wrapper; + } + + @Override + public CreateClientOutput createClient(CreateClientInput input, RequestContext context) { + return wrapper.invoke("CreateClient", () -> { + // (2.3, 2.4) Exactly-one-variant validation surfaces as a modeled + // ESDKClientError, preserved by the wrapper. + validator.validate(input.getConfig()); + + EsdkClient client; + try { + client = factory.create(input.getConfig()); + } catch (ESDKClientError modeled) { + // A config the ESDK itself rejects at construction is still a + // client-construction failure per Requirement 3.6. + throw constructionFailure(modeled); + } catch (RuntimeException constructionFailure) { + // (3.6) Construction failed: leave the registry unchanged and + // return a GenericServerError. Registration is not attempted. + throw constructionFailure(constructionFailure); + } + + String clientId = registry.register(client); + return CreateClientOutput.builder().clientId(clientId).build(); + }); + } + + private static GenericServerError constructionFailure(Throwable cause) { + String detail = cause.getMessage() == null + ? cause.getClass().getName() + : cause.getMessage(); + return GenericServerError.builder() + .message("CreateClient failed to construct the ESDK client: " + detail) + .build(); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/DecryptHandler.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/DecryptHandler.java new file mode 100644 index 00000000..3f865a2e --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/DecryptHandler.java @@ -0,0 +1,39 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import aws.cryptography.esdk.testserver.server.error.OperationWrapper; +import aws.cryptography.esdk.testserver.server.model.DecryptInput; +import aws.cryptography.esdk.testserver.server.model.DecryptOutput; +import aws.cryptography.esdk.testserver.server.registry.EsdkClient; +import aws.cryptography.esdk.testserver.server.service.DecryptOperation; +import java.nio.ByteBuffer; +import software.amazon.smithy.java.server.RequestContext; + +/** + * Blob variant of decrypt: resolves the {@code ClientId}, decrypts the ciphertext + * blob with the referenced real ESDK client, and returns the plaintext blob + * (Requirement 4.3). A failure inside the ESDK is forwarded as an + * {@code ESDKClientError} with no plaintext and an unchanged registry + * (Requirements 4.10, Property 8); a missing/unknown {@code ClientId} yields a + * {@code GenericServerError} before any ESDK call (Requirement 3.9). + */ +public final class DecryptHandler implements DecryptOperation { + + private final ClientIdGuard guard; + private final OperationWrapper wrapper; + + public DecryptHandler(ClientIdGuard guard, OperationWrapper wrapper) { + this.guard = guard; + this.wrapper = wrapper; + } + + @Override + public DecryptOutput decrypt(DecryptInput input, RequestContext context) { + return wrapper.invoke("Decrypt", () -> { + EsdkClient client = guard.resolve(input.getClientId()); + byte[] plaintext = client.decrypt( + Blobs.toArray(input.getCiphertext()), + input.getEncryptionContext()); + return DecryptOutput.builder().plaintext(ByteBuffer.wrap(plaintext)).build(); + }); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/DecryptStreamHandler.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/DecryptStreamHandler.java new file mode 100644 index 00000000..c9ac2931 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/DecryptStreamHandler.java @@ -0,0 +1,53 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import aws.cryptography.esdk.testserver.server.error.OperationWrapper; +import aws.cryptography.esdk.testserver.server.model.DecryptStreamInput; +import aws.cryptography.esdk.testserver.server.model.DecryptStreamOutput; +import aws.cryptography.esdk.testserver.server.registry.EsdkClient; +import aws.cryptography.esdk.testserver.server.service.DecryptStreamOperation; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.ByteBuffer; +import software.amazon.smithy.java.server.RequestContext; + +/** + * Stream variant of decrypt for the Streaming_Capable Java server (Requirement + * 4.6). The ciphertext payload rides on the wire as a plain {@code Blob} (not a + * Smithy {@code @streaming} member, because stock smithy-java 1.4.0 does not + * transmit {@code @streaming} members over rpcv2-CBOR); the streaming semantics + * live entirely server-side. This handler resolves the {@code ClientId}, wraps + * the received ciphertext bytes in an {@link InputStream}, drives the REAL ESDK + * Java streaming decrypt API, collects the streamed plaintext into a blob, and + * returns it (Requirements 4.1, 4.6). ESDK failures forward as an + * {@code ESDKClientError} with the ESDK message unmodified (Requirement 4.11); + * a missing/unknown {@code ClientId} yields a {@code GenericServerError} before + * any ESDK call (Requirement 3.9). + */ +public final class DecryptStreamHandler implements DecryptStreamOperation { + + private final ClientIdGuard guard; + private final OperationWrapper wrapper; + + public DecryptStreamHandler(ClientIdGuard guard, OperationWrapper wrapper) { + this.guard = guard; + this.wrapper = wrapper; + } + + @Override + public DecryptStreamOutput decryptStream(DecryptStreamInput input, RequestContext context) { + return wrapper.invoke("DecryptStream", () -> { + EsdkClient client = guard.resolve(input.getClientId()); + // Drive the ESDK STREAMING API even though the payload rides as a blob: + // wrap the received bytes in a stream, stream-decrypt, collect the bytes. + ByteArrayOutputStream plaintext = new ByteArrayOutputStream(); + try (InputStream ciphertext = + new ByteArrayInputStream(Blobs.toArray(input.getCiphertext()))) { + client.decryptStream(ciphertext, plaintext, input.getEncryptionContext()); + } + return DecryptStreamOutput.builder() + .plaintext(ByteBuffer.wrap(plaintext.toByteArray())) + .build(); + }); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EncryptHandler.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EncryptHandler.java new file mode 100644 index 00000000..766056b9 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EncryptHandler.java @@ -0,0 +1,43 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import aws.cryptography.esdk.testserver.server.error.OperationWrapper; +import aws.cryptography.esdk.testserver.server.model.ESDKAlgorithmSuiteId; +import aws.cryptography.esdk.testserver.server.model.EncryptInput; +import aws.cryptography.esdk.testserver.server.model.EncryptOutput; +import aws.cryptography.esdk.testserver.server.registry.EsdkClient; +import aws.cryptography.esdk.testserver.server.service.EncryptOperation; +import java.nio.ByteBuffer; +import software.amazon.smithy.java.server.RequestContext; + +/** + * Blob variant of encrypt: resolves the {@code ClientId}, encrypts the plaintext + * blob with the referenced real ESDK client, and returns the ciphertext blob + * (Requirement 4.2). A failure inside the ESDK is forwarded as an + * {@code ESDKClientError} with no ciphertext and an unchanged registry + * (Requirements 4.10, Property 8); a missing/unknown {@code ClientId} yields a + * {@code GenericServerError} before any ESDK call (Requirement 3.9). + */ +public final class EncryptHandler implements EncryptOperation { + + private final ClientIdGuard guard; + private final OperationWrapper wrapper; + + public EncryptHandler(ClientIdGuard guard, OperationWrapper wrapper) { + this.guard = guard; + this.wrapper = wrapper; + } + + @Override + public EncryptOutput encrypt(EncryptInput input, RequestContext context) { + return wrapper.invoke("Encrypt", () -> { + EsdkClient client = guard.resolve(input.getClientId()); + ESDKAlgorithmSuiteId suite = input.getAlgorithmSuiteId(); + byte[] ciphertext = client.encrypt( + Blobs.toArray(input.getPlaintext()), + input.getEncryptionContext(), + suite == null ? null : suite.getValue(), + input.getFrameLength()); + return EncryptOutput.builder().ciphertext(ByteBuffer.wrap(ciphertext)).build(); + }); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EncryptStreamHandler.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EncryptStreamHandler.java new file mode 100644 index 00000000..b4ff9d77 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EncryptStreamHandler.java @@ -0,0 +1,60 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import aws.cryptography.esdk.testserver.server.error.OperationWrapper; +import aws.cryptography.esdk.testserver.server.model.ESDKAlgorithmSuiteId; +import aws.cryptography.esdk.testserver.server.model.EncryptStreamInput; +import aws.cryptography.esdk.testserver.server.model.EncryptStreamOutput; +import aws.cryptography.esdk.testserver.server.registry.EsdkClient; +import aws.cryptography.esdk.testserver.server.service.EncryptStreamOperation; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.ByteBuffer; +import software.amazon.smithy.java.server.RequestContext; + +/** + * Stream variant of encrypt for the Streaming_Capable Java server (Requirement + * 4.5). The plaintext payload rides on the wire as a plain {@code Blob} (not a + * Smithy {@code @streaming} member, because stock smithy-java 1.4.0 does not + * transmit {@code @streaming} members over rpcv2-CBOR); the streaming semantics + * live entirely server-side. This handler resolves the {@code ClientId}, wraps + * the received plaintext bytes in an {@link InputStream}, drives the REAL ESDK + * Java streaming encrypt API, collects the streamed ciphertext into a blob, and + * returns it (Requirements 4.1, 4.5). ESDK failures forward as an + * {@code ESDKClientError} with the ESDK message unmodified (Requirement 4.11); + * a missing/unknown {@code ClientId} yields a {@code GenericServerError} before + * any ESDK call (Requirement 3.9). + */ +public final class EncryptStreamHandler implements EncryptStreamOperation { + + private final ClientIdGuard guard; + private final OperationWrapper wrapper; + + public EncryptStreamHandler(ClientIdGuard guard, OperationWrapper wrapper) { + this.guard = guard; + this.wrapper = wrapper; + } + + @Override + public EncryptStreamOutput encryptStream(EncryptStreamInput input, RequestContext context) { + return wrapper.invoke("EncryptStream", () -> { + EsdkClient client = guard.resolve(input.getClientId()); + ESDKAlgorithmSuiteId suite = input.getAlgorithmSuiteId(); + // Drive the ESDK STREAMING API even though the payload rides as a blob: + // wrap the received bytes in a stream, stream-encrypt, collect the bytes. + ByteArrayOutputStream ciphertext = new ByteArrayOutputStream(); + try (InputStream plaintext = + new ByteArrayInputStream(Blobs.toArray(input.getPlaintext()))) { + client.encryptStream( + plaintext, + ciphertext, + input.getEncryptionContext(), + suite == null ? null : suite.getValue(), + input.getFrameLength()); + } + return EncryptStreamOutput.builder() + .ciphertext(ByteBuffer.wrap(ciphertext.toByteArray())) + .build(); + }); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EsdkTestServerHandlers.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EsdkTestServerHandlers.java new file mode 100644 index 00000000..33a98257 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/handler/EsdkTestServerHandlers.java @@ -0,0 +1,82 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import aws.cryptography.esdk.testserver.server.config.ConfigValidator; +import aws.cryptography.esdk.testserver.server.config.EsdkClientFactory; +import aws.cryptography.esdk.testserver.server.error.OperationWrapper; +import aws.cryptography.esdk.testserver.server.registry.ClientRegistry; +import aws.cryptography.esdk.testserver.server.service.ESDKTestServer; + +/** + * Wires the generated {@link ESDKTestServer} service to the hand-written + * operation handlers over one shared, thread-safe {@link ClientRegistry}. This is + * the single assembly point the launcher (and the concurrency integration test) + * use to obtain a fully-wired service instance. + * + *

All five handlers share the same registry so that a {@code ClientId} minted + * by {@code CreateClient} resolves on subsequent {@code Encrypt}/{@code Decrypt} + * calls (Requirement 3.7), and so the registry is the single piece of shared + * mutable state exercised under concurrency (Requirement 3.3). + */ +public final class EsdkTestServerHandlers { + + private final ClientRegistry registry; + private final CreateClientHandler createClient; + private final EncryptHandler encrypt; + private final DecryptHandler decrypt; + private final EncryptStreamHandler encryptStream; + private final DecryptStreamHandler decryptStream; + + public EsdkTestServerHandlers() { + this(new ClientRegistry()); + } + + public EsdkTestServerHandlers(ClientRegistry registry) { + this.registry = registry; + OperationWrapper wrapper = new OperationWrapper(); + ConfigValidator validator = new ConfigValidator(); + EsdkClientFactory factory = new EsdkClientFactory(); + ClientIdGuard guard = new ClientIdGuard(registry); + + this.createClient = new CreateClientHandler(registry, validator, factory, wrapper); + this.encrypt = new EncryptHandler(guard, wrapper); + this.decrypt = new DecryptHandler(guard, wrapper); + this.encryptStream = new EncryptStreamHandler(guard, wrapper); + this.decryptStream = new DecryptStreamHandler(guard, wrapper); + } + + /** @return the shared registry (for inspection in tests). */ + public ClientRegistry registry() { + return registry; + } + + public CreateClientHandler createClientHandler() { + return createClient; + } + + public EncryptHandler encryptHandler() { + return encrypt; + } + + public DecryptHandler decryptHandler() { + return decrypt; + } + + public EncryptStreamHandler encryptStreamHandler() { + return encryptStream; + } + + public DecryptStreamHandler decryptStreamHandler() { + return decryptStream; + } + + /** Build the generated service wired to these handlers. */ + public ESDKTestServer service() { + return ESDKTestServer.builder() + .addCreateClientOperation(createClient) + .addDecryptOperation(decrypt) + .addDecryptStreamOperation(decryptStream) + .addEncryptOperation(encrypt) + .addEncryptStreamOperation(encryptStream) + .build(); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/launcher/ServerBootstrap.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/launcher/ServerBootstrap.java new file mode 100644 index 00000000..36bb956e --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/launcher/ServerBootstrap.java @@ -0,0 +1,110 @@ +package aws.cryptography.esdk.testserver.server.launcher; + +import aws.cryptography.esdk.testserver.server.handler.EsdkTestServerHandlers; +import software.amazon.smithy.java.server.Server; + +/** + * A minimal, runnable bootstrap for the Java {@code Language_Server}. + * + *

It instantiates the generated {@code ESDKTestServer} service wired to the + * hand-written handlers (via {@link EsdkTestServerHandlers}) and starts the + * smithy-java rpcv2Cbor HTTP server (Netty) bound to a configurable port, so a + * user (or a manual two-step run) can start a real over-HTTP server and point + * the single {@code Tests} suite at it purely through runtime configuration + * (Requirement 7.3). + * + *

This is intentionally minimal: it does NOT build the full + * Configuration_Set / source-resolution orchestrator (that is task 7). It is the + * same single service-assembly point ({@link EsdkTestServerHandlers#service()}) + * that the in-process test harness and the future orchestrator/launcher use, so + * running here exercises exactly the shipped server wiring: smithy-java-generated + * request decoding / response & error encoding over one shared, thread-safe + * {@code Client_Registry}, delegating to the real AWS Encryption SDK for Java. + * + *

The port is resolved, in precedence order, from: + *

    + *
  1. the first command-line argument, if present;
  2. + *
  3. the system property {@code esdk.testserver.port};
  4. + *
  5. the environment variable {@code ESDK_TESTSERVER_PORT};
  6. + *
  7. otherwise the default {@code 8080}.
  8. + *
+ * + *

On start it prints the base endpoint URL (host:port) to stdout so callers + * can discover where to point the {@code Tests}, then blocks until the process + * is interrupted/terminated, shutting the server down cleanly via a shutdown + * hook. + */ +public final class ServerBootstrap { + + /** System property carrying the port to bind. */ + public static final String PORT_PROPERTY = "esdk.testserver.port"; + + /** Environment variable equivalent of {@link #PORT_PROPERTY}. */ + public static final String PORT_ENV = "ESDK_TESTSERVER_PORT"; + + /** Default port when none is configured. */ + public static final int DEFAULT_PORT = 8080; + + private ServerBootstrap() { + } + + public static void main(String[] args) throws InterruptedException { + int port = resolvePort(args); + + EsdkTestServerHandlers handlers = new EsdkTestServerHandlers(); + Server server = Server.builder() + .endpoints(port) + .addService(handlers.service()) + .build(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down ESDK TestServer (Java) ..."); + server.shutdown().join(); + }, "esdk-testserver-shutdown")); + + server.start(); + System.out.println("ESDK TestServer (Java) listening at http://127.0.0.1:" + port); + System.out.println("Point the Tests at it with: " + + "-Desdk.testserver.endpoints=http://127.0.0.1:" + port); + System.out.println("Press Ctrl-C to stop."); + + // Block the main thread for the lifetime of the process; the shutdown + // hook performs the clean shutdown on SIGINT/SIGTERM. + Thread.currentThread().join(); + } + + /** + * Resolve the port from (in order) the first CLI arg, the system property, + * the environment variable, or the default. Rejects out-of-range or + * non-numeric values with a clear error. + */ + static int resolvePort(String[] args) { + String raw = null; + if (args != null && args.length > 0 && args[0] != null && !args[0].isBlank()) { + raw = args[0].trim(); + } else { + String property = System.getProperty(PORT_PROPERTY); + if (property != null && !property.isBlank()) { + raw = property.trim(); + } else { + String env = System.getenv(PORT_ENV); + if (env != null && !env.isBlank()) { + raw = env.trim(); + } + } + } + if (raw == null) { + return DEFAULT_PORT; + } + int port; + try { + port = Integer.parseInt(raw); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid port '" + raw + "': must be an integer in 1..65535"); + } + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("Invalid port " + port + ": must be in the range 1..65535"); + } + return port; + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/DiscriminatingCborCodec.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/DiscriminatingCborCodec.java new file mode 100644 index 00000000..61a7bf1a --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/DiscriminatingCborCodec.java @@ -0,0 +1,43 @@ +package aws.cryptography.esdk.testserver.server.protocol; + +import java.io.OutputStream; +import java.nio.ByteBuffer; +import software.amazon.smithy.java.cbor.Rpcv2CborCodec; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.core.serde.ShapeDeserializer; +import software.amazon.smithy.java.core.serde.ShapeSerializer; + +/** + * A {@link Codec} that behaves exactly like the stock rpcv2Cbor codec for + * deserialization and for non-error serialization, but wraps the serializer so + * that modeled error structs are emitted as discriminated documents carrying a + * {@code __type} field (see {@link DiscriminatingCborSerializer} for the why). + * + *

This is the single, surgical change that makes the two modeled errors + * transmit distinctly over rpcv2Cbor without touching the generated client, the + * model, or the blob/normal-response wire form. + */ +public final class DiscriminatingCborCodec implements Codec { + + private final Codec delegate = Rpcv2CborCodec.builder().build(); + + @Override + public ShapeSerializer createSerializer(OutputStream sink) { + return new DiscriminatingCborSerializer(delegate.createSerializer(sink)); + } + + @Override + public ShapeDeserializer createDeserializer(byte[] source) { + return delegate.createDeserializer(source); + } + + @Override + public ShapeDeserializer createDeserializer(ByteBuffer source) { + return delegate.createDeserializer(source); + } + + @Override + public void close() { + delegate.close(); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/DiscriminatingCborSerializer.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/DiscriminatingCborSerializer.java new file mode 100644 index 00000000..7580174f --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/DiscriminatingCborSerializer.java @@ -0,0 +1,165 @@ +package aws.cryptography.esdk.testserver.server.protocol; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.function.BiConsumer; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.MapSerializer; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.core.serde.event.EventStream; +import software.amazon.smithy.java.io.datastream.DataStream; + +/** + * A {@link ShapeSerializer} that delegates everything to the stock rpcv2Cbor CBOR + * serializer, EXCEPT that a modeled error struct is serialized as a + * discriminated {@link Document} rather than a plain struct. + * + *

Why: stock smithy-java 1.4.0 serializes a modeled error over rpcv2Cbor as a + * bare map of its members ({@code {"message": ...}}) with no {@code __type} + * discriminator. The generated Java {@code Test_Client}'s error deserializer keys + * off {@code __type} to reconstruct the specific modeled error shape; without it + * both {@code GenericServerError} and {@code ESDKClientError} come back as a + * generic {@code CallException} and are indistinguishable end-to-end (the + * smithy-java rpcv2-CBOR modeled-error transmission caveat called out in the + * design's Error Handling section). + * + *

Serializing the error as {@code Document.of(struct)} makes the CBOR document + * path emit the {@code __type} discriminator (the error's shape id) alongside the + * members, so the stock client maps the response back to the correct modeled + * error type. Non-error structs (normal operation outputs) are serialized exactly + * as before, so the blob round-trip wire form is byte-identical to stock. + */ +final class DiscriminatingCborSerializer implements ShapeSerializer { + + private final ShapeSerializer delegate; + + DiscriminatingCborSerializer(ShapeSerializer delegate) { + this.delegate = delegate; + } + + @Override + public void writeStruct(Schema schema, SerializableStruct struct) { + if (struct instanceof ModeledException) { + // Serialize as a discriminated document so the CBOR document path emits + // the __type discriminator the client needs to pick the modeled error. + Document.of(struct).serialize(delegate); + } else { + delegate.writeStruct(schema, struct); + } + } + + // ---- everything else delegates unchanged ------------------------------- + + @Override + public void writeList(Schema schema, T state, int size, + BiConsumer consumer) { + delegate.writeList(schema, state, size, consumer); + } + + @Override + public void writeMap(Schema schema, T state, int size, + BiConsumer consumer) { + delegate.writeMap(schema, state, size, consumer); + } + + @Override + public void writeBoolean(Schema schema, boolean value) { + delegate.writeBoolean(schema, value); + } + + @Override + public void writeByte(Schema schema, byte value) { + delegate.writeByte(schema, value); + } + + @Override + public void writeShort(Schema schema, short value) { + delegate.writeShort(schema, value); + } + + @Override + public void writeInteger(Schema schema, int value) { + delegate.writeInteger(schema, value); + } + + @Override + public void writeLong(Schema schema, long value) { + delegate.writeLong(schema, value); + } + + @Override + public void writeFloat(Schema schema, float value) { + delegate.writeFloat(schema, value); + } + + @Override + public void writeDouble(Schema schema, double value) { + delegate.writeDouble(schema, value); + } + + @Override + public void writeBigInteger(Schema schema, BigInteger value) { + delegate.writeBigInteger(schema, value); + } + + @Override + public void writeBigDecimal(Schema schema, BigDecimal value) { + delegate.writeBigDecimal(schema, value); + } + + @Override + public void writeString(Schema schema, String value) { + delegate.writeString(schema, value); + } + + @Override + public void writeBlob(Schema schema, ByteBuffer value) { + delegate.writeBlob(schema, value); + } + + @Override + public void writeBlob(Schema schema, byte[] value) { + delegate.writeBlob(schema, value); + } + + @Override + public void writeDataStream(Schema schema, DataStream value) { + delegate.writeDataStream(schema, value); + } + + @Override + public void writeEventStream(Schema schema, + EventStream value) { + delegate.writeEventStream(schema, value); + } + + @Override + public void writeTimestamp(Schema schema, Instant value) { + delegate.writeTimestamp(schema, value); + } + + @Override + public void writeDocument(Schema schema, Document value) { + delegate.writeDocument(schema, value); + } + + @Override + public void writeNull(Schema schema) { + delegate.writeNull(schema); + } + + @Override + public void flush() { + delegate.flush(); + } + + @Override + public void close() { + delegate.close(); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/ErrorTypeRpcV2CborServerProtocol.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/ErrorTypeRpcV2CborServerProtocol.java new file mode 100644 index 00000000..337cdfb9 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/ErrorTypeRpcV2CborServerProtocol.java @@ -0,0 +1,52 @@ +package aws.cryptography.esdk.testserver.server.protocol; + +import java.util.List; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.rpcv2.AbstractRpcV2ServerProtocol; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * A drop-in rpcv2Cbor server protocol identical to the stock one except that its + * codec emits the {@code __type} discriminator on modeled errors + * ({@link DiscriminatingCborCodec}), so {@code GenericServerError} and + * {@code ESDKClientError} transmit and stay distinguishable through the stock + * generated Java {@code Test_Client} (Requirements 3.9, 4.11, 5.5, 5.6, 6.1–6.4). + * + *

It parses and dispatches the same {@code /service/{Service}/operation/{Op}} + * rpcv2 requests as the stock protocol (path-based resolution, inherited from + * {@link AbstractRpcV2ServerProtocol}) and decodes input with the same CBOR codec, + * so the blob round-trip and every non-error response are byte-identical to stock. + * Only the error response body gains the {@code __type} field. + * + *

It advertises a distinct protocol id (not + * {@code smithy.protocols#rpcv2Cbor}) so it coexists with the stock provider in + * the framework's {@code ShapeId}-keyed provider map without a duplicate-key + * clash; the accompanying provider ranks ahead of the stock one so this protocol + * is selected for the rpcv2 requests the service receives. + */ +public final class ErrorTypeRpcV2CborServerProtocol extends AbstractRpcV2ServerProtocol { + + /** + * A distinct protocol id (must not equal {@code smithy.protocols#rpcv2Cbor}, + * which is the stock provider's key). The wire behavior remains rpcv2Cbor. + */ + static final ShapeId PROTOCOL_ID = + ShapeId.from("aws.cryptography.esdk.testserver#rpcV2CborWithErrorType"); + + private final Codec codec = new DiscriminatingCborCodec(); + + ErrorTypeRpcV2CborServerProtocol(List services) { + super(services, "application/cbor", true); + } + + @Override + public ShapeId getProtocolId() { + return PROTOCOL_ID; + } + + @Override + protected Codec codec() { + return codec; + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/ErrorTypeRpcV2CborServerProtocolProvider.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/ErrorTypeRpcV2CborServerProtocolProvider.java new file mode 100644 index 00000000..9eeec9e2 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/protocol/ErrorTypeRpcV2CborServerProtocolProvider.java @@ -0,0 +1,39 @@ +package aws.cryptography.esdk.testserver.server.protocol; + +import java.util.List; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.core.ServerProtocol; +import software.amazon.smithy.java.server.core.ServerProtocolProvider; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * SPI provider for {@link ErrorTypeRpcV2CborServerProtocol}. Registered via + * {@code META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider}. + * + *

It advertises a distinct {@link #getProtocolId() protocol id} so it does not + * collide with the stock rpcv2Cbor provider in the framework's {@code ShapeId}-keyed + * provider map (which would throw on a duplicate key). The framework sorts protocol + * providers by {@link #precision()} ascending and dispatches each request to the + * first protocol that resolves it; returning a precision lower than the stock + * provider's ({@code 0}) makes this protocol win for the rpcv2 requests the service + * receives, so the {@code __type}-emitting error serialization is what clients see. + */ +public final class ErrorTypeRpcV2CborServerProtocolProvider implements ServerProtocolProvider { + + @Override + public ServerProtocol provideProtocolHandler(List services) { + return new ErrorTypeRpcV2CborServerProtocol(services); + } + + @Override + public ShapeId getProtocolId() { + return ErrorTypeRpcV2CborServerProtocol.PROTOCOL_ID; + } + + @Override + public int precision() { + // Sorted ascending; lower is tried first. Beat the stock provider (0) so + // this __type-emitting protocol handles the service's rpcv2 requests. + return -100; + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/ClientRegistry.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/ClientRegistry.java new file mode 100644 index 00000000..add68ebc --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/ClientRegistry.java @@ -0,0 +1,72 @@ +package aws.cryptography.esdk.testserver.server.registry; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * In-memory, thread-safe registry of configured {@link EsdkClient} instances, + * keyed by {@code ClientId} (Requirements 3.1-3.5, 3.7). + * + *

{@link #register(EsdkClient)} generates a fresh UUID-format id that is not + * equal to any id currently in the registry, stores the client, and returns the + * id (Requirements 3.1, 3.2, 3.4, 3.5). {@link #resolve(String)} looks a client + * up by id, returning {@link Optional#empty()} for a {@code null}, empty, or + * unknown id (Requirement 3.9 is enforced at the handler layer over this miss). + * + *

Entries are never evicted, so an id resolves to the same client for the + * lifetime of the server process (Requirement 3.7). The backing map is a + * {@link ConcurrentHashMap}, and registration uses an atomic + * {@link ConcurrentMap#putIfAbsent} claim so concurrent registrations never + * collide on an id and never lose an entry (Requirement 3.3). + */ +public final class ClientRegistry { + + private final ConcurrentMap clients = new ConcurrentHashMap<>(); + + /** + * Register a client and return a fresh, unique {@code ClientId}. + * + * @param client the configured client to store; must not be {@code null}. + * @return a non-empty, UUID-format id distinct from every id currently in + * the registry (Requirements 3.1, 3.2, 3.4). + */ + public String register(EsdkClient client) { + Objects.requireNonNull(client, "client cannot be null"); + // Generate ids until one atomically claims a free slot. UUID collisions + // are astronomically unlikely; the loop makes uniqueness a guarantee + // rather than a probability, even under concurrent registration. + while (true) { + String id = UUID.randomUUID().toString(); + if (clients.putIfAbsent(id, client) == null) { + return id; + } + } + } + + /** + * Resolve the client registered under an id. + * + * @param clientId the id to look up. + * @return the registered client, or {@link Optional#empty()} if the id is + * {@code null}, empty, or not present in the registry. + */ + public Optional resolve(String clientId) { + if (clientId == null || clientId.isEmpty()) { + return Optional.empty(); + } + return Optional.ofNullable(clients.get(clientId)); + } + + /** @return whether an id is currently present in the registry. */ + public boolean contains(String clientId) { + return clientId != null && !clientId.isEmpty() && clients.containsKey(clientId); + } + + /** @return the number of entries currently in the registry. */ + public int size() { + return clients.size(); + } +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/EsdkClient.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/EsdkClient.java new file mode 100644 index 00000000..f12dbcaf --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/EsdkClient.java @@ -0,0 +1,75 @@ +package aws.cryptography.esdk.testserver.server.registry; + +import aws.cryptography.esdk.testserver.server.error.EsdkClientException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; + +/** + * A single configured, language-specific ESDK client stored in the + * {@link ClientRegistry}. On the Java server this is backed by the REAL AWS + * Encryption SDK for Java (see {@code RealEsdkClient}); tests may substitute a + * controllable fake to exercise the handler and error-mapping logic without real + * crypto. + * + *

Every method that reaches the underlying ESDK declares + * {@link EsdkClientException}: implementations catch exceptions thrown by the + * real ESDK and rethrow them wrapped, so the operation wrapper can forward them + * as an {@code ESDKClientError} with the ESDK message unmodified (Requirements + * 4.10, 5.6, Property 8). + */ +public interface EsdkClient { + + /** + * Encrypt an in-memory plaintext blob (Blob_Variant, Requirement 4.2). + * + * @param plaintext the plaintext bytes. + * @param encryptionContext optional additional authenticated data; may be + * empty but not {@code null}. + * @param algorithmSuiteId optional algorithm-suite override (enum value), or + * {@code null} for the client default. + * @param frameLength optional framing length in bytes, or {@code null}. + * @return the ciphertext bytes. + * @throws EsdkClientException if the ESDK client fails (Requirement 4.10). + */ + byte[] encrypt(byte[] plaintext, Map encryptionContext, + String algorithmSuiteId, Long frameLength) throws EsdkClientException; + + /** + * Decrypt an in-memory ciphertext blob (Blob_Variant, Requirement 4.3). + * + * @param ciphertext the ciphertext bytes. + * @param encryptionContext optional encryption context to require on decrypt; + * may be empty but not {@code null}. + * @return the plaintext bytes. + * @throws EsdkClientException if the ESDK client fails (Requirement 4.10). + */ + byte[] decrypt(byte[] ciphertext, Map encryptionContext) + throws EsdkClientException; + + /** + * Encrypt a plaintext stream into a ciphertext stream (Stream_Variant, + * Requirement 4.5). Only meaningful when {@link #isStreamingCapable()}. + * + * @throws EsdkClientException if the ESDK client fails (Requirement 4.10). + */ + void encryptStream(InputStream plaintext, OutputStream ciphertext, + Map encryptionContext, + String algorithmSuiteId, Long frameLength) throws EsdkClientException; + + /** + * Decrypt a ciphertext stream into a plaintext stream (Stream_Variant, + * Requirement 4.6). Only meaningful when {@link #isStreamingCapable()}. + * + * @throws EsdkClientException if the ESDK client fails (Requirement 4.10). + */ + void decryptStream(InputStream ciphertext, OutputStream plaintext, + Map encryptionContext) throws EsdkClientException; + + /** + * @return whether the backing ESDK implementation supports the Stream_Variant. + * The Java ESDK is Streaming_Capable, so {@code RealEsdkClient} returns + * {@code true} (Requirement 4.5, 4.6). + */ + boolean isStreamingCapable(); +} diff --git a/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/RealEsdkClient.java b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/RealEsdkClient.java new file mode 100644 index 00000000..030ae7b7 --- /dev/null +++ b/test-server/server/src/main/java/aws/cryptography/esdk/testserver/server/registry/RealEsdkClient.java @@ -0,0 +1,140 @@ +package aws.cryptography.esdk.testserver.server.registry; + +import aws.cryptography.esdk.testserver.server.error.EsdkClientException; +import com.amazonaws.encryptionsdk.AwsCrypto; +import com.amazonaws.encryptionsdk.CommitmentPolicy; +import com.amazonaws.encryptionsdk.CryptoAlgorithm; +import com.amazonaws.encryptionsdk.CryptoInputStream; +import com.amazonaws.encryptionsdk.CryptoResult; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collections; +import java.util.Map; +import software.amazon.cryptography.materialproviders.ICryptographicMaterialsManager; + +/** + * A configured {@link EsdkClient} backed by the REAL AWS Encryption SDK for Java. + * It pairs an {@link AwsCrypto} configuration (commitment policy, optional max + * encrypted data keys) with a real cryptographic materials manager built from the + * modeled config by {@code EsdkClientFactory}. + * + *

Every call reaches the real ESDK. Any exception the ESDK throws is caught and + * rethrown as an {@link EsdkClientException} so the operation wrapper forwards it + * as an {@code ESDKClientError} carrying the ESDK message unmodified (Requirements + * 4.10, 5.6). The Java ESDK is Streaming_Capable, so the stream methods delegate + * to the streaming API (Requirements 4.5, 4.6). + */ +public final class RealEsdkClient implements EsdkClient { + + private final CommitmentPolicy commitmentPolicy; + private final Integer maxEncryptedDataKeys; + private final ICryptographicMaterialsManager cmm; + + public RealEsdkClient(CommitmentPolicy commitmentPolicy, + Integer maxEncryptedDataKeys, + ICryptographicMaterialsManager cmm) { + this.commitmentPolicy = commitmentPolicy; + this.maxEncryptedDataKeys = maxEncryptedDataKeys; + this.cmm = cmm; + } + + @Override + public byte[] encrypt(byte[] plaintext, Map encryptionContext, + String algorithmSuiteId, Long frameLength) throws EsdkClientException { + try { + AwsCrypto crypto = buildCrypto(algorithmSuiteId, frameLength); + CryptoResult result = + crypto.encryptData(cmm, plaintext, nonNull(encryptionContext)); + return result.getResult(); + } catch (Exception esdkFailure) { + throw new EsdkClientException(esdkFailure); + } + } + + @Override + public byte[] decrypt(byte[] ciphertext, Map encryptionContext) + throws EsdkClientException { + try { + AwsCrypto crypto = buildCrypto(null, null); + Map ec = nonNull(encryptionContext); + CryptoResult result = ec.isEmpty() + ? crypto.decryptData(cmm, ciphertext) + : crypto.decryptData(cmm, ciphertext, ec); + return result.getResult(); + } catch (Exception esdkFailure) { + throw new EsdkClientException(esdkFailure); + } + } + + @Override + public void encryptStream(InputStream plaintext, OutputStream ciphertext, + Map encryptionContext, + String algorithmSuiteId, Long frameLength) throws EsdkClientException { + try { + AwsCrypto crypto = buildCrypto(algorithmSuiteId, frameLength); + // Use the READ-side encrypting stream (source InputStream -> CryptoInputStream + // producing ciphertext) rather than the write-side CryptoOutputStream. The + // write-side form in ESDK Java 3.0.2 emits a malformed message for zero-byte + // input (it never finalizes a valid header), which breaks the empty-plaintext + // stream round trip (Requirement 4.9); the read-side form finalizes correctly + // for all inputs including empty. Both drive the real ESDK streaming encrypt + // API (Requirement 4.5). + try (CryptoInputStream encrypting = + crypto.createEncryptingStream(cmm, plaintext, nonNull(encryptionContext))) { + encrypting.transferTo(ciphertext); + } + } catch (Exception esdkFailure) { + throw new EsdkClientException(esdkFailure); + } + } + + @Override + public void decryptStream(InputStream ciphertext, OutputStream plaintext, + Map encryptionContext) throws EsdkClientException { + try { + AwsCrypto crypto = buildCrypto(null, null); + Map ec = nonNull(encryptionContext); + // Supply the reproduced encryption context on decrypt when present, so a + // Required-Encryption-Context CMM (which drops the required keys from the + // message header) can reconstruct them — mirroring the blob decrypt path. + // Both drive the real ESDK streaming decrypt API (Requirement 4.6). + try (CryptoInputStream decrypting = ec.isEmpty() + ? crypto.createDecryptingStream(cmm, ciphertext) + : crypto.createDecryptingStream(cmm, ciphertext, ec)) { + decrypting.transferTo(plaintext); + } + } catch (Exception esdkFailure) { + throw new EsdkClientException(esdkFailure); + } + } + + @Override + public boolean isStreamingCapable() { + return true; + } + + /** + * Build an {@link AwsCrypto} for a single call, applying the client's fixed + * commitment policy and optional max-EDK cap plus any per-request algorithm + * suite / frame length overrides. + */ + private AwsCrypto buildCrypto(String algorithmSuiteId, Long frameLength) { + AwsCrypto.Builder builder = AwsCrypto.builder().withCommitmentPolicy(commitmentPolicy); + if (maxEncryptedDataKeys != null) { + builder.withMaxEncryptedDataKeys(maxEncryptedDataKeys); + } + if (algorithmSuiteId != null) { + // The modeled ESDKAlgorithmSuiteId enum values match the ESDK + // CryptoAlgorithm constant names one-for-one. + builder.withEncryptionAlgorithm(CryptoAlgorithm.valueOf(algorithmSuiteId)); + } + if (frameLength != null) { + builder.withEncryptionFrameSize(Math.toIntExact(frameLength)); + } + return builder.build(); + } + + private static Map nonNull(Map encryptionContext) { + return encryptionContext == null ? Collections.emptyMap() : encryptionContext; + } +} diff --git a/test-server/server/src/main/resources/META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider b/test-server/server/src/main/resources/META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider new file mode 100644 index 00000000..665066c6 --- /dev/null +++ b/test-server/server/src/main/resources/META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider @@ -0,0 +1,5 @@ +# ESDK TestServer: rpcv2Cbor server protocol that emits the __type discriminator on +# modeled errors so GenericServerError and ESDKClientError transmit distinctly to the +# stock generated Java Test_Client (works around the smithy-java rpcv2-CBOR +# modeled-error transmission caveat). Ranked ahead of the stock rpcv2Cbor provider. +aws.cryptography.esdk.testserver.server.protocol.ErrorTypeRpcV2CborServerProtocolProvider diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigMarshallerPropertyTest.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigMarshallerPropertyTest.java new file mode 100644 index 00000000..332488f9 --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigMarshallerPropertyTest.java @@ -0,0 +1,127 @@ +package aws.cryptography.esdk.testserver.server.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import aws.cryptography.esdk.testserver.server.model.AwsKmsKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.CachingCmmConfig; +import aws.cryptography.esdk.testserver.server.model.CryptographicMaterialsManager; +import aws.cryptography.esdk.testserver.server.model.DefaultCmmConfig; +import aws.cryptography.esdk.testserver.server.model.ESDKClientConfig; +import aws.cryptography.esdk.testserver.server.model.ESDKCommitmentPolicy; +import aws.cryptography.esdk.testserver.server.model.Keyring; +import aws.cryptography.esdk.testserver.server.model.MultiKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.RequiredEncryptionContextCmmConfig; +import java.util.List; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Combinators; +import net.jqwik.api.ForAll; +import net.jqwik.api.GenerationMode; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; + +/** + * Property-based test for {@link ConfigMarshaller}'s recursive round-trip through + * the rpcv2Cbor wire form, using jqwik. Generates arbitrarily nested configs — + * Multi keyrings containing child keyrings, and Caching / RequiredEncryptionContext + * CMMs wrapping other CMMs — exercising both recursion points of the model + * (Requirement 2.5). Runs a minimum of 100 generated iterations. + */ +class ConfigMarshallerPropertyTest { + + private static final int MAX_DEPTH = 4; + + private final ConfigMarshaller marshaller = new ConfigMarshaller(); + + // Feature: esdk-test-server, Property 7: Recursive config marshalling round-trips at any depth + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void recursiveConfigRoundTripsAtAnyDepth(@ForAll("nestedConfigs") ESDKClientConfig config) { + ESDKClientConfig roundTripped = marshaller.roundTrip(config); + assertEquals(config, roundTripped, + "marshalling a config to the wire form and back must preserve its structure"); + } + + // ----------------------------------------------------------------------- + // Generators + // ----------------------------------------------------------------------- + + @Provide + Arbitrary nestedConfigs() { + Arbitrary cmms = + Arbitraries.integers().between(0, MAX_DEPTH).flatMap(this::cmmAtDepth); + Arbitrary policies = + Arbitraries.of(ESDKCommitmentPolicy.values()); + Arbitrary> maxEdks = + Arbitraries.longs().between(1L, 1000L).optional(); + + return Combinators.combine(cmms, policies, maxEdks).as((cmm, policy, maxEdk) -> { + ESDKClientConfig.Builder builder = + ESDKClientConfig.builder().commitmentPolicy(policy).cmm(cmm); + maxEdk.ifPresent(builder::maxEncryptedDataKeys); + return builder.build(); + }); + } + + /** A CMM nested up to {@code depth} levels deep. */ + private Arbitrary cmmAtDepth(int depth) { + Arbitrary leaf = keyringAtDepth(1).map(keyring -> + CryptographicMaterialsManager.builder() + .defaultMember(DefaultCmmConfig.builder().keyring(keyring).build()) + .build()); + if (depth <= 0) { + return leaf; + } + Arbitrary caching = + Combinators.combine(cmmAtDepth(depth - 1), Arbitraries.integers().between(1, 86_400)) + .as((inner, ttl) -> CryptographicMaterialsManager.builder() + .caching(CachingCmmConfig.builder() + .underlyingCMM(inner) + .cacheLimitTtlSeconds(ttl) + .build()) + .build()); + Arbitrary requiredEc = + Combinators.combine(cmmAtDepth(depth - 1), + Arbitraries.strings().alpha().ofMaxLength(8).list().ofMaxSize(3)) + .as((inner, keys) -> CryptographicMaterialsManager.builder() + .requiredEncryptionContext(RequiredEncryptionContextCmmConfig.builder() + .underlyingCMM(inner) + .requiredEncryptionContextKeys(keys) + .build()) + .build()); + return Arbitraries.oneOf(leaf, caching, requiredEc); + } + + /** A keyring nested up to {@code depth} levels deep. */ + private Arbitrary keyringAtDepth(int depth) { + Arbitrary leaf = leafKeyrings(); + if (depth <= 0) { + return leaf; + } + Arbitrary multi = Combinators.combine( + keyringAtDepth(depth - 1).list().ofMinSize(1).ofMaxSize(3), + keyringAtDepth(depth - 1).optional()) + .as((children, generator) -> { + MultiKeyringConfig.Builder builder = + MultiKeyringConfig.builder().childKeyrings(children); + generator.ifPresent(builder::generator); + return Keyring.builder().multi(builder.build()).build(); + }); + return Arbitraries.oneOf(leaf, multi); + } + + /** + * Leaf keyrings backed by an AWS KMS keyring config. String-valued members + * (no blobs) keep round-trip equality free of ByteBuffer position concerns + * while still exercising the recursion of the enclosing structures. + */ + private Arbitrary leafKeyrings() { + Arbitrary keyIds = Arbitraries.strings().alpha().numeric().ofMinLength(1).ofMaxLength(24); + Arbitrary>> grantTokens = + Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(8).list().ofMinSize(1).ofMaxSize(3).optional(); + return Combinators.combine(keyIds, grantTokens).as((keyId, tokens) -> { + AwsKmsKeyringConfig.Builder builder = AwsKmsKeyringConfig.builder().kmsKeyId(keyId); + tokens.ifPresent(builder::grantTokens); + return Keyring.builder().awsKms(builder.build()).build(); + }); + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigTestFactory.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigTestFactory.java new file mode 100644 index 00000000..6482515a --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigTestFactory.java @@ -0,0 +1,133 @@ +package aws.cryptography.esdk.testserver.server.config; + +import aws.cryptography.esdk.testserver.server.model.AesWrappingAlg; +import aws.cryptography.esdk.testserver.server.model.AwsKmsHierarchicalKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsMrkKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsMrkMultiKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.AwsKmsRsaKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.CachingCmmConfig; +import aws.cryptography.esdk.testserver.server.model.CryptographicMaterialsManager; +import aws.cryptography.esdk.testserver.server.model.DefaultCmmConfig; +import aws.cryptography.esdk.testserver.server.model.Keyring; +import aws.cryptography.esdk.testserver.server.model.MultiKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.PaddingScheme; +import aws.cryptography.esdk.testserver.server.model.RawAesKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.RawRsaKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.RequiredEncryptionContextCmmConfig; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; + +/** + * Shared builders for constructing polymorphic config-shape instances in the + * config property tests. Kept out of the generators so the exactly-one-variant + * test (P6) and the recursive round-trip test (P7) share one faithful way to + * build each variant. + */ +final class ConfigTestFactory { + + private ConfigTestFactory() { + } + + /** + * The keyring variants used by the exactly-one-variant test. A representative + * subset of the eight modeled variants — enough to build sets of size zero + * and of size two-or-more — each easy to construct minimally. + */ + static final List KEYRING_VARIANTS = + List.of("awsKms", "awsKmsMrk", "awsKmsMrkMulti", "awsKmsRsa", "awsKmsHierarchical", + "rawAes", "rawRsa", "multi"); + + /** The three CMM variants. */ + static final List CMM_VARIANTS = + List.of("Default", "RequiredEncryptionContext", "Caching"); + + /** Build a {@link Keyring} with exactly the named variant members set. */ + static Keyring keyringWithVariants(Collection variants) { + Keyring.Builder builder = Keyring.builder(); + for (String variant : variants) { + switch (variant) { + case "awsKms" -> builder.awsKms( + AwsKmsKeyringConfig.builder().kmsKeyId("kms-key").build()); + case "awsKmsMrk" -> builder.awsKmsMrk( + AwsKmsMrkKeyringConfig.builder().kmsKeyId("mrk-key").build()); + case "awsKmsMrkMulti" -> builder.awsKmsMrkMultiKeyring( + AwsKmsMrkMultiKeyringConfig.builder().generator("mrk-key").build()); + case "awsKmsRsa" -> builder.awsKmsRsa( + AwsKmsRsaKeyringConfig.builder().kmsKeyId("rsa-key").build()); + case "awsKmsHierarchical" -> builder.awsKmsHierarchical( + AwsKmsHierarchicalKeyringConfig.builder() + .branchKeyId("branch-key") + .keyStoreTableName("KeyStoreDdbTable") + .logicalKeyStoreName("KeyStoreDdbTable") + .kmsKeyArn("arn:aws:kms:us-west-2:111122223333:key/example") + .ttlSeconds(600) + .build()); + case "rawAes" -> builder.rawAes(rawAes()); + case "rawRsa" -> builder.rawRsa(rawRsa()); + case "multi" -> builder.multi( + MultiKeyringConfig.builder().childKeyrings(List.of()).build()); + default -> throw new IllegalArgumentException("Unknown keyring variant: " + variant); + } + } + return builder.build(); + } + + /** + * Build a {@link CryptographicMaterialsManager} with exactly the named + * variant members set. Wrapped CMMs/keyrings are minimal valid single-variant + * instances so that only the top-level cardinality varies. + */ + static CryptographicMaterialsManager cmmWithVariants(Collection variants) { + CryptographicMaterialsManager.Builder builder = CryptographicMaterialsManager.builder(); + for (String variant : variants) { + switch (variant) { + case "Default" -> builder.defaultMember( + DefaultCmmConfig.builder().keyring(singleVariantKeyring()).build()); + case "RequiredEncryptionContext" -> builder.requiredEncryptionContext( + RequiredEncryptionContextCmmConfig.builder() + .underlyingCMM(defaultCmm()) + .requiredEncryptionContextKeys(List.of()) + .build()); + case "Caching" -> builder.caching( + CachingCmmConfig.builder() + .underlyingCMM(defaultCmm()) + .cacheLimitTtlSeconds(60) + .build()); + default -> throw new IllegalArgumentException("Unknown CMM variant: " + variant); + } + } + return builder.build(); + } + + /** A valid keyring with exactly one variant set (raw AES). */ + static Keyring singleVariantKeyring() { + return Keyring.builder().rawAes(rawAes()).build(); + } + + /** A valid CMM with exactly one variant set (Default over a raw-AES keyring). */ + static CryptographicMaterialsManager defaultCmm() { + return CryptographicMaterialsManager.builder() + .defaultMember(DefaultCmmConfig.builder().keyring(singleVariantKeyring()).build()) + .build(); + } + + private static RawAesKeyringConfig rawAes() { + return RawAesKeyringConfig.builder() + .keyNamespace("namespace") + .keyName("name") + .wrappingKey(ByteBuffer.wrap("0123456789abcdef".getBytes(StandardCharsets.UTF_8))) + .wrappingAlg(AesWrappingAlg.ALG_AES256_GCM_IV12_TAG16) + .build(); + } + + private static RawRsaKeyringConfig rawRsa() { + return RawRsaKeyringConfig.builder() + .keyNamespace("namespace") + .keyName("name") + .paddingScheme(PaddingScheme.OAEP_SHA256_MGF1) + .build(); + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigValidatorPropertyTest.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigValidatorPropertyTest.java new file mode 100644 index 00000000..0c73dbd9 --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/config/ConfigValidatorPropertyTest.java @@ -0,0 +1,89 @@ +package aws.cryptography.esdk.testserver.server.config; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import aws.cryptography.esdk.testserver.server.model.CryptographicMaterialsManager; +import aws.cryptography.esdk.testserver.server.model.ESDKClientError; +import aws.cryptography.esdk.testserver.server.model.Keyring; +import java.util.Set; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.GenerationMode; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; + +/** + * Property-based tests for {@link ConfigValidator}'s exactly-one-variant rule on + * the tagged-union config shapes, using jqwik. Each property runs a minimum of + * 100 generated iterations. + */ +class ConfigValidatorPropertyTest { + + private final ConfigValidator validator = new ConfigValidator(); + + // Feature: esdk-test-server, Property 6: Polymorphic config requires exactly one variant member + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void keyringWithoutExactlyOneVariantIsRejected( + @ForAll("nonSingletonKeyringVariants") Set variants) { + assertTrue(variants.size() != 1, "generator must produce zero or 2+ variants"); + Keyring keyring = ConfigTestFactory.keyringWithVariants(variants); + + ESDKClientError error = assertThrows(ESDKClientError.class, + () -> validator.validateKeyring(keyring), + "a Keyring with " + variants.size() + " variant members set must be rejected"); + assertTrue(error.getMessage() != null && !error.getMessage().isEmpty(), + "ESDKClientError must carry a non-empty message"); + } + + // Feature: esdk-test-server, Property 6: Polymorphic config requires exactly one variant member + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void cmmWithoutExactlyOneVariantIsRejected( + @ForAll("nonSingletonCmmVariants") Set variants) { + assertTrue(variants.size() != 1, "generator must produce zero or 2+ variants"); + CryptographicMaterialsManager cmm = ConfigTestFactory.cmmWithVariants(variants); + + ESDKClientError error = assertThrows(ESDKClientError.class, + () -> validator.validateCmm(cmm), + "a CMM with " + variants.size() + " variant members set must be rejected"); + assertTrue(error.getMessage() != null && !error.getMessage().isEmpty(), + "ESDKClientError must carry a non-empty message"); + } + + // Feature: esdk-test-server, Property 6: Polymorphic config requires exactly one variant member + // Positive control: the boundary case of exactly one variant is accepted, confirming the + // validator rejects only when the count is not one. + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void exactlyOneVariantIsAccepted(@ForAll("keyringVariant") String keyringVariant, + @ForAll("cmmVariant") String cmmVariant) { + Keyring keyring = ConfigTestFactory.keyringWithVariants(Set.of(keyringVariant)); + assertDoesNotThrow(() -> validator.validateKeyring(keyring)); + + CryptographicMaterialsManager cmm = ConfigTestFactory.cmmWithVariants(Set.of(cmmVariant)); + assertDoesNotThrow(() -> validator.validateCmm(cmm)); + } + + @Provide + Arbitrary> nonSingletonKeyringVariants() { + return Arbitraries.subsetOf(ConfigTestFactory.KEYRING_VARIANTS) + .filter(subset -> subset.size() != 1); + } + + @Provide + Arbitrary> nonSingletonCmmVariants() { + return Arbitraries.subsetOf(ConfigTestFactory.CMM_VARIANTS) + .filter(subset -> subset.size() != 1); + } + + @Provide + Arbitrary keyringVariant() { + return Arbitraries.of(ConfigTestFactory.KEYRING_VARIANTS); + } + + @Provide + Arbitrary cmmVariant() { + return Arbitraries.of(ConfigTestFactory.CMM_VARIANTS); + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/error/OperationWrapperPropertyTest.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/error/OperationWrapperPropertyTest.java new file mode 100644 index 00000000..6ae6e5c5 --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/error/OperationWrapperPropertyTest.java @@ -0,0 +1,127 @@ +package aws.cryptography.esdk.testserver.server.error; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.fail; + +import aws.cryptography.esdk.testserver.server.model.ESDKClientError; +import aws.cryptography.esdk.testserver.server.model.GenericServerError; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.GenerationMode; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; + +/** + * Property-based test for the catch-all operation wrapper, using jqwik. Runs a + * minimum of 100 generated iterations. + */ +class OperationWrapperPropertyTest { + + private final OperationWrapper wrapper = new OperationWrapper(); + + /** The kind of outcome a handler body produces. */ + private enum Outcome { SUCCESS, THROW_GENERIC, THROW_ESDK_MODELED, THROW_ESDK_WRAPPED, THROW_NON_MODELED } + + // Feature: esdk-test-server, Property 9: Every operation returns a modeled outcome with a non-empty message + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void everyOperationYieldsAModeledOutcome(@ForAll("outcomes") Outcome outcome, + @ForAll("messages") String message) { + OperationWrapper.HandlerBody body = bodyFor(outcome, message); + + switch (outcome) { + case SUCCESS -> { + // A successful handler returns its modeled response unchanged. + String result = wrapper.invoke("Op", body); + assertEquals(message, result, "success response must pass through unchanged"); + } + case THROW_GENERIC -> { + GenericServerError error = assertThrowsExactly(GenericServerError.class, + () -> wrapper.invoke("Op", body)); + // (6.1) modeled error preserved unchanged. + assertEquals(message, error.getMessage(), "modeled message must be preserved"); + assertNonEmptyMessage(error.getMessage()); + } + case THROW_ESDK_MODELED -> { + ESDKClientError error = assertThrowsExactly(ESDKClientError.class, + () -> wrapper.invoke("Op", body)); + assertEquals(message, error.getMessage(), "modeled message must be preserved"); + assertNonEmptyMessage(error.getMessage()); + } + case THROW_ESDK_WRAPPED -> { + // (5.6) ESDK-origin failures become ESDKClientError, message unmodified. + ESDKClientError error = assertThrowsExactly(ESDKClientError.class, + () -> wrapper.invoke("Op", body)); + assertEquals(message, error.getMessage(), + "ESDK exception message must be forwarded unmodified"); + assertNonEmptyMessage(error.getMessage()); + } + case THROW_NON_MODELED -> { + // (6.2) non-modeled exceptions become a GenericServerError whose + // non-empty message includes the originating description. + GenericServerError error = assertThrowsExactly(GenericServerError.class, + () -> wrapper.invoke("Op", body)); + assertNonEmptyMessage(error.getMessage()); + assertFalse(error.getMessage().isEmpty(), "message must include a description"); + } + default -> fail("unhandled outcome"); + } + } + + private static OperationWrapper.HandlerBody bodyFor(Outcome outcome, String message) { + return switch (outcome) { + case SUCCESS -> () -> message; + case THROW_GENERIC -> () -> { + throw GenericServerError.builder().message(message).build(); + }; + case THROW_ESDK_MODELED -> () -> { + throw ESDKClientError.builder().message(message).build(); + }; + case THROW_ESDK_WRAPPED -> () -> { + throw new EsdkClientException(new RuntimeException(message)); + }; + case THROW_NON_MODELED -> () -> { + throw new IllegalStateException(message); + }; + }; + } + + private static void assertNonEmptyMessage(String message) { + if (message == null || message.isEmpty()) { + fail("every returned modeled error must carry a non-empty message"); + } + } + + @FunctionalInterface + private interface ThrowingCall { + void run(); + } + + @SuppressWarnings("unchecked") + private static T assertThrowsExactly(Class type, ThrowingCall call) { + try { + call.run(); + } catch (Throwable thrown) { + if (type.isInstance(thrown)) { + return (T) thrown; + } + throw new AssertionError("expected " + type.getSimpleName() + " but got " + + thrown.getClass().getName(), thrown); + } + throw new AssertionError("expected " + type.getSimpleName() + " but nothing was thrown"); + } + + @Provide + Arbitrary outcomes() { + return Arbitraries.of(Outcome.class); + } + + @Provide + Arbitrary messages() { + // Non-empty messages: modeled-error message members are required and + // non-empty, and ESDK exceptions carry a description. + return Arbitraries.strings().ofMinLength(1).ofMaxLength(120); + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ClientIdGuardPropertyTest.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ClientIdGuardPropertyTest.java new file mode 100644 index 00000000..dac516a5 --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ClientIdGuardPropertyTest.java @@ -0,0 +1,99 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.fail; + +import aws.cryptography.esdk.testserver.server.error.OperationWrapper; +import aws.cryptography.esdk.testserver.server.model.DecryptInput; +import aws.cryptography.esdk.testserver.server.model.DecryptStreamInput; +import aws.cryptography.esdk.testserver.server.model.EncryptInput; +import aws.cryptography.esdk.testserver.server.model.EncryptStreamInput; +import aws.cryptography.esdk.testserver.server.model.GenericServerError; +import aws.cryptography.esdk.testserver.server.registry.ClientRegistry; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Assume; +import net.jqwik.api.ForAll; +import net.jqwik.api.GenerationMode; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; + +/** + * Property-based test for the ClientId guard on non-CreateClient operations, + * using jqwik. Runs a minimum of 100 generated iterations. + */ +class ClientIdGuardPropertyTest { + + private enum Op { ENCRYPT, DECRYPT, ENCRYPT_STREAM, DECRYPT_STREAM } + + // Feature: esdk-test-server, Property 5: Missing or unknown ClientId is rejected without side effects + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void missingOrUnknownClientIdIsRejectedWithoutSideEffects( + @ForAll("ops") Op op, + @ForAll("unknownIds") String unknownId) { + ClientRegistry registry = new ClientRegistry(); + OperationWrapper wrapper = new OperationWrapper(); + ClientIdGuard guard = new ClientIdGuard(registry); + + // A canary client that must never be touched by an unknown-id request. + ControllableEsdkClient canary = ControllableEsdkClient.succeeding(); + String canaryId = registry.register(canary); + // The generated id must be absent/empty/unknown, i.e. not the canary's id. + Assume.that(!canaryId.equals(unknownId)); + int sizeBefore = registry.size(); + + Throwable thrown = runExpectingThrow(op, guard, wrapper, unknownId); + + // (3.9, P5) absent/empty/unknown ClientId -> GenericServerError. + assertInstanceOf(GenericServerError.class, thrown, + "a missing or unknown ClientId must yield a GenericServerError"); + // No ESDK operation is performed ... + assertEquals(0, canary.cryptoCalls(), + "no ESDK operation must run for a missing or unknown ClientId"); + // ... and the registry is left unchanged. + assertEquals(sizeBefore, registry.size(), + "a rejected request must leave the registry unchanged"); + } + + private static Throwable runExpectingThrow(Op op, ClientIdGuard guard, + OperationWrapper wrapper, String id) { + byte[] payload = "payload".getBytes(StandardCharsets.UTF_8); + try { + switch (op) { + case ENCRYPT -> new EncryptHandler(guard, wrapper).encrypt( + EncryptInput.builder().clientId(id).plaintext(ByteBuffer.wrap(payload)).build(), + null); + case DECRYPT -> new DecryptHandler(guard, wrapper).decrypt( + DecryptInput.builder().clientId(id).ciphertext(ByteBuffer.wrap(payload)).build(), + null); + case ENCRYPT_STREAM -> new EncryptStreamHandler(guard, wrapper).encryptStream( + EncryptStreamInput.builder().clientId(id).plaintext(ByteBuffer.wrap(payload)).build(), + null); + case DECRYPT_STREAM -> new DecryptStreamHandler(guard, wrapper).decryptStream( + DecryptStreamInput.builder().clientId(id).ciphertext(ByteBuffer.wrap(payload)).build(), + null); + default -> fail("unhandled op"); + } + } catch (Throwable t) { + return t; + } + throw new AssertionError("expected the operation to throw a GenericServerError"); + } + + @Provide + Arbitrary ops() { + return Arbitraries.of(Op.class); + } + + @Provide + Arbitrary unknownIds() { + // Absent manifests as the empty string (the generated required member is + // error-corrected to ""); also exercise arbitrary and UUID-like unknowns. + Arbitrary arbitrary = Arbitraries.strings().ofMaxLength(40); + Arbitrary empty = Arbitraries.just(""); + return Arbitraries.oneOf(empty, arbitrary); + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ControllableEsdkClient.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ControllableEsdkClient.java new file mode 100644 index 00000000..5ea5f32d --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ControllableEsdkClient.java @@ -0,0 +1,88 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import aws.cryptography.esdk.testserver.server.error.EsdkClientException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A controllable {@link aws.cryptography.esdk.testserver.server.registry.EsdkClient} + * fake used by the handler and error-mapping property tests. It can be told to + * succeed (echoing bytes) or to fail on demand, either as an ESDK-origin failure + * (a wrapped {@link EsdkClientException}) or a framework-origin failure (a raw + * unchecked exception). It counts crypto invocations so tests can assert that a + * rejected request performed no ESDK operation (Property 5). + */ +final class ControllableEsdkClient implements aws.cryptography.esdk.testserver.server.registry.EsdkClient { + + enum Mode { SUCCEED, FAIL_ESDK, FAIL_FRAMEWORK } + + private final Mode mode; + private final String failureMessage; + private final AtomicInteger cryptoCalls = new AtomicInteger(); + + private ControllableEsdkClient(Mode mode, String failureMessage) { + this.mode = mode; + this.failureMessage = failureMessage; + } + + static ControllableEsdkClient succeeding() { + return new ControllableEsdkClient(Mode.SUCCEED, null); + } + + static ControllableEsdkClient failingInsideEsdk(String message) { + return new ControllableEsdkClient(Mode.FAIL_ESDK, message); + } + + static ControllableEsdkClient failingInFramework(String message) { + return new ControllableEsdkClient(Mode.FAIL_FRAMEWORK, message); + } + + int cryptoCalls() { + return cryptoCalls.get(); + } + + private byte[] act(byte[] payload) throws EsdkClientException { + cryptoCalls.incrementAndGet(); + switch (mode) { + case SUCCEED: + return payload; + case FAIL_ESDK: + throw new EsdkClientException(new RuntimeException(failureMessage)); + case FAIL_FRAMEWORK: + default: + throw new IllegalStateException(failureMessage); + } + } + + @Override + public byte[] encrypt(byte[] plaintext, Map encryptionContext, + String algorithmSuiteId, Long frameLength) throws EsdkClientException { + return act(plaintext); + } + + @Override + public byte[] decrypt(byte[] ciphertext, Map encryptionContext) + throws EsdkClientException { + return act(ciphertext); + } + + @Override + public void encryptStream(InputStream plaintext, OutputStream ciphertext, + Map encryptionContext, + String algorithmSuiteId, Long frameLength) throws EsdkClientException { + act(new byte[0]); + } + + @Override + public void decryptStream(InputStream ciphertext, OutputStream plaintext, + Map encryptionContext) throws EsdkClientException { + act(new byte[0]); + } + + @Override + public boolean isStreamingCapable() { + return true; + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/CreateClientFailurePropertyTest.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/CreateClientFailurePropertyTest.java new file mode 100644 index 00000000..23414045 --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/CreateClientFailurePropertyTest.java @@ -0,0 +1,148 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import aws.cryptography.esdk.testserver.server.config.ConfigValidator; +import aws.cryptography.esdk.testserver.server.config.EsdkClientFactory; +import aws.cryptography.esdk.testserver.server.error.OperationWrapper; +import aws.cryptography.esdk.testserver.server.model.AesWrappingAlg; +import aws.cryptography.esdk.testserver.server.model.AwsKmsRsaKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.CachingCmmConfig; +import aws.cryptography.esdk.testserver.server.model.CreateClientInput; +import aws.cryptography.esdk.testserver.server.model.CryptographicMaterialsManager; +import aws.cryptography.esdk.testserver.server.model.DefaultCmmConfig; +import aws.cryptography.esdk.testserver.server.model.ESDKClientConfig; +import aws.cryptography.esdk.testserver.server.model.ESDKCommitmentPolicy; +import aws.cryptography.esdk.testserver.server.model.GenericServerError; +import aws.cryptography.esdk.testserver.server.model.Keyring; +import aws.cryptography.esdk.testserver.server.model.MultiKeyringConfig; +import aws.cryptography.esdk.testserver.server.model.RawAesKeyringConfig; +import aws.cryptography.esdk.testserver.server.registry.ClientRegistry; +import java.nio.ByteBuffer; +import java.util.List; +import net.jqwik.api.ForAll; +import net.jqwik.api.GenerationMode; +import net.jqwik.api.Property; +import net.jqwik.api.constraints.IntRange; + +/** + * Property-based test that a failed {@code CreateClient} leaves the registry + * unchanged, using jqwik. Runs a minimum of 100 generated iterations. Uses the + * real {@link ConfigValidator} and {@link EsdkClientFactory}; the generated + * configs are valid per the exactly-one-variant rule but cause the real ESDK + * client construction to fail deterministically and offline (an unwired + * Caching CMM, an AWS KMS RSA keyring missing its encryption algorithm, or a + * Raw AES keyring whose wrapping key length does not match its wrapping + * algorithm), nested to varying depth. + */ +class CreateClientFailurePropertyTest { + + // Feature: esdk-test-server, Property 4: Failed CreateClient leaves the registry unchanged + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void failedCreateClientLeavesRegistryUnchanged( + @ForAll boolean useCachingCmm, + @ForAll boolean badRsaLeaf, + @ForAll @IntRange(min = 0, max = 3) int wrapDepth, + @ForAll @IntRange(min = 0, max = 5) int preexisting) { + ClientRegistry registry = new ClientRegistry(); + for (int i = 0; i < preexisting; i++) { + registry.register(ControllableEsdkClient.succeeding()); + } + int sizeBefore = registry.size(); + + CreateClientHandler handler = new CreateClientHandler( + registry, new ConfigValidator(), new EsdkClientFactory(), new OperationWrapper()); + + ESDKClientConfig config = constructionFailingConfig(useCachingCmm, badRsaLeaf, wrapDepth); + + Throwable thrown = null; + try { + handler.createClient(CreateClientInput.builder().config(config).build(), null); + } catch (Throwable t) { + thrown = t; + } + + // (3.6, P4) construction failure -> GenericServerError, no ClientId (an + // exception was thrown, not an output), and the registry is unchanged. + assertInstanceOf(GenericServerError.class, thrown, + "a client-construction failure must yield a GenericServerError"); + assertEquals(sizeBefore, registry.size(), + "a failed CreateClient must leave the registry exactly as it was"); + } + + private static ESDKClientConfig constructionFailingConfig( + boolean useCachingCmm, boolean badRsaLeaf, int wrapDepth) { + CryptographicMaterialsManager cmm = useCachingCmm + ? CryptographicMaterialsManager.builder() + .caching(CachingCmmConfig.builder() + .underlyingCMM(validDefaultCmm()) + .cacheLimitTtlSeconds(60) + .build()) + .build() + : CryptographicMaterialsManager.builder() + .defaultMember(DefaultCmmConfig.builder() + .keyring(wrapInMulti(failingLeafKeyring(badRsaLeaf), wrapDepth)) + .build()) + .build(); + return ESDKClientConfig.builder() + .commitmentPolicy(ESDKCommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT) + .cmm(cmm) + .build(); + } + + /** + * A keyring variant that is valid (single variant) but whose construction the + * factory genuinely rejects, offline and deterministically. {@code badRsaLeaf} + * picks an AWS KMS RSA keyring that supplies a public key (so no network fetch + * happens) but omits the required {@code encryptionAlgorithm}, which the + * factory rejects before touching KMS. The alternative is a Raw AES keyring + * whose 16-byte wrapping key contradicts its AES-256 wrapping algorithm, which + * the material providers library rejects at keyring construction. + */ + private static Keyring failingLeafKeyring(boolean badRsaLeaf) { + if (badRsaLeaf) { + return Keyring.builder() + .awsKmsRsa(AwsKmsRsaKeyringConfig.builder() + .kmsKeyId("arn:aws:kms:us-west-2:111122223333:key/no-such-key") + .publicKey(ByteBuffer.wrap(new byte[16])) + // encryptionAlgorithm deliberately omitted -> factory rejects. + .build()) + .build(); + } + return Keyring.builder() + .rawAes(RawAesKeyringConfig.builder() + .keyNamespace("ns") + .keyName("bad-length-key") + // 16-byte wrapping key with an AES-256 alg -> MPL rejects. + .wrappingKey(ByteBuffer.wrap(new byte[16])) + .wrappingAlg(AesWrappingAlg.ALG_AES256_GCM_IV12_TAG16) + .build()) + .build(); + } + + /** Wrap a keyring in {@code depth} nested Multi keyrings (each a single variant). */ + private static Keyring wrapInMulti(Keyring inner, int depth) { + Keyring current = inner; + for (int i = 0; i < depth; i++) { + current = Keyring.builder() + .multi(MultiKeyringConfig.builder().childKeyrings(List.of(current)).build()) + .build(); + } + return current; + } + + private static CryptographicMaterialsManager validDefaultCmm() { + RawAesKeyringConfig rawAes = RawAesKeyringConfig.builder() + .keyNamespace("ns") + .keyName("n") + .wrappingKey(ByteBuffer.wrap(new byte[32])) + .wrappingAlg(AesWrappingAlg.ALG_AES256_GCM_IV12_TAG16) + .build(); + return CryptographicMaterialsManager.builder() + .defaultMember(DefaultCmmConfig.builder() + .keyring(Keyring.builder().rawAes(rawAes).build()) + .build()) + .build(); + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ErrorMappingPropertyTest.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ErrorMappingPropertyTest.java new file mode 100644 index 00000000..2c6166d1 --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/ErrorMappingPropertyTest.java @@ -0,0 +1,100 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.fail; + +import aws.cryptography.esdk.testserver.server.error.OperationWrapper; +import aws.cryptography.esdk.testserver.server.model.DecryptInput; +import aws.cryptography.esdk.testserver.server.model.ESDKClientError; +import aws.cryptography.esdk.testserver.server.model.EncryptInput; +import aws.cryptography.esdk.testserver.server.model.GenericServerError; +import aws.cryptography.esdk.testserver.server.registry.ClientRegistry; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.GenerationMode; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; + +/** + * Property-based test for error mapping by origin at the handler layer, using + * jqwik. Runs a minimum of 100 generated iterations. + */ +class ErrorMappingPropertyTest { + + private enum Op { ENCRYPT, DECRYPT } + + // Feature: esdk-test-server, Property 8: Errors are mapped by origin + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void errorsAreMappedByOrigin(@ForAll("ops") Op op, + @ForAll boolean esdkOrigin, + @ForAll("messages") String message) { + ClientRegistry registry = new ClientRegistry(); + OperationWrapper wrapper = new OperationWrapper(); + ClientIdGuard guard = new ClientIdGuard(registry); + + ControllableEsdkClient client = esdkOrigin + ? ControllableEsdkClient.failingInsideEsdk(message) + : ControllableEsdkClient.failingInFramework(message); + String clientId = registry.register(client); + int sizeBefore = registry.size(); + + Throwable thrown = runExpectingThrow(op, guard, wrapper, clientId); + + if (esdkOrigin) { + // (5.6, P8) ESDK-origin failure -> ESDKClientError, message unmodified, + // never a GenericServerError. + ESDKClientError error = assertInstanceOf(ESDKClientError.class, thrown, + "an ESDK-origin failure must map to ESDKClientError"); + assertEquals(message, error.getMessage(), + "ESDKClientError message must equal the ESDK exception message, unmodified"); + } else { + // (5.5, P8) framework-origin failure -> GenericServerError, never an + // ESDKClientError. + assertInstanceOf(GenericServerError.class, thrown, + "a framework-origin failure must map to GenericServerError"); + } + + // In the failure case no ciphertext/plaintext is returned (an exception + // was thrown, not an output) and the registry is unchanged. + assertEquals(sizeBefore, registry.size(), + "a failed operation must leave the registry unchanged"); + } + + private static Throwable runExpectingThrow(Op op, ClientIdGuard guard, + OperationWrapper wrapper, String clientId) { + try { + switch (op) { + case ENCRYPT -> new EncryptHandler(guard, wrapper).encrypt( + EncryptInput.builder() + .clientId(clientId) + .plaintext(ByteBuffer.wrap("payload".getBytes(StandardCharsets.UTF_8))) + .build(), + null); + case DECRYPT -> new DecryptHandler(guard, wrapper).decrypt( + DecryptInput.builder() + .clientId(clientId) + .ciphertext(ByteBuffer.wrap("payload".getBytes(StandardCharsets.UTF_8))) + .build(), + null); + default -> fail("unhandled op"); + } + } catch (Throwable t) { + return t; + } + throw new AssertionError("expected the operation to throw a modeled error"); + } + + @Provide + Arbitrary ops() { + return Arbitraries.of(Op.class); + } + + @Provide + Arbitrary messages() { + return Arbitraries.strings().ofMinLength(1).ofMaxLength(120); + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/RegistryConcurrencyIntegrationTest.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/RegistryConcurrencyIntegrationTest.java new file mode 100644 index 00000000..e01118a9 --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/handler/RegistryConcurrencyIntegrationTest.java @@ -0,0 +1,144 @@ +package aws.cryptography.esdk.testserver.server.handler; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import aws.cryptography.esdk.testserver.server.model.AesWrappingAlg; +import aws.cryptography.esdk.testserver.server.model.CreateClientInput; +import aws.cryptography.esdk.testserver.server.model.CreateClientOutput; +import aws.cryptography.esdk.testserver.server.model.CryptographicMaterialsManager; +import aws.cryptography.esdk.testserver.server.model.DecryptInput; +import aws.cryptography.esdk.testserver.server.model.DecryptOutput; +import aws.cryptography.esdk.testserver.server.model.DefaultCmmConfig; +import aws.cryptography.esdk.testserver.server.model.ESDKClientConfig; +import aws.cryptography.esdk.testserver.server.model.ESDKCommitmentPolicy; +import aws.cryptography.esdk.testserver.server.model.EncryptInput; +import aws.cryptography.esdk.testserver.server.model.EncryptOutput; +import aws.cryptography.esdk.testserver.server.model.Keyring; +import aws.cryptography.esdk.testserver.server.model.RawAesKeyringConfig; +import aws.cryptography.esdk.testserver.server.registry.ClientRegistry; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +/** + * Integration test for the thread-safe {@link ClientRegistry} under concurrency + * (Requirement 3.3). It drives the fully-wired Java server handlers over one + * shared registry from many threads in parallel — each thread repeatedly calls + * {@code CreateClient} (constructing a real offline Raw-AES ESDK client), + * round-trips a blob through {@code Encrypt}/{@code Decrypt} against the id it was + * given, and resolves that id — stressing the shared registry the same way a + * running server's dispatch would. It asserts every minted {@code ClientId} is + * unique, resolvable, and that no registrations are lost. + */ +class RegistryConcurrencyIntegrationTest { + + private static final byte[] PLAINTEXT = + "concurrent-round-trip-plaintext".getBytes(StandardCharsets.UTF_8); + + @Test + void registryIsThreadSafeUnderConcurrentCreateAndResolve() throws Exception { + EsdkTestServerHandlers handlers = new EsdkTestServerHandlers(); + ClientRegistry registry = handlers.registry(); + + int threadCount = 8; + int createsPerThread = 25; + ExecutorService pool = Executors.newFixedThreadPool(threadCount); + CountDownLatch startGate = new CountDownLatch(1); + List>> futures = new ArrayList<>(); + + try { + for (int t = 0; t < threadCount; t++) { + futures.add(pool.submit(() -> { + startGate.await(); + List ids = new ArrayList<>(); + for (int i = 0; i < createsPerThread; i++) { + CreateClientOutput created = handlers.createClientHandler().createClient( + CreateClientInput.builder().config(rawAesConfig()).build(), null); + String clientId = created.getClientId(); + ids.add(clientId); + + // Round-trip a blob against the referenced client. + EncryptOutput encrypted = handlers.encryptHandler().encrypt( + EncryptInput.builder() + .clientId(clientId) + .plaintext(ByteBuffer.wrap(PLAINTEXT)) + .build(), + null); + DecryptOutput decrypted = handlers.decryptHandler().decrypt( + DecryptInput.builder() + .clientId(clientId) + .ciphertext(encrypted.getCiphertext()) + .build(), + null); + assertArrayEquals(PLAINTEXT, toArray(decrypted.getPlaintext()), + "each client's blob round-trip must preserve the plaintext"); + + // Concurrent resolves must always find the entry. + assertTrue(registry.resolve(clientId).isPresent(), + "a freshly registered ClientId must resolve"); + } + return ids; + })); + } + + startGate.countDown(); + + Set allIds = new HashSet<>(); + List collected = new ArrayList<>(); + for (Future> future : futures) { + collected.addAll(future.get(120, TimeUnit.SECONDS)); + } + allIds.addAll(collected); + + int expected = threadCount * createsPerThread; + assertEquals(expected, collected.size(), + "every CreateClient call must have returned an id"); + assertEquals(expected, allIds.size(), + "every minted ClientId must be unique across all threads"); + assertEquals(expected, registry.size(), + "the registry must retain exactly one entry per successful CreateClient"); + for (String id : Collections.unmodifiableSet(allIds)) { + assertTrue(registry.resolve(id).isPresent(), + "every minted ClientId must remain resolvable after the stress run"); + } + } finally { + pool.shutdownNow(); + } + } + + private static ESDKClientConfig rawAesConfig() { + RawAesKeyringConfig rawAes = RawAesKeyringConfig.builder() + .keyNamespace("concurrency-namespace") + .keyName("concurrency-key") + .wrappingKey(ByteBuffer.wrap(new byte[32])) + .wrappingAlg(AesWrappingAlg.ALG_AES256_GCM_IV12_TAG16) + .build(); + return ESDKClientConfig.builder() + .commitmentPolicy(ESDKCommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT) + .cmm(CryptographicMaterialsManager.builder() + .defaultMember(DefaultCmmConfig.builder() + .keyring(Keyring.builder().rawAes(rawAes).build()) + .build()) + .build()) + .build(); + } + + private static byte[] toArray(ByteBuffer buffer) { + ByteBuffer duplicate = buffer.duplicate(); + byte[] bytes = new byte[duplicate.remaining()]; + duplicate.get(bytes); + return bytes; + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/registry/ClientRegistryPropertyTest.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/registry/ClientRegistryPropertyTest.java new file mode 100644 index 00000000..fa17203d --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/registry/ClientRegistryPropertyTest.java @@ -0,0 +1,76 @@ +package aws.cryptography.esdk.testserver.server.registry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import net.jqwik.api.ForAll; +import net.jqwik.api.GenerationMode; +import net.jqwik.api.Property; +import net.jqwik.api.constraints.IntRange; + +/** + * Property-based tests for the {@link ClientRegistry}, using jqwik. Each property + * runs a minimum of 100 generated iterations. + */ +class ClientRegistryPropertyTest { + + /** Canonical 8-4-4-4-12 hex UUID form (Requirement 3.2). */ + private static final Pattern UUID_FORMAT = Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + // Feature: esdk-test-server, Property 2: ClientId values are unique and well-formed + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void clientIdsAreUniqueAndWellFormed(@ForAll @IntRange(min = 1, max = 50) int count) { + ClientRegistry registry = new ClientRegistry(); + Set seen = new HashSet<>(); + + for (int i = 0; i < count; i++) { + String id = registry.register(new StubEsdkClient()); + + assertNotNull(id, "ClientId must not be null"); + assertFalse(id.isEmpty(), "ClientId must be non-empty"); + assertTrue(UUID_FORMAT.matcher(id).matches(), + "ClientId must be UUID-format, but was: " + id); + assertTrue(seen.add(id), + "ClientId must be pairwise distinct from every other id, but repeated: " + id); + } + + assertEquals(count, seen.size(), "every register call must yield a distinct id"); + assertEquals(count, registry.size(), "registry must hold exactly the registered clients"); + } + + // Feature: esdk-test-server, Property 3: CreateClient registers exactly one resolvable client + @Property(tries = 200, generation = GenerationMode.RANDOMIZED) + void registerAddsExactlyOneResolvableClient( + @ForAll @IntRange(min = 0, max = 20) int preexisting, + @ForAll @IntRange(min = 1, max = 5) int resolveTimes) { + ClientRegistry registry = new ClientRegistry(); + for (int i = 0; i < preexisting; i++) { + registry.register(new StubEsdkClient()); + } + + int sizeBefore = registry.size(); + StubEsdkClient client = new StubEsdkClient(); + + String id = registry.register(client); + + assertEquals(sizeBefore + 1, registry.size(), + "a successful register must increase the registry size by exactly one"); + assertTrue(registry.contains(id), "the returned id must be present in the registry"); + + // Resolving the returned id any number of times yields the same client. + for (int i = 0; i < resolveTimes; i++) { + Optional resolved = registry.resolve(id); + assertTrue(resolved.isPresent(), "the returned id must resolve to a client"); + assertSame(client, resolved.get(), + "the id must resolve to the same client instance on every request"); + } + } +} diff --git a/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/registry/StubEsdkClient.java b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/registry/StubEsdkClient.java new file mode 100644 index 00000000..a4b1b520 --- /dev/null +++ b/test-server/server/src/test/java/aws/cryptography/esdk/testserver/server/registry/StubEsdkClient.java @@ -0,0 +1,47 @@ +package aws.cryptography.esdk.testserver.server.registry; + +import aws.cryptography.esdk.testserver.server.error.EsdkClientException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; + +/** + * A trivial {@link EsdkClient} stand-in used by the registry property tests, + * which assert only on object identity ({@code register} stores an instance and + * {@code resolve} returns the very same instance). The crypto methods are never + * called by those tests, so they throw to make any accidental call obvious. + * + *

Error-mapping and handler property tests use the richer, controllable fake + * {@code aws.cryptography.esdk.testserver.server.handler.ControllableEsdkClient}. + */ +final class StubEsdkClient implements EsdkClient { + + @Override + public byte[] encrypt(byte[] plaintext, Map encryptionContext, + String algorithmSuiteId, Long frameLength) { + throw new UnsupportedOperationException("StubEsdkClient does not encrypt"); + } + + @Override + public byte[] decrypt(byte[] ciphertext, Map encryptionContext) { + throw new UnsupportedOperationException("StubEsdkClient does not decrypt"); + } + + @Override + public void encryptStream(InputStream plaintext, OutputStream ciphertext, + Map encryptionContext, + String algorithmSuiteId, Long frameLength) throws EsdkClientException { + throw new UnsupportedOperationException("StubEsdkClient does not stream"); + } + + @Override + public void decryptStream(InputStream ciphertext, OutputStream plaintext, + Map encryptionContext) throws EsdkClientException { + throw new UnsupportedOperationException("StubEsdkClient does not stream"); + } + + @Override + public boolean isStreamingCapable() { + return true; + } +} diff --git a/test-server/tests/clone_setup_failure_it.sh b/test-server/tests/clone_setup_failure_it.sh new file mode 100755 index 00000000..35508788 --- /dev/null +++ b/test-server/tests/clone_setup_failure_it.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# ============================================================================ +# Integration test — ESDK TestServer bootstrap failure paths (task 11.3) +# ---------------------------------------------------------------------------- +# Exercises the FAILURE paths of the bootstrap-then-delegate entry point +# (`make test-server`) in the aws-crypto-tools-java Language_Repository: +# +# * Requirement 4.9: a missing or unparseable commons-configuration.json +# halts the run BEFORE any clone (no .commons-clone is created), runs no +# Tests, and reports an error naming the expected file location. +# * Requirement 4.10: a clone failure — unreachable URL or nonexistent +# branch — halts the run, runs no Tests, and reports a failure naming the +# Commons_Repository URL and the branch that could not be obtained. The +# post-clone sanity check (clone lacks the orchestrator core) reports the +# same coordinates. +# * In EVERY failure case: non-zero exit and zero Tests run (the run never +# reaches the orchestrator-core delegation step). +# +# All cases are hermetic — no network, no JDK, no AWS: +# * COMMONS_CONFIGURATION points at scratch fixtures for the parse cases, +# so the real commons-configuration.json is never touched. +# * COMMONS_REPO points clone attempts at local file:// fixtures built in a +# temp dir (the Makefile documents COMMONS_REPO as the test-only URL +# override for exactly this purpose). +# * HARNESS_JAVA_HOME is stubbed to a non-empty placeholder: every case +# halts before the delegation step, so no JVM is ever started, and the +# stub keeps the run independent of local JDK resolution. +# * CLONE_DIR points into the scratch dir, so the repo's own .commons-clone +# is never created or removed. +# +# Usage: bash tests/clone_setup_failure_it.sh (or `make -C .. it`) +# Exit code is non-zero if any assertion fails. +# ============================================================================ +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TS_DIR="$(cd "$HERE/.." && pwd)" # aws-crypto-tools-java/esdk/test-server +MAKE=(make -C "$TS_DIR") + +# Non-empty stub: satisfies check-harness-java without resolving a real JDK. +# Safe because every case below halts before the delegation step uses it. +JAVA_STUB="/nonexistent-hermetic-it-jdk-stub" + +pass=0 +fail=0 +ok() { echo " PASS: $1"; pass=$((pass + 1)); } +bad() { echo " FAIL: $1"; fail=$((fail + 1)); } + +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +# The delegation step's marker line: if it appears, the bootstrap reached the +# orchestrator core, which a failure path must never do. +DELEGATION_MARKER="Delegating to the orchestrator core" + +# Shared assertions ----------------------------------------------------------- + +# The run never reached the Tests: no delegation, no test-results anywhere +# under the case's clone dir. +assert_no_tests_ran() { # + local log="$1" cloneDir="$2" + if ! grep -qF "$DELEGATION_MARKER" "$log"; then + ok "the run never delegated to the orchestrator core" + else + bad "the run delegated to the orchestrator core despite the failure" + fi + if ! find "$cloneDir" -path '*build/test-results*' 2>/dev/null | grep -q .; then + ok "no Tests were run (no test-results produced)" + else + bad "test results were produced despite the failure" + fi +} + +# Requirement 4.9's halt-BEFORE-clone: the clone dir was never created and no +# clone was even attempted. +assert_halted_before_clone() { # + local log="$1" cloneDir="$2" + if [ ! -e "$cloneDir" ]; then + ok "halted before any clone (no clone directory created)" + else + bad "a clone directory was created despite the pre-clone halt" + fi + if ! grep -q "Cloning the Commons_Repository" "$log"; then + ok "no clone was attempted" + else + bad "a clone was attempted despite the configuration failure" + fi +} + +# Run `make test-server` with hermetic overrides; captures output and returns +# make's exit code. Usage: run_test_server VAR=VALUE... +run_test_server() { + local log="$1"; shift + "${MAKE[@]}" test-server HARNESS_JAVA_HOME="$JAVA_STUB" "$@" >"$log" 2>&1 +} + +# Local fixture repo for the clone cases: a valid git repo on branch `main` +# that intentionally is NOT a commons checkout (no esdk/test-server). +fixtureRepo="$scratch/fixture-commons" +git init -q -b main "$fixtureRepo" +echo "this repo is intentionally NOT a valid commons checkout" > "$fixtureRepo/README.md" +git -C "$fixtureRepo" add -A +git -C "$fixtureRepo" -c user.email=it@example.com -c user.name="esdk-it" commit -qm "init" + +# ---------------------------------------------------------------------------- +# Test A: missing commons-configuration.json halts before any clone (Req 4.9). +# ---------------------------------------------------------------------------- +echo "== Test A: missing commons-configuration.json halts before any clone (Req 4.9) ==" +logA="$scratch/a.log" +cloneA="$scratch/cloneA" +missingCfg="$scratch/does-not-exist-commons-configuration.json" +if run_test_server "$logA" COMMONS_CONFIGURATION="$missingCfg" CLONE_DIR="$cloneA"; then + bad "expected a non-zero exit when commons-configuration.json is missing" +else + ok "run halted with a non-zero exit" +fi +if grep -q "missing or unparseable" "$logA" && grep -qF "$missingCfg" "$logA"; then + ok "error names the expected commons-configuration.json location" +else + bad "error does not name the expected commons-configuration.json location" +fi +assert_halted_before_clone "$logA" "$cloneA" +assert_no_tests_ran "$logA" "$cloneA" + +# ---------------------------------------------------------------------------- +# Test B: corrupt (invalid JSON) commons-configuration.json halts before any +# clone (Req 4.9). +# ---------------------------------------------------------------------------- +echo "== Test B: corrupt commons-configuration.json halts before any clone (Req 4.9) ==" +logB="$scratch/b.log" +cloneB="$scratch/cloneB" +corruptCfg="$scratch/corrupt-commons-configuration.json" +echo '{ this is not valid JSON !!!' > "$corruptCfg" +if run_test_server "$logB" COMMONS_CONFIGURATION="$corruptCfg" CLONE_DIR="$cloneB"; then + bad "expected a non-zero exit when commons-configuration.json is corrupt" +else + ok "run halted with a non-zero exit" +fi +if grep -q "missing or unparseable" "$logB" && grep -qF "$corruptCfg" "$logB"; then + ok "error names the expected commons-configuration.json location" +else + bad "error does not name the expected commons-configuration.json location" +fi +assert_halted_before_clone "$logB" "$cloneB" +assert_no_tests_ran "$logB" "$cloneB" + +# ---------------------------------------------------------------------------- +# Test C: valid JSON without the required commonsRepository entry halts before +# any clone (Req 4.9). +# ---------------------------------------------------------------------------- +echo "== Test C: valid JSON lacking commonsRepository halts before any clone (Req 4.9) ==" +logC="$scratch/c.log" +cloneC="$scratch/cloneC" +incompleteCfg="$scratch/incomplete-commons-configuration.json" +echo '{ "product": "esdk", "supportedFeatures": [], "unsupportedFeatures": [] }' > "$incompleteCfg" +if run_test_server "$logC" COMMONS_CONFIGURATION="$incompleteCfg" CLONE_DIR="$cloneC"; then + bad "expected a non-zero exit when commonsRepository is absent" +else + ok "run halted with a non-zero exit" +fi +if grep -q "missing or unparseable" "$logC" && grep -qF "$incompleteCfg" "$logC"; then + ok "error names the expected commons-configuration.json location" +else + bad "error does not name the expected commons-configuration.json location" +fi +assert_halted_before_clone "$logC" "$cloneC" +assert_no_tests_ran "$logC" "$cloneC" + +# ---------------------------------------------------------------------------- +# Test D: a nonexistent branch halts the run naming the URL and branch +# (Req 4.10). Uses a real local fixture repo so only the branch is bogus. +# ---------------------------------------------------------------------------- +echo "== Test D: nonexistent branch halts the run naming URL + branch (Req 4.10) ==" +logD="$scratch/d.log" +cloneD="$scratch/cloneD" +bogusBranch="no-such-branch-$$" +fixtureUrl="file://$fixtureRepo" +if run_test_server "$logD" COMMONS_REPO="$fixtureUrl" COMMONS_BRANCH="$bogusBranch" CLONE_DIR="$cloneD"; then + bad "expected a non-zero exit when the branch does not exist" +else + ok "run halted with a non-zero exit" +fi +if grep -q "failed to clone" "$logD"; then + ok "failure output identifies the clone failure" +else + bad "failure output does not identify the clone failure" +fi +if grep -qF "$fixtureUrl" "$logD" && grep -qF "$bogusBranch" "$logD"; then + ok "failure names the Commons_Repository URL and the branch" +else + bad "failure does not name the URL and branch" +fi +assert_no_tests_ran "$logD" "$cloneD" + +# ---------------------------------------------------------------------------- +# Test E: an unreachable URL halts the run naming the URL and branch +# (Req 4.10). No COMMONS_BRANCH override, so the run exercises the +# configuration-entry branch selection from the REAL commons-configuration.json. +# ---------------------------------------------------------------------------- +echo "== Test E: unreachable URL halts the run naming URL + branch (Req 4.10) ==" +logE="$scratch/e.log" +cloneE="$scratch/cloneE" +bogusUrl="file:///definitely/not/a/repo" +configuredBranch="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["commonsRepository"]["branch"])' "$TS_DIR/commons-configuration.json")" +if run_test_server "$logE" COMMONS_REPO="$bogusUrl" CLONE_DIR="$cloneE"; then + bad "expected a non-zero exit when the URL is unreachable" +else + ok "run halted with a non-zero exit" +fi +if grep -q "failed to clone" "$logE"; then + ok "failure output identifies the clone failure" +else + bad "failure output does not identify the clone failure" +fi +if grep -qF "$bogusUrl" "$logE" && grep -qF "$configuredBranch" "$logE"; then + ok "failure names the Commons_Repository URL and the configured branch" +else + bad "failure does not name the URL and configured branch" +fi +assert_no_tests_ran "$logE" "$cloneE" + +# ---------------------------------------------------------------------------- +# Test F: the clone succeeds but lacks the orchestrator core -> the post-clone +# sanity check halts the run naming the URL and branch (Req 4.10). +# ---------------------------------------------------------------------------- +echo "== Test F: clone lacking the orchestrator core halts naming URL + branch (Req 4.10) ==" +logF="$scratch/f.log" +cloneF="$scratch/cloneF" +if run_test_server "$logF" COMMONS_REPO="$fixtureUrl" COMMONS_BRANCH="main" CLONE_DIR="$cloneF"; then + bad "expected a non-zero exit when the clone lacks the orchestrator core" +else + ok "run halted with a non-zero exit" +fi +if grep -q "no orchestrator core" "$logF"; then + ok "failure output identifies the missing orchestrator core" +else + bad "failure output does not identify the missing orchestrator core" +fi +if grep -qF "$fixtureUrl" "$logF" && grep -q "branch: main" "$logF"; then + ok "failure names the Commons_Repository URL and the branch" +else + bad "failure does not name the URL and branch" +fi +assert_no_tests_ran "$logF" "$cloneF" + +echo "" +echo "== Summary: $pass passed, $fail failed ==" +[ "$fail" -eq 0 ] diff --git a/test-server/tests/structural_smoke_check.sh b/test-server/tests/structural_smoke_check.sh new file mode 100755 index 00000000..d2dbdf84 --- /dev/null +++ b/test-server/tests/structural_smoke_check.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# ============================================================================ +# Structural smoke check — aws-crypto-tools-java Language_Repository (task 15.2) +# ---------------------------------------------------------------------------- +# Asserts the shipped factoring of the ESDK TestServer as seen from THIS +# Language_Repository: +# +# * Requirement 4.4: commons-configuration.json carries a complete +# Commons_Configuration_Entry — a `commonsRepository` object whose `name`, +# `url`, and `branch` are all non-empty strings. +# * Requirement 8.2 (+ 8.11 groundwork): the same file carries the required +# `product` field with the exact value "esdk" — the Feature_Declaration +# lives HERE, alongside the Commons_Configuration_Entry, not in any +# standalone feature file. +# * Requirement 8.12: the Java Feature_Declaration lists both "streaming" +# and "MPL" in `supportedFeatures`, and neither appears in +# `unsupportedFeatures`. +# * Requirement 10.6: this Language_Repository contains ZERO copies of the +# Tests definition — no directory matching the commons Tests module layout +# (no esdk/test-server/tests/src, no Gradle build under tests/) and no +# MaterialsRoundTripTests.java anywhere in the repository. +# * Requirement 8.2 (shape): NO standalone feature-configuration file exists +# anywhere under esdk/ — the declaration is folded into +# commons-configuration.json. +# +# Hermetic: no network, no JDK, no AWS — pure filesystem + python3 JSON +# assertions. The bootstrap's commons clone (.commons-clone/), integration +# scratch (.it-tmp/), .git/, and the vendored third-party submodules +# (esdk/submodules/) are excluded from the repository-wide scans: they are not +# part of this Language_Repository's own factoring. +# +# Usage: bash tests/structural_smoke_check.sh (or `make -C .. smoke-check`) +# Exit code is non-zero if any assertion fails. +# ============================================================================ +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TS_DIR="$(cd "$HERE/.." && pwd)" # aws-crypto-tools-java/esdk/test-server +REPO_ROOT="$(cd "$TS_DIR/../.." && pwd)" # aws-crypto-tools-java +CONFIG="$TS_DIR/commons-configuration.json" + +pass=0 +fail=0 +ok() { echo " PASS: $1"; pass=$((pass + 1)); } +bad() { echo " FAIL: $1"; fail=$((fail + 1)); } + +# Repository-wide scans skip these (not part of this repo's own factoring). +# Usage: repo_find — a find over $REPO_ROOT with the prunes. +repo_find() { + find "$REPO_ROOT" \ + \( -name .git -o -name .commons-clone -o -name .it-tmp \ + -o -path "$REPO_ROOT/esdk/submodules" \) -prune \ + -o "$@" -print 2>/dev/null +} + +# ---------------------------------------------------------------------------- +# Check 1: complete Commons_Configuration_Entry (Req 4.4) +# ---------------------------------------------------------------------------- +echo "== Check 1: commons-configuration.json carries a complete Commons_Configuration_Entry (Req 4.4) ==" +if [ ! -f "$CONFIG" ]; then + bad "commons-configuration.json is missing (expected at $CONFIG)" +else + ok "commons-configuration.json exists" + if entry_errors=$(python3 - "$CONFIG" <<'PY' +import json, sys +cfg = json.load(open(sys.argv[1])) +repo = cfg.get("commonsRepository") +errors = [] +if not isinstance(repo, dict): + errors.append("commonsRepository object is missing") +else: + for key in ("name", "url", "branch"): + value = repo.get(key) + if not isinstance(value, str) or not value.strip(): + errors.append(f"commonsRepository.{key} is missing or empty") +print("\n".join(errors)) +sys.exit(1 if errors else 0) +PY + ); then + ok "commonsRepository carries non-empty name, url, and branch" + else + if [ -n "$entry_errors" ]; then + bad "incomplete Commons_Configuration_Entry: ${entry_errors//$'\n'/; }" + else + bad "commons-configuration.json is not parseable JSON" + fi + fi +fi + +# ---------------------------------------------------------------------------- +# Check 2: product is exactly "esdk" (Req 8.2, groundwork for the 8.11 match) +# ---------------------------------------------------------------------------- +echo "== Check 2: product field is exactly \"esdk\" (Req 8.2) ==" +if [ -f "$CONFIG" ] && product=$(python3 -c 'import json,sys; p=json.load(open(sys.argv[1])).get("product"); sys.exit(1) if not isinstance(p, str) else print(p)' "$CONFIG" 2>/dev/null); then + if [ "$product" = "esdk" ]; then + ok "product is exactly \"esdk\"" + else + bad "product is \"$product\", expected exactly \"esdk\"" + fi +else + bad "commons-configuration.json is missing, unparseable, or lacks a string product field" +fi + +# ---------------------------------------------------------------------------- +# Check 3: Java Feature_Declaration lists streaming + MPL + hierarchical as +# supported (Req 8.12) +# ---------------------------------------------------------------------------- +echo "== Check 3: Java Feature_Declaration supports streaming, MPL, and hierarchical (Req 8.12) ==" +if [ -f "$CONFIG" ] && feature_errors=$(python3 - "$CONFIG" <<'PY' +import json, sys +cfg = json.load(open(sys.argv[1])) +supported = cfg.get("supportedFeatures") +unsupported = cfg.get("unsupportedFeatures") +errors = [] +if not isinstance(supported, list): + errors.append("supportedFeatures array is missing") +if not isinstance(unsupported, list): + errors.append("unsupportedFeatures array is missing") +if not errors: + for feature in ("streaming", "MPL", "hierarchical"): + if feature not in supported: + errors.append(f'"{feature}" is not in supportedFeatures') + if feature in unsupported: + errors.append(f'"{feature}" appears in unsupportedFeatures') +print("\n".join(errors)) +sys.exit(1 if errors else 0) +PY +); then + ok "supportedFeatures lists streaming, MPL, and hierarchical; unsupportedFeatures lists none of them" +else + if [ -n "${feature_errors:-}" ]; then + bad "Feature_Declaration violation: ${feature_errors//$'\n'/; }" + else + bad "commons-configuration.json is missing or unparseable" + fi +fi + +# ---------------------------------------------------------------------------- +# Check 4: zero copies of the Tests definition in this repo (Req 10.6) +# ---------------------------------------------------------------------------- +echo "== Check 4: no Tests definition in this Language_Repository (Req 10.6) ==" +if [ ! -e "$TS_DIR/tests/src" ]; then + ok "no esdk/test-server/tests/src directory (no commons Tests module layout)" +else + bad "esdk/test-server/tests/src exists — looks like a copy of the commons Tests module" +fi +gradle_in_tests=$(find "$TS_DIR/tests" \( -name "build.gradle*" -o -name "settings.gradle*" \) 2>/dev/null) +if [ -z "$gradle_in_tests" ]; then + ok "no Gradle build under esdk/test-server/tests/ (shell scripts only)" +else + bad "Gradle build files under esdk/test-server/tests/: ${gradle_in_tests//$'\n'/, }" +fi +tests_copies=$(repo_find -type f -name "MaterialsRoundTripTests.java") +if [ -z "$tests_copies" ]; then + ok "no MaterialsRoundTripTests.java anywhere in the repository" +else + bad "Tests definition copy found: ${tests_copies//$'\n'/, }" +fi + +# ---------------------------------------------------------------------------- +# Check 5: no standalone feature-configuration file under esdk/ (Req 8.2) +# ---------------------------------------------------------------------------- +echo "== Check 5: no standalone feature file under esdk/ (Req 8.2) ==" +feature_files=$(repo_find -type f -path "$REPO_ROOT/esdk/*" \ + \( -iname "*feature*.json" -o -iname "*feature*.yml" -o -iname "*feature*.yaml" \ + -o -iname "*feature*.toml" -o -iname "*feature*.properties" -o -iname "*feature*.cfg" \)) +if [ -z "$feature_files" ]; then + ok "no standalone feature-configuration file (the declaration lives in commons-configuration.json)" +else + bad "standalone feature file(s) found: ${feature_files//$'\n'/, }" +fi + +echo "" +echo "== Summary: $pass passed, $fail failed ==" +[ "$fail" -eq 0 ]