Skip to content

Commit 05dc33e

Browse files
committed
issue2551175 - Make ETag content-encoding aware.
HTTP ETag headers now include a suffix (-gzip, -br, -zstd) indicating the content-encoding used to send the data per rfc7232. Validate any form of ETag by stripping a suffix (if present).
1 parent 639bf02 commit 05dc33e

File tree

6 files changed

+64
-6
lines changed

6 files changed

+64
-6
lines changed

CHANGES.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ Fixed:
4747
created using 1.2.0 or newer. The fix may have predated the 1.2.0
4848
release but where the fix actually landed (representing id as a
4949
string and not as an int) is unknown.
50+
- issue2551175 - Make ETag content-encoding aware. HTTP ETag headers
51+
now include a suffix indicating the content-encoding used to send
52+
the data per rfc7232. Properly validate any form of ETag suffixed or
53+
non-suffixed for If-Match.
5054

5155
Features:
5256

doc/rest.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,12 @@ Performing a ``GET`` on an item or property of an item will return an
227227
ETag header or an @etag property. This needs to be submitted with
228228
``DELETE``, ``PUT`` and ``PATCH`` operations on the item using an
229229
``If-Match`` header or an ``"@etag`` property in the data payload if
230-
the method supports a payload.
230+
the method supports a payload. The ETag header value will include a
231+
suffix (starting with '-') indicating the Content-Encoding used to
232+
respond to the request. If the response was uncompressed, there will
233+
be no suffix. The ``@etag`` property never includes the suffix. Any
234+
ETag value suffixed or not can be sent in an ``If-Match`` header as
235+
the suffix is ignored during comparison.
231236

232237
The exact details of returned data is determined by the value of the
233238
``@verbose`` query parameter. The various supported values and their

roundup/cgi/client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,6 +2166,14 @@ def compress_encode(self, byte_content, quality=4):
21662166
self.additional_headers['Content-Length'] = str(len(new_content))
21672167
self.additional_headers['Content-Encoding'] = encoder
21682168
self.setVary('Accept-Encoding')
2169+
try:
2170+
current_etag = self.additional_headers['ETag']
2171+
except KeyError:
2172+
pass # etag not set for non-rest endpoints
2173+
else:
2174+
etag_end = current_etag.rindex('"')
2175+
self.additional_headers['ETag'] = ( current_etag[:etag_end] +
2176+
'-' + encoder + current_etag[etag_end:])
21692177

21702178
return new_content
21712179

roundup/rest.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,20 @@ def check_etag(node, key, etags, classname="Missing", id="0",
218218
repr_format=repr_format)
219219

220220
for etag in etags:
221-
if etag is not None:
222-
if etag != node_etag:
221+
# etag includes doublequotes around tag:
222+
# '"a46a5572190e4fad63958c135f3746fa"'
223+
# but can include content-encoding suffix like:
224+
# '"a46a5572190e4fad63958c135f3746fa-gzip"'
225+
# turn the latter into the former as we don't care what
226+
# encoding was used to send the body with the etag.
227+
try:
228+
suffix_start = etag.rindex('-')
229+
clean_etag = etag[:suffix_start] + '"'
230+
except (ValueError, AttributeError):
231+
# - not in etag or etag is None
232+
clean_etag = etag
233+
if clean_etag is not None:
234+
if clean_etag != node_etag:
223235
return False
224236
have_etag_match = True
225237

test/rest_common.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,8 +1180,8 @@ def testEtagProcessing(self):
11801180
each one broke and no etag. Use the put command
11811181
to trigger the etag checking code.
11821182
'''
1183-
for mode in ('header', 'etag', 'both',
1184-
'brokenheader', 'brokenetag', 'none'):
1183+
for mode in ('header', 'header-gzip', 'etag', 'etag-br',
1184+
'both', 'brokenheader', 'brokenetag', 'none'):
11851185
try:
11861186
# clean up any old header
11871187
del(self.headers)
@@ -1198,9 +1198,17 @@ def testEtagProcessing(self):
11981198
if mode == 'header':
11991199
print("Mode = %s"%mode)
12001200
self.headers = {'if-match': etag}
1201+
elif mode == 'header-gzip':
1202+
print("Mode = %s"%mode)
1203+
gzip_etag = etag[:-1] + "-gzip" + etag[-1:]
1204+
self.headers = {'if-match': gzip_etag}
12011205
elif mode == 'etag':
12021206
print("Mode = %s"%mode)
12031207
form.list.append(cgi.MiniFieldStorage('@etag', etag))
1208+
elif mode == 'etag-br':
1209+
print("Mode = %s"%mode)
1210+
br_etag = etag[:-1] + "-br" + etag[-1:]
1211+
form.list.append(cgi.MiniFieldStorage('@etag', br_etag))
12041212
elif mode == 'both':
12051213
print("Mode = %s"%mode)
12061214
self.headers = {'etag': etag}
@@ -1216,7 +1224,7 @@ def testEtagProcessing(self):
12161224
elif mode == 'none':
12171225
print( "Mode = %s"%mode)
12181226
else:
1219-
self.fail("unknown mode found")
1227+
self.fail("unknown mode '%s' found"%mode)
12201228

12211229
results = self.server.put_attribute(
12221230
'user', self.joeid, 'realname', form

test/test_liveserver.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,13 @@ def test_compression_gzip(self):
439439

440440
self.assertDictEqual(json_dict, content)
441441

442+
# verify that ETag header ends with -gzip
443+
try:
444+
self.assertRegex(f.headers['ETag'], r'^"[0-9a-f]{32}-gzip"$')
445+
except AttributeError:
446+
# python2 no assertRegex so try substring match
447+
self.assertEqual(33, f.headers['ETag'].rindex('-gzip"'))
448+
442449
# use dict comprehension to remove fields like date,
443450
# content-length etc. from f.headers.
444451
self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected)
@@ -568,6 +575,13 @@ def test_compression_br(self):
568575

569576
self.assertDictEqual(json_dict, content)
570577

578+
# verify that ETag header ends with -br
579+
try:
580+
self.assertRegex(f.headers['ETag'], r'^"[0-9a-f]{32}-br"$')
581+
except AttributeError:
582+
# python2 no assertRegex so try substring match
583+
self.assertEqual(33, f.headers['ETag'].rindex('-br"'))
584+
571585
# use dict comprehension to remove fields like date,
572586
# content-length etc. from f.headers.
573587
self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected)
@@ -714,6 +728,13 @@ def test_compression_zstd(self):
714728

715729
self.assertDictEqual(json_dict, content)
716730

731+
# verify that ETag header ends with -zstd
732+
try:
733+
self.assertRegex(f.headers['ETag'], r'^"[0-9a-f]{32}-zstd"$')
734+
except AttributeError:
735+
# python2 no assertRegex so try substring match
736+
self.assertEqual(33, f.headers['ETag'].rindex('-zstd"'))
737+
717738
# use dict comprehension to remove fields like date,
718739
# content-length etc. from f.headers.
719740
self.assertDictEqual({ key: value for (key, value) in f.headers.items() if key in expected }, expected)

0 commit comments

Comments
 (0)