diff --git a/pyproject.toml b/pyproject.toml index 459b7ff6..1c48c951 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.51.1" +version = "0.52.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/destination/__init__.py b/src/sap_cloud_sdk/destination/__init__.py index 6073a98e..223d9170 100644 --- a/src/sap_cloud_sdk/destination/__init__.py +++ b/src/sap_cloud_sdk/destination/__init__.py @@ -68,6 +68,7 @@ HttpError, DestinationOperationError, DestinationNotFoundError, + DestinationCertificateError, ) @@ -253,4 +254,5 @@ def create_certificate_client( "HttpError", "DestinationOperationError", "DestinationNotFoundError", + "DestinationCertificateError", ] diff --git a/src/sap_cloud_sdk/destination/_cert_loader.py b/src/sap_cloud_sdk/destination/_cert_loader.py new file mode 100644 index 00000000..3096bce9 --- /dev/null +++ b/src/sap_cloud_sdk/destination/_cert_loader.py @@ -0,0 +1,230 @@ +"""Client-certificate loading for mTLS destinations. + +Parses PEM and PKCS12 keystores from the Destination Service v2 certificate +payload and builds a stdlib ssl.SSLContext for mTLS. + +Supported formats (selected by the file extension of Certificate.name): + pem — combined PEM bundle (cert + optional chain + private key; key may be + encrypted via KeyStorePassword) + p12 — PKCS12 binary keystore (requires KeyStorePassword in practice) + pfx — PKCS12 binary keystore (alternate extension) + +""" + +from __future__ import annotations + +import base64 +import binascii +import os +import ssl +import tempfile +from typing import Optional + +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, +) +from cryptography.hazmat.primitives.serialization import pkcs12 + +from sap_cloud_sdk.destination._models import Authentication, Certificate, Destination +from sap_cloud_sdk.destination.exceptions import DestinationCertificateError + +_SUPPORTED_EXTENSIONS = frozenset({"pem", "p12", "pfx"}) + + +def build_client_cert_context(destination: Destination) -> Optional[ssl.SSLContext]: + """Return an mTLS SSL context for the destination, or None if not applicable. + + Returns None when: + - The destination does not use ClientCertificateAuthentication. + - The certificate list contains no PEM/PKCS12 entry and no KeyStoreLocation is set. + + Raises DestinationCertificateError when client-cert auth is required but no + usable certificate can be loaded (wrong format, malformed content, key mismatch). + """ + if not _is_client_certificate_auth(destination): + return None + + cert = _select_certificate(destination) + if cert is None: + raise DestinationCertificateError( + f"Destination '{destination.name}' uses ClientCertificateAuthentication " + "but no usable certificate is available in the destination's certificate list." + ) + + try: + return _load_cert_into_context(cert, destination) + except DestinationCertificateError: + raise + except Exception as e: + raise DestinationCertificateError( + f"Failed to load client certificate '{cert.name}': {e}" + ) from e + + +def _is_client_certificate_auth(destination: Destination) -> bool: + auth = destination.authentication + auth_value = getattr(auth, "value", auth) + return str(auth_value) == Authentication.CLIENT_CERTIFICATE_AUTHENTICATION.value + + +def _select_certificate(destination: Destination) -> Optional[Certificate]: + certs = destination.certificates + if not certs: + return None + + props = destination.properties or {} + ks_location = props.get("KeyStoreLocation") + + if ks_location: + for cert in certs: + if cert.name == ks_location: + ext = cert.name.rsplit(".", 1)[-1].lower() if "." in cert.name else "" + if ext not in _SUPPORTED_EXTENSIONS: + raise DestinationCertificateError( + f"Certificate '{cert.name}' has unsupported format '.{ext}'. " + f"Supported formats: {sorted(_SUPPORTED_EXTENSIONS)}. " + "JKS is not supported (Java-specific format)." + ) + return cert + return None + + for cert in certs: + ext = cert.name.rsplit(".", 1)[-1].lower() if "." in cert.name else "" + if ext in _SUPPORTED_EXTENSIONS: + return cert + + return None + + +def _load_cert_into_context( + cert: Certificate, destination: Destination +) -> ssl.SSLContext: + ext = cert.name.rsplit(".", 1)[-1].lower() if "." in cert.name else "" + password = _get_key_password(destination) + + if ext == "pem": + return _load_pem(cert.content, password, cert.name) + + if ext in ("p12", "pfx"): + return _load_pkcs12(cert.content, password, cert.name) + + raise DestinationCertificateError( + f"Certificate '{cert.name}' has unsupported format '.{ext}'. " + f"Supported: {sorted(_SUPPORTED_EXTENSIONS)}." + ) + + +def _load_pem(content: str, password: Optional[bytes], name: str) -> ssl.SSLContext: + pem = _decode_pem_bytes(content, name) + return _build_context(pem, password) + + +def _load_pkcs12(content: str, password: Optional[bytes], name: str) -> ssl.SSLContext: + try: + der = base64.b64decode(content) + except (binascii.Error, ValueError) as e: + raise DestinationCertificateError( + f"Certificate '{name}' content is not valid base64: {e}" + ) from e + + try: + private_key, leaf, extra_certs = pkcs12.load_key_and_certificates(der, password) + except Exception as e: + raise DestinationCertificateError( + f"Failed to load PKCS12 certificate '{name}': {e}" + ) from e + + if leaf is None or private_key is None: + raise DestinationCertificateError( + f"PKCS12 certificate '{name}' is missing a certificate or private key." + ) + + # PKCS12 gives us parsed objects (no file), so serialize leaf + chain + an + # unencrypted key into a single PEM bundle. The key is already decrypted by + # load_key_and_certificates, so no password is passed to _build_context. + key_pem = private_key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.PKCS8, + encryption_algorithm=NoEncryption(), + ) + leaf_pem = leaf.public_bytes(Encoding.PEM) + chain_pem = b"".join(c.public_bytes(Encoding.PEM) for c in (extra_certs or [])) + return _build_context(leaf_pem + chain_pem + key_pem, password=None) + + +def _build_context( + bundle_pem: bytes, + password: Optional[bytes], +) -> ssl.SSLContext: + # Write the combined PEM bundle (cert chain + key) to a temp file, + # load it into an SSLContext, then immediately delete. + str_password: Optional[str] = password.decode("utf-8") if password else None + + # Guard against an encrypted key with no password + if str_password is None and any( + marker in bundle_pem + for marker in ( + b"-----BEGIN ENCRYPTED PRIVATE KEY-----", + b"Proc-Type: 4,ENCRYPTED", + ) + ): + raise DestinationCertificateError( + "The private key is encrypted but no KeyStorePassword was provided." + ) + + fd, path = tempfile.mkstemp(suffix=".pem") + try: + with os.fdopen(fd, "wb") as fh: + fh.write(bundle_pem) + ctx = ssl.create_default_context() + ctx.load_cert_chain(path, password=str_password) + except ssl.SSLError as e: + if getattr(e, "reason", None) == "KEY_VALUES_MISMATCH": + raise DestinationCertificateError( + "The certificate and private key do not match." + ) from e + raise DestinationCertificateError( + "Could not load the client certificate/private key (possible causes: " + f"wrong password, malformed PEM, or a missing certificate/key block): {e}" + ) from e + except OSError as e: + raise DestinationCertificateError( + f"Could not load the client certificate/private key: {e}" + ) from e + finally: + os.unlink(path) + + return ctx + + +def _decode_pem_bytes(content: str, name: str) -> bytes: + if not content or not content.strip(): + raise DestinationCertificateError(f"Certificate '{name}' content is empty.") + + pem = content.strip() + + if "-----BEGIN " not in pem: + try: + decoded = base64.b64decode("".join(pem.split())) + except (binascii.Error, ValueError) as e: + raise DestinationCertificateError( + f"Certificate '{name}' content is not valid base64-encoded PEM: {e}" + ) from e + try: + pem = decoded.decode("utf-8") + except UnicodeDecodeError as e: + raise DestinationCertificateError( + f"Certificate '{name}' content is not valid UTF-8 PEM text." + ) from e + + return pem.encode("utf-8") + + +def _get_key_password(destination: Destination) -> Optional[bytes]: + props = destination.properties or {} + password = props.get("KeyStorePassword") + if password and password.strip(): + return password.encode("utf-8") + return None diff --git a/src/sap_cloud_sdk/destination/_destination_http_client.py b/src/sap_cloud_sdk/destination/_destination_http_client.py index 9e948486..f97de1a4 100644 --- a/src/sap_cloud_sdk/destination/_destination_http_client.py +++ b/src/sap_cloud_sdk/destination/_destination_http_client.py @@ -2,25 +2,44 @@ from __future__ import annotations +import ssl from typing import Any, Dict, Optional import requests from requests import Response +from requests.adapters import HTTPAdapter +from sap_cloud_sdk.destination._cert_loader import build_client_cert_context from sap_cloud_sdk.destination._models import Destination, DestinationType +class _ClientCertAdapter(HTTPAdapter): + """requests HTTPAdapter that injects a stdlib SSLContext for mTLS.""" + + def __init__(self, ssl_ctx: ssl.SSLContext, **kwargs: Any) -> None: + self._ssl_ctx = ssl_ctx + super().__init__(**kwargs) + + def init_poolmanager(self, *args: Any, **kwargs: Any) -> None: + kwargs["ssl_context"] = self._ssl_ctx + super().init_poolmanager(*args, **kwargs) + + def proxy_manager_for(self, *args: Any, **kwargs: Any) -> Any: + kwargs["ssl_context"] = self._ssl_ctx + return super().proxy_manager_for(*args, **kwargs) + + class DestinationHttpClient: """Wraps requests.Session to call the target system described by a Destination. Pre-bakes headers derived from the destination — ERP headers (sap-client, - sap-language), URL.headers.* properties, and auth tokens. + sap-language), URL.headers.* properties, and auth tokens. Certificates from the + destination's certificate list are mounted into the session. - Usage: + Use as a context manager to ensure the underlying session is closed: - dest = client.get_destination("my-erp") - http = DestinationHttpClient(dest) - response = http.request("GET", "/api/resource") + with DestinationHttpClient(dest) as http: + response = http.request("GET", "/api/resource") """ def __init__(self, destination: Destination) -> None: @@ -33,6 +52,10 @@ def __init__(self, destination: Destination) -> None: self._session.headers.update(destination.get_headers()) self._base_url = destination.url.rstrip("/") if destination.url else "" + ssl_ctx = build_client_cert_context(destination) + if ssl_ctx is not None: + self._session.mount("https://", _ClientCertAdapter(ssl_ctx)) + def request( self, method: str, @@ -65,3 +88,10 @@ def request( headers=headers, **kwargs, ) + + def __enter__(self) -> "DestinationHttpClient": + return self + + def __exit__(self, *exc: Any) -> bool: + self._session.close() + return False diff --git a/src/sap_cloud_sdk/destination/exceptions.py b/src/sap_cloud_sdk/destination/exceptions.py index 8f3278b5..7d1d36fe 100644 --- a/src/sap_cloud_sdk/destination/exceptions.py +++ b/src/sap_cloud_sdk/destination/exceptions.py @@ -49,3 +49,9 @@ class DestinationNotFoundError(DestinationOperationError): """Raised when a requested Destination is not found (HTTP 404).""" pass + + +class DestinationCertificateError(DestinationError): + """Raised when a client certificate cannot be loaded or wired into the HTTP session.""" + + pass diff --git a/src/sap_cloud_sdk/destination/user-guide.md b/src/sap_cloud_sdk/destination/user-guide.md index 5039d07c..e315cc01 100644 --- a/src/sap_cloud_sdk/destination/user-guide.md +++ b/src/sap_cloud_sdk/destination/user-guide.md @@ -433,6 +433,24 @@ http = DestinationHttpClient(dest) response = http.request("GET", "/api/resource") ``` +### Client-Certificate (mTLS) Authentication + +When a destination's `Authentication` is `ClientCertificateAuthentication`, `DestinationHttpClient` automatically configures the underlying session for mutual TLS. + +```python +from sap_cloud_sdk.destination import create_client, DestinationHttpClient + +client = create_client(instance="default") +dest = client.get_destination("my-mtls-target") + +with DestinationHttpClient(dest) as http: # mTLS is wired automatically + response = http.request("GET", "/api/resource") +``` + +- **`KeyStoreLocation`** destination property: selects a specific certificate by name when multiple are present. +- **`KeyStorePassword`** destination property: used to decrypt an encrypted private key. +- **Supported formats**: PEM (`.pem`) and PKCS12 (`.p12` / `.pfx`). + ### What headers are pre-baked When `DestinationHttpClient` is constructed, it reads the destination and pre-bakes the following headers into every request: @@ -905,6 +923,7 @@ Entries with a `"tenant"` field are treated as subscriber-specific. Entries with - `DestinationNotFoundError`: mapped from HTTP 404 where applicable - `DestinationOperationError`: general operation failures - `HttpError`: HTTP-related or local store read/write errors with `status_code` and `response_text` when applicable +- `DestinationCertificateError`: raised when a client certificate cannot be loaded or wired into the HTTP session (unsupported format, wrong/missing KeyStorePassword, malformed content, cert/key mismatch) ## Configuration diff --git a/tests/destination/integration/destination.feature b/tests/destination/integration/destination.feature index 81a17374..151d3b01 100644 --- a/tests/destination/integration/destination.feature +++ b/tests/destination/integration/destination.feature @@ -253,6 +253,27 @@ Feature: Destination Service Integration And I clean up the instance destination "test-v2-full-options" And I clean up the instance fragment "test-v2-full-fragment" + Scenario: DestinationHttpClient mounts mTLS adapter for PEM certificate with encrypted key + Given I have a subaccount destination with a generated encrypted PEM certificate named "test-mtls-pem" + When I fetch the destination "test-mtls-pem" using the v2 API at subaccount level + Then the DestinationHttpClient mounts a client certificate adapter + And I clean up the subaccount destination "test-mtls-pem" + And I clean up the subaccount certificate "test-mtls-pem.pem" + + Scenario: DestinationHttpClient mounts mTLS adapter for PKCS12 P12 certificate + Given I have a subaccount destination with a generated P12 certificate named "test-mtls-p12" + When I fetch the destination "test-mtls-p12" using the v2 API at subaccount level + Then the DestinationHttpClient mounts a client certificate adapter + And I clean up the subaccount destination "test-mtls-p12" + And I clean up the subaccount certificate "test-mtls-p12.p12" + + Scenario: DestinationHttpClient mounts mTLS adapter for PKCS12 PFX certificate + Given I have a subaccount destination with a generated PFX certificate named "test-mtls-pfx" + When I fetch the destination "test-mtls-pfx" using the v2 API at subaccount level + Then the DestinationHttpClient mounts a client certificate adapter + And I clean up the subaccount destination "test-mtls-pfx" + And I clean up the subaccount certificate "test-mtls-pfx.pfx" + Scenario: DestinationHttpClient sends an authenticated request using token fetched from BTP Given I have a destination named "sdk-test-http-client" of type "HTTP" And the destination has URL "https://httpbin.org" diff --git a/tests/destination/integration/test_destination_bdd.py b/tests/destination/integration/test_destination_bdd.py index 3670c42f..58ceea2e 100644 --- a/tests/destination/integration/test_destination_bdd.py +++ b/tests/destination/integration/test_destination_bdd.py @@ -1589,6 +1589,148 @@ def certificate_should_have_label(context, key, value): ), f"Expected label key='{key}' value='{value}' in {context.retrieved_labels}" +# ==================== MTLS / CLIENT CERTIFICATE STEPS ==================== + +def _generate_encrypted_pem() -> tuple[str, str]: + """Return (base64-encoded combined PEM, password) for an encrypted-key PEM cert.""" + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + from datetime import datetime, timedelta, timezone + import base64 + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test-mtls")]) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(timezone.utc)) + .not_valid_after(datetime.now(timezone.utc) + timedelta(days=365)) + .sign(key, hashes.SHA256()) + ) + password = "testpassword" + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(password.encode()), + ) + cert_pem = cert.public_bytes(serialization.Encoding.PEM) + combined = base64.b64encode(key_pem + cert_pem).decode() + return combined, password + + +def _generate_pkcs12(extension: str) -> tuple[str, str]: + """Return (base64-encoded PKCS12 bytes, password) for a P12/PFX cert.""" + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives.serialization import pkcs12 + from cryptography.x509.oid import NameOID + from datetime import datetime, timedelta, timezone + import base64 + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"test-mtls-{extension}")]) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(timezone.utc)) + .not_valid_after(datetime.now(timezone.utc) + timedelta(days=365)) + .sign(key, hashes.SHA256()) + ) + password = "testpassword" + p12_bytes = pkcs12.serialize_key_and_certificates( + name=b"test-mtls", + key=key, + cert=cert, + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(password.encode()), + ) + return base64.b64encode(p12_bytes).decode(), password + + +def _create_mtls_destination_and_cert( + context, + destination_client, + certificate_client, + name: str, + cert_filename: str, + content: str, + password: str, +) -> None: + """Upload a certificate and create a matching ClientCertificateAuthentication destination.""" + cert = Certificate(name=cert_filename, content=content) + certificate_client.create_certificate(cert, level=Level.SUB_ACCOUNT) + context.cleanup_certificates.append((cert_filename, Level.SUB_ACCOUNT, None)) + + dest = Destination.from_dict({ + "Name": name, + "Type": "HTTP", + "URL": "https://httpbin.org", + "Authentication": "ClientCertificateAuthentication", + "KeyStore.Source": "DestinationService", + "KeyStoreLocation": cert_filename, + "KeyStorePassword": password, + }) + destination_client.create_destination(dest, level=Level.SUB_ACCOUNT) + context.cleanup_destinations.append((name, Level.SUB_ACCOUNT, None)) + context.destination = dest + + +@given(parsers.parse('I have a subaccount destination with a generated encrypted PEM certificate named "{name}"')) +def have_mtls_pem_destination(context, destination_client, certificate_client, name): + content, password = _generate_encrypted_pem() + _create_mtls_destination_and_cert( + context, destination_client, certificate_client, + name=name, cert_filename=f"{name}.pem", content=content, password=password, + ) + + +@given(parsers.parse('I have a subaccount destination with a generated P12 certificate named "{name}"')) +def have_mtls_p12_destination(context, destination_client, certificate_client, name): + content, password = _generate_pkcs12("p12") + _create_mtls_destination_and_cert( + context, destination_client, certificate_client, + name=name, cert_filename=f"{name}.p12", content=content, password=password, + ) + + +@given(parsers.parse('I have a subaccount destination with a generated PFX certificate named "{name}"')) +def have_mtls_pfx_destination(context, destination_client, certificate_client, name): + content, password = _generate_pkcs12("pfx") + _create_mtls_destination_and_cert( + context, destination_client, certificate_client, + name=name, cert_filename=f"{name}.pfx", content=content, password=password, + ) + + +@when(parsers.parse('I fetch the destination "{name}" using the v2 API at subaccount level')) +def fetch_destination_v2_subaccount(context, destination_client, name): + from sap_cloud_sdk.destination._models import ConsumptionLevel + context.retrieved_destination = destination_client.get_destination( + name, level=ConsumptionLevel.PROVIDER_SUBACCOUNT + ) + assert context.retrieved_destination is not None, f"Destination '{name}' not found via v2 API" + + +@then("the DestinationHttpClient mounts a client certificate adapter") +def assert_client_cert_adapter_mounted(context): + from sap_cloud_sdk.destination._destination_http_client import _ClientCertAdapter + with DestinationHttpClient(context.retrieved_destination) as http: + adapter = http._session.get_adapter("https://example.com") + assert isinstance(adapter, _ClientCertAdapter), ( + f"Expected _ClientCertAdapter but got {type(adapter).__name__}. " + "The SDK is not applying the client certificate to the HTTP session." + ) + + # ==================== DESTINATION HTTP CLIENT STEPS ==================== @given("the destination has OAuth2 credentials from environment") diff --git a/tests/destination/unit/test_cert_loader.py b/tests/destination/unit/test_cert_loader.py new file mode 100644 index 00000000..1cc3913c --- /dev/null +++ b/tests/destination/unit/test_cert_loader.py @@ -0,0 +1,343 @@ +"""Unit tests for build_client_cert_context (mTLS client-certificate loader).""" + +from __future__ import annotations + +import base64 +import ssl +from datetime import datetime, timedelta, timezone + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import ( + BestAvailableEncryption, + Encoding, + NoEncryption, + PrivateFormat, +) +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import NameOID + +from sap_cloud_sdk.destination._cert_loader import build_client_cert_context +from sap_cloud_sdk.destination._models import Destination +from sap_cloud_sdk.destination.exceptions import DestinationCertificateError + + +# --------------------------------------------------------------------------- +# Module-scoped key fixtures (RSA keygen is expensive — reuse across tests) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def rsa_key_a(): + """Generate RSA key A once for the entire module.""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(scope="module") +def rsa_key_b(): + """Generate RSA key B once for the entire module.""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +# --------------------------------------------------------------------------- +# Module-level helper functions +# --------------------------------------------------------------------------- + + +def _self_signed(key) -> x509.Certificate: + """Build a minimal self-signed certificate for the given key.""" + subject = issuer = x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, "test.example.com")] + ) + return ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(timezone.utc)) + .not_valid_after(datetime.now(timezone.utc) + timedelta(days=365)) + .sign(key, hashes.SHA256()) + ) + + +def _pem_bundle(cert, key, password: bytes | None = None) -> str: + """Return a PEM string: cert block + private key block (PKCS8). + + If password is given the key is encrypted with BestAvailableEncryption, + otherwise NoEncryption is used. + """ + enc_alg = BestAvailableEncryption(password) if password else NoEncryption() + key_pem = key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.PKCS8, + encryption_algorithm=enc_alg, + ) + cert_pem = cert.public_bytes(Encoding.PEM) + return (cert_pem + key_pem).decode("utf-8") + + +def _pkcs12_bytes(cert, key, password: bytes | None) -> str: + """Return base64-encoded PKCS12 bytes for the given cert/key pair.""" + enc_alg = BestAvailableEncryption(password) if password else NoEncryption() + der = pkcs12.serialize_key_and_certificates( + name=b"x", + key=key, + cert=cert, + cas=None, + encryption_algorithm=enc_alg, + ) + return base64.b64encode(der).decode("utf-8") + + +def _dest_with_cert( + name: str, + content: str, + *, + ks_location: str | None = None, + ks_password: str | None = None, +) -> Destination: + """Build a ClientCertificateAuthentication destination with one certificate. + + include_runtime_data=True is required or the certificates list is dropped. + """ + d: dict = { + "Name": "d", + "Type": "HTTP", + "URL": "https://example.com", + "Authentication": "ClientCertificateAuthentication", + "certificates": [{"Name": name, "Content": content}], + } + if ks_location is not None: + d["KeyStoreLocation"] = ks_location + if ks_password is not None: + d["KeyStorePassword"] = ks_password + return Destination.from_dict(d, include_runtime_data=True) + + +# --------------------------------------------------------------------------- +# TestSelection — certificate selection logic +# --------------------------------------------------------------------------- + + +class TestSelection: + """Tests for how build_client_cert_context selects (or skips) a certificate.""" + + def test_non_client_cert_auth_returns_none(self): + """Non-ClientCertificateAuthentication destinations return None.""" + dest = Destination.from_dict( + { + "Name": "d", + "Type": "HTTP", + "URL": "https://example.com", + "Authentication": "NoAuthentication", + } + ) + assert build_client_cert_context(dest) is None + + def test_client_cert_auth_empty_certificates_raises(self): + """ClientCertificateAuthentication with no certificates raises.""" + dest = Destination.from_dict( + { + "Name": "d", + "Type": "HTTP", + "URL": "https://example.com", + "Authentication": "ClientCertificateAuthentication", + "certificates": [], + }, + include_runtime_data=True, + ) + with pytest.raises(DestinationCertificateError, match="no usable certificate"): + build_client_cert_context(dest) + + def test_jks_only_cert_raises_unsupported_format(self): + """A JKS-only certificate list raises DestinationCertificateError.""" + dest = Destination.from_dict( + { + "Name": "d", + "Type": "HTTP", + "URL": "https://example.com", + "Authentication": "ClientCertificateAuthentication", + "certificates": [{"Name": "keystore.jks", "Content": "anycontent"}], + }, + include_runtime_data=True, + ) + with pytest.raises(DestinationCertificateError, match="no usable certificate"): + build_client_cert_context(dest) + + def test_ks_location_selects_specific_cert(self, rsa_key_a, rsa_key_b): + """KeyStoreLocation picks the named cert when multiple certs are present.""" + cert_a = _self_signed(rsa_key_a) + cert_b = _self_signed(rsa_key_b) + bundle_a = _pem_bundle(cert_a, rsa_key_a) + bundle_b = _pem_bundle(cert_b, rsa_key_b) + + dest = Destination.from_dict( + { + "Name": "d", + "Type": "HTTP", + "URL": "https://example.com", + "Authentication": "ClientCertificateAuthentication", + "KeyStoreLocation": "cert-b.pem", + "certificates": [ + {"Name": "cert-a.pem", "Content": bundle_a}, + {"Name": "cert-b.pem", "Content": bundle_b}, + ], + }, + include_runtime_data=True, + ) + ctx = build_client_cert_context(dest) + assert isinstance(ctx, ssl.SSLContext) + + +# --------------------------------------------------------------------------- +# TestPemHappy — successful PEM loading +# --------------------------------------------------------------------------- + + +class TestPemHappy: + """Tests for successful PEM keystore loading paths.""" + + def test_unencrypted_pem_returns_ssl_context(self, rsa_key_a): + """An unencrypted PEM bundle returns an SSLContext with secure defaults.""" + cert = _self_signed(rsa_key_a) + bundle = _pem_bundle(cert, rsa_key_a) + dest = _dest_with_cert("client.pem", bundle) + + ctx = build_client_cert_context(dest) + + assert isinstance(ctx, ssl.SSLContext) + assert ctx.verify_mode == ssl.CERT_REQUIRED + assert ctx.check_hostname is True + + def test_encrypted_pem_correct_password_returns_ssl_context(self, rsa_key_a): + """An encrypted PEM key with the correct KeyStorePassword returns an SSLContext.""" + cert = _self_signed(rsa_key_a) + bundle = _pem_bundle(cert, rsa_key_a, password=b"s3cr3t") + dest = _dest_with_cert("client.pem", bundle, ks_password="s3cr3t") + + ctx = build_client_cert_context(dest) + + assert isinstance(ctx, ssl.SSLContext) + + def test_base64_wrapped_pem_is_decoded(self, rsa_key_a): + """A base64-encoded PEM bundle (no BEGIN header visible) is decoded transparently.""" + cert = _self_signed(rsa_key_a) + bundle = _pem_bundle(cert, rsa_key_a) + # Wrap the whole PEM string in base64 — exercises _decode_pem_bytes + b64_content = base64.b64encode(bundle.encode()).decode("utf-8") + dest = _dest_with_cert("client.pem", b64_content) + + ctx = build_client_cert_context(dest) + + assert isinstance(ctx, ssl.SSLContext) + + def test_chain_cert_accepted(self, rsa_key_a, rsa_key_b): + """A PEM bundle with leaf + intermediate cert + key is accepted.""" + leaf_cert = _self_signed(rsa_key_a) + intermediate_cert = _self_signed(rsa_key_b) # acts as chain material + + leaf_pem = leaf_cert.public_bytes(Encoding.PEM) + intermediate_pem = intermediate_cert.public_bytes(Encoding.PEM) + key_pem = rsa_key_a.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.PKCS8, + encryption_algorithm=NoEncryption(), + ) + # leaf + chain cert + key — matches the pattern ssl.load_cert_chain expects + bundle = (leaf_pem + intermediate_pem + key_pem).decode("utf-8") + dest = _dest_with_cert("client.pem", bundle) + + ctx = build_client_cert_context(dest) + + assert isinstance(ctx, ssl.SSLContext) + + +# --------------------------------------------------------------------------- +# TestPkcs12Happy — successful PKCS12 loading +# --------------------------------------------------------------------------- + + +class TestPkcs12Happy: + """Tests for successful PKCS12 keystore loading paths.""" + + def test_p12_with_password_returns_ssl_context(self, rsa_key_a): + """A PKCS12 (.p12) keystore with a password loads successfully.""" + cert = _self_signed(rsa_key_a) + p12_b64 = _pkcs12_bytes(cert, rsa_key_a, password=b"p12pass") + dest = _dest_with_cert("client.p12", p12_b64, ks_password="p12pass") + + ctx = build_client_cert_context(dest) + + assert isinstance(ctx, ssl.SSLContext) + + def test_pfx_extension_also_works(self, rsa_key_a): + """A PKCS12 keystore with .pfx extension is handled identically to .p12.""" + cert = _self_signed(rsa_key_a) + p12_b64 = _pkcs12_bytes(cert, rsa_key_a, password=b"pfxpass") + dest = _dest_with_cert("client.pfx", p12_b64, ks_password="pfxpass") + + ctx = build_client_cert_context(dest) + + assert isinstance(ctx, ssl.SSLContext) + + +# --------------------------------------------------------------------------- +# TestFailures — error paths +# --------------------------------------------------------------------------- + + +class TestFailures: + """Tests for DestinationCertificateError error paths.""" + + def test_malformed_pem_raises(self): + """Content that is neither valid PEM nor valid base64 raises DestinationCertificateError.""" + dest = _dest_with_cert("client.pem", "not-a-cert!!!") + with pytest.raises(DestinationCertificateError): + build_client_cert_context(dest) + + def test_encrypted_key_wrong_password_raises(self, rsa_key_a): + """An encrypted PEM key with the wrong password raises DestinationCertificateError.""" + cert = _self_signed(rsa_key_a) + bundle = _pem_bundle(cert, rsa_key_a, password=b"correct") + dest = _dest_with_cert("client.pem", bundle, ks_password="wrong") + + with pytest.raises(DestinationCertificateError): + build_client_cert_context(dest) + + def test_encrypted_key_missing_password_raises_and_does_not_hang(self, rsa_key_a): + """An encrypted PEM key with no KeyStorePassword raises immediately (no interactive prompt).""" + cert = _self_signed(rsa_key_a) + bundle = _pem_bundle(cert, rsa_key_a, password=b"somepass") + # No ks_password — the loader guards against the interactive-prompt footgun + dest = _dest_with_cert("client.pem", bundle) + + with pytest.raises(DestinationCertificateError, match="KeyStorePassword"): + build_client_cert_context(dest) + + def test_cert_key_mismatch_raises(self, rsa_key_a, rsa_key_b): + """A bundle where cert and private key belong to different keys raises DestinationCertificateError.""" + cert_a = _self_signed(rsa_key_a) + # cert signed by key_a, but private key is key_b — they don't match + cert_pem = cert_a.public_bytes(Encoding.PEM) + key_b_pem = rsa_key_b.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.PKCS8, + encryption_algorithm=NoEncryption(), + ) + bundle = (cert_pem + key_b_pem).decode("utf-8") + dest = _dest_with_cert("client.pem", bundle) + + with pytest.raises(DestinationCertificateError, match="do not match"): + build_client_cert_context(dest) + + def test_cert_only_no_key_raises(self, rsa_key_a): + """A PEM bundle with only a cert block (no private key) raises DestinationCertificateError.""" + cert = _self_signed(rsa_key_a) + cert_only = cert.public_bytes(Encoding.PEM).decode("utf-8") + dest = _dest_with_cert("client.pem", cert_only) + + with pytest.raises(DestinationCertificateError): + build_client_cert_context(dest) diff --git a/tests/destination/unit/test_destination_http_client.py b/tests/destination/unit/test_destination_http_client.py index e0d8ea84..529e21e4 100644 --- a/tests/destination/unit/test_destination_http_client.py +++ b/tests/destination/unit/test_destination_http_client.py @@ -1,11 +1,16 @@ """Unit tests for DestinationHttpClient.""" +import ssl from unittest.mock import MagicMock, patch import pytest -from sap_cloud_sdk.destination._destination_http_client import DestinationHttpClient +from sap_cloud_sdk.destination._destination_http_client import ( + _ClientCertAdapter, + DestinationHttpClient, +) from sap_cloud_sdk.destination._models import AuthToken, Destination +from sap_cloud_sdk.destination.exceptions import DestinationCertificateError def _dest(**kwargs) -> Destination: @@ -15,7 +20,9 @@ def _dest(**kwargs) -> Destination: def _auth_token(key: str, value: str) -> AuthToken: - return AuthToken(type="Bearer", value="raw", http_header={"key": key, "value": value}) + return AuthToken( + type="Bearer", value="raw", http_header={"key": key, "value": value} + ) class TestDestinationHttpClientInit: @@ -77,30 +84,71 @@ def setup_method(self): self.mock_response = MagicMock() def test_constructs_full_url(self): - with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + with patch.object( + self.client._session, "request", return_value=self.mock_response + ) as mock_req: self.client.request("GET", "/api/v1/users") assert mock_req.call_args[1]["url"] == "https://example.com/api/v1/users" def test_uppercases_method(self): - with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + with patch.object( + self.client._session, "request", return_value=self.mock_response + ) as mock_req: self.client.request("get", "/resource") assert mock_req.call_args[1]["method"] == "GET" def test_passes_params(self): - with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + with patch.object( + self.client._session, "request", return_value=self.mock_response + ) as mock_req: self.client.request("GET", "/resource", params={"$top": "10"}) assert mock_req.call_args[1]["params"] == {"$top": "10"} def test_passes_json_body(self): - with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + with patch.object( + self.client._session, "request", return_value=self.mock_response + ) as mock_req: self.client.request("POST", "/resource", json={"key": "value"}) assert mock_req.call_args[1]["json"] == {"key": "value"} def test_passes_extra_headers(self): - with patch.object(self.client._session, "request", return_value=self.mock_response) as mock_req: + with patch.object( + self.client._session, "request", return_value=self.mock_response + ) as mock_req: self.client.request("GET", "/resource", headers={"X-Custom": "yes"}) assert mock_req.call_args[1]["headers"] == {"X-Custom": "yes"} def test_returns_response(self): - with patch.object(self.client._session, "request", return_value=self.mock_response): + with patch.object( + self.client._session, "request", return_value=self.mock_response + ): assert self.client.request("GET", "/resource") is self.mock_response + + +class TestDestinationHttpClientCert: + """Tests that DestinationHttpClient wires the mTLS cert adapter correctly.""" + + _PATCH_TARGET = ( + "sap_cloud_sdk.destination._destination_http_client.build_client_cert_context" + ) + + def test_ssl_context_mounts_client_cert_adapter(self): + """When build_client_cert_context returns a context, https:// uses _ClientCertAdapter.""" + ssl_ctx = ssl.create_default_context() + with patch(self._PATCH_TARGET, return_value=ssl_ctx): + client = DestinationHttpClient(_dest()) + assert isinstance(client._session.get_adapter("https://"), _ClientCertAdapter) + + def test_none_context_does_not_mount_client_cert_adapter(self): + """When build_client_cert_context returns None, https:// uses the default requests adapter.""" + with patch(self._PATCH_TARGET, return_value=None): + client = DestinationHttpClient(_dest()) + assert not isinstance( + client._session.get_adapter("https://"), _ClientCertAdapter + ) + + def test_cert_load_error_propagates(self): + """When build_client_cert_context raises DestinationCertificateError, the constructor propagates it.""" + with patch(self._PATCH_TARGET, side_effect=DestinationCertificateError("boom")): + with pytest.raises(DestinationCertificateError): + DestinationHttpClient(_dest()) diff --git a/uv.lock b/uv.lock index 3a4bed22..e98ca5b6 100644 --- a/uv.lock +++ b/uv.lock @@ -1186,9 +1186,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/39/82/5c873dbb4fb001d22fbbd50e80d4c1b0181ddae106856132160f84b94e88/greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c", size = 633329, upload-time = "2026-08-10T14:30:06.062Z" }, { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, - { url = "https://files.pythonhosted.org/packages/70/12/f7df98e72a8eb4a7edfec5a08d6d1a4ab53a52c95ba0b2ea6c10b8dd9bd0/greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f", size = 428145, upload-time = "2026-08-10T14:30:01.071Z" }, { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, @@ -1196,9 +1194,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, - { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" }, { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, - { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" }, { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, @@ -1206,9 +1202,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, @@ -1216,9 +1210,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, - { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, @@ -1226,18 +1218,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, - { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, - { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, - { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, @@ -1245,9 +1233,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, @@ -3328,15 +3314,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] -[[package]] -name = "platformdirs" -version = "4.11.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, -] - [[package]] name = "pendulum" version = "3.2.0" @@ -3397,6 +3374,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, ] +[[package]] +name = "platformdirs" +version = "4.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -4299,7 +4285,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.51.1" +version = "0.52.0" source = { editable = "." } dependencies = [ { name = "cryptography" },