重複削除の除外リストが削除経路で効いていなかった —— 家族の大事な動画を先に保全した話と、あとから見つけた2つの穴(除外の判定漏れ・パスの前方一致)

シリーズ: NASの重複整理

  • 前提1: Windows機の D:\SynologyDrive は NAS と双方向同期していて、D: で消すと NAS からも消えます
  • 前提2: その中の「仮置き」フォルダから、重複ファイル194件・190.90GiB を7バッチで消し、事故は0件でした。この整理作業を「P22」と呼んでいます
  • 前提3: 実行は Claude Code、承認は私です。消し方は Synology Driveの双方向同期で190GiBの重複削除を事故ゼロで終えた に書きました

結論: 事故は起きませんでしたが、守りの一部は効いていませんでした

削除が終わって約4週間後の 10/10 に、削除ツールの経路を点検しました。分かったのは次の2つです。

穴 実際には何が守っていたか
穴1 「恒久除外」(どの整理作業でも削除しない場所)の判定が、最初の dry-run の中にしかなかった。実行の経路(plan / execute / Windows 側の削除スクリプト)は除外リストを見ていなかった 削除スクリプトの「仮置きフォルダの配下しか消さない」という制限
穴2 その「仮置きの配下か」の判定が、パス文字列の前方一致だけだった。仮置き\..\外のパス のような行は配下として通ってしまう 削除リストに、そういう行がたまたま無かった

穴1を塞いでいたのは別の制限で、その制限には穴2がありました。結果として事故は起きていませんが、運がよかった部分があります。

この記事の結論は、守るべきものは削除を始める前に別の場所へ保全し、除外リストは「書いたか」ではなく「削除を実行するすべての経路で判定されているか」を、実機の試験で確かめる、です。

出発点: 探していた動画が、削除候補の中にあった

重複整理とは別件で、長年探していた家族の大事な動画の捜索を、同じ時期に始めました。「過去の整理のドサクサで消してしまったかもしれない」という心当たりがありました。もし本当にそうなら、今まさに進めている重複整理が同じことを繰り返すかもしれません。

そこで、まだ何も見つかっていない段階で、捜索の側から重複整理の削除フェーズを止めました。重複のリストは確定していましたが、1件も消していない状態です。

捜索は、NAS の全共有を読み取りだけで走査して、動画ファイルの一覧を作るところから始めました(捜索の詳しい話は別の記事にします)。動画は、旧PCのバックアップ3セットの中から見つかりました。さらに、4つ目のコピーが仮置きフォルダの中にあり、それが重複リストの「消す側」に入っていました。残す側は旧PCのバックアップの1つです。

重複の判定としては正しい結果です。中身は同じで、片方を残せばよい。ただ、「残す側」が今後も消されない保証は、どこにもありませんでした。旧PCのバックアップは、次の整理で「3セットもあるから2つ消そう」と言われてもおかしくない場所です。

消す前に保全した

見つかってから約1か月半後に、保全を行いました。その間も、重複整理の削除フェーズは止めたままでした。削除フェーズの dry-run を始めたのは、保全を終えた約1時間後です。

手順 やったこと 結果
複製 NAS の原本を読み取り専用で扱い、Mac に複製 2,600件
照合 原本は NAS 上で、複製は Mac 上で SHA256 を取り、全件を突き合わせ 一致 2,600 / 不一致0 / 欠落0
保管 NAS の別の共有に新しいフォルダを作り、複製一式を置く 2,611件、全件一致
除外登録 旧PCのバックアップ3セットのルートと、保全先のフォルダを恒久除外に登録 4パス

判断したことが2つあります。

  • Synology が自動で作るサムネイル類(@eaDir)は複製しない。9,344件・約1.7GB ありましたが、NAS に取り込んだあとに自動生成されたもので、原本ではありません
  • 「3セットあるから2つ消す」はしない。3セットは同じ内容とされていましたが、ファイル数は 7,194 / 7,249 / 7,181件で一致しませんでした。完全一致を確かめたのは、動画のフォルダの部分だけです

仮置きの中の4つ目のコピーは、予定どおり B2 で消しました。残す側が恒久除外で守られ、保全したアーカイブもあるからです。

守りは3層にした

削除フェーズでは、守りを3つ用意しました。

層 何を守るか 中身
恒久除外 二度と削除の対象にしない場所 4パス(旧PCのバックアップ3セットと保全先)
保護リスト 重複を消した結果、仮置きの中で唯一の実体になる「残す側」 104件(のちに107件)
範囲の制限 削除スクリプトは、仮置きフォルダの配下しか消さない 削除スクリプトの中の判定

