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 00000000..1b33c55b Binary files /dev/null and b/test-server/server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/test-server/server/gradle/wrapper/gradle-wrapper.properties b/test-server/server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..002b867c --- /dev/null +++ b/test-server/server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/test-server/server/gradlew b/test-server/server/gradlew new file mode 100755 index 00000000..23d15a93 --- /dev/null +++ b/test-server/server/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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 ]