Behavior and browser differences
This page describes the rules the package follows, which are Chromium's, what Chrome does beyond them, and where Firefox and Safari name files differently.
Reading header lines
This section is about how Chrome reads the headers of an HTTP/1.1 response, which is what the browser tests check. Chrome reads them as the bytes that arrive, before it reads any one header. The package reads each header value the same way, as the rest of its header line as it was sent, so a value can hold line breaks, which HTTP has long deprecated inside a header:
- A blank line ends the headers. A line feed, then an optional CR and another line feed make a blank line, and what follows it is the body.
- A line that starts with a space or a tab continues the header. It is
joined on with one space, in place of the line break and the spaces and tabs
that start the line; the spaces and tabs at the end of the line before it
stay. So
filename="a \r\n b.pdf"names the filea b.pdf, with two spaces. - Any other line is another header line, so the value ends at the line break before it. Only CR and LF break lines: a vertical tab or a form feed is part of the value, and it does not start a continued line either.
- A NUL anywhere in the headers, in a name or a value, makes Chrome refuse
the whole response (
ERR_INVALID_HTTP_RESPONSE), as it refuses a response whose Content-Disposition values differ (see Reading Content-Disposition).parse_content_dispositionreports it asblocks_download, andsuggested_filenamenames the file all the same. A NUL after a blank line is in the body, and changes nothing.
Each name is matched as the name of one header line, the part before its
first colon, the way Chrome matches it: the spaces and tabs at its end are
removed, and the rest is compared whatever its ASCII case, so
Content-Disposition \t is Content-Disposition. A name that is empty, that
starts with a space or a tab, or that is not a token once trimmed matches no
header, as Chrome skips such a line. Only spaces and tabs are removed, so a
name that ends in another control character, such as a vertical tab, is not a
token.
The package reads each name and each value on its own, so it does not read every header line that they make on the wire the way Chrome does (see Limits of the port).
Reading Content-Disposition
- First value only. Every Content-Disposition line is split on commas
outside quoted strings, and the file is named from the first value of the
first line. So as long as every quote is closed, it makes no difference
whether repeated headers come as several lines or joined with
,by an HTTP library. A quote left open runs to the end of its line, which in a joined line takes in the values after it: the linesattachment; filename="aandattachment; filename=bgive the nameaand two values that differ, while joined they are one value, whose name isa, attachment; filename=b. When any value differs from the first, byte for byte once the spaces and tabs around it are trimmed, Chrome refuses the response and downloads nothing; a link with adownloadattribute starts a download that fails instead (see Usage). That happens with repeated headers that differ, and also with a single header that has a comma outside quotes, such asfilename=a,b.txtor a trailing comma.parse_content_dispositionreports it asblocks_download, as it reports a NUL in the headers (see Reading header lines);suggested_filenamenames the file from the first value all the same. - Disposition type.
inlineis inline;attachmentand any other token are attachment. A header that does not start with a token, such asfilename=report.pdf, is read as parameters with the type inline, and its file name is still used. - Parameters are read left to right and the first malformed one ends the
header: one without
=, with an empty value, or with a quote in its name. filename*comes first, whatever the order, when it decodes. Its value ischarset'language'%-encoded-bytes(RFC 8187); the language is ignored, the charset can be any one Chromium knows (UTF-8,ISO-8859-1,Shift_JIS, …), the bytes must be valid in it, and the result is normalized to NFC. Afilename*that does not decode is skipped, andfilenameis used.filenamemay be a token or a quoted string, and it is decoded the way browsers do although the RFCs do not allow it:- raw UTF-8 bytes are UTF-8;
- %-escapes are decoded (
%E4%B8%AD%E6%96%87.zipis中文.zip), and the name is dropped if they do not decode to UTF-8; - RFC 2047 encoded words are decoded (
=?UTF-8?B?5Lit5paHLnppcA==?=); - other raw bytes are decoded with
fallback_charsetif one is given; otherwise they are dropped, as Chrome on Linux does, which usually leaves no name.
- Not supported, as in Chromium: RFC 2231 continuations (
filename*0=,filename*1*=), which are ignored.
Picking the name
suggested_filename takes the first of these that is not empty:
- the file name from Content-Disposition, whatever the disposition type;
download_attribute, the<a download>value;- the last segment of the URL's path: after the last
/or\, up to a;, with %-escapes decoded. Its bytes must be UTF-8, or decode withfallback_charset.data:andabout:URLs, and URLs that are not valid, give no name. - the URL's host, but only if
default_nameisNone.
Sanitizing it
Then, in this order:
- Leading and trailing dots are removed (on Windows, trailing dots and spaces
first become
_), and/and\become_. - A name that is now empty, or made only of
-and_, is replaced withdefault_name(downloadunless you pass another), or withdownloadifdefault_nameisNone. - Characters that are not allowed anywhere become
_:",*,/,:,<,>,?,\and|, control characters, Unicode format characters such as the right-to-left override U+202E, and noncharacters. Whitespace,.and~at the start or end also become_. - The extension rules for the MIME type (see The MIME type) and the Windows rules apply, as described in Usage.
So a name made of report, U+202E and txt.exe, which displays as
reportexe.txt, becomes report_txt.exe.
The MIME type
The extension comes from the MIME type that Chrome's network stack hands to
the download code. The package works it out the same way from headers and
content_type; with neither, it uses none.
- Content-Type. All Content-Type lines are joined with
,and split on commas outside quoted strings, so a quote left open on one line runs on into the next. A value's type runs from its start to the first space, tab,;or(, and must hold the value's first/. Nothing else is checked, sotext/and"text/html"are types, with no extension. The last value that has a type gives it, in ASCII lowercase; a value without one, and the value*/*itself, change nothing. Sotext/plain, text/htmlistext/html,text/html, bogusistext/html, and*/*; q=1is*/*. - Sniffing. Chrome sniffs the body for another MIME type when the type is
missing,
text/plain,application/octet-stream,text/xml,application/xml, a Microsoft Office type, or unknown (unknown/unknown,application/unknown,*/*), unless the first value of the first X-Content-Type-Options line isnosniff, in any ASCII case. It sniffs onlyhttp:,https:,file:andfilesystem:URLs; without aurl, the package assumes it sniffs. The package never sees the body: where Chrome sniffs, it uses the declared type, or none (see Usage). - Without sniffing, a missing type becomes
text/plain, and so do the feed typesapplication/rss+xmlandapplication/atom+xml.
What Chrome does beyond this
The package returns the name that Chrome reports to DevTools and Playwright when a download starts, except in the cases listed under Limits of the port. The file Chrome finally writes can differ:
- Default text encoding. For raw bytes that are not UTF-8, Chrome uses the
browser's default text encoding. Pass it as
fallback_charset(see Usage). - Existing files. Chrome numbers the name, as in
report (1).pdf. - Long names. Chrome shortens a name that is too long for the file system, keeping the extension.
- ChromeOS. ChromeOS normalizes the name to NFC.
- Everything around the name: the Save As dialog, Safe Browsing and dangerous file warnings, extensions that rename downloads, and enterprise policies.
Other browsers
Firefox and Safari agree with Chrome on well-formed headers and on the most
common deviations: filename* comes first in either order, language tags are
ignored, raw UTF-8 in filename is UTF-8, semicolons inside quotes are part
of the name, a trailing ; is fine, and a header without a disposition type
is still read. On the rest they differ:
| Behavior | Chrome | Firefox | Safari |
|---|---|---|---|
%-escapes in filename are decoded |
yes | yes | no |
| RFC 2047 encoded words are decoded | yes | yes | no |
RFC 2231 continuations (filename*0=) are joined |
no | yes | no |
Characters Windows does not allow, such as <, ? and *, are replaced |
yes | yes | no |
| A header with spaces or tabs between its name and the colon is read | yes | no | no |
| A trailing space becomes | _ |
nothing | a space |
The Browsers workflow downloads every test case with Firefox, and with WebKit on Linux and on macOS, and each run's summary lists the cases where they differ from Chrome. The table comes from those runs, with Playwright 1.64: Chromium 156 for Chrome and Firefox 157, both on Linux, and WebKit 27.2 on macOS for Safari, since WebKit on macOS names downloads with Apple's networking code, as Safari does. Of the 920 cases in the exact set, Firefox gives Chrome's name for 468 and WebKit on macOS for 351; of the 16 cases in the limited set, Firefox does for 13 and WebKit on macOS for 15 (the two sets are described in How it is checked). Where Chrome refuses a response (see Limits of the port) and downloads nothing, a browser gives Chrome's name only if it downloads nothing either.
Limits of the port
- Refused responses. Chrome refuses a response whose Content-Disposition
values differ or whose headers hold a NUL (see Reading
Content-Disposition and Reading header
lines), which
blocks_downloadreports. It also refuses responses for reasons that the package does not look for, such as Location headers that are repeated with different values. In every case,suggested_filenamenames the file all the same. - Headers that are not the lines on the wire. The package reads the
headers it is given as the lines that Chrome received, so the name can
differ where they are not: where an HTTP library changed the lines, or
where strs are passed without
headers_encoding(see Pass the headers from your HTTP library), and where acontent_typeis given without the other headers, which leaves out X-Content-Type-Options (see Add an extension from the Content-Type). - Header lines inside a value. The package reads each header value on its
own (see Reading header lines). A line break in a
value before another header line ends the value, but the package does not
read that line as a header, as Chrome does: in
attachment; filename=a.pdf\rContent-Disposition: attachment; filename=b.pdfit misses the second Content-Disposition, for which Chrome refuses the response. A blank line in a value, or a line feed at its end, which makes a blank line with the line break after it, ends the headers on the wire; the package ends the value there, but it goes on to read the header lines after it. The HTTP libraries that Usage shows make header lines of their own out of such lines, end the headers at the blank line, or refuse the response, so this matters only for header lines that you put together yourself. - A colon, a line break, or a leading space or tab in a name. The package
matches each name as the name of one header line (see Reading header
lines). On the wire, Chrome ends a name at its first
colon, starts another line at a CR or a line feed, and can join a line that
starts with a space or a tab to the header line before it, so the headers
it reads can differ from the package's: for the name
Content-Disposition:attachment; filename=b.txt;xand the valuey, Chrome names the fileb.txt, and the package reads no Content-Disposition. Where a line break in a name makes a blank line, with another line break in the name or with the one before the name, it ends the headers on the wire, but the package goes on to read the header lines after it. - MIME sniffing. Where Chrome sniffs the body for a MIME type, the package
uses the declared one, so the extension can differ from Chrome's. Only
downloads that Chrome loads over the network are modeled: Playwright's
route.fulfill, andblob:,data:andfile:URLs, can give another MIME type. - Windows and macOS. Chrome on Windows and macOS also asks the system
which extension a MIME type has, while the package uses Chromium's built-in
table, which is all Chrome on Linux uses:
platform="windows"adds Windows' rules for names, not the system's table. For raw non-UTF-8 bytes in a name, without a default encoding, Chrome on Windows decodes them with the system's ANSI code page, where the package drops them, as Chrome on Linux does (see Reading Content-Disposition). - Hosts. Chromium converts a non-ASCII host with UTS #46; the package uses
Python's IDNA 2003 codec. They can disagree on whether a host is valid (a
right-to-left label that ends in a digit, a label longer than 63 characters)
and on the host's ASCII form (
ßandς). This only matters for URLs with non-ASCII hosts. requests, httpx, httpx2 and aiohttp give the host inresponse.urlin its ASCII form, buturllib.requestgives the URL as it was requested. - URL validity. Hosts are not parsed as IPv4 addresses, so a URL that Chromium rejects for a malformed address can still give a name here. A URL that a browser could load gives the same name either way.
- Unicode version. NFC normalization of
filename*uses the Unicode version of the running Python; Chromium's ICU has Unicode 17.0, which Python 3.15 also has. They differ only on characters that the older version does not have.