保護リストは、削除ツールに強制させる形にしました。plan は、消す側が保護対象なら問題として止まります。仮置きの中の残す側が未登録でも止まります。Windows 側の削除スクリプトは保護対象を拒否し、保護リストが届かなければ全件を中止します。

「残した側が最後の1組になる」

保護リストが必要だった例が、動画編集プロジェクトの素材です。3つの編集プロジェクト(B5〜B7)の素材は、どれも同じ31本の動画と重複していました。3つのプロジェクトの素材を消すと、31本が最後の1組になります。

  • 31本を保護リストに登録し、以後のどの整理でも削除・オンデマンド化の対象から外す
  • 最後のバッチ(B7)の前に、31本の部分ハッシュを取り、朝の値と一致することを確かめる
  • 3つのプロジェクトが素材なしになることは了承し、再リンクの手順を残す

3つの動画編集プロジェクト(A・B・C)の箱から、それぞれ素材31本への矢印が、1つの「元データ31本」の箱に集まる図。上段は削除前で、各プロジェクトの中にも31本のコピーがある。下段は削除後で、プロジェクトの中は空(墓標だけ)になり、元データ31本の箱に「保護リスト登録・最後の1組」の札

穴1: 恒久除外は dry-run でしか判定されていなかった

10/10 の点検で、判定がどこで行われているかを経路ごとに並べました。

層 \ 経路 dry-run plan execute(Mac) 削除スクリプト(Windows)
恒久除外 ○ × → ○ × → ○ × → ×(持たせない)
保護リスト ○ ○ ○(Windows へ送る) ○
範囲の制限 − − − △(前方一致) → ○

(「A → B」は 10/10 の前と後。− はその経路では判定しない)

守りの3層と判定経路の表を図にしたもの。縦に「恒久除外」「保護リスト」「範囲の制限」、横に「dry-run」「plan」「execute」「削除スクリプト」。10/10より前の状態を左、後を右に並べ、×のマスを赤、△を黄、○を緑で塗る。左側では恒久除外の行がdry-run以外すべて赤

恒久除外は、最初の dry-run の判定基準の1つでした。dry-run の時点では、除外配下の消す側は0件です。しかし plan も execute も、dry-run の結果を前提にしていて、除外リストをもう一度見ることはありませんでした。dry-run のあとで除外リストに場所を足しても、実行には効きません。

直し方は次のとおりです(コミット 25cefb6)。

  • plan と execute の両方で、消す側を恒久除外で判定する
  • execute は、dry-run や plan の記録を信用せず、削除の直前にその時点の除外リストで判定する
  • 該当した行は、エラーで止めるのではなく、Windows に送らずにスキップして SKIP_EXCLUDED として件数を出す
  • verify は、スキップした行が D: と NAS に残っていることを確かめる

どのホストにも接続しない試験(test_batch_exclusion.py)を作りました。全行 OK の dry-run に除外配下の行を混ぜて plan → execute を通すと、変更前のコードでは6行とも削除側に渡っていました。変更後は4行がスキップされ、渡るのは2行だけです。

補強: 保護リストにも二重登録した

Windows 側の削除スクリプトは、今も除外リストを見ません。そこで、旧PCのバックアップ3セットを保護リストにも登録しました(104件 → 107件)。登録するパスは、除外リストのファイルのバイト列から機械的に作り、逆に変換して元の行とバイト単位で一致することを確かめています。

穴2: パスの範囲判定が文字列の前方一致だった

Windows 側の削除スクリプトは、「仮置きフォルダの配下か」を文字列の前方一致(StartsWith)で判定していました。

判定に使う接頭辞:   D:\SynologyDrive\仮置き\
削除リストの行:     D:\SynologyDrive\仮置き\..\旧PCのバックアップ\file.dat
前方一致の結果:     配下として通る
削除コマンドの解釈: D:\SynologyDrive\旧PCのバックアップ\file.dat を消す

削除コマンドは .. を解決してから消すので、仮置きの外に届きます。保護リストの判定も、同じ未解決の文字列で行っていました。

直し方は次のとおりです(コミット ff08596)。

  • パスに . や .. のセグメントがあれば、理由を付けて拒否する
  • パスを正規化した結果が元の文字列と一致しなければ、拒否する
  • 範囲と保護の判定は、正規化したあとのパスで行う

直したら、実機で試すまで実行しない

この修正を書いた時点で、Windows 機はスリープしていて、Mac には PowerShell がありませんでした。つまり、一度も動かしていないコードです。

