行為與瀏覽器差異
這一頁說明本套件遵循的規則(也就是 Chromium 的規則)、Chrome 在這之外還做了什麼,以及 Firefox 和 Safari 的命名哪裡不同。
讀取標頭行
這一節說的是 Chrome 怎麼讀 HTTP/1.1 回應的標頭,瀏覽器測試驗證的也是這種回應。Chrome 先把標頭當成收到的位元組來讀,之後才讀個別的標頭。本套件也用同樣的方式讀每一個標頭值:把它當成該行標頭在冒號之後、照送出時原樣的其餘部分。所以值裡可以有換行;HTTP 早已不建議在標頭裡換行:
- 空行結束標頭。 一個 LF、可有可無的一個 CR,再接一個 LF,構成空行,之後的位元組是內容。
- 以空白或 tab 開頭的行延續這個標頭。 換行和該行開頭的空白與 tab 換成一個空白接上去;前一行結尾的空白與 tab 保留。所以
filename="a \r\n b.pdf"的檔名是a b.pdf,中間有兩個空白。 - 其他的行是另一行標頭,所以這個值在它前面的換行處結束。只有 CR 和 LF 會換行:垂直 tab(VT)和換頁(FF)是值的一部分,也不會開始一個延續的行。
- 標頭裡任何地方有 NUL(不論在名稱還是值裡),Chrome 就會拒絕整個回應(
ERR_INVALID_HTTP_RESPONSE),就像它拒絕 Content-Disposition 的值彼此不同的回應一樣(見讀取 Content-Disposition)。parse_content_disposition用blocks_download回報這種情況,suggested_filename照樣決定檔名。空行之後的 NUL 屬於內容,不影響結果。
每個名稱都當成一行標頭的名稱、也就是第一個冒號之前的那一段,照 Chrome 的方式比對:去掉結尾的空白和 tab 之後比對,不分 ASCII 大小寫,所以 Content-Disposition \t 就是 Content-Disposition。名稱是空的、以空白或 tab 開頭,或去掉結尾的空白和 tab 之後不是 token 時,配對不到任何標頭,就像 Chrome 略過這樣的一行。去掉的只有空白和 tab,所以結尾是其他控制字元(例如垂直 tab)的名稱不是 token。
本套件把每個名稱和每個值分開來讀,所以它們在線上構成的標頭行,並不是每一行都照 Chrome 的方式讀(見移植的限制)。
讀取 Content-Disposition
- 只看第一個值。 每一行 Content-Disposition 都會在引號外的逗號處切開,檔名取自第一行的第一個值。所以只要引號都有關上,重複的標頭不論是分成好幾行,還是被 HTTP 函式庫用
,接成一行,結果都一樣。沒關上的引號會一路延續到該行結尾,接成一行時就會吞進後面的值:attachment; filename="a和attachment; filename=b兩行,檔名是a,而且有兩個不同的值;接成一行則是單一個值,檔名是a, attachment; filename=b。只要有任何一個值跟第一個值不同(去掉前後的空白和 tab 之後逐位元組比較),Chrome 就會拒絕整個回應,什麼都不下載;有download屬性的連結則會開始一個失敗的下載(見使用方式)。內容不同的重複標頭會這樣,只有一個標頭、但引號外有逗號時也會,例如filename=a,b.txt或結尾多一個逗號。parse_content_disposition用blocks_download回報這種情況,標頭裡有 NUL 時也是(見讀取標頭行);suggested_filename照樣用第一個值決定檔名。 - 處置類型。
inline是 inline;attachment和其他任何 token 都是 attachment。不是以 token 開頭的標頭(例如filename=report.pdf)會整個當成參數來讀,類型是 inline,裡面的檔名照樣採用。 - 參數由左到右讀,遇到第一個格式錯誤的參數就停止:沒有
=、值是空的,或名稱裡有引號。 filename*優先,不論出現順序,只要能解碼。它的值是charset'language'百分比編碼的位元組(RFC 8187):語言標記會被忽略;字元集可以是 Chromium 認得的任何一種(UTF-8、ISO-8859-1、Shift_JIS……);位元組必須是該字元集的合法內容;結果會正規化成 NFC。無法解碼的filename*會被略過,改用filename。filename可以是 token 或加引號的字串,而且照瀏覽器的方式解碼,即使 RFC 不允許:- 原始 UTF-8 位元組照 UTF-8 解碼;
- 百分比編碼會被解碼(
%E4%B8%AD%E6%96%87.zip是中文.zip),解出來不是 UTF-8 時整個檔名作廢; - RFC 2047 的 encoded word 會被解碼(
=?UTF-8?B?5Lit5paHLnppcA==?=); - 其他原始位元組,有
fallback_charset時用它解碼;沒有的話就丟掉(Linux 上的 Chrome 就是這樣),通常結果是沒有檔名。
- 不支援(跟 Chromium 一樣):RFC 2231 的分段寫法(
filename*0=、filename*1*=),會被忽略。
選出檔名
suggested_filename 依序取下列第一個非空的結果:
- Content-Disposition 裡的檔名,不論處置類型是什麼;
download_attribute,也就是<a download>的值;- 網址路徑的最後一段:最後一個
/或\之後、;之前的部分,並做百分比解碼。解出的位元組必須是 UTF-8,或能用fallback_charset解碼。data:和about:網址,以及不合法的網址,都不提供檔名。 - 網址的主機名稱,但只在
default_name是None時。
清洗檔名
接著依序:
- 去掉開頭和結尾的
.(在 Windows 上,結尾的.和空白會先換成_),並把/和\換成_。 - 如果這時檔名是空的,或只由
-和_組成,就換成default_name(沒有另外指定時是download);default_name是None時換成download。 - 任何位置都不允許的字元換成
_:"、*、/、:、<、>、?、\、|、控制字元、Unicode 格式字元(例如由右至左覆寫字元 U+202E)以及非字元(noncharacter)。出現在開頭或結尾的空白、.和~也換成_。 - 套用 MIME 類型的副檔名規則(見 MIME 類型)和 Windows 規則,說明見使用方式。
所以由 report、U+202E 和 txt.exe 組成、看起來像 reportexe.txt 的檔名,會變成 report_txt.exe。
MIME 類型
副檔名取自 Chrome 網路層交給下載程式碼的 MIME 類型。本套件用同樣的方式,從 headers 和 content_type 推導出它;兩者都沒有時,不使用 MIME 類型。
- Content-Type。 所有 Content-Type 行用
,接起來,再在引號外的逗號處切開,所以某一行沒關上的引號會延續到下一行。每個值的類型從開頭算到第一個空白、tab、;或(之前,而且必須包含該值的第一個/。除此之外不做任何檢查,所以text/和"text/html"都算類型,只是沒有對應的副檔名。最後一個有類型的值決定 MIME 類型,並轉成 ASCII 小寫;沒有類型的值,以及剛好是*/*的值,不改變結果。所以text/plain, text/html是text/html,text/html, bogus是text/html,*/*; q=1則是*/*。 - 檢查內容(sniffing)。 類型沒有給、是
text/plain、application/octet-stream、text/xml、application/xml、Microsoft Office 類型,或是未知的類型(unknown/unknown、application/unknown、*/*)時,Chrome 會檢查內容、改用別的 MIME 類型;除非第一行 X-Content-Type-Options 的第一個值是nosniff(不分 ASCII 大小寫)。Chrome 只檢查http:、https:、file:和filesystem:網址的內容;沒有傳url時,本套件當成會檢查。本套件看不到內容:Chrome 會檢查內容時,它用宣告的類型,或不用任何類型(見使用方式)。 - 不檢查內容時,沒有類型就當成
text/plain;feed 類型application/rss+xml和application/atom+xml也換成text/plain。
Chrome 在這之外還做的事
本套件回傳的是下載開始時,Chrome 回報給 DevTools 和 Playwright 的檔名,移植的限制列出的情況除外。Chrome 最後實際寫入的檔案,名稱可能不同:
- 預設文字編碼。 遇到不是 UTF-8 的原始位元組,Chrome 會用瀏覽器的預設文字編碼解碼。請把它傳給
fallback_charset(見使用方式)。 - 已存在的檔案。 Chrome 會替檔名編號,例如
report (1).pdf。 - 過長的檔名。 檔名超過檔案系統的長度上限時,Chrome 會截短它,並保留副檔名。
- ChromeOS。 ChromeOS 會把檔名正規化成 NFC。
- 檔名以外的一切:「另存新檔」對話框、安全瀏覽(Safe Browsing)與危險檔案警告、會改下載檔名的擴充功能,以及企業政策。
其他瀏覽器
格式正確的標頭,以及最常見的幾種不合格寫法,Firefox 和 Safari 的結果跟 Chrome 一致:不論順序都以 filename* 優先、語言標記會被忽略、filename 裡的原始 UTF-8 照 UTF-8 解碼、引號內的分號是檔名的一部分、結尾多一個 ; 也沒關係,沒寫處置類型的標頭也照讀。其餘的就各有不同:
| 行為 | Chrome | Firefox | Safari |
|---|---|---|---|
filename 裡的百分比編碼會被解碼 |
是 | 是 | 否 |
| RFC 2047 的 encoded word 會被解碼 | 是 | 是 | 否 |
RFC 2231 分段(filename*0=)會被接起來 |
否 | 是 | 否 |
Windows 不允許的字元(例如 <、?、*)會被替換 |
是 | 是 | 否 |
| 名稱和冒號之間有空白或 tab 的標頭照樣會讀 | 是 | 否 | 否 |
| 結尾的空白會變成 | _ |
被去掉 | 保留空白 |
Browsers workflow 會用 Firefox,以及 Linux 和 macOS 上的 WebKit 下載每一個測試案例,每次執行的摘要都會列出它們跟 Chrome 檔名不同的案例。上表就是這些執行的結果,使用 Playwright 1.64:Linux 上的 Chromium 156(代表 Chrome)和 Firefox 157,以及代表 Safari 的 macOS 上的 WebKit 27.2;macOS 上的 WebKit 跟 Safari 一樣,透過 Apple 的網路程式碼決定下載檔名。精確集合的 920 個案例中,Firefox 有 468 個、macOS 上的 WebKit 有 351 個跟 Chrome 的檔名相同;限制集合的 16 個案例中,Firefox 有 13 個、macOS 上的 WebKit 有 15 個相同(兩組案例的說明見如何驗證)。Chrome 拒收回應(見移植的限制)、什麼都不下載的案例,另一個瀏覽器也什麼都不下載,才算跟 Chrome 相同。
移植的限制
- 被拒收的回應。 Content-Disposition 的值彼此不同,或標頭裡有 NUL 時,Chrome 會拒絕這個回應(見讀取 Content-Disposition和讀取標頭行),
blocks_download會回報這兩種情況。Chrome 拒絕回應還有本套件不檢查的其他原因,例如重複出現、值又不同的 Location 標頭。不論是哪一種,suggested_filename都照樣給出檔名。 - 不是線上原樣的標頭行。 本套件把收到的標頭當成 Chrome 收到的那些行來讀,所以標頭不是線上原樣時,檔名可能不同:HTTP 函式庫改動過標頭行,或沒有用
headers_encoding就傳入字串(見從 HTTP 函式庫傳入標頭);以及只給content_type、沒給其他標頭,因而少了 X-Content-Type-Options(見依 Content-Type 補副檔名)。 - 值裡的標頭行。 本套件分開讀每一個標頭值(見讀取標頭行)。值裡的換行後面接著另一行標頭時,這個值在換行處結束,但本套件不會像 Chrome 那樣把那一行讀成標頭:在
attachment; filename=a.pdf\rContent-Disposition: attachment; filename=b.pdf裡,它看不到第二個 Content-Disposition,而 Chrome 會因此拒絕這個回應。值裡的空行,或值結尾的 LF(它跟後面的換行構成空行),會在線上結束標頭;本套件在那裡結束這個值,但仍會繼續讀之後的標頭行。使用方式介紹的 HTTP 函式庫會把這種行切成各自獨立的標頭行、在空行處結束標頭,或拒絕這個回應,所以只有自己組出標頭行時才需要注意。 - 名稱含冒號或換行,或以空白或 tab 開頭。 本套件把每個名稱當成一行標頭的名稱來比對(見讀取標頭行)。在線上,Chrome 在第一個冒號處結束名稱、在 CR 或 LF 處換到另一行,以空白或 tab 開頭的行也可能接到前一行標頭上,所以 Chrome 讀到的標頭可能跟本套件不同:名稱是
Content-Disposition:attachment; filename=b.txt;x、值是y時,Chrome 的檔名是b.txt,本套件則讀不到 Content-Disposition。名稱裡的換行跟名稱裡的另一個換行、或跟名稱前面的換行構成空行時,會在線上結束標頭,但本套件仍會繼續讀之後的標頭行。 - MIME sniffing。 Chrome 檢查內容來決定 MIME 類型時,本套件用宣告的類型,所以副檔名可能跟 Chrome 的不同。只模擬 Chrome 經由網路載入的下載:Playwright 的
route.fulfill,以及blob:、data:、file:網址,可能得到不同的 MIME 類型。 - Windows 和 macOS。 Windows 和 macOS 上的 Chrome 還會詢問系統某個 MIME 類型對應什麼副檔名;本套件只用 Chromium 內建的對照表,Linux 上的 Chrome 也只用這份。
platform="windows"加上的是 Windows 的檔名規則,不包括系統的對照表。沒有預設編碼時,對檔名裡不是 UTF-8 的原始位元組,Windows 上的 Chrome 用系統的 ANSI code page 解碼;本套件則跟 Linux 上的 Chrome 一樣把它們丟掉(見讀取 Content-Disposition)。 - 主機名稱。 Chromium 用 UTS #46 轉換非 ASCII 的主機名稱,本套件用的是 Python 的 IDNA 2003 codec。兩者對某些主機名稱是否合法(以數字結尾的由右至左標籤、超過 63 個字元的標籤)以及主機名稱的 ASCII 形式(
ß和ς)看法不同。這只影響主機名稱含非 ASCII 字元的網址。requests、httpx、httpx2 和 aiohttp 在response.url裡給的都是 ASCII 形式的主機名稱,urllib.request則照請求時的網址原樣給出。 - 網址是否合法。 主機名稱不會被當成 IPv4 位址解析,所以 Chromium 因為位址格式錯誤而拒絕的網址,在這裡仍可能得到檔名。瀏覽器能載入的網址,兩邊得到的檔名都相同。
- Unicode 版本。
filename*的 NFC 正規化用的是執行中 Python 的 Unicode 版本;Chromium 的 ICU 是 Unicode 17.0,Python 3.15 也是。兩者只會在舊版本沒有的字元上不同。