1. 症状
Node.jsでFTPサーバ上のディレクトリを再帰的に走査するコードを書いたことがあるだろうか。多くの場合、最初に書くのはこういう形になる。
async function scan(dir) {
await client.cd(dir);
for (const item of await client.list()) {
if (item.isDirectory) {
await scan(item.name);
}
}
await client.cd('..');
}
シンプルで、ローカルファイルシステムの再帰走査と同じ感覚で書ける。ところが実際に運用すると、次のような不具合に遭遇することがある。
- 一部のディレクトリだけ結果から漏れる
- 途中のディレクトリでエラーが起きた後、以降の走査がすべて失敗する
-
550 CWD to the invalid pathのようなエラーが連鎖する
再帰処理のロジック自体は間違っていないのに、なぜこうなるのか。この記事では、自作のiTunes⇔Android同期ツールで実際に遭遇した、FTPディレクトリ再帰走査の不具合を題材に、原因と直し方を整理する。
2. 想定読者
- Node.jsでFTPクライアントを使うdeveloper
- FTPサーバ上のディレクトリを再帰的にスキャンしている人
- sync/backup/cleanupツールを自作している人
- 再帰走査で「一部のディレクトリだけ見落とす」バグに遭遇したことがある人
3. 原因は再帰処理ではなくcwd
結論から言うと、上記のコードの問題は再帰構造そのものではない。FTPクライアントが「現在の作業ディレクトリ(cwd)」という、セッションに紐づいたmutableな状態を持っていることが原因だ。
client.cd(dir) は「これから先の相対パス操作は、このディレクトリを基準にする」という指示であり、戻り値ではなく副作用としてクライアントの内部状態を書き換える。再帰関数の引数として渡しているのは dir という名前(文字列)だけで、「今どこにいるか」という情報は関数の外、セッションという共有状態の中に存在している。
これが再帰と組み合わさると厄介になる。子ディレクトリのスキャンが終わったら cd('..') で親に戻る、という設計は、途中で何も失敗しない限りは正しく動く。しかし以下のようなケースで簡単に破綻する。
- 子ディレクトリのlist取得やcd自体が例外を投げ、
cd('..')にたどり着かないまま関数を抜ける - 深い階層まで潜った後、意図した段数だけ正確に戻れず、想定より浅い(あるいは深い)位置に居残る
- 兄弟ディレクトリの走査を始める前提として「親ディレクトリに戻っているはず」という暗黙の契約に、呼び出し側が気づかずに依存している
呼び出し側の関数は、自分が呼んだ scan() が終わった後、セッションのcwdが「呼ぶ前と同じ場所」に戻っていることを前提にコードを書く。しかしその保証は、cd('..') が確実に実行され、かつ確実に成功する場合にしか成り立たない。これは典型的な暗黙のmutable state依存であり、「今どこにいるか」をコールスタックではなくセッション側に持たせてしまった設計上の問題だ。
4. 実際に起きたこと
このツールでは元々、次のような実装だった(実際のコードを一般化したもの)。
async function listFilesRecursive(client, prefix) {
const list = await client.list();
const files = [];
for (const item of list) {
const relPath = prefix ? `${prefix}/${item.name}` : item.name;
if (item.type === 1) { // file
files.push({ relPath, size: item.size ?? 0 });
} else if (item.type === 2) { // directory
try {
await client.cd(item.name);
const sub = await listFilesRecursive(client, relPath);
files.push(...sub);
await client.cd('..');
} catch {
// skip inaccessible directories
}
}
}
return files;
}
これ自体はスキャン専用の読み取り処理であり、直接その場で破綻が観測されたわけではない。だが同じ「相対cdで潜って cd('..') で戻る」という設計は、隣接する削除処理(スキャン結果をもとにFTP上のファイルをDELEする側)でも使われており、実機ではそちらで実際に障害が起きた。大量のファイル削除中に一部のディレクトリへのcdが失敗し、エラー処理側が本来より多い回数 cd('..') を実行してしまったため、想定していたMusicルートより上まで戻ってしまった。その結果、以降のすべての相対cdがずれた場所を基準に評価され、550 CWD to the invalid path というエラーが連鎖し、大量のファイルの削除に失敗する事態になった。
これを踏まえて、削除処理だけでなく、同じ設計パターンを使っていたスキャン(再帰走査)側についても、同種の脆さを未然に潰す形で作り直した。以下はその話である。
5. なぜテストで捕まえにくいか
この種のバグが厄介なのは、テストや小規模な動作確認をすり抜けやすい点にある。
- ディレクトリ階層が浅い(1〜2段)と、
cd('..')の移動回数がそもそも少なく、cwdのずれを引き起こす経路を踏みにくい - 兄弟ディレクトリの数が少ないと、「あるディレクトリの失敗が別のディレクトリに影響する」という経路自体が発生しない
- ルート直下だけで完結するテストフィクスチャでは、深い階層からの復帰が一度も試されない
- FTPサーバの実装によって、存在しないパスへの
cdやその後の挙動に差があり、開発環境で使うテスト用FTPサーバでは再現しないことがある
つまり、「浅い・単純・少数」なテストケースでは通ってしまい、実運用に近い「深い・複雑・多数」の階層で初めて表面化する。これは再帰処理そのもののテスト網羅性の問題というより、cwdという隠れた共有状態が、テストケースの形によって顕在化したりしなかったりするという性質に起因する。
6. 修正の方向性:cwdに依存しない絶対パス走査
このツールで実際に採用した修正の骨子は、「相対cdで潜って cd('..') で戻る」のをやめて、訪問するディレクトリごとに、毎回ルートからの絶対パスを組み立ててcdするという設計に変えることだった。
function absolutePathFor(scanRootAbsolute, prefix) {
return prefix ? `${scanRootAbsolute}/${prefix}` : scanRootAbsolute;
}
async function scanDirectory(client, prefix, state) {
const target = absolutePathFor(state.scanRootAbsolute, prefix);
try {
await client.cd(target);
} catch (e) {
return { entered: false, files: [], error: e };
}
const list = await client.list();
const files = [];
for (const item of list) {
const relPath = prefix ? `${prefix}/${item.name}` : item.name;
if (item.type === 2) {
const sub = await scanDirectory(client, relPath, state);
if (sub.entered) files.push(...sub.files);
} else if (item.type === 1) {
files.push({ relPath, size: item.size ?? 0 });
}
}
return { entered: true, files };
}
ポイントは次の3点である。
- 再帰呼び出しは
prefix(そのディレクトリのルートからの相対パス文字列)だけを引数として渡す。cwdの状態は一切共有しない - ディレクトリに入るときは、常に「ルート + prefix」から絶対パスを組み立てて
cd()する。「今どこにいるか」を過去の呼び出しの結果に依存させない -
cd('..')によるstate restoration自体が不要になる。子の処理が終わっても親に戻る必要がない。次にそのディレクトリへ行きたければ、また絶対パスを組み立ててcd()すればよいだけだからだ
この設計だと、あるディレクトリへのcdが失敗しても、影響はそのディレクトリ(とその配下)に閉じる。兄弟ディレクトリは自分自身の絶対パスで独立してcdされるため、先に失敗した処理の後始末が漏れていても一切影響を受けない。FTPセッション自体のcwdは相変わらず存在し、cd()のたびに変化する。変わったのは、走査の正しさが「直前までどこにいたか」に依存しなくなったことだ。各訪問先をルート起点のパスで明示するため、あるディレクトリでの失敗によるcwdのずれが、兄弟ディレクトリへ連鎖しにくくなる。
7. FTPのpathとOSのpathを混同しない
このパターンを実装する上でもう一つ注意すべき点がある。今回対象にしたFTPサーバでは、リモートパスは / 区切りとして扱われる。一方、Node.jsのpathモジュール(特にpath.join)は、実行OSに応じた区切り文字を使ってパスを組み立てる。もしFTPのリモートパス生成に何も考えずにpath.joinを使うと、Windows環境でだけリモートパスに \ が混入し、Unix系のFTPサーバ側で解釈できないパスを送りつけることになりかねない。
今回のツールでは、リモートパスの組み立てに path.join を使わず、上記コード例のようにテンプレートリテラルで / を明示的に挟む方式を採っている。ローカルファイルシステムのパス操作(拡張子判定など)には引き続き path モジュールを使うが、それとFTPのリモートパス文字列は完全に別物として扱う。
一般化できるのは、「FTPのリモートパスは必ず/区切りである」という断定ではなく、リモート側のpathname規則とローカルOSのpathname規則を同一視しないという考え方である。必要であればpath.posixを使う選択肢もあるが、少なくとも「OS依存のpath結合関数を、プロトコル側のパス生成にそのまま流用しない」という意識は持っておいた方がよい。
8. root境界の扱い
絶対パスで走査する設計に変えると、「どこがルートか」が関数の外から見えやすくなるという副次的な利点もある。cd('..')方式では、rootがどこにあるかは暗黙の前提(「これまで何回cdしてきたか」)でしか分からず、バグがあった場合にrootより上に出てしまう可能性を否定できない。絶対パス方式では、走査対象のルートパス(このツールでは「FTP上の設定済みルートディレクトリ + 音楽用サブディレクトリ名」)を最初に一度だけ組み立て、それより外側のパスへcdする経路がコード上に一切存在しない。
もう一つ、今回使用したFTPサーバ固有の注意点として、素の /(ルート)にcdすると、設定したい対象ディレクトリではなく、期待する共有ルートとは別の位置を指してしまう挙動が確認された。そのため、走査対象のルート(configured scan root)を明示的な基準として扱い、絶対パスを組み立てる際は「先頭が/であること」だけでなく、「/の後に必ず実体のあるパスが続くこと」を保証するようにしている。絶対パス化は万能ではなく、対象プロトコル・サーバ実装固有の癖を踏まえて設計する必要がある。本筋は、走査ルートを明示すること、意図せずルートの外側へ出ないこと、そして直前のcwdに依存しないことの3点である。
9. エラーパスの扱い
もう一つ重要な変更点は、エラー発生時の戻り値の設計である。旧実装は「入れなかったディレクトリはスキップする」というシンプルな方針だったが、これは「スキャンが実は不完全だった」という事実を握りつぶしてしまう。
新しい実装では、各ディレクトリの走査結果を { entered: boolean, files: [...] } という形で返し、entered: false の場合は呼び出し元がそれを「警告」として記録し、スキャン全体の結果に incomplete: true というフラグを立てるようにした。これにより、「一部のディレクトリを読めなかったが、その事実に誰も気づかないまま処理が先に進む」という状態を避けている。特に、スキャン結果を「削除してよいファイルの一覧」の判定に使うような場面では、不完全なスキャン結果を完全な結果として扱ってしまうこと自体がリスクになるため、この区別は設計上重要な意味を持つ。
10. テスト戦略
このバグの性質上、浅く単純なテストケースだけでは再発を防げない。実際に効果があったテストの形を一般化すると、次のようになる。
- 兄弟ディレクトリを複数用意し、途中の1つを失敗させる:失敗したディレクトリ以外が正しく走査結果に残ることを確認する
-
cd('..')が一度も呼ばれていないことを直接アサートする:実装が絶対パス方式を守っていることを、結果だけでなく手段のレベルで検証する -
すべての
cd()呼び出しが、ルートを起点にした絶対パスであることを検証する:相対名でのcdが紛れ込んでいないかを確認する - 意図的に「壊れた/無関係なcwd」を事前にセットしてから走査を始める:どのディレクトリから走査を始めても、それ以前の状態に依存せず正しく動くことを保証する
-
走査失敗時に
incompleteフラグと警告が正しく記録されることを確認する:エラーが握りつぶされていないかを検証する - 正常系では警告が一切出ないことも確認する:異常検知のロジックが過敏に反応して正常系を汚染していないかを見る
特に2つ目・4つ目のテストは、「最終的な結果が正しいか」だけでなく「どうやってその結果に辿り着いたか」を検証している点が重要である。cwd依存のバグは、たまたま結果が正しく見えるケースでも、内部的には壊れやすい経路を通っていることがあるため、実装の手段そのものをアサートすることに価値がある。
11. チェックリスト
- FTPクライアントのcwdを、複数の再帰呼び出しにまたがって暗黙に共有していないか
-
ディレクトリから「出る」ための
cd('..')に、正しさの保証を依存させていないか - 子ディレクトリの処理中に例外が起きたとき、cwdの復帰処理がスキップされないか
- 走査を絶対パスベースにできないか(できるなら、その方が一段回復力が高い)
- リモートプロトコルのパス生成に、OS依存のpath結合関数を流用していないか
- 走査対象のルート境界が、コード上に明示されているか
- 一部だけ失敗したスキャン結果を、「全部成功した」結果と区別して扱っているか
- 兄弟ディレクトリ・深い階層・失敗系を含むテストケースを用意しているか
12. まとめ
- 今回の不具合の本質は、再帰処理そのものではなく、FTPクライアントのcwdという暗黙のmutable stateへの依存だった
-
cd(child)→ 再帰 →cd('..')という設計は、途中でエラーが起きた瞬間に「呼び出し元が期待するcwd」と「実際のcwd」がずれるリスクを常に抱えている - 対策は、各ディレクトリへの訪問ごとにルートからの絶対パスを組み立てて
cdする設計に変えること。これによりcd('..')によるstate restorationそのものが不要になる - リモートプロトコルのパスとローカルファイルシステムのパスは別物として扱い、OS依存のpath結合関数をリモートパス生成に流用しない
- 一部のディレクトリが読めなかった事実は、握りつぶさずに「不完全なスキャン」として明示的に扱う
- このバグは浅く単純なテストケースをすり抜けやすいため、兄弟ディレクトリ・深い階層・失敗系を含むテスト、および「どうやって結果に辿り着いたか」を検証するテストが有効
13. 参考資料
- RFC 959, File Transfer Protocol (FTP) — CWD(Change Working Directory)コマンドおよびpathname構文の基本仕様
- Node.js Documentation,
path.posix— OSに依存せずPOSIX形式(/区切り)のパス操作を行うためのAPI