为什么Python解码比无效字节替换更多内容?

27 投票
4 回答
7430 浏览
提问于 2025-04-15 21:03

在尝试解码一个编码不正确的 UTF-8 HTML 页面时,Python、Firefox 和 Chrome 的结果各不相同。

这个测试页面中的无效编码片段看起来像 'PREFIX\xe3\xabSUFFIX'

>>> fragment = 'PREFIX\xe3\xabSUFFIX'
>>> fragment.decode('utf-8', 'strict')
...
UnicodeDecodeError: 'utf8' codec can't decode bytes in position 6-8: invalid data

更新: 这个问题最终在 Python 的 Unicode 组件中被记录为一个 错误报告。这个问题在 Python 2.7.11 和 3.5.2 中被修复。


接下来是 Python、Firefox 和 Chrome 处理解码错误时使用的替换策略。注意它们之间的不同,特别是 Python 内置的处理方式是如何去掉有效的 S(以及无效的字节序列)。

Python

Python 内置的 replace 错误处理器会把无效的 \xe3\xabSUFFIX 中的 S 替换为 U+FFFD。

>>> fragment.decode('utf-8', 'replace')
u'PREFIX\ufffdUFFIX'
>>> print _
PREFIX�UFFIX

浏览器

为了测试浏览器如何解码无效的字节序列,我们将使用一个 CGI 脚本:

#!/usr/bin/env python
print """\
Content-Type: text/plain; charset=utf-8

PREFIX\xe3\xabSUFFIX"""

Firefox 和 Chrome 浏览器的渲染结果是:

PREFIX�SUFFIX

为什么 Python 内置的 replace 错误处理器会从 SUFFIX 中去掉 S

(曾是更新 1)

根据维基百科的介绍,UTF-8(感谢 mjv),以下字节范围用于指示字节序列的开始:

  • 0xC2-0xDF : 2 字节序列的开始
  • 0xE0-0xEF : 3 字节序列的开始
  • 0xF0-0xF4 : 4 字节序列的开始

测试片段 'PREFIX\xe3\abSUFFIX' 中有 0xE3,这告诉 Python 解码器接下来是一个 3 字节的序列,但这个序列被认为是无效的,因此 Python 解码器会忽略整个序列,包括 '\xabS',然后继续处理后面的内容,忽略任何可能在中间的正确序列。

这意味着对于像 '\xF0SUFFIX' 这样的无效编码序列,它会解码为 u'\ufffdFIX',而不是 u'\ufffdSUFFIX'

示例 1: 引入 DOM 解析错误

>>> '<div>\xf0<div>Price: $20</div>...</div>'.decode('utf-8', 'replace')
u'<div>\ufffdv>Price: $20</div>...</div>'
>>> print _
<div>�v>Price: $20</div>...</div>

示例 2: 安全问题(也请参见 Unicode 安全考虑):

>>> '\xf0<!-- <script>alert("hi!");</script> -->'.decode('utf-8', 'replace')
u'\ufffd- <script>alert("hi!");</script> -->'
>>> print _
�- <script>alert("hi!");</script> -->

示例 3: 删除抓取应用中的有效信息

>>> '\xf0' + u'it\u2019s'.encode('utf-8') # "it’s"
'\xf0it\xe2\x80\x99s'
>>> _.decode('utf-8', 'replace')
u'\ufffd\ufffd\ufffds'
>>> print _
���s

使用 CGI 脚本在浏览器中渲染:

#!/usr/bin/env python
print """\
Content-Type: text/plain; charset=utf-8

\xf0it\xe2\x80\x99s"""

渲染结果:

�it’s

有没有官方推荐的处理解码替换的方法?

(曾是更新 2)

在一次 公开审查 中,Unicode 技术委员会选择了以下候选项的第 2 个选项:

  1. 用一个 U+FFFD 替换整个无效的子序列。
  2. 用一个 U+FFFD 替换无效子序列的每个最大子部分。
  3. 用一个 U+FFFD 替换无效子序列的每个代码单元。

UTC 的决议是在 2008-08-29,来源: http://www.unicode.org/review/resolved-pri-100.html

