|
| 1 | +# FT173: pathlib モジュール |
| 2 | + |
| 3 | +**日付**: 2026-05-21 |
| 4 | +**テーマ**: `pathlib.Path` によるパス操作・glob・stat・パストラバーサル防止 |
| 5 | +**セキュリティ診断**: なし(FT174 で実施) |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 概要 |
| 10 | + |
| 11 | +Python 標準ライブラリの `pathlib` モジュールを検証する。 |
| 12 | +`os.path` が提供してきた文字列ベースのパス操作を OOP スタイルで置き換え、 |
| 13 | +可読性・型安全性・クロスプラットフォーム対応を改善する。 |
| 14 | + |
| 15 | +このFTで確認する点: |
| 16 | +- `Path` オブジェクトの基本属性(`name`, `stem`, `suffix`, `parent`, `parts`) |
| 17 | +- パス結合(`/` 演算子、`Path / str`)の実用パターン |
| 18 | +- `glob()` / `iterdir()` によるファイル一覧・再帰探索 |
| 19 | +- `stat()` でのファイルメタデータ取得 |
| 20 | +- `resolve()` + `relative_to()` によるパストラバーサル防止 |
| 21 | +- `tempfile` との組み合わせで安全な一時ファイル操作 |
| 22 | +- TypedDict による `dict` 戻り値の型安全化 |
| 23 | + |
| 24 | +--- |
| 25 | + |
| 26 | +## 実装したサンプルアプリ |
| 27 | + |
| 28 | +**場所**: `/home/xi/docker/nene2-python-FT/ft173-pathlib/` |
| 29 | + |
| 30 | +### 主要機能 |
| 31 | + |
| 32 | +| 関数/クラス | 概要 | |
| 33 | +|---|---| |
| 34 | +| `path_info(path_str)` | `Path` の全属性を `PathInfoDict` で返す | |
| 35 | +| `join_paths(*parts)` | `Path / str` 演算子でパスを結合 | |
| 36 | +| `resolve_relative(base, relative)` | トラバーサル防止つきの相対パス解決 | |
| 37 | +| `write_text_file(path, content)` | UTF-8 テキスト書き込み、`WriteResultDict` 返却 | |
| 38 | +| `read_text_file(path)` | UTF-8 テキスト読み込み | |
| 39 | +| `append_line(path, line)` | 1行追記し総行数を返す | |
| 40 | +| `ensure_directory(path)` | `mkdir(parents=True, exist_ok=True)` ラッパー | |
| 41 | +| `list_directory(path, pattern)` | glob パターン付きディレクトリ一覧 | |
| 42 | +| `glob_files(base, pattern)` | パストラバーサルチェック付き glob | |
| 43 | +| `file_stat(path)` | `stat()` ラップ、不在なら `None` | |
| 44 | +| `safe_temp_write(content)` | 一時ファイルへの書き込みと即時削除 | |
| 45 | +| `walk_tree(base, max_depth)` | 再帰ツリー走査(最大深度制限付き) | |
| 46 | +| `is_allowed_extension(path_str)` | 許可拡張子セットによるファイル検証 | |
| 47 | +| `change_extension(path_str, new_ext)` | `with_suffix()` で拡張子変更 | |
| 48 | +| `ALLOWED_EXTENSIONS` | 許可拡張子: `.txt .md .json .csv .log` | |
| 49 | + |
| 50 | +TypedDict による型安全な戻り値: |
| 51 | + |
| 52 | +| TypedDict | 使用関数 | |
| 53 | +|---|---| |
| 54 | +| `PathInfoDict` | `path_info()` | |
| 55 | +| `WriteResultDict` | `write_text_file()` | |
| 56 | +| `DirEntryDict` | `list_directory()` | |
| 57 | +| `FileStatDict` | `file_stat()` | |
| 58 | +| `TempWriteResultDict` | `safe_temp_write()` | |
| 59 | +| `WalkEntryDict` | `walk_tree()` | |
| 60 | + |
| 61 | +### HTTP エンドポイント |
| 62 | + |
| 63 | +| メソッド | パス | 概要 | |
| 64 | +|---|---|---| |
| 65 | +| GET | `/pathlib/info` | パス属性を返す | |
| 66 | +| POST | `/pathlib/join` | パス結合 | |
| 67 | +| GET | `/pathlib/resolve` | パストラバーサル検出 | |
| 68 | +| POST | `/pathlib/write` | サンドボックスへの書き込み(拡張子・トラバーサルチェック) | |
| 69 | +| GET | `/pathlib/read` | サンドボックスからの読み込み | |
| 70 | +| POST | `/pathlib/append` | 行追記 | |
| 71 | +| GET | `/pathlib/list` | ファイル一覧 | |
| 72 | +| GET | `/pathlib/glob` | glob パターン検索 | |
| 73 | +| GET | `/pathlib/stat` | ファイル stat 情報 | |
| 74 | +| POST | `/pathlib/temp-write` | 一時ファイル書き込み・読み返し | |
| 75 | +| GET | `/pathlib/tree` | ディレクトリツリー走査 | |
| 76 | +| GET | `/pathlib/extension-check` | 拡張子バリデーション | |
| 77 | + |
| 78 | +--- |
| 79 | + |
| 80 | +## テスト結果 |
| 81 | + |
| 82 | +**34 passed** |
| 83 | + |
| 84 | +``` |
| 85 | +test_app.py::test_path_info_absolute PASSED |
| 86 | +test_app.py::test_path_info_with_extension PASSED |
| 87 | +test_app.py::test_join_paths PASSED |
| 88 | +test_app.py::test_join_paths_single PASSED |
| 89 | +test_app.py::test_join_paths_empty PASSED |
| 90 | +test_app.py::test_resolve_relative_safe PASSED |
| 91 | +test_app.py::test_resolve_relative_traversal_blocked PASSED |
| 92 | +test_app.py::test_resolve_relative_traversal_encoded PASSED |
| 93 | +test_app.py::test_resolve_relative_absolute_path_in_relative PASSED |
| 94 | +test_app.py::test_write_and_read PASSED |
| 95 | +test_app.py::test_append_line PASSED |
| 96 | +test_app.py::test_list_directory PASSED |
| 97 | +test_app.py::test_list_directory_with_pattern PASSED |
| 98 | +test_app.py::test_glob_files PASSED |
| 99 | +test_app.py::test_file_stat_exists PASSED |
| 100 | +test_app.py::test_file_stat_not_found PASSED |
| 101 | +test_app.py::test_safe_temp_write PASSED |
| 102 | +test_app.py::test_walk_tree PASSED |
| 103 | +test_app.py::test_is_allowed_extension_allowed PASSED |
| 104 | +test_app.py::test_is_allowed_extension_disallowed PASSED |
| 105 | +test_app.py::test_change_extension PASSED |
| 106 | +test_app.py::test_http_path_info PASSED |
| 107 | +test_app.py::test_http_join PASSED |
| 108 | +test_app.py::test_http_resolve_safe PASSED |
| 109 | +test_app.py::test_http_resolve_traversal PASSED |
| 110 | +test_app.py::test_http_write_and_read PASSED |
| 111 | +test_app.py::test_http_write_disallowed_extension PASSED |
| 112 | +test_app.py::test_http_write_path_traversal PASSED |
| 113 | +test_app.py::test_http_read_not_found PASSED |
| 114 | +test_app.py::test_http_list PASSED |
| 115 | +test_app.py::test_http_temp_write PASSED |
| 116 | +test_app.py::test_http_extension_check_allowed PASSED |
| 117 | +test_app.py::test_http_extension_check_disallowed PASSED |
| 118 | +test_app.py::test_security_headers PASSED |
| 119 | +
|
| 120 | +34 passed in 0.46s |
| 121 | +``` |
| 122 | + |
| 123 | +--- |
| 124 | + |
| 125 | +## 摩擦ポイント |
| 126 | + |
| 127 | +### F-1: `dict[str, object]` 戻り値が mypy で型エラーになる(深刻度: 中) |
| 128 | + |
| 129 | +**事象**: 初期実装で `write_text_file()` などが `dict[str, object]` を返していたため、 |
| 130 | +テスト側で `result["size"] > 0` が mypy エラー(`object < int` 比較不可)になった。 |
| 131 | + |
| 132 | +**原因**: `dict[str, object]` の値型は `object` なので算術比較が型エラーになる。 |
| 133 | +CLAUDE.md で「`dict[str, Any]` 禁止・TypedDict を使え」と明示しているが、 |
| 134 | +sandbox の初期実装で見落とした。 |
| 135 | + |
| 136 | +**対応**: 全戻り値に TypedDict (`PathInfoDict`, `WriteResultDict` など6種) を定義。 |
| 137 | +mypy エラーが即時発見されたことで設計ミスを早期に修正できた。 |
| 138 | + |
| 139 | +### F-2: `Generator` 型注釈が必要な yield フィクスチャ(深刻度: 低) |
| 140 | + |
| 141 | +**事象**: pytest フィクスチャで `yield` を使う場合、戻り値型を `Path` と書くと |
| 142 | +mypy が `Generator` を期待するエラーを出す。 |
| 143 | + |
| 144 | +**原因**: `yield` を含む関数は `Path` ではなく `Generator[Path, None, None]` を返すジェネレーター関数として扱われる。 |
| 145 | + |
| 146 | +**対応**: `from collections.abc import Generator` をインポートし、 |
| 147 | +戻り値型を `Generator[Path, None, None]` に修正した。 |
| 148 | + |
| 149 | +--- |
| 150 | + |
| 151 | +## 観察点 |
| 152 | + |
| 153 | +### 観察1: `Path.resolve()` + `relative_to()` によるパストラバーサル完全防止 |
| 154 | + |
| 155 | +```python |
| 156 | +def resolve_relative(base: str, relative: str) -> str | None: |
| 157 | + base_path = Path(base).resolve() |
| 158 | + target = (base_path / relative).resolve() |
| 159 | + try: |
| 160 | + target.relative_to(base_path) |
| 161 | + return str(target) |
| 162 | + except ValueError: |
| 163 | + return None |
| 164 | +``` |
| 165 | + |
| 166 | +`../../etc/passwd` は `resolve()` でシンボリックリンクと `..` を展開した後、 |
| 167 | +`relative_to(base_path)` が `ValueError` を送出するため `None` を返す。 |
| 168 | +絶対パス(`/etc/passwd`)を `relative` に渡した場合も、 |
| 169 | +`(base_path / "/etc/passwd")` が `Path("/etc/passwd")` に解決されるため同様にブロックされる。 |
| 170 | + |
| 171 | +``` |
| 172 | +resolve_relative("/tmp", "../../etc/passwd") → None ✅ ブロック |
| 173 | +resolve_relative("/tmp", "/etc/passwd") → None ✅ ブロック |
| 174 | +resolve_relative("/tmp", "safe/file.txt") → "/tmp/safe/file.txt" ✅ 通過 |
| 175 | +``` |
| 176 | + |
| 177 | +URL エンコード (`%2e%2e%2f...`) は `Path` がリテラル文字列として扱うためブロックされず安全側に倒れる: |
| 178 | +`Path("/tmp") / "%2e%2e%2fetc%2fpasswd"` → `/tmp/%2e%2e%2fetc%2fpasswd`(実際の `..` にならない) |
| 179 | + |
| 180 | +### 観察2: `/` 演算子と絶対パスの組み合わせ |
| 181 | + |
| 182 | +```python |
| 183 | +# 直感的に思えるが、右辺が絶対パスのとき左辺が無視される |
| 184 | +Path("/base") / "/etc/passwd" # → PosixPath("/etc/passwd") |
| 185 | +``` |
| 186 | + |
| 187 | +これは `os.path.join()` と同じ仕様。`resolve_relative()` が絶対パスを |
| 188 | +`relative` に受け取ったとき `None` を返す理由がここにある。 |
| 189 | +`/` 演算子を直接使う実装では必ずこのケースを忘れる。 |
| 190 | + |
| 191 | +### 観察3: `frozenset` による許可拡張子の不変集合 |
| 192 | + |
| 193 | +```python |
| 194 | +ALLOWED_EXTENSIONS: frozenset[str] = frozenset({".txt", ".md", ".json", ".csv", ".log"}) |
| 195 | + |
| 196 | +def is_allowed_extension(path_str: str) -> bool: |
| 197 | + return Path(path_str).suffix.lower() in ALLOWED_EXTENSIONS |
| 198 | +``` |
| 199 | + |
| 200 | +`frozenset` を使うことで `in` 演算子が O(1) になり、誤って要素を追加できない。 |
| 201 | +`Path(path_str).suffix` は `.py` のようにドット付きの拡張子を返す。 |
| 202 | +`.lower()` で大文字拡張子(`.TXT`、`.Txt`)もブロックできる。 |
| 203 | + |
| 204 | +### 観察4: `walk_tree()` のネストされた関数でクロージャーを活用 |
| 205 | + |
| 206 | +```python |
| 207 | +def walk_tree(base: Path, max_depth: int = 3) -> list[WalkEntryDict]: |
| 208 | + results: list[WalkEntryDict] = [] |
| 209 | + |
| 210 | + def _walk(path: Path, depth: int) -> None: |
| 211 | + if depth > max_depth: |
| 212 | + return |
| 213 | + for child in sorted(path.iterdir()): |
| 214 | + results.append({...}) |
| 215 | + if child.is_dir(): |
| 216 | + _walk(child, depth + 1) |
| 217 | + |
| 218 | + if base.is_dir(): |
| 219 | + _walk(base, 1) |
| 220 | + return results |
| 221 | +``` |
| 222 | + |
| 223 | +内部関数 `_walk` が外側の `results` リストと `base`, `max_depth` をクロージャーで参照している。 |
| 224 | +再帰時に `results` をパラメーターとして渡す必要がなく、関数シグネチャがシンプル。 |
| 225 | +`max_depth` で無限再帰(シンボリックリンクループ等)を防止している。 |
| 226 | + |
| 227 | +--- |
| 228 | + |
| 229 | +## nene2-python フレームワークとの統合 |
| 230 | + |
| 231 | +- `nene2.middleware` の3ミドルウェア(`ErrorHandlerMiddleware`, `SecurityHeadersMiddleware`, `RequestIdMiddleware`)を正しい順序(LIFO)で追加。`test_security_headers` が `x-request-id` と `x-content-type-options` を確認している |
| 232 | +- サンドボックスディレクトリは `tempfile.mkdtemp()` で作成し、`lifespan` で `shutil.rmtree()` により自動クリーンアップ。テスト間の分離は `TestClient(create_app())` で別インスタンスを生成することで担保 |
| 233 | +- `WriteBody`, `AppendBody`, `JoinBody` が全フィールドに `max_length` を設定。Pydantic v2 による境界検証が機能している |
| 234 | + |
| 235 | +--- |
| 236 | + |
| 237 | +## Developer Experience (DX) Review |
| 238 | + |
| 239 | +### ペルソナ1: 初心者(Python 歴1年・独学中・女性・バックエンド志望) |
| 240 | + |
| 241 | +`pathlib` の基本操作(`/` 演算子でパス結合、`.name` でファイル名取得)は直感的で |
| 242 | +公式ドキュメントを読めば理解できる。 |
| 243 | + |
| 244 | +**ドキュメント理解**: `Path / "subdir"` という演算子オーバーロードは最初は奇妙に見えるが、 |
| 245 | +一度理解すれば機械的に使える。`glob()` パターン(`*.txt`, `**/*.py`)は Unix glob の知識が |
| 246 | +そのまま使えるため、シェルに慣れていれば問題ない。 |
| 247 | +**事故リスク**: 中。`Path("/base") / "/absolute/path"` が左辺を無視するトラップは |
| 248 | +初心者には気づきにくい。`resolve_relative()` という安全なラッパーを提供することで回避できる。 |
| 249 | +**規約の使いやすさ**: TypedDict の定義が多く最初は戸惑うが、IDE 補完が効くようになるため |
| 250 | +慣れると書きやすい。 |
| 251 | + |
| 252 | +### ペルソナ2: ロースキル経験者(Python 歴3-4年・スクリプト系・男性・SES) |
| 253 | + |
| 254 | +`os.path.join()` に慣れている人は `Path / str` を最初に使わず |
| 255 | +`str(Path(base) / subdir)` と書きがちだが、機能的には同じなので実害はない。 |
| 256 | + |
| 257 | +**コピペ可能性**: `resolve_relative()` の実装パターン(`resolve()` + `relative_to()`)は |
| 258 | +コピペで正しく動く設計になっている。 |
| 259 | +**拡張時の罠**: `glob_files()` に `**` パターンを追加するとき、 |
| 260 | +サブディレクトリを含む結果が返るようになる点を知らないと予期しない動作になる可能性がある。 |
| 261 | +**セキュリティ的な事故リスク**: 中。`resolve_relative()` を使わずに直接 `Path(base) / user_input` |
| 262 | +とするコードを書いた場合、絶対パスインジェクションが通る。ラッパー関数を必ず経由する規約が必要。 |
| 263 | + |
| 264 | +### ペルソナ3: フロントエンド寄り経験者(React/TS 歴4年・バックエンド転向中・ノンバイナリ) |
| 265 | + |
| 266 | +ファイル操作は Node.js の `fs` モジュールより Python の `pathlib` の方が直感的に感じるはず。 |
| 267 | +TypeScript の `interface` と Python の `TypedDict` の対応が理解できれば、型エラーへの対処も容易。 |
| 268 | + |
| 269 | +**エラーレスポンスの質**: 422 (拡張子不許可)、404 (ファイル不在) が明確に返るため |
| 270 | +クライアント実装がしやすい。拡張子エラーで許可リストがレスポンスに含まれるのも親切設計。 |
| 271 | +**Python 固有概念の学習コスト**: `frozenset` はやや Python 固有だが、 |
| 272 | +「変更不可な Set」という説明で十分理解できる。`TypedDict` は TS の `interface` と |
| 273 | +ほぼ同じ概念なので学習コストが低い。 |
| 274 | +**事故リスク**: 低。HTTP 境界は Pydantic で守られており、型エラーは 422 で返る。 |
| 275 | + |
| 276 | +### ペルソナ4: バックエンド経験者(Django/FastAPI 歴5-6年・男性・リードエンジニア) |
| 277 | + |
| 278 | +`pathlib` の `glob()` と Django の `FileSystemStorage` / FastAPI の `UploadFile` との |
| 279 | +組み合わせパターンが即座に想像できる。TypedDict の活用は好意的に評価されるはず。 |
| 280 | + |
| 281 | +**他フレームワークとの差異**: Django は `default_storage` で抽象化、FastAPI は raw ファイル操作が多い。 |
| 282 | +nene2-python の `resolve_relative()` パターンは明示的で理解しやすい。 |
| 283 | +**nene2-python の薄さへの評価**: ファイル操作に ORM 的な抽象層を設けていない点は |
| 284 | +「シンプルで良い」と評価されるだろう。`ensure_directory()` が `is_dir()` を返す |
| 285 | +boolean 契約が明確。 |
| 286 | +**本番投入可能性**: `safe_temp_write()` の「書いて読んで即削除」パターンは、 |
| 287 | +本番では `/tmp` のディスクフルやシンボリックリンク攻撃への対策が別途必要。 |
| 288 | + |
| 289 | +### ペルソナ5: シニアエンジニア(設計・コードレビュー担当・女性・10-12年) |
| 290 | + |
| 291 | +`resolve_relative()` の設計は正しい。`Path.resolve()` が symlink を展開し、 |
| 292 | +`relative_to()` が ValueError を使って範囲外を検出するパターンは慣用的で信頼できる。 |
| 293 | + |
| 294 | +**コードレビューチェックポイント**: |
| 295 | +- [x] `resolve_relative()` をバイパスした直接の `open(filename)` がないか — `app.py` では `_safe_sandbox_path()` を経由しているため OK |
| 296 | +- [x] `glob()` でシンボリックリンクを `..` に張ることでのトラバーサルが封じられているか — `resolve()` でシンボリックリンクを展開済みなので OK |
| 297 | +- [ ] `walk_tree()` に `followlinks=False` 相当の保護がない — シンボリックリンクで深さが無限になりうる(`max_depth` で制限しているが symlink ループには注意) |
| 298 | +- [ ] `append_line()` が `path.open()` を2回呼んでいる(書き込みと行数カウントで別々)— TOCTOU ではないが非効率 |
| 299 | + |
| 300 | +**チームでの安全な共有パターン**: `_safe_sandbox_path()` のようなプライベート関数で |
| 301 | +パス解決を一元化するパターンはチーム内での標準化に適している。 |
| 302 | +**ツール追加の必要性**: `ruff` の `PTH` ルール(pathlib 推奨)を有効にすると |
| 303 | +`os.path` の誤用を自動検出できる。 |
| 304 | + |
| 305 | +### ペルソナ6: 設計者・ポリシー照合(nene2-python 設計ポリシー目線) |
| 306 | + |
| 307 | +CLAUDE.md の「パス操作は `pathlib.Path` で操作し、パストラバーサルを防ぐ」方針を |
| 308 | +具体的なコードパターンとして実証できた FT となった。 |
| 309 | + |
| 310 | +**ポリシー達成度**: 高 |
| 311 | +**「初心者でも安全な API」達成度**: 高(`resolve_relative()` ラッパーが防衛線になっている) |
| 312 | +**設計上の負債・ドキュメント不足**: |
| 313 | +- `walk_tree()` のシンボリックリンクループ対策が未実装(`max_depth` は深さで止めるが symlink ループは止められない) |
| 314 | +- `append_line()` の「総行数を返す」仕様が実用上は不要なケースが多い。`bool` で十分かもしれない |
| 315 | +- TypedDict 6種の定義が `demos.py` のモジュールサイズを増やしているが、300行制限内(225行)なので許容範囲 |
| 316 | + |
| 317 | +**Follow-up Issue 候補**: `walk_tree()` に `followlinks=False` 相当の symlink ループ検出を追加する Issue を検討 |
| 318 | + |
| 319 | +--- |
| 320 | + |
| 321 | +## Follow-up Issues |
| 322 | + |
| 323 | +| 優先度 | タイトル | 種別 | |
| 324 | +|---|---|---| |
| 325 | +| 中 | `walk_tree()` にシンボリックリンクループ検出(訪問済みパスの set で管理)を追加 | feat | |
| 326 | +| 低 | `ruff PTH` ルールを有効化して `os.path` 誤用を静的検出 | chore | |
| 327 | +| 低 | `append_line()` の戻り値を `int`(行数)から `int`(追記後行数)に統一する命名改善 | docs | |
| 328 | + |
| 329 | +--- |
| 330 | + |
| 331 | +## まとめ |
| 332 | + |
| 333 | +FT173 では `pathlib.Path` の主要機能を一通り実装し、 |
| 334 | +パストラバーサル防止の標準パターン(`resolve()` + `relative_to()`)を実証した。 |
| 335 | +TypedDict による `dict` 戻り値の型安全化は mypy が即時エラーを発見する仕組みとして機能した。 |
| 336 | +`/` 演算子に絶対パスを渡すと左辺が無視される Python 仕様トラップは、 |
| 337 | +`resolve_relative()` のような安全なラッパーで封じることが重要と確認できた。 |
| 338 | + |
| 339 | +次の FT174 は 174 % 3 = 0 → セキュリティ診断が必要。 |
0 commit comments