Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions src/s3_encryption/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't care to enough to quibble about this more than in this comment, so leave it if you want but, it's redundant given get() will return None by default anyway. Or is this a linter thing?

return

# The parsed response already has the Body as a StreamingBody
# We need to read it, decrypt it, and replace it

Expand Down Expand Up @@ -272,9 +278,16 @@ def process_instruction_file(self, parsed):
"""
instruction_key = getattr(self._context, _CTX_KEY, None)

body = parsed.get("Body", None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto, redundant 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 {}
Comment thread
texastony marked this conversation as resolved.
instruction_data = body.read()
instruction_metadata = parse_instruction_file(instruction_data, instruction_key)

# Append parsed instruction file content to existing metadata
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/s3_encryption/instruction_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"):
Expand Down
54 changes: 54 additions & 0 deletions test/integration/test_i_s3_encryption_instruction_file.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions test/test_s3_encryption_client.py
Original file line number Diff line number Diff line change
@@ -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"
Loading