UTC Public Review 121 还包括一个无效字节流的示例 '\x61\xF1\x80\x80\xE1\x80\xC2\x62',它展示了每个选项的解码结果。

            61      F1      80      80      E1      80      C2      62
      1   U+0061  U+FFFD                                          U+0062
      2   U+0061  U+FFFD                  U+FFFD          U+FFFD  U+0062
      3   U+0061  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+0062

在普通 Python 中,三个结果是:

  1. u'a\ufffdb' 显示为 a�b
  2. u'a\ufffd\ufffd\ufffdb' 显示为 a���b
  3. u'a\ufffd\ufffd\ufffd\ufffd\ufffd\ufffdb' 显示为 a������b

而这是 Python 对无效示例字节流的处理:

>>> '\x61\xF1\x80\x80\xE1\x80\xC2\x62'.decode('utf-8', 'replace')
u'a\ufffd\ufffd\ufffd'
>>> print _
a���

再次使用 CGI 脚本测试浏览器如何渲染这些有问题的编码字节:

#!/usr/bin/env python
print """\
Content-Type: text/plain; charset=utf-8

\x61\xF1\x80\x80\xE1\x80\xC2\x62"""

Chrome 和 Firefox 的渲染结果是:

a���b

注意,浏览器的渲染结果与 PR121 推荐的第 2 个选项一致。

虽然 选项 3 在 Python 中看起来容易实现,但 选项 2 和 1 则比较具有挑战性。

>>> replace_option3 = lambda exc: (u'\ufffd', exc.start+1)
>>> codecs.register_error('replace_option3', replace_option3)
>>> '\x61\xF1\x80\x80\xE1\x80\xC2\x62'.decode('utf-8', 'replace_option3')
u'a\ufffd\ufffd\ufffd\ufffd\ufffd\ufffdb'
>>> print _
a������b

4 个回答

4

'PREFIX\xe3\xabSUFFIX' 这个例子中,\xe3 表示它和接下来的两个字节一起组成一个unicode字符。这里的 \xEy 对所有的y都是适用的。不过,\xe3\xabS 显然并不是一个有效的字符。因为Python知道它应该读取三个字节,所以无论如何它都会把这三个字节都读进来,因为它并不知道你的S是个S,而不是其他原因下的0x53这个字节。

9

0xE3这个字节是表示一个3字节字符的可能的第一个字节之一。

看起来,Python在解码时会把这三个字节拿来尝试解码,但结果发现它们并不对应任何实际的字符代码点,这就是为什么Python会报出UnicodeDecodeError,并用一个替代字符来表示错误。
不过,Python的解码逻辑在处理时似乎没有遵循Unicode联盟关于“格式不正确”的UTF-8序列的替代字符的建议。

想了解更多关于UTF-8编码的信息,可以查看维基百科上的UTF-8文章

新的(最终?)编辑:关于Unicode联盟推荐的替代字符实践(PR121)
(顺便说一下,恭喜dangra不断深入挖掘,使问题变得更好)
我和dangra在理解这个建议时都有些不准确;我最新的理解是,这个建议确实也提到了尝试“重新同步”。
关键概念是最大子部分 [对于一个格式不正确的序列]
根据PR121文档中提供的(唯一)示例,“最大子部分”意味着读取那些不可能是序列一部分的字节。例如,序列中的第5个字节0xE1不可能是“第二、第三或第四个字节”,因为它不在x80-xBF范围内,因此这就结束了以xF1开头的格式不正确的序列。接下来必须尝试用xE1等开始一个新的序列。同样,当遇到x62时,它也不能被解释为第二/第三/第四个字节,错误的序列就结束了,而“b”(x62)被“保存”了……

从这个角度来看(直到有更正为止;-)),Python的解码逻辑似乎是有问题的。

另外,查看John Machin在这个帖子中的回答,可以找到更多关于Unicode标准/建议的具体引用。

9

你知道你的 S 是有效的,因为你有前瞻性和回顾性的优势 :-) 假设原本有一个合法的 3 字节 UTF-8 序列,但在传输过程中第三个字节被损坏了……如果按照你提到的修改,你可能会抱怨一个多余的 S 没有被替换掉。在没有错误纠正代码、没有水晶球或没有手鼓的情况下,没有“正确”的处理方式。

