mcpField
F
fileflow-pathologize

fileflow-pathologize

github.com/spf13开发687108 次安装4 天前更新
代码仓库

关于 fileflow-pathologize

这项编码技能介绍如何使用 Go 库 fileflow 和 pathologize 安全移动文件并清理跨操作系统路径,重点是避免覆盖冲突,并将不可信路径限制在可信根目录内。

主要功能

  1. 01跨文件系统移动文件
  2. 02避免冲突的复制与重命名
  3. 03跨平台文件名清理
  4. 04限制不可信路径在根目录内

适用场景

  1. 01在不同卷之间移动文件
  2. 02目标已存在时避免覆盖数据
  3. 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。

安全:防御路径遍历

绝不能让不可信输入决定文件的存放位置。以下两种方式都安全:

  1. Join(推荐)——传入可信根目录和不可信部分;这些部分会经过清理,并被限制在根目录下:

    dst := pathologize.Join(root, untrusted)   // can't escape root
    
  2. 逐段执行 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...)——经过清理且被限制在根目录下
对不可信输入使用 CleanPathCleanPath 让路径有效,而非安全——请使用 Join
连可信根目录也一并清理Join 会原样使用根目录;只有各部分是不可信的
操作前手动调用 os.MkdirAllMove/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

评论

支持 Markdown