22# SPDX-License-Identifier: BSD-3-Clause
33import urllib .parse as _up
44from datetime import datetime , timezone
5- from email .utils import formatdate
5+ from email .utils import format_datetime
66from hashlib import md5
77from http import HTTPStatus
88from http .server import BaseHTTPRequestHandler
@@ -130,13 +130,14 @@ def _handle_write(self):
130130 return
131131 try :
132132 data = self .server .state .copy_object (bucket , key , src_bucket , src_key )
133+ last_modified = self .server .state .get_object_last_modified (bucket , key )
133134 except FileNotFoundError :
134135 self ._send_error (HTTPStatus .NOT_FOUND , "NoSuchKey" )
135136 return
136137 xml = (
137138 '<?xml version="1.0" encoding="UTF-8"?>'
138139 "<CopyObjectResult>"
139- f"<LastModified>{ _escape_xml (formatdate ( usegmt = True ))} </LastModified>"
140+ f"<LastModified>{ _escape_xml (_s3_datetime ( last_modified ))} </LastModified>"
140141 f"<ETag>"{ _escape_xml (_etag (data ))} "</ETag>"
141142 "</CopyObjectResult>"
142143 ).encode ()
@@ -207,6 +208,7 @@ def _handle_read(self, listing: bool, only_headers: bool = False):
207208
208209 try :
209210 data = self .server .state .get_object (bucket , key )
211+ last_modified = self .server .state .get_object_last_modified (bucket , key )
210212 except FileNotFoundError :
211213 self ._send_error (HTTPStatus .NOT_FOUND , "Not found" )
212214 return
@@ -234,10 +236,10 @@ def _handle_read(self, listing: bool, only_headers: bool = False):
234236 "Accept-Ranges" : "bytes" ,
235237 "Content-Length" : str (len (slice_data )),
236238 "ETag" : _etag (data ),
239+ "Last-Modified" : _http_datetime (last_modified ),
237240 }
238241 if only_headers :
239242 headers .setdefault ("Content-Type" , "application/octet-stream" )
240- headers .setdefault ("Last-Modified" , formatdate (usegmt = True ))
241243 self ._send_status (HTTPStatus .PARTIAL_CONTENT , extra_headers = headers )
242244 else :
243245 self ._send_bytes (
@@ -254,15 +256,19 @@ def _handle_read(self, listing: bool, only_headers: bool = False):
254256 "Content-Length" : str (len (data )),
255257 "Accept-Ranges" : "bytes" ,
256258 "Content-Type" : "application/octet-stream" ,
257- "Last-Modified" : formatdate ( usegmt = True ),
259+ "Last-Modified" : _http_datetime ( last_modified ),
258260 "ETag" : _etag (data ),
259261 },
260262 )
261263 else :
262264 self ._send_bytes (
263265 data ,
264266 content_type = "application/octet-stream" ,
265- extra_headers = {"Accept-Ranges" : "bytes" },
267+ extra_headers = {
268+ "Accept-Ranges" : "bytes" ,
269+ "Last-Modified" : _http_datetime (last_modified ),
270+ "ETag" : _etag (data ),
271+ },
266272 )
267273
268274 def _handle_delete (self ):
@@ -414,7 +420,6 @@ def _render_list_bucket_result(
414420 start_after = (qs .get ("start-after" ) or ["" ])[0 ]
415421 exclusive_after = continuation or start_after
416422
417- now = datetime .now (timezone .utc ).strftime ("%Y-%m-%dT%H:%M:%S.000Z" )
418423 state = self .server .state
419424
420425 items : list [tuple [Literal ["cp" , "key" ], str ]] = []
@@ -471,15 +476,17 @@ def _render_list_bucket_result(
471476 else :
472477 try :
473478 data = state .get_object (bucket , path )
479+ last_modified = state .get_object_last_modified (bucket , path )
474480 size = len (data )
475481 etag = _etag (data )
476482 except Exception : # noqa: BLE001
477483 size = 0
478484 etag = '""'
485+ last_modified = datetime .fromtimestamp (0 , tz = timezone .utc )
479486 fragments .append (
480487 "<Contents>"
481488 f"<Key>{ _escape_xml (path )} </Key>"
482- f"<LastModified>{ now } </LastModified>"
489+ f"<LastModified>{ _s3_datetime ( last_modified ) } </LastModified>"
483490 f"<ETag>{ etag } </ETag>"
484491 f"<Size>{ size } </Size>"
485492 "</Contents>"
@@ -541,6 +548,18 @@ def _escape_xml(text: str) -> str: # noqa: D401
541548 )
542549
543550
551+ def _http_datetime (value : datetime ) -> str :
552+ """Format an aware datetime for HTTP Last-Modified headers."""
553+
554+ return format_datetime (value .astimezone (timezone .utc ), usegmt = True )
555+
556+
557+ def _s3_datetime (value : datetime ) -> str :
558+ """Format an aware datetime for S3 XML LastModified fields."""
559+
560+ return value .astimezone (timezone .utc ).strftime ("%Y-%m-%dT%H:%M:%S.000Z" )
561+
562+
544563def _etag (data : bytes ) -> str : # noqa: D401
545564 """Generate an ETag for binary data.
546565
0 commit comments