提示

返回博客列表

文件名里的坑:斜杠、emoji、NFD、Windows 保留字,一个 sanitize 函数的进化史

第一次踩坑是三年前。用户解析了一个标题叫"直播回放 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 这类成熟库,但要知道它在干什么。

目录

一、第一版:我以为只是斜杠的问题

第一版长这样:

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 是"从右到左覆盖",它会让它后面的文字反向显示。这是钓鱼邮件的经典手法:

文件名实际是:  video‮txt.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

几个要点:

  1. 用 realpath 而不是 abspath。abspath 只做规范化(a/../b → b),不解析符号链接;realpath 会解析。如果 base 目录里有个软链接指向外面,abspath 检查不出来。
  2. 检查 base + os.sep 前缀,别只检查 startswith(base)——否则 /data/backup 能通过 /data 的检查(因为 /data/backup.startswith('/data') 为真,但它可能是另一个目录)。
  3. 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) 归一化
video‮txt.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,每次改动跑一遍,看看有没有文件名变短/变空的(变空说明清洗过度了)。

十、坑清单

  1. 只处理了 / → 后面的 :*?"<>| 全在 Windows 上炸。
  2. 保留了 Windows 保留名 → CON.mp4 保存出来打不开。
  3. 结尾空格被系统静默删掉 → 保存不报错,再找找不到。
  4. 按字符截断而不是字节 → 中文文件名超限,或者截断出非法 UTF-8(UnicodeDecodeError)。
  5. 截断到 255 之后再加后缀 → 又超了。要预留。
  6. 没做 Unicode 归一化 → macOS 用户上传的文件在 Linux 上被当成新文件,去重失效、磁盘翻倍。
  7. 用了 NFKC → 全角变半角、连字被拆,用户找不到自己的文件。
  8. 控制字符没清 → 日志被换行搞乱、CSV 导出错位。
  9. 没处理双向控制字符 → 文件名显示成别的东西,有安全隐患。
  10. 路径穿越只检查了 .. 字符串 → ..\、URL 编码 %2e%2e%2f、符号链接都能绕过。要用 realpath + 前缀校验。
  11. 自己实现非法字符表 → 漏项。用 pathvalidate。
  12. 先 exists() 再创建 → TOCTOU 竞态,并发下两个进程写同一个文件。用 O_EXCL。
  13. 清洗函数不幂等 → 重复整理把文件名改得越来越怪。
  14. 在 Linux 上模拟 Windows 大小写规则 → 判断出错。交给系统 API。
  15. 替用户"优化"文件名(比如删掉所有非 ASCII)→ 中文用户全受影响。能保留就保留。

最后说两句题外话。

第一,不要为了"整理"去批量改别人的文件名。我做过一次"好心"的批量重命名,把一批老归档文件统一成规范格式,结果有个业务流程是靠文件名做匹配的,全断了。现在我的原则是:新文件按新规则生成,老文件除非有明确需求否则不动;真要动,先备份一份映射表。

第二,文件名这个话题看起来琐碎,但它处在"用户输入"和"系统资源"的交界处,是安全和稳定性事故的高发地带。路径穿越是实打实的漏洞(OWASP 榜单常客),Unicode 归一化是数据一致性问题,长度限制是稳定性问题。花半天把它做对,比事后救火划算得多。

我现在的习惯是:任何接受用户输入生成文件名的功能,上线前都过一遍上面那张测试表。成本五分钟,省下的是后面无数个"用户说文件找不到"的工单。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部