diff --git a/.github/workflows/all-ci.yml b/.github/workflows/all-ci.yml
index e35342fc..d8c92bb9 100644
--- a/.github/workflows/all-ci.yml
+++ b/.github/workflows/all-ci.yml
@@ -37,6 +37,16 @@ jobs:
python-version: ${{ inputs.python-version || '3.11' }}
secrets: inherit
+ python-perf:
+ permissions:
+ id-token: write
+ contents: read
+ name: Python Performance Tests
+ uses: ./.github/workflows/python-perf.yml
+ with:
+ python-version: ${{ inputs.python-version || '3.11' }}
+ secrets: inherit
+
run-duvet:
permissions:
id-token: write
diff --git a/.github/workflows/python-integ.yml b/.github/workflows/python-integ.yml
index 0f50c8e8..ec50a72c 100644
--- a/.github/workflows/python-integ.yml
+++ b/.github/workflows/python-integ.yml
@@ -49,7 +49,7 @@ jobs:
- name: Run unit tests
run: |
- uv run pytest test/ --ignore=test/integration/ --verbose \
+ uv run pytest test/ --ignore=test/integration/ --ignore=test/performance/ --verbose \
--cov=src/s3_encryption --cov-report=term-missing --cov-report=html:coverage-unit \
--cov-fail-under=89
diff --git a/.github/workflows/python-perf.yml b/.github/workflows/python-perf.yml
new file mode 100644
index 00000000..c5a4ab41
--- /dev/null
+++ b/.github/workflows/python-perf.yml
@@ -0,0 +1,66 @@
+name: Python Performance Tests
+
+on:
+ workflow_call:
+ inputs:
+ python-version:
+ description: "Python version to use"
+ default: "3.11"
+ required: false
+ type: string
+
+jobs:
+ python-perf:
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ submodules: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ inputs.python-version || '3.11' }}
+
+ - name: Cache uv dependencies
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/uv
+ key: ${{ runner.os }}-uv-${{ hashFiles('**/pyproject.toml') }}
+ restore-keys: |
+ ${{ runner.os }}-uv-
+
+ - name: Install Uv
+ run: pip install uv
+
+ - name: Install dependencies
+ run: make install
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v6
+ with:
+ role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role
+ aws-region: us-west-2
+
+ - name: Run performance tests
+ run: make test-perf
+ env:
+ CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }}
+ CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }}
+ PERF_NUM_ROUNDS: "30"
+
+ - name: Generate performance HTML report
+ if: always()
+ run: uv run python test/performance/generate_report.py
+
+ - name: Upload performance report
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: performance-report
+ path: perf-results/
diff --git a/.gitignore b/.gitignore
index 3691eef4..b0b67407 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,4 @@ smithy-java-core/out
*.pid
.coverage
coverage-report/
+perf-results/
diff --git a/Makefile b/Makefile
index caf05737..e764a496 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: lint format test test-unit test-integration install
+.PHONY: lint format test test-unit test-integration test-perf install
# Default target
all: lint test duvet
@@ -25,12 +25,16 @@ test: test-unit test-integration test-examples
# Run unit tests with coverage
test-unit:
- uv run pytest test/ --ignore=test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=89
+ uv run pytest test/ --ignore=test/integration/ --ignore=test/performance/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=89
# Run integration tests with separate coverage
test-integration:
uv run pytest test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=83
+# Run performance tests
+test-perf:
+ uv run pytest test/performance/ --verbose -x
+
test-examples:
uv run pytest examples/test/ -v
diff --git a/test/performance/__init__.py b/test/performance/__init__.py
new file mode 100644
index 00000000..f94fd12a
--- /dev/null
+++ b/test/performance/__init__.py
@@ -0,0 +1,2 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
diff --git a/test/performance/conftest.py b/test/performance/conftest.py
new file mode 100644
index 00000000..2e686a30
--- /dev/null
+++ b/test/performance/conftest.py
@@ -0,0 +1,43 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Shared fixtures for performance tests."""
+
+import os
+
+import boto3
+import pytest
+
+from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig
+from s3_encryption.materials.kms_keyring import KmsKeyring
+
+BUCKET = os.environ.get("CI_S3_BUCKET", "s3ec-python-github-test-bucket")
+REGION = os.environ.get("CI_AWS_REGION", "us-west-2")
+KMS_KEY_ID = os.environ.get(
+ "CI_KMS_KEY_ALIAS", "arn:aws:kms:us-west-2:370957321024:alias/S3EC-Python-Github-KMS-Key"
+)
+
+# Performance test configuration
+NUM_ROUNDS = int(os.environ.get("PERF_NUM_ROUNDS", "30"))
+OBJECT_SIZES_MB = [10, 25, 50]
+
+
+def _make_s3ec(algorithm_suite, commitment_policy):
+ kms_client = boto3.client("kms", region_name=REGION)
+ keyring = KmsKeyring(kms_client, KMS_KEY_ID)
+ wrapped_client = boto3.client("s3")
+ config = S3EncryptionClientConfig(
+ keyring,
+ encryption_algorithm=algorithm_suite,
+ commitment_policy=commitment_policy,
+ )
+ return S3EncryptionClient(wrapped_client, config)
+
+
+@pytest.fixture(scope="module")
+def plain_s3():
+ return boto3.client("s3", region_name=REGION)
+
+
+@pytest.fixture(scope="module")
+def kms_client():
+ return boto3.client("kms", region_name=REGION)
diff --git a/test/performance/generate_report.py b/test/performance/generate_report.py
new file mode 100644
index 00000000..14030657
--- /dev/null
+++ b/test/performance/generate_report.py
@@ -0,0 +1,398 @@
+#!/usr/bin/env python3
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Generate an HTML performance report with tables, bar charts, and histograms."""
+
+import json
+import math
+import sys
+from pathlib import Path
+
+RESULTS_FILE = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("perf-results/results.json")
+OUTPUT_FILE = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("perf-results/report.html")
+
+COLORS = {
+ "plain": "#36a2eb",
+ "aes_gcm": "#ff6384",
+ "kc_gcm": "#ff9f40",
+ "local": "#4bc0c0",
+}
+
+
+def _fmt(seconds: float) -> str:
+ if seconds < 1:
+ return f"{seconds * 1000:.1f} ms"
+ return f"{seconds:.2f} s"
+
+
+def _percentile(sorted_vals, p):
+ """Compute the p-th percentile from a sorted list."""
+ k = (len(sorted_vals) - 1) * (p / 100)
+ f = math.floor(k)
+ c = math.ceil(k)
+ if f == c:
+ return sorted_vals[int(k)]
+ return sorted_vals[f] * (c - k) + sorted_vals[c] * (k - f)
+
+
+def _median(vals):
+ s = sorted(vals)
+ return _percentile(s, 50)
+
+
+def _p95(vals):
+ s = sorted(vals)
+ return _percentile(s, 95)
+
+
+def _lookup(results, prefix, size_mb):
+ for r in results:
+ if r["test"].startswith(prefix) and r["size_mb"] == size_mb:
+ return r
+ return None
+
+
+def _bar_chart_svg(chart_id, title, groups, sizes, width=700, bar_h=28, gap=6):
+ """Render a grouped horizontal bar chart (median values) as SVG."""
+ label_col_w = 120
+ chart_w = width - label_col_w - 80
+ n_groups = len(groups)
+ block_h = n_groups * (bar_h + gap) + 20
+ total_h = len(sizes) * block_h + 60
+
+ max_val = max(
+ (v for g in groups for v in g["values"].values()),
+ default=1,
+ )
+ if max_val == 0:
+ max_val = 1
+
+ svg = [
+ f'")
+ return "\n".join(svg)
+
+
+def _histogram_svg(chart_id, title, series_list, width=700, height=220, n_bins=15):
+ """Render overlaid histograms for multiple series as SVG.
+
+ Args:
+ chart_id: unique SVG id
+ title: chart title
+ series_list: list of {label, color, durations: [float]}
+ width, height: SVG dimensions
+ n_bins: number of histogram bins
+ """
+ # Compute global range across all series
+ all_vals = [d for s in series_list for d in s["durations"]]
+ if not all_vals:
+ return ""
+ lo = min(all_vals)
+ hi = max(all_vals)
+ if lo == hi:
+ hi = lo + 0.001 # avoid zero-width range
+
+ margin_l, margin_r, margin_t, margin_b = 60, 20, 50, 40
+ plot_w = width - margin_l - margin_r
+ plot_h = height - margin_t - margin_b
+ bin_width = (hi - lo) / n_bins
+
+ # Build histogram counts for each series
+ histograms = []
+ global_max_count = 0
+ for s in series_list:
+ counts = [0] * n_bins
+ for d in s["durations"]:
+ idx = min(int((d - lo) / bin_width), n_bins - 1)
+ counts[idx] += 1
+ global_max_count = max(global_max_count, max(counts))
+ histograms.append(counts)
+ if global_max_count == 0:
+ global_max_count = 1
+
+ svg = [
+ f'")
+ return "\n".join(svg)
+
+
+def _build_charts_and_histograms(results, sizes):
+ """Build bar charts (median) and histograms for each category."""
+ html_parts = []
+
+ # --- Define chart groups ---
+ chart_defs = [
+ {
+ "id": "put",
+ "title": "PutObject: Plain S3 vs S3EC",
+ "series": [
+ ("Plain S3", "plain", "plain_s3_put"),
+ ("S3EC AES_GCM", "aes_gcm", "s3ec_put_aes_gcm"),
+ ("S3EC KC_GCM", "kc_gcm", "s3ec_put_kc_gcm"),
+ ],
+ },
+ {
+ "id": "get",
+ "title": "GetObject: Plain S3 vs S3EC",
+ "series": [
+ ("Plain S3", "plain", "plain_s3_get"),
+ ("S3EC AES_GCM", "aes_gcm", "s3ec_get_aes_gcm"),
+ ("S3EC KC_GCM", "kc_gcm", "s3ec_get_kc_gcm"),
+ ],
+ },
+ {
+ "id": "rt",
+ "title": "Roundtrip: S3EC vs Local Crypto + Plain S3",
+ "series": [
+ ("Local Crypto + Plain S3", "local", "local_crypto_roundtrip"),
+ ("S3EC AES_GCM", "aes_gcm", "s3ec_roundtrip_aes_gcm"),
+ ("S3EC KC_GCM", "kc_gcm", "s3ec_roundtrip_kc_gcm"),
+ ],
+ },
+ ]
+
+ for cdef in chart_defs:
+ # Bar chart using median
+ groups = []
+ for label, color_key, prefix in cdef["series"]:
+ vals = {}
+ for s in sizes:
+ r = _lookup(results, f"{prefix}_{s}mb", s)
+ if r:
+ vals[s] = _median(r["durations_s"])
+ groups.append({"label": label, "color": COLORS[color_key], "values": vals})
+ html_parts.append(
+ _bar_chart_svg(f"chart-{cdef['id']}", f"{cdef['title']} (Median)", groups, sizes)
+ )
+
+ # Histograms — one per payload size, stacked vertically
+ for s in sizes:
+ series_list = []
+ for label, color_key, prefix in cdef["series"]:
+ r = _lookup(results, f"{prefix}_{s}mb", s)
+ if r:
+ series_list.append(
+ {
+ "label": label,
+ "color": COLORS[color_key],
+ "durations": r["durations_s"],
+ }
+ )
+ if series_list:
+ html_parts.append(
+ _histogram_svg(
+ f"hist-{cdef['id']}-{s}mb",
+ f"{cdef['title']} — {s} MB Distribution",
+ series_list,
+ )
+ )
+
+ return "\n".join(html_parts)
+
+
+def _build_table(results):
+ """Build the full results table with median and p95."""
+ groups: dict[str, list[dict]] = {}
+ for r in results:
+ name = r["test"]
+ if "roundtrip" in name or "local_crypto" in name:
+ cat = "Roundtrip: S3EC vs Local Crypto + Plain S3"
+ elif "put" in name:
+ cat = "PutObject: Plain S3 vs S3EC"
+ elif "get" in name:
+ cat = "GetObject: Plain S3 vs S3EC"
+ else:
+ cat = "Other"
+ groups.setdefault(cat, []).append(r)
+
+ sections_html = ""
+ for cat, items in groups.items():
+ rows = ""
+ for r in sorted(items, key=lambda x: (x["size_mb"], x["test"])):
+ d = r["durations_s"]
+ med = _median(d)
+ p95 = _p95(d)
+ durations_str = ", ".join(_fmt(v) for v in d)
+ rows += f"""
+
+ | {r['test']} |
+ {r['size_mb']} MB |
+ {r['rounds']} |
+ {_fmt(med)} |
+ {_fmt(r['mean_s'])} |
+ {_fmt(p95)} |
+ {_fmt(r['min_s'])} |
+ {_fmt(r['max_s'])} |
+ {durations_str} |
+
"""
+
+ sections_html += f"""
+ {cat}
+
+
+
+ | Test | Size | Rounds |
+ Median | Mean | p95 |
+ Min | Max | All Durations |
+
+
+ {rows}
+
+
"""
+ return sections_html
+
+
+def generate_html(data: dict) -> str:
+ config = data["config"]
+ results = data["results"]
+ timestamp = data["timestamp"]
+ sizes = config["object_sizes_mb"]
+
+ visuals_html = _build_charts_and_histograms(results, sizes)
+ tables_html = _build_table(results)
+
+ return f"""
+
+
+
+S3EC Performance Report
+
+
+
+S3 Encryption Client — Performance Report
+
+ Generated: {timestamp}
+ Rounds per test: {config['num_rounds']} ·
+ Object sizes: {', '.join(str(s) + ' MB' for s in sizes)} ·
+ Bucket: {config['bucket']} · Region: {config['region']}
+
+
+
+{visuals_html}
+
+
+{tables_html}
+
+"""
+
+
+def main():
+ if not RESULTS_FILE.exists():
+ print(f"Results file not found: {RESULTS_FILE}", file=sys.stderr)
+ sys.exit(1)
+
+ with open(RESULTS_FILE) as f:
+ data = json.load(f)
+
+ html = generate_html(data)
+ OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
+ OUTPUT_FILE.write_text(html)
+ print(f"Report written to {OUTPUT_FILE}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/performance/test_perf_s3_encryption.py b/test/performance/test_perf_s3_encryption.py
new file mode 100644
index 00000000..590a0eb2
--- /dev/null
+++ b/test/performance/test_perf_s3_encryption.py
@@ -0,0 +1,277 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Performance tests comparing S3EC against plaintext S3 and local encryption + S3 upload.
+
+Each test runs multiple rounds with large objects to get a meaningful signal.
+To control for temporal network variation, all variants within a test are
+interleaved: round N of every variant runs back-to-back before moving to
+round N+1. This ensures each variant experiences the same network conditions.
+
+Results are collected via a module-scoped list and written to a JSON file
+that the HTML report generator consumes.
+"""
+
+import json
+import os
+import random
+import time
+from datetime import datetime
+
+import pytest
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+
+from s3_encryption.materials.materials import AlgorithmSuite, CommitmentPolicy
+
+from .conftest import BUCKET, KMS_KEY_ID, NUM_ROUNDS, OBJECT_SIZES_MB, REGION, _make_s3ec
+
+PERF_KEY_PREFIX = "perf-test/"
+RESULTS_FILE = os.environ.get("PERF_RESULTS_FILE", "perf-results/results.json")
+
+_results: list[dict] = []
+
+# Pre-generate payloads once at module level
+_PAYLOADS: dict[int, bytes] = {}
+_WARMUP_PAYLOAD = b"x" * 1024
+
+# Algorithm suite configs
+_AES_GCM = (
+ AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT,
+)
+_KC_GCM = (
+ AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT,
+)
+
+
+def _get_payload(size_mb: int) -> bytes:
+ if size_mb not in _PAYLOADS:
+ chunk = os.urandom(1024)
+ _PAYLOADS[size_mb] = (chunk * 1024 * size_mb)[: size_mb * 1024 * 1024]
+ return _PAYLOADS[size_mb]
+
+
+def _unique_key(prefix: str) -> str:
+ return PERF_KEY_PREFIX + prefix + datetime.now().strftime("%Y%m%d-%H%M%S-%f")
+
+
+def _record(test_name, size_mb, durations):
+ _results.append(
+ {
+ "test": test_name,
+ "size_mb": size_mb,
+ "rounds": len(durations),
+ "durations_s": durations,
+ "mean_s": sum(durations) / len(durations),
+ "min_s": min(durations),
+ "max_s": max(durations),
+ }
+ )
+
+
+def _warmup_connection(client):
+ """Warm up TCP/TLS connections with a tiny payload."""
+ key = _unique_key("warmup-conn-")
+ client.put_object(Bucket=BUCKET, Key=key, Body=_WARMUP_PAYLOAD)
+ resp = client.get_object(Bucket=BUCKET, Key=key)
+ resp["Body"].read()
+
+
+# ---------------------------------------------------------------------------
+# Interleaved put_object benchmark
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("size_mb", OBJECT_SIZES_MB)
+def test_put_interleaved(plain_s3, size_mb):
+ """Interleaved put_object: plain S3, S3EC AES_GCM, S3EC KC_GCM."""
+ payload = _get_payload(size_mb)
+
+ s3ec_aes = _make_s3ec(*_AES_GCM)
+ s3ec_kc = _make_s3ec(*_KC_GCM)
+
+ # Warm up all connections
+ _warmup_connection(plain_s3)
+ _warmup_connection(s3ec_aes)
+ _warmup_connection(s3ec_kc)
+
+ plain_d, aes_d, kc_d = [], [], []
+
+ # Define the three variants as callables
+ def run_plain():
+ key = _unique_key(f"plain-put-{size_mb}mb-")
+ t0 = time.perf_counter()
+ plain_s3.put_object(Bucket=BUCKET, Key=key, Body=payload)
+ return time.perf_counter() - t0
+
+ def run_aes():
+ key = _unique_key(f"s3ec-put-aes-{size_mb}mb-")
+ t0 = time.perf_counter()
+ s3ec_aes.put_object(Bucket=BUCKET, Key=key, Body=payload)
+ return time.perf_counter() - t0
+
+ def run_kc():
+ key = _unique_key(f"s3ec-put-kc-{size_mb}mb-")
+ t0 = time.perf_counter()
+ s3ec_kc.put_object(Bucket=BUCKET, Key=key, Body=payload)
+ return time.perf_counter() - t0
+
+ variants = [(run_plain, plain_d), (run_aes, aes_d), (run_kc, kc_d)]
+
+ for _ in range(NUM_ROUNDS):
+ # Shuffle order each round to eliminate positional bias
+ random.shuffle(variants)
+ for fn, collector in variants:
+ collector.append(fn())
+
+ _record(f"plain_s3_put_{size_mb}mb", size_mb, plain_d)
+ _record(f"s3ec_put_aes_gcm_{size_mb}mb", size_mb, aes_d)
+ _record(f"s3ec_put_kc_gcm_{size_mb}mb", size_mb, kc_d)
+
+
+# ---------------------------------------------------------------------------
+# Interleaved get_object benchmark
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("size_mb", OBJECT_SIZES_MB)
+def test_get_interleaved(plain_s3, size_mb):
+ """Interleaved get_object: plain S3, S3EC AES_GCM, S3EC KC_GCM."""
+ payload = _get_payload(size_mb)
+
+ s3ec_aes = _make_s3ec(*_AES_GCM)
+ s3ec_kc = _make_s3ec(*_KC_GCM)
+
+ # Upload source objects
+ plain_key = _unique_key(f"plain-get-src-{size_mb}mb-")
+ plain_s3.put_object(Bucket=BUCKET, Key=plain_key, Body=payload)
+
+ aes_key = _unique_key(f"s3ec-get-src-aes-{size_mb}mb-")
+ s3ec_aes.put_object(Bucket=BUCKET, Key=aes_key, Body=payload)
+
+ kc_key = _unique_key(f"s3ec-get-src-kc-{size_mb}mb-")
+ s3ec_kc.put_object(Bucket=BUCKET, Key=kc_key, Body=payload)
+
+ # Warm up all connections
+ _warmup_connection(plain_s3)
+ _warmup_connection(s3ec_aes)
+ _warmup_connection(s3ec_kc)
+
+ plain_d, aes_d, kc_d = [], [], []
+
+ def run_plain():
+ t0 = time.perf_counter()
+ resp = plain_s3.get_object(Bucket=BUCKET, Key=plain_key)
+ resp["Body"].read()
+ return time.perf_counter() - t0
+
+ def run_aes():
+ t0 = time.perf_counter()
+ resp = s3ec_aes.get_object(Bucket=BUCKET, Key=aes_key)
+ resp["Body"].read()
+ return time.perf_counter() - t0
+
+ def run_kc():
+ t0 = time.perf_counter()
+ resp = s3ec_kc.get_object(Bucket=BUCKET, Key=kc_key)
+ resp["Body"].read()
+ return time.perf_counter() - t0
+
+ variants = [(run_plain, plain_d), (run_aes, aes_d), (run_kc, kc_d)]
+
+ for _ in range(NUM_ROUNDS):
+ random.shuffle(variants)
+ for fn, collector in variants:
+ collector.append(fn())
+
+ _record(f"plain_s3_get_{size_mb}mb", size_mb, plain_d)
+ _record(f"s3ec_get_aes_gcm_{size_mb}mb", size_mb, aes_d)
+ _record(f"s3ec_get_kc_gcm_{size_mb}mb", size_mb, kc_d)
+
+
+# ---------------------------------------------------------------------------
+# Interleaved roundtrip benchmark
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("size_mb", OBJECT_SIZES_MB)
+def test_roundtrip_interleaved(plain_s3, kms_client, size_mb):
+ """Interleaved roundtrip: S3EC AES_GCM, S3EC KC_GCM, local crypto + plain S3."""
+ payload = _get_payload(size_mb)
+
+ s3ec_aes = _make_s3ec(*_AES_GCM)
+ s3ec_kc = _make_s3ec(*_KC_GCM)
+
+ # Warm up all connections
+ _warmup_connection(plain_s3)
+ _warmup_connection(s3ec_aes)
+ _warmup_connection(s3ec_kc)
+ kms_client.generate_data_key(KeyId=KMS_KEY_ID, KeySpec="AES_256")
+
+ aes_d, kc_d, local_d = [], [], []
+
+ def run_aes():
+ key = _unique_key(f"s3ec-rt-aes-{size_mb}mb-")
+ t0 = time.perf_counter()
+ s3ec_aes.put_object(Bucket=BUCKET, Key=key, Body=payload)
+ resp = s3ec_aes.get_object(Bucket=BUCKET, Key=key)
+ resp["Body"].read()
+ return time.perf_counter() - t0
+
+ def run_kc():
+ key = _unique_key(f"s3ec-rt-kc-{size_mb}mb-")
+ t0 = time.perf_counter()
+ s3ec_kc.put_object(Bucket=BUCKET, Key=key, Body=payload)
+ resp = s3ec_kc.get_object(Bucket=BUCKET, Key=key)
+ resp["Body"].read()
+ return time.perf_counter() - t0
+
+ def run_local():
+ key = _unique_key(f"local-rt-{size_mb}mb-")
+ t0 = time.perf_counter()
+ dk_resp = kms_client.generate_data_key(KeyId=KMS_KEY_ID, KeySpec="AES_256")
+ aesgcm = AESGCM(dk_resp["Plaintext"])
+ nonce = os.urandom(12)
+ ciphertext = aesgcm.encrypt(nonce, payload, None)
+ plain_s3.put_object(Bucket=BUCKET, Key=key, Body=nonce + ciphertext)
+ resp = plain_s3.get_object(Bucket=BUCKET, Key=key)
+ blob = resp["Body"].read()
+ aesgcm.decrypt(blob[:12], blob[12:], None)
+ return time.perf_counter() - t0
+
+ variants = [(run_aes, aes_d), (run_kc, kc_d), (run_local, local_d)]
+
+ for _ in range(NUM_ROUNDS):
+ random.shuffle(variants)
+ for fn, collector in variants:
+ collector.append(fn())
+
+ _record(f"s3ec_roundtrip_aes_gcm_{size_mb}mb", size_mb, aes_d)
+ _record(f"s3ec_roundtrip_kc_gcm_{size_mb}mb", size_mb, kc_d)
+ _record(f"local_crypto_roundtrip_{size_mb}mb", size_mb, local_d)
+
+
+# ---------------------------------------------------------------------------
+# Write results to JSON at end of module
+# ---------------------------------------------------------------------------
+
+
+def test_zz_write_results():
+ """Final test that writes collected results to a JSON file for the HTML report."""
+ os.makedirs(os.path.dirname(RESULTS_FILE) or ".", exist_ok=True)
+ with open(RESULTS_FILE, "w") as f:
+ json.dump(
+ {
+ "timestamp": datetime.now().isoformat(),
+ "config": {
+ "num_rounds": NUM_ROUNDS,
+ "object_sizes_mb": OBJECT_SIZES_MB,
+ "bucket": BUCKET,
+ "region": REGION,
+ },
+ "results": _results,
+ },
+ f,
+ indent=2,
+ )
+ print(f"\nPerformance results written to {RESULTS_FILE}")