diff --git a/CMakeLists.txt b/CMakeLists.txt index 6498a09e..908836fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,10 +29,16 @@ function(get_python_version) set(PYTHON3_EXE "python3") endif() - execute_process(COMMAND ${PYTHON3_EXE} -c "import platform;print(platform.python_version())" - OUTPUT_VARIABLE local_python_version) - string(STRIP "${local_python_version}" PYTHON_VERSION) - set(LOCAL_PYTHON_VERSION "${PYTHON_VERSION}" PARENT_SCOPE) + # Be careful with whitespace + execute_process(COMMAND ${PYTHON3_EXE} -c + "import platform +import re +version = platform.python_version() +match = re.search(r'\\d+\\.\\d+\\.\\d+', version) +print(match.group())" + OUTPUT_VARIABLE local_python_version + OUTPUT_STRIP_TRAILING_WHITESPACE) + set(LOCAL_PYTHON_VERSION "${local_python_version}" PARENT_SCOPE) endfunction() if(WIN32) @@ -53,7 +59,7 @@ message(STATUS "LOCAL_PYTHON_VERSION=${LOCAL_PYTHON_VERSION}") if(LOCAL_PYTHON_VERSION) set(Python3_FIND_VIRTUALENV FIRST) - message("finding python version ${LOCAL_PYTHON_VERSION}") + message(STATUS "Attempting to find python version: ${LOCAL_PYTHON_VERSION}") find_package(Python3 ${LOCAL_PYTHON_VERSION} EXACT COMPONENTS Interpreter Development.Module) else() find_package(Python3 COMPONENTS Interpreter Development.Module) @@ -99,7 +105,7 @@ if(NOT USE_STATIC_BORINGSSL) if(NOT OPENSSL_VERSION) message(STATUS "No OpenSSL version set...cannot attempt to download.") else() - # default version is currently 1.1.1g (see setup.py) + # see pycbc_build_setup.py for default OpenSSL versions FetchContent_Declare(openssl URL https://github.com/python/cpython-bin-deps/archive/openssl-bin-${OPENSSL_VERSION}.zip) message(STATUS "fetching OpenSSL version: ${OPENSSL_VERSION}") @@ -112,8 +118,15 @@ if(NOT USE_STATIC_BORINGSSL) find_program(BREW_COMMAND brew) if(BREW_COMMAND) message(STATUS "brew command: ${BREW_COMMAND}") + set(BREW_OPENSSL "openssl@3") + if(USE_OPENSSLV1_1) + set(BREW_OPENSSL "openssl@1.1") + message(STATUS "Using OpenSSL v1.1 from homebrew") + else() + message(STATUS "Using OpenSSL v3 from homebrew") + endif() execute_process( - COMMAND ${BREW_COMMAND} --prefix openssl@1.1 + COMMAND ${BREW_COMMAND} --prefix ${BREW_OPENSSL} OUTPUT_VARIABLE BREW_OPENSSL_PREFIX RESULT_VARIABLE BREW_RESULT OUTPUT_STRIP_TRAILING_WHITESPACE) @@ -132,7 +145,7 @@ if(NOT USE_STATIC_BORINGSSL) endif() message(STATUS "Adding ${OPENSSL_INCLUDE_DIR} to include dirs...") - include_directories(${OPENSSL_INCLUDE_DIR}) + include_directories(SYSTEM ${OPENSSL_INCLUDE_DIR}) else() set(COUCHBASE_CXX_CLIENT_POST_LINKED_OPENSSL OFF diff --git a/couchbase/encryption/__init__.py b/couchbase/encryption/__init__.py index ee7528b4..80b59a4c 100644 --- a/couchbase/encryption/__init__.py +++ b/couchbase/encryption/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2016-2022. Couchbase, Inc. +# Copyright 2016-2025. Couchbase, Inc. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License") @@ -14,8 +14,10 @@ # limitations under the License. from .crypto_manager import CryptoManager # noqa: F401 -from .decrypter import Decrypter # noqa: F401 -from .encrypter import Encrypter # noqa: F401 from .encryption_result import EncryptionResult # noqa: F401 from .key import Key # noqa: F401 from .keyring import Keyring # noqa: F401 + +# import Encrypter/Decrypter last to avoid circular import +from .decrypter import Decrypter # nopep8 # isort:skip # noqa: F401 +from .encrypter import Encrypter # nopep8 # isort:skip # noqa: F401 diff --git a/couchbase/encryption/crypto_manager.py b/couchbase/encryption/crypto_manager.py index 7daa1fd4..26e3a22d 100644 --- a/couchbase/encryption/crypto_manager.py +++ b/couchbase/encryption/crypto_manager.py @@ -1,4 +1,4 @@ -# Copyright 2016-2022. Couchbase, Inc. +# Copyright 2016-2025. Couchbase, Inc. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License") @@ -14,7 +14,9 @@ # limitations under the License. from abc import ABC, abstractmethod -from typing import Optional, Union +from typing import (Any, + Optional, + Union) class CryptoManager(ABC): @@ -22,13 +24,10 @@ class CryptoManager(ABC): """ - _DEFAULT_ENCRYPTER_ALIAS = "__DEFAULT__" + _DEFAULT_ENCRYPTER_ALIAS = '__DEFAULT__' @abstractmethod - def encrypt(self, - plaintext, # type: Union[str, bytes, bytearray] - encrypter_alias=None, # type: Optional[str] - ) -> dict: + def encrypt(self, plaintext: Union[str, bytes, bytearray], encrypter_alias: Optional[str] = None) -> dict[str, Any]: """Encrypts the given plaintext using the given encrypter alias. Args: @@ -36,21 +35,18 @@ def encrypt(self, encrypter_alias (str, optional): Alias of encrypter to use, if None, default alias is used. Returns: - Dict: A :class:`~couchbase.encryption.EncryptionResult` as a dict + dict: A :class:`~couchbase.encryption.EncryptionResult` as a dict Raises: :class:`~couchbase.exceptions.EncryptionFailureException` """ - pass @abstractmethod - def decrypt(self, - encrypted, # type: dict - ) -> bytes: + def decrypt(self, encrypted: dict[str, Any]) -> bytes: """Decrypts the given encrypted result based on the 'alg' key in the encrypted result. Args: - encrypted (Dict): A dict containing encryption information, must have an 'alg' key. + encrypted (dict[str, Any]): A dict containing encryption information, must have an 'alg' key. Returns: bytes: A decrypted result based on the given encrypted input. @@ -59,12 +55,9 @@ def decrypt(self, :class:`~couchbase.exceptions.DecryptionFailureException` """ - pass @abstractmethod - def mangle(self, - field_name, # type: str - ) -> str: + def mangle(self, field_name: str) -> str: """Mangles provided JSON field name. Args: @@ -73,12 +66,9 @@ def mangle(self, Returns: str: The mangled field name. """ - pass @abstractmethod - def demangle(self, - field_name, # type: str - ) -> str: + def demangle(self, field_name: str) -> str: """Demangles provided JSON field name. Args: @@ -87,12 +77,9 @@ def demangle(self, Returns: str: The demangled field name. """ - pass @abstractmethod - def is_mangled(self, - field_name, # type: str - ) -> bool: + def is_mangled(self, field_name: str) -> bool: """Checks if provided JSON field name has been mangled. Args: @@ -102,4 +89,3 @@ def is_mangled(self, bool: True if the field is mangled, False otherwise. """ - pass diff --git a/couchbase/encryption/decrypter.py b/couchbase/encryption/decrypter.py index d15b2a81..94ac6996 100644 --- a/couchbase/encryption/decrypter.py +++ b/couchbase/encryption/decrypter.py @@ -1,4 +1,4 @@ -# Copyright 2016-2022. Couchbase, Inc. +# Copyright 2016-2025. Couchbase, Inc. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License") @@ -14,10 +14,9 @@ # limitations under the License. from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Optional +from typing import Optional -if TYPE_CHECKING: - from couchbase.encryption import EncryptionResult, Keyring +from couchbase.encryption import EncryptionResult, Keyring class Decrypter(ABC): @@ -25,15 +24,12 @@ class Decrypter(ABC): """ - def __init__(self, - keyring, # type: Keyring - alg=None, # type: Optional[str] - ): + def __init__(self, keyring: Keyring, alg: Optional[str] = None) -> None: self._keyring = keyring self._alg = alg @property - def keyring(self): + def keyring(self) -> Keyring: return self._keyring def algorithm(self) -> str: @@ -45,9 +41,7 @@ def algorithm(self) -> str: return self._alg @abstractmethod - def decrypt(self, - encrypted, # type: EncryptionResult - ) -> bytes: + def decrypt(self, encrypted: EncryptionResult) -> bytes: """Decrypts the given :class:`~couchbase.encryption.EncryptionResult` ciphertext. The Decrypter's algorithm should match the `alg` property of the given diff --git a/couchbase/encryption/encrypter.py b/couchbase/encryption/encrypter.py index a42c41ef..09f746a1 100644 --- a/couchbase/encryption/encrypter.py +++ b/couchbase/encryption/encrypter.py @@ -1,4 +1,4 @@ -# Copyright 2016-2022. Couchbase, Inc. +# Copyright 2016-2025. Couchbase, Inc. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License") @@ -20,10 +20,8 @@ class Encrypter(ABC): - def __init__(self, - keyring, # type: Keyring - key, # type: str - ): + + def __init__(self, keyring: Keyring, key: str) -> None: self._keyring = keyring self._key = key @@ -36,9 +34,7 @@ def key(self) -> str: return self._key @abstractmethod - def encrypt(self, - plaintext, # type: Union[str, bytes, bytearray] - ) -> EncryptionResult: + def encrypt(self, plaintext: Union[str, bytes, bytearray]) -> EncryptionResult: """Encrypts the given plaintext Args: @@ -52,4 +48,3 @@ def encrypt(self, :class:`~couchbase.exceptions.InvalidCryptoKeyException`: If the :class:`.Encrypter` has an invalid key for encryption. """ - pass diff --git a/couchbase/encryption/encryption_result.py b/couchbase/encryption/encryption_result.py index ce1175ae..ef51fbac 100644 --- a/couchbase/encryption/encryption_result.py +++ b/couchbase/encryption/encryption_result.py @@ -1,4 +1,4 @@ -# Copyright 2016-2022. Couchbase, Inc. +# Copyright 2016-2025. Couchbase, Inc. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License") @@ -25,93 +25,67 @@ class EncryptionResult: def __init__(self, - alg, # type: str - kid=None, # type: Optional[str] - ciphertext=None, # type: Optional[str] - **kwargs, # type: Optional[Any] + alg: str = '', + kid: Optional[str] = None, + ciphertext: Optional[str] = None, + **kwargs: Any ): - self._map = {"alg": alg} + if not alg: + raise InvalidArgumentException('EncryptionResult must include alg property.') + + self._map: dict[str, Any] = {'alg': alg} if kid: - self._map["kid"] = kid + self._map['kid'] = kid if ciphertext and self._valid_base64(ciphertext): - self._map["ciphertext"] = ciphertext + self._map['ciphertext'] = ciphertext if kwargs: self._map.update(**kwargs) @classmethod - def new_encryption_result_from_dict(cls, - values, # type: dict - ) -> EncryptionResult: - - alg = values.pop("alg", None) - if not alg: - raise InvalidArgumentException( - "EncryptionResult must include alg property." - ) - - return EncryptionResult(alg, **values) + def new_encryption_result_from_dict(cls, values: dict[str, Any]) -> EncryptionResult: + return EncryptionResult(**values) - def put(self, - key, # type: str - val # type: Any - ): + def put(self, key: str, val: Any) -> None: self._map[key] = val - def put_and_base64_encode(self, - key, # type: str - val, # type: bytes - ): + def put_and_base64_encode(self, key: str, val: bytes) -> None: if not isinstance(val, bytes): - raise ValueError("Provided value must be of type bytes.") + raise ValueError('Provided value must be of type bytes.') self._map[key] = base64.b64encode(val) - def get(self, - key, # type: str - ) -> Any: + def get(self, key: str) -> Any: val = self._map.get(key, None) if not val: - raise CryptoKeyNotFoundException( - message="No mapping to EncryptionResult value found for key: '{}'.".format( - key) - ) + raise CryptoKeyNotFoundException(message=f"No mapping to EncryptionResult value found for key: '{key}'.") return val def algorithm(self) -> str: - return self._map["alg"] + return self._map['alg'] - def get_with_base64_decode(self, - key, # type: str - ) -> bytes: + def get_with_base64_decode(self, key: str) -> bytes: val = self._map.get(key, None) if not val: - raise CryptoKeyNotFoundException( - message="No mapping to EncryptionResult value found for key: '{}'.".format( - key) - ) + raise CryptoKeyNotFoundException(message=f"No mapping to EncryptionResult value found for key: '{key}'.") return base64.b64decode(val) - def asdict(self) -> dict: + def asdict(self) -> dict[str, Any]: return self._map - def _valid_base64(self, - val, # type: Union[str, bytes, bytearray] - ) -> bool: + def _valid_base64(self, val: Union[str, bytes, bytearray]) -> bool: try: if isinstance(val, str): - bytes_val = bytes(val, "ascii") + bytes_val = bytes(val, 'ascii') elif isinstance(val, bytes): bytes_val = val elif isinstance(val, bytearray): bytes_val = val else: - raise ValueError( - "Provided value must be of type str, bytes or bytearray" - ) + raise ValueError('Provided value must be of type str, bytes or bytearray') return base64.b64encode(base64.b64decode(bytes_val)) == bytes_val diff --git a/couchbase/encryption/key.py b/couchbase/encryption/key.py index 8b73c5fc..756af070 100644 --- a/couchbase/encryption/key.py +++ b/couchbase/encryption/key.py @@ -1,4 +1,4 @@ -# Copyright 2016-2022. Couchbase, Inc. +# Copyright 2016-2025. Couchbase, Inc. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License") @@ -17,10 +17,7 @@ class Key: - def __init__(self, - id, # type: str - bytes_, # type: Union[bytes, bytearray] - ): + def __init__(self, id: str, bytes_: Union[bytes, bytearray]) -> None: self._id = id self._bytes = bytes_ if isinstance(bytes_, bytes) else bytes(bytes_) diff --git a/couchbase/encryption/keyring.py b/couchbase/encryption/keyring.py index 74aca9c3..998dc668 100644 --- a/couchbase/encryption/keyring.py +++ b/couchbase/encryption/keyring.py @@ -1,4 +1,4 @@ -# Copyright 2016-2022. Couchbase, Inc. +# Copyright 2016-2025. Couchbase, Inc. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License") @@ -14,21 +14,18 @@ # limitations under the License. from abc import ABC, abstractmethod -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from couchbase.encryption import Key +from couchbase.encryption import Key class Keyring(ABC): + @abstractmethod - def get_key(self, - key_id, # type: str - ) -> Key: + def get_key(self, key_id: str) -> Key: """Returns requested key Args: - keyid (str): Key ID to retrieve + key_id (str): Key ID to retrieve Returns: :class:`~couchbase.encryption.Key`: The corresponding :class:`~couchbase.encryption.Key` diff --git a/couchbase/exceptions.py b/couchbase/exceptions.py index 4f0bf5ec..c6c58d91 100644 --- a/couchbase/exceptions.py +++ b/couchbase/exceptions.py @@ -16,9 +16,7 @@ import json import re import sys -from collections import defaultdict from enum import Enum -from string import Template from typing import (Any, Dict, Optional, @@ -400,6 +398,8 @@ def inner_cause(self) -> Optional[Exception]: Returns: Optional[Exception]: Exception's inner cause, if it exists. """ + if not self._exc_info: + return return self._exc_info.get('inner_cause', None) @classmethod @@ -409,7 +409,7 @@ def pycbc_create_exception(cls, base=None, message=None): def __repr__(self): from couchbase._utils import is_null_or_empty details = [] - if self._base: + if hasattr(self, '_base') and self._base: details.append( "ec={}, category={}".format( self._base.err(), @@ -1754,95 +1754,131 @@ def __str__(self): # Field Level Encryption Exceptions -# @TODO: Need to look at FLE library to make updates here - class CryptoException(CouchbaseException): - def __init__(self, params=None, - message="Generic Cryptography exception", **kwargs): - params = params or {} - param_dict = params.get("objextra") or defaultdict(lambda: "unknown") - params["message"] = Template(message).safe_substitute(**param_dict) - super(CryptoException, self).__init__(params=params, **kwargs) + def __init__(self, + message: Optional[str] = 'Generic Cryptography exception', + exc_info: Optional[Dict[str, Any]] = None + ) -> None: + super().__init__(message=message, exc_info=exc_info) + + def __repr__(self): + return f"{type(self).__name__}({super().__repr__()})" + + def __str__(self): + return self.__repr__() class EncryptionFailureException(CryptoException): - def __init__(self, params=None, - message="Generic encryption failure.", **kwargs): - super(EncryptionFailureException, self).__init__( - params=params, message=message, **kwargs - ) + def __init__(self, + message: Optional[str] = 'Generic Encryption exception', + exc_info: Optional[Dict[str, Any]] = None + ) -> None: + super().__init__(message=message, exc_info=exc_info) + + def __repr__(self): + return f"{type(self).__name__}({super().__repr__()})" + + def __str__(self): + return self.__repr__() class DecryptionFailureException(CryptoException): - def __init__(self, params=None, - message="Generic decryption failure.", **kwargs): - super(DecryptionFailureException, self).__init__( - params=params, message=message, **kwargs - ) + def __init__(self, + message: Optional[str] = 'Generic Decryption exception', + exc_info: Optional[Dict[str, Any]] = None + ) -> None: + super().__init__(message=message, exc_info=exc_info) + + def __repr__(self): + return f"{type(self).__name__}({super().__repr__()})" + + def __str__(self): + return self.__repr__() class CryptoKeyNotFoundException(CryptoException): def __init__(self, message): self._message = message - super(CryptoKeyNotFoundException, self).__init__(message=message) + super().__init__(message=message) + + def __repr__(self): + return f'{type(self).__name__}(message={self._message})' def __str__(self): - return "{}: {}".format(self.__class__.__name__, self._message) + return self.__repr__() class InvalidCryptoKeyException(CryptoException): def __init__(self, message): self._message = message - super(InvalidCryptoKeyException, self).__init__(message=message) + super().__init__(message=message) + + def __repr__(self): + return f'{type(self).__name__}(message={self._message})' def __str__(self): - return "{}: {}".format(self.__class__.__name__, self._message) + return self.__repr__() class EncrypterNotFoundException(CryptoException): def __init__(self, message): self._message = message - super(EncrypterNotFoundException, self).__init__(message=message) + super().__init__(message=message) + + def __repr__(self): + return f'{type(self).__name__}(message={self._message})' def __str__(self): - return "{}: {}".format(self.__class__.__name__, self._message) + return self.__repr__() class DecrypterNotFoundException(CryptoException): def __init__(self, message): self._message = message - super(DecrypterNotFoundException, self).__init__(message=message) + super().__init__(message=message) + + def __repr__(self): + return f'{type(self).__name__}(message={self._message})' def __str__(self): - return "{}: {}".format(self.__class__.__name__, self._message) + return self.__repr__() class EncrypterAlreadyExistsException(CryptoException): def __init__(self, message): self._message = message - super(EncrypterAlreadyExistsException, self).__init__(message=message) + super().__init__(message=message) + + def __repr__(self): + return f'{type(self).__name__}(message={self._message})' def __str__(self): - return "{}: {}".format(self.__class__.__name__, self._message) + return self.__repr__() class DecrypterAlreadyExistsException(CryptoException): def __init__(self, message): self._message = message - super(DecrypterAlreadyExistsException, self).__init__(message=message) + super().__init__(message=message) + + def __repr__(self): + return f'{type(self).__name__}(message={self._message})' def __str__(self): - return "{}: {}".format(self.__class__.__name__, self._message) + return self.__repr__() class InvalidCipherTextException(CryptoException): def __init__(self, message): self._message = message - super(InvalidCipherTextException, self).__init__(message=message) + super().__init__(message=message) + + def __repr__(self): + return f'{type(self).__name__}(message={self._message})' def __str__(self): - return "{}: {}".format(self.__class__.__name__, self._message) + return self.__repr__() # CXX Error Map diff --git a/couchbase/tests/exceptions_t.py b/couchbase/tests/exceptions_t.py index 9d9e1c7a..0e24041d 100644 --- a/couchbase/tests/exceptions_t.py +++ b/couchbase/tests/exceptions_t.py @@ -23,35 +23,30 @@ class ExceptionTestSuite: TEST_MANIFEST = [ + 'test_couchbase_exception_base', 'test_exceptions_create_only_message', ] @pytest.fixture(scope='class', name='cb_exceptions') def get_couchbase_exceptions(self): couchbase_exceptions = [] - skip_list = [ - 'CouchbaseException', - 'CryptoException', - 'EncryptionFailureException', - 'DecryptionFailureException', - 'CryptoKeyNotFoundException', - 'InvalidCryptoKeyException', - 'EncrypterNotFoundException', - 'DecrypterNotFoundException', - 'EncrypterAlreadyExistsException', - 'DecrypterAlreadyExistsException', - 'InvalidCipherTextException' - ] for ex in dir(E): exp = getattr(sys.modules['couchbase.exceptions'], ex) try: - if issubclass(exp, E.CouchbaseException) and exp.__name__ not in skip_list: + if issubclass(exp, E.CouchbaseException) and exp.__name__ != 'CouchbaseException': couchbase_exceptions.append(exp) except TypeError: pass return couchbase_exceptions + def test_couchbase_exception_base(self): + base = E.CouchbaseException(message='This is a test message.') + assert isinstance(base, Exception) + assert isinstance(base, E.CouchbaseException) + assert str(base).startswith('<') + assert 'message=This is a test message.' in str(base) + def test_exceptions_create_only_message(self, cb_exceptions): for ex in cb_exceptions: new_ex = ex('This is a test message.') diff --git a/deps/couchbase-cxx-client b/deps/couchbase-cxx-client index effbd6e7..3d4d3ba6 160000 --- a/deps/couchbase-cxx-client +++ b/deps/couchbase-cxx-client @@ -1 +1 @@ -Subproject commit effbd6e74f92ddeea1dfe5495f95723f7d8bd9f6 +Subproject commit 3d4d3ba6a86a2460619b8330da4987c843faa1c0 diff --git a/pycbc_build_setup.py b/pycbc_build_setup.py index 20cd33ed..fa64bdb6 100644 --- a/pycbc_build_setup.py +++ b/pycbc_build_setup.py @@ -71,11 +71,18 @@ def process_build_env_vars(): # noqa: C901 # We use OpenSSL by default if building the SDK; however, starting with v4.1.9 we build our wheels using BoringSSL. pycbc_use_openssl = os.getenv('PYCBC_USE_OPENSSL', 'true').lower() in ENV_TRUE - if pycbc_use_openssl is True: + pycbc_use_opensslv1_1 = os.getenv('PYCBC_USE_OPENSSLV1_1', 'false').lower() in ENV_TRUE + if pycbc_use_openssl is True or pycbc_use_opensslv1_1: cmake_extra_args += ['-DUSE_STATIC_BORINGSSL:BOOL=OFF'] ssl_version = os.getenv('PYCBC_OPENSSL_VERSION', None) - if not ssl_version: - ssl_version = '1.1.1w' + if pycbc_use_opensslv1_1 is True: + cmake_extra_args += ['-DUSE_OPENSSLV1_1:BOOL=ON'] + if not ssl_version: + # lastest 1.1 version: https://github.com/openssl/openssl/releases/tag/OpenSSL_1_1_1w + ssl_version = '1.1.1w' + elif not ssl_version: + # lastest 3.x version: https://github.com/openssl/openssl/releases/tag/openssl-3.5.2 + ssl_version = '3.5.2' cmake_extra_args += [f'-DOPENSSL_VERSION={ssl_version}'] else: cmake_extra_args += ['-DUSE_STATIC_BORINGSSL:BOOL=ON']