From 7c2a240a667aa9c17085b18fc99c36ca15acf7ec Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 11 Sep 2026 23:45:43 +0000 Subject: [PATCH 1/6] 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 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 '} + 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 ] From f42d4b78d5fdb05f02d683eb517ea213c7fccc91 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier <76071473+kessplas@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:06:44 +0000 Subject: [PATCH 2/6] chore(test-server): read commons coordinates from server-config.json commons-configuration.json was replaced by the server-config.json / feature-config.json / bug-config.json trio in the config split but the Makefile still read the removed file; point COMMONS_CONFIGURATION at server-config.json (same commonsRepository shape) so `make test-server` resolves commons again. Its commonsRepository.branch is already lucmcdon/esdk-test-server-all-languages. --- test-server/Makefile | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test-server/Makefile b/test-server/Makefile index 7ece64b1..9dd96478 100644 --- a/test-server/Makefile +++ b/test-server/Makefile @@ -18,7 +18,7 @@ # # make test-server [COMMONS_BRANCH=] # -# 1. Parse commons-configuration.json (the Commons_Configuration_Entry). +# 1. Parse server-config.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 @@ -47,7 +47,7 @@ # ---------------------------------------------------------------------------- # 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 +# object of `server-config.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 @@ -58,7 +58,7 @@ # # 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. +# in server-config.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 @@ -79,20 +79,20 @@ MAKEFILE_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) # 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. +# /esdk/test-server/server-config.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 +COMMONS_CONFIGURATION := $(MAKEFILE_DIR)/server-config.json # Invocation-time commons branch override (Requirement 4.8). Empty means "use -# the branch configured in commons-configuration.json". Supplying it flips the +# the branch configured in server-config.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. +# The authoritative URL is the one in server-config.json. COMMONS_REPO ?= # Where the fresh clone lands. Kept under a scratch dir ignored by .gitignore @@ -146,7 +146,7 @@ help: ## Show this help @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 coordinates (single source of truth: server-config.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; \ @@ -299,7 +299,7 @@ smoke-check: ## Run the structural smoke check for this Language_Repository # ---------------------------------------------------------------------------- # 11.3 + 15.2: the repository's hermetic checks — the bootstrap failure-path -# integration tests (missing/corrupt commons-configuration.json, bogus +# integration tests (missing/corrupt server-config.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 From 76d3608dc66f05c6ee053850850e108849bf4f8b Mon Sep 17 00:00:00 2001 From: Kess Plasmeier <76071473+kessplas@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:56:59 +0000 Subject: [PATCH 3/6] ci(test-server): add the cross-language TestServer workflow The Java move brought the server + config + Makefile but no CI workflow (workflows live at the repo root, outside test-server/), so the TestServer tests never ran on this PR. Add the orchestrated run (`make -C test-server test-server`) modeled on the sibling repos, provisioning the full cross-language toolchain matrix. Needs the COMMONS_REPO_PAT secret. --- .github/workflows/test-server.yml | 172 ++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 .github/workflows/test-server.yml diff --git a/.github/workflows/test-server.yml b/.github/workflows/test-server.yml new file mode 100644 index 00000000..635b458e --- /dev/null +++ b/.github/workflows/test-server.yml @@ -0,0 +1,172 @@ +name: ESDK TestServer (Java) + +on: + pull_request: + paths: + - 'test-server/**' + - '.github/workflows/test-server.yml' + push: + paths: + - 'test-server/**' + - '.github/workflows/test-server.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + # The complete cross-language TestServer run: `make -C test-server test-server` + # clones commons and the orchestrator materializes + builds + launches every + # configured Language_Server (this working tree is the live Java server), runs the + # full pairwise matrix, and tears down. Provisions the full toolchain matrix + the + # commons build caches. Needs the COMMONS_REPO_PAT secret (read on commons + the + # language repos). + test-server: + name: Cross-language TestServer run + runs-on: ubuntu-22.04 + permissions: + id-token: write + contents: read + env: + AWS_REGION: us-west-2 + GRADLE_OPTS: -Dorg.gradle.daemon=false + # Cold builds (Java gradle/maven, Rust aws-lc-sys, .NET dafny transpile) exceed + # the orchestrator's 180s default server-readiness window; widen it so the first + # (cold-cache) run fits. Warm runs are fast via the build caches. + ESDK_TESTSERVER_READY_TIMEOUT_SECONDS: "1800" + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 (Temurin) — exposes JAVA_HOME_17_X64 for the MPL stub jar + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up JDK 21 (Temurin) — the commons orchestrator (smithy-java) needs 21+ + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Set up Python 3.11 (the materialized Python Language_Server needs >=3.11) + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up Node.js 22 (the materialized JavaScript Language_Server build needs 17+) + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Set up Go (Go Language_Server + the Rust servers' aws-lc-sys native builds) + uses: actions/setup-go@v5 + with: + go-version: "stable" + + - name: Set up the Rust toolchain (Rust Language_Server builds) + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal + rustup default stable + cargo --version + + - name: Set up .NET (the materialized .NET Language_Server build) + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 6.0.x + 8.0.x + + - name: Install Dafny 4.9.0 (the .NET library transpile in the materialized clone) + run: | + set -euo pipefail + dotnet tool install --global dafny --version 4.9.0 + echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + "$HOME/.dotnet/tools/dafny" --version + + - name: Build aws-c-common (the materialized C Language_Server cmake requires it) + run: | + set -euo pipefail + git clone --depth 1 https://github.com/awslabs/aws-c-common "$RUNNER_TEMP/aws-c-common" + cmake -S "$RUNNER_TEMP/aws-c-common" -B "$RUNNER_TEMP/aws-c-common-build" \ + -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/.aws-c-common-install" \ + -DBUILD_TESTING=OFF + cmake --build "$RUNNER_TEMP/aws-c-common-build" --target install -- -j + echo "CMAKE_PREFIX_PATH=${{ github.workspace }}/.aws-c-common-install" >> "$GITHUB_ENV" + + - name: Restore build caches (Gradle/Maven/Cargo/NuGet/Go/npm) + uses: actions/cache/restore@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.m2/repository + ~/.cargo/registry + ~/.cargo/git + ~/.nuget/packages + ~/go/pkg/mod + ~/.cache/go-build + ~/.npm + key: ${{ runner.os }}-esdk-ts-build-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-esdk-ts-build- + + - name: Install stub for the unpublished MPL test-vectors artifact + # The Java Language_Server build runs `mvn install`, which resolves a + # test-scope MPL artifact (TestAwsCryptographicMaterialProviders) that is + # not on Maven Central; a stub jar satisfies resolution (its tests skip). + run: | + set -euo pipefail + tmp="$(mktemp -d)" + echo placeholder > "$tmp/README.txt" + "$JAVA_HOME_17_X64/bin/jar" cf "$tmp/stub.jar" -C "$tmp" README.txt + mvn -q -e org.apache.maven.plugins:maven-install-plugin:3.1.1:install-file \ + -Dfile="$tmp/stub.jar" \ + -DgroupId=software.amazon.cryptography \ + -DartifactId=TestAwsCryptographicMaterialProviders \ + -Dversion=1.7.0 \ + -Dpackaging=jar + + - name: Authenticate the private clones (commons, rust) + env: + PAT: ${{ secrets.COMMONS_REPO_PAT }} + run: | + test -n "$PAT" || { echo "::error::COMMONS_REPO_PAT is empty/unset in this repo"; exit 1; } + git config --global url."https://x-access-token:${PAT}@github.com/".insteadOf "git@github.com:" + + - name: Configure AWS credentials via OIDC (required KMS scenarios) + id: aws-credentials + continue-on-error: true + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.ESDK_TEST_SERVER_KMS_ROLE_ARN || 'arn:aws:iam::370957321024:role/esdk-test-server-ci-kms-role' }} + aws-region: us-west-2 + + - name: Verify AWS credentials are active (gates the orchestrated run) + run: | + if ! aws sts get-caller-identity; then + echo "::error::No usable AWS credentials for the required KMS scenarios." >&2 + exit 1 + fi + + - name: Run the orchestrated TestServer + run: make -C test-server test-server + + - name: Save build caches + if: always() + uses: actions/cache/save@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.m2/repository + ~/.cargo/registry + ~/.cargo/git + ~/.nuget/packages + ~/go/pkg/mod + ~/.cache/go-build + ~/.npm + key: ${{ runner.os }}-esdk-ts-build-${{ github.run_id }} From ffe5e93b55d9c4b501938a48f31035656dfcff97 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier <76071473+kessplas@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:59:47 +0000 Subject: [PATCH 4/6] ci(test-server): run the TestServer through the shared commons workflow Replace the copied standalone workflow with a caller of the commons esdk-test-server-reusable.yml in focus mode (focus/checkout-languages: java), matching aws-crypto-tools-rust. COMMONS_REPO_PAT is still needed to clone the private aws-crypto-tools-rust servers. --- .github/workflows/test-server.yml | 179 ++++-------------------------- 1 file changed, 23 insertions(+), 156 deletions(-) diff --git a/.github/workflows/test-server.yml b/.github/workflows/test-server.yml index 635b458e..20d281d4 100644 --- a/.github/workflows/test-server.yml +++ b/.github/workflows/test-server.yml @@ -3,170 +3,37 @@ name: ESDK TestServer (Java) on: pull_request: paths: - - 'test-server/**' - - '.github/workflows/test-server.yml' + - "test-server/**" + - "src/**" + - "pom.xml" + - ".github/workflows/test-server.yml" push: + branches: + - master paths: - - 'test-server/**' - - '.github/workflows/test-server.yml' + - "test-server/**" + - "src/**" + - "pom.xml" + - ".github/workflows/test-server.yml" workflow_dispatch: permissions: contents: read jobs: - # The complete cross-language TestServer run: `make -C test-server test-server` - # clones commons and the orchestrator materializes + builds + launches every - # configured Language_Server (this working tree is the live Java server), runs the - # full pairwise matrix, and tears down. Provisions the full toolchain matrix + the - # commons build caches. Needs the COMMONS_REPO_PAT secret (read on commons + the - # language repos). + # The cross-language TestServer run, from the shared commons workflow. This + # checkout supplies the java server and library; every other server comes from + # its configured branch. Only the pairs involving java run; the full matrix + # runs in commons. repo-pat reads the private aws-crypto-tools-rust clones. test-server: - name: Cross-language TestServer run - runs-on: ubuntu-22.04 + name: TestServer permissions: - id-token: write contents: read - env: - AWS_REGION: us-west-2 - GRADLE_OPTS: -Dorg.gradle.daemon=false - # Cold builds (Java gradle/maven, Rust aws-lc-sys, .NET dafny transpile) exceed - # the orchestrator's 180s default server-readiness window; widen it so the first - # (cold-cache) run fits. Warm runs are fast via the build caches. - ESDK_TESTSERVER_READY_TIMEOUT_SECONDS: "1800" - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Set up JDK 17 (Temurin) — exposes JAVA_HOME_17_X64 for the MPL stub jar - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "17" - - - name: Set up JDK 21 (Temurin) — the commons orchestrator (smithy-java) needs 21+ - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - - name: Set up Python 3.11 (the materialized Python Language_Server needs >=3.11) - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Set up Node.js 22 (the materialized JavaScript Language_Server build needs 17+) - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Set up Go (Go Language_Server + the Rust servers' aws-lc-sys native builds) - uses: actions/setup-go@v5 - with: - go-version: "stable" - - - name: Set up the Rust toolchain (Rust Language_Server builds) - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal - rustup default stable - cargo --version - - - name: Set up .NET (the materialized .NET Language_Server build) - uses: actions/setup-dotnet@v5 - with: - dotnet-version: | - 6.0.x - 8.0.x - - - name: Install Dafny 4.9.0 (the .NET library transpile in the materialized clone) - run: | - set -euo pipefail - dotnet tool install --global dafny --version 4.9.0 - echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" - "$HOME/.dotnet/tools/dafny" --version - - - name: Build aws-c-common (the materialized C Language_Server cmake requires it) - run: | - set -euo pipefail - git clone --depth 1 https://github.com/awslabs/aws-c-common "$RUNNER_TEMP/aws-c-common" - cmake -S "$RUNNER_TEMP/aws-c-common" -B "$RUNNER_TEMP/aws-c-common-build" \ - -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/.aws-c-common-install" \ - -DBUILD_TESTING=OFF - cmake --build "$RUNNER_TEMP/aws-c-common-build" --target install -- -j - echo "CMAKE_PREFIX_PATH=${{ github.workspace }}/.aws-c-common-install" >> "$GITHUB_ENV" - - - name: Restore build caches (Gradle/Maven/Cargo/NuGet/Go/npm) - uses: actions/cache/restore@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - ~/.m2/repository - ~/.cargo/registry - ~/.cargo/git - ~/.nuget/packages - ~/go/pkg/mod - ~/.cache/go-build - ~/.npm - key: ${{ runner.os }}-esdk-ts-build-${{ github.run_id }} - restore-keys: | - ${{ runner.os }}-esdk-ts-build- - - - name: Install stub for the unpublished MPL test-vectors artifact - # The Java Language_Server build runs `mvn install`, which resolves a - # test-scope MPL artifact (TestAwsCryptographicMaterialProviders) that is - # not on Maven Central; a stub jar satisfies resolution (its tests skip). - run: | - set -euo pipefail - tmp="$(mktemp -d)" - echo placeholder > "$tmp/README.txt" - "$JAVA_HOME_17_X64/bin/jar" cf "$tmp/stub.jar" -C "$tmp" README.txt - mvn -q -e org.apache.maven.plugins:maven-install-plugin:3.1.1:install-file \ - -Dfile="$tmp/stub.jar" \ - -DgroupId=software.amazon.cryptography \ - -DartifactId=TestAwsCryptographicMaterialProviders \ - -Dversion=1.7.0 \ - -Dpackaging=jar - - - name: Authenticate the private clones (commons, rust) - env: - PAT: ${{ secrets.COMMONS_REPO_PAT }} - run: | - test -n "$PAT" || { echo "::error::COMMONS_REPO_PAT is empty/unset in this repo"; exit 1; } - git config --global url."https://x-access-token:${PAT}@github.com/".insteadOf "git@github.com:" - - - name: Configure AWS credentials via OIDC (required KMS scenarios) - id: aws-credentials - continue-on-error: true - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ vars.ESDK_TEST_SERVER_KMS_ROLE_ARN || 'arn:aws:iam::370957321024:role/esdk-test-server-ci-kms-role' }} - aws-region: us-west-2 - - - name: Verify AWS credentials are active (gates the orchestrated run) - run: | - if ! aws sts get-caller-identity; then - echo "::error::No usable AWS credentials for the required KMS scenarios." >&2 - exit 1 - fi - - - name: Run the orchestrated TestServer - run: make -C test-server test-server - - - name: Save build caches - if: always() - uses: actions/cache/save@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - ~/.m2/repository - ~/.cargo/registry - ~/.cargo/git - ~/.nuget/packages - ~/go/pkg/mod - ~/.cache/go-build - ~/.npm - key: ${{ runner.os }}-esdk-ts-build-${{ github.run_id }} + actions: read + id-token: write + uses: aws/aws-crypto-tools-commons/.github/workflows/esdk-test-server-reusable.yml@main + with: + focus: java + checkout-languages: java + secrets: + repo-pat: ${{ secrets.COMMONS_REPO_PAT }} From d24adbb90e678d6f36bd99cfe2cade183678f272 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier <76071473+kessplas@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:09:56 +0000 Subject: [PATCH 5/6] chore: retrigger CI From b7d6660a8222b34b23d3a3d82ba9ea100b41cf10 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Thu, 15 Oct 2026 21:38:46 +0000 Subject: [PATCH 6/6] fix(test-server): declare raw-ecdh, aws-kms-ecdh, and caching supported The AWS Encryption SDK for Java supports the raw ECDH and AWS KMS ECDH keyrings (through the Material Providers Library) and data key caching, so the Java server's feature declaration lists them as supported. --- test-server/feature-config.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test-server/feature-config.json b/test-server/feature-config.json index 1020db2b..d83247e9 100644 --- a/test-server/feature-config.json +++ b/test-server/feature-config.json @@ -5,6 +5,7 @@ "hierarchical", "raw-aes", "raw-rsa", + "raw-ecdh", "multi", "aws-kms", "aws-kms-multi", @@ -13,11 +14,9 @@ "aws-kms-mrk-multi", "aws-kms-mrk-discovery", "aws-kms-rsa", - "required-encryption-context" - ], - "unsupportedFeatures": [ - "raw-ecdh", "aws-kms-ecdh", + "required-encryption-context", "caching" - ] + ], + "unsupportedFeatures": [] }