diff --git a/src/s3_encryption/__init__.py b/src/s3_encryption/__init__.py index 9b8772d6..7ece424c 100644 --- a/src/s3_encryption/__init__.py +++ b/src/s3_encryption/__init__.py @@ -6,6 +6,7 @@ import threading from attrs import define, field +from botocore.exceptions import ClientError from botocore.response import StreamingBody from .exceptions import S3EncryptionClientError @@ -225,6 +226,11 @@ def on_get_object_after_call(self, parsed, **kwargs): # Get encryption context from thread-local storage (set by get_object wrapper) encryption_context = getattr(self._context, _CTX_ENCRYPTION_CONTEXT, None) + # If Body is None, the S3 request failed (e.g., NoSuchKey). + # Return early and let boto3 raise the original error. + if parsed.get("Body", None) is None: + return + # The parsed response already has the Body as a StreamingBody # We need to read it, decrypt it, and replace it @@ -272,9 +278,16 @@ def process_instruction_file(self, parsed): """ instruction_key = getattr(self._context, _CTX_KEY, None) + body = parsed.get("Body", None) + if body is None: + raise S3EncryptionClientError( + f"Instruction file body is empty for key: {instruction_key}" + ) + # In plaintext mode, parse instruction file and append to metadata - existing_metadata = parsed.get("Metadata", {}) - instruction_data = parsed.get("Body").read() + # Metadata may be present but None, so `or {}` handles that case + existing_metadata = parsed.get("Metadata", {}) or {} + instruction_data = body.read() instruction_metadata = parse_instruction_file(instruction_data, instruction_key) # Append parsed instruction file content to existing metadata @@ -385,9 +398,16 @@ def get_object(self, **kwargs): except S3EncryptionClientError: # Re-raise our own exceptions without wrapping raise + except ClientError as e: + # Wrap S3 service errors (e.g., NoSuchKey) with context + raise S3EncryptionClientError( + f"Failed to retrieve and/or decrypt object: {str(e)}" + ) from e except Exception as e: # Wrap any unexpected errors during decryption - raise S3EncryptionClientError(f"Failed to decrypt object: {str(e)}") from e + raise S3EncryptionClientError( + f"Failed to retrieve and/or decrypt object: {str(e)}" + ) from e finally: # Clean up thread-local storage; # do not clean up the client as it is not thread local only diff --git a/src/s3_encryption/instruction_file.py b/src/s3_encryption/instruction_file.py index 351c4b15..60305d17 100644 --- a/src/s3_encryption/instruction_file.py +++ b/src/s3_encryption/instruction_file.py @@ -9,6 +9,8 @@ import json from typing import Any +from botocore.exceptions import ClientError + from .exceptions import S3EncryptionClientError from .metadata import VALID_S3EC_METADATA_KEYS @@ -95,6 +97,12 @@ def fetch_instruction_file(s3_client, bucket: str, key: str) -> dict[str, Any]: try: response = s3_client.get_object(Bucket=bucket, Key=key) + except ClientError as e: + raise S3EncryptionClientError( + f"Exception encountered while fetching Instruction File. " + f"Ensure the object you are attempting to decrypt has been encrypted using the S3 Encryption Client. " + f"Instruction key: {key}" + ) from e finally: # Clear the flags after the call if hasattr(s3_client, "_s3ec_plugin_context"): diff --git a/test/integration/test_i_s3_encryption_instruction_file.py b/test/integration/test_i_s3_encryption_instruction_file.py index 6c93d832..6ca72e8b 100644 --- a/test/integration/test_i_s3_encryption_instruction_file.py +++ b/test/integration/test_i_s3_encryption_instruction_file.py @@ -1,6 +1,7 @@ # Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 import os +import uuid import boto3 import pytest @@ -173,6 +174,58 @@ def test_decrypt_v2_instruction_file_custom_suffix(delayed_auth): print("Success! V2 custom suffix instruction file decryption completed.") +def test_get_nonexistent_object_raises_s3_encryption_client_error(): + """Test that getting a non-existent object raises S3EncryptionClientError. + + Matches Java S3EC behavior: NoSuchKeyException is wrapped in + S3EncryptionClientException with the original as the cause. + """ + from botocore.exceptions import ClientError + + from s3_encryption.exceptions import S3EncryptionClientError + + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, kms_key_id) + wrapped_client = boto3.client("s3") + + config = S3EncryptionClientConfig(keyring) + s3ec = S3EncryptionClient(wrapped_client, config) + + with pytest.raises( + S3EncryptionClientError, match="Failed to retrieve and/or decrypt object" + ) as exc_info: + s3ec.get_object(Bucket=bucket, Key="this-object-does-not-exist") + + assert isinstance(exc_info.value.__cause__, ClientError) + + +def test_get_object_with_missing_instruction_file_raises_s3_encryption_client_error(): + """Test that a missing instruction file raises S3EncryptionClientError. + + When an object has no encryption metadata and the instruction file + also doesn't exist, the error should indicate the instruction file issue. + """ + from s3_encryption.exceptions import S3EncryptionClientError + + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, kms_key_id) + wrapped_client = boto3.client("s3") + + config = S3EncryptionClientConfig(keyring) + s3ec = S3EncryptionClient(wrapped_client, config) + + # Use a separate plain S3 client to put an unencrypted object + plain_s3 = boto3.client("s3") + test_key = f"plain-object-no-instruction-file-{uuid.uuid4()}" + plain_s3.put_object(Bucket=bucket, Key=test_key, Body=b"hello") + + try: + with pytest.raises(S3EncryptionClientError, match="Instruction file body is empty"): + s3ec.get_object(Bucket=bucket, Key=test_key) + finally: + plain_s3.delete_object(Bucket=bucket, Key=test_key) + + LARGE_FILE_SIZE = 52428800 # 50 MB @@ -183,6 +236,7 @@ def test_decrypt_large_v2_instruction_file_delayed_auth(): kms_client = boto3.client("kms", region_name=region) keyring = KmsKeyring(kms_client, kms_key_id) wrapped_client = boto3.client("s3") + config = S3EncryptionClientConfig( keyring, enable_delayed_authentication=True, diff --git a/test/test_s3_encryption_client.py b/test/test_s3_encryption_client.py new file mode 100644 index 00000000..2b164fe8 --- /dev/null +++ b/test/test_s3_encryption_client.py @@ -0,0 +1,72 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests for S3EncryptionClient get_object error handling.""" + +from unittest.mock import Mock + +import pytest +from botocore.exceptions import ClientError + +from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig +from s3_encryption.exceptions import S3EncryptionClientError +from s3_encryption.materials.keyring import S3Keyring + + +class TestGetObjectNonExistentObject: + """S3EncryptionClient wraps S3 errors with context, preserving the original cause.""" + + def _build_client(self): + mock_s3 = Mock() + mock_s3.meta.events = Mock() + mock_s3.meta.events.register = Mock() + mock_keyring = Mock(spec=S3Keyring) + config = S3EncryptionClientConfig(keyring=mock_keyring) + return S3EncryptionClient(wrapped_s3_client=mock_s3, config=config), mock_s3 + + def test_no_such_key_raises_s3_encryption_client_error(self): + client, mock_s3 = self._build_client() + error_response = { + "Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."} + } + mock_s3.get_object.side_effect = ClientError(error_response, "GetObject") + + with pytest.raises( + S3EncryptionClientError, match="Failed to retrieve and/or decrypt object" + ) as exc_info: + client.get_object(Bucket="test-bucket", Key="nonexistent-key") + + assert isinstance(exc_info.value.__cause__, ClientError) + assert exc_info.value.__cause__.response["Error"]["Code"] == "NoSuchKey" + + def test_access_denied_raises_s3_encryption_client_error(self): + client, mock_s3 = self._build_client() + error_response = {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}} + mock_s3.get_object.side_effect = ClientError(error_response, "GetObject") + + with pytest.raises( + S3EncryptionClientError, match="Failed to retrieve and/or decrypt object" + ) as exc_info: + client.get_object(Bucket="test-bucket", Key="forbidden-key") + + assert isinstance(exc_info.value.__cause__, ClientError) + assert exc_info.value.__cause__.response["Error"]["Code"] == "AccessDenied" + + +class TestFetchMissingInstructionFile: + """fetch_instruction_file wraps NoSuchKey with instruction-file-specific message.""" + + def test_missing_instruction_file_raises_s3_encryption_client_error(self): + mock_s3 = Mock() + mock_s3._s3ec_plugin_context = Mock() + error_response = { + "Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."} + } + mock_s3.get_object.side_effect = ClientError(error_response, "GetObject") + + from s3_encryption.instruction_file import fetch_instruction_file + + with pytest.raises(S3EncryptionClientError, match="fetching Instruction File") as exc_info: + fetch_instruction_file(mock_s3, "test-bucket", "test-key.instruction") + + assert isinstance(exc_info.value.__cause__, ClientError) + assert exc_info.value.__cause__.response["Error"]["Code"] == "NoSuchKey"