fileflow-pathologize について
Go ライブラリの fileflow と pathologize を使い、安全なファイル移動と OS 間でのパスのサニタイズを行うコーディングスキルです。上書き競合を避け、信頼できないパス要素を信頼済みルート内に収めます。
主な機能
- 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
素朴なファイル処理でよく起きる2つの問題、ファイルの安全な移動(fileflow)と安全な名前の生成(pathologize)に対処する、合成可能な小規模の spf13 ライブラリです。
使用する場面
- ファイルの移動、コピー、名前変更を行う場合。特にドライブやボリュームをまたぐ場合
- データを上書きせずに「移動先にすでに存在する」ケースを処理する場合
os.Renameでinvalid cross-device link/EXDEVが発生する場合- ファイル名、ユーザー入力、正規表現のキャプチャから移動先パスを作成する場合
- ダウンローダー、展開ツール、インポーター、ファイル整理ツール、アップロードハンドラー
os.Rename+io.Copy+filepath.Cleanを手作業で組み合わせようとしているあらゆる場面
標準ライブラリより優れている理由
os.Rename はファイルシステムをまたぐと失敗し(EXDEV)、移動先を黙って上書きします。filepath.Clean は .. を正規化しますが、他のオペレーティングシステムでは無効な文字を削除しませんし、予約名(CON、NUL)にも対処しません。また、path/filepath が検証するのはホスト OS に対してのみです。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 はすべて最終的な移動先パスを返します。これは、後述する競合処理において重要です。ファイルシステムをまたいで失敗させたい場合を除き、Rename より Move を優先してください。Exists はアクセス可能な通常ファイルのみを検出し、ディレクトリに対しては false を返します。
3つの操作はすべて、デフォルトで移動先のディレクトリを作成します。Copy 自体がディレクトリを作成するようになったため、CopyWithPaths は廃止されました。
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:1 桁または 2 桁のサフィックスは連番を継続し、 3 桁以上は意味のある数字として扱います(report-2025.txtではなくreport-2024-1.txt)。FindAvailableNameTS:20060102-150405.000000000を追加します。
候補数の上限 100 は内部設定であり、変更できません。
書き込みの安全性と耐久性
Renameは上書きなしのプリミティブを使用するため、競合する書き込みによって 通常は既存の宛先が上書きされません。Windows では置換なしのMoveFileExを 使用し、Unix ではアトミックなリンク作成と削除を使用します。- FAT や一部のネットワークマウントなど、ハードリンクをサポートしない Unix
ファイルシステムでは、名前変更は
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
ファイル名またはパスを、実行環境の OS だけでなく、現在のすべての OS で安全に
使用できる形式にします。意図的に厳しい制限を設けています。不正な文字を除去し、
末尾のドットや空白を削り、長さを 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 は、信頼できるルートと 1 つ以上の信頼できないパーツを結合します。
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 してください。
セキュリティ:パストラバーサル対策
信頼できない入力によってファイルの保存先を決めてはなりません。安全な方法は次の 2 つです。
-
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