百分号表示的是字节,不是直接表示字符
链接路径、查询参数和表单内容都可能包含转义字符,但不能假设它们完全采用相同规则。特别是空格与加号的关系,如果在错误的场景中转换,就会改变原始内容。
因此,解码入口需要知道当前处理的是哪类数据,而不是看到某个符号就统一替换。上下文明确后,输出结果才有稳定的含义。
一个非 ASCII 字符通常先按约定编码成多个字节,每个字节再写成百分号加两位十六进制数字。恢复时也应先得到字节序列,最后统一解码为文本。
如果把每个转义值直接当成 Unicode 字符,就会把原本属于同一个 UTF-8 字符的多个字节拆开,得到一串错误文字。处理顺序看似细小,却决定了非英文输入是否能够正确往返。
加号的含义来自上下文
from urllib.parse import unquote, unquote_plus
value = "a+b%2Bc"
assert unquote(value) == "a+b+c"
assert unquote_plus(value) == "a b+c"在某些表单编码中,加号表示空格;在普通百分号转义中,加号可以就是加号本身。调用者必须说明当前数据使用哪一种语义,底层工具不能只根据字符串内容猜测。
特别是加号作为原文时,应使用对应的转义表达,避免恢复时被当成空格。生成与解析若选择了不同规则,即使所有字符都是 ASCII,也会发生内容改变。
先检查转义形状,再处理字符编码
处理时可以沿输入顺序识别完整的转义片段,符合规则才转换为对应字节。遇到不完整片段时,按照已约定的兼容策略保留或拒绝,避免前后步骤采用不同处理方式。
是否把加号还原为空格,应作为独立选项由调用场景决定。还原之后也不要在没有协议依据的情况下再解码一遍,否则原本作为文本存在的转义符可能被重新解释。
import re
from urllib.parse import unquote_to_bytes
encoded = "%E4%B8%AD"
if re.search(r"%(?![0-9A-Fa-f]{2})", encoded):
raise ValueError("非法百分号转义")
raw = unquote_to_bytes(encoded)
text = raw.decode("utf-8", errors="strict")
assert text == "中"这段代码把两个判断分开:百分号后是否有合法的十六进制字节,以及恢复后的字节能否构成约定编码的文本。第一层通过,不代表第二层一定通过,例如单独恢复出某些 UTF-8 延续字节仍然是无效输入。
某些兼容协议会选择保留不合法的百分号片段。这可以是一种约定,但应该在文档和测试中明确写出,不应依赖某个运行时碰巧采取的宽松行为。
一次解码完成后,不要继续猜测
例如百分号本身被编码后,再解一次可能产生新的转义形式。若循环解码直到字符串不再变化,输入就获得了隐含的多层含义,调用者难以判断实际会参与后续处理的内容。
| 输入形态 | 一次解码后的含义 |
|---|---|
| %2B | 加号 |
| %252B | 文本 %2B |
| a+b | 由当前上下文决定加号或空格 |
| %G1 | 按协议拒绝或保留 |
路径、查询字段和完整 URL 也不应当作同一个上下文处理。对整个 URL 先解码,再拆分路径和查询,可能把转义后的分隔字符提前恢复为真正的结构分隔符。通常应先识别结构,再解码对应字段。
把解码边界放在明确的接口处
转义恢复只解决传输表达,不能证明内容可以作为文件路径、跳转地址或查询语句使用。不同用途仍需要自己的边界验证。
验收可以包含普通文本、合法转义、残缺片段、加号与空格混排,以及本身包含百分号的内容。重点是恢复过程可预测,并且不会因为多做了一次转换而改变用户原意。
框架可能已经替应用解码过查询参数。如果业务代码再次无条件解码,就会出现双重解码。检查入口时,应确认拿到的是原始传输形式还是已经恢复的字段值,并让类型或变量名称表达这一点。
测试至少覆盖原始加号、转义加号、空格、中文、多层转义、不完整百分号和非法 UTF-8。每个测试都要写明上下文与层数,否则所谓正确结果只是某个默认接口的输出。
最后,解码只是还原数据,不是清理危险内容。恢复后的文本要进入文件访问、数据库查询或页面输出时,仍需要各自的参数化、路径限制或转义机制。不要把“已经解码”误当成“可以在任何位置使用”。