33import asyncio
44import base64
55import re
6+ from collections .abc import AsyncIterator
67from datetime import datetime
78from typing import NoReturn
89from urllib .parse import parse_qs , unquote
@@ -234,27 +235,28 @@ async def _download_encrypted_single(
234235 wrapped_dek = base64 .b64decode (wrapped_dek_b64 )
235236 return crypto .decrypt_object (ciphertext , wrapped_dek , self .keyring .key_by_id (kid ))
236237
237- async def _download_encrypted_multipart (
238+ async def _iter_multipart_plaintext (
238239 self ,
239240 client : S3Client ,
240241 bucket : str ,
241242 key : str ,
242243 meta ,
244+ dek : bytes ,
243245 range_start : int | None = None ,
244246 range_end : int | None = None ,
245- ) -> bytes :
246- """Download and decrypt multipart encrypted object, optionally with range."""
247- dek = crypto .unwrap_key (meta .wrapped_dek , self .keyring .key_by_id (meta .kid ))
248- sorted_parts = sorted (meta .parts , key = lambda p : p .part_number )
247+ ) -> AsyncIterator [bytes ]:
248+ """Yield decrypted plaintext for each part of a multipart-encrypted object.
249249
250- plaintext_chunks = []
250+ Parts outside the requested byte range are skipped. Parts that partially
251+ overlap the range are trimmed before yielding.
252+ """
253+ sorted_parts = sorted (meta .parts , key = lambda p : p .part_number )
251254 pt_offset = 0
252255 ct_offset = 0
253256
254257 for part in sorted_parts :
255258 part_pt_end = pt_offset + part .plaintext_size - 1
256259
257- # Check if part is in range (or no range specified)
258260 in_range = range_start is None or (
259261 part_pt_end >= range_start and pt_offset <= range_end
260262 )
@@ -266,18 +268,34 @@ async def _download_encrypted_multipart(
266268 ciphertext = await body .read ()
267269 part_plaintext = crypto .decrypt (ciphertext , dek )
268270
269- # Trim if range specified
270271 if range_start is not None :
271272 trim_start = max (0 , range_start - pt_offset )
272273 trim_end = min (part .plaintext_size , range_end - pt_offset + 1 )
273274 part_plaintext = part_plaintext [trim_start :trim_end ]
274275
275- plaintext_chunks . append ( part_plaintext )
276+ yield part_plaintext
276277
277278 pt_offset = part_pt_end + 1
278279 ct_offset += part .ciphertext_size
279280
280- return b"" .join (plaintext_chunks )
281+ async def _download_encrypted_multipart (
282+ self ,
283+ client : S3Client ,
284+ bucket : str ,
285+ key : str ,
286+ meta ,
287+ range_start : int | None = None ,
288+ range_end : int | None = None ,
289+ ) -> bytes :
290+ """Download and decrypt multipart encrypted object, optionally with range."""
291+ dek = crypto .unwrap_key (meta .wrapped_dek , self .keyring .key_by_id (meta .kid ))
292+ chunks = [
293+ chunk
294+ async for chunk in self ._iter_multipart_plaintext (
295+ client , bucket , key , meta , dek , range_start , range_end
296+ )
297+ ]
298+ return b"" .join (chunks )
281299
282300 async def forward_request (self , request : Request , creds : S3Credentials ) -> Response :
283301 """Forward unhandled requests to S3.
0 commit comments