文件名校验这件事,乍看就是几个if判断的小功能,但真正上手做,你会发现它其实是一门跨平台文件系统的行为学。我最近给团队做跨平台文件同步工具和统一上传服务,后端选了 Go,需要在一处集中搞定所有文件名的合法性校验。原本以为半小时能写完,结果被 Windows 保留名、macOS 的 Unicode 归一化、Go 字符串的字节陷阱轮流教育了一遍。这篇博文就把我沉淀下来的 validate filenames 算法、完整源码、测试用例和实战心得一次性讲清楚,想直接抄作业的拿走代码,想搞懂背后原理的,也能跟着思路把规则一条条捋明白。
这套算法适合所有需要接收文件名的场景:文件上传、数据导出、网盘同步、日志落盘、甚至 CLI 工具里的--output参数校验。无论你是在写 Go 服务端,还是准备把一套文件处理逻辑从单平台搬到多平台,这篇文章提供的实现都能帮你少踩几周坑。
1. 为什么要专门做一套文件名校验算法
1.1 先说清楚:你会在哪一步遇到文件名校验
文件名校验不是"为了存在感",它的需求几乎全部来自真实业务事故。我按自己遇到过的场景排了个优先级,你对照着看就知道缺一不可。
第一类是用户上传。这是最典型的场景,Web 后台上传文件时,multipart/form-data里的文件名完全由客户端说了算。用户可能在 Windows 上传一个report:final.txt,或者从网盘下载一个测试(最终版).doc.*,这些名字到了 Linux 服务器上大部分没问题,但一旦需要同步回 Windows,或者被其他 Windows 客户端下载,就会变成打不开、存不下、甚至直接丢失的"残废文件"。上传服务必须在上游挡住这些名字。
第二类是跨平台同步。这是我最头疼的场景。家里的 NAS 跑 Linux,手机是 iOS/Android,主力机是 Windows,有时候还要接一台 macOS。同样的目录往几个平台同时同步,任何一个平台上生成的文件名都必须满足所有平台的文件系统规则,否则另一个平台要么拒绝同步,要么名字被悄悄改写,用户视角就是"文件怎么凭空消失了"。
第三类是业务系统自动拼文件名。订单导出、报表生成、日志归档,代码里经常写fmt.Sprintf("%s_%d.csv", orderNo, timestamp)这种逻辑。订单号本身没问题,但用户填写的备注、商品名、地区名一旦拼进来,就可能带出空格、斜杠、引号。这种场景下校验函数是最后一道防线,能直接把脏字符串拦截在写入磁盘之前,不然排查起来特别痛苦。
第四类就是安全审计。文件名校验从安全角度看,至少能挡掉三类问题:路径穿越、空字节注入、超长文件名导致的异常行为。路径穿越靠拒绝/、\和.、..来防;空字节在所有主流操作系统的文件 API 里都是非法字符但某些旧代码会直接拼接路径导致截断;超长文件名则可能触发各种工具链的隐藏 bug。这些不是危言耸听,我在测试环境真的见过通过文件名包含..的请求,配合后端不严谨的路径拼接,写出了目录外的文件。
1.2 不是"看着像文件名"就行:各文件系统的隐藏规则
很多人以为文件名就是一串字符,只要不是空就能存。真正干过跨平台文件处理的都知道,各文件系统的规则差异大得离谱。我把最核心的几项整理成了下面这个表,方便你一眼看明白。
| 规则 | Windows (NTFS) | Linux (ext4) | macOS (APFS/HFS+) |
|---|---|---|---|
| 非法保留字符 | `< > : " / \ | ? *` | /和\x00 |
| 保留设备名 | CON, PRN, AUX, NUL, COM1-9, LPT1-9 | 无 | 无 |
| 尾部点/空格 | 会被文件系统自动去除 | 完全合法 | 不推荐,某些挂载方式下有问题 |
| 单组件长度上限 | 255 个 UTF-16 码元 | 255 字节 | 255 个 UTF-8 字符 |
| 大小写敏感 | 默认不敏感 | 敏感 | 默认不敏感 |
| Unicode 归一化 | 一般存 NFC | 不主动处理 | 自动转 NFD |
这表里最反直觉的是 Windows 的保留设备名。你在 Windows 资源管理器里创建不了CON、PRN、AUX、NUL,更不要说COM1到COM9、LPT1到LPT9。而且让我踩坑的是,CON.txt同样是禁止的,因为它取文件名里点号之前的部分去匹配保留名,点号后的扩展名救不了它。Linux 上随便你创建一个叫CON的文件,但一旦同步到 Windows,直接失败。规则不可怕,可怕的是你不知道规则藏在哪。
尾部点和空格也很隐蔽。Windows 的文件系统在创建文件时,会自动去掉名字末尾的点和空格,"report."存进去就变成"report","foo "变成"foo"。这在 Windows 本地操作是"用户无感知的修复",但在跨平台同步时就是大坑:远端 Linux 上report.和report是完完全全两个文件,同步到 Windows 后其中一个会神秘覆盖另一个,或者静默失败。所以凡是要做跨平台传输的名字,尾部点和空格必须一刀切掉。
长度问题也要单独说。Windows 的单组件上限是 255 个 UTF-16 码元,Linux 的 ext4 是 255 字节,macOS 的 APFS 是 255 个 UTF-8 字符。这三者算出来的同一个中文文件名的"消耗量"都不一样。后面我会详细说 Go 里怎么处理这个差异,这里先记住结论:跨平台校验时,按字节数做上限最稳妥,因为len()和文件系统走得最近。
1.3 Go 处理文件名的三个基础认知
如果你已经完成了 go 语言环境配置,能正常跑go run,那可以直接看下面的代码。但如果你刚开始写 Go,或者已经写了一阵子却还在用 C 语言思维处理字符串,下面这三个点必须先建立起来。
第一,Go 的string本质是字节序列,不是字符数组。len(name)返回的是字节数,不是字符数。一个中文字符在 UTF-8 下占 3 个字节,一个 emoji 表情占 4 个字节。for i, r := range name里的r是 rune(Unicode 码点),i是字节下标不是字符下标。这套规则和 Python 的字符索引完全不同,很多初学者在这里翻车。
第二,Go 标准库path/filepath是按当前运行平台的规则来处理路径的。同一个/在 Windows 和 Linux 下语义不同,filepath.Separator告诉你当前平台的分隔符。但我们在做"跨平台规则判断"时,不能依赖运行时所在平台,必须让规则参数化,显式指定按 Windows 规则还是 Unix 规则来校验。这也是我设计校验器时第一个想清楚的点。
第三,Go 没有 do-while 语法。很多从 C/Java 过来的人写循环时习惯先执行一次再判断条件,我在实现尾部空格裁剪时也想过要不要手动写 do-while,后来发现直接strings.TrimRight一行搞定,根本不需要模拟。这也是 Go 的哲学:能用标准库表达的,就别在语言层面绕弯子。
2. validate filenames 的规则拆解与接口设计
2.1 校验规则清单与执行顺序
我把规则拆成了六条,严格按照下面的顺序执行:先做开销小、能快速短路的基础检查,再做依赖平台配置的复杂检查。顺序看着简单,背后是有讲究的。
- 空字符串拦截。空文件名没有存在价值,除非业务上明确允许。
.和..拦截。这两个特殊目录项一旦被当成普通文件名保存,后面一定会出路径穿越问题。- UTF-8 合法性检查。Linux 文件系统其实允许非 UTF-8 字节序列出现在文件名里,但绝大多数应用层程序默认 UTF-8,一个非法序列极可能导致后续所有字符串处理逻辑出错,直接拒绝最省心。
- 长度检查。先查字节数,超过配置上限就直接返回错误。
- 非法字符检查。控制字符(
0x00-0x1F)、0x7F,以及平台特定的保留字符,逐 rune 扫描。 - 平台专属规则。Windows 额外检查尾部点和空格、保留设备名;Unix 平台跳过这一步。
这个顺序的核心原则是"便宜的规则在前"。空判断和"."/ ".."判断都是常数时间,字符串长度是一条指令的事,UTF-8 合法性是线性扫描但不需要分配内存,最后才进非法字符和 Windows 专属规则。实际测试里,绝大多数非法输入都在前三条被拦住,根本走不到最后一步。
另外说下控制字符。Linux 上你其实可以创建带换行符的文件名,在终端里能把你输出搞乱,很多脚本也会直接崩。Windows 上控制字符干脆就是非法。所以我把r < 32以及0x7F一律拦截作为默认策略,这是业务政策不是文件系统强制要求。如果你的产品确实需要在 Linux 上允许换行文件名,把这条改成可配置即可,但我不建议你这么干,后续日志、监控、第三方程式的成本远高于你省下的那点"自由度"。
2.2 参数化配置:Options 的设计取舍
既然是"验证文件名算法",就不能写死一套规则。同一个项目里,用户上传模块想严格一点,内部数据导出模块可能宽松一点;一套代码同时部署在 Windows 和 Linux 上,默认平台也得跟着走。我为校验器设计的配置项只有四个,够用且不冗余。
type Options struct { // Platform 指定按哪套文件系统规则校验。 Platform Platform // MaxLength 是单文件名组件的最大字节数,0 表示使用默认值。 MaxLength int // AllowEmpty 为 true 时允许空字符串通过校验。 AllowEmpty bool // AllowSpace 为 true 时允许文件名内部包含空格(默认允许)。 AllowSpace bool }Platform是核心配置。默认我推荐PlatformWindows,理由很简单:Windows 的规则是其他平台的超集,只要按 Windows 规则校验通过的文件名,拿到 Linux 和 macOS 上一定没问题;反过来则不成立。做跨平台同步、上传、归档这类面向多端的产品,统一用 Windows 规则是最安全的决策。
MaxLength为什么要暴露出来?因为你可能会遇到一些特殊文件系统。比如某些云存储网关、虚拟文件系统、加密目录,它们对文件名长度有额外限制,比操作系统的 255 更短。这种时候配置项能让你在同一个包内适配不同后端,而不是复制一支代码出来改。
AllowSpace这个选项是我后来加的。大部分场景允许空格没问题,但某些内部系统,比如生成给对端程序批量读取的文件,空格会破坏字段分隔;再比如要生成 URL 友好的下载文件名,空格会被浏览器转义成%20,体验很差。默认允许空格,需要时关掉。
2.3 结构化错误类型:把"为什么失败"变成可处理的字段
早期版本我直接用errors.New("文件名不合法")返回,测试和前端对接时马上后悔了。前端需要区分"名字太长"和"包含非法字符"来显示不同提示;日志需要知道具体是哪个字符触发了拦截,好做统计和用户体验优化;测试更是需要精确断言错误类型,而不是模糊的字符串相等。所以我把错误设计成了结构化类型。
type ErrorKind int const ( KindEmpty ErrorKind = iota KindDotName KindTooLong KindInvalidChar KindReservedName KindTrailingDotSpace KindInvalidEncoding ) type ValidationError struct { Kind ErrorKind Name string Index int // 非法字符在字符串中的字节下标 Char rune // 触发校验失败的字符 Max int // 触发长度错误时的长度上限 msg string } func (e *ValidationError) Error() string { return e.msg }ValidationError实现error接口,但在断言时可以用errors.As拿到底层字段。前端接到后可以按Kind给出精准文案,比如:
var ve *validate.ValidationError if errors.As(err, &ve) { switch ve.Kind { case validate.KindReservedName: // 提示用户"这个名字被系统保留,换个名字" case validate.KindTooLong: // 提示用户"文件名过长,最多 255 字节" case validate.KindInvalidChar: // 提示用户"文件名含有非法字符 %q",ve.Char 直接展示 } }这里我想传达一个设计理念:校验函数不只是返回"对/错",还应该返回"错在哪、错成什么样、最大允许值是多少"。这三点凑齐,上层逻辑就能做出非常精细的处理,而不是把所有问题都归成一句"文件名无效"。
3. 完整源码实现与逐段解读
3.1 基础结构体与默认配置
先看整体骨架。这个文件我命名为validate.go,后续测试文件validate_test.go和它放同一个包。
package validate import ( "fmt" "strings" "unicode/utf8" ) type Platform int const ( PlatformWindows Platform = iota PlatformUnix ) const ( DefaultMaxLenWindows = 255 DefaultMaxLenUnix = 255 ) type Options struct { Platform Platform MaxLength int AllowEmpty bool AllowSpace bool } type Validator struct { opts Options } func New(opts Options) *Validator { if opts.MaxLength <= 0 { switch opts.Platform { case PlatformUnix: opts.MaxLength = DefaultMaxLenUnix default: opts.MaxLength = DefaultMaxLenWindows } } return &Validator{opts: opts} }这里有个细节是默认长度按平台区分。Windows、ext4、APFS 的上限都是 255,但含义不同,我之前表格里写过。正是因为有这个差异,New初始化时统一用字节数作为衡量标准。对 ext4 来说 255 字节就是硬上限;对 Windows 的 255 个 UTF-16 码元来说,用字节数会稍微严格一点点,这是一个可接受的保守策略,后面测试部分我会专门讲。
AllowEmpty默认是 false。如果传进来的 Options 里AllowEmpty是 false,Validate("")就会返回KindEmpty错误;如果业务上确实允许空名字(比如某个批量接口允许空字段占位),配置为 true 即可,调用方意图一目了然。
3.2 主校验函数 Validate 的完整实现
核心的Validate方法这么写:
func (v *Validator) Validate(name string) error { if name == "" { if v.opts.AllowEmpty { return nil } return &ValidationError{Kind: KindEmpty, Name: name, msg: "文件名为空"} } if name == "." || name == ".." { return &ValidationError{Kind: KindDotName, Name: name, msg: "文件名不能是 . 或 .."} } if !utf8.ValidString(name) { return &ValidationError{Kind: KindInvalidEncoding, Name: name, msg: "文件名包含非法 UTF-8 字节序列"} } if len(name) > v.opts.MaxLength { return &ValidationError{ Kind: KindTooLong, Name: name, Max: v.opts.MaxLength, msg: fmt.Sprintf("文件名长度 %d 字节,超过上限 %d", len(name), v.opts.MaxLength), } } for index, r := range name { if r == 0x7f || r < 32 { return &ValidationError{ Kind: KindInvalidChar, Name: name, Index: index, Char: r, msg: "文件名包含控制字符", } } if !v.opts.AllowSpace && r == ' ' { return &ValidationError{ Kind: KindInvalidChar, Name: name, Index: index, Char: r, msg: "文件名不允许包含空格", } } if isInvalidChar(r, v.opts.Platform) { return &ValidationError{ Kind: KindInvalidChar, Name: name, Index: index, Char: r, msg: "文件名包含非法字符", } } } if v.opts.Platform == PlatformWindows { if err := v.checkWindowsRules(name); err != nil { return err } } return nil } func isInvalidChar(r rune, p Platform) bool { switch p { case PlatformUnix: return r == '/' default: switch r { case '<', '>', ':', '"', '/', '\\', '|', '?', '*': return true } return false } }注意for index, r := range name这里index是字节下标。比如字符串"测/试"里/的字节下标是 3,因为"测"占了 3 个字节。我在ValidationError.Index里存这个值,是为了让上层能精确定位到底哪个位置出了问题。如果你想让 Index 变成 rune 序号,可以另外计数,但我在实践中发现字节下标配合日志输出反而更好用,直接name[index]就能取到原字节。
isInvalidChar是平台差异的集中体现。Unix 平台我只禁/,因为它是路径分隔符,不允许出现在单个文件名组件里。反斜杠在 Linux 和 macOS 上是完全合法的字符,所以a\b.txt在 Unix 平台上能正常存在。Windows 平台则把\、<、>、:、"、/、|、?、*全部列入黑名单。这里有一个很容易写错的地方:/在 Windows 和 Unix 下都要禁,不要写成只有 Windows 才禁,否则 Unix 平台会放行路径分隔符,保存时等于直接创建多级目录。
3.3 Windows 专属规则:尾部点空格与保留设备名
Windows 规则我在checkWindowsRules里实现:
var windowsReservedNames = map[string]bool{ "CON": true, "PRN": true, "AUX": true, "NUL": true, "COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true, "COM6": true, "COM7": true, "COM8": true, "COM9": true, "LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true, "LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true, } func (v *Validator) checkWindowsRules(name string) error { trimmed := strings.TrimRight(name, ". ") if len(trimmed) != len(name) { return &ValidationError{ Kind: KindTrailingDotSpace, Name: name, msg: "文件名不能以点或空格结尾", } } base := name if idx := strings.IndexByte(base, '.'); idx >= 0 { base = base[:idx] } if windowsReservedNames[strings.ToUpper(base)] { return &ValidationError{ Kind: KindReservedName, Name: name, msg: fmt.Sprintf("%s 是 Windows 保留设备名", strings.ToUpper(base)), } } return nil }先解释strings.TrimRight(name, ". ")。C 系语言里写这个逻辑你可能会想用 do-while 循环,Go 没有 do-while,但标准库的TrimRight语义刚好就是"从右往左,去掉匹配集合里的字符"。它返回的新字符串和原字符串长度不同,就说明尾部存在点或空格,直接返回错误。
保留设备名的判断核心是"取点号之前的基名,转大写,查表"。CON.txt会先被拆成CON,然后命中CON,直接拦截。这是 Windows 的真实行为,不是我的保守策略。con.log同理,大小写不敏感所以先ToUpper。至于COM10、COM11这些,经典规则只保留到COM9和LPT9,因为 DOS 时代只定义到 9。我在实际项目里见过有人把COM10也列为非法,其实COM10在 Windows 上是能正常创建文件的。如果你要处理的是特别老旧的网络存储协议,可以额外封掉更多,但至少在 NTFS 上,COM10不是系统设备名。
3.4 对外接口与调用示例
为了让调用方写起来最舒服,我暴露了两个层面:一个是可复用的Validator实例,适合在服务初始化时创建一次;另一个是包级便捷函数Validate,内置一个默认的 Windows 严格校验器,适合随手调用。
var DefaultValidator = New(Options{Platform: PlatformWindows}) func Validate(name string) error { return DefaultValidator.Validate(name) } func ValidateFilename(name string, opts Options) error { return New(opts).Validate(name) }调用示例很简单。假设你在写一个 HTTP 上传接口:
func uploadHandler(w http.ResponseWriter, r *http.Request) { file, header, err := r.FormFile("file") if err != nil { http.Error(w, "读取上传文件失败", http.StatusBadRequest) return } defer file.Close() if err := validate.Validate(header.Filename); err != nil { var ve *validate.ValidationError if errors.As(err, &ve) { http.Error(w, ve.Error(), http.StatusBadRequest) } return } // 校验通过,继续保存 // dst := filepath.Join(uploadDir, header.Filename) // ... }再把几个典型输入跑一下,输出非常直观:
$ go run example.go 正常文件.txt -> OK CON -> CON 是 Windows 保留设备名 a/b.txt -> 文件名包含非法字符 中文 目录/子文件? -> 文件名包含非法字符 report.2024. -> 文件名不能以点或空格结尾这里我故意在示例里让"中文 目录/子文件?"这种带斜杠和问号的名字进来,真实业务这么干肯定会出事。校验器在扫描到/时就返回了,?根本没机会被检查到。这就是我在 2.1 节说"非法字符扫描一次遍历"的好处:总能给出第一个出错位置,避免一层层嵌套检查导致报错信息和用户看到的问题对不上。
4. 实操中踩过的坑与排查实录
4.1 len 统计的是字节,不是字符
这是 Go 新手最容易踩的坑,在文件名场景里尤其危险。len("测试文件.txt")返回 16,不是 9。中文每个字占 3 字节,4 个汉字 12 字节再加上.txt的 4 字节。如果你按字符数上限 255 去看,这个文件名完全没问题;但如果你在代码里用len做长度判断,一个短中文名可能比英文名多占两倍空间,极限情况下 86 个汉字就能撑满 ext4 的 255 字节上限。
那 Windows 的"255 个 UTF-16 码元"该怎么算?它和 Go 的 rune 数也不是一回事。一个 rune 是 Unicode 码点,对大部分汉字来说 1 个 rune 就是 1 个 UTF-16 码元;但 emoji 和一些生僻字在 UTF-16 下要占用 2 个码元(代理对)。一个纯 emoji 组成的文件名,在 Windows 上的"真实长度"是 rune 数的两倍。如果产品面向的用户的文件名里有大量 emoji,你可以加一个辅助函数把 UTF-16 码元数算出来,再和上限比较:
func countUTF16(s string) int { n := 0 for _, r := range s { if r > 0xFFFF { n += 2 } else { n++ } } return n }不过我的建议是,默认以字节数作为唯一长度指标就够了。原因很简单:ext4 只认字节数,Windows 的 255 码元严格来说比字节数宽松,用字节数校验是"只可能更严格、不可能更宽松",不会出现"校验通过了但实际存不下"的情况。你可以在长度上限上留一点余量,比如配置成 250,兼顾中文场景的兼容性。
4.2 macOS 的 NFD 归一化让同名文件"对不上"
这个坑非常隐蔽。macOS 的 APFS/HFS+ 在保存文件名时,会自动把 Unicode 做 NFD 归一化。什么意思呢?像é这个字符,在内存里有两种表示:一种是单个码点U+00E9(NFC 形式),另一种是e加U+0301组合重音(NFD 形式)。macOS 偏好 NFD,会把 NFC 形式的文件名转换成 NFD 再落盘。
于是问题来了:你在 Windows 或 Linux 上创建了一个café.txt,Go 程序里存的字符串是 NFC 形式;macOS 客户端拿到这个名字去磁盘上找文件时,系统自动把它转成 NFD,实际落盘的名字和远端记录的名字字节不同,文件匹配失败,表现为"文件明明在,就是打不开/找不到"。
解决办法是在校验之前先统一归一化。Go 官方扩展包golang.org/x/text/unicode/norm提供了现成实现:
import "golang.org/x/text/unicode/norm" name = norm.NFC.String(name)我建议在进入Validate之前就做归一化,或者把它放进Validator的调用管道里。这样校验、存储、比较都基于同一个归一化形式,跨平台问题瞬间少一大半。要注意的是,归一化会改变字符串的实际字节数,所以一定要在归一化之后再做长度检查,否则校验的是旧长度,保存的是新长度,容易漏判。
4.3 保留名比你想的更隐蔽
Windows 保留设备名是这次开发里最让我头疼的部分。原因不只是它有名单,而是它出现的位置太容易被忽略。
第一,大小写不敏感。Windows 文件系统默认不区分大小写,所以con.log、CON.LOG、cOn.txT全都不能创建。判断时必须先ToUpper再查表,我在源码里已经处理了。
第二,点号后的扩展名救不了你。CON.txt依旧非法,因为 Windows 在解析文件名时看的是点号前的基名。源码里IndexByte取基名的逻辑就是为了复现这个行为。
第三,网络共享和云盘客户端可能比本地还严格。SMB 协议、某些网盘桌面端,对保留名的处理比 NTFS 还保守,COM10、LPT10甚至COM0都可能被当作非法名字拒绝。我们团队用的那款网盘客户端,COM0.txt同步上去直接失败,虽然 NTFS 本身允许。如果你的产品对接了大量第三方存储,建议把保留名单做得比默认更严,比如把COM0、LPT0也加上,虽然牺牲一点点自由度,但能减少大量工单。
4.4 常见问题速查表
| 现象 | 根本原因 | 处理建议 |
|---|---|---|
| 上传的文件名带空格,Windows 用户下载后名字变了 | Windows 自动去除尾部空格 | 校验阶段拦截尾部空格 |
云盘同步时CON目录永远失败 | Windows 保留设备名 | 用保留名黑名单拦截 |
| 同一个中文名,macOS 显示正常但程序找不到 | NFC/NFD 编码不一致 | 统一归一化为 NFC 再入库 |
| 英文短名字莫名其妙"太长" | 字节数和字符数混淆 | 统一按字节数做上限 |
| 文件名里的 emoji 导致 Windows 报错 | UTF-16 代理对占用双倍码元 | 需要时用countUTF16精确计算 |
| 后端保存后路径多了一层目录 | 文件名里混入了/或\ | 校验器拒绝路径分隔符再加filepath.Base兜底 |
最后一行我要多说一句。即使你已经做了完备的文件名校验,保存文件时仍然要习惯性地调用filepath.Base再拼路径。这属于纵深防御:校验器可能在某个分支被跳过,但Base会把一切路径前缀剥掉,无论什么情况下都不可能让用户输入变成路径穿越。两层都做,才能睡得安稳。
5. 单元测试与工程落地建议
5.1 表驱动测试用例怎么写
Go 社区最流行的测试风格就是表驱动测试,把测试数据定义成结构体切片,一个循环跑完所有用例。文件名校验这种输入输出都非常明确的场景,简直是表驱动测试的完美样本:
func TestValidateTable(t *testing.T) { tests := []struct { name string opts Options fileName string wantOK bool wantKind ErrorKind }{ {"空文件名校验", Options{Platform: PlatformWindows}, "", false, KindEmpty}, {"允许空文件名的场景", Options{Platform: PlatformWindows, AllowEmpty: true}, "", true, 0}, {"点号目录", Options{Platform: PlatformWindows}, ".", false, KindDotName}, {"点点点目录", Options{Platform: PlatformWindows}, "..", false, KindDotName}, {"Windows 斜杠", Options{Platform: PlatformWindows}, "a/b.txt", false, KindInvalidChar}, {"Windows 反斜杠", Options{Platform: PlatformWindows}, "a\\b.txt", false, KindInvalidChar}, {"Unix 斜杠", Options{Platform: PlatformUnix}, "a/b.txt", false, KindInvalidChar}, {"Unix 反斜杠合法", Options{Platform: PlatformUnix}, "a\\b.txt", true, 0}, {"Windows 保留名", Options{Platform: PlatformWindows}, "CON", false, KindReservedName}, {"保留名带扩展", Options{Platform: PlatformWindows}, "con.log", false, KindReservedName}, {"尾部点", Options{Platform: PlatformWindows}, "report.", false, KindTrailingDotSpace}, {"尾部空格", Options{Platform: PlatformWindows}, "foo ", false, KindTrailingDotSpace}, {"Unix 尾部点合法", Options{Platform: PlatformUnix}, "report.", true, 0}, {"字节超长", Options{Platform: PlatformUnix}, strings.Repeat("a", 256), false, KindTooLong}, {"中文正常名", Options{Platform: PlatformWindows}, "中文文件.txt", true, 0}, {"空字节", Options{Platform: PlatformUnix}, "a\x00b", false, KindInvalidChar}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := New(tt.opts).Validate(tt.fileName) if tt.wantOK { if err != nil { t.Fatalf("期望通过,实际错误: %v", err) } return } var ve *ValidationError if !errors.As(err, &ve) { t.Fatalf("期望 ValidationError,实际是: %v", err) } if tt.wantKind != 0 && ve.Kind != tt.wantKind { t.Fatalf("错误类型不匹配: 期望 %v,实际 %v", tt.wantKind, ve.Kind) } }) } }这个测试表里我把每个关键分支都覆盖了:空串、点目录、路径分隔符双平台差异、保留名带不带扩展名、尾部点和空格、字节超长、中文正常名字、空字节。t.Run让每个用例独立显示,失败时能直接跳到具体 case,排查效率高。测试报告里每个测试名都对应一段业务规则,后续接手的人看测试用例就能理解整个校验逻辑。
源码我放在测试文件里,核心思路是断言错误类型而不是断言错误字符串。字符串容易改,一改就导致大量测试用例假失败;错误类型是 API 的一部分,稳定得多。上线前在 CI 里跑一遍go test ./...,这九十个字就能保证你后续简单改规则不会把基础行为改坏。
5.2 把校验器嵌入到真实业务里
代码写完只是开始,真正有价值的是怎么和业务结合。我总结了几条落地经验,每一条都用真金白银的线上事故换来的。
第一,前端要校验,后端更要校验。前端校验是体验,后端校验是安全。用户可以在浏览器 DevTools 里轻松绕过前端 JS,所以后端必须独立执行完整规则,且不能相信前端传过来的"已经校验过"的标记。
第二,校验和清洗要分开。我的包里目前是"只校验不修改",名字过不了就直接拒绝。但有些场景下用户已经输入了my/file?.txt,你直接拒绝会让用户很烦躁,更好的做法是给一个清洗函数,把非法字符替换成_,比如my_file_.txt,然后存储清洗后的名字。实现上你可以在本包基础上包一层 sanitize 逻辑,替换规则自己定。两种策略并存的好处是:上传场景用拒绝,导出场景用清洗,互不干扰。
第三,校验之前先归一化,归一化之后重新走一次完整校验。我前文提到过 NFD/NFC 转换会改变字节长度,所以流程必须是:归一化、然后Validate、然后存储。不要先校验再归一化,否则会因为长度变化漏掉极限情况。
第四,性能上这个算法可以放心用。Validate对英文文件名几乎是一次len+ 循环扫描,O(n) 复杂度,没有正则引擎的额外开销。我曾经用go test -bench跑过,普通文件名单次校验在几十纳秒到一两百纳秒量级,HTTP 上传场景完全不用考虑缓存和优化。与其优化这个函数,不如把精力留给下游的磁盘 IO。
如果后续想扩展,我建议按这几个方向迭代:支持更多平台策略(比如 Android、iOS 的特殊限制)、支持自定义非法字符集、支持文件名大小写冲突检测、把归一化步骤内置到一个SanitizeAndValidate管道函数里。我的初版只覆盖了核心规则,但这套接口设计已经足够支撑这些扩展,改动不会影响现有调用方。
最后再分享一个小技巧:虽然默认规则是 Windows 严格模式,但我在服务初始化时会把Options从配置中心拉下来,这样某天某块业务需要放宽尾部空格限制时,改配置就能上线,不用发版本。产品经理永远会有新的文件名格式需求,提前留好配置口子,能让你少加半年班。
我个人在这套算法跑完三个月后的最大体会是:文件名校验看似是字符串处理,实际上做的是文件系统行为对齐。你把 Windows、Linux、macOS 各自那条"只要不出事就好"的潜规则摊到桌面上,用一套参数化规则统一表达出来,才是真正能跨平台活下来的实现。前期的坑很多,但填完之后,你的代码会比其他模块都稳,因为你再也不用被"神秘消失的文件"、"同步失败的 CON 目录"、"打开乱码的中文名"追着跑了。