Skip to content
Merged
33 changes: 17 additions & 16 deletions .github/workflows/python-integ.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,34 +48,35 @@ jobs:
aws-region: us-west-2

- name: Run unit tests
run: make test-unit
run: |
uv run pytest test/ --ignore=test/integration/ --verbose \
--cov=src/s3_encryption --cov-report=term-missing --cov-report=html:coverage-unit \
--cov-fail-under=89

- name: Run integration tests
run: make test-integration
run: |
uv run pytest test/integration/ --verbose \
--cov=src/s3_encryption --cov-report=term-missing --cov-report=html:coverage-integ \
--cov-fail-under=83
env:
CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }}
CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }}
CI_MRK_KEY_ID_PRIMARY: ${{ vars.CI_MRK_KEY_ID_PRIMARY }}
CI_MRK_KEY_ID_REPLICA: ${{ vars.CI_MRK_KEY_ID_REPLICA }}

- name: Run examples
run: make test-examples

- name: Generate coverage HTML report
if: always()
run: uv run coverage html -d coverage-report
uses: actions/upload-artifact@v7
with:
name: coverage-unit
path: coverage-unit/

- name: Upload coverage report
- name: Upload integration test coverage report
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage-report/

- name: Check coverage threshold
run: |
THRESHOLD=93
ACTUAL=$(uv run coverage report --format=total)
echo "Coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)"
if [ "$ACTUAL" -gt "$THRESHOLD" ]; then
echo "::warning::Coverage is ${ACTUAL}%, consider updating --fail-under to ${ACTUAL} in python-integ.yml"
fi
uv run coverage report --fail-under=$THRESHOLD
name: coverage-integ
path: coverage-integ/
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ format:
# Run all tests with combined coverage
test: test-unit test-integration test-examples

# Run unit tests (creates .coverage report)
# Run unit tests with coverage
test-unit:
uv run pytest test/ --ignore=test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing
uv run pytest test/ --ignore=test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=89

# Run integration tests (appends to .coverage report from test-unit)
# Run integration tests with separate coverage
test-integration:
uv run pytest test/integration/ --verbose --cov=src/s3_encryption --cov-append --cov-report=term-missing
uv run pytest test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=83

test-examples:
uv run pytest examples/test/ -v
Expand Down
31 changes: 31 additions & 0 deletions cdk/lib/cdk-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,33 @@ export class S3ECPythonGithub extends cdk.Stack {
}
)

// Multi-Region Key (MRK) for cross-region testing.
// The primary key is created here in the stack's region (us-west-2).
// A replica MUST be created manually in us-east-1 via the AWS Console
// or a separate CDK stack, since CDK cannot create cross-region replicas
// within a single stack.
const S3ECMRKPrimaryKey = new Key(
this,
"S3ECMRKPrimaryKey",
{
enableKeyRotation: true,
description: "Multi-Region primary key for S3EC cross-region testing",
// multiRegion is not a direct CDK L2 prop; use cfnOptions override
}
);
// Override to enable multi-region on the underlying CloudFormation resource
const cfnMrkKey = S3ECMRKPrimaryKey.node.defaultChild as cdk.aws_kms.CfnKey;
cfnMrkKey.addPropertyOverride("MultiRegion", true);

const S3ECMRKPrimaryKeyAlias = new Alias(
this,
"S3ECMRKPrimaryKeyAlias",
{
aliasName: "alias/S3EC-Python-MRK-Primary",
targetKey: S3ECMRKPrimaryKey,
}
);

// S3 buckets
const AccessConfiguration: BlockPublicAccessOptions = {
blockPublicAcls: false,
Expand Down Expand Up @@ -162,6 +189,10 @@ export class S3ECPythonGithub extends cdk.Stack {
resources: [
S3ECGithubKMSKey.keyArn,
S3ECTestServerKMSKey.keyArn, // Add access to the test-server KMS key
S3ECMRKPrimaryKey.keyArn, // MRK primary key
// MRK replica in us-east-1 — ARN must use wildcard account
// since the replica shares the same key ID but different region
`arn:aws:kms:us-east-1:${this.account}:key/${S3ECMRKPrimaryKey.keyId}`,
]
})
]
Expand Down
37 changes: 37 additions & 0 deletions src/s3_encryption/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,32 @@ def process_instruction_file(self, parsed):
parsed["Body"] = streaming_body


def _validate_encryption_context(encryption_context):
"""Validate that all encryption context keys and values are US-ASCII.

S3 applies double-encoding to non-ASCII metadata values that SDKs do not
automatically decode, which causes decryption to fail because the stored
encryption context won't match the original.

Raises:
S3EncryptionClientError: If any key or value contains non-ASCII characters.
"""
if encryption_context is None:
return
if not isinstance(encryption_context, dict):
raise S3EncryptionClientError("EncryptionContext must be a dictionary")
for k, v in encryption_context.items():
if not isinstance(k, str) or not isinstance(v, str):
raise S3EncryptionClientError("EncryptionContext keys and values must be strings")
if not k.isascii() or not v.isascii():
raise S3EncryptionClientError(
f"EncryptionContext keys and values must contain only US-ASCII characters. "
f"Non-ASCII characters in S3 metadata are encoded by the server "
f"and will cause decryption to fail. "
f"First offending entry: {repr(k)}: {repr(v)}"
)


@define
class S3EncryptionClient:
"""Client for encrypting and decrypting S3 objects.
Expand Down Expand Up @@ -322,6 +348,15 @@ def __attrs_post_init__(self):
event_system.register("before-call.s3.PutObject", self._plugin.on_put_object_before_call)
event_system.register("after-call.s3.GetObject", self._plugin.on_get_object_after_call)

def __getattr__(self, name):
"""Proxy unrecognized attributes to the wrapped S3 client.

This allows the S3EncryptionClient to be used like a regular boto3 S3
client for operations it doesn't intercept (e.g. copy_object,
list_objects_v2, etc.).
"""
return getattr(self.wrapped_s3_client, name)

def put_object(self, **kwargs):
"""Encrypt and upload an object to S3.

Expand All @@ -343,6 +378,7 @@ def put_object(self, **kwargs):
"""
# Extract EncryptionContext if provided (not a standard S3 parameter)
encryption_context = kwargs.pop("EncryptionContext", None)
_validate_encryption_context(encryption_context)

# Store encryption context in thread-local storage for the event handler
self._plugin._context.encryption_context = encryption_context
Expand Down Expand Up @@ -380,6 +416,7 @@ def get_object(self, **kwargs):
"""
# Extract EncryptionContext if provided (not a standard S3 parameter)
encryption_context = kwargs.pop("EncryptionContext", None)
_validate_encryption_context(encryption_context)
##= specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
##= type=implementation
##% The S3EC SHOULD support providing a custom Instruction File suffix
Expand Down
Loading
Loading