Skip to content

Commit e4cb239

Browse files
authored
Merge pull request #65 from KennyMcSimpson/fix/download-response-filenames
fix(download): parse quoted and extended response filenames
2 parents 2251856 + ac87e2a commit e4cb239

2 files changed

Lines changed: 82 additions & 9 deletions

File tree

‎plugins/tools/_download_runner.py‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import argparse
66
import contextlib
77
import email.message
8+
import email.utils
89
import fcntl
910
import hashlib
1011
import http.client
@@ -56,10 +57,6 @@
5657
"image/",
5758
"text/",
5859
)
59-
_CONTENT_DISPOSITION_NAME_RE = re.compile(
60-
r"""filename\*?=(?:UTF-8''|["']?)([^;"']+)""",
61-
re.IGNORECASE,
62-
)
6360

6461

6562
class DownloadError(RuntimeError):
@@ -270,11 +267,21 @@ def _preflight(
270267

271268

272269
def _header_filename(headers: email.message.Message) -> str:
273-
disposition = headers.get("Content-Disposition") or ""
274-
match = _CONTENT_DISPOSITION_NAME_RE.search(disposition)
275-
if not match:
276-
return ""
277-
return urllib.parse.unquote(match.group(1)).strip()
270+
extended = ""
271+
fallback = ""
272+
for name, value in headers.get_params(header="content-disposition", failobj=[]):
273+
if name != "filename":
274+
continue
275+
# The parser represents filename* as an RFC 2231 tuple. Prefer it
276+
# over the ordinary filename, regardless of parameter order, but only
277+
# when it decodes to something usable: an empty filename* must not
278+
# discard a valid ordinary filename.
279+
if isinstance(value, tuple):
280+
if not extended:
281+
extended = email.utils.collapse_rfc2231_value(value).strip()
282+
elif not fallback:
283+
fallback = value.strip()
284+
return extended or fallback
278285

279286

280287
def _safe_filename(candidate: str) -> str:

‎tests/test_download_filenames.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
from email.message import Message
4+
5+
import pytest
6+
7+
from plugins.tools._download_runner import _header_filename, _resolve_basename
8+
9+
10+
@pytest.mark.parametrize(
11+
("disposition", "expected"),
12+
[
13+
('attachment; filename="report.pdf"', "report.pdf"),
14+
('attachment; filename="report;final.pdf"', "report;final.pdf"),
15+
('attachment; filename="O\'Reilly.pdf"', "O'Reilly.pdf"),
16+
('attachment; filename = "report.pdf"', "report.pdf"),
17+
('attachment; filename="report%20draft.pdf"', "report%20draft.pdf"),
18+
("attachment; filename*=UTF-8''report%20final.pdf", "report final.pdf"),
19+
("attachment; filename*=UTF-8'en'report%20final.pdf", "report final.pdf"),
20+
("attachment; filename*=UTF-8''caf%C3%A9.pdf", "café.pdf"),
21+
("attachment; filename*=ISO-8859-1''caf%E9.pdf", "café.pdf"),
22+
(
23+
'attachment; filename="fallback.txt"; filename*=UTF-8\'\'actual.pdf',
24+
"actual.pdf",
25+
),
26+
(
27+
'attachment; filename*=UTF-8\'\'actual.pdf; filename="fallback.txt"',
28+
"actual.pdf",
29+
),
30+
('attachment; filename="paper.pdf"; filename*=UTF-8\'\'', "paper.pdf"),
31+
("attachment; filename*=UTF-8''; filename=\"paper.pdf\"", "paper.pdf"),
32+
("attachment; filename*=UTF-8''%20%20; filename=\"paper.pdf\"", "paper.pdf"),
33+
("attachment", ""),
34+
],
35+
)
36+
def test_response_filename(disposition: str, expected: str) -> None:
37+
headers = Message()
38+
headers["Content-Disposition"] = disposition
39+
40+
assert _header_filename(headers) == expected
41+
42+
43+
@pytest.mark.parametrize(
44+
("path_arg", "disposition", "expected"),
45+
[
46+
("", 'attachment; filename="report;final.pdf"', "report_final.pdf"),
47+
("", 'attachment; filename="O\'Reilly.pdf"', "O_Reilly.pdf"),
48+
(
49+
"",
50+
'attachment; filename="fallback.txt"; filename*=UTF-8\'\'actual.pdf',
51+
"actual.pdf",
52+
),
53+
("", 'attachment; filename="../../outside.pdf"', "outside.pdf"),
54+
("", "attachment; filename*=UTF-8''..%2Foutside.pdf", "outside.pdf"),
55+
("", "attachment", "url name.pdf"),
56+
("", 'attachment; filename="paper.pdf"; filename*=UTF-8\'\'', "paper.pdf"),
57+
("", "", "url name.pdf"),
58+
("chosen.pdf", 'attachment; filename="report;final.pdf"', "chosen.pdf"),
59+
],
60+
)
61+
def test_download_basename(path_arg: str, disposition: str, expected: str) -> None:
62+
headers = Message()
63+
if disposition:
64+
headers["Content-Disposition"] = disposition
65+
66+
assert _resolve_basename(path_arg, "https://example.com/url%20name.pdf", headers) == expected

0 commit comments

Comments
 (0)