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'', + f'{title}', + ] + lx = label_col_w + for g in groups: + svg.append(f'') + svg.append(f'{g["label"]}') + lx += len(g["label"]) * 7 + 30 + + y = 58 + for size in sizes: + svg.append( + f'{size} MB' + ) + for i, g in enumerate(groups): + val = g["values"].get(size, 0) + bw = max(2, (val / max_val) * chart_w) + by = y + i * (bar_h + gap) + svg.append( + f'' + ) + svg.append( + f'{_fmt(val)}' + ) + y += block_h + svg.append("") + 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'', + f'{title}', + ] + + # Legend + lx = margin_l + for s in series_list: + svg.append(f'') + svg.append(f'{s["label"]}') + lx += len(s["label"]) * 6 + 24 + + # Axes + ax_y = margin_t + plot_h + svg.append( + f'' + ) + svg.append( + f'' + ) + + # X-axis labels (5 ticks) + for i in range(6): + val = lo + (hi - lo) * i / 5 + x = margin_l + plot_w * i / 5 + svg.append( + f'{_fmt(val)}' + ) + + # Y-axis labels + for i in range(4): + cnt = int(global_max_count * i / 3) + y_pos = ax_y - plot_h * i / 3 + svg.append( + f'{cnt}' + ) + + # Draw bars for each series (slightly offset for overlap visibility) + bar_px = plot_w / n_bins + n_series = len(series_list) + sub_w = bar_px / n_series if n_series > 1 else bar_px * 0.8 + + for si, (s, counts) in enumerate(zip(series_list, histograms)): + for bi, cnt in enumerate(counts): + if cnt == 0: + continue + bh = (cnt / global_max_count) * plot_h + bx = margin_l + bi * bar_px + si * sub_w + by = ax_y - bh + svg.append( + f'' + ) + + svg.append("") + 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}

+ + + + + + + + + {rows} + +
TestSizeRoundsMedianMeanp95MinMaxAll Durations
""" + 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}")