7バッチの削除は 9/14 に終わっていて、この時点でこの削除スクリプトを使う予定の作業はありませんでした。次に使うのは、仮置きに残ったファイルを仕分ける次のフェーズの削除バッチで、そのフェーズはまだ承認前(読み取りのみ)です。10/24 に控えていたごみ箱を空にする作業は、NAS 上で直接消す別のスクリプトで行うので、この削除スクリプトは通りません。それでも、次に誰かが使うときに未試験のまま動かないように、「実機の確認が通るまで、この削除スクリプトを通る execute は流さない」と明記してから、試験スクリプトを用意しました。

Windows 機を Wake-on-LAN で起こし、判定の関数だけを28ケースで呼びました。削除の候補は1件も送っていません。

実際の判定 件数 主なケース
PASS(通す) 2 正常なパス、濁点が分解形(NFD)の正常なパス
SKIP_DOTSEGMENT 6 .. 入り4件、. 入り2件
SKIP_OUTSIDE 61 D:\SynologyDrive 直下、旧PCのバックアップ配下(NFD)、名前が前方一致する隣のフォルダ、C:、仮置きそのもの、\\?\ 接頭辞
SKIP_NOT_CANONICAL 6 / 区切り、\ の連続、末尾の点・空白、相対パス、デバイス名
SKIP_PROTECTED 5 保護対象そのもの、配下、大文字小文字違い、NFD
SKIP_BADPATH 3 300文字のパス、代替データストリーム、空文字
計 28 期待と違ったもの0件

通したのは正常なパス2件だけです。合格を確かめてから、この「未試験のうちは流さない」という禁止を解除しました。次のフェーズで削除バッチを流すときも、バッチごとに私の承認が要ることは変わりません。

残った制限が1つあります。Windows PowerShell 5.1 は、260文字以上のパスを正規化できずに例外を出します。そういう行は SKIP_BADPATH になり、消さない側に倒れます。今回の削除パスは最長184文字で該当しませんでしたが、次の整理では、対象のパスの長さを先に調べます。

パス文字列の罠

範囲や除外の判定では、パスの文字列そのものにも罠がありました。

罠 例 対策
濁点の分解形(NFD) 「バックアップ」の「バ」「プ」が、文字と濁点・半濁点に分かれて保存されている NFC に正規化してから比べる
私用領域の文字 フォルダ名の日付の区切りが、見た目は普通の記号でも私用領域の文字になっている 実際のバイト列から組み立てる
全角数字 フォルダ名の数字が全角 同上
大文字小文字 Windows は区別しない 小文字にそろえて比べる

いちばん大事なのは、パスを手で打ち直さないことです。手で打ったパスで「存在しない」と返ってきても、存在しない証拠にはなりません。今回は、判定を共通のモジュール(p22_guard.py)にまとめ、NFC 正規化・大文字小文字無視・区切り文字付きの前方一致で比べるようにしました。

検討したが選ばなかったこと

選択肢 選ばなかった理由
除外リストを Windows 側の削除スクリプトにも持たせる 二重の判定は安全側だが、リストが2か所に分かれて食い違うという別の故障点を作る。Mac 側の execute が直前に判定し、Windows 側は保護リストへの二重登録で守る
唯一の実体になった残す側を、恒久除外のリストに足す 恒久除外は保全した動画のための既存の正本で、性質が違う。保護リストを別に作った
注意書きとしてルールに書くだけにする ツールが止めてくれない

まとめ: 自作の削除ツールの点検リスト

確かめること 今回の答え
守るべきものは、削除を始める前に別の場所へ保全したか 削除フェーズを止めて、保全してから再開した
除外の判定は、削除を実行するすべての経路で行われているか dry-run だけだった → plan と execute の直前にも
パスは正規化してから判定しているか 前方一致だけだった → 正規化してから判定、.. は拒否
リストが届かなかったら、全件止まるか 保護リストが無ければ全件中止
残す側が「最後の1組」にならないか 31本を保護リストに登録し、最後のバッチの前に部分ハッシュ
直したコードを、実機で試してから実行したか 実機で28ケースを試し、合格してから execute を解禁

消したファイルの記録をどこに残したかは 重複ファイルを消したフォルダに「墓標ファイル」を置く に、ルールそのものの置き場所を変えた話は CLAUDE.mdは書き直すたびに痩せる に書きました。

Footnotes

  1. 作業記録の注記では「仮置き外5件」と数えていますが、\\?\ 接頭辞の1件を「その他の拒否」に分けていたためです。ここでは実際の判定(SKIP_OUTSIDE)で数えて6件としています。 ↩