关于 fileflow-pathologize
这项编码技能介绍如何使用 Go 库 fileflow 和 pathologize 安全移动文件并清理跨操作系统路径,重点是避免覆盖冲突,并将不可信路径限制在可信根目录内。
主要功能
- 01跨文件系统移动文件
- 02避免冲突的复制与重命名
- 03跨平台文件名清理
- 04限制不可信路径在根目录内
适用场景
- 01在不同卷之间移动文件
- 02目标已存在时避免覆盖数据
- 03根据用户输入或正则捕获构造路径
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spf13/ go-skills --skill fileflow-pathologize安装后在 Claude Code 中输入/fileflow-pathologize确认已加载。
附带文件.claude-plugin/plugin.json
详细介绍
安全文件操作:fileflow 和 pathologize
两个小巧、可组合的 spf13 库,分别解决朴素文件代码常见的两个问题:安全移动文件(fileflow)和生成安全名称(pathologize)。
何时使用
- 移动、复制或重命名文件,尤其是在不同磁盘/卷之间操作时
- 处理“目标已存在”的情况,同时避免覆盖数据
- 调用
os.Rename时遇到invalid cross-device link/EXDEV - 根据文件名、用户输入或正则表达式捕获内容构建目标路径
- 下载器、解压器、导入器、文件整理工具、上传处理程序
- 任何你正准备手动组合
os.Rename、io.Copy和filepath.Clean的场景
相比标准库的优势
os.Rename 在跨文件系统时会失败(EXDEV),并且会静默覆盖目标。filepath.Clean 会规范化 ..,但不会移除其他操作系统上非法的字符,也不会处理保留名称(CON、NUL)。而且,path/filepath 只会根据宿主操作系统进行校验:在 Linux 上没问题的名称,文件同步到 Windows 客户端时可能就会出错。这些库弥补了上述所有不足,并且可以干净地组合使用。
核心模式:将可信根目录与不可信路径部分拼接,然后移动文件
这是最重要的模式。使用 pathologize.Join 将可信根目录与不可信路径部分(正则表达式捕获内容、用户输入、抓取的标题)组合起来,然后用 fileflow 移动文件。Join 会清理每个部分,并确保结果在词法上始终位于根目录之下——任何部分都无法注入绝对路径,也无法通过 ../ 逃出根目录。
import (
"path/filepath"
"github.com/spf13/fileflow"
"github.com/spf13/pathologize"
)
// destRoot is trusted (a configured directory); id/name are untrusted.
dstDir := pathologize.Join(destRoot, "ig", id) // parts sanitized + contained
dst := filepath.Join(dstDir, filepath.Base(src))
final, err := fileflow.Move(src, dst) // cross-fs safe, conflict safe
if err != nil {
return err
}
// final is where the file actually landed (may be suffixed on conflict).
fileflow.Move 会为你创建缺失的目标目录,因此无需另外调用 os.MkdirAll。
fileflow
安全地移动/复制/重命名文件,包括跨文件系统操作,且不会覆盖内容不同的数据。包级函数使用零值默认设置;需要按实例配置行为时,请使用 Flow。
函数
func Move(src, dst string) (string, error) // rename if same FS, else copy+delete
func Rename(src, dst string) (string, error) // same-FS only; fails on cross-device
func Copy(src, dst string) (string, error) // atomic copy; returns final path
func Exists(path string) bool
func Equal(file1, file2 string) (bool, error) // byte-for-byte comparison
Move、Rename 和 Copy 都会返回最终目标路径,这一点对下面的冲突处理很重要。除非你明确希望在跨文件系统时失败,否则优先使用 Move 而不是 Rename。Exists 只会报告可访问的常规文件;如果是目录,则返回 false。
默认情况下,这三种操作都会创建缺失的目标目录。CopyWithPaths 已不存在,因为现在 Copy 本身就会创建这些目录。
使用 Flow 配置行为
type Flow struct {
FindAvailableName func(string) (string, error)
DirMode fs.FileMode
NoCreateDirs bool
}
零值即可直接使用。FindAvailableName 为 nil 时,会选择 FindAvailableNameInc;DirMode 为零值时,会选择 fileflow.DefaultDirMode(0o755)。如果目标目录必须已经存在,请设置 NoCreateDirs: true。例如,当目标是必需的挂载点,且在错误的卷上创建路径会带来危险时,就应这样设置。此时操作不会创建目录,而是返回一个可通过 errors.Is 匹配 fs.ErrNotExist 的错误。
f := fileflow.Flow{
FindAvailableName: fileflow.FindAvailableNameAuto,
DirMode: 0o700,
NoCreateDirs: true,
}
final, err := f.Move(src, dst)
Flow 也提供与包级函数签名相同的 Move、Rename 和 Copy 方法。请在首次使用前配置实例;当其方法可能正在运行时,不要修改实例。旧的可变包级全局变量已移除。
冲突处理(核心功能)
当 dst 已存在时,fileflow 不会盲目覆盖:
- 如果现有文件与源文件完全相同 →
Copy不会更改src,并直接返回dst;Move/Rename会删除src并返回dst。 - 如果二者不同 → 会添加递增后缀:
photo.jpg→photo-1.jpg→photo-2.jpg,最多尝试 100 个候选名称。
始终使用返回的路径;切勿假定文件已保存到 dst。
命名策略
// Default: preserve the full requested name, then append -1, -2, ...
f := fileflow.Flow{FindAvailableName: fileflow.FindAvailableNameInc}
// Continue a trailing one-or-more-digit counter: file-3.txt -> file-4.txt.
f.FindAvailableName = fileflow.FindAvailableNameCont
// Continue one- or two-digit counters, but preserve years/zero-padded IDs.
f.FindAvailableName = fileflow.FindAvailableNameAuto
// Append a nanosecond timestamp; fall back to incrementing on collision.
f.FindAvailableName = fileflow.FindAvailableNameTS
// Or supply any func(baseName string) (string, error).
f.FindAvailableName = myStrategy
请谨慎选择:
FindAvailableNameInc(默认):file-1.txt→file-1-1.txt,以及report-2024.txt→report-2024-1.txt。FindAvailableNameCont:file-1.txt→ 下一个可用的序号,但也会将report-2024.txt视为计数器。FindAvailableNameAuto:会延续一位或两位数的后缀,同时将三位或更多位数字视为有意义的内容(report-2024-1.txt,而非report-2025.txt)。FindAvailableNameTS:追加20060102-150405.000000000。
100 个候选项的上限是内部限制,无法配置。
写入安全性与持久性
Rename使用禁止替换的原语,因此并发写入者通常无法覆盖已占用的目标路径。Windows 使用不替换目标的MoveFileEx;Unix 使用原子链接与删除。- 在不支持硬链接的 Unix 文件系统上(例如 FAT 和某些网络挂载),重命名会回退到
os.Rename;此时不覆盖保证仅为尽力而为。 Copy会在目标目录中写入临时文件、同步文件、保留源文件的权限模式,并使用相同的禁止替换逻辑将其重命名到目标位置。复制失败不会暴露不完整的目标文件,也不会留下临时文件。
错误(哨兵错误使用 errors.Is,结构化错误使用 errors.As/errors.AsType)
fileflow.ErrSameFile // src and dst are the same file
fileflow.ErrMaxAttemptsReached // ran out of conflict-suffix attempts
*fileflow.ErrFailedMovingFile // fields: Src, Dst, Err
*fileflow.ErrFailedRemovingOriginal // fields: File, Err
ErrSameFile 可检测等效路径、符号链接,以及大小写不敏感文件系统上的仅大小写差异,而不仅仅是字符串完全相同。只有当这符合调用方的语义时,才应将其视为无操作成功。删除错误可能会同时返回最终目标路径和 ErrFailedRemovingOriginal,因为内容已安全抵达,但源文件无法删除。
哨兵错误是 errors.Is 的目标;结构化错误是 errors.As 的目标。使用目标指针形式提取错误:
var moveErr *fileflow.ErrFailedMovingFile
if errors.As(err, &moveErr) {
log.Printf("move %s -> %s failed: %v", moveErr.Src, moveErr.Dst, moveErr.Err)
}
在 Go 1.26 或更高版本中,errors.AsType[T] 类型安全、速度更快,并且免去了声明目标变量的步骤;低于该版本时,请继续使用上面的 errors.As 形式。
errors.AsType 是泛型函数:
if moveErr, ok := errors.AsType[*fileflow.ErrFailedMovingFile](err); ok {
log.Printf("move %s -> %s failed: %v", moveErr.Src, moveErr.Dst, moveErr.Err)
}
pathologize
让文件名或路径在所有现代操作系统上都安全,而不只是当前主机。此函数有意采用严格限制:剥除非法字符、去除末尾的点号和空格、限制长度为 255 字节,并处理保留名称(CON → CON_)。
函数
func Clean(filename string) string // sanitize ONE name/segment
func CleanPath(path string) string // make a full path VALID (volume-aware)
func Join(root string, parts ...string) string // trusted root + untrusted parts, contained
func IsClean(filename string) bool // true if Clean(name) == name
pathologize.Clean("CON..") // "CON_"
pathologize.Clean("a/b:c*d?.txt") // "abcd.txt" (/, :, *, ? are illegal)
Clean 会剥除控制字符以及 \ / : * ? " < > |。无效 UTF-8 会转换为 U+FFFD;会去除首尾空白和末尾点号;名称长度上限为 255 字节;保留设备名称会被处理(CON → CON_)。Clean 具有幂等性,且永远不会返回空字符串(会回退为 "file")。
Join — 安全构建目标路径的方式(默认使用此函数)
func Join(root string, parts ...string) string
Join 会将一个可信根路径与一个或多个不可信部分组合起来:
root会原样传递——其分隔符、驱动器前缀和末尾斜杠都会保留,且绝不会经过清理。它必须来自可信来源(已配置的目标路径,而不是用户输入)。- 每个
part都会经过完整清理:按/和\拆分,删除结构性片段(空片段、.、..),并对每个剩余片段调用Clean。如果某个部分清理后为空,则会贡献"file"。 - 结果保证在词法上始终位于
root之下——部分路径无法逃出根路径或注入绝对路径。这是CleanPath不提供的路径包含保证。
pathologize.Join("/data", "../../etc", "pass:wd") // "/data/etc/passwd" — contained + cleaned
pathologize.Join(`C:\Sorted`, "ig", userName) // root verbatim, userName sanitized
CleanPath — 让完整路径有效(不代表安全)
CleanPath 执行 path.Clean 语义(合并 //、解析 . 和中间的 ..、移除末尾分隔符),并额外对每个组成部分执行 Clean。在 v1.1 中,它能够识别卷标且不依赖特定分隔符:
- 接受
/和\作为分隔符;输出始终使用/。 - Windows 驱动器前缀(
C:)原样保留。 - UNC前缀(
\\server\share)保留为//server/share。
pathologize.CleanPath(`C:\Users\me\report:final`) // "C:/Users/me/reportfinal"
pathologize.CleanPath("a//b/../c") // "a/c"
CleanPath 让路径有效,而非安全。与 path.Clean 一样,它会保留相对路径开头的 ..,并使绝对路径保持绝对路径——它不会消除路径遍历风险,也不会将结果限制在任何根目录下。仅在处理由你控制的路径时使用它(例如由作者配置的绝对目标路径)。对于不可信输入,请使用 Join,或自行逐段执行 Clean。
安全:防御路径遍历
绝不能让不可信输入决定文件的存放位置。以下两种方式都安全:
-
Join(推荐)——传入可信根目录和不可信部分;这些部分会经过清理,并被限制在根目录下:dst := pathologize.Join(root, untrusted) // can't escape root -
逐段执行
Clean——自行拼接路径时,先对每个不可信部分执行Clean。Clean会移除分隔符,并将路径遍历标记解析为无害的单个部分(".."→"file"),因此捕获的值无法引入新的路径层级。
不要依赖 CleanPath 来实现这一点——它会按设计保留 .. 和绝对路径。
常见错误
| 错误 | 修正方法 |
|---|---|
跨卷使用 os.Rename → EXDEV | 使用 fileflow.Move(失败时回退为复制并删除) |
假定文件已存放在 dst | 使用 Move/Rename/Copy 返回的路径 |
| 覆盖现有文件 | fileflow 已会为内容不同的文件添加后缀——不要事先检查 |
| 设置旧版包级全局变量 | 配置一个 fileflow.Flow;可变全局变量已移除 |
调用已移除的 CopyWithPaths | 使用 Copy;它默认会创建父目录 |
| 要求挂载点存在,但允许创建目录 | 使用 Flow{NoCreateDirs: true} |
| 手动根据不可信输入构造目标路径 | pathologize.Join(root, parts...)——经过清理且被限制在根目录下 |
对不可信输入使用 CleanPath | CleanPath 让路径有效,而非安全——请使用 Join |
| 连可信根目录也一并清理 | Join 会原样使用根目录;只有各部分是不可信的 |
操作前手动调用 os.MkdirAll | Move/Rename/Copy 默认会创建父目录 |
路径拆分和扩展名解析
本技能中的代码没有使用 LastIndex 拆分路径,因此 Go 1.27 的 strings.CutLast 和 bytes.CutLast 辅助函数不会替换此处的任何示例。需要获取文件扩展名时,请继续使用 filepath.Ext(对于以斜杠分隔的路径,则使用 path.Ext):CutLast 会围绕最后一次出现的分隔符切片,并非扩展名解析器。本技能的其余部分——Join、Clean、CleanPath、Flow 和冲突后缀处理——不受 Go 1.26 和 1.27 影响。
安装
go get github.com/spf13/fileflow
go get github.com/spf13/pathologize