「フルパス→ファイル名だけ」に変換した瞬間、一意性は誰も保証していない — flatステージングのbasename衝突
自動化パイプラインで「隔離用の一時ディレクトリに、許可リストのファイルだけをコピーして渡す」処理を書いたことはないでしょうか。今回、そうした処理の中に「なぜそこで例外を投げているのか」が一目では分かりにくい1行があったので、設計意図まで含めて掘り下げます。
① 現象
社内の自動化基盤に、タスクごとに「参照してよいファイル一覧(フルパスのリスト)」を受け取り、それらを中立な一時ワークスペースへコピーしてからワーカーを起動するスクリプトがあります。このスクリプトは、コピー元のファイル一覧に含まれる2つのファイルのファイル名(basename)が同じだった場合、コピーを行わず即座に例外を投げて処理全体を止めます。ディレクトリが違えば衝突しないはずのファイル名が、ここでは衝突として扱われます。
② 再現する最小の状況
一覧に渡すパスが以下のように「ディレクトリは違うが末尾のファイル名が同じ」場合に発生します。
# allowed_context_files に相当する入力(フルパス2本、ディレクトリは別)
$files = @(
'C:\work\projA\notes\summary.md',
'C:\work\projB\reports\summary.md'
)
これをステージング処理に渡すと、2本目の summary.md を処理する段で例外になります。
③ 原因
ステージング処理の骨格は次のようになっています(実装より抜粋)。
foreach ($rel in $card.allowed_context_files) {
$leaf = Split-Path $rel -Leaf
if ($leaf -eq 'card.json') { continue }
# ...省略(存在確認・安全なパスかの検証など)...
if ($seen.ContainsKey($leaf)) {
throw "basename collision in allowed_context_files: $leaf (PoC=flat staging前提)"
}
$seen[$leaf] = $true
Copy-Item $src (Join-Path $ws $leaf)
$staged += $leaf
}
ポイントは Split-Path $rel -Leaf です。入力は「ディレクトリを含むフルパス」という一意な名前空間を持っていますが、コピー先の一時ワークスペースはサブディレクトリを作らないフラットな1階層です。フラットにする理由はコメントに明記されています。
中立workspaceを作り、allowed_context_filesだけをbasenameでコピーする(repoルートを一切見せない=隔離対象の内部構造へ物理的に到達不能=scope強制)
つまりこれは事故ではなく意図した設計です。ワーカーに元のディレクトリ構造やリポジトリのルートパスを一切見せないことで「参照範囲の強制(コンテキスト隔離)」を実現するために、あえて情報量を落として「フルパス→ファイル名だけ」という不可逆な変換をしています。しかし、この変換によって「入力側では保証されていた一意性」が、変換後の空間では保証されなくなります。$seen というハッシュテーブルは、その一意性の消失を検出するために置かれており、衝突時は黙って上書きする代わりに例外で止めるという選択がされています。
④ 直し方(コードで)
「隔離のためにディレクトリ情報を捨てる」という要件自体は変えずに、衝突だけを解消するには、捨てる情報の一部(相対パス)をハッシュ化して名前空間に埋め戻す方法があります。元のフルパスをそのまま見せるわけではないため、隔離の目的は壊しません。
foreach ($rel in $files) {
$leaf = Split-Path $rel -Leaf
$key = $leaf
if ($seen.ContainsKey($leaf)) {
$bytes = [Text.Encoding]::UTF8.GetBytes($rel)
$hash = [System.BitConverter]::ToString(
(New-Object Security.Cryptography.MD5CryptoServiceProvider).ComputeHash($bytes)
).Replace('-', '').Substring(0, 8)
$key = "$hash-$leaf"
}
$seen[$key] = $true
Copy-Item $rel (Join-Path $ws $key)
}
ここで重要なのは、ステージング後にワーカーへ渡す一覧(今回のケースではカード情報の allowed_context_files)も、コピー先で実際に使われた $key に書き換えて一致させることです。名前だけ変えて参照側の一覧を更新し忘れると、ワーカーは存在しないファイル名を探すことになります。
⑤ 学び
「フルパスからファイル名だけを取り出す」「階層構造をフラットな1つの名前空間に押し込める」といった変換は、隔離・秘匿・簡素化などの目的でよく行われますが、変換前の空間が持っていた一意性の保証は、変換後の空間には自動的には引き継がれません。これはファイルステージングに限らず、S3のキー設計、Dockerレイヤーのキャッシュキー、ログの集約、URLスラッグの生成など、「階層あり→フラット」の変換を伴う設計全般に当てはまる落とし穴です。
今回のコードで学びになる点は、衝突を黙って上書きせず throw で止めていたことです。フラット化で失われる情報を検出する仕組み($seen のようなガード)を最初から用意しておけば、衝突は「原因不明の上書き事故」ではなく「その場で分かる例外」として現れます。変換で情報を捨てるときは、捨てた情報が担っていた保証(今回は一意性)が何だったかを明示し、それを別の形(今回はハッシュ)で埋め戻すか、少なくとも失敗を検出可能にしておくことが、こうした設計の落とし穴を避ける具体的な手立てになります。