更新

正如 @mjv 所说,UTC 问题主要是关于 应该包含多少个 U+FFFD。

实际上,Python 并没有使用 UTC 的三种选项中的任何一种。

这是 UTC 唯一的例子:

      61      F1      80      80      E1      80      C2      62
1   U+0061  U+FFFD                                          U+0062
2   U+0061  U+FFFD                  U+FFFD          U+FFFD  U+0062
3   U+0061  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+FFFD  U+0062

这是 Python 的处理方式:

>>> bad = '\x61\xf1\x80\x80\xe1\x80\xc2\x62cdef'
>>> bad.decode('utf8', 'replace')
u'a\ufffd\ufffd\ufffdcdef'
>>>

为什么呢?

F1 应该开始一个 4 字节的序列,但 E1 是无效的。一个坏序列,一个替换。
从下一个字节开始,第三个字节 80。砰,又一个 FFFD。
从 C2 开始,这引入了一个 2 字节的序列,但 C2 62 是无效的,所以又砰一次。

有趣的是,UTC 没有提到 Python 的做法(在前导字符指示的字节数后重新开始)。也许这在某个 Unicode 标准中实际上是被禁止或不推荐的。需要更多阅读。请关注这个话题。

更新 2 休斯顿,我们遇到问题了

=== 引自 Unicode 5.2 第三章 ===

转换过程的限制

要求不将字符串中的任何格式不正确的代码单元子序列解释为字符(见符合性条款 C10)对转换过程有重要影响。

例如,这些过程可能将 UTF-8 代码单元序列解释为 Unicode 字符序列。如果转换器遇到一个格式不正确的 UTF-8 代码单元序列,它以一个有效的首字节开始,但后续字节无效(见表 3-7),它必须不将后续字节作为格式不正确的子序列的一部分来消耗,无论这些后续字节本身是否构成一个格式正确的 UTF-8 代码单元子序列。

如果一个 UTF-8 转换过程在遇到第一个错误时停止,而不报告任何格式不正确的 UTF-8 代码单元子序列的结束,那么这个要求在实际操作中几乎没有区别。然而,如果 UTF-8 转换器在检测到错误后继续处理,可能会用一个或多个 U+FFFD 替换字符来替代无法解释的、格式不正确的 UTF-8 代码单元子序列,那么这个要求就引入了一个重要的限制。例如,对于输入的 UTF-8 代码单元序列 <C2 41 42>,这样的 UTF-8 转换过程 必须不返回 <U+FFFD><U+FFFD, U+0042>,因为这些输出将是错误地将一个格式正确的子序列解释为格式不正确的子序列的结果。这样的过程的预期返回值应该是 <U+FFFD, U+0041, U+0042>

对于一个 UTF-8 转换过程来说,消耗有效的后续字节不仅是 不符合规范,而且还使转换器面临 安全漏洞。请参见 Unicode 技术报告 #36,“Unicode 安全考虑”。

=== 引用结束 ===

接下来详细讨论了“应该发出多少个 FFFD”的问题,并给出了例子。

使用他们在倒数第二段引用中的例子:

>>> bad2 = "\xc2\x41\x42"
>>> bad2.decode('utf8', 'replace')
u'\ufffdB'
# FAIL

请注意,这个问题同时存在于 'replace' 'ignore' 选项的 str.decode('utf_8') 中——这都是关于省略数据,而不是发出多少个 U+FFFD;只要正确处理数据的输出,U+FFFD 的问题自然就会解决,正如我没有引用的部分所解释的那样。

更新 3 当前版本的 Python(包括 2.7)将 unicodedata.unidata_version 显示为 '5.1.0',这可能表示 Unicode 相关的代码旨在符合 Unicode 5.1.0。无论如何,Python 当前做的事情的冗长禁止条款直到 Unicode 5.2.0 才出现。我会在 Python 跟踪器上提出一个问题,而不提及 'oht'.encode('rot13') 这个词。

报告 在这里

撰写回答