第一次踩坑是三年前。用户解析了一个标题叫"直播回放 2026/03/11 完整版"的视频,下载服务把它当成文件名去保存,结果:
FileNotFoundError: [Errno 2] No such file or directory: '/data/直播回放 2026/03/11 完整版.mp4'因为标题里那个
/被当成了目录分隔符。我当时的修复很简单粗暴——把/替换成_。然后第二天来了第二个问题:有个文件在 Windows 上保存失败,文件名叫CON.mp4。再然后是 macOS 用户传上来的文件名在 Linux 上"明明打印出来一样,但==比较就是 False"。再然后是文件名太长(255 字节限制),再然后是 emoji 文件名在 exFAT 移动硬盘上变成乱码……三年下来,那个当初一行的替换变成了一个 80 行的函数。这篇文章把这条路上遇到的每一个坑、对应的规则,以及最终的实现写出来。
TL;DR:文件名处理要同时过五关:非法字符(Windows 的 9 个 + 控制字符)、保留名(CON/PRN/AUX/NUL/COM1-9/LPT1-9)、长度(Linux/ext4 是 255 字节不是字符,Windows 路径 260)、Unicode 归一化(macOS 存 NFD、其他存 NFC,不归一化会导致"看起来一样但不等")、结尾字符(Windows 不允许空格和点结尾)。路径层面还要防
../穿越。生产环境建议直接用pathvalidate这类成熟库,但要知道它在干什么。
目录
- 一、第一版:我以为只是斜杠的问题
- 二、各平台的规则到底是什么
- 三、Unicode 归一化:NFD 与 NFC 的坑
- 四、长度限制:255 字节,不是 255 个字符
- 五、看不见的字符:控制字符、零宽字符、RTL 覆盖
- 六、路径穿越:../ 不只是"开头有斜杠"那么简单
- 七、最终版 sanitize 函数
- 八、同名冲突与大小写
- 九、怎么测试这玩意儿
- 十、坑清单
一、第一版:我以为只是斜杠的问题
第一版长这样:
def safe_name(name: str) -> str:
return name.replace('/', '_')
它解决了当天的问题,然后留下了后面所有的坑。我把它列出来,是因为几乎每个写过文件保存功能的人,第一版都是这个:
| 问题 | 表现 | 什么时候发现 |
|---|---|---|
Windows 非法字符 :*?"<>\| |
Windows 客户端保存失败 | 一周后 |
Windows 保留名 CON NUL |
保存的文件打不开/消失 | 一个月后 |
| 结尾空格/点 | Windows 自动去掉,程序再找就找不到 | 两个月后 |
| 长度 > 255 字节 | OSError: [Errno 36] File name too long |
数据量上来之后 |
| NFD/NFC | 同一个文件被识别成两个 | 有 macOS 用户之后 |
| 控制字符 | 文件名里有换行,日志全乱 | 半年后 |
| emoji + exFAT | 拷贝到移动硬盘后乱码 | 刻盘交付时 |
核心教训:文件名不是"随便一个字符串",它是一个有严格语法的系统资源标识符,而且每个操作系统的语法还不一样。你不能用"处理文本"的心态去处理它。
二、各平台的规则到底是什么
我查了一圈(主要靠实际测试 + 各家的文档),整理成这张表:
| 平台/文件系统 | 禁止的字符 | 保留名 | 长度限制 | 大小写 |
|---|---|---|---|---|
| Linux ext4/XFS | / 和 NUL(\0) |
无 | 单文件名 255 字节 | 敏感 |
| Windows (Win32 API) | < > : " / \ | ? * 及 0x00-0x1F |
CON PRN AUX NUL COM1~COM9 LPT1~LPT9(带扩展名也算,如 CON.txt) |
路径总长 260(旧 API);文件名本身也基本 ≤255 | 不敏感 |
| macOS (APFS) | / 和 NUL(: 在 Finder 里会显示成 /) |
无 | 255 UTF-8 字节 | 默认不敏感 |
| exFAT | 同 Windows 的非法字符集 | 无 | 255 UTF-16 码元 | 不敏感 |
| FAT32 | 同 Windows + 更多 | 无 | 8.3 或长文件名 255;单文件最大 4GB-1 | 不敏感 |
几个容易搞错的点:
1. NTFS 本身允许很多东西,限制来自 Win32 API。 NTFS 支持 32767 字符的路径、允许大部分 Unicode,但你通过 Python(走 Win32 API)操作时,还是受上表的限制。所以"NTFS 支持"不等于"你的程序能写"。
2. Windows 的保留名检查是"忽略扩展名"的。CON.mp4、con.MP4、Con.txt.bak 全部中招。而且它不区分大小写。我踩的就是这个:一个视频标题正好是 CON(某个设备名相关的词),保存出来的文件在 Windows 上根本打不开。
3. Windows 不允许文件名以空格或点结尾。myvideo. 或 myvideo 会被系统自动截断成 myvideo。这个坑特别阴险——保存的时候不报错,只是悄悄改了,等你按原名字去找就找不到了。
4. FAT32 的 4GB 限制到现在还在坑人。客户拿移动硬盘来拷素材,IOError: [Errno 27] File too large,一看是 FAT32。现在我的交付文档里会写一句"移动硬盘请格式化成 exFAT 或 NTFS"。
5. 大小写不敏感导致同名冲突。Windows 上 Video.mp4 和 video.mp4 是同一个文件;Linux 上是两个。所以"先检查存不存在再保存"这个逻辑,在 Windows 上要用 os.path.exists()(它是大小写不敏感的,行为跟系统一致),别自己用字符串比较。
三、Unicode 归一化:NFD 与 NFC 的坑
这是我觉得最"反直觉"的一个坑,值得单独讲。
同一个"é",有两种表示方式:
- NFC(合成形式):一个码位
U+00E9 - NFD(分解形式):两个码位
U+0065(e) +U+0301(重音符号)
在终端里打印出来都是 é,肉眼完全看不出区别,但:
>>> a = 'café' # 从 macOS 来的,NFD
>>> b = 'café' # 从 Linux 来的,NFC
>>> a == b
False
>>> len(a), len(b)
(5, 4)
macOS 的文件系统(HFS+/APFS)默认用 NFD 存储文件名,Linux 和 Windows 用 NFC。 所以:
- 用户在 Mac 上上传
café.mp4,存到 Linux 服务器; - 服务器按 NFC 存了一份;
- 用户再从 Mac 上访问,Mac 传过来的是 NFD 形式;
- 服务器判断"文件不存在",又存了一份;
- 现在有两份"看起来一样"的文件。
我在一次去重任务里发现同一批素材里"重复率异常高",查了半天才发现是这个——去重逻辑用文件名做 key,而 NF D 和 NFC 被当成了不同文件。
解决办法:入库前统一归一化。
import unicodedata
def normalize(name: str) -> str:
return unicodedata.normalize('NFC', name)
用 NFC 还是 NFD? 我选 NFC,理由是:
- Linux/Windows 生态是主流,NFC 是它们的原生形式;
- NFC 的字符串更短(合成形式占字节少);
- 大多数工具和库默认按 NFC 处理,兼容性更好。
NFKC/NFKD 要慎用。它们是"兼容归一化",会做语义转换:
>>> unicodedata.normalize('NFKC', 'fi') # 连字
'fi'
>>> unicodedata.normalize('NFKC', '①')
'1'
>>> unicodedata.normalize('NFKC', '2') # 全角 2
'2'
对文件名来说,这可能改变了用户原本的意思。我只用它做"搜索用的归一化副本"(比如建索引时存一份 NFKC 形式用于模糊匹配),绝不拿它当最终文件名。
还有一类是全角/半角和中英文标点,比如标题里的 :(全角冒号)在 Windows 上是允许的(不在非法字符列表里),但有些老工具处理不了。我的处理是保留原样——除非确认目标系统会出问题,否则不要替用户改内容。宁可文件名长一点,也不要让用户找不到自己的文件。
四、长度限制:255 字节,不是 255 个字符
这个错误信息我见过很多次:
OSError: [Errno 36] File name too long: 'xxx.mp4'
ext4 限制的是 255 字节(bytes),不是 255 个字符(characters)。中文在 UTF-8 里是 3 字节一个字,所以:
>>> len('我' * 100) # 100 个字符
100
>>> len(('我' * 100).encode()) # 300 字节 → 超限!
300
一个纯中文文件名最多只能有 85 个汉字(255 / 3)。加上扩展名 .mp4 和可能的序号后缀,实际能用的更少。emoji 在 UTF-8 里通常 4 字节,一个 emoji 占 4 字节,255 字节只能放 63 个 emoji。
截断必须按字节做,而且不能切在多字节字符中间,否则会得到非法 UTF-8:
def truncate_bytes(name: str, max_bytes: int = 255, reserve: int = 8) -> str:
"""按字节安全截断 UTF-8 字符串,不切断多字节字符。"""
encoded = name.encode('utf-8')
limit = max_bytes - reserve # 给序号/扩展名留位置
if len(encoded) <= limit:
return name
# 从 limit 往前找,找到第一个完整的字符边界
cut = limit
while cut > 0 and (encoded[cut] & 0xC0) == 0x80: # 0x80 开头是 continuation byte
cut -= 1
return encoded[:cut].decode('utf-8', errors='ignore')
关键点在 while 那两行:UTF-8 的后续字节都以 10xxxxxx 开头(即 & 0xC0 == 0x80),往前回退直到遇到一个非后续字节,就是字符边界。用 errors='ignore' 兜底,保证不会抛异常。
我用 reserve 留出空间,是因为后面可能要加 (1)、(2) 这种去重后缀和扩展名——先截到 255 再加后缀,又超了。
Windows 的 260 字符路径限制是另一回事(整个路径,不只是文件名)。Python 3.6+ 在 Windows 上默认支持长路径(如果系统开启了 LongPathsEnabled),但不能指望。我的做法是:目录层级别太深,文件名别太长,把总路径控制在 200 字符以内。
五、看不见的字符:控制字符、零宽字符、RTL 覆盖
这类字符的特点是:你在终端里看不到它们,但它们会搞乱一切。
1. 控制字符(0x00-0x1F)
标题里带换行符 \n 是最常见的(有些平台的标题就是多行文本)。后果:
- 日志文件被"换行",一条记录变两条;
- CSV 导出直接错位;
- 某些 shell 命令被截断。
import re
name = re.sub(r'[\x00-\x1f\x7f]', '', name) # 去掉控制字符
2. 零宽字符(U+200B 零宽空格、U+200C/200D、U+FEFF BOM)
这些字符显示时完全不可见,但会让两个"看起来一样"的文件名不相等。U+FEFF(BOM)出现在字符串开头时还会导致 JSON 解析问题。
name = name.replace('\ufeff', '').replace('\u200b', '')
name = re.sub(r'[\u200c\u200d\u2060]', '', name)
3. RTL 覆盖字符(U+202E / RLO)——这个是安全问题
U+202E 是"从右到左覆盖",它会让它后面的文字反向显示。这是钓鱼邮件的经典手法:
文件名实际是: videotxt.exe
用户看到的是: videoexe.txt
在文件下载的语境里,这意味着你以为用户拿到的是视频,实际是可执行文件。虽然我们的服务不会去执行它,但用户会。
处理办法(二选一,我两个都做了):
# 方案 A:直接删除双向控制字符
name = re.sub(r'[\u202a-\u202e\u2066-\u2069]', '', name)
# 方案 B:检测到就标记,走人工审核/拒绝
if re.search(r'[\u202a-\u202e]', name):
logger.warning('suspicious bidi characters in filename: %r', name)
这条是我唯一会"拒绝服务"而不是"清洗"的规则——因为它几乎只出现在恶意场景里。
六、路径穿越:../ 不只是"开头有斜杠"那么简单
如果文件名里有 ../,而且你直接把它拼进路径:
path = os.path.join(BASE_DIR, user_input) # user_input = '../../etc/passwd'
open(path, 'w') # 写到系统目录去了
这就是经典的路径穿越(Path Traversal)。对下载服务来说尤其危险,因为文件名通常来自用户提供的 URL 或页面标题——完全不可信。
正确处理:
import os
def safe_join(base: str, *parts: str) -> str:
"""把用户输入的路径片段安全地拼到 base 下,确保结果不超出 base。"""
base = os.path.realpath(base)
target = os.path.realpath(os.path.join(base, *parts))
# 关键:必须是 base 本身或者它的子路径
if target != base and not target.startswith(base + os.sep):
raise ValueError(f'path traversal detected: {target}')
return target
几个要点:
- 用
realpath而不是abspath。abspath只做规范化(a/../b→b),不解析符号链接;realpath会解析。如果 base 目录里有个软链接指向外面,abspath检查不出来。 - 检查
base + os.sep前缀,别只检查startswith(base)——否则/data/backup能通过/data的检查(因为/data/backup.startswith('/data') 为真,但它可能是另一个目录)。 - Windows 上要同时处理
/和\。..\\也是穿越。用os.path系列函数能处理大部分,但输入清洗阶段最好两个都替换掉。
我们的做法还有一层:文件名和目录分开管理。用户提供的标题只用来生成文件名(不含任何分隔符),目录由服务端按日期/用户 ID 生成。这样即使清洗漏了什么,影响范围也仅限于一个目录内的文件名。
七、最终版 sanitize 函数
把上面的规则合起来,这是我现在在用的(生产版本简化后):
import os
import re
import unicodedata
import logging
logger = logging.getLogger(__name__)
# Windows 非法字符(含路径分隔符)
ILLEGAL_CHARS = r'[<>:"|?*\\\/\x00-\x1f\x7f]'
# 双向控制字符(安全相关)
BIDI_CHARS = r'[\u202a-\u202e\u2066-\u2069]'
# 零宽字符
ZERO_WIDTH = r'[\u200b-\u200d\u2060\ufeff]'
# Windows 保留设备名
RESERVED = {
'CON', 'PRN', 'AUX', 'NUL',
*(f'COM{i}' for i in range(1, 10)),
*(f'LPT{i}' for i in range(1, 10)),
}
MAX_BYTES = 255
RESERVE_BYTES = 12 # 给扩展名 + 去重序号留位置
def truncate_bytes(name: str, max_bytes: int) -> str:
encoded = name.encode('utf-8')
if len(encoded) <= max_bytes:
return name
cut = max_bytes
while cut > 0 and (encoded[cut] & 0xC0) == 0x80:
cut -= 1
return encoded[:cut].decode('utf-8', errors='ignore')
def sanitize_filename(name: str, default: str = 'untitled') -> str:
"""把任意字符串清洗成可安全落盘的文件名(不含扩展名)。"""
if not name or not name.strip():
return default
# 1. Unicode 归一化(统一 NFC)
name = unicodedata.normalize('NFC', name)
# 2. 去掉双向控制字符(安全),记日志
if re.search(BIDI_CHARS, name):
logger.warning('bidi control chars found in filename: %r', name)
name = re.sub(BIDI_CHARS, '', name)
# 3. 去掉零宽字符
name = re.sub(ZERO_WIDTH, '', name)
# 4. 非法字符 → 替换成下划线(保留可读性,别删掉,否则语义丢失)
name = re.sub(ILLEGAL_CHARS, '_', name)
# 5. 合并连续的下划线/空白,去首尾空白
name = re.sub(r'\s+', ' ', name).strip()
# 6. Windows 不允许以点或空格结尾
name = name.rstrip('. ')
# 7. 保留设备名检查(忽略大小写和扩展名)
stem = name.split('.')[0].upper()
if stem in RESERVED:
name = '_' + name
# 8. 按字节截断
name = truncate_bytes(name, MAX_BYTES - RESERVE_BYTES)
# 9. 兜底:清洗完变空了
return name or default
几个设计选择说明理由:
为什么用 _ 替换而不是删除? 保留可读性。A:B 变成 AB 和 A_B,后者用户还能看懂。删掉的话语义丢失更多。
为什么保留名加前缀 _ 而不是改名? 用户看到的还是自己的标题(前面多个下划线),比变成 file_001 好认。
为什么先归一化再清洗? 顺序有讲究:NFD 形式的某些字符在分解后可能暴露出组合字符,先归一化能保证后续正则匹配的一致性。
生产建议:直接用 pathvalidate。 我们项目里后来引入了它(requirements 里有),它把上面这些规则都实现好了,而且处理得更细(比如 Windows 的 CON.txt 检查、平台自适应):
from pathvalidate import sanitize_filename, sanitize_filepath
sanitize_filename('直播回放 2026/03/11.mp4', replacement_text='_')
# '直播回放 2026_03_11.mp4'
sanitize_filename('CON.mp4', replacement_text='_')
# '_CON.mp4'
但我依然保留了自研的那层,原因是:
- 双向控制字符的检测(安全策略)是业务相关的,
pathvalidate不管这个; - 按字节截断的
reserve逻辑跟我们的命名规则耦合; - 归一化策略(NFC)要跟全链路一致。
我的建议是:用库打底,业务规则自己加。 别自己实现那张非法字符表——那是别人踩过的坑,没必要再踩一遍。
八、同名冲突与大小写
清洗之后还有最后一关:同一个目录下已经有同名文件怎么办。
def unique_path(directory: str, filename: str, ext: str) -> str:
"""返回一个不冲突的完整路径,冲突时追加 (1) (2)..."""
base = os.path.join(directory, filename + ext)
if not os.path.exists(base):
return base
for i in range(1, 1000):
candidate = os.path.join(directory, f'{filename}({i}){ext}')
if not os.path.exists(candidate):
return candidate
# 实在不行,加时间戳兜底
import time
return os.path.join(directory, f'{filename}_{int(time.time())}{ext}')
这里有 TOCTOU 竞态:os.path.exists() 和真正的创建之间有时间差,两个进程可能同时判断"不存在"然后都去创建。解决办法是直接用独占创建:
import os
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
with os.fdopen(fd, 'wb') as f:
f.write(data)
O_EXCL 保证"文件不存在才创建,否则报错"。这是原子操作,由内核保证。捕获 FileExistsError 再换名字重试就行。
大小写冲突:在 Windows/exFAT 上,Video.mp4 和 video.mp4 冲突,但 os.path.exists() 的行为跟系统一致(Windows 上返回 True),所以用系统 API 判断就对了。不要在 Linux 上模拟 Windows 的大小写规则,容易出错;如果必须跨平台一致,就把文件名统一转小写(或者存一份小写形式用于比较)。
九、怎么测试这玩意儿
这个函数是"输入空间极大、出错后果严重"的典型,值得好好测。我的测试用例表:
| 输入 | 期望输出 | 覆盖的规则 |
|---|---|---|
直播回放 2026/03/11 |
直播回放 2026_03_11 |
斜杠 |
a:b*c?d"e<f>g\|h |
a_b_c_d_e_f_g_h |
Windows 非法字符 |
CON |
_CON |
保留名 |
con.txt |
_con.txt |
保留名带扩展名 |
myvideo. |
myvideo |
结尾点 |
myvideo |
myvideo |
结尾空格 |
café(NFD 输入) |
café(NFC) |
归一化 |
videotxt.exe |
videotxt.exe + 警告日志 |
RTL 覆盖 |
'我' * 100 |
长度 ≤ 243 字节且是合法 UTF-8 | 字节截断 |
'🎬' * 100 |
长度 ≤ 243 字节且不乱码 | 多字节截断 |
'' / ' ' |
untitled |
空值兜底 |
'a' * 300 |
243 字符 | 纯 ASCII 截断 |
pytest 版:
import pytest
from downloader.utils.filename import sanitize_filename
@pytest.mark.parametrize('raw,expected', [
('直播回放 2026/03/11', '直播回放 2026_03_11'),
('a:b*c?d"e<f>g|h', 'a_b_c_d_e_f_g_h'),
('CON', '_CON'),
('con.txt', '_con.txt'),
('myvideo.', 'myvideo'),
('', 'untitled'),
])
def test_sanitize(raw, expected):
assert sanitize_filename(raw) == expected
def test_no_traversal():
assert '..' not in sanitize_filename('../../etc/passwd')
assert '/' not in sanitize_filename('../../etc/passwd')
def test_byte_limit():
for raw in ['我' * 100, '🎬' * 100, 'a' * 300, '混合 mixed 🎬' * 40]:
out = sanitize_filename(raw)
assert len(out.encode('utf-8')) <= 243
out.encode('utf-8').decode('utf-8') # 必须是合法 UTF-8,否则抛异常
def test_idempotent():
"""清洗一次和清洗两次结果必须一样(幂等)"""
import random, string
for _ in range(200):
raw = ''.join(random.choices(string.printable + '我🎬é', k=random.randint(1, 60)))
once = sanitize_filename(raw)
twice = sanitize_filename(once)
assert once == twice
幂等性测试是我后来加的,它帮我抓到一个 bug:早期版本里"结尾空格"处理在"保留名加前缀"之前,导致第二次清洗时又多了个下划线。清洗函数必须幂等,否则"重新整理文件名"这种批量操作会把文件名越改越怪。
另外,从真实数据里捞一批标题来做回归测试也很有用——我把线上出现过的 5000 个标题存成 fixture,每次改动跑一遍,看看有没有文件名变短/变空的(变空说明清洗过度了)。
十、坑清单
- 只处理了
/→ 后面的:*?"<>|全在 Windows 上炸。 - 保留了 Windows 保留名 →
CON.mp4保存出来打不开。 - 结尾空格被系统静默删掉 → 保存不报错,再找找不到。
- 按字符截断而不是字节 → 中文文件名超限,或者截断出非法 UTF-8(
UnicodeDecodeError)。 - 截断到 255 之后再加后缀 → 又超了。要预留。
- 没做 Unicode 归一化 → macOS 用户上传的文件在 Linux 上被当成新文件,去重失效、磁盘翻倍。
- 用了 NFKC → 全角变半角、连字被拆,用户找不到自己的文件。
- 控制字符没清 → 日志被换行搞乱、CSV 导出错位。
- 没处理双向控制字符 → 文件名显示成别的东西,有安全隐患。
- 路径穿越只检查了
..字符串 →..\、URL 编码%2e%2e%2f、符号链接都能绕过。要用realpath+ 前缀校验。 - 自己实现非法字符表 → 漏项。用
pathvalidate。 - 先
exists()再创建 → TOCTOU 竞态,并发下两个进程写同一个文件。用O_EXCL。 - 清洗函数不幂等 → 重复整理把文件名改得越来越怪。
- 在 Linux 上模拟 Windows 大小写规则 → 判断出错。交给系统 API。
- 替用户"优化"文件名(比如删掉所有非 ASCII)→ 中文用户全受影响。能保留就保留。
最后说两句题外话。
第一,不要为了"整理"去批量改别人的文件名。我做过一次"好心"的批量重命名,把一批老归档文件统一成规范格式,结果有个业务流程是靠文件名做匹配的,全断了。现在我的原则是:新文件按新规则生成,老文件除非有明确需求否则不动;真要动,先备份一份映射表。
第二,文件名这个话题看起来琐碎,但它处在"用户输入"和"系统资源"的交界处,是安全和稳定性事故的高发地带。路径穿越是实打实的漏洞(OWASP 榜单常客),Unicode 归一化是数据一致性问题,长度限制是稳定性问题。花半天把它做对,比事后救火划算得多。
我现在的习惯是:任何接受用户输入生成文件名的功能,上线前都过一遍上面那张测试表。成本五分钟,省下的是后面无数个"用户说文件找不到"的工单。