Skip to content

Commit 264b7e4

Browse files
committed
perf: Fast-path for common Content-Disposition headers.
Modern browsers follow a very strict pattern for the Content-Disposition header within multipart segments. We can use that to avoid expensive regular expressions in almost all real-world scenarios and improve parser throughput by up to 40% for requests with mostly small fields.
1 parent 41817e2 commit 264b7e4

2 files changed

Lines changed: 69 additions & 7 deletions

File tree

multipart.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ def content_disposition_unquote(val, filename=False):
281281
but there are rare ambiguous edge cases where we have to guess. If in doubt,
282282
this function assumes a modern browser and follows the WHATWG HTML5
283283
specification (limited percent-encoding, no backslash-encoding).
284+
285+
If 'filename' is true, additional windows/ie6 legacy workarounds are applied.
284286
"""
285287

286288
if val and '"' == val[0] == val[-1]:
@@ -289,11 +291,11 @@ def content_disposition_unquote(val, filename=False):
289291
val = val.replace("\\\\", "\\").replace('\\"', '"')
290292
elif "%" in val: # Modern (HTML5) limited percent-encoding
291293
val = val.replace("%0D", "\r").replace("%0A", "\n").replace("%22", '"')
292-
# ie6/windows bug: full path instead of just filename
293-
if filename and (val[1:3] == ":\\" or val[:2] == "\\\\"):
294-
val = val.rpartition("\\")[-1]
295294
elif "%" in val: # Modern (HTML5) limited percent-encoding
296295
val = val.replace("%0D", "\r").replace("%0A", "\n").replace("%22", '"')
296+
# ie6/windows bug: full path instead of just filename
297+
if filename and (val[1:3] == ":\\" or val[:2] == "\\\\"):
298+
val = val.rpartition("\\")[-1]
297299
return val
298300

299301

@@ -317,6 +319,32 @@ def parse_options_header(header, options=None, unquote=header_unquote):
317319
return header[:i].lower().strip(), options
318320

319321

322+
def _parse_content_disposition(value):
323+
"""Specialized parser for Content-Disposition header values.
324+
325+
Returns a (disposition type, name, filename) tuple. All three
326+
can be empty or invalid, name and filename can be None.
327+
"""
328+
# Fast path for Content-Disposition headers emitted by all modern browsers
329+
split = value.split('"', 4)
330+
if split[0] == "form-data; name=":
331+
if len(split) == 3 and split[2] == "":
332+
name = split[1]
333+
if "%" in name:
334+
name = content_disposition_unquote(name)
335+
return "form-data", name, None
336+
if len(split) == 5 and split[2] == "; filename=" and split[4] == "":
337+
name, filename = split[1], split[3]
338+
if "%" in name:
339+
name = content_disposition_unquote(name)
340+
if "%" in filename or "\\" in filename:
341+
filename = content_disposition_unquote(filename, True)
342+
return "form-data", name, filename
343+
# Slow path for legacy browsers or non-browser clients
344+
dtype, opts = parse_options_header(value, unquote=content_disposition_unquote)
345+
return dtype, opts.get("name"), opts.get("filename")
346+
347+
320348
##############################################################################
321349
################################## SansIO Parser #############################
322350
##############################################################################
@@ -857,11 +885,9 @@ def __init__(self, headerlist: List[Tuple[str, str]]):
857885

858886
for name, value in headerlist:
859887
if name == "Content-Disposition":
860-
self.disposition, args = parse_options_header(
861-
value, unquote=content_disposition_unquote
888+
self.disposition, self.name, self.filename = _parse_content_disposition(
889+
value
862890
)
863-
self.name = args.get("name")
864-
self.filename = args.get("filename")
865891
elif name == "Content-Type":
866892
self.content_type, args = parse_options_header(value)
867893
self.charset = args.get("charset")

test/test_header_utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,39 @@ def test_options_parser(self):
4444
parse = functools.partial(multipart.parse_options_header, unquote=multipart.content_disposition_unquote)
4545
self.assertEqual(parse(head+'FileName="Te%22s\\\\t.txt"')[1]['filename'], 'Te"s\\\\t.txt')
4646

47+
def test_content_disposition_parser(self):
48+
parse = multipart._parse_content_disposition
49+
50+
self.assertEqual(parse('form-data; name="field"'), ('form-data', 'field', None))
51+
self.assertEqual(parse('form-data; name=""'), ('form-data', '', None))
52+
self.assertEqual(
53+
parse('form-data; name="file"; filename="test.txt"'),
54+
('form-data', 'file', 'test.txt'),
55+
)
56+
self.assertEqual(
57+
parse('form-data; name="file"; filename=""'),
58+
('form-data', 'file', ''),
59+
)
60+
61+
self.assertEqual(parse('form-data; name="a%22b"'), ('form-data', 'a"b', None))
62+
self.assertEqual(
63+
parse('form-data; name="a%0Db%0Ac"'),
64+
('form-data', 'a\rb\nc', None),
65+
)
66+
self.assertEqual(
67+
parse('form-data; name="file"; filename="a%22b.txt"'),
68+
('form-data', 'file', 'a"b.txt'),
69+
)
70+
71+
self.assertEqual(
72+
parse('form-data; filename="test.txt"; name="field"'),
73+
('form-data', 'field', 'test.txt'),
74+
)
75+
self.assertEqual(parse('FORM-DATA; name="field"'), ('form-data', 'field', None))
76+
self.assertEqual(parse('form-data ; name="field"'), ('form-data', 'field', None))
77+
self.assertEqual(parse('form-data; name=field'), ('form-data', 'field', None))
78+
self.assertEqual(parse('form-data; name="a\\"b"'), ('form-data', 'a"b', None))
79+
self.assertEqual(
80+
parse('form-data; name="file"; filename="C:\\fakepath\\test.txt"'),
81+
('form-data', 'file', 'test.txt'),
82+
)

0 commit comments

Comments
 (0)